diff --git a/apps/web/src/api/assets/index.ts b/apps/web/src/api/assets/index.ts index f9cf446ac..5714db262 100644 --- a/apps/web/src/api/assets/index.ts +++ b/apps/web/src/api/assets/index.ts @@ -43,7 +43,13 @@ export { } from "./assets" // 上传 -export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload" +export { + prepareDirectUpload, + completeDirectUpload, + uploadAssetDirect, + prepareDirectUploadHandle, + type DirectUploadHandle, +} from "./upload" // 任务 export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs" diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index 1875479e5..0efe111ab 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -133,6 +133,12 @@ export interface DirectUploadPrepareResult { expires_at: string fields: Record max_size_bytes: number + /** + * prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。 + * 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。 + * 旧后端不返回该字段,前端降级为无预建卡片的原有行为。 + */ + asset_id?: string } /** 直传完成确认返回 */ diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index 12304db42..e396b5d77 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -27,28 +27,18 @@ export const completeDirectUpload = async (data: { return response.data } -/** 直传上传(大文件推荐),支持可选进度回调 */ -export const uploadAssetDirect = async (data: { - file: File - library_id: string - onProgress?: (percent: number) => void -}): Promise => { - const project = await getOrCreateDefaultProject() +/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */ +const putToOSS = ( + prepared: DirectUploadPrepareResult, + file: File, + onProgress?: (percent: number) => void, +): Promise => + new Promise((resolve, reject) => { + const directForm = new FormData() + Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) + directForm.append("file", file) - const prepared = await prepareDirectUpload({ - project_id: project.id, - library_id: data.library_id, - filename: data.file.name, - content_type: data.file.type || "application/octet-stream", - file_size: data.file.size, - }) - - const directForm = new FormData() - Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) - directForm.append("file", data.file) - - // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 - await new Promise((resolve, reject) => { + // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 const xhr = new XMLHttpRequest() xhr.open(prepared.method, prepared.upload_url) @@ -56,8 +46,8 @@ export const uploadAssetDirect = async (data: { xhr.timeout = 10 * 60 * 1000 xhr.upload.onprogress = (e) => { - if (e.lengthComputable && data.onProgress) { - data.onProgress(Math.round((e.loaded / e.total) * 100)) + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)) } } xhr.onload = () => { @@ -102,9 +92,53 @@ export const uploadAssetDirect = async (data: { xhr.send(directForm) }) - return completeDirectUpload({ +/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */ +export interface DirectUploadHandle { + /** prepare 返回(含可能的预建 asset_id) */ + prepared: DirectUploadPrepareResult + /** 直传 OSS(可重复调用用于重试) */ + transfer: (onProgress?: (percent: number) => void) => Promise + /** 直传完成后调用 complete 确认入库 */ + complete: () => Promise +} + +/** + * 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset), + * 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。 + */ +export const prepareDirectUploadHandle = async (data: { + file: File + library_id: string +}): Promise => { + const project = await getOrCreateDefaultProject() + + const prepared = await prepareDirectUpload({ project_id: project.id, library_id: data.library_id, - storage_key: prepared.storage_key, + filename: data.file.name, + content_type: data.file.type || "application/octet-stream", + file_size: data.file.size, }) + + return { + prepared, + transfer: (onProgress) => putToOSS(prepared, data.file, onProgress), + complete: () => + completeDirectUpload({ + project_id: project.id, + library_id: data.library_id, + storage_key: prepared.storage_key, + }), + } +} + +/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */ +export const uploadAssetDirect = async (data: { + file: File + library_id: string + onProgress?: (percent: number) => void +}): Promise => { + const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id }) + await handle.transfer(data.onProgress) + return handle.complete() } diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index d327dd0bf..17e379623 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -20,6 +20,7 @@ import LibrarySidebar from "@/pages/assets/components/LibrarySidebar" import AssetFilterBar from "@/pages/assets/components/AssetFilterBar" import BatchOperationBar from "@/pages/assets/components/BatchOperationBar" import AssetUploadZone from "@/pages/assets/components/AssetUploadZone" +import UploadQueuePanel from "@/pages/assets/components/UploadQueuePanel" import AssetGridSection from "@/pages/assets/components/AssetGridSection" import AssetModals from "@/pages/assets/components/AssetModals" import { useAssetsData } from "@/pages/assets/hooks/useAssetsData" @@ -69,7 +70,30 @@ const AssetLibrary: React.FC = () => { }) /* ── 上传 ── */ - const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId }) + const { + uploadItems, + enqueueUploads, + retryUpload, + removeUpload, + clearFinished, + uploading, + activeCount, + pendingCount, + } = useAssetUpload({ effectiveLibId }) + + /* ── 上传中 asset_id → 进度/状态映射,合并进网格卡片展示真实进度 ── */ + const uploadProgressMap = React.useMemo(() => { + const map = new Map() + for (const it of uploadItems) { + if (it.assetId && (it.status === "uploading" || it.status === "ingesting")) { + map.set(it.assetId, { + progress: it.status === "ingesting" ? 100 : it.progress, + uploading: it.status === "uploading", + }) + } + } + return map + }, [uploadItems]) /* ── 选中态管理 ── */ const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({ @@ -144,8 +168,17 @@ const AssetLibrary: React.FC = () => { {/* 上传区域 */} + + {/* 上传队列:独立进度 + 失败重试/移除 */} + {/* 筛选栏 */} @@ -180,6 +213,7 @@ const AssetLibrary: React.FC = () => { assets={filteredAssets} selectedIds={selectedIds} diagnosingId={diagnosingId} + uploadProgressMap={uploadProgressMap} onRetry={refetchAssets} onToggleSelect={toggleSelect} onDiagnose={handleDiagnose} @@ -191,8 +225,6 @@ const AssetLibrary: React.FC = () => { {/* ─── 弹窗集合 ─── */} setCreateModalOpen(false)} onCreateModalOk={handleCreateLibrary} diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 8c8f45876..c76ff4bc2 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -700,41 +700,126 @@ } } -/* ─── 上传进度弹窗 ─── */ -.xx-upload-progress-modal .ant-modal-content { - padding: 24px 16px 20px; - border-radius: 16px; +/* ─── 上传队列面板 ─── */ +.xx-upload-queue { + margin-top: 12px; + border: 1px solid var(--border-primary, #e5e7eb); + border-radius: 12px; + background: #fff; + overflow: hidden; } -.xx-upload-progress-body { +.xx-upload-queue-header { display: flex; - flex-direction: column; + justify-content: space-between; align-items: center; - gap: 16px; - padding: 8px 0; + padding: 10px 14px; + border-bottom: 1px solid var(--border-primary, #eef2f7); + background: #f8fafc; } -.xx-upload-progress-ring { - display: block; -} - -.xx-upload-progress-text { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.xx-upload-progress-pct { - font-size: 22px; - font-weight: 700; - color: var(--primary-color, #6366f1); - line-height: 1; -} - -.xx-upload-progress-label { +.xx-upload-queue-title { font-size: 13px; - color: var(--text-secondary, #6b7280); + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.xx-upload-queue-list { + max-height: 240px; + overflow-y: auto; +} + +.xx-upload-queue-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; +} + +.xx-upload-queue-item + .xx-upload-queue-item { + border-top: 1px solid var(--border-primary, #f1f5f9); +} + +.xx-upload-queue-icon { + padding-top: 2px; + font-size: 15px; +} + +.xx-upload-queue-body { + flex: 1; + min-width: 0; +} + +.xx-upload-queue-name { + font-size: 13px; + color: var(--text-primary, #1e293b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.xx-upload-queue-progress { + margin-top: 6px; + height: 4px; + border-radius: 2px; + background: var(--border-primary, #e5e7eb); + overflow: hidden; +} + +.xx-upload-queue-progress-bar { + height: 100%; + border-radius: 2px; + background: var(--primary-color, #6366f1); + transition: width 0.25s ease; +} + +.xx-upload-queue-status { + margin-top: 4px; + font-size: 12px; + color: var(--text-tertiary, #94a3b8); +} + +.xx-upload-queue-error .xx-upload-queue-status { + color: #ef4444; +} + +.xx-upload-queue-actions { + display: flex; + gap: 6px; +} + +.xx-upload-queue-btn { + border: none; + background: none; + cursor: pointer; + font-size: 13px; + color: var(--text-tertiary, #94a3b8); + padding: 2px 4px; +} + +.xx-upload-queue-btn:hover { + color: var(--primary-color, #6366f1); +} + +/* ─── 素材卡片上传中遮罩进度条 ─── */ +.xx-asset-thumb-uploading { + flex-direction: column; + gap: 8px; +} + +.xx-asset-upload-bar { + width: 70%; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.35); + overflow: hidden; +} + +.xx-asset-upload-bar-inner { + height: 100%; + border-radius: 2px; + background: #fff; + transition: width 0.25s ease; } /* ─── 批量打标签弹窗 ─── */ diff --git a/apps/web/src/pages/assets/components/AssetCard.tsx b/apps/web/src/pages/assets/components/AssetCard.tsx index 22ab9c0fe..a89293330 100644 --- a/apps/web/src/pages/assets/components/AssetCard.tsx +++ b/apps/web/src/pages/assets/components/AssetCard.tsx @@ -20,6 +20,8 @@ export interface AssetCardProps { asset: AssetItem selected: boolean diagnosing?: boolean + /** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */ + uploadProgress?: { progress: number; uploading: boolean } onToggle: () => void onDiagnose: () => void onPlay: () => void @@ -30,11 +32,13 @@ const AssetCard: React.FC = ({ asset, selected, diagnosing, + uploadProgress, onToggle, onDiagnose, onPlay, onDelete, }) => { + const isUploading = !!uploadProgress?.uploading // 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null const usageBadge = getUsageBadge(asset) return ( @@ -49,11 +53,25 @@ const AssetCard: React.FC = ({ )} - {/* 处理中遮罩 */} - {asset.loading && ( + {/* 上传中遮罩:真实进度百分比 + 进度条 */} + {isUploading && ( +
+ + 上传中 {uploadProgress?.progress ?? 0}% +
+
+
+
+ )} + + {/* 转码/处理中遮罩 */} + {asset.loading && !isUploading && (
- 处理中 + 转码处理中
)} diff --git a/apps/web/src/pages/assets/components/AssetGridSection.tsx b/apps/web/src/pages/assets/components/AssetGridSection.tsx index 81f206330..c88307734 100644 --- a/apps/web/src/pages/assets/components/AssetGridSection.tsx +++ b/apps/web/src/pages/assets/components/AssetGridSection.tsx @@ -8,6 +8,9 @@ import type { AssetItem } from "../types" import AssetCard from "./AssetCard" import { SkeletonCard } from "./AssetSkeleton" +/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */ +export type UploadProgressMap = Map + export interface AssetGridSectionProps { loading: boolean error: boolean @@ -15,6 +18,7 @@ export interface AssetGridSectionProps { assets: AssetItem[] selectedIds: Set diagnosingId: string | null + uploadProgressMap?: UploadProgressMap onRetry?: () => void onToggleSelect: (id: string) => void onDiagnose: (asset: AssetItem) => void @@ -29,6 +33,7 @@ export const AssetGridSection: React.FC = ({ assets, selectedIds, diagnosingId, + uploadProgressMap, onRetry, onToggleSelect, onDiagnose, @@ -70,6 +75,7 @@ export const AssetGridSection: React.FC = ({ asset={asset} selected={selectedIds.has(asset.id)} diagnosing={diagnosingId === asset.id} + uploadProgress={uploadProgressMap?.get(asset.id)} onToggle={() => onToggleSelect(asset.id)} onDiagnose={() => onDiagnose(asset)} onPlay={() => onPlay(asset)} diff --git a/apps/web/src/pages/assets/components/AssetModals.tsx b/apps/web/src/pages/assets/components/AssetModals.tsx index 3fb4e7314..9a89859df 100644 --- a/apps/web/src/pages/assets/components/AssetModals.tsx +++ b/apps/web/src/pages/assets/components/AssetModals.tsx @@ -11,12 +11,9 @@ import BatchTagModal from "./BatchTagModal" import BatchClassifyModal from "./BatchClassifyModal" import BatchMarkModal from "./BatchMarkModal" import ResultDrawer from "./ResultDrawer" -import UploadProgressModal from "./UploadProgressModal" export interface AssetModalsProps { /* 上传进度 */ - uploading: boolean - uploadProgress: number /* 新建视频库 */ createModalOpen: boolean @@ -68,8 +65,6 @@ export interface AssetModalsProps { } export const AssetModals: React.FC = ({ - uploading, - uploadProgress, createModalOpen, onCreateModalCancel, onCreateModalOk, @@ -109,9 +104,6 @@ export const AssetModals: React.FC = ({ }) => { return ( <> - {/* 上传进度弹窗 */} - - {/* 新建视频库弹窗 */} void + activeCount: number + pendingCount: number + onUpload: (files: File[]) => void } -export const AssetUploadZone: React.FC = ({ uploading, onUpload }) => { +export const AssetUploadZone: React.FC = ({ + uploading, + activeCount, + pendingCount, + onUpload, +}) => { return ( { - onUpload(file as File) + // antd 多选时会对每个文件同步连续触发一次 beforeUpload; + // 每次只入队当前文件,React 批处理保证多文件一次性渲染 + onUpload([file as File]) return false }} showUploadList={false} @@ -27,9 +36,11 @@ export const AssetUploadZone: React.FC = ({ uploading, onU

- {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"} + {uploading + ? `上传中…(进行 ${activeCount} 个${pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""})` + : "点击或拖拽文件到此区域上传"}

-

支持视频、图片,单文件不超过 2GB

+

支持视频、图片,单文件不超过 2GB;多文件自动排队上传

) diff --git a/apps/web/src/pages/assets/components/UploadProgressModal.tsx b/apps/web/src/pages/assets/components/UploadProgressModal.tsx deleted file mode 100644 index bbb2db574..000000000 --- a/apps/web/src/pages/assets/components/UploadProgressModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react" -import { Modal as AntModal } from "antd" - -/* ============================================================ - * UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比) - * ============================================================ */ -export interface UploadProgressModalProps { - open: boolean - progress: number -} - -const UploadProgressModal: React.FC = ({ open, progress }) => ( - -
- - {/* 背景圆环 */} - - {/* 进度圆弧 */} - - -
- {progress}% - 上传中… -
-
-
-) - -export default UploadProgressModal diff --git a/apps/web/src/pages/assets/components/UploadQueuePanel.tsx b/apps/web/src/pages/assets/components/UploadQueuePanel.tsx new file mode 100644 index 000000000..1dead1405 --- /dev/null +++ b/apps/web/src/pages/assets/components/UploadQueuePanel.tsx @@ -0,0 +1,117 @@ +/** + * 上传队列面板 + * 展示批量上传中每个文件的独立状态/进度;失败可重试、可移除、可清空已完成。 + * 上传中的素材卡片同时也会出现在素材网格(后端 prepare 预建 asset), + * 此面板用于展示真实传输进度与失败重试入口。 + */ +import React from "react" +import { + LoadingOutlined, + CheckCircleFilled, + CloseCircleFilled, + ReloadOutlined, + CloseOutlined, +} from "@ant-design/icons" +import type { UploadItem } from "../hooks/useAssetUpload" + +export interface UploadQueuePanelProps { + items: UploadItem[] + onRetry: (tempId: string) => void + onRemove: (tempId: string) => void + onClearFinished: () => void +} + +const STATUS_TEXT: Record = { + preparing: "排队中…", + uploading: "上传中", + ingesting: "转码中…", + done: "已完成", + error: "上传失败", +} + +const UploadQueuePanel: React.FC = ({ + items, + onRetry, + onRemove, + onClearFinished, +}) => { + if (items.length === 0) return null + const finishedCount = items.filter((it) => it.status === "done").length + + return ( +
+
+ + 上传任务({items.length} + {finishedCount > 0 ? `,已完成 ${finishedCount}` : ""}) + + {finishedCount > 0 && ( + + )} +
+
+ {items.map((it) => { + const isActive = it.status === "preparing" || it.status === "uploading" + const showProgress = it.status === "uploading" || it.status === "ingesting" + return ( +
+ + {it.status === "done" || it.duplicated ? ( + + ) : it.status === "error" ? ( + + ) : ( + + )} + +
+
+ {it.fileName} +
+ {showProgress ? ( +
+
+
+ ) : null} +
+ {it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]} + {it.status === "uploading" ? ` ${it.progress}%` : ""} + {it.status === "error" && it.error ? `:${it.error}` : ""} +
+
+ + {it.status === "error" && ( + + )} + {(it.status === "error" || it.status === "done") && !isActive && ( + + )} + +
+ ) + })} +
+
+ ) +} + +export default UploadQueuePanel diff --git a/apps/web/src/pages/assets/hooks/useAssetUpload.ts b/apps/web/src/pages/assets/hooks/useAssetUpload.ts index 60e2a3267..36d128c2a 100644 --- a/apps/web/src/pages/assets/hooks/useAssetUpload.ts +++ b/apps/web/src/pages/assets/hooks/useAssetUpload.ts @@ -1,65 +1,193 @@ -import { useState, useCallback } from "react" +import { useState, useCallback, useRef, useEffect } from "react" import { useQueryClient } from "@tanstack/react-query" import { message } from "antd" -import { uploadAssetDirect } from "@/api/assets" -import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants" +import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets" +import { MAX_FILE_SIZE } from "../constants" -/** - * 素材上传 Hook - * 封装上传状态、进度管理和上传逻辑 - */ -interface UseAssetUploadProps { - effectiveLibId: string +/** 单文件上传状态机 */ +export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error" + +export interface UploadItem { + /** 前端临时 id(prepare 前无 asset_id 时用) */ + tempId: string + file: File + fileName: string + /** 进度 0~100(仅直传阶段有真实进度) */ + progress: number + status: UploadItemStatus + /** 后端 prepare 预建的 asset id(旧后端可能为空) */ + assetId?: string + /** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */ + duplicated?: boolean + error?: string } -export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) { +/** 批量直传最大并发数,避免多文件瓜分上行带宽 */ +const MAX_CONCURRENT = 3 + +/** + * 素材批量上传 Hook + * - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表 + * - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态 + * - complete 后素材进入转码(ingesting/processing),由列表轮询反映 + * - 失败卡片支持重试/移除 + */ +export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { const queryClient = useQueryClient() - const [uploading, setUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) + const [items, setItems] = useState([]) + const itemsRef = useRef([]) + itemsRef.current = items - const handleUpload = useCallback( - async (file: File) => { - if (file.size > MAX_FILE_SIZE) { - message.error(`文件 "${file.name}" 超过 2GB 限制`) - return + const updateItem = useCallback((tempId: string, patch: Partial) => { + setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it))) + }, []) + + /** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */ + const refreshList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + }, [queryClient]) + + /** 执行单个文件的完整上传流程(prepare→transfer→complete) */ + const runUpload = useCallback( + async (item: UploadItem, handle?: DirectUploadHandle) => { + try { + // 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳) + const h = + handle ?? + (await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId })) + if (h.prepared.asset_id) { + updateItem(item.tempId, { + status: "uploading", + assetId: h.prepared.asset_id, + progress: 0, + }) + // 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格 + refreshList() + } else { + updateItem(item.tempId, { status: "uploading", progress: 0 }) + } + + // 2. OSS 直传(真实进度) + await h.transfer((pct) => updateItem(item.tempId, { progress: pct })) + + // 3. complete:后端创建 ingest job,素材进入转码 + updateItem(item.tempId, { status: "ingesting", progress: 100 }) + const result = await h.complete() + refreshList() + + if (result.duplicated) { + updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id }) + message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`) + } else { + updateItem(item.tempId, { status: "done" }) + message.success(`"${item.fileName}" 上传完成,正在转码处理`) + } + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : "上传失败" + console.error("[useAssetUpload] 上传失败:", item.fileName, err) + updateItem(item.tempId, { status: "error", error: detail }) + message.error(`"${item.fileName}" 上传失败:${detail}`) } + }, + [effectiveLibId, refreshList, updateItem], + ) + + /** + * 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。 + * runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state + * 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。 + */ + const inFlightRef = useRef(0) + const claimedRef = useRef>(new Set()) + const pumpRef = useRef<() => void>(() => {}) + pumpRef.current = () => { + while (inFlightRef.current < MAX_CONCURRENT) { + const next = itemsRef.current.find( + (it) => it.status === "preparing" && !claimedRef.current.has(it.tempId), + ) + if (!next) return + claimedRef.current.add(next.tempId) + inFlightRef.current += 1 + void runUpload(next).finally(() => { + inFlightRef.current -= 1 + claimedRef.current.delete(next.tempId) + // 一个任务结束(成功/失败)后继续拉起排队任务 + setTimeout(() => pumpRef.current(), 0) + }) + } + } + + useEffect(() => { + pumpRef.current() + }, [items]) + + /** 入队一个或多个文件 */ + const enqueueUploads = useCallback( + (files: File[]) => { if (!effectiveLibId) { message.warning("请先选择或创建一个视频库") return } - - setUploading(true) - setUploadProgress(0) - try { - if (file.size > LARGE_FILE_THRESHOLD) { - message.info(`大文件 "${file.name}" 将使用直传上传`) + const valid: File[] = [] + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + message.error(`文件 "${file.name}" 超过 2GB 限制`) + continue } - await uploadAssetDirect({ - file, - library_id: effectiveLibId, - onProgress: (pct) => setUploadProgress(pct), - }) - message.success(`"${file.name}" 上传成功`) - queryClient.invalidateQueries({ queryKey: ["assets"] }) - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - } catch (err: unknown) { - const detail = err instanceof Error ? err.message : "" - console.error("[handleUpload] 上传失败:", err) - message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`) - // 错误时延迟关闭弹窗,让用户能看到错误提示 - await new Promise((r) => setTimeout(r, 1500)) - } finally { - setUploading(false) - setUploadProgress(0) + valid.push(file) } + if (valid.length === 0) return + + const newItems: UploadItem[] = valid.map((file, idx) => ({ + tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`, + file, + fileName: file.name, + progress: 0, + status: "preparing", + })) + setItems((prev) => [...prev, ...newItems]) }, - [effectiveLibId, queryClient], + [effectiveLibId], ) + /** 重试失败任务 */ + const retryUpload = useCallback( + (tempId: string) => { + const target = itemsRef.current.find((it) => it.tempId === tempId) + if (!target) return + updateItem(tempId, { status: "preparing", progress: 0, error: undefined }) + // 状态更新后由 useEffect 触发 pump + }, + [updateItem], + ) + + /** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */ + const removeUpload = useCallback((tempId: string) => { + setItems((prev) => prev.filter((it) => it.tempId !== tempId)) + }, []) + + /** 清空已完成/去重记录 */ + const clearFinished = useCallback(() => { + setItems((prev) => prev.filter((it) => it.status !== "done")) + }, []) + + const activeCount = items.filter( + (it) => it.status === "preparing" || it.status === "uploading", + ).length + const pendingCount = items.filter((it) => it.status === "preparing").length + const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting") + return { - uploading, - uploadProgress, - handleUpload, + uploadItems: items, + enqueueUploads, + retryUpload, + removeUpload, + clearFinished, + /** 是否有进行中的上传(用于上传区文案) */ + uploading: hasActive, + activeCount, + pendingCount, } } diff --git a/apps/web/src/pages/assets/hooks/useAssetsData.ts b/apps/web/src/pages/assets/hooks/useAssetsData.ts index 34f2e8588..4ec192400 100644 --- a/apps/web/src/pages/assets/hooks/useAssetsData.ts +++ b/apps/web/src/pages/assets/hooks/useAssetsData.ts @@ -45,11 +45,21 @@ export function useAssetsData() { queryKey: ["assets", effectiveLibId], queryFn: () => getAssets(effectiveLibId, { - // 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材 + // 拉取所有非删除状态的素材,让用户上传后立刻能看到"上传中/处理中"的素材 status: "ready,uploading,ingesting,processing,pending,error,failed", }), enabled: !!effectiveLibId, staleTime: 30_000, + // 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止 + refetchInterval: (query) => { + const data = query.state.data as { items: ApiAssetItem[] } | undefined + const items = data?.items ?? [] + const processing = items.some((a) => { + const st = a.status ?? "" + return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending" + }) + return processing ? 3000 : false + }, }) const assets: AssetItem[] = useMemo( diff --git a/apps/web/src/test/pages/assets/smoke.test.tsx b/apps/web/src/test/pages/assets/smoke.test.tsx index b898bf5f3..1eba95ad5 100644 --- a/apps/web/src/test/pages/assets/smoke.test.tsx +++ b/apps/web/src/test/pages/assets/smoke.test.tsx @@ -20,7 +20,7 @@ import "@/pages/assets/components/CreateLibraryModal" import "@/pages/assets/components/LibrarySidebar" import "@/pages/assets/components/PlayModal" import "@/pages/assets/components/ResultDrawer" -import "@/pages/assets/components/UploadProgressModal" +import "@/pages/assets/components/UploadQueuePanel" // 类型与常量 import "@/pages/assets/types" diff --git a/apps/web/src/test/pages/assets/useAssetUpload.test.tsx b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx new file mode 100644 index 000000000..3bc612669 --- /dev/null +++ b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx @@ -0,0 +1,226 @@ +/** + * useAssetUpload 队列批量上传测试 + * - 并发直传不超过 MAX_CONCURRENT(3) + * - prepare 返回 asset_id 后 invalidate 列表 + * - 状态流转 uploading→ingesting→done;失败可重试;duplicated 命中 + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook, waitFor, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { ReactNode } from "react" + +const invalidateQueries = vi.fn() +vi.mock("@/api/assets", () => ({ + prepareDirectUploadHandle: vi.fn(), +})) + +const { prepareDirectUploadHandle } = await import("@/api/assets") +const { useAssetUpload } = await import("@/pages/assets/hooks/useAssetUpload") + +interface FakeHandle { + prepared: { + upload_url: string + method: string + storage_key: string + expires_at: string + fields: Record + max_size_bytes: number + asset_id: string + } + transfer: ReturnType + complete: ReturnType + /** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */ + finish: (fail?: boolean) => void +} + +let activeTransfers = 0 +let maxConcurrent = 0 + +/** + * 创建一个假 handle:transfer 返回挂起的 promise, + * finish 槽位在 transfer executor 同步执行时挂载,测试中调用 finish() 控制成败 + */ +const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?: boolean }) => { + const h = { + prepared: { + upload_url: "https://oss.example.com/u", + method: "POST", + storage_key: `uploads/${opts.id}/y.mp4`, + expires_at: "2099-01-01", + fields: {}, + max_size_bytes: 2_000_000_000, + asset_id: opts.id, + }, + transfer: vi.fn(), + complete: vi.fn().mockResolvedValue({ + storage_key: "uploads/x/y.mp4", + ingest_job_id: opts.duplicated ? "" : "job-1", + url: "https://oss.example.com/u", + duplicated: opts.duplicated, + asset_id: opts.id, + }), + finish: (() => {}) as (fail?: boolean) => void, + } + h.transfer.mockImplementation( + () => + new Promise((_resolve, reject) => { + activeTransfers += 1 + maxConcurrent = Math.max(maxConcurrent, activeTransfers) + h.finish = (fail = false) => { + activeTransfers -= 1 + if (fail || opts.failTransfer) reject(new Error("OSS boom")) + else _resolve() + } + }), + ) + return h +} + +type FakeHandleLike = ReturnType + +/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */ +const installPrepareMock = ( + handles: FakeHandleLike[], + optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean }, +) => { + let callNo = 0 + ;(prepareDirectUploadHandle as unknown as ReturnType).mockImplementation( + async () => { + const id = `asset-${callNo++}` + const overrides = optOverrides?.(id) ?? {} + const h = makeFakeHandle({ id, ...overrides }) + handles.push(h) + await new Promise((r) => setTimeout(r, 10)) + return h + }, + ) +} + +const createWrapper = () => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + // 监听 invalidate 调用 + const orig = qc.invalidateQueries.bind(qc) + qc.invalidateQueries = ((...args: unknown[]) => { + invalidateQueries() + return orig(...(args as never)) + }) as never + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const mp4 = (name: string) => new File([new Uint8Array(10)], name, { type: "video/mp4" }) + +beforeEach(() => { + vi.clearAllMocks() + activeTransfers = 0 + maxConcurrent = 0 +}) + +describe("useAssetUpload", () => { + it("5 个文件批量入队:同时直传不超过 3 个,全部完成且刷新列表", async () => { + const handles: FakeHandleLike[] = [] + installPrepareMock(handles) + + const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.enqueueUploads(Array.from({ length: 5 }, (_, i) => mp4(`v${i}.mp4`))) + }) + + // 3 个进入 uploading(transfer 被挂起),2 个排队 + await waitFor(() => { + expect(handles.length).toBe(3) + expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(3) + expect(result.current.uploadItems.filter((it) => it.status === "preparing").length).toBe(2) + }) + expect(maxConcurrent).toBe(3) + + // 完成前 3 个 → 队列拉起后 2 个 + await act(async () => { + handles[0].finish() + handles[1].finish() + handles[2].finish() + }) + await waitFor(() => { + expect(handles.length).toBe(5) + expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(2) + }) + expect(maxConcurrent).toBeLessThanOrEqual(3) + await waitFor(() => expect(handles[4].transfer).toHaveBeenCalled()) + + // 完成剩余 2 个 + await act(async () => { + handles[3].finish() + handles[4].finish() + }) + await waitFor(() => { + expect(result.current.uploadItems.filter((it) => it.status === "done").length).toBe(5) + }) + expect(invalidateQueries).toHaveBeenCalled() + }) + + it("传输失败标记 error,重试后成功", async () => { + const handles: FakeHandleLike[] = [] + let firstCall = true + installPrepareMock(handles, () => { + const fail = firstCall + firstCall = false + return { failTransfer: fail } + }) + + const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.enqueueUploads([mp4("bad.mp4")]) + }) + await waitFor(() => expect(handles.length).toBe(1)) + await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled()) + await act(async () => { + handles[0].finish() + }) + await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error")) + + // 重试:重新 prepare(handles[1] 成功) + const tempId = result.current.uploadItems[0].tempId + await act(async () => { + result.current.retryUpload(tempId) + }) + await waitFor(() => expect(handles.length).toBe(2)) + await waitFor(() => expect(handles[1].transfer).toHaveBeenCalled()) + await act(async () => { + handles[1].finish() + }) + await waitFor(() => { + expect(result.current.uploadItems.find((it) => it.tempId === tempId)?.status).toBe("done") + }) + }) + + it("complete 返回 duplicated 时标记去重完成", async () => { + const handles: FakeHandleLike[] = [] + installPrepareMock(handles, () => ({ duplicated: true })) + + const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.enqueueUploads([mp4("dup.mp4")]) + }) + await waitFor(() => expect(handles.length).toBe(1)) + await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled()) + await act(async () => { + handles[0].finish() + }) + await waitFor(() => { + expect(result.current.uploadItems[0].duplicated).toBe(true) + expect(result.current.uploadItems[0].status).toBe("done") + }) + }) +})