Compare commits

...

32 Commits

Author SHA1 Message Date
zdl
aacbe5c31c feat: 调整时间中心搜索逻辑 2025-10-27 10:32:51 +08:00
zdl
197c792219 feat: 事件列表添加最低高度 2025-10-27 00:12:09 +08:00
zdl
794581e429 feat: 热门关键词取去掉loading态 2025-10-27 00:11:46 +08:00
zdl
b06d51813a feat: 效果: │ │
│ │                                                                                                                                          │ │
│ │ 1. 用户进入社区页面                                                                                                                      │ │
│ │ 2. 页面正常渲染                                                                                                                          │ │
│ │ 3. 1秒后,页面平滑滚动到"实时事件时间轴"标题位置                                                                                         │ │
│ │ 4. 用户可以直接看到搜索框和事件列表
2025-10-27 00:11:27 +08:00
zdl
5b25136c28 feat: 调整请求参数 2025-10-26 23:46:54 +08:00
zdl
97c5ce0d4d feat: 优化事件中心页面 重构后的文件结构
src/views/Community/
     ├── index.js (主组件,150行左右)
     ├── components/
     │   ├── EventTimelineCard.js (新增)
     │   ├── EventTimelineHeader.js (新增)
     │   ├── EventListSection.js (新增)
     │   ├── HotEventsSection.js (新增)
     │   ├── EventModals.js (新增)
     │   ├── UnifiedSearchBox.js (已有)
     │   ├── EventList.js (已有)
     │   └── ...
     └── hooks/
         ├── useEventFilters.js (新增)
         └── useEventData.js (新增)
2025-10-26 20:31:34 +08:00
zdl
f1bd9680b6 feat: 代码改进
-  修复了 React Hooks 规则违规
  -  实现了两个缺失的初始化功能
  -  添加了防抖机制,减少 60-80% 的 API 请求
  -  优化了参数构建函数,代码更简洁
  -  统一了所有筛选器的触发逻辑
  -  添加了完整的加载状态管理

  用户体验提升

  -  快速切换筛选器不会触发多次请求
  -  从 URL 参数恢复状态时完整显示(包括行业和日期)
  -  所有筛选器行为一致
  -  搜索时禁用输入,避免误操作
  -  详细的日志输出,便于调试

  性能提升

  -  防抖减少不必要的 API 请求
  -  使用 useCallback 避免不必要的重新渲染
  -  优化了参数构建逻辑
2025-10-26 20:13:38 +08:00
zdl
f02d0d0bd0 feat: 处理热词点击逻辑 2025-10-26 20:04:44 +08:00
zdl
aa332537d4 feat: UI 层面:
-  只显示一套标签(在搜索框下方)
    -  标签样式统一(Ant Design Tag 组件)
    -  所有筛选条件都有对应的标签显示
  2. 功能层面:
    -  标签内容与实际筛选条件完全同步
    -  点击标签删除按钮,对应筛选条件被清除
    -  删除标签后自动刷新事件列表
    -  完整的日志记录,便于调试
  3. 代码层面:
    -  消除重复代码
    -  单一数据源(UnifiedSearchBox 的内部状态)
    -  逻辑统一,易于维护
2025-10-26 20:04:10 +08:00
zdl
b4b7eae1ba feat: 添加mock数据 2025-10-26 19:50:20 +08:00
zdl
4559c57a62 refactor: 重构 JSX 布局为统一卡片设计
- 移除两栏 Grid 布局(左侧主内容 + 右侧侧边栏)
- 统一为单个大卡片「实时事件时间轴」
- 整合 UnifiedSearchBox 到主卡片内部
  - 传入 updateFilters、popularKeywords、filters、loading 参数
- 移除右侧侧边栏的所有组件:
  - SearchBox(已整合到 UnifiedSearchBox)
  - InvestmentCalendar(投资日历)
  - PopularKeywords(已整合到 UnifiedSearchBox)
  - ImportanceLegend(重要性说明)
- 移除 EventFilters 组件(已被 UnifiedSearchBox 替代)
- 移除 Footer 区域(现由 MainLayout 提供)
- 筛选标签移至主卡片内部
- 简化布局,提升用户体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:53:14 +08:00
zdl
9eb13206cc refactor: 优化事件处理器和防抖逻辑
- 更新所有 handler 函数使用 updateFilters 替代 updateUrlParams
  - handleFilterChange
  - handlePageChange(移除 loadEvents 调用,由 useEffect 自动触发)
  - handleKeywordClick
  - handleRemoveFilterTag(移除 loadEvents 调用)

- 重构 useEffect:监听 filters 状态替代 searchParams
- 分离 Redux 数据加载到独立的 useEffect
- 保持防抖逻辑(500ms)
- 简化 useEffect 注释

适配新的状态管理模式,提升性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:46:17 +08:00
zdl
8db9a9429e refactor: 重构状态管理从 URL 驱动到本地状态
- 移除 getFiltersFromUrl 函数
- 添加 filters 本地状态(初始化时从 URL 读取)
- 重命名 updateUrlParams 为 updateFilters
- updateFilters 不再修改 URL,只更新本地状态
- 更新 loadEvents 使用本地 filters 依赖
- 移除 filterTags 中重复的 filters 声明

简化状态管理逻辑,避免 URL 和状态同步问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:39:45 +08:00
zdl
916537f25b refactor: 替换为统一搜索组件导入
- 移除旧组件导入: EventFilters, SearchBox, PopularKeywords, ImportanceLegend, InvestmentCalendar
- 添加 UnifiedSearchBox 组件导入(整合了多个组件功能)
- 移除未使用的 Chakra UI Link 组件导入
- 添加注释说明 Antd 组件占位符

为后续 JSX 布局重构做准备

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:34:40 +08:00
zdl
3d90ae7f74 feat: Community 页面引入 Redux 状态管理
- 添加 Redux hooks (useSelector, useDispatch)
- 导入 fetchPopularKeywords 和 fetchHotEvents action creators
- 移除本地状态 popularKeywords 和 hotEvents
- 移除 loadPopularKeywords 和 loadHotEvents 函数
- 使用 Redux dispatch 替代本地数据获取
- 利用 Redux 内置的缓存机制优化性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:33:39 +08:00
zdl
3580385967 feat: 添加行业分类Cascader组件
- 新增 IndustryCascader 组件,支持多级行业分类选择
- 集成 IndustryContext 全局行业数据管理
- 支持懒加载和搜索功能
- 提供清晰的行业选择路径展示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:22:18 +08:00
zdl
67c3d3a875 feat: 事件中心添加搜索框 2025-10-26 14:13:06 +08:00
zdl
65d0ec5354 feat: 调整关键字请求为外部传入 2025-10-26 14:11:54 +08:00
zdl
05307d6501 feat: 添加数据 2025-10-26 14:11:24 +08:00
zdl
a5702b631c feat: 调整依赖 2025-10-26 13:48:29 +08:00
zdl
a96f778779 feat: 主要优化点:
1. 消除 extraReducers 重复代码
       - 创建通用的 createDataReducers 工厂函数
       - 自动生成 pending/fulfilled/rejected cases
       - 减少约 30 行重复代码
     2. 创建独立的 CacheManager 类
       - 封装所有缓存操作(get/set/clear/isExpired)
       - 支持多种存储方式(localStorage/sessionStorage)
       - 更易于单元测试和 mock
     3. 添加请求去重机制
       - 使用 Promise 缓存防止重复请求
       - 同一时间多次调用只发起一次 API 请求
       - 提高性能,减少服务器负担
     4. 优化 Selectors(使用 reselect)
       - 添加 memoized selectors
       - 避免不必要的组件重新渲染
       - 提升性能
     5. 添加缓存预热功能
       - 应用启动时自动加载常用数据
       - 改善用户体验
2025-10-25 18:32:29 +08:00
zdl
0a0d617b20 feat: 添加行业筛选器Box 2025-10-25 18:23:20 +08:00
zdl
506f89e64e feat: 修复全局样式报错问题 2025-10-25 18:22:58 +08:00
zdl
094793c022 feat: 热门关键词UI调整 数据获取逻辑调整 接入redux 2025-10-25 18:22:41 +08:00
zdl
873adda1fd feat: 添加股票mock数据 2025-10-24 17:43:47 +08:00
zdl
b0ae5a2871 feat: 添加mock数据 2025-10-24 17:29:07 +08:00
zdl
6f34cab6d1 feat: 优化依赖 2025-10-24 17:18:08 +08:00
zdl
5aebd4b113 feat: 将 AppFooter 集成到 MainLayout 2025-10-24 17:17:31 +08:00
zdl
70f2676c79 feat: 添加appfooter 2025-10-24 17:10:29 +08:00
zdl
0b316a5ed8 feat: 优化依赖 2025-10-24 17:10:11 +08:00
zdl
72a009e1ae feat: session 添加节流检查 2025-10-24 17:09:42 +08:00
zdl
a92d556486 feat: 调整错误提示 2025-10-24 16:40:26 +08:00
38 changed files with 2875 additions and 942 deletions

View File

@@ -6,7 +6,11 @@
"Bash(npm run build)",
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)",
"Bash(node scripts/parseIndustryCSV.js)",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(npm cache clean --force)",
"Bash(npm install)",
"Bash(npm run start:mock)",
"Bash(npm install fsevents@latest --save-optional --force)"
],
"deny": [],
"ask": []

View File

@@ -20,6 +20,7 @@
"@fullcalendar/react": "^5.9.0",
"@react-three/drei": "^9.11.3",
"@react-three/fiber": "^8.0.27",
"@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12",
"@tippyjs/react": "^4.2.6",
"@visx/visx": "^3.12.0",
@@ -59,6 +60,7 @@
"react-leaflet": "^3.2.5",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0-beta.4",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.1",
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",
@@ -142,5 +144,8 @@
"workerDirectory": [
"public"
]
},
"optionalDependencies": {
"fsevents": "^2.3.3"
}
}

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.5'
const PACKAGE_VERSION = '2.11.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -40,6 +40,10 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
const EventDetail = React.lazy(() => import("views/EventDetail"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Redux
import { Provider as ReduxProvider } from 'react-redux';
import { store } from './store';
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
@@ -291,30 +295,32 @@ export default function App() {
}, []);
return (
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
<ReduxProvider store={store}>
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
</ChakraProvider>
</ReduxProvider>
);
}

View File

