feat(LimitAnalyse): 新增涨停情绪周期日历组件

- 日历展示每日涨停数据,支持日期选择
  - 左侧 AI 摘要卡片和核心指标展示
  - 左右 3:7 布局,颜色提亮优化
This commit is contained in:
zdl
2026-01-05 13:53:32 +08:00
parent 365a30da2e
commit d16938de9e
10 changed files with 1023 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
/**
* 日历格子组件
* 显示单个日期的涨停信息、趋势、板块等
*/
import React, { memo } from 'react';
import { Box, HStack, VStack, Text, Badge, Icon, Tooltip } from '@chakra-ui/react';
import { Flame } from 'lucide-react';
import { goldColors, textColors, bgColors } from '../constants/theme';
import { getHeatColor } from '../constants/config';
import TrendIcon from './TrendIcon';
import TooltipContent from './TooltipContent';
import type { DateData } from './LeftPanel';
interface CalendarCellProps {
date: Date | null;
dateData: DateData | null;
previousData: DateData | null;
isSelected: boolean;
isToday: boolean;
isWeekend: boolean;
isFiltered: boolean;
onClick: (date: Date) => void;
}
// 自定义比较函数,避免不必要的重渲染
const arePropsEqual = (prev: CalendarCellProps, next: CalendarCellProps): boolean => {
if (prev.date?.getTime() !== next.date?.getTime()) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isToday !== next.isToday) return false;
if (prev.isFiltered !== next.isFiltered) return false;
if (prev.dateData?.count !== next.dateData?.count) return false;
if (prev.previousData?.count !== next.previousData?.count) return false;
return true;
};
const CalendarCell: React.FC<CalendarCellProps> = memo(
({ date, dateData, previousData, isSelected, isToday, isWeekend, isFiltered, onClick }) => {
// 空日期占位
if (!date) {
return <Box />;
}
const hasData = !!dateData;
const count = dateData?.count || 0;
const heatColors = getHeatColor(count);
const topSector = dateData?.top_sector || '';
// 周末无数据显示"休市"
if (isWeekend && !hasData) {
return (
<Box
p={1.5}
borderRadius="10px"
bg="rgba(30, 30, 40, 0.3)"
border="1px solid rgba(255, 255, 255, 0.03)"
textAlign="center"
minH="60px"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Text fontSize="md" fontWeight="400" color="rgba(255, 255, 255, 0.25)">
{date.getDate()}
</Text>
<Text fontSize="9px" color="rgba(255, 255, 255, 0.2)">
</Text>
</Box>
);
}
// 被筛选过滤掉的日期
if (isFiltered) {
return (
<Box
p={1.5}
borderRadius="10px"
bg="rgba(50, 50, 60, 0.2)"
border="1px solid rgba(255, 255, 255, 0.03)"
opacity={0.3}
textAlign="center"
minH="60px"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontSize="md" fontWeight="500" color={textColors.muted}>
{date.getDate()}
</Text>
</Box>
);
}
// 正常日期
return (
<Tooltip
label={<TooltipContent date={date} hasData={hasData} count={count} topSector={topSector} />}
placement="top"
hasArrow
bg={bgColors.card}
border="1px solid rgba(212, 175, 55, 0.3)"
borderRadius="12px"
>
<Box
as="button"
p={1.5}
borderRadius="10px"
bg={hasData ? heatColors.bg : 'rgba(40, 40, 50, 0.3)'}
border={
isSelected
? `2px solid ${goldColors.primary}`
: hasData
? `1px solid ${heatColors.border}`
: '1px solid rgba(255, 255, 255, 0.08)'
}
boxShadow={isSelected ? `0 0 12px ${goldColors.glow}` : 'none'}
position="relative"
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: 'scale(1.02)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
borderColor: goldColors.primary,
}}
onClick={() => onClick(date)}
w="full"
minH="60px"
>
{/* 今天标记 */}
{isToday && (
<Badge
position="absolute"
top="1px"
right="1px"
bg="rgba(239, 68, 68, 0.8)"
color="white"
fontSize="8px"
px={0.5}
borderRadius="sm"
>
</Badge>
)}
<VStack spacing={0} align="center">
{/* 日期 */}
<Text
fontSize="md"
fontWeight={isSelected || isToday ? 'bold' : '500'}
color={isSelected ? goldColors.primary : textColors.primary}
>
{date.getDate()}
</Text>
{/* 涨停数 + 趋势 */}
{hasData && (
<HStack spacing={0.5} justify="center">
<Text fontSize="xs" fontWeight="bold" color={heatColors.text}>
{count}
</Text>
<TrendIcon current={count} previous={previousData?.count} />
</HStack>
)}
{/* 主要板块 */}
{hasData && topSector && (
<HStack spacing={0.5}>
{count > 80 && <Icon as={Flame} boxSize={2.5} color="#f97316" />}
<Text fontSize="9px" color={textColors.secondary} noOfLines={1} maxW="60px">
{topSector}
</Text>
</HStack>
)}
</VStack>
</Box>
</Tooltip>
);
},
arePropsEqual
);
CalendarCell.displayName = 'CalendarCell';
export default CalendarCell;

