feat(story): 支持多视频上传与展示
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit

1. 在文件服务 API 中新增 `batchGetFileInfo` 接口,支持批量获取文件详情。
2. 优化 `AddTimeLineItemModal` 组件,支持多视频选择、预览、批量上传及进度展示。
3. 改进 `TimelineGridItem` 组件,支持在时间轴列表中展示多个视频及对应的缩略图。
4. 增强视频上传流程,自动生成视频首帧作为缩略图并保存元数据。
This commit is contained in:
2026-02-13 11:14:07 +08:00
parent cd752d97d8
commit 5139817b3c
3 changed files with 358 additions and 191 deletions

View File

@@ -1,8 +1,8 @@
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
import { getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
import { PlusOutlined, SearchOutlined, VideoCameraOutlined, LoadingOutlined } from '@ant-design/icons';
import { batchGetFileInfo, getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
import { DeleteOutlined, PlusOutlined, SearchOutlined, UploadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { useRequest } from '@umijs/max';
import {
Button,
@@ -14,13 +14,15 @@ import {
Image,
Input,
InputRef,
List,
message,
Modal,
Pagination,
Progress,
Spin,
Tabs,
Tooltip,
Upload,
Progress,
} from 'antd';
import dayjs from 'dayjs';
import moment from 'moment';
@@ -36,6 +38,18 @@ interface ModalProps {
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem';
}
interface VideoItem {
uid: string;
file?: File;
status: 'pending' | 'uploading' | 'done' | 'error';
progress: number;
videoInstanceId?: string;
thumbnailInstanceId?: string;
duration?: number;
previewUrl?: string;
name?: string;
}
const AddTimeLineItemModal: React.FC<ModalProps> = ({
visible,
onCancel,
@@ -59,37 +73,74 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
const [searchKeyword, setSearchKeyword] = useState('');
const searchInputRef = useRef<InputRef>(null);
// 视频相关状态
const [videoInfo, setVideoInfo] = useState<{
videoUrl?: string;
duration?: number;
thumbnailUrl?: string;
uploading?: boolean;
progress?: number;
}>({
videoUrl: initialValues?.videoUrl,
duration: initialValues?.duration,
thumbnailUrl: initialValues?.thumbnailUrl,
});
const [videoList, setVideoList] = useState<VideoItem[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
console.log(initialValues);
if (initialValues && option.startsWith('edit')) {
form.setFieldsValue({
title: initialValues.title,
date: initialValues.storyItemTime ? moment(initialValues.storyItemTime) : undefined,
location: initialValues.location,
description: initialValues.description,
});
setVideoInfo({
videoUrl: initialValues.videoUrl,
duration: initialValues.duration,
thumbnailUrl: initialValues.thumbnailUrl,
});
} else if (!initialValues) {
setVideoInfo({});
const init = async () => {
if (!initialValues && option !== 'edit') {
setVideoList([]);
return;
}
if (option.startsWith('edit') && initialValues) {
form.setFieldsValue({
title: initialValues.title,
date: initialValues.storyItemTime ? moment(initialValues.storyItemTime) : undefined,
location: initialValues.location,
description: initialValues.description,
});
const list: VideoItem[] = [];
if (initialValues.videoUrl) {
list.push({
uid: initialValues.videoUrl,
status: 'done',
progress: 100,
videoInstanceId: initialValues.videoUrl,
thumbnailInstanceId: initialValues.thumbnailUrl,
duration: initialValues.duration,
name: 'Main Video',
});
}
if (
initialValues.relatedImageInstanceIds &&
initialValues.relatedImageInstanceIds.length > 0
) {
try {
const res = await batchGetFileInfo(initialValues.relatedImageInstanceIds);
if (res.code === 200 && res.data) {
const videoIds = new Set<string>();
res.data.forEach((file: any) => {
if (file.contentType && file.contentType.startsWith('video')) {
list.push({
uid: file.instanceId,
status: 'done',
progress: 100,
videoInstanceId: file.instanceId,
thumbnailInstanceId: file.thumbnailInstanceId,
duration: file.duration,
name: file.imageName,
});
videoIds.add(file.instanceId);
}
});
// Filter out videos from selected gallery images to avoid duplication
setSelectedGalleryImages((prev) => prev.filter((id) => !videoIds.has(id)));
}
} catch (e) {
console.error('Failed to fetch related video info', e);
}
}
setVideoList(list);
} else {
setVideoList([]);
}
};
if (visible) {
init();
}
}, [initialValues, option, visible]);
@@ -144,27 +195,47 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
},
);
const handleVideoUpload = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
const handleVideoSelect = (file: File) => {
const newItem: VideoItem = {
uid: `${Date.now()}-${file.name}`,
file: file,
status: 'pending',
progress: 0,
name: file.name,
previewUrl: URL.createObjectURL(file),
};
setVideoList((prev) => [...prev, newItem]);
return false; // Prevent auto upload
};
setVideoInfo((prev) => ({ ...prev, uploading: true, progress: 0 }));
const removeVideo = (uid: string) => {
setVideoList((prev) => prev.filter((item) => item.uid !== uid));
};
const uploadSingleVideo = async (item: VideoItem) => {
if (!item.file) return;
// Update status to uploading
setVideoList((prev) =>
prev.map((v) => (v.uid === item.uid ? { ...v, status: 'uploading' } : v)),
);
try {
// 1. 获取上传 URL
const file = item.file;
const fileName = `${Date.now()}-${file.name}`;
const uploadUrlRes = await getUploadUrl(fileName);
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
const uploadUrl = uploadUrlRes.data;
// 2. 上传文件到 MinIO
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl, true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
setVideoInfo((prev) => ({ ...prev, progress: percent }));
onProgress({ percent });
setVideoList((prev) =>
prev.map((v) => (v.uid === item.uid ? { ...v, progress: percent } : v)),
);
}
};
@@ -177,86 +248,126 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
xhr.send(file);
});
// 3. 保存视频元数据到文件服务,获取 instanceId
// Thumbnail generation
let thumbnailInstanceId = '';
let duration = 0;
try {
const videoEl = document.createElement('video');
videoEl.preload = 'metadata';
videoEl.src = item.previewUrl || URL.createObjectURL(file);
videoEl.muted = true;
videoEl.playsInline = true;
await new Promise((resolve, reject) => {
videoEl.onloadedmetadata = () => {
videoEl.currentTime = 1; // Try to seek to 1s
};
videoEl.onseeked = () => resolve(true);
videoEl.onerror = reject;
// Timeout to avoid hanging
setTimeout(() => resolve(true), 3000);
});
duration = Math.floor(videoEl.duration || 0);
const canvas = document.createElement('canvas');
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
const thumbnailBlob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, 'image/jpeg', 0.7),
);
if (thumbnailBlob) {
const thumbName = `thumb-${Date.now()}.jpg`;
const thumbUrlRes = await getUploadUrl(thumbName);
if (thumbUrlRes.code === 200) {
await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
const thumbMetaRes = await saveFileMetadata({
imageName: thumbName,
objectKey: thumbName,
size: thumbnailBlob.size,
contentType: 'image/jpeg',
});
if (thumbMetaRes.code === 200) {
thumbnailInstanceId = thumbMetaRes.data;
}
}
}
}
} catch (e) {
console.warn('Thumbnail generation failed', e);
}
// Metadata
const metaRes = await saveFileMetadata({
imageName: file.name,
objectKey: fileName,
size: file.size,
contentType: file.type || 'video/mp4',
thumbnailInstanceId: thumbnailInstanceId,
duration: duration,
});
if (metaRes.code !== 200) throw new Error('保存元数据失败');
const videoInstanceId = metaRes.data;
// 4. 生成缩略图
const videoUrl = URL.createObjectURL(file);
const videoEl = document.createElement('video');
videoEl.src = videoUrl;
videoEl.currentTime = 1;
videoEl.muted = true;
videoEl.playsInline = true;
await new Promise((resolve) => {
videoEl.onloadeddata = () => resolve(true);
videoEl.load();
});
// Ensure seeked
videoEl.currentTime = 1;
await new Promise((r) => (videoEl.onseeked = r));
const canvas = document.createElement('canvas');
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
canvas.getContext('2d')?.drawImage(videoEl, 0, 0);
let thumbnailInstanceId = '';
try {
const thumbnailBlob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, 'image/jpeg', 0.7),
);
if (thumbnailBlob) {
const thumbName = `thumb-${Date.now()}.jpg`;
const thumbUrlRes = await getUploadUrl(thumbName);
if (thumbUrlRes.code === 200) {
await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
// 保存缩略图元数据
const thumbMetaRes = await saveFileMetadata({
imageName: thumbName,
objectKey: thumbName,
size: thumbnailBlob.size,
contentType: 'image/jpeg',
});
if (thumbMetaRes.code === 200) {
thumbnailInstanceId = thumbMetaRes.data;
}
}
}
} catch (e) {
console.warn('缩略图生成失败', e);
}
setVideoInfo({
videoUrl: videoInstanceId, // 存储 instanceId
duration: Math.floor(videoEl.duration),
thumbnailUrl: thumbnailInstanceId, // 存储 instanceId
uploading: false,
progress: 100,
});
onSuccess('上传成功');
} catch (err) {
console.error(err);
onError(err);
setVideoInfo((prev) => ({ ...prev, uploading: false }));
message.error('视频上传失败');
setVideoList((prev) =>
prev.map((v) =>
v.uid === item.uid
? {
...v,
status: 'done',
progress: 100,
videoInstanceId: videoInstanceId,
thumbnailInstanceId: thumbnailInstanceId,
duration: duration,
}
: v,
),
);
} catch (error) {
console.error(error);
message.error(`${item.name} 上传失败`);
setVideoList((prev) => prev.map((v) => (v.uid === item.uid ? { ...v, status: 'error' } : v)));
}
};
const handleUploadVideos = async () => {
const pending = videoList.filter((v) => v.status === 'pending');
if (pending.length === 0) return;
await Promise.all(pending.map((item) => uploadSingleVideo(item)));
message.success('所有任务处理完成');
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const location = code2Location(values.location);
// Process videos
const doneVideos = videoList.filter((v) => v.status === 'done');
let videoUrl: string | undefined = undefined;
let duration: number | undefined = undefined;
let thumbnailUrl: string | undefined = undefined;
let relatedIds = [...selectedGalleryImages];
if (doneVideos.length > 0) {
// First video is main
const mainVideo = doneVideos[0];
videoUrl = mainVideo.videoInstanceId;
duration = mainVideo.duration;
thumbnailUrl = mainVideo.thumbnailInstanceId;
// Rest are related
doneVideos.slice(1).forEach((v) => {
if (v.videoInstanceId) relatedIds.push(v.videoInstanceId);
});
}
const newItem = {
storyItemTime: dayjs(values.date).format('YYYY-MM-DDTHH:mm:ss'),
title: values.title,
@@ -266,12 +377,12 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
storyInstanceId: storyId,
location,
instanceId: initialValues?.instanceId,
// 添加选中的图库图片ID
relatedImageInstanceIds: selectedGalleryImages,
// 添加选中的图库图片ID + 额外视频ID
relatedImageInstanceIds: relatedIds,
// 视频信息
videoUrl: videoInfo.videoUrl,
duration: videoInfo.duration,
thumbnailUrl: videoInfo.thumbnailUrl,
videoUrl: videoUrl,
duration: duration,
thumbnailUrl: thumbnailUrl,
};
// 构建 FormData
const formData = new FormData();
@@ -524,77 +635,109 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
key: 'video',
label: '上传视频',
children: (
<div
style={{
textAlign: 'center',
padding: '20px',
border: '1px dashed #d9d9d9',
borderRadius: '2px',
}}
>
{!videoInfo.videoUrl && !videoInfo.uploading && (
<div style={{ padding: '10px' }}>
<div style={{ marginBottom: 16 }}>
<Upload
beforeUpload={(file) => {
handleVideoUpload({
file,
onSuccess: () => {},
onError: () => {},
onProgress: () => {},
});
return false;
}}
beforeUpload={handleVideoSelect}
showUploadList={false}
accept="video/*"
multiple
>
<div style={{ cursor: 'pointer' }}>
<VideoCameraOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
<div style={{ marginTop: 8 }}></div>
</div>
<Button icon={<PlusOutlined />}></Button>
</Upload>
)}
<Button
type="primary"
icon={<UploadOutlined />}
onClick={handleUploadVideos}
disabled={!videoList.some((v) => v.status === 'pending')}
style={{ marginLeft: 8 }}
>
</Button>
</div>
{videoInfo.uploading && (
<div style={{ width: '80%', margin: '0 auto' }}>
<LoadingOutlined style={{ fontSize: 24, marginBottom: 10 }} />
<div style={{ marginBottom: 10 }}>... {videoInfo.progress}%</div>
<Progress percent={videoInfo.progress} status="active" />
</div>
)}
{videoInfo.videoUrl && !videoInfo.uploading && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ marginBottom: 10, color: '#52c41a' }}></div>
{videoInfo.thumbnailUrl ? (
<img
src={`/api/file/image-low-res/${videoInfo.thumbnailUrl}`}
alt="thumbnail"
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
/>
) : (
<div
style={{
width: 200,
height: 120,
background: '#000',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
<List
grid={{ gutter: 16, column: 3 }}
dataSource={videoList}
renderItem={(item) => (
<List.Item>
<Card
size="small"
actions={[
<DeleteOutlined key="delete" onClick={() => removeVideo(item.uid)} />,
]}
>
</div>
)}
<Button
type="link"
danger
onClick={() => setVideoInfo({})}
style={{ display: 'block', margin: '10px auto' }}
>
</Button>
</div>
)}
<div
style={{
height: 120,
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
{item.status === 'done' ? (
item.thumbnailInstanceId ? (
<img
src={`/api/file/image-low-res/${item.thumbnailInstanceId}`}
alt="thumbnail"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
) : (
<VideoCameraOutlined style={{ fontSize: 32, color: '#fff' }} />
)
) : (
<video
src={item.previewUrl}
style={{ maxWidth: '100%', maxHeight: '100%' }}
muted
/>
)}
{item.status === 'uploading' && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 10,
}}
>
<Progress percent={item.progress} status="active" size="small" />
</div>
)}
{item.status === 'pending' && (
<div style={{ position: 'absolute', top: 5, right: 5 }}>
<Tooltip title="等待上传">
<span style={{ color: '#faad14', fontSize: 20 }}></span>
</Tooltip>
</div>
)}
</div>
<div
style={{
marginTop: 8,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={item.name}
>
{item.name || 'Video'}
</div>
</Card>
</List.Item>
)}
/>
</div>
),
},