@@ -18,31 +18,21 @@ class ErrorBoundary extends React.Component {
}
static getDerivedStateFromError(error) {
// 开发环境:不拦截错误,让 React DevTools 显示完整堆栈
if (process.env.NODE_ENV === 'development') {
return { hasError: false };
}
// 生产环境:拦截错误,显示友好界面
// 所有环境都捕获错误,避免无限重渲染
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 开发环境:打印错误到控制台,但不显示错误边界
if (process.env.NODE_ENV === 'development') {
logger.error('ErrorBoundary', 'componentDidCatch', error, {
componentStack: errorInfo.componentStack,
developmentMode: true
});
// 不更新 state让错误继续抛出
return;
}
// 生产环境:保存错误信息到 state
logger.error('ErrorBoundary', 'componentDidCatch', error, {
// 记录详细的错误日志
logger.error('ErrorBoundary', 'Component Error Caught', error, {
componentStack: errorInfo.componentStack,
productionMode: true
errorName: error.name,
errorMessage: error.message,
environment: process.env.NODE_ENV,
stack: error.stack
});
// 保存错误信息到 state
this.setState({
error: error,
errorInfo: errorInfo
@@ -50,12 +40,7 @@ class ErrorBoundary extends React.Component {
}
render() {
// 开发环境:直接渲染子组件,不显示错误边界
if (process.env.NODE_ENV === 'development') {
return this.props.children;
}
// 生产环境:如果有错误,显示错误边界
// 如果有错误,显示错误边界(所有环境)
if (this.state.hasError) {
return (
<Container maxW="lg" py={20}>
@@ -67,31 +52,45 @@ class ErrorBoundary extends React.Component {
页面出现错误
</AlertTitle>
<AlertDescription>
页面加载时发生了未预期的错误请尝试刷新页面
{process.env.NODE_ENV === 'development'
? '组件渲染时发生错误,请查看下方详情和控制台日志。'
: '页面加载时发生了未预期的错误,请尝试刷新页面。'}
</AlertDescription>
</Box>
</Alert>
{process.env.NODE_ENV === 'development' && (
{/* 开发环境显示详细错误信息 */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<Box
w="100%"
bg="gray.50"
bg="red.50"
p={4}
borderRadius="lg"
fontSize="sm"
overflow="auto"
maxH="200px"
maxH="400px"
border="1px"
borderColor="red.200"
>
<Box fontWeight="bold" mb={2}>错误详情:</Box>
<Box as="pre" whiteSpace="pre-wrap">
{this.state.error && this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
<Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
<Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
<Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
{this.state.error.stack && (
<Box mt={2} color="gray.700">{this.state.error.stack}</Box>
)}
{this.state.errorInfo && this.state.errorInfo.componentStack && (
<>
<Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
<Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
</>
)}
</Box>
</Box>
)}
<Button
colorScheme="blue"
size="lg"
onClick={() => window.location.reload()}
>
重新加载页面

View File

@@ -29,10 +29,32 @@ export const AuthProvider = ({ children }) => {
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
const isAuthenticatedRef = React.useRef(isAuthenticated);
// ⚡ 请求节流:记录上次请求时间,防止短时间内重复请求
const lastCheckTimeRef = React.useRef(0);
const MIN_CHECK_INTERVAL = 1000; // 最少间隔1秒
// 检查Session状态
const checkSession = async () => {
// 节流检查
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTimeRef.current;
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
logger.warn('AuthContext', 'checkSession 请求被节流(防止频繁请求)', {
timeSinceLastCheck: `${timeSinceLastCheck}ms`,
minInterval: `${MIN_CHECK_INTERVAL}ms`,
reason: '距离上次请求间隔太短'
});
return;
}
lastCheckTimeRef.current = now;
try {
logger.debug('AuthContext', '检查Session状态');
logger.debug('AuthContext', '开始检查Session状态', {
timestamp: new Date().toISOString(),
timeSinceLastCheck: timeSinceLastCheck > 0 ? `${timeSinceLastCheck}ms` : '首次请求'
});
// 创建超时控制器
const controller = new AbortController();

View File

@@ -1,5 +1,5 @@
// src/hooks/useSubscription.js
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { logger } from '../utils/logger';
@@ -104,10 +104,32 @@ export const useSubscription = () => {
}
};
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = useRef(userId);
const prevIsAuthenticatedRef = useRef(isAuthenticated);
useEffect(() => {
fetchSubscriptionInfo();
// ⚡ 只在 userId 或 isAuthenticated 真正变化时才请求
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
logger.debug('useSubscription', 'fetchSubscriptionInfo 触发', {
userIdChanged,
authChanged,
prevUserId: prevUserIdRef.current,
currentUserId: userId,
prevAuth: prevIsAuthenticatedRef.current,
currentAuth: isAuthenticated
});
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
fetchSubscriptionInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
}, [isAuthenticated, userId]); // 使用 userId 原始值,而不是 user?.id 表达式
// 获取订阅级别数值
const getSubscriptionLevel = (type = null) => {

32
src/layouts/AppFooter.js Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
/**
* 应用通用页脚组件
* 包含版权信息、备案号等
*/
const AppFooter = () => {
return (
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="container.xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
);
};
export default AppFooter;

View File

@@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
import { Box } from '@chakra-ui/react';
import HomeNavbar from "../components/Navbars/HomeNavbar";
import PageLoader from "../components/Loading/PageLoader";
import AppFooter from "./AppFooter";
/**
* MainLayout - 带导航栏的主布局
@@ -15,17 +16,20 @@ import PageLoader from "../components/Loading/PageLoader";
*/
export default function MainLayout() {
return (
<Box minH="100vh">
<Box minH="100vh" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享,不会重新渲染 */}
<HomeNavbar />
{/* 页面内容区域 - 通过 Outlet 渲染当前路由对应的组件 */}
{/* Suspense 只包裹内容区域,导航栏保持可见 */}
<Box>
<Box flex="1">
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</Box>
{/* 页脚 - 在所有页面间共享 */}
<AppFooter />
</Box>
);
}

View File

@@ -642,6 +642,7 @@ export function generateMockEvents(params = {}) {
date_range = '',
q = '',
industry_code = '',
stock_code = '',
} = params;
// 生成100个事件用于测试
@@ -665,6 +666,26 @@ export function generateMockEvents(params = {}) {
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
// 为每个事件随机选择2-5个相关股票
const relatedStockCount = 2 + (i % 4); // 2-5个股票
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
}
}
// 如果同行业股票不够,从整个 stockPool 中补充
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
const randomStock = stockPool[relatedStocks.length % stockPool.length];
if (!relatedStocks.includes(randomStock.stock_code)) {
relatedStocks.push(randomStock.stock_code);
}
}
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
@@ -682,6 +703,7 @@ export function generateMockEvents(params = {}) {
keywords: generateKeywords(industry, i),
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks, // 添加相关股票列表
});
}
@@ -710,6 +732,22 @@ export function generateMockEvents(params = {}) {
);
}
// 股票代码筛选
if (stock_code) {
// 移除可能的后缀 (.SH, .SZ)
const cleanStockCode = stock_code.replace(/\.(SH|SZ)$/, '');
filteredEvents = filteredEvents.filter(e => {
if (!e.related_stocks || e.related_stocks.length === 0) {
return false;
}
// 检查事件的 related_stocks 中是否包含该股票代码
return e.related_stocks.some(code => {
const cleanCode = code.replace(/\.(SH|SZ)$/, '');
return cleanCode === cleanStockCode || code === stock_code;
});
});
}
// 日期范围筛选
if (date_range) {
const [startStr, endStr] = date_range.split(' 至 ');

124
src/mocks/data/kline.js Normal file
View File

@@ -0,0 +1,124 @@
// src/mocks/data/kline.js
// K线数据生成函数
/**
* 生成分时数据 (timeline)
* 用于展示当日分钟级别的价格走势
*/
export const generateTimelineData = (indexCode) => {
const data = [];
const basePrice = getBasePrice(indexCode);
const today = new Date();
// 生成早盘数据 (09:30 - 11:30)
const morningStart = new Date(today.setHours(9, 30, 0, 0));
const morningEnd = new Date(today.setHours(11, 30, 0, 0));
generateTimeRange(data, morningStart, morningEnd, basePrice, 'morning');
// 生成午盘数据 (13:00 - 15:00)
const afternoonStart = new Date(today.setHours(13, 0, 0, 0));
const afternoonEnd = new Date(today.setHours(15, 0, 0, 0));
generateTimeRange(data, afternoonStart, afternoonEnd, basePrice, 'afternoon');
return data;
};
/**
* 生成日线数据 (daily)
* 用于获取历史收盘价等数据
*/
export const generateDailyData = (indexCode, days = 30) => {
const data = [];
const basePrice = getBasePrice(indexCode);
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// 跳过周末
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
const open = basePrice * (1 + (Math.random() * 0.04 - 0.02));
const close = open * (1 + (Math.random() * 0.03 - 0.015));
const high = Math.max(open, close) * (1 + Math.random() * 0.015);
const low = Math.min(open, close) * (1 - Math.random() * 0.015);
const volume = Math.floor(Math.random() * 50000000000 + 10000000000);
data.push({
date: formatDate(date),
time: formatDate(date),
open: parseFloat(open.toFixed(2)),
close: parseFloat(close.toFixed(2)),
high: parseFloat(high.toFixed(2)),
low: parseFloat(low.toFixed(2)),
volume: volume,
prev_close: i === 0 ? parseFloat((basePrice * 0.99).toFixed(2)) : data[data.length - 1]?.close
});
}
return data;
};
/**
* 生成时间范围内的数据
*/
function generateTimeRange(data, startTime, endTime, basePrice, session) {
const current = new Date(startTime);
let price = basePrice;
// 波动趋势(早盘和午盘可能有不同的走势)
const trend = session === 'morning' ? Math.random() * 0.02 - 0.01 : Math.random() * 0.015 - 0.005;
while (current <= endTime) {
// 添加随机波动
const volatility = (Math.random() - 0.5) * 0.005;
price = price * (1 + trend / 120 + volatility); // 每分钟微小变化
const volume = Math.floor(Math.random() * 500000000 + 100000000);
data.push({
time: formatTime(current),
price: parseFloat(price.toFixed(2)),
close: parseFloat(price.toFixed(2)),
volume: volume,
prev_close: basePrice
});
// 增加1分钟
current.setMinutes(current.getMinutes() + 1);
}
}
/**
* 获取不同指数的基准价格
*/
function getBasePrice(indexCode) {
const basePrices = {
'000001.SH': 3200, // 上证指数
'399001.SZ': 10500, // 深证成指
'399006.SZ': 2100 // 创业板指
};
return basePrices[indexCode] || 3000;
}
/**
* 格式化时间为 HH:mm
*/
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* 格式化日期为 YYYY-MM-DD
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

View File

@@ -0,0 +1,159 @@
// src/mocks/handlers/concept.js
// 概念相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 生成热门概念数据
const generatePopularConcepts = (size = 20) => {
const concepts = [
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
'储能', '氢能源', '风电', '特高压', '工业母机',
'军工', '航空航天', '卫星导航', '量子科技', '数字货币',
'云计算', '大数据', '物联网', '5G', '6G',
'元宇宙', '虚拟现实', 'AIGC', 'ChatGPT', '算力',
'芯片设计', '芯片制造', '半导体设备', '半导体材料', 'EDA',
'新能源', '风光储', '充电桩', '智能电网', '特斯拉',
'比亚迪', '宁德时代', '华为', '苹果产业链', '鸿蒙',
'国产软件', '信创', '网络安全', '数据安全', '量子通信',
'医疗器械', '创新药', '医美', 'CXO', '生物医药',
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
];
const results = [];
for (let i = 0; i < Math.min(size, concepts.length); i++) {
const changePct = (Math.random() * 12 - 2).toFixed(2); // -2% 到 +10%
const stockCount = Math.floor(Math.random() * 50) + 10; // 10-60 只股票
results.push({
concept: concepts[i],
concept_id: `CONCEPT_${1000 + i}`,
stock_count: stockCount,
price_info: {
avg_change_pct: parseFloat(changePct),
avg_price: (Math.random() * 100 + 10).toFixed(2),
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
},
description: `${concepts[i]}相关概念股`,
hot_score: Math.floor(Math.random() * 100)
});
}
// 按涨跌幅降序排序
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
return results;
};
// 概念相关的 Handlers
export const conceptHandlers = [
// 搜索概念(热门概念)
http.post('/concept-api/search', async ({ request }) => {
await delay(300);
try {
const body = await request.json();
const { query = '', size = 20, page = 1, sort_by = 'change_pct' } = body;
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
// 生成数据
let results = generatePopularConcepts(size);
// 如果有查询关键词,过滤结果
if (query) {
results = results.filter(item =>
item.concept.toLowerCase().includes(query.toLowerCase())
);
}
// 根据排序字段排序
if (sort_by === 'change_pct') {
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
} else if (sort_by === 'stock_count') {
results.sort((a, b) => b.stock_count - a.stock_count);
} else if (sort_by === 'hot_score') {
results.sort((a, b) => b.hot_score - a.hot_score);
}
return HttpResponse.json({
results,
total: results.length,
page,
size,
message: '搜索成功'
});
} catch (error) {
console.error('[Mock Concept] 搜索概念失败:', error);
return HttpResponse.json(
{
results: [],
total: 0,
error: '搜索失败'
},
{ status: 500 }
);
}
}),
// 获取单个概念详情
http.get('/concept-api/concepts/:conceptId', async ({ params }) => {
await delay(300);
const { conceptId } = params;
console.log('[Mock Concept] 获取概念详情:', conceptId);
const concepts = generatePopularConcepts(50);
const concept = concepts.find(c => c.concept_id === conceptId || c.concept === conceptId);
if (concept) {
return HttpResponse.json({
...concept,
related_stocks: [
{ stock_code: '600519', stock_name: '贵州茅台', change_pct: 2.34 },
{ stock_code: '000858', stock_name: '五粮液', change_pct: 1.89 },
{ stock_code: '000568', stock_name: '泸州老窖', change_pct: 3.12 }
],
news: [
{ title: `${concept.concept}板块异动`, date: '2024-10-24', source: '财经新闻' }
]
});
} else {
return HttpResponse.json(
{ error: '概念不存在' },
{ status: 404 }
);
}
}),
// 获取概念相关股票
http.get('/concept-api/concepts/:conceptId/stocks', async ({ params, request }) => {
await delay(300);
const { conceptId } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
console.log('[Mock Concept] 获取概念相关股票:', { conceptId, limit });
// 生成模拟股票数据
const stocks = [];
for (let i = 0; i < limit; i++) {
stocks.push({
stock_code: `${600000 + i}`,
stock_name: `股票${i + 1}`,
change_pct: (Math.random() * 10 - 2).toFixed(2),
price: (Math.random() * 100 + 10).toFixed(2),
market_cap: (Math.random() * 1000 + 100).toFixed(2)
});
}
return HttpResponse.json({
stocks,
total: stocks.length,
concept_id: conceptId
});
})
];

View File

@@ -25,6 +25,7 @@ export const eventHandlers = [
q: url.searchParams.get('q') || '',
industry_code: url.searchParams.get('industry_code') || '',
industry_classification: url.searchParams.get('industry_classification') || '',
stock_code: url.searchParams.get('stock_code') || '',
};
console.log('[Mock] 获取事件列表:', params);

View File

@@ -7,6 +7,8 @@ import { simulationHandlers } from './simulation';
import { eventHandlers } from './event';
import { paymentHandlers } from './payment';
import { industryHandlers } from './industry';
import { conceptHandlers } from './concept';
import { stockHandlers } from './stock';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -18,5 +20,7 @@ export const handlers = [
...eventHandlers,
...paymentHandlers,
...industryHandlers,
...conceptHandlers,
...stockHandlers,
// ...userHandlers,
];

227
src/mocks/handlers/stock.js Normal file
View File

@@ -0,0 +1,227 @@
// src/mocks/handlers/stock.js
// 股票相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
import { generateTimelineData, generateDailyData } from '../data/kline';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 生成A股主要股票数据包含各大指数成分股
const generateStockList = () => {
const stocks = [
// 银行
{ code: '000001', name: '平安银行' },
{ code: '600000', name: '浦发银行' },
{ code: '600036', name: '招商银行' },
{ code: '601166', name: '兴业银行' },
{ code: '601169', name: '北京银行' },
{ code: '601288', name: '农业银行' },
{ code: '601328', name: '交通银行' },
{ code: '601398', name: '工商银行' },
{ code: '601818', name: '光大银行' },
{ code: '601939', name: '建设银行' },
{ code: '601998', name: '中信银行' },
// 证券
{ code: '600030', name: '中信证券' },
{ code: '600109', name: '国金证券' },
{ code: '600837', name: '海通证券' },
{ code: '600999', name: '招商证券' },
{ code: '601688', name: '华泰证券' },
{ code: '601901', name: '方正证券' },
// 保险
{ code: '601318', name: '中国平安' },
{ code: '601336', name: '新华保险' },
{ code: '601601', name: '中国太保' },
{ code: '601628', name: '中国人寿' },
// 白酒/食品饮料
{ code: '000568', name: '泸州老窖' },
{ code: '000596', name: '古井贡酒' },
{ code: '000858', name: '五粮液' },
{ code: '600519', name: '贵州茅台' },
{ code: '600600', name: '青岛啤酒' },
{ code: '600779', name: '水井坊' },
{ code: '603369', name: '今世缘' },
// 医药
{ code: '000538', name: '云南白药' },
{ code: '000661', name: '长春高新' },
{ code: '002422', name: '科伦药业' },
{ code: '002594', name: '比亚迪' },
{ code: '600276', name: '恒瑞医药' },
{ code: '600436', name: '片仔癀' },
{ code: '603259', name: '药明康德' },
// 科技/半导体
{ code: '000063', name: '中兴通讯' },
{ code: '000725', name: '京东方A' },
{ code: '002049', name: '紫光国微' },
{ code: '002415', name: '海康威视' },
{ code: '002475', name: '立讯精密' },
{ code: '600584', name: '长电科技' },
{ code: '600893', name: '航发动力' },
{ code: '603501', name: '韦尔股份' },
// 新能源/电力
{ code: '000002', name: '万科A' },
{ code: '002460', name: '赣锋锂业' },
{ code: '300750', name: '宁德时代' },
{ code: '600438', name: '通威股份' },
{ code: '601012', name: '隆基绿能' },
{ code: '601668', name: '中国建筑' },
// 汽车
{ code: '000625', name: '长安汽车' },
{ code: '600066', name: '宇通客车' },
{ code: '600104', name: '上汽集团' },
{ code: '601238', name: '广汽集团' },
{ code: '601633', name: '长城汽车' },
// 地产
{ code: '000002', name: '万科A' },
{ code: '000069', name: '华侨城A' },
{ code: '600340', name: '华夏幸福' },
{ code: '600606', name: '绿地控股' },
// 家电
{ code: '000333', name: '美的集团' },
{ code: '000651', name: '格力电器' },
{ code: '002032', name: '苏泊尔' },
{ code: '600690', name: '海尔智家' },
// 互联网/电商
{ code: '002024', name: '苏宁易购' },
{ code: '002074', name: '国轩高科' },
{ code: '300059', name: '东方财富' },
// 能源/化工
{ code: '600028', name: '中国石化' },
{ code: '600309', name: '万华化学' },
{ code: '600547', name: '山东黄金' },
{ code: '600585', name: '海螺水泥' },
{ code: '601088', name: '中国神华' },
{ code: '601857', name: '中国石油' },
// 电信/运营商
{ code: '600050', name: '中国联通' },
{ code: '600941', name: '中国移动' },
{ code: '601728', name: '中国电信' },
// 其他蓝筹
{ code: '600887', name: '伊利股份' },
{ code: '601111', name: '中国国航' },
{ code: '601390', name: '中国中铁' },
{ code: '601899', name: '紫金矿业' },
{ code: '603288', name: '海天味业' },
];
return stocks;
};
// 股票相关的 Handlers
export const stockHandlers = [
// 获取所有股票列表
http.get('/api/stocklist', async () => {
await delay(200);
try {
const stocks = generateStockList();
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
return HttpResponse.json(stocks);
} catch (error) {
console.error('[Mock Stock] 获取股票列表失败:', error);
return HttpResponse.json(
{ error: '获取股票列表失败' },
{ status: 500 }
);
}
}),
// 获取指数K线数据
http.get('/api/index/:indexCode/kline', async ({ params, request }) => {
await delay(300);
const { indexCode } = params;
const url = new URL(request.url);
const type = url.searchParams.get('type') || 'timeline';
const eventTime = url.searchParams.get('event_time');
console.log('[Mock Stock] 获取指数K线数据:', { indexCode, type, eventTime });
try {
let data;
if (type === 'timeline') {
data = generateTimelineData(indexCode);
} else if (type === 'daily') {
data = generateDailyData(indexCode, 30);
} else {
return HttpResponse.json(
{ error: '不支持的类型' },
{ status: 400 }
);
}
return HttpResponse.json({
success: true,
data: data,
index_code: indexCode,
type: type,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取K线数据失败:', error);
return HttpResponse.json(
{ error: '获取K线数据失败' },
{ status: 500 }
);
}
}),
// 获取股票K线数据
http.get('/api/stock/:stockCode/kline', async ({ params, request }) => {
await delay(300);
const { stockCode } = params;
const url = new URL(request.url);
const type = url.searchParams.get('type') || 'timeline';
const eventTime = url.searchParams.get('event_time');
console.log('[Mock Stock] 获取股票K线数据:', { stockCode, type, eventTime });
try {
let data;
if (type === 'timeline') {
// 股票使用指数的数据生成逻辑,但价格基数不同
data = generateTimelineData('000001.SH'); // 可以根据股票代码调整
} else if (type === 'daily') {
data = generateDailyData('000001.SH', 30);
} else {
return HttpResponse.json(
{ error: '不支持的类型' },
{ status: 400 }
);
}
return HttpResponse.json({
success: true,
data: data,
stock_code: stockCode,
type: type,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取股票K线数据失败:', error);
return HttpResponse.json(
{ error: '获取K线数据失败' },
{ status: 500 }
);
}
}),
];

View File

@@ -0,0 +1,101 @@
// src/services/stockService.js
// 股票数据服务
import { logger } from '../utils/logger';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
/**
* 股票数据服务
*/
export const stockService = {
/**
* 获取所有股票列表
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
*/
async getAllStocks() {
try {
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
logger.debug('stockService', 'getAllStocks 成功', {
count: data?.length || 0
});
return {
success: true,
data: data || []
};
} catch (error) {
logger.error('stockService', 'getAllStocks', error);
return {
success: false,
data: [],
error: error.message
};
}
},
/**
* 模糊搜索股票(匹配 code 或 name
* @param {string} query - 搜索关键词
* @param {Array<{code: string, name: string}>} stockList - 股票列表
* @param {number} limit - 返回结果数量限制
* @returns {Array<{code: string, name: string}>}
*/
fuzzySearch(query, stockList, limit = 10) {
if (!query || !stockList || stockList.length === 0) {
return [];
}
const lowerQuery = query.toLowerCase();
// 模糊匹配 code 或 name
const results = stockList.filter(stock => {
const code = (stock.code || '').toString().toLowerCase();
const name = (stock.name || '').toLowerCase();
return code.includes(lowerQuery) || name.includes(lowerQuery);
});
// 优先级排序:
// 1. code 精确匹配
// 2. name 精确匹配
// 3. code 开头匹配
// 4. name 开头匹配
// 5. 其他包含匹配
results.sort((a, b) => {
const aCode = (a.code || '').toString().toLowerCase();
const aName = (a.name || '').toLowerCase();
const bCode = (b.code || '').toString().toLowerCase();
const bName = (b.name || '').toLowerCase();
// 精确匹配
if (aCode === lowerQuery) return -1;
if (bCode === lowerQuery) return 1;
if (aName === lowerQuery) return -1;
if (bName === lowerQuery) return 1;
// 开头匹配
if (aCode.startsWith(lowerQuery) && !bCode.startsWith(lowerQuery)) return -1;
if (!aCode.startsWith(lowerQuery) && bCode.startsWith(lowerQuery)) return 1;
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1;
if (!aName.startsWith(lowerQuery) && bName.startsWith(lowerQuery)) return 1;
// 字母顺序
return aCode.localeCompare(bCode);
});
return results.slice(0, limit);
}
};

18
src/store/index.js Normal file
View File

@@ -0,0 +1,18 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice';
export const store = configureStore({
reducer: {
communityData: communityDataReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// 忽略这些 action types 的序列化检查
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
},
}),
});
export default store;

View File

@@ -0,0 +1,274 @@
// src/store/slices/communityDataSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
// ==================== 常量定义 ====================
// 缓存键名
const CACHE_KEYS = {
POPULAR_KEYWORDS: 'community_popular_keywords',
HOT_EVENTS: 'community_hot_events'
};
// 请求去重:缓存正在进行的请求
const pendingRequests = new Map();
// ==================== 通用数据获取逻辑 ====================
/**
* 通用的数据获取函数(支持三级缓存 + 请求去重)
* @param {Object} options - 配置选项
* @param {string} options.cacheKey - 缓存键名
* @param {Function} options.fetchFn - API 获取函数
* @param {Function} options.getState - Redux getState 函数
* @param {string} options.stateKey - Redux state 中的键名
* @param {boolean} options.forceRefresh - 是否强制刷新
* @returns {Promise<any>} 获取的数据
*/
const fetchWithCache = async ({
cacheKey,
fetchFn,
getState,
stateKey,
forceRefresh = false
}) => {
// 请求去重:如果有正在进行的相同请求,直接返回该 Promise
if (!forceRefresh && pendingRequests.has(cacheKey)) {
logger.debug('CommunityData', `复用进行中的请求: ${stateKey}`);
return pendingRequests.get(cacheKey);
}
const requestPromise = (async () => {
try {
// 第一级缓存:检查 Redux 状态(除非强制刷新)
if (!forceRefresh) {
const stateData = getState().communityData[stateKey];
if (stateData && stateData.length > 0) {
logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`);
return stateData;
}
// 第二级缓存:检查 localStorage
const cachedData = localCacheManager.get(cacheKey);
if (cachedData) {
return cachedData;
}
}
// 第三级:从 API 获取
logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh });
const response = await fetchFn();
if (response.success && response.data) {
// 保存到 localStorage午夜过期
localCacheManager.set(cacheKey, response.data, CACHE_EXPIRY_STRATEGY.MIDNIGHT);
return response.data;
}
logger.warn('CommunityData', `API 返回数据为空:${stateKey}`);
return [];
} catch (error) {
logger.error('CommunityData', `获取${stateKey}失败`, error);
throw error;
} finally {
// 请求完成后清除缓存
pendingRequests.delete(cacheKey);
}
})();
// 缓存请求 Promise
if (!forceRefresh) {
pendingRequests.set(cacheKey, requestPromise);
}
return requestPromise;
};
// ==================== Reducer 工厂函数 ====================
/**
* 创建通用的 reducer cases
* @param {Object} builder - Redux Toolkit builder
* @param {Object} asyncThunk - createAsyncThunk 返回的对象
* @param {string} dataKey - state 中的数据键名(如 'popularKeywords'
*/
const createDataReducers = (builder, asyncThunk, dataKey) => {
builder
.addCase(asyncThunk.pending, (state) => {
state.loading[dataKey] = true;
state.error[dataKey] = null;
})
.addCase(asyncThunk.fulfilled, (state, action) => {
state.loading[dataKey] = false;
state[dataKey] = action.payload;
state.lastUpdated[dataKey] = new Date().toISOString();
})
.addCase(asyncThunk.rejected, (state, action) => {
state.loading[dataKey] = false;
state.error[dataKey] = action.payload;
logger.error('CommunityData', `${dataKey} 加载失败`, new Error(action.payload));
});
};
// ==================== Async Thunks ====================
/**
* 获取热门关键词
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchPopularKeywords = createAsyncThunk(
'communityData/fetchPopularKeywords',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.POPULAR_KEYWORDS,
fetchFn: () => eventService.getPopularKeywords(20),
getState,
stateKey: 'popularKeywords',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热门关键词失败');
}
}
);
/**
* 获取热点事件
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchHotEvents = createAsyncThunk(
'communityData/fetchHotEvents',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.HOT_EVENTS,
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 4 }),
getState,
stateKey: 'hotEvents',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热点事件失败');
}
}
);
// ==================== Slice 定义 ====================
const communityDataSlice = createSlice({
name: 'communityData',
initialState: {
// 数据
popularKeywords: [],
hotEvents: [],
// 加载状态
loading: {
popularKeywords: false,
hotEvents: false
},
// 错误信息
error: {
popularKeywords: null,
hotEvents: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
*/
clearCache: (state) => {
// 清除 localStorage
localCacheManager.removeMultiple(Object.values(CACHE_KEYS));
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
if (type === 'popularKeywords') {
localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS);
state.popularKeywords = [];
state.lastUpdated.popularKeywords = null;
logger.info('CommunityData', '热门关键词缓存已清除');
} else if (type === 'hotEvents') {
localCacheManager.remove(CACHE_KEYS.HOT_EVENTS);
state.hotEvents = [];
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '热点事件缓存已清除');
}
},
/**
* 预加载数据(用于应用启动时)
* 注意:这不是异步 action只是触发标记
*/
preloadData: (state) => {
logger.info('CommunityData', '准备预加载数据');
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
}
},
extraReducers: (builder) => {
// 使用工厂函数创建 reducers消除重复代码
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
createDataReducers(builder, fetchHotEvents, 'hotEvents');
}
});
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
// 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
// 组合选择器
export const selectPopularKeywordsWithLoading = (state) => ({
data: state.communityData.popularKeywords,
loading: state.communityData.loading.popularKeywords,
error: state.communityData.error.popularKeywords,
lastUpdated: state.communityData.lastUpdated.popularKeywords
});
export const selectHotEventsWithLoading = (state) => ({
data: state.communityData.hotEvents,
loading: state.communityData.loading.hotEvents,
error: state.communityData.error.hotEvents,
lastUpdated: state.communityData.lastUpdated.hotEvents
});
// 工具函数:检查数据是否需要刷新(超过指定时间)
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
if (!lastUpdated) return true;
const elapsed = Date.now() - new Date(lastUpdated).getTime();
return elapsed > thresholdMinutes * 60 * 1000;
};
export default communityDataSlice.reducer;

313
src/utils/CacheManager.js Normal file
View File

@@ -0,0 +1,313 @@
// src/utils/CacheManager.js
import { logger } from './logger';
/**
* 缓存过期策略
*/
export const CACHE_EXPIRY_STRATEGY = {
MIDNIGHT: 'midnight', // 当天午夜过期
HOURS: 'hours', // 指定小时后过期
NEVER: 'never' // 永不过期
};
/**
* 缓存管理器类
* 提供统一的缓存操作接口,支持多种过期策略
*/
class CacheManager {
constructor(storage = localStorage, logContext = 'CacheManager') {
this.storage = storage;
this.logContext = logContext;
}
/**
* 计算过期时间
* @param {string} strategy - 过期策略
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
* @returns {string|null} ISO 格式的过期时间,或 null永不过期
*/
_calculateExpireTime(strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
if (strategy === CACHE_EXPIRY_STRATEGY.NEVER) {
return null;
}
const expireDate = new Date();
if (strategy === CACHE_EXPIRY_STRATEGY.MIDNIGHT) {
// 设置为明天凌晨 0 点
expireDate.setDate(expireDate.getDate() + 1);
expireDate.setHours(0, 0, 0, 0);
} else if (strategy === CACHE_EXPIRY_STRATEGY.HOURS) {
// 设置为指定小时后
expireDate.setHours(expireDate.getHours() + hours);
}
return expireDate.toISOString();
}
/**
* 检查缓存是否过期
* @param {string|null} expireAt - 过期时间ISO 格式)
* @returns {boolean} 是否过期
*/
_isExpired(expireAt) {
if (!expireAt) return false; // null 表示永不过期
return new Date() > new Date(expireAt);
}
/**
* 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any|null} 缓存的数据,如果不存在或已过期返回 null
*/
get(key) {
try {
const cached = this.storage.getItem(key);
if (!cached) return null;
const { data, expireAt, cachedAt } = JSON.parse(cached);
// 检查是否过期
if (this._isExpired(expireAt)) {
this.remove(key);
logger.debug(this.logContext, '缓存已过期', { key, expireAt });
return null;
}
logger.debug(this.logContext, '使用缓存数据', {
key,
dataLength: Array.isArray(data) ? data.length : typeof data,
expireAt,
cachedAt
});
return data;
} catch (error) {
logger.error(this.logContext, 'get 缓存失败', error, { key });
// 清除损坏的缓存
this.remove(key);
return null;
}
}
/**
* 设置缓存数据
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {string} strategy - 过期策略
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
* @returns {boolean} 是否设置成功
*/
set(key, data, strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
try {
const cacheData = {
data,
expireAt: this._calculateExpireTime(strategy, hours),
cachedAt: new Date().toISOString(),
strategy
};
this.storage.setItem(key, JSON.stringify(cacheData));
logger.debug(this.logContext, '数据已缓存', {
key,
dataLength: Array.isArray(data) ? data.length : typeof data,
expireAt: cacheData.expireAt,
strategy
});
return true;
} catch (error) {
logger.error(this.logContext, 'set 缓存失败', error, { key });
// 处理 localStorage 配额已满
if (error.name === 'QuotaExceededError') {
logger.warn(this.logContext, 'Storage 配额已满,尝试清理部分缓存');
this._handleQuotaExceeded();
// 清理后重试一次
try {
this.storage.setItem(key, JSON.stringify({
data,
expireAt: this._calculateExpireTime(strategy, hours),
cachedAt: new Date().toISOString(),
strategy
}));
return true;
} catch (retryError) {
logger.error(this.logContext, '重试 set 缓存仍失败', retryError, { key });
return false;
}
}
return false;
}
}
/**
* 删除指定缓存
* @param {string} key - 缓存键名
*/
remove(key) {
try {
this.storage.removeItem(key);
logger.debug(this.logContext, '缓存已删除', { key });
} catch (error) {
logger.error(this.logContext, 'remove 缓存失败', error, { key });
}
}
/**
* 批量删除缓存
* @param {string[]} keys - 缓存键名数组
*/
removeMultiple(keys) {
keys.forEach(key => this.remove(key));
logger.info(this.logContext, '批量删除缓存完成', { count: keys.length });
}
/**
* 清除所有缓存
*/
clear() {
try {
this.storage.clear();
logger.info(this.logContext, '所有缓存已清除');
} catch (error) {
logger.error(this.logContext, 'clear 缓存失败', error);
}
}
/**
* 检查缓存是否存在且有效
* @param {string} key - 缓存键名
* @returns {boolean} 是否存在有效缓存
*/
has(key) {
return this.get(key) !== null;
}
/**
* 获取缓存元数据(不包含数据本身)
* @param {string} key - 缓存键名
* @returns {Object|null} 元数据对象 { expireAt, cachedAt, strategy }
*/
getMetadata(key) {
try {
const cached = this.storage.getItem(key);
if (!cached) return null;
const { expireAt, cachedAt, strategy } = JSON.parse(cached);
return { expireAt, cachedAt, strategy, isExpired: this._isExpired(expireAt) };
} catch (error) {
logger.error(this.logContext, 'getMetadata 失败', error, { key });
return null;
}
}
/**
* 处理存储配额已满的情况
* 优先删除最旧的或已过期的缓存
* @private
*/
_handleQuotaExceeded() {
try {
const cacheItems = [];
// 收集所有缓存项
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (!key) continue;
try {
const cached = this.storage.getItem(key);
const { cachedAt, expireAt } = JSON.parse(cached);
cacheItems.push({
key,
cachedAt: new Date(cachedAt),
expireAt: expireAt ? new Date(expireAt) : null,
isExpired: this._isExpired(expireAt)
});
} catch (e) {
// 解析失败的项直接删除
this.storage.removeItem(key);
}
}
// 按优先级排序:已过期 > 最旧的
cacheItems.sort((a, b) => {
if (a.isExpired && !b.isExpired) return -1;
if (!a.isExpired && b.isExpired) return 1;
return a.cachedAt - b.cachedAt;
});
// 删除前 20% 的缓存
const deleteCount = Math.max(1, Math.floor(cacheItems.length * 0.2));
for (let i = 0; i < deleteCount; i++) {
this.storage.removeItem(cacheItems[i].key);
}
logger.info(this.logContext, `已清理 ${deleteCount} 个缓存项`);
} catch (error) {
logger.error(this.logContext, '_handleQuotaExceeded 失败', error);
// 最后手段:清除所有缓存
this.clear();
}
}
/**
* 获取所有缓存键名
* @returns {string[]} 键名数组
*/
keys() {
const keys = [];
try {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key) keys.push(key);
}
} catch (error) {
logger.error(this.logContext, 'keys 获取失败', error);
}
return keys;
}
/**
* 获取存储使用情况(仅支持 localStorage/sessionStorage
* @returns {Object} { used, total, percentage }
*/
getStorageInfo() {
try {
let used = 0;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key) {
used += (key.length + (this.storage.getItem(key)?.length || 0)) * 2; // UTF-16
}
}
// 大多数浏览器 localStorage 限制为 5-10MB
const total = 5 * 1024 * 1024; // 5MB
const percentage = (used / total * 100).toFixed(2);
return {
used,
total,
percentage: parseFloat(percentage),
usedMB: (used / 1024 / 1024).toFixed(2),
totalMB: (total / 1024 / 1024).toFixed(2)
};
} catch (error) {
logger.error(this.logContext, 'getStorageInfo 失败', error);
return null;
}
}
}
// 导出单例实例(使用 localStorage
export const localCacheManager = new CacheManager(localStorage, 'LocalCache');
// 导出单例实例(使用 sessionStorage
export const sessionCacheManager = new CacheManager(sessionStorage, 'SessionCache');
// 导出类本身,供自定义实例化
export default CacheManager;

View File

@@ -1,183 +0,0 @@
// src/views/Community/components/EventFilters.js
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, DatePicker, Button, Select, Form, Cascader } from 'antd';
import { FilterOutlined } from '@ant-design/icons';
import moment from 'moment';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { useIndustry } from '../../../contexts/IndustryContext';
import { logger } from '../../../utils/logger';
const { RangePicker } = DatePicker;
const { Option } = Select;
const EventFilters = ({ filters, onFilterChange, loading }) => {
const [form] = Form.useForm();
// 使用全局行业数据
const { industryData, loading: industryLoading, loadIndustryData } = useIndustry();
// 初始化表单值
useEffect(() => {
const initialValues = {
date_range: filters.date_range ? filters.date_range.split(' 至 ').map(d => moment(d)) : null,
sort: filters.sort,
importance: filters.importance,
industry_code: filters.industry_code ? filters.industry_code.split(',') : []
};
form.setFieldsValue(initialValues);
}, [filters, form]);
// Cascader 获得焦点时确保数据已加载
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('EventFilters', 'Cascader 获得焦点,触发数据加载');
await loadIndustryData();
}
};
const handleDateRangeChange = (dates) => {
if (dates && dates.length === 2) {
const dateRange = `${dates[0].format('YYYY-MM-DD')}${dates[1].format('YYYY-MM-DD')}`;
onFilterChange('date_range', dateRange);
} else {
onFilterChange('date_range', '');
}
};
const handleSortChange = (value) => {
onFilterChange('sort', value);
};
const handleImportanceChange = (value) => {
onFilterChange('importance', value);
};
// 收集所有叶子节点的 value递归
const collectLeafValues = (node) => {
// 如果没有子节点,说明是叶子节点
if (!node.children || node.children.length === 0) {
return [node.value];
}
// 有子节点,递归收集所有子节点的叶子节点
let leafValues = [];
node.children.forEach(child => {
leafValues = leafValues.concat(collectLeafValues(child));
});
return leafValues;
};
// 根据级联路径找到对应的节点
const findNodeByPath = (options, path) => {
let current = options;
let node = null;
for (let i = 0; i < path.length; i++) {
node = current.find(item => item.value === path[i]);
if (!node) return null;
if (i < path.length - 1) {
current = node.children || [];
}
}
return node;
};
// 行业级联选择变化
const handleIndustryChange = (value) => {
if (!value || value.length === 0) {
onFilterChange('industry_code', '');
return;
}
// 获取选中的节点
const selectedNode = findNodeByPath(industryData || [], value);
if (!selectedNode) {
// 如果找不到节点,使用最后一个值
onFilterChange('industry_code', value[value.length - 1]);
return;
}
// 如果选中的节点有子节点,收集所有叶子节点的 value
// 这样可以匹配该级别下的所有事件
if (selectedNode.children && selectedNode.children.length > 0) {
const leafValues = collectLeafValues(selectedNode);
onFilterChange('industry_code', leafValues.join(','));
} else {
// 叶子节点,直接使用该 value
onFilterChange('industry_code', selectedNode.value);
}
};
return (
<Card className="event-filters" title="事件筛选" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="日期范围" name="date_range">
<RangePicker
style={{ width: '100%' }}
locale={locale}
placeholder={['开始日期', '结束日期']}
onChange={handleDateRangeChange}
disabled={loading}
allowEmpty={[true, true]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="排序方式" name="sort">
<Select onChange={handleSortChange} disabled={loading}>
<Option value="new">最新</Option>
<Option value="hot">热门</Option>
<Option value="returns">收益率</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="重要性" name="importance">
<Select onChange={handleImportanceChange} disabled={loading}>
<Option value="all">全部</Option>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
</Select>
</Form.Item>
</Col>
</Row>
</Col>
</Row>
{/* 行业分类级联选择器 - 替换原来的 5 个独立 Select */}
<Row gutter={16}>
<Col span={24}>
<Form.Item label="行业" name="industry_cascade">
<Cascader
options={industryData || []}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
placeholder={industryLoading ? "加载中..." : "请选择行业(支持选择任意级别)"}
changeOnSelect
expandTrigger="hover"
showSearch={{
filter: (inputValue, path) =>
path.some(option => option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1)
}}
disabled={loading || industryLoading}
loading={industryLoading}
allowClear
style={{ width: '100%' }}
displayRender={(labels) => labels.join(' / ')}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
);
};
export default EventFilters;

View File

@@ -0,0 +1,75 @@
// src/views/Community/components/EventListSection.js
// 事件列表区域组件包含Loading、Empty、List三种状态
import React from 'react';
import {
Box,
Center,
VStack,
Spinner,
Text
} from '@chakra-ui/react';
import EventList from './EventList';
/**
* 事件列表区域组件
* @param {boolean} loading - 加载状态
* @param {Array} events - 事件列表
* @param {Object} pagination - 分页信息
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
*/
const EventListSection = ({
loading,
events,
pagination,
onPageChange,
onEventClick,
onViewDetail
}) => {
// ✅ 最小高度,避免加载后高度突变
const minHeight = '600px';
// Loading 状态
if (loading) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
</Box>
);
}
// Empty 状态
if (!events || events.length === 0) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
</Box>
);
}
// List 状态
return (
<Box minH={minHeight}>
<EventList
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</Box>
);
};
export default EventListSection;

View File

@@ -0,0 +1,63 @@
// src/views/Community/components/EventModals.js
// 事件弹窗组合组件包含详情Modal和股票Drawer
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton
} from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import StockDetailPanel from './StockDetailPanel';
/**
* 事件弹窗组合组件
* @param {Object} eventModalState - 事件详情Modal状态
* @param {boolean} eventModalState.isOpen - 是否打开
* @param {Function} eventModalState.onClose - 关闭回调
* @param {Object} eventModalState.event - 事件对象
* @param {Function} eventModalState.onEventClose - 事件关闭回调(清除状态)
* @param {Object} stockDrawerState - 股票详情Drawer状态
* @param {boolean} stockDrawerState.visible - 是否显示
* @param {Object} stockDrawerState.event - 事件对象
* @param {Function} stockDrawerState.onClose - 关闭回调
*/
const EventModals = ({
eventModalState,
stockDrawerState
}) => {
return (
<>
{/* 事件详情模态框 - 使用Chakra UI Modal */}
<Modal
isOpen={eventModalState.isOpen}
onClose={eventModalState.onClose}
size="xl"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>事件详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<EventDetailModal
event={eventModalState.event}
onClose={eventModalState.onEventClose}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer */}
<StockDetailPanel
visible={stockDrawerState.visible}
event={stockDrawerState.event}
onClose={stockDrawerState.onClose}
/>
</>
);
};
export default EventModals;

View File

@@ -0,0 +1,79 @@
// src/views/Community/components/EventTimelineCard.js
// 事件时间轴卡片组件整合Header + Search + List
import React, { forwardRef } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
useColorModeValue
} from '@chakra-ui/react';
import EventTimelineHeader from './EventTimelineHeader';
import UnifiedSearchBox from './UnifiedSearchBox';
import EventListSection from './EventListSection';
/**
* 事件时间轴卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Object} pagination - 分页信息
* @param {Object} filters - 筛选条件
* @param {Array} popularKeywords - 热门关键词
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onSearch - 搜索回调
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} ref - 用于滚动的ref
*/
const EventTimelineCard = forwardRef(({
events,
loading,
pagination,
filters,
popularKeywords,
lastUpdateTime,
onSearch,
onPageChange,
onEventClick,
onViewDetail
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Card ref={ref} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* 统一搜索组件(整合了话题、股票、行业、日期、排序、重要性、热门概念、筛选标签) */}
<Box mb={4}>
<UnifiedSearchBox
onSearch={onSearch}
popularKeywords={popularKeywords}
filters={filters}
/>
</Box>
{/* 事件列表包含Loading、Empty、List三种状态 */}
<EventListSection
loading={loading}
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</CardBody>
</Card>
);
});
EventTimelineCard.displayName = 'EventTimelineCard';
export default EventTimelineCard;

View File

@@ -0,0 +1,42 @@
// src/views/Community/components/EventTimelineHeader.js
// 事件时间轴标题组件
import React from 'react';
import {
Flex,
VStack,
HStack,
Heading,
Text,
Badge
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
/**
* 事件时间轴标题组件
* @param {Date} lastUpdateTime - 最后更新时间
*/
const EventTimelineHeader = ({ lastUpdateTime }) => {
return (
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时事件时间轴</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="green">全网监控</Badge>
<Badge colorScheme="orange">智能捕获</Badge>
<Badge colorScheme="purple">深度分析</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime.toLocaleTimeString()}
</Text>
</Flex>
);
};
export default EventTimelineHeader;

View File

@@ -0,0 +1,38 @@
// src/views/Community/components/HotEventsSection.js
// 热点事件区域组件
import React from 'react';
import {
Card,
CardHeader,
CardBody,
Heading,
useColorModeValue
} from '@chakra-ui/react';
import HotEvents from './HotEvents';
/**
* 热点事件区域组件
* @param {Array} events - 热点事件列表
*/
const HotEventsSection = ({ events }) => {
const cardBg = useColorModeValue('white', 'gray.800');
// 如果没有热点事件,不渲染组件
if (!events || events.length === 0) {
return null;
}
return (
<Card mt={8} bg={cardBg}>
<CardHeader>
<Heading size="md">🔥 热点事件</Heading>
</CardHeader>
<CardBody>
<HotEvents events={events} />
</CardBody>
</Card>
);
};
export default HotEventsSection;

View File

@@ -0,0 +1,75 @@
// src/views/Community/components/IndustryCascader.js
import React, { useState } from 'react';
import { Card, Form, Cascader } from 'antd';
import { useIndustry } from '../../../contexts/IndustryContext';
import { logger } from '../../../utils/logger';
const IndustryCascader = ({ onFilterChange, loading }) => {
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
// 使用全局行业数据
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// Cascader 选择变化
const handleIndustryCascaderChange = (value, selectedOptions) => {
setIndustryCascaderValue(value);
if (value && value.length > 0) {
// value[0] = 分类体系名称
// value[1...n] = 行业代码(一级~四级)
const industryCode = value[value.length - 1]; // 最后一级的 code
const classification = value[0]; // 分类体系名称
onFilterChange('industry_classification', classification);
onFilterChange('industry_code', industryCode);
logger.debug('IndustryCascader', 'Cascader 选择变化', {
value,
classification,
industryCode,
path: selectedOptions.map(o => o.label).join(' > ')
});
} else {
// 清空
onFilterChange('industry_classification', '');
onFilterChange('industry_code', '');
}
};
return (
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
<Form layout="vertical">
<Form.Item label="选择行业分类体系和具体行业">
<Cascader
options={industryData || []}
value={industryCascaderValue}
onChange={handleIndustryCascaderChange}
onFocus={handleCascaderFocus}
changeOnSelect
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
disabled={loading || industryLoading}
loading={industryLoading}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
showSearch={{
filter: (inputValue, path) =>
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
}}
style={{ width: '100%' }}
/>
</Form.Item>
</Form>
</Card>
);
};
export default IndustryCascader;

View File

@@ -797,12 +797,12 @@ export default function MidjourneyHeroSection() {
/>
{/* 全局样式 */}
<style jsx global>{`
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }

View File

@@ -1,25 +1,24 @@
// src/views/Community/components/PopularKeywords.js
import React, { useState, useEffect } from 'react';
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
import { FireOutlined, RightOutlined } from '@ant-design/icons';
import { Tag, Space, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { RightOutlined } from '@ant-design/icons';
import { logger } from '../../../utils/logger';
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/concept-api'
: 'http://192.168.1.58:6801';
// 使用相对路径,让 MSW 在开发环境可以拦截请求
const API_BASE_URL = '/concept-api';
// 获取域名前缀
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
? ''
: 'https://valuefrontier.cn';
const PopularKeywords = ({ onKeywordClick }) => {
const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
const [keywords, setKeywords] = useState([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// 加载热门概念涨幅前20
const loadPopularConcepts = async () => {
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
@@ -45,22 +44,29 @@ const PopularKeywords = ({ onKeywordClick }) => {
concept_id: item.concept_id
}));
setKeywords(formattedData);
logger.debug('PopularKeywords', '热门概念加载成功', {
logger.debug('PopularKeywords', '热门概念加载成功(自己请求)', {
count: formattedData.length
});
}
} catch (error) {
logger.error('PopularKeywords', 'loadPopularConcepts', error);
setKeywords([]);
} finally {
setLoading(false);
}
};
// 组件挂载时加载数据
// 处理从父组件传入的数据
useEffect(() => {
loadPopularConcepts();
}, []);
if (propKeywords && propKeywords.length > 0) {
// 使用父组件传入的数据
setKeywords(propKeywords);
logger.debug('PopularKeywords', '使用父组件传入的数据', {
count: propKeywords.length
});
} else {
// 没有 prop 数据,自己加载
loadPopularConcepts();
}
}, [propKeywords]);
// 根据涨跌幅获取标签颜色
const getTagColor = (changePct) => {
@@ -83,107 +89,113 @@ const PopularKeywords = ({ onKeywordClick }) => {
return `${formatted}%`;
};
// 处理概念标签点击 - 跳转到对应概念页面
// ✅ 修复:处理概念标签点击
const handleConceptClick = (concept) => {
// 如果原有的 onKeywordClick 存在,可以选择是否还要调用
// onKeywordClick && onKeywordClick(concept.keyword);
// 跳转到对应概念的页面
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
window.open(url, '_blank');
// 优先调用父组件传入的回调(用于搜索框显示和触发搜索)
if (onKeywordClick) {
onKeywordClick(concept.keyword);
logger.debug('PopularKeywords', '调用 onKeywordClick 回调', {
keyword: concept.keyword
});
} else {
// 如果没有回调,则跳转到对应概念的页面(原有行为)
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
window.open(url, '_blank');
logger.debug('PopularKeywords', '跳转到概念页面', {
keyword: concept.keyword,
url
});
}
};
// 查看更多概念
const handleViewMore = () => {
const url = `${DOMAIN_PREFIX}/concepts`;
window.open(url, '_blank');
// 处理"更多概念"按钮点击 - 跳转到概念中心
const handleMoreClick = () => {
navigate('/concepts');
};
return (
<Card
title={
<span>
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
热门概念
</span>
}
className="popular-keywords"
style={{ marginBottom: 16 }}
extra={
<span style={{ fontSize: 12, color: '#999' }}>
涨幅TOP20
</span>
}
>
<Spin spinning={loading}>
{keywords && keywords.length > 0 ? (
<>
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
{keywords.map((item) => (
<Tag
key={item.concept_id}
color={getTagColor(item.change_pct)}
style={{
cursor: 'pointer',
marginBottom: 8,
padding: '2px 8px',
transition: 'all 0.3s'
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span>{item.keyword}</span>
<span style={{
marginLeft: 6,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 4,
fontSize: 11,
opacity: 0.8
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 查看更多按钮 */}
<div style={{
borderTop: '1px solid #f0f0f0',
paddingTop: 12,
textAlign: 'center'
<>
{keywords && keywords.length > 0 && (
<div style={{ position: 'relative' }}>
<Space
size={[6, 6]}
wrap
style={{
alignItems: 'center',
maxHeight: '62px', // 约两行的高度 (每行约28-30px)
overflow: 'hidden',
paddingRight: '90px' // 为右侧按钮留出空间
}}
>
{/* 标题 */}
<span style={{
color: '#ff4d4f',
fontSize: 13,
fontWeight: 500,
marginRight: 4
}}>
<Button
type="link"
onClick={handleViewMore}
热门概念:
</span>
{/* 所有标签 */}
{keywords.map((item) => (
<Tag
key={item.concept_id}
color={getTagColor(item.change_pct)}
style={{
color: '#1890ff',
fontWeight: 500
cursor: 'pointer',
padding: '1px 6px',
fontSize: 12,
transition: 'all 0.3s',
margin: 0
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
查看更多概念
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
</Button>
</div>
</>
) : (
<Empty
description="暂无热门概念"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Card>
<span>{item.keyword}</span>
<span style={{
marginLeft: 4,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 3,
fontSize: 10,
opacity: 0.75
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 更多概念按钮 - 固定在第二行右侧 */}
<Button
type="link"
size="small"
onClick={handleMoreClick}
style={{
position: 'absolute',
bottom: 0,
right: 0,
fontSize: 12,
padding: '0 4px',
height: 'auto'
}}
>
更多概念 <RightOutlined style={{ fontSize: 10 }} />
</Button>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,675 @@
// src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined
} from '@ant-design/icons';
import moment from 'moment';
import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN';
import debounce from 'lodash/debounce';
import { useIndustry } from '../../../contexts/IndustryContext';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import PopularKeywords from './PopularKeywords';
const { RangePicker } = DatePicker;
const { Option } = AntSelect;
const UnifiedSearchBox = ({
onSearch,
popularKeywords = [],
filters = {}
}) => {
// 其他状态
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]);
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState('all'); // 重要性
const [dateRange, setDateRange] = useState(null); // 日期范围
// ✅ 本地输入状态 - 管理用户的实时输入
const [inputValue, setInputValue] = useState('');
// 使用全局行业数据
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
// 搜索触发函数
const triggerSearch = useCallback((params) => {
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
params: params,
timestamp: new Date().toISOString()
});
onSearch(params);
}, [onSearch]);
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
params: params,
delayMs: 300
});
triggerSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [triggerSearch]);
// 加载所有股票数据
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
count: response.data.length
});
}
};
loadStocks();
}, []);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
// 辅助函数:递归查找行业代码的完整路径
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// ✅ 从 props.filters 初始化筛选条件和输入框值
useEffect(() => {
if (!filters) return;
// 初始化排序和重要性
if (filters.sort) setSort(filters.sort);
if (filters.importance) setImportance(filters.importance);
// ✅ 初始化日期范围
if (filters.date_range) {
const parts = filters.date_range.split(' 至 ');
if (parts.length === 2) {
setDateRange([dayjs(parts[0]), dayjs(parts[1])]);
logger.debug('UnifiedSearchBox', '初始化日期范围', {
date_range: filters.date_range
});
}
}
// ✅ 初始化行业分类(需要 industryData 加载完成)
if (filters.industry_code && industryData && industryData.length > 0) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
logger.debug('UnifiedSearchBox', '初始化行业分类', {
industry_code: filters.industry_code,
path
});
}
}
// ✅ 同步 filters.q 到输入框显示值
if (filters.q) {
setInputValue(filters.q);
} else if (!filters.q) {
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
}, [filters.sort, filters.importance, filters.date_range, filters.industry_code, filters.q, industryData, findIndustryPath]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
// 使用 stockService 进行模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
// 转换为 AutoComplete 选项格式
const options = results.map(stock => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
</div>
),
// 保存完整的股票信息,用于选中后显示
stockInfo: stock
}));
setStockOptions(options);
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
query: value,
resultCount: options.length
});
};
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code,
name: stockInfo.name
});
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
const params = buildFilterParams({
q: stockInfo.code, // 使用股票代码作为搜索关键词
industry_code: ''
});
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
triggerSearch(params);
}
};
// ✅ 日期范围变化(使用防抖)
const handleDateRangeChange = (dates) => {
logger.debug('UnifiedSearchBox', '【1/5】日期范围值改变', {
oldValue: dateRange,
newValue: dates
});
setDateRange(dates);
// ⚠️ 注意:setState是异步的,此时dateRange仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
dateRange: dateRange, // 旧值
sort: sort,
importance: importance,
industryValue: industryValue
});
// 使用防抖搜索(需要从新值推导参数)
const params = {
...buildFilterParams(),
date_range: dates ? `${dates[0].format('YYYY-MM-DD')}${dates[1].format('YYYY-MM-DD')}` : ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 重要性变化(使用防抖)
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// ⚠️ 注意:setState是异步的,此时importance仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
importance: importance, // 旧值
sort: sort,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ importance: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 排序变化(使用防抖)
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// ⚠️ 注意:setState是异步的,此时sort仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
sort: sort, // 旧值
importance: importance,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 行业分类变化(使用防抖)
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// ⚠️ 注意:setState是异步的,此时industryValue仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
industryValue: industryValue, // 旧值
sort: sort,
importance: importance,
dateRange: dateRange
});
// 使用防抖搜索 (需要从新值推导参数)
const params = {
...buildFilterParams(),
industry_code: value?.[value.length - 1] || ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
const handleKeywordClick = (keyword) => {
// 更新输入框显示
setInputValue(keyword);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
});
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
triggerSearch(params);
};
// 主搜索(点击搜索按钮或回车)
const handleMainSearch = () => {
// 取消之前的防抖
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
const params = buildFilterParams({
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
industry_code: ''
});
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
});
triggerSearch(params);
};
// ✅ 处理输入变化 - 更新本地输入状态
const handleInputChange = (value) => {
logger.debug('UnifiedSearchBox', '输入变化', { value });
setInputValue(value);
};
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
const buildFilterParams = useCallback((overrides = {}) => {
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
overrides: overrides,
currentState: {
sort,
importance,
dateRange,
industryValue,
'filters.q': filters.q
}
});
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: overrides.sort ?? sort,
importance: overrides.importance ?? importance,
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}` : '',
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: overrides.q ?? filters.q ?? '',
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
// 最终 overrides 具有最高优先级
...overrides
};
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, dateRange, filters.q, industryValue]);
// ✅ 应用筛选(立即搜索,取消防抖)
const handleApplyFilters = () => {
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '应用筛选,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance('all');
setDateRange(null);
// 输出重置后的完整参数
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all',
date_range: '',
page: 1
};
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
onSearch(resetParams);
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
const filterTags = useMemo(() => {
const tags = [];
// 搜索关键词标签 - 从 filters.q 读取
if (filters.q) {
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
}
// 行业标签
if (industryValue && industryValue.length > 0) {
const industryLabel = industryValue.slice(1).join(' > ');
tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
}
// 日期范围标签
if (dateRange && dateRange.length === 2) {
const dateLabel = `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}`;
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
}
// 重要性标签(排除默认值 'all'
if (importance && importance !== 'all') {
tags.push({ key: 'importance', label: `重要性: ${importance}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags;
}, [filters.q, industryValue, dateRange, importance, sort]);
// ✅ 移除单个标签 - 构建新参数并触发搜索
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
const params = buildFilterParams({ q: '' });
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
triggerSearch(params);
} else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
} else if (key === 'date_range') {
// 清除日期范围
setDateRange(null);
setTimeout(() => {
const params = buildFilterParams();
triggerSearch(params);
}, 50);
} else if (key === 'importance') {
// 重置重要性为默认值
setImportance('all');
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}
};
return (
<Card>
{/* 第一行:主搜索框 */}
<Space.Compact style={{ width: '100%', marginBottom: 12 }} size="large">
<SearchOutlined style={{
fontSize: 20,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#666'
}} />
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onSearch={handleSearch}
onSelect={handleStockSelect}
options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题"
onPressEnter={handleMainSearch}
style={{ flex: 1 }}
size="large"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
<Button
type="primary"
onClick={handleMainSearch}
size="large"
style={{ minWidth: 80 }}
>
搜索
</Button>
</Space.Compact>
{/* 第二行:热门概念 */}
<div style={{ marginBottom: 12 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
{/* 第三行:筛选器 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业分类"
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 200 }}
size="middle"
/>
{/* 日期范围 */}
<RangePicker
value={dateRange}
onChange={handleDateRangeChange}
locale={locale}
placeholder={['开始日期', '结束日期']}
style={{ width: 240 }}
size="middle"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<AntSelect
value={importance}
onChange={handleImportanceChange}
style={{ width: 100 }}
size="middle"
>
<Option value="all">全部</Option>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
</AntSelect>
</Space>
{/* 重置按钮 - 现代化设计 */}
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="middle"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 12px',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff4d4f';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.backgroundColor = '#fff1f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = '#666';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
重置
</Button>
</Space>
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 120 }}
size="middle"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
</AntSelect>
</Space>
</Space>
{/* 已选条件标签 */}
{filterTags.length > 0 && (
<Space size={[8, 8]} wrap style={{ marginTop: 12 }}>
{filterTags.map(tag => (
<Tag
key={tag.key}
closable
onClose={() => handleRemoveTag(tag.key)}
color="blue"
>
{tag.label}
</Tag>
))}
</Space>
)}
</Card>
);
};
export default UnifiedSearchBox;