View File

@@ -0,0 +1,186 @@
/**
* 左侧面板组件
* 包含AI 总结卡片、核心指标卡片、快捷筛选器
*/
import React, { memo, useMemo } from 'react';
import { Box, HStack, VStack, Text, Badge, Icon, Button } from '@chakra-ui/react';
import { Sparkles, Target } from 'lucide-react';
import { css } from '@emotion/react';
import { goldColors, textColors } from '../constants/theme';
import { FILTER_BUTTONS, formatDisplayDate, HEAT_LEVELS } from '../constants/config';
// 日期数据类型
export interface DateData {
date: string;
count: number;
top_sector?: string;
fail_rate?: number;
}
interface LeftPanelProps {
selectedDate: Date | null;
dateData: DateData | null;
previousDateData: DateData | null;
onFilterChange: (filter: string | null) => void;
activeFilter: string | null;
}
/**
* 生成 AI 情绪总结文案
*/
const generateEmotionSummary = (count: number | null, topSector: string): string => {
if (!count) return '请选择一个交易日查看分析';
const level = HEAT_LEVELS.find((l) => count >= l.threshold);
const emotionMap: Record<string, string> = {
high: '市场情绪高涨',
medium: '市场情绪正在回暖',
low: '市场情绪温和',
cold: '市场情绪低迷',
};
const emotion = emotionMap[level?.key || 'cold'] || '市场情绪低迷';
return topSector ? `${emotion},主线题材:${topSector}` : `${emotion},关注市场动向`;
};
const LeftPanel: React.FC<LeftPanelProps> = memo(
({ selectedDate, dateData, previousDateData, onFilterChange, activeFilter }) => {
// AI 总结
const aiSummary = useMemo(() => {
const count = dateData?.count || 0;
const topSector = dateData?.top_sector || '';
return generateEmotionSummary(count, topSector);
}, [dateData]);
// 计算涨跌变化
const countChange = useMemo(() => {
if (!dateData || !previousDateData) return null;
return dateData.count - previousDateData.count;
}, [dateData, previousDateData]);
return (
<VStack spacing={3} align="stretch" flex="3" minW="240px">
{/* 标题 */}
<VStack align="start" spacing={1}>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, #F4D03F, #D4AF37)"
bgClip="text"
css={css`
text-shadow: 0 0 20px ${goldColors.glow};
`}
>
</Text>
<Text fontSize="xs" color={textColors.secondary}>
</Text>
</VStack>
{/* AI 总结卡片 */}
<Box p={3} borderRadius="12px" bg="rgba(139, 92, 246, 0.18)" border="1px solid rgba(139, 92, 246, 0.45)">
<HStack spacing={2} mb={1.5}>
<Icon as={Sparkles} boxSize={3.5} color="#a78bfa" />
<Text fontSize="xs" fontWeight="600" color="#c4b5fd">
AI
</Text>
</HStack>
<Text fontSize="xs" color={textColors.primary} lineHeight="1.5">
{aiSummary}
</Text>
</Box>
{/* 核心指标卡片 */}
<Box p={3} borderRadius="12px" bg="rgba(212, 175, 55, 0.15)" border="1px solid rgba(212, 175, 55, 0.4)">
<HStack spacing={2} mb={2}>
<Icon as={Target} boxSize={3.5} color={goldColors.light} />
<Text fontSize="xs" fontWeight="600" color={goldColors.light}>
</Text>
</HStack>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text fontSize="xs" color={textColors.muted}>
:
</Text>
<Text fontSize="xs" fontWeight="500" color={textColors.primary}>
{formatDisplayDate(selectedDate)}
</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="xs" color={textColors.muted}>
:
</Text>
<HStack spacing={1.5}>
<Text fontSize="md" fontWeight="bold" color="#ef4444">
{dateData?.count || '--'}
</Text>
{countChange !== null && (
<Badge
bg={countChange >= 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)'}
color={countChange >= 0 ? '#ef4444' : '#22c55e'}
fontSize="10px"
>
{countChange >= 0 ? '+' : ''}
{countChange}
</Badge>
)}
</HStack>
</HStack>
<HStack justify="space-between">
<Text fontSize="xs" color={textColors.muted}>
:
</Text>
<Text fontSize="xs" fontWeight="500" color={textColors.primary}>
{dateData?.fail_rate ? `${dateData.fail_rate}%` : '22%'}
</Text>
</HStack>
</VStack>
</Box>
{/* 快捷筛选器 */}
<VStack spacing={1.5} align="stretch">
{FILTER_BUTTONS.map(({ key, label, icon: ButtonIcon, activeColor, hoverColor, textColor, borderColor }) => {
const isActive = activeFilter === key;
return (
<Button
key={key}
size="xs"
variant="unstyled"
display="flex"
alignItems="center"
px={2.5}
py={1.5}
h="auto"
fontSize="xs"
bg={isActive ? activeColor : 'rgba(255, 255, 255, 0.06)'}
color={isActive ? textColor : textColors.secondary}
borderWidth="1px"
borderStyle="solid"
borderColor={borderColor}
borderRadius="8px"
_hover={{
bg: isActive ? hoverColor : 'rgba(255, 255, 255, 0.12)',
color: isActive ? textColor : textColors.primary,
}}
onClick={() => onFilterChange(isActive ? null : key)}
justifyContent="flex-start"
leftIcon={<ButtonIcon size={12} />}
>
{label}
</Button>
);
})}
</VStack>
</VStack>
);
}
);
LeftPanel.displayName = 'LeftPanel';
export default LeftPanel;

