Files
timeline-frontend/src/pages/story/components/TimelineGridItem.tsx
jianghao 5139817b3c
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
feat(story): 支持多视频上传与展示
1. 在文件服务 API 中新增 `batchGetFileInfo` 接口,支持批量获取文件详情。
2. 优化 `AddTimeLineItemModal` 组件,支持多视频选择、预览、批量上传及进度展示。
3. 改进 `TimelineGridItem` 组件,支持在时间轴列表中展示多个视频及对应的缩略图。
4. 增强视频上传流程,自动生成视频首帧作为缩略图并保存元数据。
2026-02-13 11:14:07 +08:00

241 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TimelineGridItem.tsx - Grid card layout for timeline items
import TimelineImage from '@/components/TimelineImage';
import { StoryItem } from '@/pages/story/data';
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
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 { removeStoryItem } from '../service';
import TimelineVideo from './TimelineVideo';
// 格式化时间数组为易读格式
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: filesInfo } = useRequest(
async () => {
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 [];
},
{
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 = filesInfo?.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 - 固定间隔,单行展示,多余折叠 */}
{item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
<div className="item-images-container" style={{ marginTop: '10px' }}>
{item.videoUrl && (
<div style={{ marginBottom: 10 }}>
<TimelineVideo
videoInstanceId={item.videoUrl}
thumbnailInstanceId={item.thumbnailUrl}
/>
</div>
)}
{filesInfo && filesInfo.length > 0 && (
<div className="item-images-row">
{filesInfo
.slice(0, 6) // 最多显示6张图片
.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} />
)}
</div>
))}
{filesInfo.length > 6 && (
<div className="more-images-indicator">+{filesInfo.length - 6}</div>
)}
</div>
)}
</div>
) : null}
{/* 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;