63e68756ac
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m47s
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 / 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 / Check if frontend-only change (pull_request) Successful in 4m1s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2m16s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m25s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 8m37s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from "react"
|
||
import { useQueryClient } from "@tanstack/react-query"
|
||
import { message } from "antd"
|
||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||
import { MAX_FILE_SIZE } from "../constants"
|
||
|
||
/** 单文件上传状态机 */
|
||
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
|
||
}
|
||
|
||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||
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 [items, setItems] = useState<UploadItem[]>([])
|
||
const itemsRef = useRef<UploadItem[]>([])
|
||
itemsRef.current = items
|
||
|
||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
|
||
}, [])
|
||
|
||
/** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */
|
||
const refreshList = useCallback(() => {
|
||
// 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟
|
||
if (effectiveLibId) {
|
||
queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] })
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||
}, [queryClient, effectiveLibId])
|
||
|
||
/** 执行单个文件的完整上传流程(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
|
||
}
|
||
const valid: File[] = []
|
||
for (const file of files) {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||
continue
|
||
}
|
||
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],
|
||
)
|
||
|
||
/** 重试失败任务 */
|
||
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 {
|
||
uploadItems: items,
|
||
enqueueUploads,
|
||
retryUpload,
|
||
removeUpload,
|
||
clearFinished,
|
||
/** 是否有进行中的上传(用于上传区文案) */
|
||
uploading: hasActive,
|
||
activeCount,
|
||
pendingCount,
|
||
}
|
||
}
|