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
|
||||
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')) {
|
||||
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,
|
||||
});
|
||||
setVideoInfo({
|
||||
videoUrl: initialValues.videoUrl,
|
||||
|
||||
const list: VideoItem[] = [];
|
||||
if (initialValues.videoUrl) {
|
||||
list.push({
|
||||
uid: initialValues.videoUrl,
|
||||
status: 'done',
|
||||
progress: 100,
|
||||
videoInstanceId: initialValues.videoUrl,
|
||||
thumbnailInstanceId: initialValues.thumbnailUrl,
|
||||
duration: initialValues.duration,
|
||||
thumbnailUrl: initialValues.thumbnailUrl,
|
||||
name: 'Main Video',
|
||||
});
|
||||
} else if (!initialValues) {
|
||||
setVideoInfo({});
|
||||
}
|
||||
|
||||
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,50 +248,43 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 3. 保存视频元数据到文件服务,获取 instanceId
|
||||
const metaRes = await saveFileMetadata({
|
||||
imageName: file.name,
|
||||
objectKey: fileName,
|
||||
size: file.size,
|
||||
contentType: file.type || 'video/mp4',
|
||||
});
|
||||
|
||||
if (metaRes.code !== 200) throw new Error('保存元数据失败');
|
||||
const videoInstanceId = metaRes.data;
|
||||
|
||||
// 4. 生成缩略图
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
// Thumbnail generation
|
||||
let thumbnailInstanceId = '';
|
||||
let duration = 0;
|
||||
try {
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.src = videoUrl;
|
||||
videoEl.currentTime = 1;
|
||||
videoEl.preload = 'metadata';
|
||||
videoEl.src = item.previewUrl || URL.createObjectURL(file);
|
||||
videoEl.muted = true;
|
||||
videoEl.playsInline = true;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
videoEl.onloadeddata = () => resolve(true);
|
||||
videoEl.load();
|
||||
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);
|
||||
});
|
||||
// Ensure seeked
|
||||
videoEl.currentTime = 1;
|
||||
await new Promise((r) => (videoEl.onseeked = r));
|
||||
|
||||
duration = Math.floor(videoEl.duration || 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoEl.videoWidth;
|
||||
canvas.height = videoEl.videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(videoEl, 0, 0);
|
||||
|
||||
let thumbnailInstanceId = '';
|
||||
|
||||
try {
|
||||
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,
|
||||
@@ -232,31 +296,78 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('缩略图生成失败', e);
|
||||
console.warn('Thumbnail generation failed', e);
|
||||
}
|
||||
|
||||
setVideoInfo({
|
||||
videoUrl: videoInstanceId, // 存储 instanceId
|
||||
duration: Math.floor(videoEl.duration),
|
||||
thumbnailUrl: thumbnailInstanceId, // 存储 instanceId
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
// Metadata
|
||||
const metaRes = await saveFileMetadata({
|
||||
imageName: file.name,
|
||||
objectKey: fileName,
|
||||
size: file.size,
|
||||
contentType: file.type || 'video/mp4',
|
||||
thumbnailInstanceId: thumbnailInstanceId,
|
||||
duration: duration,
|
||||
});
|
||||
|
||||
onSuccess('上传成功');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setVideoInfo((prev) => ({ ...prev, uploading: false }));
|
||||
message.error('视频上传失败');
|
||||
if (metaRes.code !== 200) throw new Error('保存元数据失败');
|
||||
const videoInstanceId = metaRes.data;
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={handleUploadVideos}
|
||||
disabled={!videoList.some((v) => v.status === 'pending')}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
开始上传
|
||||
</Button>
|
||||
</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' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, column: 3 }}
|
||||
dataSource={videoList}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
size="small"
|
||||
actions={[
|
||||
<DeleteOutlined key="delete" onClick={() => removeVideo(item.uid)} />,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 200,
|
||||
height: 120,
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
视频已上传
|
||||
</div>
|
||||
{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
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => setVideoInfo({})}
|
||||
style={{ display: 'block', margin: '10px auto' }}
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
删除视频
|
||||
</Button>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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} />
|
||||
{item.videoUrl && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<TimelineVideo
|
||||
videoInstanceId={item.videoUrl}
|
||||
thumbnailInstanceId={item.thumbnailUrl}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
imagesList &&
|
||||
imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
)}
|
||||
{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>}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user