diff --git a/src/components/InvestmentCalendar/index.js b/src/components/InvestmentCalendar/index.js index a24a16e9..a84a9c1f 100644 --- a/src/components/InvestmentCalendar/index.js +++ b/src/components/InvestmentCalendar/index.js @@ -1,5 +1,7 @@ // src/components/InvestmentCalendar/index.js -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { loadWatchlist, toggleWatchlist } from '@store/slices/stockSlice'; import { Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty, Drawer, Typography, Divider, Space, Tooltip, message, Alert @@ -24,6 +26,10 @@ const { TabPane } = Tabs; const { Text, Title, Paragraph } = Typography; const InvestmentCalendar = () => { + // Redux 状态 + const dispatch = useDispatch(); + const reduxWatchlist = useSelector(state => state.stock.watchlist); + // 权限控制 const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); @@ -45,7 +51,6 @@ const InvestmentCalendar = () => { const [selectedStock, setSelectedStock] = useState(null); const [selectedEventTime, setSelectedEventTime] = useState(null); // 记录事件时间 const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表 - const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码 const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态 // 加载月度事件统计 @@ -174,10 +179,29 @@ const InvestmentCalendar = () => { } }; + // 使用 ref 确保只加载一次自选股 + const watchlistLoadedRef = useRef(false); + + // 组件挂载时加载自选股列表(仅加载一次) + useEffect(() => { + if (!watchlistLoadedRef.current) { + watchlistLoadedRef.current = true; + dispatch(loadWatchlist()); + } + }, [dispatch]); + useEffect(() => { loadEventCounts(currentMonth); }, [currentMonth, loadEventCounts]); + // 检查股票是否已在自选中 + const isStockInWatchlist = useCallback((stockCode) => { + const sixDigitCode = getSixDigitCode(stockCode); + return reduxWatchlist.some(item => + getSixDigitCode(item.stock_code) === sixDigitCode + ); + }, [reduxWatchlist]); + // 自定义日期单元格渲染(Ant Design 5.x API) const cellRender = (current, info) => { // 只处理日期单元格,月份单元格返回默认 @@ -220,7 +244,12 @@ const InvestmentCalendar = () => { }; // 处理日期选择 - const handleDateSelect = (value) => { + // info.source 区分选择来源:'date' = 点击日期,'month'/'year' = 切换月份/年份 + const handleDateSelect = (value, info) => { + // 只有点击日期单元格时才打开弹窗,切换月份/年份时不打开 + if (info?.source !== 'date') { + return; + } setSelectedDate(value); loadDateEvents(value); setModalVisible(true); @@ -379,42 +408,35 @@ const InvestmentCalendar = () => { } }; - // 添加单只股票到自选(支持新旧格式) + // 添加单只股票到自选(乐观更新,无需 loading 状态) const addSingleToWatchlist = async (stock) => { // 兼容新旧格式 const code = stock.code || stock[0]; const name = stock.name || stock[1]; const stockCode = getSixDigitCode(code); - setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true })); + // 检查是否已在自选中 + if (isStockInWatchlist(code)) { + message.info(`${name} 已在自选中`); + return; + } try { - const response = await fetch('/api/account/watchlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - stock_code: stockCode, // 使用六位代码 - stock_name: name // 股票名称 - }) - }); + // 乐观更新:dispatch 后 Redux 立即更新状态,UI 立即响应 + await dispatch(toggleWatchlist({ + stockCode, + stockName: name, + isInWatchlist: false // false 表示添加 + })).unwrap(); - const data = await response.json(); - if (data.success) { - message.success(`已将 ${name}(${stockCode}) 添加到自选`); - } else { - message.error(data.error || '添加失败'); - } + message.success(`已将 ${name}(${stockCode}) 添加到自选`); } catch (error) { + // 失败时 Redux 会自动回滚状态 logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, { stockCode, stockName: name }); message.error('添加失败,请重试'); - } finally { - setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false })); } }; @@ -791,17 +813,16 @@ const InvestmentCalendar = () => { key: 'action', width: 100, render: (_, record) => { - const stockCode = getSixDigitCode(record.code); - const isAdding = addingToWatchlist[stockCode] || false; + const inWatchlist = isStockInWatchlist(record.code); return ( ); } diff --git a/src/mocks/data/account.js b/src/mocks/data/account.js index 73b7fc7e..9cd28bf7 100644 --- a/src/mocks/data/account.js +++ b/src/mocks/data/account.js @@ -128,6 +128,13 @@ export const mockRealtimeQuotes = [ // ==================== 关注事件数据 ==================== +// 事件关注内存存储(Set 存储已关注的事件 ID) +export const followedEventsSet = new Set(); + +// 关注事件完整数据存储(Map: eventId -> eventData) +export const followedEventsMap = new Map(); + +// 初始关注事件列表(用于初始化) export const mockFollowingEvents = [ { id: 101, @@ -231,6 +238,74 @@ export const mockFollowingEvents = [ } ]; +// 初始化:将 mockFollowingEvents 的数据加入内存存储 +mockFollowingEvents.forEach(event => { + followedEventsSet.add(event.id); + followedEventsMap.set(event.id, event); +}); + +/** + * 切换事件关注状态 + * @param {number} eventId - 事件 ID + * @param {Object} eventData - 事件数据(关注时需要) + * @returns {{ isFollowing: boolean, followerCount: number }} + */ +export function toggleEventFollowStatus(eventId, eventData = null) { + const wasFollowing = followedEventsSet.has(eventId); + + if (wasFollowing) { + // 取消关注 + followedEventsSet.delete(eventId); + followedEventsMap.delete(eventId); + } else { + // 添加关注 + followedEventsSet.add(eventId); + if (eventData) { + followedEventsMap.set(eventId, { + ...eventData, + followed_at: new Date().toISOString() + }); + } else { + // 如果没有提供事件数据,创建基础数据 + followedEventsMap.set(eventId, { + id: eventId, + title: `事件 ${eventId}`, + tags: [], + followed_at: new Date().toISOString() + }); + } + } + + const isFollowing = !wasFollowing; + const followerCount = isFollowing ? Math.floor(Math.random() * 500) + 100 : Math.floor(Math.random() * 500) + 50; + + console.log('[Mock Data] 切换事件关注状态:', { + eventId, + wasFollowing, + isFollowing, + followedEventsCount: followedEventsSet.size + }); + + return { isFollowing, followerCount }; +} + +/** + * 检查事件是否已关注 + * @param {number} eventId - 事件 ID + * @returns {boolean} + */ +export function isEventFollowed(eventId) { + return followedEventsSet.has(eventId); +} + +/** + * 获取所有已关注的事件列表 + * @returns {Array} + */ +export function getFollowedEvents() { + return Array.from(followedEventsMap.values()); +} + // ==================== 评论数据 ==================== export const mockEventComments = [ diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js index 3394e0df..d151ae18 100644 --- a/src/mocks/handlers/account.js +++ b/src/mocks/handlers/account.js @@ -9,7 +9,8 @@ import { mockInvestmentPlans, mockCalendarEvents, mockSubscriptionCurrent, - getCalendarEventsByDateRange + getCalendarEventsByDateRange, + getFollowedEvents } from '../data/account'; // 模拟网络延迟(毫秒) @@ -250,7 +251,7 @@ export const accountHandlers = [ // ==================== 事件关注管理 ==================== - // 8. 获取关注的事件 + // 8. 获取关注的事件(使用内存状态动态返回) http.get('/api/account/events/following', async () => { await delay(NETWORK_DELAY); @@ -262,11 +263,14 @@ export const accountHandlers = [ ); } - console.log('[Mock] 获取关注的事件'); + // 从内存存储获取已关注的事件列表 + const followedEvents = getFollowedEvents(); + + console.log('[Mock] 获取关注的事件, 数量:', followedEvents.length); return HttpResponse.json({ success: true, - data: mockFollowingEvents + data: followedEvents }); }), diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index a616e95a..a8f488e5 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events'; -import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account'; +import { getMockFutureEvents, getMockEventCountsForMonth, toggleEventFollowStatus, isEventFollowed } from '../data/account'; import { generatePopularConcepts } from './concept'; // 模拟网络延迟 @@ -260,15 +260,19 @@ export const eventHandlers = [ await delay(200); const { eventId } = params; + const numericEventId = parseInt(eventId, 10); - console.log('[Mock] 获取事件详情, eventId:', eventId); + console.log('[Mock] 获取事件详情, eventId:', numericEventId); try { + // 检查是否已关注 + const isFollowing = isEventFollowed(numericEventId); + // 返回模拟的事件详情数据 return HttpResponse.json({ success: true, data: { - id: parseInt(eventId), + id: numericEventId, title: `测试事件 ${eventId} - 重大政策发布`, description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。', importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)], @@ -278,7 +282,7 @@ export const eventHandlers = [ related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)), follower_count: Math.floor(Math.random() * 500) + 50, view_count: Math.floor(Math.random() * 5000) + 100, - is_following: false, + is_following: isFollowing, // 使用内存状态 post_count: Math.floor(Math.random() * 50), expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)), }, @@ -395,19 +399,29 @@ export const eventHandlers = [ } }), - // 切换事件关注状态 - http.post('/api/events/:eventId/follow', async ({ params }) => { + // 切换事件关注状态(使用内存状态管理) + http.post('/api/events/:eventId/follow', async ({ params, request }) => { await delay(200); const { eventId } = params; + const numericEventId = parseInt(eventId, 10); - console.log('[Mock] 切换事件关注状态, eventId:', eventId); + console.log('[Mock] 切换事件关注状态, eventId:', numericEventId); try { - // 模拟切换逻辑:随机生成关注状态 - // 实际应用中,这里应该从某个状态存储中读取和更新 - const isFollowing = Math.random() > 0.5; - const followerCount = Math.floor(Math.random() * 1000) + 100; + // 尝试从请求体获取事件数据(用于新关注时保存完整信息) + let eventData = null; + try { + const body = await request.json(); + if (body && body.title) { + eventData = body; + } + } catch { + // 没有请求体或解析失败,忽略 + } + + // 使用内存状态管理切换关注 + const { isFollowing, followerCount } = toggleEventFollowStatus(numericEventId, eventData); return HttpResponse.json({ success: true, diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js index b6cf7db0..f6ac344f 100644 --- a/src/store/slices/communityDataSlice.js +++ b/src/store/slices/communityDataSlice.js @@ -608,14 +608,40 @@ const communityDataSlice = createSlice({ state.error[stateKey] = action.payload; logger.error('CommunityData', `${stateKey} 加载失败`, new Error(action.payload)); }) - // toggleEventFollow + // ===== toggleEventFollow(乐观更新)===== + // pending: 立即切换状态 + .addCase(toggleEventFollow.pending, (state, action) => { + const eventId = action.meta.arg; + const current = state.eventFollowStatus[eventId]; + // 乐观切换:如果当前已关注则变为未关注,反之亦然 + state.eventFollowStatus[eventId] = { + isFollowing: !(current?.isFollowing), + followerCount: current?.followerCount ?? 0 + }; + logger.debug('CommunityData', 'toggleEventFollow pending (乐观更新)', { + eventId, + newIsFollowing: !(current?.isFollowing) + }); + }) + // rejected: 回滚状态 + .addCase(toggleEventFollow.rejected, (state, action) => { + const eventId = action.meta.arg; + const current = state.eventFollowStatus[eventId]; + // 回滚:恢复到之前的状态(再次切换回去) + state.eventFollowStatus[eventId] = { + isFollowing: !(current?.isFollowing), + followerCount: current?.followerCount ?? 0 + }; + logger.error('CommunityData', 'toggleEventFollow rejected (已回滚)', { + eventId, + error: action.payload + }); + }) + // fulfilled: 使用 API 返回的准确数据覆盖 .addCase(toggleEventFollow.fulfilled, (state, action) => { const { eventId, isFollowing, followerCount } = action.payload; state.eventFollowStatus[eventId] = { isFollowing, followerCount }; logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount }); - }) - .addCase(toggleEventFollow.rejected, (_state, action) => { - logger.error('CommunityData', 'toggleEventFollow rejected', action.payload); }); } }); diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js index 37622694..b6ba2714 100644 --- a/src/store/slices/stockSlice.js +++ b/src/store/slices/stockSlice.js @@ -440,9 +440,10 @@ const stockSlice = createSlice({ state.loading.allStocks = false; }) - // ===== toggleWatchlist ===== - .addCase(toggleWatchlist.fulfilled, (state, action) => { - const { stockCode, stockName, isInWatchlist } = action.payload; + // ===== toggleWatchlist(乐观更新)===== + // pending: 立即更新状态 + .addCase(toggleWatchlist.pending, (state, action) => { + const { stockCode, stockName, isInWatchlist } = action.meta.arg; if (isInWatchlist) { // 移除 state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); @@ -453,6 +454,25 @@ const stockSlice = createSlice({ state.watchlist.push({ stock_code: stockCode, stock_name: stockName }); } } + }) + // rejected: 回滚状态 + .addCase(toggleWatchlist.rejected, (state, action) => { + const { stockCode, stockName, isInWatchlist } = action.meta.arg; + // 回滚:与 pending 操作相反 + if (isInWatchlist) { + // 之前移除了,现在加回来 + const exists = state.watchlist.some(item => item.stock_code === stockCode); + if (!exists) { + state.watchlist.push({ stock_code: stockCode, stock_name: stockName }); + } + } else { + // 之前添加了,现在移除 + state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); + } + }) + // fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作 + .addCase(toggleWatchlist.fulfilled, () => { + // 状态已在 pending 时更新 }); } }); diff --git a/src/views/Company/CompanyOverview.js b/src/views/Company/CompanyOverview.js index 5046280a..15fa08ee 100644 --- a/src/views/Company/CompanyOverview.js +++ b/src/views/Company/CompanyOverview.js @@ -910,6 +910,11 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { const toast = useToast(); const bgColor = useColorModeValue('gray.50', 'gray.900'); const cardBg = useColorModeValue('white', 'gray.800'); + // 高亮区域颜色(修复:不能在 JSX 中调用 hooks) + const blueBg = useColorModeValue('blue.50', 'blue.900'); + const greenBg = useColorModeValue('green.50', 'green.900'); + const purpleBg = useColorModeValue('purple.50', 'purple.900'); + const orangeBg = useColorModeValue('orange.50', 'orange.900'); const { isOpen: isAnnouncementOpen, onOpen: onAnnouncementOpen, onClose: onAnnouncementClose } = useDisclosure(); const [selectedAnnouncement, setSelectedAnnouncement] = useState(null); @@ -1374,7 +1379,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { 投资亮点 - + {comprehensiveData.qualitative_analysis.core_positioning?.investment_highlights || '暂无数据'} @@ -1385,7 +1390,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { 商业模式 - + {comprehensiveData.qualitative_analysis.core_positioning?.business_model_desc || '暂无数据'} @@ -1783,7 +1788,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { 战略方向 - + {comprehensiveData.qualitative_analysis.strategy.strategy_description || '暂无数据'} @@ -1794,7 +1799,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => { 战略举措 - + {comprehensiveData.qualitative_analysis.strategy.strategic_initiatives || '暂无数据'}