feat(assets): 上传即入库——批量上传队列化(并发3)+真实进度+刷新不丢
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m38s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 5m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m43s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m8s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2m38s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m37s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m55s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m38s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 5m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m43s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m8s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2m38s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m37s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m55s
- API 层:prepareDirectUpload 返回新增可选 asset_id(后端 prepare 预建 status=uploading 的素材记录,旧后端不返回则降级为原行为);新增 prepareDirectUploadHandle 拆分为 prepare/transfer/complete 三段, 支持队列调度与失败重试;uploadAssetDirect 旧签名保留,配音/克隆等 既有调用方零改动 - useAssetUpload 重写为队列式批量管理:多文件入队,OSS 直传并发限制 3 (inFlightRef + claimedRef 防重复认领),其余排队;每个文件独立 状态机 preparing→uploading(真实进度)→ingesting→done/error; prepare 拿到 asset_id 立即 invalidate 列表,卡片以「上传中」态出现在 网格(服务端记录为准,刷新不丢);失败可重试、可移除、可清空已完成; duplicated 去重提示不变 - 上传 UI:删除阻塞式 UploadProgressModal,新增 UploadQueuePanel (每文件独立进度条/状态/重试/移除);AssetUploadZone 支持多文件入队, 文案显示进行中/排队数量;AssetCard 上传中遮罩显示真实百分比+进度条, 转码中遮罩与上传中区分 - useAssetsData:列表存在 uploading/ingesting/processing/pending 素材时 每 3s 轮询(refetchInterval 函数动态求值),全部就绪自动停止; status 全量参数保持不变 - 不动:去重逻辑、单文件/配音上传流程、上传弹窗交互、素材筛选/播放
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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<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
|
||||
|
||||
/**
|
||||
* 创建一个假 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<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 个进入 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user