refactor: EventDetailCard 重命名为 EventCard,支持多变体模式
- 新增 EventCard.tsx 组件,支持 variant 属性(detail/compact) - 删除 EventDetailCard.tsx(功能已合并到 EventCard) - EventDetailModal 改用新的 EventCard 组件
This commit is contained in:
296
src/views/Dashboard/components/EventCard.tsx
Normal file
296
src/views/Dashboard/components/EventCard.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* EventDetailCard - 事件详情卡片组件
|
||||
* 用于日历视图中展示单个事件的详细信息
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Badge,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
/**
|
||||
* EventDetailCard Props
|
||||
*/
|
||||
export interface EventDetailCardProps {
|
||||
/** 事件数据 */
|
||||
event: InvestmentEvent;
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 次要文字颜色 */
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最大显示行数
|
||||
*/
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* EventDetailCard 组件
|
||||
*/
|
||||
export const EventDetailCard: React.FC<EventDetailCardProps> = ({
|
||||
event,
|
||||
borderColor: borderColorProp,
|
||||
secondaryText: secondaryTextProp,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// 默认颜色
|
||||
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const borderColor = borderColorProp || defaultBorderColor;
|
||||
const secondaryText = secondaryTextProp || defaultSecondaryText;
|
||||
|
||||
// 检测内容是否溢出
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (el) {
|
||||
// 计算行高和最大高度
|
||||
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||||
const maxHeight = lineHeight * MAX_LINES;
|
||||
setIsOverflow(el.scrollHeight > maxHeight + 5); // 5px 容差
|
||||
}
|
||||
}, [event.description]);
|
||||
|
||||
// 获取事件类型标签
|
||||
const getEventBadge = () => {
|
||||
if (event.source === 'future') {
|
||||
return <Badge colorScheme="blue" variant="subtle">系统事件</Badge>;
|
||||
} else if (event.type === 'plan') {
|
||||
return <Badge colorScheme="purple" variant="subtle">我的计划</Badge>;
|
||||
} else if (event.type === 'review') {
|
||||
return <Badge colorScheme="green" variant="subtle">我的复盘</Badge>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{/* 标题和标签 */}
|
||||
<Flex justify="space-between" align="start" mb={{ base: 1, md: 2 }} gap={{ base: 1, md: 2 }}>
|
||||
<HStack flexWrap="wrap" flex={1} spacing={{ base: 1, md: 2 }} gap={1}>
|
||||
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
|
||||
{event.title}
|
||||
</Text>
|
||||
{getEventBadge()}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 描述内容 - 支持展开/收起 */}
|
||||
{event.description && (
|
||||
<Box mb={{ base: 1, md: 2 }}>
|
||||
<Text
|
||||
ref={descriptionRef}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
color={secondaryText}
|
||||
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{event.description}
|
||||
</Text>
|
||||
{isOverflow && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
mt={1}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||||
>
|
||||
{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={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue" mb={1}>
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailCard;
|
||||
@@ -8,7 +8,7 @@ import React from 'react';
|
||||
import { Modal, Space } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { EventDetailCard } from './EventDetailCard';
|
||||
import { EventCard } from './EventCard';
|
||||
import { EventEmptyState } from './EventEmptyState';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
@@ -82,9 +82,10 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{events.map((event, idx) => (
|
||||
<EventDetailCard
|
||||
<EventCard
|
||||
key={idx}
|
||||
event={event}
|
||||
variant="detail"
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user