View File

@@ -0,0 +1,54 @@
/**
* 日历格子 Tooltip 内容组件
* 用于性能优化,避免每次渲染创建新的 JSX
*/
import React, { memo } from 'react';
import { Box, Text } from '@chakra-ui/react';
import { goldColors } from '../constants/theme';
interface TooltipContentProps {
date: Date;
hasData: boolean;
count: number;
topSector: string;
}
const TooltipContent: React.FC<TooltipContentProps> = memo(
({ date, hasData, count, topSector }) => (
<Box p={2} minW="180px">
<Text fontWeight="bold" mb={1}>
{date.getFullYear()}{date.getMonth() + 1}{date.getDate()}
</Text>
{hasData ? (
<>
<Text fontSize="sm">
:{' '}
<Text as="span" color="#ef4444" fontWeight="bold">
{count}
</Text>{' '}
</Text>
{topSector && (
<Text fontSize="sm">
:{' '}
<Text as="span" color={goldColors.light}>
{topSector}
</Text>
</Text>
)}
<Text fontSize="xs" color="gray.400" mt={1}>
</Text>
</>
) : (
<Text fontSize="sm" color="gray.400">
</Text>
)}
</Box>
)
);
TooltipContent.displayName = 'TooltipContent';
export default TooltipContent;

