From b57850459106fb9d5eab62e5b8ddd1a74973c0b4 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 23 Dec 2025 20:09:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(watchlist):=20=E8=87=AA=E9=80=89?= =?UTF-8?q?=E8=82=A1=E6=95=B0=E6=8D=AE=E6=BA=90=E7=BB=9F=E4=B8=80=E5=88=B0?= =?UTF-8?q?=20Redux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stockSlice: 新增 loadWatchlistQuotes thunk 加载自选股行情 - useWatchlist: 改用 Redux selector 获取自选股数据 - WatchlistMenu: 使用 Redux 数据源,移除本地状态管理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/FeatureMenus/WatchlistMenu.js | 33 ++- src/hooks/useWatchlist.js | 129 +++++------- src/store/slices/stockSlice.js | 191 ++++++++++++++++++ 3 files changed, 260 insertions(+), 93 deletions(-) diff --git a/src/components/Navbars/components/FeatureMenus/WatchlistMenu.js b/src/components/Navbars/components/FeatureMenus/WatchlistMenu.js index d20fbb06..418147bf 100644 --- a/src/components/Navbars/components/FeatureMenus/WatchlistMenu.js +++ b/src/components/Navbars/components/FeatureMenus/WatchlistMenu.js @@ -1,7 +1,7 @@ // src/components/Navbars/components/FeatureMenus/WatchlistMenu.js // 自选股下拉菜单组件 -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { Menu, MenuButton, @@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons'; import { FiStar } from 'react-icons/fi'; import { useNavigate } from 'react-router-dom'; import { useWatchlist } from '../../../../hooks/useWatchlist'; +import FavoriteButton from '@/components/FavoriteButton'; /** * 自选股下拉菜单组件 @@ -29,6 +30,7 @@ import { useWatchlist } from '../../../../hooks/useWatchlist'; */ const WatchlistMenu = memo(() => { const navigate = useNavigate(); + const [removingCode, setRemovingCode] = useState(null); const { watchlistQuotes, watchlistLoading, @@ -39,6 +41,17 @@ const WatchlistMenu = memo(() => { handleRemoveFromWatchlist } = useWatchlist(); + // 处理取消关注(带 loading 状态) + const handleUnwatch = async (stockCode) => { + if (removingCode) return; + setRemovingCode(stockCode); + try { + await handleRemoveFromWatchlist(stockCode); + } finally { + setRemovingCode(null); + } + }; + const titleColor = useColorModeValue('gray.600', 'gray.300'); const loadingTextColor = useColorModeValue('gray.500', 'gray.300'); const emptyTextColor = useColorModeValue('gray.500', 'gray.300'); @@ -114,21 +127,19 @@ const WatchlistMenu = memo(() => { (item.current_price || '-')} { e.preventDefault(); e.stopPropagation(); - handleRemoveFromWatchlist(item.stock_code); }} > - 取消 + handleUnwatch(item.stock_code)} + size="sm" + colorScheme="gold" + showTooltip={true} + /> diff --git a/src/hooks/useWatchlist.js b/src/hooks/useWatchlist.js index e3404ad8..378fab7e 100644 --- a/src/hooks/useWatchlist.js +++ b/src/hooks/useWatchlist.js @@ -1,12 +1,16 @@ // src/hooks/useWatchlist.js -// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步) +// 自选股管理自定义 Hook(与 Redux 状态同步,支持多组件共用) import { useState, useCallback, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useToast } from '@chakra-ui/react'; import { logger } from '../utils/logger'; import { getApiBase } from '../utils/apiConfig'; -import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice'; +import { + toggleWatchlist as toggleWatchlistAction, + loadWatchlist, + loadWatchlistQuotes +} from '../store/slices/stockSlice'; const WATCHLIST_PAGE_SIZE = 10; @@ -31,20 +35,18 @@ const WATCHLIST_PAGE_SIZE = 10; export const useWatchlist = () => { const toast = useToast(); const dispatch = useDispatch(); - const [watchlistQuotes, setWatchlistQuotes] = useState([]); - const [watchlistLoading, setWatchlistLoading] = useState(false); const [watchlistPage, setWatchlistPage] = useState(1); const [followingEvents, setFollowingEvents] = useState([]); + // 从 Redux 获取自选股数据(与 GlobalSidebar 共用) + const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []); + const watchlistLoading = useSelector(state => state.stock.loading?.watchlistQuotes || false); + // 从 Redux 获取自选股列表长度(用于监听变化) - // 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染 const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0); - // 检查 Redux watchlist 是否已初始化(加载状态) - const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist); - // 用于跟踪上一次的 watchlist 长度 - const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1,确保第一次变化也能检测到 + const prevWatchlistLengthRef = useRef(-1); // 初始化时加载 Redux watchlist(确保 Redux 状态被初始化) const hasInitializedRef = useRef(false); @@ -56,35 +58,11 @@ export const useWatchlist = () => { } }, [dispatch]); - // 加载自选股实时行情 - const loadWatchlistQuotes = useCallback(async () => { - try { - setWatchlistLoading(true); - const base = getApiBase(); - const resp = await fetch(base + '/api/account/watchlist/realtime', { - credentials: 'include', - cache: 'no-store' - }); - if (resp.ok) { - const data = await resp.json(); - if (data && data.success && Array.isArray(data.data)) { - setWatchlistQuotes(data.data); - logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length }); - } else { - setWatchlistQuotes([]); - } - } else { - setWatchlistQuotes([]); - } - } catch (e) { - logger.warn('useWatchlist', '加载自选股实时行情失败', { - error: e.message - }); - setWatchlistQuotes([]); - } finally { - setWatchlistLoading(false); - } - }, []); + // 加载自选股实时行情(通过 Redux) + const loadWatchlistQuotesFunc = useCallback(() => { + logger.debug('useWatchlist', '触发 loadWatchlistQuotes'); + dispatch(loadWatchlistQuotes()); + }, [dispatch]); // 监听 Redux watchlist 长度变化,自动刷新行情数据 useEffect(() => { @@ -102,7 +80,7 @@ export const useWatchlist = () => { // 延迟一小段时间再刷新,确保后端数据已更新 const timer = setTimeout(() => { logger.debug('useWatchlist', '执行 loadWatchlistQuotes'); - loadWatchlistQuotes(); + dispatch(loadWatchlistQuotes()); }, 500); prevWatchlistLengthRef.current = currentLength; @@ -111,66 +89,53 @@ export const useWatchlist = () => { // 更新 ref prevWatchlistLengthRef.current = currentLength; - }, [reduxWatchlistLength, loadWatchlistQuotes]); + }, [reduxWatchlistLength, dispatch]); - // 添加到自选股 + // 添加到自选股(通过 Redux) const handleAddToWatchlist = useCallback(async (stockCode, stockName) => { try { - const base = getApiBase(); - const resp = await fetch(base + '/api/account/watchlist', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ stock_code: stockCode, stock_name: stockName }) - }); - const data = await resp.json().catch(() => ({})); - if (resp.ok && data.success) { - // 刷新自选股列表 - loadWatchlistQuotes(); - toast({ title: '已添加至自选股', status: 'success', duration: 1500 }); - return true; - } else { - toast({ title: '添加失败', status: 'error', duration: 2000 }); - return false; - } + // 通过 Redux action 添加(乐观更新) + await dispatch(toggleWatchlistAction({ + stockCode, + stockName, + isInWatchlist: false // 表示当前不在自选股中,需要添加 + })).unwrap(); + + // 刷新行情 + dispatch(loadWatchlistQuotes()); + toast({ title: '已添加至自选股', status: 'success', duration: 1500 }); + return true; } catch (e) { - toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 }); + logger.error('useWatchlist', '添加自选股失败', e); + toast({ title: e.message || '添加失败', status: 'error', duration: 2000 }); return false; } - }, [toast, loadWatchlistQuotes]); + }, [dispatch, toast]); - // 从自选股移除 + // 从自选股移除(通过 Redux) const handleRemoveFromWatchlist = useCallback(async (stockCode) => { try { // 找到股票名称 - const stockItem = watchlistQuotes.find(item => { - const normalize6 = (code) => { - const m = String(code || '').match(/(\d{6})/); - return m ? m[1] : String(code || ''); - }; - return normalize6(item.stock_code) === normalize6(stockCode); - }); + const normalize6 = (code) => { + const m = String(code || '').match(/(\d{6})/); + return m ? m[1] : String(code || ''); + }; + const stockItem = watchlistQuotes.find(item => + normalize6(item.stock_code) === normalize6(stockCode) + ); const stockName = stockItem?.stock_name || ''; - // 通过 Redux action 移除(会同步更新 Redux 状态) + // 通过 Redux action 移除(乐观更新) await dispatch(toggleWatchlistAction({ stockCode, stockName, isInWatchlist: true // 表示当前在自选股中,需要移除 })).unwrap(); - // 更新本地状态(立即响应 UI) - setWatchlistQuotes((prev) => { - const normalize6 = (code) => { - const m = String(code || '').match(/(\d{6})/); - return m ? m[1] : String(code || ''); - }; - const target = normalize6(stockCode); - const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target); - const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE)); - setWatchlistPage((p) => Math.min(p, newMaxPage)); - return updated; - }); + // 更新分页(如果当前页超出范围) + const newLength = watchlistQuotes.length - 1; + const newMaxPage = Math.max(1, Math.ceil(newLength / WATCHLIST_PAGE_SIZE)); + setWatchlistPage(p => Math.min(p, newMaxPage)); toast({ title: '已从自选股移除', status: 'info', duration: 1500 }); } catch (e) { @@ -195,7 +160,7 @@ export const useWatchlist = () => { watchlistPage, setWatchlistPage, WATCHLIST_PAGE_SIZE, - loadWatchlistQuotes, + loadWatchlistQuotes: loadWatchlistQuotesFunc, followingEvents, handleAddToWatchlist, handleRemoveFromWatchlist, diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js index 53ab942a..0aba0392 100644 --- a/src/store/slices/stockSlice.js +++ b/src/store/slices/stockSlice.js @@ -292,6 +292,132 @@ export const loadAllStocks = createAsyncThunk( } ); +/** + * 加载自选股实时行情 + * 用于统一行情刷新,两个面板共用 + */ +export const loadWatchlistQuotes = createAsyncThunk( + 'stock/loadWatchlistQuotes', + async () => { + logger.debug('stockSlice', 'loadWatchlistQuotes'); + + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/account/watchlist/realtime`, { + credentials: 'include', + cache: 'no-store' + }); + const data = await response.json(); + + if (data.success && Array.isArray(data.data)) { + logger.debug('stockSlice', '自选股行情加载成功', { count: data.data.length }); + return data.data; + } + + return []; + } catch (error) { + logger.error('stockSlice', 'loadWatchlistQuotes', error); + return []; + } + } +); + +/** + * 加载关注事件列表 + * 用于统一关注事件数据源,两个面板共用 + */ +export const loadFollowingEvents = createAsyncThunk( + 'stock/loadFollowingEvents', + async () => { + logger.debug('stockSlice', 'loadFollowingEvents'); + + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/account/events/following`, { + credentials: 'include', + cache: 'no-store' + }); + const data = await response.json(); + + if (data.success && Array.isArray(data.data)) { + // 合并重复的事件(用最新的数据) + const eventMap = new Map(); + for (const evt of data.data) { + if (evt && evt.id) { + eventMap.set(evt.id, evt); + } + } + const merged = Array.from(eventMap.values()); + // 按创建时间降序排列 + if (merged.length > 0 && merged[0].created_at) { + merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); + } else { + merged.sort((a, b) => (b.id || 0) - (a.id || 0)); + } + logger.debug('stockSlice', '关注事件列表加载成功', { count: merged.length }); + return merged; + } + + return []; + } catch (error) { + logger.error('stockSlice', 'loadFollowingEvents', error); + return []; + } + } +); + +/** + * 加载用户评论列表 + */ +export const loadEventComments = createAsyncThunk( + 'stock/loadEventComments', + async () => { + logger.debug('stockSlice', 'loadEventComments'); + + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/account/events/posts`, { + credentials: 'include', + cache: 'no-store' + }); + const data = await response.json(); + + if (data.success && Array.isArray(data.data)) { + logger.debug('stockSlice', '用户评论列表加载成功', { count: data.data.length }); + return data.data; + } + + return []; + } catch (error) { + logger.error('stockSlice', 'loadEventComments', error); + return []; + } + } +); + +/** + * 切换关注事件状态(关注/取消关注) + */ +export const toggleFollowEvent = createAsyncThunk( + 'stock/toggleFollowEvent', + async ({ eventId, isFollowing }) => { + logger.debug('stockSlice', 'toggleFollowEvent', { eventId, isFollowing }); + + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/events/${eventId}/follow`, { + method: 'POST', + credentials: 'include' + }); + const data = await response.json(); + + if (!response.ok || data.success === false) { + throw new Error(data.error || '操作失败'); + } + + return { eventId, isFollowing }; + } +); + /** * 切换自选股状态 */ @@ -359,6 +485,15 @@ const stockSlice = createSlice({ // 自选股列表 [{ stock_code, stock_name }] watchlist: [], + // 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }] + watchlistQuotes: [], + + // 关注事件列表 [{ id, title, event_type, ... }] + followingEvents: [], + + // 用户评论列表 [{ id, content, event_id, ... }] + eventComments: [], + // 全部股票列表(用于前端模糊搜索)[{ code, name }] allStocks: [], @@ -370,6 +505,9 @@ const stockSlice = createSlice({ historicalEvents: false, chainAnalysis: false, watchlist: false, + watchlistQuotes: false, + followingEvents: false, + eventComments: false, allStocks: false }, @@ -517,6 +655,18 @@ const stockSlice = createSlice({ state.loading.watchlist = false; }) + // ===== loadWatchlistQuotes ===== + .addCase(loadWatchlistQuotes.pending, (state) => { + state.loading.watchlistQuotes = true; + }) + .addCase(loadWatchlistQuotes.fulfilled, (state, action) => { + state.watchlistQuotes = action.payload; + state.loading.watchlistQuotes = false; + }) + .addCase(loadWatchlistQuotes.rejected, (state) => { + state.loading.watchlistQuotes = false; + }) + // ===== loadAllStocks ===== .addCase(loadAllStocks.pending, (state) => { state.loading.allStocks = true; @@ -563,6 +713,47 @@ const stockSlice = createSlice({ .addCase(toggleWatchlist.fulfilled, (state) => { // 状态已在 pending 时更新,这里同步到 localStorage saveWatchlistToCache(state.watchlist); + }) + + // ===== loadFollowingEvents ===== + .addCase(loadFollowingEvents.pending, (state) => { + state.loading.followingEvents = true; + }) + .addCase(loadFollowingEvents.fulfilled, (state, action) => { + state.followingEvents = action.payload; + state.loading.followingEvents = false; + }) + .addCase(loadFollowingEvents.rejected, (state) => { + state.loading.followingEvents = false; + }) + + // ===== loadEventComments ===== + .addCase(loadEventComments.pending, (state) => { + state.loading.eventComments = true; + }) + .addCase(loadEventComments.fulfilled, (state, action) => { + state.eventComments = action.payload; + state.loading.eventComments = false; + }) + .addCase(loadEventComments.rejected, (state) => { + state.loading.eventComments = false; + }) + + // ===== toggleFollowEvent(乐观更新)===== + // pending: 立即更新状态 + .addCase(toggleFollowEvent.pending, (state, action) => { + const { eventId, isFollowing } = action.meta.arg; + if (isFollowing) { + // 当前已关注,取消关注 → 移除 + state.followingEvents = state.followingEvents.filter(evt => evt.id !== eventId); + } + // 添加关注的情况需要事件完整数据,不在这里处理 + }) + // rejected: 回滚状态(仅取消关注需要回滚) + .addCase(toggleFollowEvent.rejected, (state, action) => { + // 取消关注失败时,需要刷新列表恢复数据 + // 由于没有原始事件数据,这里只能触发重新加载 + logger.warn('stockSlice', 'toggleFollowEvent rejected, 需要重新加载关注事件列表'); }); } });