Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步 feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新 fix: 修复投资日历切换月份时自动打开事件弹窗的问题 fix: 修复 CompanyOverview 中 Hooks 顺序错误
This commit is contained in:
@@ -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 (
|
||||
<Button
|
||||
type="default"
|
||||
type={inWatchlist ? "primary" : "default"}
|
||||
size="small"
|
||||
loading={isAdding}
|
||||
onClick={() => addSingleToWatchlist(record)}
|
||||
disabled={inWatchlist}
|
||||
>
|
||||
加自选
|
||||
{inWatchlist ? '已关注' : '加自选'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 时更新
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
<GridItem colSpan={{ base: 2, md: 1 }}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.600">投资亮点</Text>
|
||||
<Box p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
|
||||
<Box p={3} bg={blueBg} borderRadius="md">
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{comprehensiveData.qualitative_analysis.core_positioning?.investment_highlights || '暂无数据'}
|
||||
</Text>
|
||||
@@ -1385,7 +1390,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => {
|
||||
<GridItem colSpan={{ base: 2, md: 1 }}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.600">商业模式</Text>
|
||||
<Box p={3} bg={useColorModeValue('green.50', 'green.900')} borderRadius="md">
|
||||
<Box p={3} bg={greenBg} borderRadius="md">
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{comprehensiveData.qualitative_analysis.core_positioning?.business_model_desc || '暂无数据'}
|
||||
</Text>
|
||||
@@ -1783,7 +1788,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => {
|
||||
<GridItem colSpan={{ base: 2, md: 1 }}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.600">战略方向</Text>
|
||||
<Box p={4} bg={useColorModeValue('purple.50', 'purple.900')} borderRadius="md">
|
||||
<Box p={4} bg={purpleBg} borderRadius="md">
|
||||
<Text fontSize="sm">
|
||||
{comprehensiveData.qualitative_analysis.strategy.strategy_description || '暂无数据'}
|
||||
</Text>
|
||||
@@ -1794,7 +1799,7 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => {
|
||||
<GridItem colSpan={{ base: 2, md: 1 }}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.600">战略举措</Text>
|
||||
<Box p={4} bg={useColorModeValue('orange.50', 'orange.900')} borderRadius="md">
|
||||
<Box p={4} bg={orangeBg} borderRadius="md">
|
||||
<Text fontSize="sm">
|
||||
{comprehensiveData.qualitative_analysis.strategy.strategic_initiatives || '暂无数据'}
|
||||
</Text>
|
||||
|
||||
Reference in New Issue
Block a user