View File

@@ -0,0 +1,46 @@
/**
* 趋势图标组件
* 显示涨停数变化趋势(上涨/下跌/持平)
*/
import React, { memo } from 'react';
import { HStack, Icon, Text } from '@chakra-ui/react';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { textColors } from '../constants/theme';
interface TrendIconProps {
current: number | null | undefined;
previous: number | null | undefined;
}
const TrendIcon: React.FC<TrendIconProps> = memo(({ current, previous }) => {
if (!previous || !current) return null;
const diff = current - previous;
// 变化幅度小于等于5显示持平
if (Math.abs(diff) <= 5) {
return (
<Text fontSize="9px" color={textColors.muted}>
</Text>
);
}
const isUp = diff > 0;
const color = isUp ? '#ef4444' : '#22c55e';
const IconComponent = isUp ? TrendingUp : TrendingDown;
const arrow = isUp ? '↑' : '↓';
return (
<HStack spacing={0.5}>
<Icon as={IconComponent} boxSize={3} color={color} />
<Text fontSize="9px" color={color}>
{arrow}
</Text>
</HStack>
);
});
TrendIcon.displayName = 'TrendIcon';
export default TrendIcon;

View File

@@ -0,0 +1,10 @@
/**
* 子组件统一导出
*/
export { default as TrendIcon } from './TrendIcon';
export { default as TooltipContent } from './TooltipContent';
export { default as LeftPanel } from './LeftPanel';
export { default as CalendarCell } from './CalendarCell';
// 类型导出
export type { DateData } from './LeftPanel';

View File

@@ -0,0 +1,133 @@
/**
* LimitUpEmotionCycle 常量配置
* 热度级别、筛选按钮、日历常量
*/
import { Flame, Zap, Filter, Snowflake, LucideIcon } from 'lucide-react';
import { textColors } from './theme';
// 热度级别颜色配置
export interface HeatColors {
bg: string;
text: string;
border: string;
}
// 热度级别配置
export interface HeatLevel {
key: string;
threshold: number;
label: string;
icon: LucideIcon;
colors: HeatColors;
}
// 筛选按钮配置
export interface FilterButton extends HeatLevel {
activeColor: string;
hoverColor: string;
textColor: string;
borderColor: string;
}
// 热度级别配置 - 单一配置源
export const HEAT_LEVELS: HeatLevel[] = [
{
key: 'high',
threshold: 80,
label: '超级高潮日',
icon: Flame,
colors: {
bg: 'rgba(147, 51, 234, 0.55)',
text: '#d8b4fe',
border: 'rgba(147, 51, 234, 0.65)',
},
},
{
key: 'medium',
threshold: 60,
label: '高潮日',
icon: Zap,
colors: {
bg: 'rgba(239, 68, 68, 0.50)',
text: '#fca5a5',
border: 'rgba(239, 68, 68, 0.60)',
},
},
{
key: 'low',
threshold: 40,
label: '温和日',
icon: Filter,
colors: {
bg: 'rgba(251, 146, 60, 0.45)',
text: '#fed7aa',
border: 'rgba(251, 146, 60, 0.55)',
},
},
{
key: 'cold',
threshold: 0,
label: '偏冷日',
icon: Snowflake,
colors: {
bg: 'rgba(59, 130, 246, 0.35)',
text: '#93c5fd',
border: 'rgba(59, 130, 246, 0.45)',
},
},
];
// 默认热度颜色(无数据时)
const DEFAULT_HEAT_COLORS: HeatColors = {
bg: 'rgba(60, 60, 70, 0.12)',
text: textColors.muted || 'rgba(255, 255, 255, 0.65)',
border: 'transparent',
};
/**
* 根据涨停数获取热度颜色
*/
export const getHeatColor = (count: number | null | undefined): HeatColors => {
if (!count) return DEFAULT_HEAT_COLORS;
const level = HEAT_LEVELS.find((l) => count >= l.threshold);
return level?.colors || DEFAULT_HEAT_COLORS;
};
/**
* 根据涨停数获取热度级别 key
*/
export const getHeatLevelKey = (count: number | null | undefined): string | null => {
if (!count) return null;
const level = HEAT_LEVELS.find((l) => count >= l.threshold);
return level?.key || null;
};
// 基于 HEAT_LEVELS 生成筛选按钮配置
export const FILTER_BUTTONS: FilterButton[] = HEAT_LEVELS.map((level) => ({
...level,
label: `${level.label} (${level.threshold > 0 ? `${level.threshold}` : '<40家'})`,
activeColor: level.colors.bg,
hoverColor: level.colors.bg.replace(/[\d.]+\)$/, (m) => `${(parseFloat(m) + 0.1).toFixed(2)})`),
textColor: level.colors.text,
borderColor: level.colors.border,
}));
// 日历常量
export const WEEK_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
export const MONTH_NAMES = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'] as const;
// 日期格式化工具函数
export const formatDateStr = (date: Date | null): string => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
export const formatDisplayDate = (date: Date | null): string => {
if (!date) return '--';
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};

