Merge branch 'feature_bugfix/20260116_V2' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/20260116_V2
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/**
|
||||
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)
|
||||
|
||||
@@ -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) => {
|
||||
// 使用 clickedDate(YYYYMMDD 格式)构造 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 (
|
||||
<>
|
||||
|
||||
@@ -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}>
|
||||
{/* 筛选控件 */}
|
||||
{mode !== "mainline" && (
|
||||
<>
|
||||
{!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>
|
||||
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
type="text"
|
||||
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
|
||||
>
|
||||
{!isMobile && "重置筛选"}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
)}
|
||||
{/* 重置 */}
|
||||
<Tooltip title="重置筛选">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ color: PROFESSIONAL_COLORS.text.secondary, flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user