refactor(events): 关注事件数据源统一到 Redux
- useFollowingEvents: 改用 Redux selector 获取关注事件 - GlobalSidebarContext: 移除本地 followingEvents 状态,使用 Redux - 侧边栏和导航栏共享同一数据源,保持状态同步 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,21 @@
|
|||||||
* GlobalSidebarContext - 全局右侧工具栏状态管理
|
* GlobalSidebarContext - 全局右侧工具栏状态管理
|
||||||
*
|
*
|
||||||
* 管理侧边栏的展开/收起状态和数据加载
|
* 管理侧边栏的展开/收起状态和数据加载
|
||||||
|
* 自选股和关注事件数据都从 Redux 获取,与导航栏共用数据源
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { getApiBase } from '@/utils/apiConfig';
|
import {
|
||||||
|
loadWatchlist,
|
||||||
|
loadWatchlistQuotes,
|
||||||
|
toggleWatchlist,
|
||||||
|
loadFollowingEvents,
|
||||||
|
loadEventComments,
|
||||||
|
toggleFollowEvent
|
||||||
|
} from '@/store/slices/stockSlice';
|
||||||
|
|
||||||
const GlobalSidebarContext = createContext(null);
|
const GlobalSidebarContext = createContext(null);
|
||||||
|
|
||||||
@@ -17,17 +26,30 @@ const GlobalSidebarContext = createContext(null);
|
|||||||
export const GlobalSidebarProvider = ({ children }) => {
|
export const GlobalSidebarProvider = ({ children }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
// 侧边栏展开/收起状态(默认折叠)
|
// 侧边栏展开/收起状态(默认折叠)
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
// 数据状态
|
// 从 Redux 获取自选股数据(与导航栏共用)
|
||||||
const [watchlist, setWatchlist] = useState([]);
|
const watchlist = useSelector(state => state.stock.watchlist || []);
|
||||||
const [realtimeQuotes, setRealtimeQuotes] = useState({});
|
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
const watchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||||
const [eventComments, setEventComments] = useState([]);
|
const quotesLoading = useSelector(state => state.stock.loading?.watchlistQuotes);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
// 将 watchlistQuotes 数组转换为 { stock_code: quote } 格式(兼容现有组件)
|
||||||
|
const realtimeQuotes = React.useMemo(() => {
|
||||||
|
const quotesMap = {};
|
||||||
|
watchlistQuotes.forEach(item => {
|
||||||
|
quotesMap[item.stock_code] = item;
|
||||||
|
});
|
||||||
|
return quotesMap;
|
||||||
|
}, [watchlistQuotes]);
|
||||||
|
|
||||||
|
// 从 Redux 获取关注事件数据(与导航栏共用)
|
||||||
|
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||||
|
const eventComments = useSelector(state => state.stock.eventComments || []);
|
||||||
|
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||||
|
|
||||||
// 防止重复加载
|
// 防止重复加载
|
||||||
const hasLoadedRef = useRef(false);
|
const hasLoadedRef = useRef(false);
|
||||||
@@ -40,86 +62,27 @@ export const GlobalSidebarProvider = ({ children }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载实时行情
|
* 加载实时行情(通过 Redux)
|
||||||
*/
|
*/
|
||||||
const loadRealtimeQuotes = useCallback(async () => {
|
const loadRealtimeQuotes = useCallback(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
dispatch(loadWatchlistQuotes());
|
||||||
try {
|
}, [userId, dispatch]);
|
||||||
setQuotesLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
const quotesMap = {};
|
|
||||||
data.data.forEach(item => {
|
|
||||||
quotesMap[item.stock_code] = item;
|
|
||||||
});
|
|
||||||
setRealtimeQuotes(quotesMap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('GlobalSidebar', 'loadRealtimeQuotes', error, {
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setQuotesLoading(false);
|
|
||||||
}
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载所有数据(自选股、关注事件、评论)
|
* 加载所有数据(自选股和关注事件都从 Redux 获取)
|
||||||
*/
|
*/
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
// 自选股通过 Redux 加载
|
||||||
setLoading(true);
|
dispatch(loadWatchlist());
|
||||||
const base = getApiBase();
|
dispatch(loadWatchlistQuotes());
|
||||||
const ts = Date.now();
|
|
||||||
|
|
||||||
const [w, e, c] = await Promise.all([
|
// 关注事件和评论通过 Redux 加载
|
||||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
dispatch(loadFollowingEvents());
|
||||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
dispatch(loadEventComments());
|
||||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
}, [userId, dispatch]);
|
||||||
]);
|
|
||||||
|
|
||||||
const jw = await w.json();
|
|
||||||
const je = await e.json();
|
|
||||||
const jc = await c.json();
|
|
||||||
|
|
||||||
if (jw.success) {
|
|
||||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
|
||||||
setWatchlist(watchlistData);
|
|
||||||
|
|
||||||
// 加载实时行情
|
|
||||||
if (watchlistData.length > 0) {
|
|
||||||
loadRealtimeQuotes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (je.success) {
|
|
||||||
setFollowingEvents(Array.isArray(je.data) ? je.data : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jc.success) {
|
|
||||||
setEventComments(Array.isArray(jc.data) ? jc.data : []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('GlobalSidebar', 'loadData', err, {
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [userId, loadRealtimeQuotes]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新数据
|
* 刷新数据
|
||||||
@@ -129,53 +92,47 @@ export const GlobalSidebarProvider = ({ children }) => {
|
|||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消关注股票
|
* 取消关注股票(通过 Redux)
|
||||||
*/
|
*/
|
||||||
const unwatchStock = useCallback(async (stockCode) => {
|
const unwatchStock = useCallback(async (stockCode) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 找到股票名称
|
||||||
const response = await fetch(base + '/api/account/watchlist/remove', {
|
const stockItem = watchlist.find(s => s.stock_code === stockCode);
|
||||||
method: 'POST',
|
const stockName = stockItem?.stock_name || '';
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ stock_code: stockCode }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
// 通过 Redux action 移除(乐观更新)
|
||||||
// 本地更新,不用重新请求
|
await dispatch(toggleWatchlist({
|
||||||
setWatchlist(prev => prev.filter(s => s.stock_code !== stockCode));
|
stockCode,
|
||||||
setRealtimeQuotes(prev => {
|
stockName,
|
||||||
const newQuotes = { ...prev };
|
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||||
delete newQuotes[stockCode];
|
})).unwrap();
|
||||||
return newQuotes;
|
|
||||||
});
|
logger.debug('GlobalSidebar', 'unwatchStock 成功', { stockCode });
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId });
|
logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId });
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, dispatch, watchlist]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消关注事件
|
* 取消关注事件(通过 Redux)
|
||||||
*/
|
*/
|
||||||
const unfollowEvent = useCallback(async (eventId) => {
|
const unfollowEvent = useCallback(async (eventId) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 通过 Redux action 取消关注(乐观更新)
|
||||||
const response = await fetch(base + `/api/events/${eventId}/unfollow`, {
|
await dispatch(toggleFollowEvent({
|
||||||
method: 'POST',
|
eventId,
|
||||||
credentials: 'include',
|
isFollowing: true // 表示当前已关注,需要取消
|
||||||
});
|
})).unwrap();
|
||||||
|
|
||||||
if (response.ok) {
|
logger.debug('GlobalSidebar', 'unfollowEvent 成功', { eventId });
|
||||||
// 本地更新
|
|
||||||
setFollowingEvents(prev => prev.filter(e => e.id !== eventId));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId });
|
logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId });
|
||||||
|
// 失败时重新加载列表
|
||||||
|
dispatch(loadFollowingEvents());
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
// 用户登录后加载数据
|
// 用户登录后加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,13 +142,9 @@ export const GlobalSidebarProvider = ({ children }) => {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户登出时重置
|
// 用户登出时重置(所有状态由 Redux 管理)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
hasLoadedRef.current = false;
|
hasLoadedRef.current = false;
|
||||||
setWatchlist([]);
|
|
||||||
setRealtimeQuotes({});
|
|
||||||
setFollowingEvents([]);
|
|
||||||
setEventComments([]);
|
|
||||||
}
|
}
|
||||||
}, [user, loadData]);
|
}, [user, loadData]);
|
||||||
|
|
||||||
@@ -208,30 +161,31 @@ export const GlobalSidebarProvider = ({ children }) => {
|
|||||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
}, [user, loadData]);
|
}, [user, loadData]);
|
||||||
|
|
||||||
// 定时刷新实时行情(每分钟一次)
|
// 定时刷新实时行情(每分钟一次,两个面板共用)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (watchlist.length > 0) {
|
if (watchlist.length > 0 && userId) {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
loadRealtimeQuotes();
|
console.log('[GlobalSidebar] 定时刷新行情');
|
||||||
|
dispatch(loadWatchlistQuotes());
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [watchlist.length, loadRealtimeQuotes]);
|
}, [watchlist.length, userId, dispatch]);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
// 状态
|
// 状态
|
||||||
isOpen,
|
isOpen,
|
||||||
toggle,
|
toggle,
|
||||||
|
|
||||||
// 数据
|
// 数据(watchlist 和 realtimeQuotes 从 Redux 获取)
|
||||||
watchlist,
|
watchlist,
|
||||||
realtimeQuotes,
|
realtimeQuotes,
|
||||||
followingEvents,
|
followingEvents,
|
||||||
eventComments,
|
eventComments,
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
loading,
|
loading: watchlistLoading || eventsLoading,
|
||||||
quotesLoading,
|
quotesLoading,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
// src/hooks/useFollowingEvents.js
|
// src/hooks/useFollowingEvents.js
|
||||||
// 关注事件管理自定义 Hook
|
// 关注事件管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { getApiBase } from '../utils/apiConfig';
|
import {
|
||||||
|
loadFollowingEvents as loadFollowingEventsAction,
|
||||||
|
toggleFollowEvent
|
||||||
|
} from '../store/slices/stockSlice';
|
||||||
|
|
||||||
const EVENTS_PAGE_SIZE = 8;
|
const EVENTS_PAGE_SIZE = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关注事件管理 Hook
|
* 关注事件管理 Hook(导航栏专用)
|
||||||
* 提供事件加载、分页、取消关注等功能
|
* 提供关注事件加载、分页、取消关注等功能
|
||||||
|
* 监听 Redux 中的 followingEvents 变化,自动同步
|
||||||
*
|
*
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* followingEvents: Array,
|
* followingEvents: Array,
|
||||||
@@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8;
|
|||||||
*/
|
*/
|
||||||
export const useFollowingEvents = () => {
|
export const useFollowingEvents = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
const dispatch = useDispatch();
|
||||||
const [eventsLoading, setEventsLoading] = useState(false);
|
|
||||||
const [eventsPage, setEventsPage] = useState(1);
|
const [eventsPage, setEventsPage] = useState(1);
|
||||||
|
|
||||||
// 加载关注的事件
|
// 从 Redux 获取关注事件数据(与 GlobalSidebar 共用)
|
||||||
const loadFollowingEvents = useCallback(async () => {
|
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||||
try {
|
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||||
setEventsLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
const resp = await fetch(base + '/api/account/events/following', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data && 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());
|
|
||||||
// 按创建时间降序排列(假设事件有 created_at 或 id)
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
setFollowingEvents(merged);
|
|
||||||
} else {
|
|
||||||
setFollowingEvents([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFollowingEvents([]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('useFollowingEvents', '加载关注事件失败', {
|
|
||||||
error: e.message
|
|
||||||
});
|
|
||||||
setFollowingEvents([]);
|
|
||||||
} finally {
|
|
||||||
setEventsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 取消关注事件
|
// 从 Redux 获取关注事件列表长度(用于监听变化)
|
||||||
|
const reduxEventsLength = useSelector(state => state.stock.followingEvents?.length || 0);
|
||||||
|
|
||||||
|
// 用于跟踪上一次的事件长度
|
||||||
|
const prevEventsLengthRef = useRef(-1);
|
||||||
|
|
||||||
|
// 初始化时加载 Redux followingEvents(确保 Redux 状态被初始化)
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedRef.current) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
logger.debug('useFollowingEvents', '初始化 Redux followingEvents');
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 加载关注事件(通过 Redux)
|
||||||
|
const loadFollowingEvents = useCallback(() => {
|
||||||
|
logger.debug('useFollowingEvents', '触发 loadFollowingEvents');
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 监听 Redux followingEvents 长度变化,自动更新分页
|
||||||
|
useEffect(() => {
|
||||||
|
const currentLength = reduxEventsLength;
|
||||||
|
const prevLength = prevEventsLengthRef.current;
|
||||||
|
|
||||||
|
// 当事件列表长度变化时,更新分页(确保不超出范围)
|
||||||
|
if (prevLength !== -1 && currentLength !== prevLength) {
|
||||||
|
const newMaxPage = Math.max(1, Math.ceil(currentLength / EVENTS_PAGE_SIZE));
|
||||||
|
setEventsPage(p => Math.min(p, newMaxPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
prevEventsLengthRef.current = currentLength;
|
||||||
|
}, [reduxEventsLength]);
|
||||||
|
|
||||||
|
// 取消关注事件(通过 Redux)
|
||||||
const handleUnfollowEvent = useCallback(async (eventId) => {
|
const handleUnfollowEvent = useCallback(async (eventId) => {
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 通过 Redux action 取消关注(乐观更新)
|
||||||
const resp = await fetch(base + `/api/events/${eventId}/follow`, {
|
await dispatch(toggleFollowEvent({
|
||||||
method: 'POST',
|
eventId,
|
||||||
credentials: 'include'
|
isFollowing: true // 表示当前已关注,需要取消
|
||||||
});
|
})).unwrap();
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (resp.ok && data && data.success !== false) {
|
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
||||||
setFollowingEvents((prev) => {
|
|
||||||
const updated = (prev || []).filter((x) => x.id !== eventId);
|
|
||||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE));
|
|
||||||
setEventsPage((p) => Math.min(p, newMaxPage));
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
|
||||||
} else {
|
|
||||||
toast({ title: '操作失败', status: 'error', duration: 2000 });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
|
logger.error('useFollowingEvents', '取消关注事件失败', e);
|
||||||
|
toast({ title: e.message || '操作失败', status: 'error', duration: 2000 });
|
||||||
|
// 失败时重新加载列表
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [dispatch, toast]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
followingEvents,
|
followingEvents,
|
||||||
|
|||||||
Reference in New Issue
Block a user