feat(assets): 上传即入库——批量上传队列化(并发3)+真实进度+刷新不丢 #1557

Merged
auto-approve-bot merged 1 commits from feat/upload-instant-assets into develop 2026-08-30 17:56:04 +08:00
15 changed files with 793 additions and 178 deletions
+7 -1
View File
@@ -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"
+6
View File
@@ -133,6 +133,12 @@ export interface DirectUploadPrepareResult {
expires_at: string
fields: Record<string, string>
max_size_bytes: number
/**
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
}
/** 直传完成确认返回 */
+59 -25
View File
@@ -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<DirectUploadCompleteResult> => {
const project = await getOrCreateDefaultProject()
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */
const putToOSS = (
prepared: DirectUploadPrepareResult,
file: File,
onProgress?: (percent: number) => void,
): Promise<void> =>
new Promise<void>((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<void>((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<void>
/** 直传完成后调用 complete 确认入库 */
complete: () => Promise<DirectUploadCompleteResult>
}
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
}): Promise<DirectUploadHandle> => {
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<DirectUploadCompleteResult> => {
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
await handle.transfer(data.onProgress)
return handle.complete()
}
+37 -5
View File
@@ -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<string, { progress: number; uploading: boolean }>()
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 = () => {
{/* 上传区域 */}
<AssetUploadZone
uploading={uploading}
uploadProgress={uploadProgress}
onUpload={handleUpload}
activeCount={activeCount}
pendingCount={pendingCount}
onUpload={enqueueUploads}
/>
{/* 上传队列:独立进度 + 失败重试/移除 */}
<UploadQueuePanel
items={uploadItems}
onRetry={retryUpload}
onRemove={removeUpload}
onClearFinished={clearFinished}
/>
{/* 筛选栏 */}
@@ -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 = () => {
{/* ─── 弹窗集合 ─── */}
<AssetModals
uploading={uploading}
uploadProgress={uploadProgress}
createModalOpen={createModalOpen}
onCreateModalCancel={() => setCreateModalOpen(false)}
onCreateModalOk={handleCreateLibrary}
+113 -28
View File
@@ -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;
}
/* ─── 批量打标签弹窗 ─── */
@@ -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<AssetCardProps> = ({
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<AssetCardProps> = ({
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
{/* 上传中遮罩:真实进度百分比 + 进度条 */}
{isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-uploading">
<LoadingOutlined />
<span> {uploadProgress?.progress ?? 0}%</span>
<div className="xx-asset-upload-bar">
<div
className="xx-asset-upload-bar-inner"
style={{ width: `${uploadProgress?.progress ?? 0}%` }}
/>
</div>
</div>
)}
{/* 转码/处理中遮罩 */}
{asset.loading && !isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
<span></span>
</div>
)}
@@ -8,6 +8,9 @@ import type { AssetItem } from "../types"
import AssetCard from "./AssetCard"
import { SkeletonCard } from "./AssetSkeleton"
/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */
export type UploadProgressMap = Map<string, { progress: number; uploading: boolean }>
export interface AssetGridSectionProps {
loading: boolean
error: boolean
@@ -15,6 +18,7 @@ export interface AssetGridSectionProps {
assets: AssetItem[]
selectedIds: Set<string>
diagnosingId: string | null
uploadProgressMap?: UploadProgressMap
onRetry?: () => void
onToggleSelect: (id: string) => void
onDiagnose: (asset: AssetItem) => void
@@ -29,6 +33,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
assets,
selectedIds,
diagnosingId,
uploadProgressMap,
onRetry,
onToggleSelect,
onDiagnose,
@@ -70,6 +75,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
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)}
@@ -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<AssetModalsProps> = ({
uploading,
uploadProgress,
createModalOpen,
onCreateModalCancel,
onCreateModalOk,
@@ -109,9 +104,6 @@ export const AssetModals: React.FC<AssetModalsProps> = ({
}) => {
return (
<>
{/* 上传进度弹窗 */}
<UploadProgressModal open={uploading} progress={uploadProgress} />
{/* 新建视频库弹窗 */}
<CreateLibraryModal
open={createModalOpen}
@@ -1,5 +1,6 @@
/**
* AssetLibrary 上传拖拽区域
* 多文件拖入即入队(并发由 useAssetUpload 队列控制,最多 3 路直传)
*/
import React from "react"
import { Upload } from "antd"
@@ -7,15 +8,23 @@ import { InboxOutlined } from "@ant-design/icons"
export interface AssetUploadZoneProps {
uploading: boolean
uploadProgress: number
onUpload: (file: File) => void
activeCount: number
pendingCount: number
onUpload: (files: File[]) => void
}
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
activeCount,
pendingCount,
onUpload,
}) => {
return (
<Upload.Dragger
beforeUpload={(file) => {
onUpload(file as File)
// antd 多选时会对每个文件同步连续触发一次 beforeUpload
// 每次只入队当前文件,React 批处理保证多文件一次性渲染
onUpload([file as File])
return false
}}
showUploadList={false}
@@ -27,9 +36,11 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onU
<InboxOutlined />
</p>
<p className="xx-asset-upload-text">
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
{uploading
? `上传中…(进行 ${activeCount}${pendingCount > 0 ? `,排队 ${pendingCount}` : ""}`
: "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint"> 2GB</p>
<p className="xx-asset-upload-hint"> 2GB</p>
</div>
</Upload.Dragger>
)
@@ -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<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
@@ -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<UploadItem["status"], string> = {
preparing: "排队中…",
uploading: "上传中",
ingesting: "转码中…",
done: "已完成",
error: "上传失败",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
onRemove,
onClearFinished,
}) => {
if (items.length === 0) return null
const finishedCount = items.filter((it) => it.status === "done").length
return (
<div className="xx-upload-queue">
<div className="xx-upload-queue-header">
<span className="xx-upload-queue-title">
{items.length}
{finishedCount > 0 ? `,已完成 ${finishedCount}` : ""}
</span>
{finishedCount > 0 && (
<button type="button" className="xx-link-btn" onClick={onClearFinished}>
</button>
)}
</div>
<div className="xx-upload-queue-list">
{items.map((it) => {
const isActive = it.status === "preparing" || it.status === "uploading"
const showProgress = it.status === "uploading" || it.status === "ingesting"
return (
<div key={it.tempId} className={`xx-upload-queue-item xx-upload-queue-${it.status}`}>
<span className="xx-upload-queue-icon">
{it.status === "done" || it.duplicated ? (
<CheckCircleFilled style={{ color: "#22c55e" }} />
) : it.status === "error" ? (
<CloseCircleFilled style={{ color: "#ef4444" }} />
) : (
<LoadingOutlined style={{ color: "var(--primary-color)" }} />
)}
</span>
<div className="xx-upload-queue-body">
<div className="xx-upload-queue-name" title={it.fileName}>
{it.fileName}
</div>
{showProgress ? (
<div className="xx-upload-queue-progress">
<div
className="xx-upload-queue-progress-bar"
style={{ width: `${it.status === "ingesting" ? 100 : it.progress}%` }}
/>
</div>
) : null}
<div className="xx-upload-queue-status">
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
</div>
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
<button
type="button"
className="xx-upload-queue-btn"
title="重试"
onClick={() => onRetry(it.tempId)}
>
<ReloadOutlined />
</button>
)}
{(it.status === "error" || it.status === "done") && !isActive && (
<button
type="button"
className="xx-upload-queue-btn"
title="移除"
onClick={() => onRemove(it.tempId)}
>
<CloseOutlined />
</button>
)}
</span>
</div>
)
})}
</div>
</div>
)
}
export default UploadQueuePanel
+172 -44
View File
@@ -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 {
/** 前端临时 idprepare 前无 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<UploadItem[]>([])
const itemsRef = useRef<UploadItem[]>([])
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<UploadItem>) => {
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<Set<string>>(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,
}
}
@@ -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(
@@ -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"
@@ -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<string, string>
max_size_bytes: number
asset_id: string
}
transfer: ReturnType<typeof vi.fn>
complete: ReturnType<typeof vi.fn>
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
finish: (fail?: boolean) => void
}
let activeTransfers = 0
let maxConcurrent = 0
/**
* 创建一个假 handletransfer 返回挂起的 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<void>((_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<typeof makeFakeHandle>
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
const installPrepareMock = (
handles: FakeHandleLike[],
optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean },
) => {
let callNo = 0
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
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 个进入 uploadingtransfer 被挂起),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"))
// 重试:重新 preparehandles[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")
})
})
})