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")
+ })
+ })
+})