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 { Modal, Space } from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
import { EventDetailCard } from './EventDetailCard';
|
import { EventCard } from './EventCard';
|
||||||
import { EventEmptyState } from './EventEmptyState';
|
import { EventEmptyState } from './EventEmptyState';
|
||||||
import type { InvestmentEvent } from '@/types';
|
import type { InvestmentEvent } from '@/types';
|
||||||
|
|
||||||
@@ -82,9 +82,10 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
{events.map((event, idx) => (
|
{events.map((event, idx) => (
|
||||||
<EventDetailCard
|
<EventCard
|
||||||
key={idx}
|
key={idx}
|
||||||
event={event}
|
event={event}
|
||||||
|
variant="detail"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
secondaryText={secondaryText}
|
secondaryText={secondaryText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user