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;
end: Date;
dates: string[];
clickedDate: string;
}) => void;
/** 月份变化回调 */
onMonthChange?: (year: number, month: number) => void;
@@ -372,16 +373,33 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
[onDateClick]
);
// 处理事件点击
// 处理事件点击(计算点击的具体日期)
const handleEventClick = useCallback(
(arg: EventClickArg) => {
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?.({
title: arg.event.title,
start: arg.event.start,
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';
state[paginationKey].current_page = 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====================
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;

View File

@@ -35,7 +35,8 @@ import {
toggleEventFollow,
selectEventFollowStatus,
selectVerticalEventsWithLoading,
selectFourRowEventsWithLoading
selectFourRowEventsWithLoading,
updateEventVoteOptimistic
} from '@store/slices/communityDataSlice';
import { usePagination } from './hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './constants';
@@ -223,7 +224,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
setCurrentMode(mode);
}, [mode]);
// 看涨看跌投票处理
// 看涨看跌投票处理(乐观更新)
const handleVoteChange = useCallback(async ({ eventId, voteType }) => {
if (!isLoggedIn) {
toast({
@@ -236,6 +237,10 @@ const [currentMode, setCurrentMode] = useState('vertical');
return;
}
// 🚀 乐观更新:立即 +1无需等待 API 响应
dispatch(updateEventVoteOptimistic({ eventId, voteType, delta: 1 }));
// 后台默默发送 API 请求
try {
const response = await fetch(`${getApiBase()}/api/events/${eventId}/sentiment-vote`, {
method: 'POST',
@@ -250,17 +255,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
throw new Error(data.error || '投票失败');
}
toast({
title: voteType === 'bullish' ? '已看涨' : '已看跌',
status: 'success',
duration: 1500,
isClosable: true,
});
// 刷新当前页数据以更新投票计数
handlePageChange(currentPage, true);
// 成功:静默完成,不显示 toast避免干扰用户
console.log('[投票] 成功', { eventId, voteType });
} catch (error) {
console.error('投票失败:', error);
// ❌ 失败时回滚:-1 撤销乐观更新
dispatch(updateEventVoteOptimistic({ eventId, voteType, delta: -1 }));
toast({
title: '投票失败',
description: error.message,
@@ -269,7 +269,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
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 { eventService } from "@services/eventService";
import { getApiBase } from "@utils/apiConfig";
import { getConceptHtmlUrl } from "@utils/textUtils";
import { textColors } from "../constants";
import { formatDateStr } from "../utils";
@@ -146,15 +145,21 @@ const CombinedCalendar = memo(({ DetailModal }) => {
setCurrentMonth(new Date(year, month - 1, 1));
}, []);
// 处理概念条点击 - 打开概念详情页
const handleEventClick = useCallback((event) => {
// event.title 格式: "概念名 (N天)" 或 "概念名"
const conceptName = event.title.replace(/\s*\(\d+天\)$/, "");
const url = getConceptHtmlUrl(conceptName);
if (url) {
window.open(url, "_blank");
// 处理概念条点击 - 弹出当天弹窗
const handleEventClick = useCallback(
(event) => {
// 使用 clickedDateYYYYMMDD 格式)构造 Date 对象
const dateStr = event.clickedDate || event.dates?.[0];
if (dateStr) {
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 (
<>

View File

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