Files
vf_react/src/components/Calendar/FullCalendarPro.tsx
zdl c6d7ac5d41 Merge branch 'feature_bugfix/20260112_count' into feature_bugfix/20260106
合并 HeroPanel 模块化重构及多个组件优化:
- HeroPanel: 3000+ 行拆分为模块化子组件 (~219行)
- ThemeCometChart/MarketOverviewBanner: 提取常量和子组件
- CompactSearchBox/TradingTimeFilter: 提取工具函数
- MainlineTimeline: 提取时间线子组件
- StockChangeIndicators: 修复 React Hooks 规则

冲突解决:保留重构后的精简版 HeroPanel.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-15 15:11:51 +08:00

665 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FullCalendarPro - 炫酷黑金主题日历组件
* 支持跨天事件条、动画效果、悬浮提示
* 使用 dayCellDidMount 钩子实现完整的单元格自定义内容
*/
import React, { useMemo, useRef, useCallback, useEffect } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import type {
EventInput,
EventClickArg,
DatesSetArg,
DayCellMountArg,
} from "@fullcalendar/core";
import { Box, Text, VStack, Tooltip } from "@chakra-ui/react";
import { keyframes } from "@emotion/react";
import dayjs from "dayjs";
// 动画定义
const shimmer = keyframes`
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
`;
const glow = keyframes`
0%, 100% { box-shadow: 0 0 5px rgba(212, 175, 55, 0.3); }
50% { box-shadow: 0 0 20px rgba(212, 175, 55, 0.6); }
`;
/**
* 事件数据接口
*/
export interface CalendarEventData {
date: string; // YYYYMMDD 格式
count: number; // 涨停数
topSector: string; // 最热概念
eventCount?: number; // 未来事件数
indexChange?: number; // 上证指数涨跌幅
}
/**
* FullCalendarPro Props
*/
export interface FullCalendarProProps {
/** 日历数据 */
data: CalendarEventData[];
/** 日期点击回调 */
onDateClick?: (date: Date, data?: CalendarEventData) => void;
/** 事件点击回调(点击跨天条) */
onEventClick?: (event: {
title: string;
start: Date;
end: Date;
dates: string[];
}) => void;
/** 月份变化回调 */
onMonthChange?: (year: number, month: number) => void;
/** 当前月份 */
currentMonth?: Date;
/** 高度 */
height?: string | number;
}
/**
* 概念颜色映射 - 为不同概念生成不同的渐变色
*/
const CONCEPT_COLORS: Record<
string,
{ bg: string; border: string; text: string }
> = {};
const COLOR_PALETTE = [
{
bg: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)",
border: "#FFD700",
text: "#1a1a2e",
}, // 金色 - 深色文字
{
bg: "linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)",
border: "#00CED1",
text: "#1a1a2e",
}, // 青色 - 深色文字
{
bg: "linear-gradient(135deg, #FF6B6B 0%, #EE5A5A 100%)",
border: "#FF6B6B",
text: "#fff",
}, // 红色 - 白色文字
{
bg: "linear-gradient(135deg, #A855F7 0%, #9333EA 100%)",
border: "#A855F7",
text: "#fff",
}, // 紫色 - 白色文字
{
bg: "linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)",
border: "#3B82F6",
text: "#fff",
}, // 蓝色 - 白色文字
{
bg: "linear-gradient(135deg, #10B981 0%, #059669 100%)",
border: "#10B981",
text: "#1a1a2e",
}, // 绿色 - 深色文字
{
bg: "linear-gradient(135deg, #F59E0B 0%, #D97706 100%)",
border: "#F59E0B",
text: "#1a1a2e",
}, // 橙色 - 深色文字
{
bg: "linear-gradient(135deg, #EC4899 0%, #DB2777 100%)",
border: "#EC4899",
text: "#fff",
}, // 粉色 - 白色文字
{
bg: "linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)",
border: "#6366F1",
text: "#fff",
}, // 靛蓝 - 白色文字
{
bg: "linear-gradient(135deg, #14B8A6 0%, #0D9488 100%)",
border: "#14B8A6",
text: "#1a1a2e",
}, // 青绿 - 深色文字
];
let colorIndex = 0;
const getConceptColor = (concept: string) => {
if (!CONCEPT_COLORS[concept]) {
CONCEPT_COLORS[concept] = COLOR_PALETTE[colorIndex % COLOR_PALETTE.length];
colorIndex++;
}
return CONCEPT_COLORS[concept];
};
/**
* 将连续相同概念的日期合并成跨天事件
* 注意:在周六截断,避免跨周显示导致布局问题
*/
const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => {
if (!data.length) return [];
// 按日期排序
const sorted = [...data]
.filter((d) => d.topSector)
.sort((a, b) => a.date.localeCompare(b.date));
const events: EventInput[] = [];
let currentEvent: {
concept: string;
startDate: string;
endDate: string;
dates: string[];
totalCount: number;
} | null = null;
sorted.forEach((item, index) => {
const dateStr = item.date;
const concept = item.topSector;
// 检查是否与前一天连续且概念相同
const prevItem = sorted[index - 1];
const isConsecutive =
prevItem &&
concept === prevItem.topSector &&
isNextDay(prevItem.date, dateStr);
// 检查前一天是否是周六day() === 6如果是则需要截断开始新事件
const prevDate = prevItem ? dayjs(prevItem.date, 'YYYYMMDD') : null;
const shouldBreakAtWeekend = prevDate && prevDate.day() === 6; // 周六
if (isConsecutive && currentEvent && !shouldBreakAtWeekend) {
// 延续当前事件
currentEvent.endDate = dateStr;
currentEvent.dates.push(dateStr);
currentEvent.totalCount += item.count;
} else {
// 保存之前的事件
if (currentEvent) {
events.push(createEventInput(currentEvent));
}
// 开始新事件
currentEvent = {
concept,
startDate: dateStr,
endDate: dateStr,
dates: [dateStr],
totalCount: item.count,
};
}
});
// 保存最后一个事件
if (currentEvent) {
events.push(createEventInput(currentEvent));
}
return events;
};
/**
* 检查两个日期是否连续(跳过周末)
*/
const isNextDay = (date1: string, date2: string): boolean => {
const d1 = dayjs(date1, "YYYYMMDD");
const d2 = dayjs(date2, "YYYYMMDD");
// 简单判断相差1-3天内考虑周末
const diff = d2.diff(d1, "day");
if (diff === 1) return true;
if (diff === 2 && d1.day() === 5) return true; // 周五到周日
if (diff === 3 && d1.day() === 5) return true; // 周五到周一
return false;
};
/**
* 创建 FullCalendar 事件对象
*/
const createEventInput = (event: {
concept: string;
startDate: string;
endDate: string;
dates: string[];
totalCount: number;
}): EventInput => {
const color = getConceptColor(event.concept);
const startDate = dayjs(event.startDate, "YYYYMMDD");
const endDate = dayjs(event.endDate, "YYYYMMDD").add(1, "day"); // FullCalendar 的 end 是 exclusive
return {
id: `${event.concept}-${event.startDate}`,
title: event.concept,
start: startDate.format("YYYY-MM-DD"),
end: endDate.format("YYYY-MM-DD"),
backgroundColor: "transparent",
borderColor: "transparent",
textColor: color.text,
extendedProps: {
concept: event.concept,
dates: event.dates,
totalCount: event.totalCount,
daysCount: event.dates.length,
gradient: color.bg,
borderColor: color.border,
textColor: color.text,
},
};
};
/**
* 创建自定义单元格内容的 HTML
*/
const createCellContentHTML = (
date: Date,
dateData: CalendarEventData | undefined,
isToday: boolean
): string => {
const dayNum = date.getDate();
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const hasZtData = dateData && dateData.count > 0;
const hasEventCount = dateData?.eventCount && dateData.eventCount > 0;
const hasIndexChange =
dateData?.indexChange !== undefined && dateData?.indexChange !== null;
// 日期颜色
const dateColor = isToday ? "#FFD700" : isWeekend ? "#FB923C" : "#FFFFFF";
const dateFontWeight = isToday ? "bold" : "600";
// 上证涨跌幅
let indexChangeHTML = "";
if (hasIndexChange) {
const indexChange = dateData.indexChange!;
const indexColor = indexChange >= 0 ? "#EF4444" : "#22C55E";
const sign = indexChange >= 0 ? "+" : "";
indexChangeHTML = `<span style="font-size: 12px; font-weight: 700; color: ${indexColor};">${sign}${indexChange.toFixed(1)}%</span>`;
}
// 涨停数据(热度)
let ztDataHTML = "";
if (hasZtData) {
const ztColor = dateData.count >= 60 ? "#EF4444" : "#F59E0B";
ztDataHTML = `
<span style="display: inline-flex; align-items: center; gap: 2px;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="${ztColor}" stroke-width="2">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>
</svg>
<span style="font-size: 12px; font-weight: bold; color: ${ztColor};">${dateData.count}</span>
</span>
`;
}
// 未来事件计数
let eventCountHTML = "";
if (hasEventCount) {
eventCountHTML = `
<span style="display: inline-flex; align-items: center; gap: 1px;">
<span style="font-size: 12px; font-weight: bold; color: #22C55E;">${dateData.eventCount}</span>
<span style="font-size: 10px; color: #22C55E;">事件</span>
</span>
`;
}
return `
<div class="fc-custom-cell-content" style="width: 100%; padding: 2px 4px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 3px;">
<span style="font-size: 13px; font-weight: ${dateFontWeight}; color: ${dateColor};">${dayNum}</span>
${indexChangeHTML}
</div>
<div style="display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 2px; height: 12px;">
${ztDataHTML}
${eventCountHTML}
</div>
</div>
`;
};
/**
* FullCalendarPro 组件
*/
export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
data,
onDateClick,
onEventClick,
onMonthChange,
currentMonth,
height = "auto",
}) => {
const calendarRef = useRef<FullCalendar>(null);
const dataMapRef = useRef<Map<string, CalendarEventData>>(new Map());
// 将数据转换为事件
const events = useMemo(() => mergeConsecutiveConcepts(data), [data]);
// 创建日期数据映射
const dataMap = useMemo(() => {
const map = new Map<string, CalendarEventData>();
data.forEach((d) => map.set(d.date, d));
return map;
}, [data]);
// 同步 dataMap 到 ref供 dayCellDidMount 使用
useEffect(() => {
dataMapRef.current = dataMap;
}, [dataMap]);
// 当数据变化时,更新所有已挂载的单元格内容
useEffect(() => {
if (!calendarRef.current) return;
// 获取所有日期单元格并更新内容
const calendarEl = calendarRef.current.getApi().el;
const dayCells = calendarEl?.querySelectorAll(".fc-daygrid-day");
dayCells?.forEach((cell: Element) => {
const dateAttr = cell.getAttribute("data-date");
if (!dateAttr) return;
const date = new Date(dateAttr);
const dateStr = dayjs(date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
const isToday = dayjs(date).isSame(dayjs(), "day");
// 找到 day-top 容器并更新内容
const dayTop = cell.querySelector(".fc-daygrid-day-top");
if (dayTop) {
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
}
});
}, [dataMap]);
// 处理日期点击
const handleDateClick = useCallback(
(arg: { date: Date; dateStr: string }) => {
const dateStr = dayjs(arg.date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
onDateClick?.(arg.date, dateData);
},
[onDateClick]
);
// 处理事件点击
const handleEventClick = useCallback(
(arg: EventClickArg) => {
const { extendedProps } = arg.event;
if (arg.event.start && arg.event.end) {
onEventClick?.({
title: arg.event.title,
start: arg.event.start,
end: arg.event.end,
dates: extendedProps.dates as string[],
});
}
},
[onEventClick]
);
// 处理月份变化
const handleDatesSet = useCallback(
(arg: DatesSetArg) => {
const visibleDate = arg.view.currentStart;
onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1);
},
[onMonthChange]
);
// 单元格挂载时插入自定义内容
const handleDayCellDidMount = useCallback((arg: DayCellMountArg) => {
const { date, el, isToday } = arg;
const dateStr = dayjs(date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
// 找到 day-top 容器并插入自定义内容
const dayTop = el.querySelector(".fc-daygrid-day-top");
if (dayTop) {
// 清空默认内容
dayTop.innerHTML = "";
// 插入自定义内容
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
}
}, []);
// 自定义事件内容(跨天条)
const eventContent = useCallback(
(arg: {
event: { title: string; extendedProps: Record<string, unknown> };
}) => {
const { extendedProps } = arg.event;
const daysCount = extendedProps.daysCount as number;
const totalCount = extendedProps.totalCount as number;
const textColor = (extendedProps.textColor as string) || "#fff";
const gradient = extendedProps.gradient as string;
const borderColor = extendedProps.borderColor as string;
return (
<Tooltip
label={
<VStack spacing={1} align="start" p={1}>
<Text fontWeight="bold" color="white">{arg.event.title}</Text>
<Text fontSize="xs" color="white"> {daysCount} </Text>
<Text fontSize="xs" color="white"> {totalCount} </Text>
</VStack>
}
placement="top"
hasArrow
bg="rgba(15, 15, 22, 0.95)"
border="1px solid rgba(212, 175, 55, 0.3)"
borderRadius="md"
>
<Box
w="100%"
h="18px"
bg={gradient}
borderRadius="md"
border={`1px solid ${borderColor}`}
display="flex"
alignItems="center"
justifyContent="center"
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: "scale(1.02)",
boxShadow: `0 0 12px ${borderColor}`,
}}
overflow="hidden"
position="relative"
>
{/* 闪光效果 */}
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.3), transparent)"
backgroundSize="200% 100%"
animation={`${shimmer} 3s linear infinite`}
opacity={0.5}
/>
<Text
fontSize="xs"
fontWeight="bold"
color={textColor}
noOfLines={1}
px={2}
position="relative"
zIndex={1}
>
{arg.event.title}
{daysCount > 1 && (
<Text as="span" fontSize="2xs" ml={1} opacity={0.8}>
({daysCount})
</Text>
)}
</Text>
</Box>
</Tooltip>
);
},
[]
);
return (
<Box
height={height}
position="relative"
sx={{
// FullCalendar 深色主题样式
".fc": {
fontFamily: "inherit",
},
".fc-theme-standard": {
bg: "transparent",
},
".fc-theme-standard td, .fc-theme-standard th": {
borderColor: "rgba(212, 175, 55, 0.15)",
},
".fc-theme-standard .fc-scrollgrid": {
borderColor: "rgba(212, 175, 55, 0.2)",
},
// 工具栏
".fc-toolbar": {
marginBottom: "0.5em !important",
},
".fc-toolbar-title": {
fontSize: "0.95rem !important",
fontWeight: "bold !important",
background: "linear-gradient(135deg, #FFD700 0%, #F5E6A3 100%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
},
".fc-button": {
bg: "rgba(212, 175, 55, 0.2) !important",
border: "1px solid rgba(212, 175, 55, 0.4) !important",
color: "#FFD700 !important",
borderRadius: "6px !important",
padding: "4px 8px !important",
fontSize: "12px !important",
transition: "all 0.2s !important",
"&:hover": {
bg: "rgba(212, 175, 55, 0.3) !important",
transform: "scale(1.05)",
},
"&:disabled": {
opacity: 0.5,
},
},
".fc-button-active": {
bg: "rgba(212, 175, 55, 0.4) !important",
},
// 星期头
".fc-col-header": {
bg: "rgba(15, 15, 22, 0.95) !important",
},
".fc-col-header-cell": {
bg: "transparent !important",
py: "6px !important",
borderColor: "rgba(255, 215, 0, 0.1) !important",
},
".fc-col-header-cell-cushion": {
color: "white !important",
fontWeight: "600 !important",
fontSize: "12px",
},
".fc-scrollgrid-section-header": {
bg: "rgba(15, 15, 22, 0.95) !important",
},
".fc-scrollgrid-section-header > td": {
bg: "rgba(15, 15, 22, 0.95) !important",
borderColor: "rgba(255, 215, 0, 0.1) !important",
},
// 日期格子
".fc-daygrid-day": {
bg: "rgba(15, 15, 22, 0.4)",
transition: "all 0.2s",
cursor: "pointer",
"&:hover": {
bg: "rgba(212, 175, 55, 0.1)",
},
},
".fc-daygrid-day.fc-day-today": {
bg: "rgba(212, 175, 55, 0.15) !important",
animation: `${glow} 2s ease-in-out infinite`,
},
".fc-daygrid-day-frame": {
minHeight: "50px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
},
".fc-daygrid-day-top": {
width: "100%",
padding: "0 !important",
flexDirection: "column",
},
// 隐藏 FullCalendar 默认的日期数字链接
".fc-daygrid-day-top a.fc-daygrid-day-number": {
display: "none !important",
},
// 非当月日期
".fc-day-other": {
opacity: 0.4,
},
// 自定义单元格内容样式
".fc-custom-cell-content": {
width: "100%",
},
// 日期内容区域
".fc-daygrid-day-events": {
marginTop: "auto !important",
marginBottom: "0 !important",
paddingBottom: "0 !important",
},
// 事件
".fc-daygrid-event": {
borderRadius: "6px",
border: "none !important",
margin: "0 3px !important",
marginBottom: "0 !important",
},
".fc-event-main": {
padding: "0 !important",
},
".fc-daygrid-event-harness": {
marginTop: "2px",
},
// 更多事件链接
".fc-daygrid-more-link": {
color: "#FFD700 !important",
fontWeight: "600",
fontSize: "11px",
},
}}
>
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
initialDate={currentMonth}
locale="zh-cn"
headerToolbar={{
left: "prev,next today",
center: "title",
right: "",
}}
buttonText={{
today: "今天",
}}
events={events}
dateClick={handleDateClick}
eventClick={handleEventClick}
datesSet={handleDatesSet}
dayCellDidMount={handleDayCellDidMount}
eventContent={eventContent}
dayMaxEvents={3}
moreLinkText={(n) => `+${n} 更多`}
fixedWeekCount={false}
height="auto"
contentHeight="auto"
/>
</Box>
);
};
export default FullCalendarPro;