diff --git a/package.json b/package.json index 4b4b08a7..e0601b91 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Calendar/FullCalendarPro.tsx b/src/components/Calendar/FullCalendarPro.tsx new file mode 100644 index 00000000..afcfe3ac --- /dev/null +++ b/src/components/Calendar/FullCalendarPro.tsx @@ -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 = {}; +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 = ({ + data, + onDateClick, + onEventClick, + onMonthChange, + currentMonth, + height = '600px', +}) => { + const calendarRef = useRef(null); + + // 将数据转换为事件 + const events = useMemo(() => mergeConsecutiveConcepts(data), [data]); + + // 创建日期数据映射 + const dataMap = useMemo(() => { + const map = new Map(); + 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 ( + + {/* 日期数字 */} + + {arg.date.getDate()} + + + {/* 涨停数据指示器 */} + {dateData && ( + + = 60 ? '#EF4444' : '#F59E0B'} /> + = 60 ? '#EF4444' : '#F59E0B'}> + {dateData.count} + + + )} + + {/* 未来事件指示 */} + {dateData?.eventCount && dateData.eventCount > 0 && ( + + )} + + ); + }, [dataMap]); + + // 自定义事件内容(跨天条) + const eventContent = useCallback((arg: { event: { title: string; extendedProps: Record } }) => { + const { extendedProps } = arg.event; + const daysCount = extendedProps.daysCount as number; + + return ( + + {arg.event.title} + 连续 {daysCount} 天 + 累计涨停 {extendedProps.totalCount as number} 家 + + } + placement="top" + hasArrow + bg="rgba(15, 15, 22, 0.95)" + border="1px solid rgba(212, 175, 55, 0.3)" + borderRadius="md" + > + + {/* 闪光效果 */} + + + {arg.event.title} + {daysCount > 1 && ( + + ({daysCount}天) + + )} + + + + ); + }, []); + + return ( + + `+${n} 更多`} + fixedWeekCount={false} + height="100%" + /> + + ); +}; + +export default FullCalendarPro; diff --git a/src/components/Calendar/index.ts b/src/components/Calendar/index.ts index c6666b58..e16e1c3c 100644 --- a/src/components/Calendar/index.ts +++ b/src/components/Calendar/index.ts @@ -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, diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index e8ebb374..fea6c633 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -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%" /> - {/* 月份导航 */} - - } - variant="ghost" - size="md" - color={textColors.secondary} - _hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }} - onClick={handlePrevMonth} - aria-label="上个月" - /> - - - - {currentMonth.getFullYear()}年{MONTH_NAMES[currentMonth.getMonth()]} - - - 综合日历 - - - } - variant="ghost" - size="md" - color={textColors.secondary} - _hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }} - onClick={handleNextMonth} - aria-label="下个月" - /> - + {/* FullCalendar Pro - 炫酷跨天事件条日历 */} + - {/* 星期标题 */} - - {WEEK_DAYS.map((day, idx) => ( - - {day} - - ))} - - - {/* 日历格子 */} - {loading ? ( -
- -
- ) : ( - - {calendarCellsData.map((cellData) => ( - - ))} - - )} - - {/* 图例 */} - + {/* 图例说明 */} + - - 涨停≥80 + + 连续热门概念 - - 涨停≥60 + + 涨停≥60 - - 涨停≥40 + + 涨停<60 - - 未来事件 + + 有未来事件