View File

@@ -1,11 +1,12 @@
// 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 { queryStoryItemImages, removeStoryItem } from '../service';
import { removeStoryItem } from '../service';
import TimelineVideo from './TimelineVideo';
// 格式化时间数组为易读格式
@@ -60,9 +61,16 @@ const TimelineGridItem: React.FC<{
root.style.setProperty('--timeline-more-color', token.colorWhite);
}, [token]);
const { data: imagesList } = useRequest(
const { data: filesInfo } = useRequest(
async () => {
return await queryStoryItemImages(item.instanceId);
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],
@@ -86,7 +94,7 @@ const TimelineGridItem: React.FC<{
// 动态计算卡片大小1-4格
const calculateCardSize = () => {
const imageCount = imagesList?.length || 0;
const imageCount = filesInfo?.length || 0;
const descriptionLength = item.description?.length || 0;
// 根据图片数量和描述长度决定卡片大小
@@ -175,30 +183,39 @@ const TimelineGridItem: React.FC<{
<p>{truncateText(item.description, descriptionMaxLength)}</p>
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
{item.videoUrl ? (
{item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
<div className="item-images-container" style={{ marginTop: '10px' }}>
<TimelineVideo videoInstanceId={item.videoUrl} thumbnailInstanceId={item.thumbnailUrl} />
</div>
) : (
imagesList &&
imagesList.length > 0 && (
<div className="item-images-container">
{item.videoUrl && (
<div style={{ marginBottom: 10 }}>
<TimelineVideo
videoInstanceId={item.videoUrl}
thumbnailInstanceId={item.thumbnailUrl}
/>
</div>
)}
{filesInfo && filesInfo.length > 0 && (
<div className="item-images-row">
{imagesList
.filter((imageInstanceId) => imageInstanceId && imageInstanceId.trim() !== '')
{filesInfo
.slice(0, 6) // 最多显示6张图片
.map((imageInstanceId, index) => (
<div key={imageInstanceId + index} className="item-image-wrapper">
<TimelineImage title={imageInstanceId} imageInstanceId={imageInstanceId} />
.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>
))}
{imagesList.length > 6 && (
<div className="more-images-indicator">+{imagesList.length - 6}</div>
{filesInfo.length > 6 && (
<div className="more-images-indicator">+{filesInfo.length - 6}</div>
)}
</div>
</div>
)
)}
)}
</div>
) : null}
{/* Location badge */}
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}

View File

@@ -74,3 +74,10 @@ export async function saveFileMetadata(data: any) {
data,
});
}
export async function batchGetFileInfo(instanceIds: string[]): Promise<CommonResponse<any[]>> {
return request<CommonResponse<any[]>>('/file/batch-info', {
method: 'POST',
data: instanceIds,
});
}