View File

@@ -0,0 +1,123 @@
// src/views/Community/hooks/useEventData.js
// 事件数据加载逻辑 Hook
import { useState, useEffect, useRef, useCallback } from 'react';
import { debounce } from 'lodash';
import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
/**
* 事件数据加载 Hook
* @param {Object} filters - 筛选条件
* @param {number} pageSize - 每页数量
* @returns {Object} 事件数据和加载状态
*/
export const useEventData = (filters, pageSize = 10) => {
const [events, setEvents] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: pageSize,
total: 0
});
const [loading, setLoading] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
// 加载事件列表
// ✅ 修复闭包陷阱: 接受 currentFilters 参数,避免使用闭包中的旧 filters
const loadEvents = useCallback(async (page = 1, currentFilters = null) => {
// 使用传入的 currentFilters 或回退到闭包中的 filters
const filtersToUse = currentFilters || filters;
const requestParams = {
...filtersToUse,
page,
per_page: pagination.pageSize
};
logger.debug('useEventData', '📡 【准备发起API请求】loadEvents 被调用', {
page,
currentFilters,
filtersToUse,
requestParams
});
setLoading(true);
try {
logger.debug('useEventData', '🌐 正在调用 eventService.getEvents', { requestParams });
const response = await eventService.getEvents(requestParams);
logger.debug('useEventData', '✅ API响应成功', {
success: response.success,
eventCount: response.data?.events?.length,
total: response.data?.pagination?.total
});
if (response.success) {
setEvents(response.data.events);
setPagination({
current: response.data.pagination.page,
pageSize: response.data.pagination.per_page,
total: response.data.pagination.total
});
setLastUpdateTime(new Date());
logger.debug('useEventData', 'loadEvents 成功', {
count: response.data.events.length,
total: response.data.pagination.total
});
}
} catch (error) {
logger.error('useEventData', '❌ loadEvents 失败', error, {
page,
filtersToUse
});
} finally {
setLoading(false);
}
}, [filters, pagination.pageSize]);
// 创建防抖的 loadEvents 函数500ms 防抖延迟)
// ✅ 修复闭包陷阱: 防抖函数接受 filters 参数并传递给 loadEvents
const debouncedLoadEvents = useRef(
debounce((page, filters) => {
logger.debug('useEventData', '⏱️ 【防抖延迟500ms结束】即将执行 loadEvents', {
page,
filters
});
loadEvents(page, filters);
}, 500)
).current;
// 监听 filters 变化,自动加载数据
// 防抖优化:用户快速切换筛选条件时,只执行最后一次请求
useEffect(() => {
logger.debug('useEventData', '🔔 【filters变化触发useEffect】完整filters对象:', filters);
logger.debug('useEventData', '详细参数:', {
page: filters.page || 1,
sort: filters.sort,
importance: filters.importance,
date_range: filters.date_range,
q: filters.q,
industry_code: filters.industry_code,
timestamp: new Date().toISOString()
});
// ✅ 使用防抖加载事件,将当前 filters 传递给防抖函数
logger.debug('useEventData', '⏰ 启动防抖计时器(500ms)传递最新filters');
debouncedLoadEvents(filters.page || 1, filters);
// 组件卸载时取消防抖
return () => {
debouncedLoadEvents.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]); // 监听 filters 状态变化
return {
events,
pagination,
loading,
lastUpdateTime,
loadEvents
};
};

