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 || '暂无数据'}