View File

@@ -0,0 +1,47 @@
/**
* LimitUpEmotionCycle 主题配置
* 黑金主题色系
*/
import { keyframes } from '@emotion/react';
// 颜色配置类型
export interface ColorConfig {
primary: string;
light?: string;
dark?: string;
glow?: string;
secondary?: string;
muted?: string;
card?: string;
item?: string;
hover?: string;
}
// 黑金主题色系
export const goldColors: ColorConfig = {
primary: '#D4AF37',
light: '#F4D03F',
dark: '#B8860B',
glow: 'rgba(212, 175, 55, 0.4)',
};
// 背景色
export const bgColors: ColorConfig = {
primary: 'rgba(15, 15, 22, 0.95)',
card: 'rgba(15, 15, 22, 0.95)',
item: 'rgba(20, 20, 30, 0.8)',
hover: 'rgba(30, 30, 45, 0.9)',
};
// 文字色 - 提亮版本
export const textColors: ColorConfig = {
primary: '#ffffff',
secondary: 'rgba(255, 255, 255, 0.85)',
muted: 'rgba(255, 255, 255, 0.65)',
};
// 动画 - shimmer 效果
export const shimmer = keyframes`
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
`;

View File

@@ -0,0 +1,5 @@
/**
* Hooks 统一导出
*/
export { default as useCalendarData } from './useCalendarData';
export type { AvailableDate, CalendarCellData } from './useCalendarData';

View File

