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;
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)
|
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)
|
||||||
|
|||||||
@@ -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+天\)$/, "");
|
// 使用 clickedDate(YYYYMMDD 格式)构造 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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{/* 重置 */}
|
||||||
|
<Tooltip title="重置筛选">
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
type="text"
|
type="text"
|
||||||
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
|
size="small"
|
||||||
>
|
style={{ color: PROFESSIONAL_COLORS.text.secondary, flexShrink: 0 }}
|
||||||
{!isMobile && "重置筛选"}
|
/>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</Space>
|
</>
|
||||||
</Flex>
|
|
||||||
)}
|
)}
|
||||||
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user