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';
|
2026-02-13 11:14:07 +08:00
|
|
|
|
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
|
2026-01-19 18:09:37 +08:00
|
|
|
|
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';
|
2026-02-13 11:14:07 +08:00
|
|
|
|
import { removeStoryItem } from '../service';
|
2026-02-12 16:55:05 +08:00
|
|
|
|
import TimelineVideo from './TimelineVideo';
|
2026-01-19 18:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 格式化时间数组为易读格式
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
|
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();
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
|
// 动态设置CSS变量以适配主题
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const root = document.documentElement;
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
|
// 根据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]);
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-02-13 11:14:07 +08:00
|
|
|
|
const { data: filesInfo } = useRequest(
|
2026-02-12 16:55:05 +08:00
|
|
|
|
async () => {
|
2026-02-13 11:14:07 +08:00
|
|
|
|
const idsResponse = await queryStoryItemImages(item.instanceId);
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
const ids = idsResponse.data || idsResponse || [];
|
|
|
|
|
|
if (Array.isArray(ids) && ids.length > 0) {
|
|
|
|
|
|
const res = await batchGetFileInfo(ids);
|
|
|
|
|
|
return res.data || [];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
2026-02-12 16:55:05 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
refreshDeps: [item.instanceId],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-01-19 18:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2026-02-13 11:14:07 +08:00
|
|
|
|
const imageCount = filesInfo?.length || 0;
|
2026-01-19 18:09:37 +08:00
|
|
|
|
const descriptionLength = item.description?.length || 0;
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
|
// 根据图片数量和描述长度决定卡片大小
|
|
|
|
|
|
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();
|
2026-02-12 16:55:05 +08:00
|
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
|
// 统一的文本长度 - 根据卡片大小调整
|
|
|
|
|
|
const getDescriptionMaxLength = (size: number) => {
|
|
|
|
|
|
switch (size) {
|
2026-02-12 16:55:05 +08:00
|
|
|
|
case 1:
|
|
|
|
|
|
return 80;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
return 150;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
return 200;
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
return 300;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 100;
|
2026-01-19 18:09:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-02-12 16:55:05 +08:00
|
|
|
|
<article className={`timeline-grid-item size-${cardSize}`} onClick={() => onOpenDetail(item)}>
|
2026-01-19 18:09:37 +08:00
|
|
|
|
{/* 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 - 固定间隔,单行展示,多余折叠 */}
|
2026-02-13 11:14:07 +08:00
|
|
|
|
{item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
|
2026-02-12 16:55:05 +08:00
|
|
|
|
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
2026-02-13 11:14:07 +08:00
|
|
|
|
{item.videoUrl && (
|
|
|
|
|
|
<div style={{ marginBottom: 10 }}>
|
|
|
|
|
|
<TimelineVideo
|
|
|
|
|
|
videoInstanceId={item.videoUrl}
|
|
|
|
|
|
thumbnailInstanceId={item.thumbnailUrl}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{filesInfo && filesInfo.length > 0 && (
|
2026-02-12 16:55:05 +08:00
|
|
|
|
<div className="item-images-row">
|
2026-02-13 11:14:07 +08:00
|
|
|
|
{filesInfo
|
2026-02-12 16:55:05 +08:00
|
|
|
|
.slice(0, 6) // 最多显示6张图片
|
2026-02-13 11:14:07 +08:00
|
|
|
|
.map((file, index) => (
|
|
|
|
|
|
<div key={file.instanceId + index} className="item-image-wrapper">
|
|
|
|
|
|
{file.contentType && file.contentType.startsWith('video') ? (
|
|
|
|
|
|
<TimelineVideo
|
|
|
|
|
|
videoInstanceId={file.instanceId}
|
|
|
|
|
|
thumbnailInstanceId={file.thumbnailInstanceId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<TimelineImage title={file.imageName} imageInstanceId={file.instanceId} />
|
|
|
|
|
|
)}
|
2026-02-12 16:55:05 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-02-13 11:14:07 +08:00
|
|
|
|
{filesInfo.length > 6 && (
|
|
|
|
|
|
<div className="more-images-indicator">+{filesInfo.length - 6}</div>
|
2026-02-12 16:55:05 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-13 11:14:07 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-01-19 18:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Location badge */}
|
2026-02-12 16:55:05 +08:00
|
|
|
|
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
2026-01-19 18:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 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;
|