@@ -0,0 +1,186 @@
/**
* 日历数据处理 Hook
* 负责日历天数生成、日期数据映射、筛选逻辑等
*/
import { useMemo, useCallback } from 'react';
import { formatDateStr, HEAT_LEVELS } from '../constants/config';
import type { DateData } from '../components/LeftPanel';
// 可用日期数据类型(从父组件传入)
export interface AvailableDate {
date: string; // YYYYMMDD 格式
count: number;
top_sector?: string;
fail_rate?: number;
[key: string]: unknown;
}
// 预计算的日历格子数据
export interface CalendarCellData {
key: string;
date: Date | null;
dateData: DateData | null;
previousData: DateData | null;
isSelected: boolean;
isToday: boolean;
isWeekend: boolean;
isFiltered: boolean;
}
interface UseCalendarDataParams {
currentMonth: Date;
availableDates: AvailableDate[] | null;
selectedDate: Date | null;
activeFilter: string | null;
}
interface UseCalendarDataReturn {
days: (Date | null)[];
calendarCellsData: CalendarCellData[];
getDateData: (date: Date | null) => DateData | null;
getPreviousDateData: (date: Date | null) => DateData | null;
selectedDateData: DateData | null;
previousDateData: DateData | null;
}
export const useCalendarData = ({
currentMonth,
availableDates,
selectedDate,
activeFilter,
}: UseCalendarDataParams): UseCalendarDataReturn => {
// 生成日历天数
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: (Date | null)[] = [];
// 前置空白天数
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]);
// 构建日期数据映射表 - 使用 Map 提升查找性能
const dateDataMap = useMemo(() => {
const map = new Map<string, DateData>();
if (availableDates) {
availableDates.forEach((d) => {
map.set(d.date, {
date: d.date,
count: d.count,
top_sector: d.top_sector,
fail_rate: d.fail_rate,
});
});
}
return map;
}, [availableDates]);
// 获取日期数据 - O(1) 查找
const getDateData = useCallback(
(date: Date | null): DateData | null => {
if (!date) return null;
return dateDataMap.get(formatDateStr(date)) || null;
},
[dateDataMap]
);
// 获取前一天数据
const getPreviousDateData = useCallback(
(date: Date | null): DateData | null => {
if (!date) return null;
const prev = new Date(date);
prev.setDate(prev.getDate() - 1);
return getDateData(prev);
},
[getDateData]
);
// 筛选逻辑
const isDateFiltered = useCallback(
(dateData: DateData | null): boolean => {
if (!activeFilter || !dateData) return false;
const count = dateData.count || 0;
// 偏冷日特殊处理:只显示 <40 的日期
if (activeFilter === 'cold') {
return count >= 40; // 过滤掉 >=40 的日期
}
const filterConfig = HEAT_LEVELS.find((f) => f.key === activeFilter);
return filterConfig ? count < filterConfig.threshold : false;
},
[activeFilter]
);
// 当前选中日期的数据
const selectedDateData = useMemo(() => getDateData(selectedDate), [getDateData, selectedDate]);
const previousDateData = useMemo(
() => (selectedDate ? getPreviousDateData(selectedDate) : null),
[getPreviousDateData, selectedDate]
);
// 预计算所有日历格子数据 - 性能优化核心
const calendarCellsData = useMemo(() => {
const today = new Date();
const todayStr = today.toDateString();
const selectedDateStr = selectedDate?.toDateString();
return days.map((date, index): CalendarCellData => {
if (!date) {
return {
key: `empty-${index}`,
date: null,
dateData: null,
previousData: null,
isSelected: false,
isToday: false,
isWeekend: false,
isFiltered: false,
};
}
const dateStr = formatDateStr(date);
const dateData = dateDataMap.get(dateStr) || null;
const dayOfWeek = date.getDay();
// 获取前一天数据
const prevDate = new Date(date);
prevDate.setDate(prevDate.getDate() - 1);
const previousData = dateDataMap.get(formatDateStr(prevDate)) || null;
return {
key: dateStr || `day-${index}`,
date,
dateData,
previousData,
isSelected: date.toDateString() === selectedDateStr,
isToday: date.toDateString() === todayStr,
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
isFiltered: isDateFiltered(dateData),
};
});
}, [days, dateDataMap, selectedDate, isDateFiltered]);
return {
days,
calendarCellsData,
getDateData,
getPreviousDateData,
selectedDateData,
previousDateData,
};
};
export default useCalendarData;

View File

