feat(Center): 新增 FUIEventCard 毛玻璃风格事件卡片

- 融合 ReviewCard 的 UI 风格(毛玻璃 + 金色主题)
- 支持编辑、删除、展开描述等功能
- 使用 FUI_THEME 常量统一管理主题色
- 用于复盘列表的高级视觉呈现

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-23 10:52:06 +08:00
parent 429737c111
commit e93d5532bf

View File

@@ -0,0 +1,255 @@
/**
* FUIEventCard - 毛玻璃风格投资事件卡片组件
*
* 融合 ReviewCard 的 UI 风格(毛玻璃 + 金色主题)
* 与 EventCard 的功能(编辑、删除、展开描述)
*
* 用于复盘列表的高级视觉呈现
*/
import React, { useState, useEffect, useRef, memo } from 'react';
import {
Box,
IconButton,
Flex,
VStack,
HStack,
Text,
Tag,
TagLabel,
TagLeftIcon,
Button,
} from '@chakra-ui/react';
import {
FiEdit2,
FiTrash2,
FiCalendar,
FiTrendingUp,
FiChevronDown,
FiChevronUp,
} from 'react-icons/fi';
import { FileText, Heart, Target } from 'lucide-react';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import type { InvestmentEvent } from '@/types';
dayjs.locale('zh-cn');
// 主题颜色常量(与 ReviewCard 保持一致)
const FUI_THEME = {
bg: 'rgba(26, 26, 46, 0.7)',
border: 'rgba(212, 175, 55, 0.15)',
borderHover: 'rgba(212, 175, 55, 0.3)',
text: {
primary: 'rgba(255, 255, 255, 0.95)',
secondary: 'rgba(255, 255, 255, 0.6)',
muted: 'rgba(255, 255, 255, 0.4)',
},
accent: '#D4AF37', // 金色
icon: '#F59E0B', // 橙色图标
};
/**
* FUIEventCard Props
*/
export interface FUIEventCardProps {
/** 事件数据 */
event: InvestmentEvent;
/** 主题颜色 */
colorScheme?: string;
/** 显示标签(用于 aria-label */
label?: string;
/** 编辑回调 */
onEdit?: (event: InvestmentEvent) => void;
/** 删除回调 */
onDelete?: (id: number) => void;
}
/** 描述最大显示行数 */
const MAX_LINES = 3;
/**
* FUIEventCard 组件
*/
export const FUIEventCard = memo<FUIEventCardProps>(({
event,
colorScheme = 'orange',
label = '复盘',
onEdit,
onDelete,
}) => {
// 展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflow, setIsOverflow] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
// 获取描述内容
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]);
return (
<Box
bg={FUI_THEME.bg}
borderRadius="lg"
p={4}
border="1px solid"
borderColor={FUI_THEME.border}
backdropFilter="blur(8px)"
transition="all 0.3s ease"
_hover={{
borderColor: FUI_THEME.borderHover,
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(212, 175, 55, 0.15)',
}}
>
<VStack align="stretch" spacing={3}>
{/* 头部区域:图标 + 标题 + 操作按钮 */}
<Flex justify="space-between" align="start" gap={2}>
<HStack spacing={2} flex={1}>
<Box
as={FileText}
boxSize={4}
color={FUI_THEME.icon}
flexShrink={0}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={FUI_THEME.text.primary}
noOfLines={1}
>
[{event.title}]
</Text>
</HStack>
{/* 编辑/删除按钮 */}
{(onEdit || onDelete) && (
<HStack spacing={0}>
{onEdit && (
<IconButton
icon={<FiEdit2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
onClick={() => onEdit(event)}
aria-label={`编辑${label}`}
/>
)}
{onDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
onClick={() => onDelete(event.id)}
aria-label={`删除${label}`}
/>
)}
</HStack>
)}
</Flex>
{/* 日期行 */}
<HStack spacing={2}>
<FiCalendar size={12} color={FUI_THEME.text.muted} />
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
</Text>
</HStack>
{/* 描述内容(可展开/收起) */}
{description && (
<Box>
<HStack spacing={1} align="start">
<Text color={FUI_THEME.text.muted} fontSize="xs"></Text>
{event.type === 'plan' ? (
<>
<Box as={Target} boxSize={3} color={FUI_THEME.accent} flexShrink={0} mt="2px" />
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
:
</Text>
</>
) : (
<>
<Box as={Heart} boxSize={3} color="#EF4444" flexShrink={0} mt="2px" />
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
:
</Text>
</>
)}
</HStack>
<Text
ref={descriptionRef}
fontSize="xs"
color={FUI_THEME.text.primary}
noOfLines={isExpanded ? undefined : MAX_LINES}
whiteSpace="pre-wrap"
mt={1}
pl={5}
>
{description}
</Text>
{isOverflow && (
<Button
size="xs"
variant="ghost"
color={FUI_THEME.accent}
mt={1}
ml={4}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
{/* 股票标签 */}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap" gap={1}>
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
:
</Text>
{event.stocks.map((stock, idx) => {
const stockCode = typeof stock === 'string' ? stock : stock.code;
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
return (
<Tag
key={stockCode || idx}
size="sm"
bg="rgba(212, 175, 55, 0.1)"
color={FUI_THEME.accent}
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<TagLeftIcon as={FiTrendingUp} boxSize={3} />
<TagLabel fontSize="xs">{displayText}</TagLabel>
</Tag>
);
})}
</HStack>
)}
</VStack>
</Box>
);
});
FUIEventCard.displayName = 'FUIEventCard';
export default FUIEventCard;