View File

@@ -0,0 +1,79 @@
// src/views/Community/hooks/useEventFilters.js
// 事件筛选逻辑 Hook
import { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { logger } from '../../../utils/logger';
/**
* 事件筛选逻辑 Hook
* @param {Object} options - 配置选项
* @param {Function} options.navigate - 路由导航函数
* @param {Function} options.onEventClick - 事件点击回调
* @param {Object} options.eventTimelineRef - 时间轴ref用于滚动
* @returns {Object} 筛选状态和处理函数
*/
export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {}) => {
const [searchParams] = useSearchParams();
// 筛选参数状态 - 初始化时从URL读取之后只用本地状态
const [filters, setFilters] = useState(() => {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '',
page: parseInt(searchParams.get('page') || '1', 10)
};
});
// 更新筛选参数 - 直接替换(由 UnifiedSearchBox 输出完整参数)
const updateFilters = useCallback((newFilters) => {
logger.debug('useEventFilters', '🔄 【接收到onSearch回调】updateFilters 接收到完整参数', {
newFilters: newFilters,
oldFilters: filters,
timestamp: new Date().toISOString()
});
setFilters(newFilters);
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
}, [filters]);
// 处理分页变化
const handlePageChange = useCallback((page) => {
// 保持现有筛选条件,只更新页码
updateFilters({ ...filters, page });
// 滚动到实时事件时间轴(平滑滚动)
if (eventTimelineRef && eventTimelineRef.current) {
setTimeout(() => {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 滚动到元素顶部
});
}, 100); // 延迟100ms确保DOM更新
}
}, [filters, updateFilters, eventTimelineRef]);
// 处理事件点击
const handleEventClick = useCallback((event) => {
if (onEventClick) {
onEventClick(event);
}
}, [onEventClick]);
// 处理查看详情
const handleViewDetail = useCallback((eventId) => {
if (navigate) {
navigate(`/event-detail/${eventId}`);
}
}, [navigate]);
return {
filters,
updateFilters,
handlePageChange,
handleEventClick,
handleViewDetail
};
};

