community增加事件详情
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
"@fontsource/raleway": "^4.5.0",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"antd": "^5.27.4",
|
||||
"axios": "^1.10.0",
|
||||
|
||||
480
src/components/Calendar/FullCalendarPro.tsx
Normal file
480
src/components/Calendar/FullCalendarPro.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* FullCalendarPro - 炫酷黑金主题日历组件
|
||||
* 支持跨天事件条、动画效果、悬浮提示
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef, useCallback } from 'react';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { EventInput, EventClickArg, DatesSetArg } from '@fullcalendar/core';
|
||||
import { Box, Text, HStack, VStack, Tooltip } from '@chakra-ui/react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { Flame } from 'lucide-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; // 未来事件数
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: '#000' }, // 金色
|
||||
{ bg: 'linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)', border: '#00CED1', text: '#000' }, // 青色
|
||||
{ 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: '#fff' }, // 绿色
|
||||
{ bg: 'linear-gradient(135deg, #F59E0B 0%, #D97706 100%)', border: '#F59E0B', text: '#000' }, // 橙色
|
||||
{ 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: '#fff' }, // 青绿
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
if (isConsecutive && currentEvent) {
|
||||
// 延续当前事件
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* FullCalendarPro 组件
|
||||
*/
|
||||
export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
|
||||
data,
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
onMonthChange,
|
||||
currentMonth,
|
||||
height = '600px',
|
||||
}) => {
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
|
||||
// 将数据转换为事件
|
||||
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]);
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = useCallback((arg: { date: Date; dateStr: string }) => {
|
||||
const dateStr = dayjs(arg.date).format('YYYYMMDD');
|
||||
const dateData = dataMap.get(dateStr);
|
||||
onDateClick?.(arg.date, dateData);
|
||||
}, [dataMap, 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 dayCellContent = useCallback((arg: { date: Date; dayNumberText: string; isToday: boolean }) => {
|
||||
const dateStr = dayjs(arg.date).format('YYYYMMDD');
|
||||
const dateData = dataMap.get(dateStr);
|
||||
const isWeekend = arg.date.getDay() === 0 || arg.date.getDay() === 6;
|
||||
|
||||
return (
|
||||
<Box position="relative" w="100%" h="100%">
|
||||
{/* 日期数字 */}
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight={arg.isToday ? 'bold' : '600'}
|
||||
color={arg.isToday ? '#FFD700' : isWeekend ? 'orange.300' : 'white'}
|
||||
textAlign="center"
|
||||
>
|
||||
{arg.date.getDate()}
|
||||
</Text>
|
||||
|
||||
{/* 涨停数据指示器 */}
|
||||
{dateData && (
|
||||
<HStack
|
||||
spacing={1}
|
||||
justify="center"
|
||||
mt={1}
|
||||
>
|
||||
<Flame size={12} color={dateData.count >= 60 ? '#EF4444' : '#F59E0B'} />
|
||||
<Text fontSize="xs" fontWeight="bold" color={dateData.count >= 60 ? '#EF4444' : '#F59E0B'}>
|
||||
{dateData.count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 未来事件指示 */}
|
||||
{dateData?.eventCount && dateData.eventCount > 0 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="2px"
|
||||
right="2px"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="#22C55E"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}, [dataMap]);
|
||||
|
||||
// 自定义事件内容(跨天条)
|
||||
const eventContent = useCallback((arg: { event: { title: string; extendedProps: Record<string, unknown> } }) => {
|
||||
const { extendedProps } = arg.event;
|
||||
const daysCount = extendedProps.daysCount as number;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack spacing={1} align="start" p={1}>
|
||||
<Text fontWeight="bold">{arg.event.title}</Text>
|
||||
<Text fontSize="xs">连续 {daysCount} 天</Text>
|
||||
<Text fontSize="xs">累计涨停 {extendedProps.totalCount as number} 家</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="22px"
|
||||
bg={extendedProps.gradient as string}
|
||||
borderRadius="md"
|
||||
border={`1px solid ${extendedProps.borderColor}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: `0 0 12px ${extendedProps.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={(extendedProps.textColor as string) || '#fff'}
|
||||
noOfLines={1}
|
||||
px={2}
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
>
|
||||
{arg.event.title}
|
||||
{daysCount > 1 && (
|
||||
<Text as="span" fontSize="10px" 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-title': {
|
||||
fontSize: '1.5rem !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: '8px !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-cell': {
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
py: '12px !important',
|
||||
},
|
||||
'.fc-col-header-cell-cushion': {
|
||||
color: '#FFD700 !important',
|
||||
fontWeight: '600 !important',
|
||||
fontSize: '14px',
|
||||
},
|
||||
// 日期格子
|
||||
'.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: '90px',
|
||||
},
|
||||
'.fc-daygrid-day-top': {
|
||||
justifyContent: 'center',
|
||||
pt: '8px',
|
||||
},
|
||||
'.fc-daygrid-day-number': {
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
'.fc-day-today .fc-daygrid-day-number': {
|
||||
color: '#FFD700 !important',
|
||||
fontWeight: 'bold !important',
|
||||
},
|
||||
// 非当月日期
|
||||
'.fc-day-other': {
|
||||
opacity: 0.4,
|
||||
},
|
||||
// 事件
|
||||
'.fc-daygrid-event': {
|
||||
borderRadius: '6px',
|
||||
border: 'none !important',
|
||||
margin: '2px 4px !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}
|
||||
dayCellContent={dayCellContent}
|
||||
eventContent={eventContent}
|
||||
dayMaxEvents={3}
|
||||
moreLinkText={(n) => `+${n} 更多`}
|
||||
fixedWeekCount={false}
|
||||
height="100%"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullCalendarPro;
|
||||
@@ -11,6 +11,10 @@ export type { BaseCalendarProps, CellRenderInfo } from './BaseCalendar';
|
||||
export { CalendarEventBlock } from './CalendarEventBlock';
|
||||
export type { CalendarEvent, EventType } from './CalendarEventBlock';
|
||||
|
||||
// FullCalendar Pro 组件(支持跨天事件条)
|
||||
export { FullCalendarPro } from './FullCalendarPro';
|
||||
export type { FullCalendarProProps, CalendarEventData } from './FullCalendarPro';
|
||||
|
||||
// 主题配置
|
||||
export {
|
||||
CALENDAR_THEME,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { getApiBase } from '@utils/apiConfig';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import dayjs from 'dayjs';
|
||||
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||
import { FullCalendarPro } from '@components/Calendar';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Text: AntText } = Typography;
|
||||
@@ -2309,7 +2310,7 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
|
||||
};
|
||||
|
||||
/**
|
||||
* 综合日历组件(无左侧面板,点击弹窗)
|
||||
* 综合日历组件 - 使用 FullCalendarPro 实现跨天事件条效果
|
||||
*/
|
||||
const CombinedCalendar = () => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
@@ -2324,7 +2325,6 @@ const CombinedCalendar = () => {
|
||||
const [eventCounts, setEventCounts] = useState([]);
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
@@ -2356,27 +2356,21 @@ const CombinedCalendar = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load event counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadEventCounts();
|
||||
}, [currentMonth]);
|
||||
|
||||
// 获取当月涨停板块详情
|
||||
// 获取涨停板块详情(加载所有数据,不限于当月)
|
||||
useEffect(() => {
|
||||
const loadMonthZtDetails = async () => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const loadZtDetails = async () => {
|
||||
const details = {};
|
||||
const promises = [];
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}${String(month + 1).padStart(2, '0')}${String(day).padStart(2, '0')}`;
|
||||
const hasData = ztDatesData.some(d => d.date === dateStr);
|
||||
if (hasData && !ztDailyDetails[dateStr]) {
|
||||
// 加载所有有数据的日期
|
||||
ztDatesData.forEach(d => {
|
||||
const dateStr = d.date;
|
||||
if (!ztDailyDetails[dateStr]) {
|
||||
promises.push(
|
||||
fetch(`/data/zt/daily/${dateStr}.json`)
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
@@ -2385,10 +2379,8 @@ const CombinedCalendar = () => {
|
||||
// 优先使用词云图最高频词,fallback 到板块数据
|
||||
let topWord = '';
|
||||
if (data.word_freq_data && data.word_freq_data.length > 0) {
|
||||
// word_freq_data 已按频率排序,第一个就是最高频
|
||||
topWord = data.word_freq_data[0].name;
|
||||
} else if (data.sector_data) {
|
||||
// fallback: 使用板块数据中最高的
|
||||
let maxCount = 0;
|
||||
Object.entries(data.sector_data).forEach(([sector, info]) => {
|
||||
if (info.count > maxCount) {
|
||||
@@ -2403,7 +2395,7 @@ const CombinedCalendar = () => {
|
||||
.catch(() => null)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (promises.length > 0) {
|
||||
await Promise.all(promises);
|
||||
@@ -2412,114 +2404,24 @@ const CombinedCalendar = () => {
|
||||
};
|
||||
|
||||
if (ztDatesData.length > 0) {
|
||||
loadMonthZtDetails();
|
||||
loadZtDetails();
|
||||
}
|
||||
}, [currentMonth, ztDatesData]);
|
||||
}, [ztDatesData]);
|
||||
|
||||
// 构建日期数据映射
|
||||
const ztDataMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
ztDatesData.forEach(d => {
|
||||
// 构建 FullCalendarPro 所需的数据格式
|
||||
const calendarData = useMemo(() => {
|
||||
return ztDatesData.map(d => {
|
||||
const detail = ztDailyDetails[d.date] || {};
|
||||
map.set(d.date, {
|
||||
const eventDateStr = `${d.date.slice(0,4)}-${d.date.slice(4,6)}-${d.date.slice(6,8)}`;
|
||||
const eventCount = eventCounts.find(e => e.date === eventDateStr)?.count || 0;
|
||||
return {
|
||||
date: d.date,
|
||||
count: d.count,
|
||||
top_sector: detail.top_sector,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [ztDatesData, ztDailyDetails]);
|
||||
|
||||
const eventCountMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
eventCounts.forEach(d => {
|
||||
map.set(d.date, d.count);
|
||||
});
|
||||
return map;
|
||||
}, [eventCounts]);
|
||||
|
||||
// 生成日历天数
|
||||
const days = useMemo(() => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
result.push(null);
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
result.push(new Date(year, month, i));
|
||||
}
|
||||
return result;
|
||||
}, [currentMonth]);
|
||||
|
||||
// 预计算日历格子数据(含连续概念检测)
|
||||
const calendarCellsData = useMemo(() => {
|
||||
const today = new Date();
|
||||
const todayStr = today.toDateString();
|
||||
const selectedDateStr = selectedDate?.toDateString();
|
||||
|
||||
// 第一遍:构建基础数据
|
||||
const baseData = days.map((date, index) => {
|
||||
if (!date) {
|
||||
return { key: `empty-${index}`, date: null, topSector: null };
|
||||
}
|
||||
|
||||
const ztDateStr = formatDateStr(date);
|
||||
const eventDateStr = dayjs(date).format('YYYY-MM-DD');
|
||||
const ztData = ztDataMap.get(ztDateStr) || null;
|
||||
const eventCount = eventCountMap.get(eventDateStr) || 0;
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
const prevDate = new Date(date);
|
||||
prevDate.setDate(prevDate.getDate() - 1);
|
||||
const previousZtData = ztDataMap.get(formatDateStr(prevDate)) || null;
|
||||
|
||||
return {
|
||||
key: ztDateStr || `day-${index}`,
|
||||
date,
|
||||
ztData,
|
||||
topSector: detail.top_sector || '',
|
||||
eventCount,
|
||||
previousZtData,
|
||||
isSelected: date.toDateString() === selectedDateStr,
|
||||
isToday: date.toDateString() === todayStr,
|
||||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
||||
topSector: ztData?.top_sector || null,
|
||||
dayOfWeek,
|
||||
};
|
||||
});
|
||||
|
||||
// 第二遍:检测连续概念(同一行内,排除周末和跨行)
|
||||
return baseData.map((cellData, index) => {
|
||||
if (!cellData.date || !cellData.topSector) {
|
||||
return { ...cellData, connectLeft: false, connectRight: false };
|
||||
}
|
||||
|
||||
// 检查左边(前一格)
|
||||
let connectLeft = false;
|
||||
if (index > 0 && cellData.dayOfWeek !== 0) { // 不是周日(行首)
|
||||
const prevCell = baseData[index - 1];
|
||||
if (prevCell?.date && prevCell.topSector === cellData.topSector && !prevCell.isWeekend) {
|
||||
connectLeft = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查右边(后一格)
|
||||
let connectRight = false;
|
||||
if (index < baseData.length - 1 && cellData.dayOfWeek !== 6) { // 不是周六(行尾)
|
||||
const nextCell = baseData[index + 1];
|
||||
if (nextCell?.date && nextCell.topSector === cellData.topSector && !nextCell.isWeekend) {
|
||||
connectRight = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...cellData, connectLeft, connectRight };
|
||||
});
|
||||
}, [days, ztDataMap, eventCountMap, selectedDate]);
|
||||
}, [ztDatesData, ztDailyDetails, eventCounts]);
|
||||
|
||||
// 处理日期点击 - 打开弹窗
|
||||
const handleDateClick = useCallback(async (date) => {
|
||||
@@ -2567,13 +2469,9 @@ const CombinedCalendar = () => {
|
||||
setDetailLoading(false);
|
||||
}, [ztDailyDetails]);
|
||||
|
||||
// 月份导航
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() - 1));
|
||||
}, []);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + 1));
|
||||
// 月份变化回调
|
||||
const handleMonthChange = useCallback((year, month) => {
|
||||
setCurrentMonth(new Date(year, month - 1, 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -2599,94 +2497,32 @@ const CombinedCalendar = () => {
|
||||
backgroundSize="200% 100%"
|
||||
/>
|
||||
|
||||
{/* 月份导航 */}
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<IconButton
|
||||
icon={<ChevronLeft size={22} />}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
color={textColors.secondary}
|
||||
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
onClick={handlePrevMonth}
|
||||
aria-label="上个月"
|
||||
/>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={Calendar} boxSize={5} color={goldColors.primary} />
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColors.primary}>
|
||||
{currentMonth.getFullYear()}年{MONTH_NAMES[currentMonth.getMonth()]}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textColors.muted}>
|
||||
综合日历
|
||||
</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<ChevronRight size={22} />}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
color={textColors.secondary}
|
||||
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
onClick={handleNextMonth}
|
||||
aria-label="下个月"
|
||||
/>
|
||||
</HStack>
|
||||
{/* FullCalendar Pro - 炫酷跨天事件条日历 */}
|
||||
<FullCalendarPro
|
||||
data={calendarData}
|
||||
currentMonth={currentMonth}
|
||||
onDateClick={handleDateClick}
|
||||
onMonthChange={handleMonthChange}
|
||||
height="650px"
|
||||
/>
|
||||
|
||||
{/* 星期标题 */}
|
||||
<SimpleGrid columns={7} spacing={2} mb={3}>
|
||||
{WEEK_DAYS.map((day, idx) => (
|
||||
<Text
|
||||
key={day}
|
||||
textAlign="center"
|
||||
fontSize="md"
|
||||
fontWeight="600"
|
||||
color={idx === 0 || idx === 6 ? 'orange.300' : textColors.secondary}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 日历格子 */}
|
||||
{loading ? (
|
||||
<Center h="300px">
|
||||
<Spinner size="xl" color={goldColors.primary} thickness="3px" />
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid columns={7} spacing={2}>
|
||||
{calendarCellsData.map((cellData) => (
|
||||
<CalendarCell
|
||||
key={cellData.key}
|
||||
date={cellData.date}
|
||||
ztData={cellData.ztData}
|
||||
eventCount={cellData.eventCount}
|
||||
previousZtData={cellData.previousZtData}
|
||||
isSelected={cellData.isSelected}
|
||||
isToday={cellData.isToday}
|
||||
isWeekend={cellData.isWeekend}
|
||||
onClick={handleDateClick}
|
||||
connectLeft={cellData.connectLeft}
|
||||
connectRight={cellData.connectRight}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 图例 */}
|
||||
<HStack spacing={5} mt={4} justify="center" flexWrap="wrap">
|
||||
{/* 图例说明 */}
|
||||
<HStack spacing={4} mt={4} justify="center" flexWrap="wrap">
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Flame} boxSize={4} color="purple.400" />
|
||||
<Text fontSize="sm" color={textColors.muted}>涨停≥80</Text>
|
||||
<Box w="20px" h="10px" borderRadius="md" bgGradient="linear(135deg, #FFD700 0%, #FFA500 100%)" />
|
||||
<Text fontSize="xs" color={textColors.muted}>连续热门概念</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Flame} boxSize={4} color="red.400" />
|
||||
<Text fontSize="sm" color={textColors.muted}>涨停≥60</Text>
|
||||
<Icon as={Flame} boxSize={3} color="#EF4444" />
|
||||
<Text fontSize="xs" color={textColors.muted}>涨停≥60</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Flame} boxSize={4} color="orange.400" />
|
||||
<Text fontSize="sm" color={textColors.muted}>涨停≥40</Text>
|
||||
<Icon as={Flame} boxSize={3} color="#F59E0B" />
|
||||
<Text fontSize="xs" color={textColors.muted}>涨停<60</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FileText} boxSize={4} color="#22c55e" />
|
||||
<Text fontSize="sm" color={textColors.muted}>未来事件</Text>
|
||||
<Box w="6px" h="6px" borderRadius="full" bg="#22C55E" />
|
||||
<Text fontSize="xs" color={textColors.muted}>有未来事件</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user