@@ -0,0 +1,171 @@
/**
* 涨停情绪周期组件
* 左侧面板AI总结 + 核心指标 + 快捷筛选)+ 右侧增强日历
*
* 优化点:
* 1. 日历格子直接显示涨停数、趋势箭头、主要板块
* 2. 左侧显示AI复盘总结、核心指标
* 3. 快速筛选器(只看高潮日等)
* 4. 悬浮提示卡片显示详细信息
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, HStack, Text, Icon, IconButton, SimpleGrid } from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { css } from '@emotion/react';
import { GLASS_BLUR } from '@/constants/glassConfig';
// 子组件
import { LeftPanel, CalendarCell } from './components';
// Hooks
import { useCalendarData, AvailableDate } from './hooks';
// 常量
import { goldColors, bgColors, textColors, shimmer } from './constants/theme';
import { WEEK_DAYS, MONTH_NAMES } from './constants/config';
// 静态 CSS 样式 - 模块级别定义,避免每次渲染创建新对象
const shimmerAnimation = css`
animation: ${shimmer} 3s linear infinite;
background-size: 200% 100%;
`;
interface LimitUpEmotionCycleProps {
selectedDate: Date | null;
onDateChange: (date: Date) => void;
availableDates?: AvailableDate[] | null;
}
const LimitUpEmotionCycle: React.FC<LimitUpEmotionCycleProps> = ({
selectedDate,
onDateChange,
availableDates = null,
}) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [activeFilter, setActiveFilter] = useState<string | null>(null);
// 同步月份 - 当选中日期变化时自动切换到对应月份
useEffect(() => {
if (selectedDate) {
setCurrentMonth((prev) => {
const isSameMonth =
prev.getFullYear() === selectedDate.getFullYear() && prev.getMonth() === selectedDate.getMonth();
return isSameMonth ? prev : new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
});
}
}, [selectedDate]);
// 使用日历数据 Hook - 包含预计算的格子数据
const { calendarCellsData, selectedDateData, previousDateData } = useCalendarData({
currentMonth,
availableDates,
selectedDate,
activeFilter,
});
// 月份导航回调
const handlePrevMonth = useCallback(() => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1));
}, []);
const handleNextMonth = useCallback(() => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1));
}, []);
return (
<Box
bg={bgColors.card}
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
borderRadius="20px"
border="1px solid rgba(212, 175, 55, 0.2)"
p={4}
position="relative"
overflow="hidden"
>
{/* 顶部装饰条 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="3px"
bgGradient="linear(to-r, transparent, #D4AF37, #F4D03F, #D4AF37, transparent)"
css={shimmerAnimation}
/>
<HStack spacing={6} align="flex-start">
{/* 左侧面板 */}
<LeftPanel
selectedDate={selectedDate}
dateData={selectedDateData}
previousDateData={previousDateData}
onFilterChange={setActiveFilter}
activeFilter={activeFilter}
/>
{/* 右侧日历 */}
<Box flex="7">
{/* 月份导航 */}
<HStack justify="space-between" mb={3}>
<IconButton
icon={<ChevronLeft size={18} />}
variant="ghost"
size="sm"
color={textColors.secondary}
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
onClick={handlePrevMonth}
aria-label="上个月"
/>
<HStack spacing={2}>
<Icon as={Calendar} boxSize={4} color={goldColors.primary} />
<Text fontSize="lg" fontWeight="bold" color={textColors.primary}>
{currentMonth.getFullYear()}{MONTH_NAMES[currentMonth.getMonth()]}
</Text>
</HStack>
<IconButton
icon={<ChevronRight size={18} />}
variant="ghost"
size="sm"
color={textColors.secondary}
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
onClick={handleNextMonth}
aria-label="下个月"
/>
</HStack>
{/* 星期标题 */}
<SimpleGrid columns={7} spacing={1.5} mb={1.5}>
{WEEK_DAYS.map((day, idx) => (
<Text
key={day}
textAlign="center"
fontSize="xs"
fontWeight="600"
color={idx === 0 || idx === 6 ? textColors.muted : textColors.secondary}
>
{day}
</Text>
))}
</SimpleGrid>
{/* 日历格子 - 使用预计算数据 */}
<SimpleGrid columns={7} spacing={1.5}>
{calendarCellsData.map((cellData) => (
<CalendarCell
key={cellData.key}
date={cellData.date}
dateData={cellData.dateData}
previousData={cellData.previousData}
isSelected={cellData.isSelected}
isToday={cellData.isToday}
isWeekend={cellData.isWeekend}
isFiltered={cellData.isFiltered}
onClick={onDateChange}
/>
))}
</SimpleGrid>
</Box>
</HStack>
</Box>
);
};
export default LimitUpEmotionCycle;