Files
timeline-frontend/src/pages/story/components/TimelineGridItem.tsx

218 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-01-19 18:09:37 +08:00
// TimelineGridItem.tsx - Grid card layout for timeline items
import TimelineImage from '@/components/TimelineImage';
import { StoryItem } from '@/pages/story/data';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { useIntl, useRequest } from '@umijs/max';
import { Button, message, Popconfirm, Tag, theme } from 'antd';
import React, { useEffect } from 'react';
import { queryStoryItemImages, removeStoryItem } from '../service';
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [, , , hour, minute] = time;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
// 如果已经是字符串格式,提取时间部分
const timeStr = String(time);
const timePart = timeStr.split(' ')[1];
if (timePart) {
const [hour, minute] = timePart.split(':');
return `${hour}:${minute}`;
}
return timeStr;
};
const TimelineGridItem: React.FC<{
item: StoryItem;
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
onOpenDetail: (item: StoryItem) => void;
refresh: () => void;
disableEdit?: boolean;
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
const intl = useIntl();
const { token } = theme.useToken();
// 动态设置CSS变量以适配主题
useEffect(() => {
const root = document.documentElement;
// 根据Ant Design的token设置主题变量
root.style.setProperty('--timeline-bg', token.colorBgContainer);
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
root.style.setProperty('--timeline-card-border', token.colorBorder);
root.style.setProperty('--timeline-card-shadow', `0 2px 8px ${token.colorBgMask}`);
root.style.setProperty('--timeline-text-primary', token.colorText);
root.style.setProperty('--timeline-text-secondary', token.colorTextSecondary);
root.style.setProperty('--timeline-text-tertiary', token.colorTextTertiary);
root.style.setProperty('--timeline-header-color', token.colorPrimary);
root.style.setProperty('--timeline-location-bg', `${token.colorSuccess}1A`); // 10% opacity
root.style.setProperty('--timeline-location-border', `${token.colorSuccess}4D`); // 30% opacity
root.style.setProperty('--timeline-location-color', token.colorSuccess);
root.style.setProperty('--timeline-image-border', token.colorBorder);
root.style.setProperty('--timeline-more-bg', token.colorBgMask);
root.style.setProperty('--timeline-more-color', token.colorWhite);
}, [token]);
const { data: imagesList } = useRequest(async () => {
return await queryStoryItemImages(item.instanceId);
}, {
refreshDeps: [item.instanceId]
});
const handleDelete = async () => {
try {
if (!item.instanceId) return;
const response = await removeStoryItem(item.instanceId);
if (response.code === 200) {
message.success(intl.formatMessage({ id: 'story.deleteSuccess' }));
refresh();
} else {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
} catch (error) {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
};
// 动态计算卡片大小1-4格
const calculateCardSize = () => {
const imageCount = imagesList?.length || 0;
const descriptionLength = item.description?.length || 0;
// 根据图片数量和描述长度决定卡片大小
if (imageCount >= 4 || descriptionLength > 200) {
return 4; // 占据整行
} else if (imageCount >= 2 || descriptionLength > 100) {
return 2; // 占据2格
} else if (imageCount >= 1 && descriptionLength > 50) {
return 2; // 有图片且描述较长占据2格
} else {
return 1; // 默认占据1格
}
};
const cardSize = calculateCardSize();
// 统一的文本长度 - 根据卡片大小调整
const getDescriptionMaxLength = (size: number) => {
switch (size) {
case 1: return 80;
case 2: return 150;
case 3: return 200;
case 4: return 300;
default: return 100;
}
};
const descriptionMaxLength = getDescriptionMaxLength(cardSize);
// 截断描述文本
const truncateText = (text: string | undefined, maxLength: number) => {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
// 只返回article元素不包含任何其他元素
return (
<article
className={`timeline-grid-item size-${cardSize}`}
onClick={() => onOpenDetail(item)}
>
{/* Action buttons */}
{!disableEdit && (
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleOption(item, 'edit');
}}
aria-label={intl.formatMessage({ id: 'story.edit' })}
/>
<Popconfirm
title={intl.formatMessage({ id: 'story.deleteConfirm' })}
description={intl.formatMessage({ id: 'story.deleteConfirmDescription' })}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete();
}}
okText={intl.formatMessage({ id: 'story.yes' })}
cancelText={intl.formatMessage({ id: 'story.no' })}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
danger
onClick={(e) => e.stopPropagation()}
aria-label={intl.formatMessage({ id: 'story.delete' })}
/>
</Popconfirm>
</div>
)}
{/* Time header */}
<h3>{formatTimeArray(item.storyItemTime)}</h3>
{/* Title */}
<div className="item-title">{item.title}</div>
{/* Description */}
<p>{truncateText(item.description, descriptionMaxLength)}</p>
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
{imagesList && imagesList.length > 0 && (
<div className="item-images-container">
<div className="item-images-row">
{imagesList
.filter(imageInstanceId => imageInstanceId && imageInstanceId.trim() !== '')
.slice(0, 6) // 最多显示6张图片
.map((imageInstanceId, index) => (
<div key={imageInstanceId + index} className="item-image-wrapper">
<TimelineImage
title={imageInstanceId}
imageInstanceId={imageInstanceId}
/>
</div>
))}
{imagesList.length > 6 && (
<div className="more-images-indicator">
+{imagesList.length - 6}
</div>
)}
</div>
</div>
)}
{/* Location badge */}
{item.location && (
<span className="timeline-location-badge">📍 {item.location}</span>
)}
{/* Creator/Updater tags */}
<div className="item-tags">
{item.createName && (
<Tag color="blue" style={{ fontSize: '11px', padding: '2px 8px' }}>
{item.createName}
</Tag>
)}
{item.updateName && item.updateName !== item.createName && (
<Tag color="green" style={{ fontSize: '11px', padding: '2px 8px' }}>
{item.updateName}
</Tag>
)}
</div>
</article>
);
};
export default TimelineGridItem;