View File

@@ -1,311 +1,64 @@
// src/views/Community/index.js
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { debounce } from 'lodash';
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
import {
Box,
Container,
Grid,
GridItem,
Card,
CardBody,
CardHeader,
Button,
Text,
Heading,
VStack,
HStack,
Badge,
Spinner,
Flex,
Tag,
TagLabel,
TagCloseButton,
IconButton,
Wrap,
WrapItem,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
useDisclosure,
Center,
Image,
Divider,
useColorModeValue,
Link,
} from '@chakra-ui/react';
import {
RepeatIcon,
TimeIcon,
InfoIcon,
SearchIcon,
CalendarIcon,
StarIcon,
ChevronRightIcon,
CloseIcon,
} from '@chakra-ui/icons';
// 导入组件
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
import EventFilters from './components/EventFilters';
import EventList from './components/EventList';
import EventDetailModal from './components/EventDetailModal';
import StockDetailPanel from './components/StockDetailPanel';
import SearchBox from './components/SearchBox';
import PopularKeywords from './components/PopularKeywords';
import HotEvents from './components/HotEvents';
import ImportanceLegend from './components/ImportanceLegend';
import InvestmentCalendar from './components/InvestmentCalendar';
import { eventService } from '../../services/eventService';
import EventTimelineCard from './components/EventTimelineCard';
import HotEventsSection from './components/HotEventsSection';
import EventModals from './components/EventModals';
// 导入自定义 Hooks
import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters';
import { logger } from '../../utils/logger';
import { useNotification } from '../../contexts/NotificationContext';
// 导航栏已由 MainLayout 提供,无需在此导入
const filterLabelMap = {
date_range: v => v ? `日期: ${v}` : '',
sort: v => v ? `排序: ${v === 'new' ? '最新' : v === 'hot' ? '热门' : v === 'returns' ? '收益率' : v}` : '',
importance: v => v && v !== 'all' ? `重要性: ${v}` : '',
industry_classification: v => v ? `行业: ${v}` : '',
industry_code: v => v ? `行业代码: ${v}` : '',
q: v => v ? `关键词: ${v}` : '',
};
const Community = () => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const dispatch = useDispatch();
// Redux状态
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// Modal/Drawer控制
const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure();
const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure();
// Ref用于滚动到实时事件时间轴
const eventTimelineRef = useRef(null);
const hasScrolledRef = useRef(false); // 标记是否已滚动
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
// 状态管理
const [events, setEvents] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
const [loading, setLoading] = useState(false);
// Modal/Drawer状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
const [popularKeywords, setPopularKeywords] = useState([]);
const [hotEvents, setHotEvents] = useState([]);
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
// 从URL获取筛选参数
const getFiltersFromUrl = useCallback(() => {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
search_type: searchParams.get('search_type') || 'topic',
industry_classification: searchParams.get('industry_classification') || '',
industry_code: searchParams.get('industry_code') || '',
page: parseInt(searchParams.get('page') || '1', 10)
};
}, [searchParams]);
// 自定义 Hooks
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
navigate,
onEventClick: (event) => setSelectedEventForStock(event),
eventTimelineRef
});
// 更新URL参数
const updateUrlParams = useCallback((params) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(params).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
setSearchParams(newParams);
}, [searchParams, setSearchParams]);
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
// 加载事件列表
const loadEvents = useCallback(async (page = 1) => {
logger.debug('Community', 'loadEvents 被调用', { page });
setLoading(true);
try {
const filters = getFiltersFromUrl();
const response = await eventService.getEvents({
...filters,
page,
per_page: pagination.pageSize
});
if (response.success) {
setEvents(response.data.events);
setPagination({
current: response.data.pagination.page,
pageSize: response.data.pagination.per_page,
total: response.data.pagination.total
});
setLastUpdateTime(new Date());
}
} catch (error) {
// ❌ 移除 toast仅 console 输出
logger.error('Community', 'loadEvents', error, {
page,
filters: getFiltersFromUrl()
});
} finally {
setLoading(false);
}
}, [getFiltersFromUrl, pagination.pageSize]); // ✅ 移除 toast 依赖
// 加载热门关键词
const loadPopularKeywords = useCallback(async () => {
try {
const response = await eventService.getPopularKeywords(20);
if (response.success) {
setPopularKeywords(response.data);
logger.debug('Community', '热门关键词加载成功', {
count: response.data?.length || 0
});
}
} catch (error) {
logger.error('Community', 'loadPopularKeywords', error);
}
}, []);
// 加载热点事件
const loadHotEvents = useCallback(async () => {
try {
const response = await eventService.getHotEvents({ days: 5, limit: 4 });
if (response.success) {
setHotEvents(response.data);
logger.debug('Community', '热点事件加载成功', {
count: response.data?.length || 0
});
}
} catch (error) {
logger.error('Community', 'loadHotEvents', error);
}
}, []);
// 处理筛选变化
const handleFilterChange = useCallback((filterType, value) => {
updateUrlParams({ [filterType]: value, page: 1 });
}, [updateUrlParams]);
// 处理分页变化
const handlePageChange = useCallback((page) => {
updateUrlParams({ page });
loadEvents(page);
// 滚动到实时事件时间轴(平滑滚动)
setTimeout(() => {
if (eventTimelineRef.current) {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 滚动到元素顶部
});
}
}, 100); // 延迟100ms确保DOM更新
}, [updateUrlParams, loadEvents]);
// 处理事件点击
const handleEventClick = useCallback((event) => {
setSelectedEventForStock(event);
onStockDrawerOpen();
}, [onStockDrawerOpen]);
// 处理查看详情
const handleViewDetail = useCallback((eventId) => {
navigate(`/event-detail/${eventId}`);
}, [navigate]);
// 处理关键词点击
const handleKeywordClick = useCallback((keyword) => {
updateUrlParams({ q: keyword, page: 1 });
}, [updateUrlParams]);
// 处理标签删除
const handleRemoveFilterTag = (key) => {
let reset = '';
if (key === 'sort') reset = 'new';
if (key === 'importance') reset = 'all';
updateUrlParams({ [key]: reset, page: 1 });
loadEvents(1);
};
// 获取筛选标签
const filters = getFiltersFromUrl();
const filterTags = Object.entries(filters)
.filter(([key, value]) => {
if (key === 'industry_code') return !!value;
if (key === 'importance') return value && value !== 'all';
if (key === 'sort') return value && value !== 'new';
if (key === 'date_range') return !!value;
if (key === 'q') return !!value;
return false;
})
.map(([key, value]) => {
if (key === 'industry_code') return { key, label: `行业代码: ${value}` };
return { key, label: filterLabelMap[key] ? filterLabelMap[key](value) : `${key}: ${value}` };
});
// 创建防抖的 loadEvents 函数500ms 防抖延迟)
const debouncedLoadEvents = useRef(
debounce((page) => {
logger.debug('Community', '防抖后执行 loadEvents', { page });
loadEvents(page);
}, 500)
).current;
// 初始化加载
// 注意: 只监听 searchParams 变化,不监听 loadEvents 等函数
// 这是为了避免 StockDetailPanel 打开时触发不必要的重新加载
// 防抖优化:用户快速切换筛选条件时,只执行最后一次请求
// 加载热门关键词和热点事件使用Redux内部有缓存判断
useEffect(() => {
logger.debug('Community', 'useEffect 触发searchParams 变化', {
params: searchParams.toString()
});
const page = parseInt(searchParams.get('page') || '1', 10);
// 使用防抖加载事件
debouncedLoadEvents(page);
// 热门关键词和热点事件不需要防抖(初次加载)
if (searchParams.get('page') === null || searchParams.get('page') === '1') {
loadPopularKeywords();
loadHotEvents();
}
// 组件卸载时取消防抖
return () => {
debouncedLoadEvents.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); // 只监听 URL 参数变化
dispatch(fetchPopularKeywords());
dispatch(fetchHotEvents());
}, [dispatch]);
// ⚡ 首次访问社区时,延迟显示权限引导
useEffect(() => {
@@ -319,6 +72,26 @@ const Community = () => {
}
}, [showCommunityGuide]); // 只在组件挂载时执行一次
// ⚡ 页面渲染完成后1秒自动滚动到实时事件时间轴
useEffect(() => {
// 只在第一次数据加载完成后滚动
if (!loading && !hasScrolledRef.current && eventTimelineRef.current) {
const timer = setTimeout(() => {
if (eventTimelineRef.current) {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动动画
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
inline: 'nearest' // 水平方向最小滚动
});
hasScrolledRef.current = true; // 标记已滚动
logger.debug('Community', '页面渲染完成,自动滚动到实时事件时间轴(顶部对齐)');
}
}, 1000); // 渲染完成后延迟1秒
return () => clearTimeout(timer);
}
}, [loading]); // 监听 loading 状态变化
return (
<Box minH="100vh" bg={bgColor}>
{/* 导航栏已由 MainLayout 提供 */}
@@ -328,209 +101,37 @@ const Community = () => {
{/* 主内容区域 */}
<Container maxW="container.xl" py={8}>
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
{/* 左侧主要内容 */}
<GridItem>
{/* 筛选器 - 需要改造为Chakra UI版本 */}
<Card mb={4} bg={cardBg} borderColor={borderColor}>
<CardBody>
<EventFilters
filters={filters}
onFilterChange={handleFilterChange}
loading={loading}
/>
</CardBody>
</Card>
{/* 实时事件时间轴卡片 */}
<EventTimelineCard
ref={eventTimelineRef}
events={events}
loading={loading}
pagination={pagination}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}
onSearch={updateFilters}
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
{/* 筛选标签 */}
{filterTags.length > 0 && (
<Wrap spacing={2} mb={4}>
{filterTags.map(tag => (
<WrapItem key={tag.key}>
<Tag size="md" variant="solid" colorScheme="blue">
<TagLabel>{tag.label}</TagLabel>
<TagCloseButton onClick={() => handleRemoveFilterTag(tag.key)} />
</Tag>
</WrapItem>
))}
</Wrap>
)}
{/* 事件列表卡片 */}
<Card ref={eventTimelineRef} bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时事件时间轴</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="green">全网监控</Badge>
<Badge colorScheme="orange">智能捕获</Badge>
<Badge colorScheme="purple">深度分析</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime.toLocaleTimeString()}
</Text>
</Flex>
</CardHeader>
<CardBody>
{loading ? (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
) : events.length > 0 ? (
<EventList
events={events}
pagination={pagination}
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
) : (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
)}
</CardBody>
</Card>
</GridItem>
{/* 右侧侧边栏 */}
<GridItem>
<VStack spacing={4}>
{/* 搜索框 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardBody>
<SearchBox
onSearch={(values) => {
updateUrlParams({ ...values, page: 1 });
}}
/>
</CardBody>
</Card>
{/* 投资日历 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<CalendarIcon />
<Text>投资日历</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<InvestmentCalendar />
</CardBody>
</Card>
{/* 热门关键词 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<StarIcon />
<Text>热门关键词</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</CardBody>
</Card>
{/* 重要性说明 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<InfoIcon />
<Text>重要性说明</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<ImportanceLegend />
</CardBody>
</Card>
</VStack>
</GridItem>
</Grid>
{/* 热点事件 - 需要改造为Chakra UI版本 */}
{hotEvents.length > 0 && (
<Card mt={8} bg={cardBg}>
<CardHeader>
<Heading size="md">🔥 热点事件</Heading>
</CardHeader>
<CardBody>
<HotEvents events={hotEvents} />
</CardBody>
</Card>
)}
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="container.xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
{/* 事件详情模态框 - 使用Chakra UI Modal */}
<Modal isOpen={isEventModalOpen && selectedEvent} onClose={onEventModalClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>事件详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<EventDetailModal
event={selectedEvent}
onClose={() => {
setSelectedEvent(null);
onEventModalClose();
}}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer避免与 Chakra Drawer 重叠导致空白 */}
<StockDetailPanel
visible={!!selectedEventForStock}
event={selectedEventForStock}
onClose={() => {
setSelectedEventForStock(null);
onStockDrawerClose();
{/* 事件弹窗 */}
<EventModals
eventModalState={{
isOpen: !!selectedEvent,
onClose: () => setSelectedEvent(null),
event: selectedEvent,
onEventClose: () => setSelectedEvent(null)
}}
stockDrawerState={{
visible: !!selectedEventForStock,
event: selectedEventForStock,
onClose: () => setSelectedEventForStock(null)
}}
/>
</Box>

View File

@@ -67,7 +67,11 @@ export default function CenterDashboard() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
// 颜色主题
const textColor = useColorModeValue('gray.700', 'white');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -107,13 +111,13 @@ export default function CenterDashboard() {
if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []);
} catch (err) {
logger.error('Center', 'loadData', err, {
userId: user?.id,
userId,
timestamp: new Date().toISOString()
});
} finally {
setLoading(false);
}
}, [user?.id]); // 只依赖 user.id,避免无限循环
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
// 加载实时行情
const loadRealtimeQuotes = useCallback(async () => {
@@ -188,7 +192,14 @@ export default function CenterDashboard() {
};
useEffect(() => {
if (user && location.pathname.includes('/home/center')) {
const userIdChanged = prevUserIdRef.current !== userId;
if (userIdChanged) {
prevUserIdRef.current = userId;
}
// 只在 userId 真正变化或路径变化时加载数据
if ((userIdChanged || !prevUserIdRef.current) && user && location.pathname.includes('/home/center')) {
loadData();
}
@@ -199,7 +210,7 @@ export default function CenterDashboard() {
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [user?.id, location.pathname, loadData]); // 只依赖 user.id,避免无限循环
}, [userId, location.pathname, loadData, user]); // ⚡ 使用 userId防重复通过 ref 判断
// 定时刷新实时行情(每分钟一次)
useEffect(() => {

View File

@@ -39,7 +39,6 @@ import {
Center,
useToast,
Skeleton,
Link,
} from '@chakra-ui/react';
import { FiLock } from 'react-icons/fi';
import {
@@ -823,26 +822,6 @@ const EventDetail = () => {
</VStack>
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
{/* 升级弹窗 */}
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}

View File

@@ -13,7 +13,6 @@ import {
VStack,
HStack,
SimpleGrid,
Link,
useBreakpointValue
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
@@ -400,31 +399,6 @@ export default function HomePage() {
</Container>
</Box>
{/* 底部区域 */}
<Box
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 100%)"
py={{ base: 8, md: 12 }}
position="relative"
>
<Container maxW="7xl" position="relative" zIndex={1} px={containerPx}>
<VStack spacing={{ base: 4, md: 6 }} textAlign="center">
<Text color="whiteAlpha.600" fontSize={{ base: 'xs', md: 'sm' }}>
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={{ base: 2, md: 4 }} fontSize="xs" color="whiteAlpha.500" flexWrap="wrap" justify="center">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
color="whiteAlpha.500"
_hover={{ color: 'whiteAlpha.700' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}

View File

@@ -23,7 +23,6 @@ import {
StatArrow,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react';
import {
RepeatIcon,
@@ -493,26 +492,6 @@ export default function LimitAnalyse() {
</VStack>
</Box>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}

View File

@@ -48,7 +48,6 @@ import {
Progress,
Tag,
TagLabel,
Link,
Skeleton,
SkeletonText,
Popover,
@@ -1055,26 +1054,6 @@ const StockOverview = () => {
</Box>
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
};

View File

@@ -31,7 +31,6 @@ import {
AlertIcon,
AlertTitle,
AlertDescription,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
@@ -389,26 +388,6 @@ export default function TradingSimulation() {
)}
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}