feat(LimitAnalyse): 新增涨停情绪周期日历组件
- 日历展示每日涨停数据,支持日期选择 - 左侧 AI 摘要卡片和核心指标展示 - 左右 3:7 布局,颜色提亮优化
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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}日`;
|
||||
};
|
||||
@@ -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; }
|
||||
`;
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Hooks 统一导出
|
||||
*/
|
||||
export { default as useCalendarData } from './useCalendarData';
|
||||
export type { AvailableDate, CalendarCellData } from './useCalendarData';
|
||||
@@ -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;
|
||||
171
src/views/LimitAnalyse/components/LimitUpEmotionCycle/index.tsx
Normal file
171
src/views/LimitAnalyse/components/LimitUpEmotionCycle/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user