Files
vf_react/src/views/Center/components/EventCard.tsx
zdl 18ba36a539 refactor(Center): 全面优化个人中心模块
- 目录重命名: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>
2025-12-22 18:59:09 +08:00

297 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;