feat(story): 支持多视频上传与展示
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
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:
@@ -1,8 +1,8 @@
|
|||||||
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
||||||
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
||||||
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
||||||
import { getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
|
import { batchGetFileInfo, getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
|
||||||
import { PlusOutlined, SearchOutlined, VideoCameraOutlined, LoadingOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, SearchOutlined, UploadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import { useRequest } from '@umijs/max';
|
import { useRequest } from '@umijs/max';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -14,13 +14,15 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Input,
|
Input,
|
||||||
InputRef,
|
InputRef,
|
||||||
|
List,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Progress,
|
||||||
Spin,
|
Spin,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
Upload,
|
Upload,
|
||||||
Progress,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
@@ -36,6 +38,18 @@ interface ModalProps {
|
|||||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem';
|
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> = ({
|
const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
visible,
|
visible,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -59,37 +73,74 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const searchInputRef = useRef<InputRef>(null);
|
const searchInputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
// 视频相关状态
|
const [videoList, setVideoList] = useState<VideoItem[]>([]);
|
||||||
const [videoInfo, setVideoInfo] = useState<{
|
|
||||||
videoUrl?: string;
|
|
||||||
duration?: number;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
uploading?: boolean;
|
|
||||||
progress?: number;
|
|
||||||
}>({
|
|
||||||
videoUrl: initialValues?.videoUrl,
|
|
||||||
duration: initialValues?.duration,
|
|
||||||
thumbnailUrl: initialValues?.thumbnailUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(initialValues);
|
const init = async () => {
|
||||||
if (initialValues && option.startsWith('edit')) {
|
if (!initialValues && option !== 'edit') {
|
||||||
form.setFieldsValue({
|
setVideoList([]);
|
||||||
title: initialValues.title,
|
return;
|
||||||
date: initialValues.storyItemTime ? moment(initialValues.storyItemTime) : undefined,
|
}
|
||||||
location: initialValues.location,
|
|
||||||
description: initialValues.description,
|
if (option.startsWith('edit') && initialValues) {
|
||||||
});
|
form.setFieldsValue({
|
||||||
setVideoInfo({
|
title: initialValues.title,
|
||||||
videoUrl: initialValues.videoUrl,
|
date: initialValues.storyItemTime ? moment(initialValues.storyItemTime) : undefined,
|
||||||
duration: initialValues.duration,
|
location: initialValues.location,
|
||||||
thumbnailUrl: initialValues.thumbnailUrl,
|
description: initialValues.description,
|
||||||
});
|
});
|
||||||
} else if (!initialValues) {
|
|
||||||
setVideoInfo({});
|
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]);
|
}, [initialValues, option, visible]);
|
||||||
|
|
||||||
@@ -144,27 +195,47 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVideoUpload = async (options: any) => {
|
const handleVideoSelect = (file: File) => {
|
||||||
const { file, onSuccess, onError, onProgress } = options;
|
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 {
|
try {
|
||||||
// 1. 获取上传 URL
|
const file = item.file;
|
||||||
const fileName = `${Date.now()}-${file.name}`;
|
const fileName = `${Date.now()}-${file.name}`;
|
||||||
const uploadUrlRes = await getUploadUrl(fileName);
|
const uploadUrlRes = await getUploadUrl(fileName);
|
||||||
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
|
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
|
||||||
|
|
||||||
const uploadUrl = uploadUrlRes.data;
|
const uploadUrl = uploadUrlRes.data;
|
||||||
|
|
||||||
// 2. 上传文件到 MinIO
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('PUT', uploadUrl, true);
|
xhr.open('PUT', uploadUrl, true);
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const percent = Math.round((e.loaded / e.total) * 100);
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
setVideoInfo((prev) => ({ ...prev, progress: percent }));
|
setVideoList((prev) =>
|
||||||
onProgress({ percent });
|
prev.map((v) => (v.uid === item.uid ? { ...v, progress: percent } : v)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,86 +248,126 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
xhr.send(file);
|
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({
|
const metaRes = await saveFileMetadata({
|
||||||
imageName: file.name,
|
imageName: file.name,
|
||||||
objectKey: fileName,
|
objectKey: fileName,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
contentType: file.type || 'video/mp4',
|
contentType: file.type || 'video/mp4',
|
||||||
|
thumbnailInstanceId: thumbnailInstanceId,
|
||||||
|
duration: duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (metaRes.code !== 200) throw new Error('保存元数据失败');
|
if (metaRes.code !== 200) throw new Error('保存元数据失败');
|
||||||
const videoInstanceId = metaRes.data;
|
const videoInstanceId = metaRes.data;
|
||||||
|
|
||||||
// 4. 生成缩略图
|
setVideoList((prev) =>
|
||||||
const videoUrl = URL.createObjectURL(file);
|
prev.map((v) =>
|
||||||
const videoEl = document.createElement('video');
|
v.uid === item.uid
|
||||||
videoEl.src = videoUrl;
|
? {
|
||||||
videoEl.currentTime = 1;
|
...v,
|
||||||
videoEl.muted = true;
|
status: 'done',
|
||||||
videoEl.playsInline = true;
|
progress: 100,
|
||||||
|
videoInstanceId: videoInstanceId,
|
||||||
await new Promise((resolve) => {
|
thumbnailInstanceId: thumbnailInstanceId,
|
||||||
videoEl.onloadeddata = () => resolve(true);
|
duration: duration,
|
||||||
videoEl.load();
|
}
|
||||||
});
|
: v,
|
||||||
// Ensure seeked
|
),
|
||||||
videoEl.currentTime = 1;
|
);
|
||||||
await new Promise((r) => (videoEl.onseeked = r));
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
const canvas = document.createElement('canvas');
|
message.error(`${item.name} 上传失败`);
|
||||||
canvas.width = videoEl.videoWidth;
|
setVideoList((prev) => prev.map((v) => (v.uid === item.uid ? { ...v, status: 'error' } : v)));
|
||||||
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('视频上传失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const handleOk = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const location = code2Location(values.location);
|
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 = {
|
const newItem = {
|
||||||
storyItemTime: dayjs(values.date).format('YYYY-MM-DDTHH:mm:ss'),
|
storyItemTime: dayjs(values.date).format('YYYY-MM-DDTHH:mm:ss'),
|
||||||
title: values.title,
|
title: values.title,
|
||||||
@@ -266,12 +377,12 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
storyInstanceId: storyId,
|
storyInstanceId: storyId,
|
||||||
location,
|
location,
|
||||||
instanceId: initialValues?.instanceId,
|
instanceId: initialValues?.instanceId,
|
||||||
// 添加选中的图库图片ID
|
// 添加选中的图库图片ID + 额外视频ID
|
||||||
relatedImageInstanceIds: selectedGalleryImages,
|
relatedImageInstanceIds: relatedIds,
|
||||||
// 视频信息
|
// 视频信息
|
||||||
videoUrl: videoInfo.videoUrl,
|
videoUrl: videoUrl,
|
||||||
duration: videoInfo.duration,
|
duration: duration,
|
||||||
thumbnailUrl: videoInfo.thumbnailUrl,
|
thumbnailUrl: thumbnailUrl,
|
||||||
};
|
};
|
||||||
// 构建 FormData
|
// 构建 FormData
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -524,77 +635,109 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
key: 'video',
|
key: 'video',
|
||||||
label: '上传视频',
|
label: '上传视频',
|
||||||
children: (
|
children: (
|
||||||
<div
|
<div style={{ padding: '10px' }}>
|
||||||
style={{
|
<div style={{ marginBottom: 16 }}>
|
||||||
textAlign: 'center',
|
|
||||||
padding: '20px',
|
|
||||||
border: '1px dashed #d9d9d9',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!videoInfo.videoUrl && !videoInfo.uploading && (
|
|
||||||
<Upload
|
<Upload
|
||||||
beforeUpload={(file) => {
|
beforeUpload={handleVideoSelect}
|
||||||
handleVideoUpload({
|
|
||||||
file,
|
|
||||||
onSuccess: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
onProgress: () => {},
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
|
multiple
|
||||||
>
|
>
|
||||||
<div style={{ cursor: 'pointer' }}>
|
<Button icon={<PlusOutlined />}>选择视频</Button>
|
||||||
<VideoCameraOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
|
|
||||||
<div style={{ marginTop: 8 }}>点击上传视频</div>
|
|
||||||
</div>
|
|
||||||
</Upload>
|
</Upload>
|
||||||
)}
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={handleUploadVideos}
|
||||||
|
disabled={!videoList.some((v) => v.status === 'pending')}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
开始上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{videoInfo.uploading && (
|
<List
|
||||||
<div style={{ width: '80%', margin: '0 auto' }}>
|
grid={{ gutter: 16, column: 3 }}
|
||||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 10 }} />
|
dataSource={videoList}
|
||||||
<div style={{ marginBottom: 10 }}>视频处理中... {videoInfo.progress}%</div>
|
renderItem={(item) => (
|
||||||
<Progress percent={videoInfo.progress} status="active" />
|
<List.Item>
|
||||||
</div>
|
<Card
|
||||||
)}
|
size="small"
|
||||||
|
actions={[
|
||||||
{videoInfo.videoUrl && !videoInfo.uploading && (
|
<DeleteOutlined key="delete" onClick={() => removeVideo(item.uid)} />,
|
||||||
<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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
视频已上传
|
<div
|
||||||
</div>
|
style={{
|
||||||
)}
|
height: 120,
|
||||||
<Button
|
background: '#000',
|
||||||
type="link"
|
display: 'flex',
|
||||||
danger
|
alignItems: 'center',
|
||||||
onClick={() => setVideoInfo({})}
|
justifyContent: 'center',
|
||||||
style={{ display: 'block', margin: '10px auto' }}
|
position: 'relative',
|
||||||
>
|
}}
|
||||||
删除视频
|
>
|
||||||
</Button>
|
{item.status === 'done' ? (
|
||||||
</div>
|
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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// TimelineGridItem.tsx - Grid card layout for timeline items
|
// TimelineGridItem.tsx - Grid card layout for timeline items
|
||||||
import TimelineImage from '@/components/TimelineImage';
|
import TimelineImage from '@/components/TimelineImage';
|
||||||
import { StoryItem } from '@/pages/story/data';
|
import { StoryItem } from '@/pages/story/data';
|
||||||
|
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
|
||||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import { useIntl, useRequest } from '@umijs/max';
|
import { useIntl, useRequest } from '@umijs/max';
|
||||||
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { queryStoryItemImages, removeStoryItem } from '../service';
|
import { removeStoryItem } from '../service';
|
||||||
import TimelineVideo from './TimelineVideo';
|
import TimelineVideo from './TimelineVideo';
|
||||||
|
|
||||||
// 格式化时间数组为易读格式
|
// 格式化时间数组为易读格式
|
||||||
@@ -60,9 +61,16 @@ const TimelineGridItem: React.FC<{
|
|||||||
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const { data: imagesList } = useRequest(
|
const { data: filesInfo } = useRequest(
|
||||||
async () => {
|
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],
|
refreshDeps: [item.instanceId],
|
||||||
@@ -86,7 +94,7 @@ const TimelineGridItem: React.FC<{
|
|||||||
|
|
||||||
// 动态计算卡片大小(1-4格)
|
// 动态计算卡片大小(1-4格)
|
||||||
const calculateCardSize = () => {
|
const calculateCardSize = () => {
|
||||||
const imageCount = imagesList?.length || 0;
|
const imageCount = filesInfo?.length || 0;
|
||||||
const descriptionLength = item.description?.length || 0;
|
const descriptionLength = item.description?.length || 0;
|
||||||
|
|
||||||
// 根据图片数量和描述长度决定卡片大小
|
// 根据图片数量和描述长度决定卡片大小
|
||||||
@@ -175,30 +183,39 @@ const TimelineGridItem: React.FC<{
|
|||||||
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
||||||
|
|
||||||
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
||||||
{item.videoUrl ? (
|
{item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
|
||||||
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
||||||
<TimelineVideo videoInstanceId={item.videoUrl} thumbnailInstanceId={item.thumbnailUrl} />
|
{item.videoUrl && (
|
||||||
</div>
|
<div style={{ marginBottom: 10 }}>
|
||||||
) : (
|
<TimelineVideo
|
||||||
imagesList &&
|
videoInstanceId={item.videoUrl}
|
||||||
imagesList.length > 0 && (
|
thumbnailInstanceId={item.thumbnailUrl}
|
||||||
<div className="item-images-container">
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filesInfo && filesInfo.length > 0 && (
|
||||||
<div className="item-images-row">
|
<div className="item-images-row">
|
||||||
{imagesList
|
{filesInfo
|
||||||
.filter((imageInstanceId) => imageInstanceId && imageInstanceId.trim() !== '')
|
|
||||||
.slice(0, 6) // 最多显示6张图片
|
.slice(0, 6) // 最多显示6张图片
|
||||||
.map((imageInstanceId, index) => (
|
.map((file, index) => (
|
||||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
<div key={file.instanceId + index} className="item-image-wrapper">
|
||||||
<TimelineImage title={imageInstanceId} imageInstanceId={imageInstanceId} />
|
{file.contentType && file.contentType.startsWith('video') ? (
|
||||||
|
<TimelineVideo
|
||||||
|
videoInstanceId={file.instanceId}
|
||||||
|
thumbnailInstanceId={file.thumbnailInstanceId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TimelineImage title={file.imageName} imageInstanceId={file.instanceId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{imagesList.length > 6 && (
|
{filesInfo.length > 6 && (
|
||||||
<div className="more-images-indicator">+{imagesList.length - 6}</div>
|
<div className="more-images-indicator">+{filesInfo.length - 6}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Location badge */}
|
{/* Location badge */}
|
||||||
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
||||||
|
|||||||
@@ -74,3 +74,10 @@ export async function saveFileMetadata(data: any) {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchGetFileInfo(instanceIds: string[]): Promise<CommonResponse<any[]>> {
|
||||||
|
return request<CommonResponse<any[]>>('/file/batch-info', {
|
||||||
|
method: 'POST',
|
||||||
|
data: instanceIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user