Merge branch 'feature_bugfix/20260116_V2' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/20260116_V2

This commit is contained in:
2026-01-16 19:52:59 +08:00
5 changed files with 117 additions and 55 deletions

View File

@@ -49,6 +49,7 @@ export interface FullCalendarProProps {
start: Date; start: Date;
end: Date; end: Date;
dates: string[]; dates: string[];
clickedDate: string;
}) => void; }) => void;
/** 月份变化回调 */ /** 月份变化回调 */
onMonthChange?: (year: number, month: number) => void; onMonthChange?: (year: number, month: number) => void;
@@ -372,16 +373,33 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
[onDateClick] [onDateClick]
); );
// 处理事件点击 // 处理事件点击(计算点击的具体日期)
const handleEventClick = useCallback( const handleEventClick = useCallback(
(arg: EventClickArg) => { (arg: EventClickArg) => {
const { extendedProps } = arg.event; const { extendedProps } = arg.event;
if (arg.event.start && arg.event.end) { const dates = extendedProps.dates as string[];
if (arg.event.start && dates?.length > 0) {
// 计算点击的是哪一天
let clickedDate = dates[0]; // 默认第一天
if (dates.length > 1) {
const rect = arg.el.getBoundingClientRect();
const clickX = arg.jsEvent.clientX - rect.left;
const dayWidth = rect.width / dates.length;
const dayIndex = Math.min(
Math.floor(clickX / dayWidth),
dates.length - 1
);
clickedDate = dates[dayIndex];
}
onEventClick?.({ onEventClick?.({
title: arg.event.title, title: arg.event.title,
start: arg.event.start, start: arg.event.start,
end: arg.event.end, end: arg.event.end,
dates: extendedProps.dates as string[], dates: dates,
clickedDate: clickedDate,
}); });
} }
}, },

View File

@@ -508,6 +508,35 @@ const communityDataSlice = createSlice({
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination'; const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
state[paginationKey].current_page = page; state[paginationKey].current_page = page;
logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page }); logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page });
},
/**
* 乐观更新事件投票计数(同步)
* @param {Object} action.payload - { eventId, voteType, delta }
* - eventId: 事件ID
* - voteType: 'bullish' | 'bearish'
* - delta: 增量(+1 或 -1
*/
updateEventVoteOptimistic: (state, action) => {
const { eventId, voteType, delta = 1 } = action.payload;
const countKey = voteType === 'bullish' ? 'bullish_count' : 'bearish_count';
// 更新纵向模式的事件数据
Object.keys(state.verticalEventsByPage || {}).forEach(page => {
const events = state.verticalEventsByPage[page];
const eventIndex = events.findIndex(e => e.id === eventId);
if (eventIndex !== -1) {
events[eventIndex][countKey] = (events[eventIndex][countKey] || 0) + delta;
}
});
// 更新平铺模式的事件数据
const fourRowIndex = (state.fourRowEvents || []).findIndex(e => e.id === eventId);
if (fourRowIndex !== -1) {
state.fourRowEvents[fourRowIndex][countKey] = (state.fourRowEvents[fourRowIndex][countKey] || 0) + delta;
}
logger.debug('CommunityData', '乐观更新事件投票', { eventId, voteType, delta });
} }
}, },
@@ -714,7 +743,7 @@ const communityDataSlice = createSlice({
// ==================== 导出 ==================== // ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions; export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage, updateEventVoteOptimistic } = communityDataSlice.actions;
// ==================== 基础选择器Selectors==================== // ==================== 基础选择器Selectors====================
export const selectPopularKeywords = (state) => state.communityData.popularKeywords; export const selectPopularKeywords = (state) => state.communityData.popularKeywords;

View File

@@ -35,7 +35,8 @@ import {
toggleEventFollow, toggleEventFollow,
selectEventFollowStatus, selectEventFollowStatus,
selectVerticalEventsWithLoading, selectVerticalEventsWithLoading,
selectFourRowEventsWithLoading selectFourRowEventsWithLoading,
updateEventVoteOptimistic
} from '@store/slices/communityDataSlice'; } from '@store/slices/communityDataSlice';
import { usePagination } from './hooks/usePagination'; import { usePagination } from './hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './constants'; import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './constants';
@@ -223,7 +224,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
setCurrentMode(mode); setCurrentMode(mode);
}, [mode]); }, [mode]);
// 看涨看跌投票处理 // 看涨看跌投票处理(乐观更新)
const handleVoteChange = useCallback(async ({ eventId, voteType }) => { const handleVoteChange = useCallback(async ({ eventId, voteType }) => {
if (!isLoggedIn) { if (!isLoggedIn) {
toast({ toast({
@@ -236,6 +237,10 @@ const [currentMode, setCurrentMode] = useState('vertical');
return; return;
} }
// 🚀 乐观更新:立即 +1无需等待 API 响应
dispatch(updateEventVoteOptimistic({ eventId, voteType, delta: 1 }));
// 后台默默发送 API 请求
try { try {
const response = await fetch(`${getApiBase()}/api/events/${eventId}/sentiment-vote`, { const response = await fetch(`${getApiBase()}/api/events/${eventId}/sentiment-vote`, {
method: 'POST', method: 'POST',
@@ -250,17 +255,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
throw new Error(data.error || '投票失败'); throw new Error(data.error || '投票失败');
} }
toast({ // 成功:静默完成,不显示 toast避免干扰用户
title: voteType === 'bullish' ? '已看涨' : '已看跌', console.log('[投票] 成功', { eventId, voteType });
status: 'success',
duration: 1500,
isClosable: true,
});
// 刷新当前页数据以更新投票计数
handlePageChange(currentPage, true);
} catch (error) { } catch (error) {
console.error('投票失败:', error); console.error('投票失败:', error);
// ❌ 失败时回滚:-1 撤销乐观更新
dispatch(updateEventVoteOptimistic({ eventId, voteType, delta: -1 }));
toast({ toast({
title: '投票失败', title: '投票失败',
description: error.message, description: error.message,
@@ -269,7 +269,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
isClosable: true, isClosable: true,
}); });
} }
}, [isLoggedIn, toast, handlePageChange, currentPage]); }, [isLoggedIn, toast, dispatch]);
/** /**
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑) * ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)

View File

@@ -14,7 +14,6 @@ import dayjs from "dayjs";
import { GLASS_BLUR } from "@/constants/glassConfig"; import { GLASS_BLUR } from "@/constants/glassConfig";
import { eventService } from "@services/eventService"; import { eventService } from "@services/eventService";
import { getApiBase } from "@utils/apiConfig"; import { getApiBase } from "@utils/apiConfig";
import { getConceptHtmlUrl } from "@utils/textUtils";
import { textColors } from "../constants"; import { textColors } from "../constants";
import { formatDateStr } from "../utils"; import { formatDateStr } from "../utils";
@@ -146,15 +145,21 @@ const CombinedCalendar = memo(({ DetailModal }) => {
setCurrentMonth(new Date(year, month - 1, 1)); setCurrentMonth(new Date(year, month - 1, 1));
}, []); }, []);
// 处理概念条点击 - 打开概念详情页 // 处理概念条点击 - 弹出当天弹窗
const handleEventClick = useCallback((event) => { const handleEventClick = useCallback(
// event.title 格式: "概念名 (N天)" 或 "概念名" (event) => {
const conceptName = event.title.replace(/\s*\(\d+天\)$/, ""); // 使用 clickedDateYYYYMMDD 格式)构造 Date 对象
const url = getConceptHtmlUrl(conceptName); const dateStr = event.clickedDate || event.dates?.[0];
if (url) { if (dateStr) {
window.open(url, "_blank"); const year = parseInt(dateStr.slice(0, 4), 10);
} const month = parseInt(dateStr.slice(4, 6), 10) - 1;
}, []); const day = parseInt(dateStr.slice(6, 8), 10);
const date = new Date(year, month, day);
handleDateClick(date);
}
},
[handleDateClick]
);
return ( return (
<> <>

View File

@@ -388,18 +388,19 @@ const CompactSearchBox = ({
return ( return (
<div style={{ padding: 0, background: "transparent" }}> <div style={{ padding: 0, background: "transparent" }}>
{/* 第一行:搜索框 + 日期筛选 */} {/* 单行布局:搜索框 + 时间筛选 + 行业/等级/排序/重置 */}
<Flex <Flex
align="center" align="center"
gap={isMobile ? 8 : 12} gap={isMobile ? 4 : 8}
wrap="wrap"
style={{ style={{
background: "rgba(255, 255, 255, 0.03)", background: "rgba(255, 255, 255, 0.03)",
border: `1px solid ${PROFESSIONAL_COLORS.gold[500]}`, border: `1px solid ${PROFESSIONAL_COLORS.gold[500]}`,
borderRadius: "24px", borderRadius: "24px",
padding: isMobile ? "2px 4px" : "8px 16px", padding: isMobile ? "4px 8px" : "8px 16px",
marginBottom: isMobile ? 8 : 12,
}} }}
> >
{/* 搜索框 */}
<AutoComplete <AutoComplete
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
@@ -408,7 +409,7 @@ const CompactSearchBox = ({
onFocus={onSearchFocus} onFocus={onSearchFocus}
options={stockOptions} options={stockOptions}
onKeyDown={(e) => e.key === "Enter" && handleMainSearch()} onKeyDown={(e) => e.key === "Enter" && handleMainSearch()}
style={{ flex: 1, minWidth: isMobile ? 100 : 200 }} style={{ flex: 1, minWidth: isMobile ? 80 : 120 }}
className="gold-placeholder" className="gold-placeholder"
allowClear={{ allowClear={{
clearIcon: ( clearIcon: (
@@ -433,13 +434,14 @@ const CompactSearchBox = ({
{!isMobile && ( {!isMobile && (
<Divider <Divider
type="vertical" type="vertical"
style={{ height: 24, margin: "0 8px", borderColor: "rgba(255,255,255,0.15)" }} style={{ height: 24, margin: "0 4px", borderColor: "rgba(255,255,255,0.15)" }}
/> />
)} )}
<div style={{ display: "flex", alignItems: "center", gap: 0 }}> {/* 时间筛选 */}
<div style={{ display: "flex", alignItems: "center", gap: 0, flexShrink: 0 }}>
<CalendarOutlined <CalendarOutlined
style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }} style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 4 }}
/> />
<TradingTimeFilter <TradingTimeFilter
value={tradingTimeRange?.key || null} value={tradingTimeRange?.key || null}
@@ -448,12 +450,18 @@ const CompactSearchBox = ({
mobile={isMobile} mobile={isMobile}
/> />
</div> </div>
</Flex>
{/* 第二行:筛选件 */} {/* 筛选件 */}
{mode !== "mainline" && ( {mode !== "mainline" && (
<Flex justify="space-between" align="center"> <>
<Space size={isMobile ? 4 : 8}> {!isMobile && (
<Divider
type="vertical"
style={{ height: 24, margin: "0 4px", borderColor: "rgba(255,255,255,0.15)" }}
/>
)}
{/* 行业筛选 */}
<Cascader <Cascader
value={industryValue} value={industryValue}
onChange={handleIndustryChange} onChange={handleIndustryChange}
@@ -478,16 +486,17 @@ const CompactSearchBox = ({
labels[labels.length - 1] || (isMobile ? "行业" : "行业筛选") labels[labels.length - 1] || (isMobile ? "行业" : "行业筛选")
} }
disabled={industryLoading} disabled={industryLoading}
style={{ minWidth: isMobile ? 70 : 80 }} style={{ minWidth: isMobile ? 60 : 80 }}
suffixIcon={null} suffixIcon={null}
className="transparent-cascader" className="transparent-cascader"
/> />
{/* 事件等级 */}
<AntSelect <AntSelect
mode="multiple" mode="multiple"
value={importance} value={importance}
onChange={handleImportanceChange} onChange={handleImportanceChange}
style={{ minWidth: isMobile ? 100 : 120 }} style={{ minWidth: isMobile ? 80 : 100 }}
placeholder={ placeholder={
<span style={{ display: "flex", alignItems: "center", gap: 4 }}> <span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} /> <ThunderboltOutlined style={{ fontSize: 12 }} />
@@ -506,13 +515,12 @@ const CompactSearchBox = ({
</Option> </Option>
))} ))}
</AntSelect> </AntSelect>
</Space>
<Space size={isMobile ? 4 : 8}> {/* 排序 */}
<AntSelect <AntSelect
value={sort} value={sort}
onChange={handleSortChange} onChange={handleSortChange}
style={{ minWidth: isMobile ? 55 : 120 }} style={{ minWidth: isMobile ? 50 : 100 }}
className="bracket-select" className="bracket-select"
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
@@ -525,17 +533,19 @@ const CompactSearchBox = ({
))} ))}
</AntSelect> </AntSelect>
<Button {/* 重置 */}
icon={<ReloadOutlined />} <Tooltip title="重置筛选">
onClick={handleReset} <Button
type="text" icon={<ReloadOutlined />}
style={{ color: PROFESSIONAL_COLORS.text.secondary }} onClick={handleReset}
> type="text"
{!isMobile && "重置筛选"} size="small"
</Button> style={{ color: PROFESSIONAL_COLORS.text.secondary, flexShrink: 0 }}
</Space> />
</Flex> </Tooltip>
)} </>
)}
</Flex>
</div> </div>
); );
}; };