diff --git a/package.json b/package.json index 29d5fb90..f13b6abb 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index 73b493ca..2f658e91 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -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() diff --git a/src/App.js b/src/App.js index 31c61776..64e84621 100755 --- a/src/App.js +++ b/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 ( - - - - - - - - - - - - + + + + + + + + + + + + + + ); } \ No newline at end of file diff --git a/src/services/stockService.js b/src/services/stockService.js new file mode 100644 index 00000000..5b1b4681 --- /dev/null +++ b/src/services/stockService.js @@ -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); + } +}; diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 00000000..52a809b0 --- /dev/null +++ b/src/store/index.js @@ -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; diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js new file mode 100644 index 00000000..3f4b7c24 --- /dev/null +++ b/src/store/slices/communityDataSlice.js @@ -0,0 +1,321 @@ +// src/store/slices/communityDataSlice.js +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { eventService } from '../../services/eventService'; +import { logger } from '../../utils/logger'; + +// ==================== 常量定义 ==================== + +// 缓存键名 +const CACHE_KEYS = { + POPULAR_KEYWORDS: 'community_popular_keywords', + HOT_EVENTS: 'community_hot_events' +}; + +// 缓存有效期类型 +const CACHE_EXPIRY_TYPE = { + MIDNIGHT: 'midnight', // 当天午夜过期 + HOURS: 'hours' // 指定小时数后过期 +}; + +// ==================== 缓存工具函数 ==================== + +/** + * 计算缓存过期时间 + * @param {string} type - 过期类型 (midnight/hours) + * @param {number} hours - 小时数(仅当 type 为 hours 时使用) + * @returns {string} ISO 格式的过期时间 + */ +const getExpireTime = (type = CACHE_EXPIRY_TYPE.MIDNIGHT, hours = 24) => { + const expireDate = new Date(); + + if (type === CACHE_EXPIRY_TYPE.MIDNIGHT) { + // 设置为明天凌晨 0 点 + expireDate.setDate(expireDate.getDate() + 1); + expireDate.setHours(0, 0, 0, 0); + } else { + // 设置为指定小时后 + expireDate.setHours(expireDate.getHours() + hours); + } + + return expireDate.toISOString(); +}; + +/** + * 从 localStorage 获取缓存数据 + * @param {string} key - 缓存键名 + * @returns {any|null} 缓存的数据或 null + */ +const getCachedData = (key) => { + try { + const cached = localStorage.getItem(key); + if (!cached) return null; + + const { data, expireAt } = JSON.parse(cached); + + // 检查是否过期 + if (new Date() > new Date(expireAt)) { + localStorage.removeItem(key); + logger.debug('CommunityData', '缓存已过期', { key }); + return null; + } + + logger.debug('CommunityData', '使用缓存数据', { + key, + dataLength: Array.isArray(data) ? data.length : 'N/A', + expireAt + }); + return data; + } catch (error) { + logger.error('CommunityData', 'getCachedData 失败', error, { key }); + // 清除损坏的缓存 + localStorage.removeItem(key); + return null; + } +}; + +/** + * 保存数据到 localStorage + * @param {string} key - 缓存键名 + * @param {any} data - 要缓存的数据 + * @param {string} expiryType - 过期类型 + */ +const setCachedData = (key, data, expiryType = CACHE_EXPIRY_TYPE.MIDNIGHT) => { + try { + const cacheData = { + data, + expireAt: getExpireTime(expiryType), + cachedAt: new Date().toISOString() + }; + localStorage.setItem(key, JSON.stringify(cacheData)); + logger.debug('CommunityData', '数据已缓存', { + key, + dataLength: Array.isArray(data) ? data.length : 'N/A', + expireAt: cacheData.expireAt, + cachedAt: cacheData.cachedAt + }); + } catch (error) { + logger.error('CommunityData', 'setCachedData 失败', error, { key }); + // localStorage 可能已满,尝试清理 + if (error.name === 'QuotaExceededError') { + logger.warn('CommunityData', 'localStorage 配额已满,尝试清理旧缓存'); + Object.values(CACHE_KEYS).forEach(cacheKey => { + localStorage.removeItem(cacheKey); + }); + } + } +}; + +// ==================== 通用数据获取逻辑 ==================== + +/** + * 通用的数据获取函数(支持三级缓存:Redux -> localStorage -> API) + * @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} 获取的数据 + */ +const fetchWithCache = async ({ + cacheKey, + fetchFn, + getState, + stateKey, + forceRefresh = false +}) => { + try { + // 第一级缓存:检查 Redux 状态(除非强制刷新) + if (!forceRefresh) { + const stateData = getState().communityData[stateKey]; + if (stateData && stateData.length > 0) { + logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`); + return stateData; + } + + // 第二级缓存:检查 localStorage + const cachedData = getCachedData(cacheKey); + if (cachedData) { + return cachedData; + } + } + + // 第三级:从 API 获取 + logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh }); + const response = await fetchFn(); + + if (response.success && response.data) { + // 保存到 localStorage + setCachedData(cacheKey, response.data); + return response.data; + } + + logger.warn('CommunityData', `API 返回数据为空:${stateKey}`); + return []; + } catch (error) { + logger.error('CommunityData', `获取${stateKey}失败`, error); + throw error; + } +}; + +// ==================== 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 + Object.values(CACHE_KEYS).forEach(key => { + localStorage.removeItem(key); + }); + + // 清除 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') { + localStorage.removeItem(CACHE_KEYS.POPULAR_KEYWORDS); + state.popularKeywords = []; + state.lastUpdated.popularKeywords = null; + logger.info('CommunityData', '热门关键词缓存已清除'); + } else if (type === 'hotEvents') { + localStorage.removeItem(CACHE_KEYS.HOT_EVENTS); + state.hotEvents = []; + state.lastUpdated.hotEvents = null; + logger.info('CommunityData', '热点事件缓存已清除'); + } + } + }, + + extraReducers: (builder) => { + // ===== 热门关键词 ===== + builder + .addCase(fetchPopularKeywords.pending, (state) => { + state.loading.popularKeywords = true; + state.error.popularKeywords = null; + }) + .addCase(fetchPopularKeywords.fulfilled, (state, action) => { + state.loading.popularKeywords = false; + state.popularKeywords = action.payload; + state.lastUpdated.popularKeywords = new Date().toISOString(); + }) + .addCase(fetchPopularKeywords.rejected, (state, action) => { + state.loading.popularKeywords = false; + state.error.popularKeywords = action.payload; + logger.error('CommunityData', '热门关键词加载失败', new Error(action.payload)); + }); + + // ===== 热点事件 ===== + builder + .addCase(fetchHotEvents.pending, (state) => { + state.loading.hotEvents = true; + state.error.hotEvents = null; + }) + .addCase(fetchHotEvents.fulfilled, (state, action) => { + state.loading.hotEvents = false; + state.hotEvents = action.payload; + state.lastUpdated.hotEvents = new Date().toISOString(); + }) + .addCase(fetchHotEvents.rejected, (state, action) => { + state.loading.hotEvents = false; + state.error.hotEvents = action.payload; + logger.error('CommunityData', '热点事件加载失败', new Error(action.payload)); + }); + } +}); + +// ==================== 导出 ==================== + +export const { clearCache, clearSpecificCache } = 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 default communityDataSlice.reducer; diff --git a/src/views/Community/components/PopularKeywords.js b/src/views/Community/components/PopularKeywords.js index 5ee2c2c9..e01fe846 100644 --- a/src/views/Community/components/PopularKeywords.js +++ b/src/views/Community/components/PopularKeywords.js @@ -1,12 +1,12 @@ // 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, Spin, 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' @@ -16,6 +16,7 @@ const DOMAIN_PREFIX = process.env.NODE_ENV === 'production' const PopularKeywords = ({ onKeywordClick }) => { const [keywords, setKeywords] = useState([]); const [loading, setLoading] = useState(false); + const navigate = useNavigate(); // 加载热门概念(涨幅前20) const loadPopularConcepts = async () => { @@ -93,97 +94,94 @@ const PopularKeywords = ({ onKeywordClick }) => { window.open(url, '_blank'); }; - // 查看更多概念 - const handleViewMore = () => { - const url = `${DOMAIN_PREFIX}/concepts`; - window.open(url, '_blank'); + // 处理"更多概念"按钮点击 - 跳转到概念中心 + const handleMoreClick = () => { + navigate('/concepts'); }; return ( - - - 热门概念 - - } - className="popular-keywords" - style={{ marginBottom: 16 }} - extra={ - - 涨幅TOP20 - - } - > - - {keywords && keywords.length > 0 ? ( - <> - - {keywords.map((item) => ( - 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'; - }} - > - {item.keyword} - - {formatChangePct(item.change_pct)} - - - ({item.count}股) - - - ))} - - - {/* 查看更多按钮 */} -
+ {keywords && keywords.length > 0 && ( +
+ + {/* 标题 */} + - -
- - ) : ( - - )} - - + {item.keyword} + + {formatChangePct(item.change_pct)} + + + ({item.count}股) + + + ))} + + + {/* 更多概念按钮 - 固定在第二行右侧 */} + +
+ )} +
); };