diff --git a/src/pages/story/components/AddTimeLineItemModal.tsx b/src/pages/story/components/AddTimeLineItemModal.tsx index 8023671..6c60f39 100644 --- a/src/pages/story/components/AddTimeLineItemModal.tsx +++ b/src/pages/story/components/AddTimeLineItemModal.tsx @@ -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 = ({ visible, onCancel, @@ -59,37 +73,74 @@ const AddTimeLineItemModal: React.FC = ({ const [searchKeyword, setSearchKeyword] = useState(''); const searchInputRef = useRef(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([]); const videoRef = useRef(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(); + 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 = ({ }, ); - 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 = ({ 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((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((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 = ({ 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 = ({ key: 'video', label: '上传视频', children: ( -
- {!videoInfo.videoUrl && !videoInfo.uploading && ( +
+
{ - handleVideoUpload({ - file, - onSuccess: () => {}, - onError: () => {}, - onProgress: () => {}, - }); - return false; - }} + beforeUpload={handleVideoSelect} showUploadList={false} accept="video/*" + multiple > -
- -
点击上传视频
-
+
- )} + +
- {videoInfo.uploading && ( -
- -
视频处理中... {videoInfo.progress}%
- -
- )} - - {videoInfo.videoUrl && !videoInfo.uploading && ( -
-
视频已就绪
- {videoInfo.thumbnailUrl ? ( - thumbnail - ) : ( -
( + + removeVideo(item.uid)} />, + ]} > - 视频已上传 -
- )} - -
- )} +
+ {item.status === 'done' ? ( + item.thumbnailInstanceId ? ( + thumbnail + ) : ( + + ) + ) : ( +
+
+ {item.name || 'Video'} +
+ + + )} + />
), }, diff --git a/src/pages/story/components/TimelineGridItem.tsx b/src/pages/story/components/TimelineGridItem.tsx index cb702c5..cd070b6 100644 --- a/src/pages/story/components/TimelineGridItem.tsx +++ b/src/pages/story/components/TimelineGridItem.tsx @@ -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<{

{truncateText(item.description, descriptionMaxLength)}

{/* Images preview - 固定间隔,单行展示,多余折叠 */} - {item.videoUrl ? ( + {item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
- -
- ) : ( - imagesList && - imagesList.length > 0 && ( -
+ {item.videoUrl && ( +
+ +
+ )} + {filesInfo && filesInfo.length > 0 && (
- {imagesList - .filter((imageInstanceId) => imageInstanceId && imageInstanceId.trim() !== '') + {filesInfo .slice(0, 6) // 最多显示6张图片 - .map((imageInstanceId, index) => ( -
- + .map((file, index) => ( +
+ {file.contentType && file.contentType.startsWith('video') ? ( + + ) : ( + + )}
))} - {imagesList.length > 6 && ( -
+{imagesList.length - 6}
+ {filesInfo.length > 6 && ( +
+{filesInfo.length - 6}
)}
-
- ) - )} + )} +
+ ) : null} {/* Location badge */} {item.location && 📍 {item.location}} diff --git a/src/services/file/api.ts b/src/services/file/api.ts index 25e8daf..6c9a774 100644 --- a/src/services/file/api.ts +++ b/src/services/file/api.ts @@ -74,3 +74,10 @@ export async function saveFileMetadata(data: any) { data, }); } + +export async function batchGetFileInfo(instanceIds: string[]): Promise> { + return request>('/file/batch-info', { + method: 'POST', + data: instanceIds, + }); +}