- 目录重命名:Dashboard → Center(匹配路由 /home/center) - 删除遗留代码:Default.js、InvestmentPlansAndReviews.js、InvestmentCalendarChakra.js(共 2596 行) - 创建 src/types/center.ts 类型定义(15+ 接口) - 性能优化: - 创建 useCenterColors Hook 封装 7 个 useColorModeValue - 创建 utils/formatters.ts 提取纯函数 - 修复 loadRealtimeQuotes 的 useCallback 依赖项 - InvestmentPlanningCenter 添加 useMemo 缓存 - TypeScript 迁移:Center.js → Center.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
297 lines
8.2 KiB
TypeScript
297 lines
8.2 KiB
TypeScript
/**
|
||
* EventCard - 统一的投资事件卡片组件
|
||
*
|
||
* 通过 variant 属性控制两种显示模式:
|
||
* - list: 列表模式(EventPanel 中使用,带编辑/删除按钮)
|
||
* - detail: 详情模式(日历弹窗中使用,显示类型徽章)
|
||
*
|
||
* 两种模式都支持:
|
||
* - 标题显示
|
||
* - 描述内容展开/收起
|
||
* - 股票标签显示
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||
import {
|
||
Box,
|
||
Badge,
|
||
IconButton,
|
||
Flex,
|
||
Card,
|
||
CardBody,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
Icon,
|
||
Tag,
|
||
TagLabel,
|
||
TagLeftIcon,
|
||
Button,
|
||
useColorModeValue,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
FiEdit2,
|
||
FiTrash2,
|
||
FiFileText,
|
||
FiCalendar,
|
||
FiTrendingUp,
|
||
FiChevronDown,
|
||
FiChevronUp,
|
||
} from 'react-icons/fi';
|
||
import dayjs from 'dayjs';
|
||
import 'dayjs/locale/zh-cn';
|
||
|
||
import type { InvestmentEvent, EventType, EventSource } from '@/types';
|
||
|
||
dayjs.locale('zh-cn');
|
||
|
||
/**
|
||
* 卡片变体类型
|
||
*/
|
||
export type EventCardVariant = 'list' | 'detail';
|
||
|
||
/**
|
||
* 类型信息接口
|
||
*/
|
||
interface TypeInfo {
|
||
color: string;
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* 获取类型信息
|
||
*/
|
||
const getTypeInfo = (type?: EventType, source?: EventSource): TypeInfo => {
|
||
if (source === 'future') {
|
||
return { color: 'blue', text: '系统事件' };
|
||
}
|
||
if (type === 'plan') {
|
||
return { color: 'purple', text: '我的计划' };
|
||
}
|
||
if (type === 'review') {
|
||
return { color: 'green', text: '我的复盘' };
|
||
}
|
||
return { color: 'gray', text: '未知类型' };
|
||
};
|
||
|
||
/**
|
||
* EventCard Props
|
||
*/
|
||
export interface EventCardProps {
|
||
/** 事件数据 */
|
||
event: InvestmentEvent;
|
||
/** 卡片变体: list(列表模式) | detail(详情模式) */
|
||
variant?: EventCardVariant;
|
||
/** 主题颜色(list 模式) */
|
||
colorScheme?: string;
|
||
/** 显示标签(用于 aria-label) */
|
||
label?: string;
|
||
/** 主要文本颜色 */
|
||
textColor?: string;
|
||
/** 次要文本颜色 */
|
||
secondaryText?: string;
|
||
/** 卡片背景色(list 模式) */
|
||
cardBg?: string;
|
||
/** 边框颜色(detail 模式) */
|
||
borderColor?: string;
|
||
/** 编辑回调(list 模式) */
|
||
onEdit?: (event: InvestmentEvent) => void;
|
||
/** 删除回调(list 模式) */
|
||
onDelete?: (id: number) => void;
|
||
}
|
||
|
||
/** 描述最大显示行数 */
|
||
const MAX_LINES = 3;
|
||
|
||
/**
|
||
* EventCard 组件
|
||
*/
|
||
export const EventCard = memo<EventCardProps>(({
|
||
event,
|
||
variant = 'list',
|
||
colorScheme = 'purple',
|
||
label = '事件',
|
||
textColor,
|
||
secondaryText,
|
||
cardBg,
|
||
borderColor,
|
||
onEdit,
|
||
onDelete,
|
||
}) => {
|
||
// 展开/收起状态
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [isOverflow, setIsOverflow] = useState(false);
|
||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||
|
||
// 默认颜色值(使用 hooks)
|
||
const defaultTextColor = useColorModeValue('gray.700', 'white');
|
||
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
|
||
const defaultCardBg = useColorModeValue('gray.50', 'gray.700');
|
||
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||
|
||
// 使用传入的值或默认值
|
||
const finalTextColor = textColor || defaultTextColor;
|
||
const finalSecondaryText = secondaryText || defaultSecondaryText;
|
||
const finalCardBg = cardBg || defaultCardBg;
|
||
const finalBorderColor = borderColor || defaultBorderColor;
|
||
|
||
// 获取描述内容
|
||
const description = event.description || event.content || '';
|
||
|
||
// 检测描述是否溢出
|
||
useEffect(() => {
|
||
const el = descriptionRef.current;
|
||
if (el && description) {
|
||
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||
const maxHeight = lineHeight * MAX_LINES;
|
||
setIsOverflow(el.scrollHeight > maxHeight + 5);
|
||
} else {
|
||
setIsOverflow(false);
|
||
}
|
||
}, [description]);
|
||
|
||
// 获取类型信息
|
||
const typeInfo = getTypeInfo(event.type, event.source);
|
||
|
||
// 是否为 list 模式
|
||
const isListMode = variant === 'list';
|
||
|
||
// 渲染容器
|
||
const renderContainer = (children: React.ReactNode) => {
|
||
if (isListMode) {
|
||
return (
|
||
<Card
|
||
bg={finalCardBg}
|
||
shadow="sm"
|
||
_hover={{ shadow: 'md' }}
|
||
transition="all 0.2s"
|
||
>
|
||
<CardBody p={{ base: 2, md: 3 }}>
|
||
{children}
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
}
|
||
return (
|
||
<Box
|
||
p={{ base: 3, md: 4 }}
|
||
borderRadius="md"
|
||
border="1px"
|
||
borderColor={finalBorderColor}
|
||
>
|
||
{children}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
return renderContainer(
|
||
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
|
||
{/* 头部区域:标题 + 徽章 + 操作按钮 */}
|
||
<Flex justify="space-between" align="start" gap={{ base: 1, md: 2 }}>
|
||
<VStack align="start" spacing={1} flex={1}>
|
||
{/* 标题行 */}
|
||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
|
||
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
|
||
{event.title}
|
||
</Text>
|
||
{/* detail 模式显示类型徽章 */}
|
||
{!isListMode && (
|
||
<Badge colorScheme={typeInfo.color} variant="subtle" fontSize={{ base: 'xs', md: 'sm' }}>
|
||
{typeInfo.text}
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
|
||
{/* list 模式显示日期 */}
|
||
{isListMode && (
|
||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={finalSecondaryText} />
|
||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
</VStack>
|
||
|
||
{/* list 模式显示编辑/删除按钮 */}
|
||
{isListMode && (onEdit || onDelete) && (
|
||
<HStack spacing={{ base: 0, md: 1 }}>
|
||
{onEdit && (
|
||
<IconButton
|
||
icon={<FiEdit2 />}
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => onEdit(event)}
|
||
aria-label={`编辑${label}`}
|
||
/>
|
||
)}
|
||
{onDelete && (
|
||
<IconButton
|
||
icon={<FiTrash2 />}
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="red"
|
||
onClick={() => onDelete(event.id)}
|
||
aria-label={`删除${label}`}
|
||
/>
|
||
)}
|
||
</HStack>
|
||
)}
|
||
</Flex>
|
||
|
||
{/* 描述内容(可展开/收起) */}
|
||
{description && (
|
||
<Box>
|
||
<Text
|
||
ref={descriptionRef}
|
||
fontSize={{ base: 'xs', md: 'sm' }}
|
||
color={finalTextColor}
|
||
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||
whiteSpace="pre-wrap"
|
||
>
|
||
{description}
|
||
</Text>
|
||
{isOverflow && (
|
||
<Button
|
||
size="xs"
|
||
variant="ghost"
|
||
colorScheme={colorScheme}
|
||
mt={1}
|
||
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
>
|
||
{isExpanded ? '收起' : '展开'}
|
||
</Button>
|
||
)}
|
||
</Box>
|
||
)}
|
||
|
||
{/* 股票标签 */}
|
||
{event.stocks && event.stocks.length > 0 && (
|
||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
|
||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||
相关股票:
|
||
</Text>
|
||
{event.stocks.map((stock, idx) => {
|
||
// 兼容两种格式:对象 {code, name} 或字符串
|
||
const stockCode = typeof stock === 'string' ? stock : stock.code;
|
||
const stockName = typeof stock === 'string' ? stock : stock.name;
|
||
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
|
||
return (
|
||
<Tag key={stockCode || idx} size="sm" colorScheme="blue" variant="subtle">
|
||
<TagLeftIcon as={FiTrendingUp} />
|
||
<TagLabel>{displayText}</TagLabel>
|
||
</Tag>
|
||
);
|
||
})}
|
||
</HStack>
|
||
)}
|
||
</VStack>
|
||
);
|
||
});
|
||
|
||
EventCard.displayName = 'EventCard';
|
||
|
||
export default EventCard;
|