Compare commits
32 Commits
6df66abcb4
...
aacbe5c31c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aacbe5c31c | ||
|
|
197c792219 | ||
|
|
794581e429 | ||
|
|
b06d51813a | ||
|
|
5b25136c28 | ||
|
|
97c5ce0d4d | ||
|
|
f1bd9680b6 | ||
|
|
f02d0d0bd0 | ||
|
|
aa332537d4 | ||
|
|
b4b7eae1ba | ||
|
|
4559c57a62 | ||
|
|
9eb13206cc | ||
|
|
8db9a9429e | ||
|
|
916537f25b | ||
|
|
3d90ae7f74 | ||
|
|
3580385967 | ||
|
|
67c3d3a875 | ||
|
|
65d0ec5354 | ||
|
|
05307d6501 | ||
|
|
a5702b631c | ||
|
|
a96f778779 | ||
|
|
0a0d617b20 | ||
|
|
506f89e64e | ||
|
|
094793c022 | ||
|
|
873adda1fd | ||
|
|
b0ae5a2871 | ||
|
|
6f34cab6d1 | ||
|
|
5aebd4b113 | ||
|
|
70f2676c79 | ||
|
|
0b316a5ed8 | ||
|
|
72a009e1ae | ||
|
|
a92d556486 |
@@ -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": []
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
48
src/App.js
48
src/App.js
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
>
|
||||
重新加载页面
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
32
src/layouts/AppFooter.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
124
src/mocks/data/kline.js
Normal 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}`;
|
||||
}
|
||||
159
src/mocks/handlers/concept.js
Normal file
159
src/mocks/handlers/concept.js
Normal 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
|
||||
});
|
||||
})
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
@@ -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
227
src/mocks/handlers/stock.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
101
src/services/stockService.js
Normal file
101
src/services/stockService.js
Normal 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
18
src/store/index.js
Normal 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;
|
||||
274
src/store/slices/communityDataSlice.js
Normal file
274
src/store/slices/communityDataSlice.js
Normal 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
313
src/utils/CacheManager.js
Normal 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;
|
||||
@@ -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;
|
||||
75
src/views/Community/components/EventListSection.js
Normal file
75
src/views/Community/components/EventListSection.js
Normal 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;
|
||||
63
src/views/Community/components/EventModals.js
Normal file
63
src/views/Community/components/EventModals.js
Normal 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;
|
||||
79
src/views/Community/components/EventTimelineCard.js
Normal file
79
src/views/Community/components/EventTimelineCard.js
Normal 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;
|
||||
42
src/views/Community/components/EventTimelineHeader.js
Normal file
42
src/views/Community/components/EventTimelineHeader.js
Normal 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;
|
||||
38
src/views/Community/components/HotEventsSection.js
Normal file
38
src/views/Community/components/HotEventsSection.js
Normal 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;
|
||||
75
src/views/Community/components/IndustryCascader.js
Normal file
75
src/views/Community/components/IndustryCascader.js
Normal 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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
675
src/views/Community/components/UnifiedSearchBox.js
Normal file
675
src/views/Community/components/UnifiedSearchBox.js
Normal 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;
|
||||
123
src/views/Community/hooks/useEventData.js
Normal file
123
src/views/Community/hooks/useEventData.js
Normal 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
|
||||
};
|
||||
};
|
||||
79
src/views/Community/hooks/useEventFilters.js
Normal file
79
src/views/Community/hooks/useEventFilters.js
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user