feat(admin): 剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8e1a02ace0
commit
6b3620c3c4
9 files changed
+1210
-8
No files matched your search
@@ -0,0 +1,310 @@
|
||||
// Author: fruit.com.cn
|
||||
/** 剧集批量上传队列:文件并发、进度加权、断点会话、失败隔离 */
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { getUploadProgressApi } from '@/api/admin/media'
|
||||
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
|
||||
import {
|
||||
listPendingSessions,
|
||||
matchFileForSession,
|
||||
removeUploadSession,
|
||||
type UploadSessionRecord,
|
||||
upsertUploadSession
|
||||
} from './uploadSessions'
|
||||
|
||||
export type UploadItemStatus = 'waiting' | 'uploading' | 'merging' | 'done' | 'failed' | 'paused'
|
||||
|
||||
export interface UploadQueueItem {
|
||||
id: string
|
||||
file: File
|
||||
fileName: string
|
||||
fileSize: number
|
||||
lastModified: number
|
||||
percent: number
|
||||
status: UploadItemStatus
|
||||
error: string
|
||||
uploadId: string
|
||||
assetId?: number
|
||||
url?: string
|
||||
durationSec?: number
|
||||
}
|
||||
|
||||
export interface UseUploadQueueOptions {
|
||||
/** 当前短剧 id(调用时读取,避免切换剧后闭包过期) */
|
||||
getDramaId: () => string
|
||||
bizType?: string
|
||||
}
|
||||
|
||||
const FILE_CONCURRENCY = 2
|
||||
const CHUNK_CONCURRENCY = 3
|
||||
const DEFAULT_BIZ_TYPE = 'EPISODE'
|
||||
const MERGING_PERCENT = 95
|
||||
const MAX_PERCENT = 100
|
||||
|
||||
let queueSeq = 0
|
||||
|
||||
function nextItemId(): string {
|
||||
queueSeq += 1
|
||||
return `uq-${Date.now()}-${queueSeq}`
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return String(error || 'upload failed')
|
||||
}
|
||||
|
||||
function isHttpNotFound(error: unknown): boolean {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
return status === 404
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传队列控制器。
|
||||
* @param options dramaId 与可选 bizType
|
||||
*/
|
||||
export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
const items = ref<UploadQueueItem[]>([])
|
||||
const paused = ref(false)
|
||||
const bizType = options.bizType || DEFAULT_BIZ_TYPE
|
||||
let running = 0
|
||||
|
||||
const overallPercent = computed(() => {
|
||||
const list = items.value
|
||||
if (!list.length) return 0
|
||||
const totalBytes = list.reduce((sum, item) => sum + item.fileSize, 0)
|
||||
if (totalBytes <= 0) return 0
|
||||
const weighted = list.reduce((sum, item) => sum + item.fileSize * item.percent, 0)
|
||||
return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes))
|
||||
})
|
||||
|
||||
const hasActiveUpload = computed(() =>
|
||||
items.value.some(
|
||||
(item) =>
|
||||
item.status === 'waiting' ||
|
||||
item.status === 'uploading' ||
|
||||
item.status === 'merging' ||
|
||||
item.status === 'paused'
|
||||
)
|
||||
)
|
||||
|
||||
const doneItems = computed(() => items.value.filter((item) => item.status === 'done'))
|
||||
|
||||
function persistSession(item: UploadQueueItem, uploadId: string): void {
|
||||
item.uploadId = uploadId
|
||||
upsertUploadSession({
|
||||
uploadId,
|
||||
fileName: item.fileName,
|
||||
fileSize: item.fileSize,
|
||||
lastModified: item.lastModified,
|
||||
dramaId: String(options.getDramaId()),
|
||||
bizType,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
function clearSession(uploadId: string): void {
|
||||
if (uploadId) removeUploadSession(uploadId)
|
||||
}
|
||||
|
||||
async function runOne(item: UploadQueueItem, resumeId?: string): Promise<void> {
|
||||
item.status = 'uploading'
|
||||
item.error = ''
|
||||
try {
|
||||
const durationSec = await probeVideoDuration(item.file)
|
||||
item.durationSec = durationSec
|
||||
const uploadOptions = {
|
||||
bizType,
|
||||
concurrency: CHUNK_CONCURRENCY,
|
||||
durationSec,
|
||||
onSession: (uploadId: string) => persistSession(item, uploadId),
|
||||
onProgress: (percent: number) => {
|
||||
item.percent = percent
|
||||
if (percent >= MERGING_PERCENT && item.status === 'uploading') {
|
||||
item.status = 'merging'
|
||||
}
|
||||
}
|
||||
}
|
||||
const asset = resumeId
|
||||
? await resumeUpload(item.file, resumeId, uploadOptions)
|
||||
: await uploadFile(item.file, uploadOptions)
|
||||
item.percent = MAX_PERCENT
|
||||
item.status = 'done'
|
||||
item.assetId = asset.id
|
||||
item.url = asset.url
|
||||
if (asset.durationSec != null) item.durationSec = asset.durationSec
|
||||
clearSession(item.uploadId || resumeId || '')
|
||||
} catch (error) {
|
||||
item.status = 'failed'
|
||||
item.error = extractErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
function pump(): void {
|
||||
if (paused.value) return
|
||||
while (running < FILE_CONCURRENCY && !paused.value) {
|
||||
const next = items.value.find((item) => item.status === 'waiting')
|
||||
if (!next) return
|
||||
// 立刻占位,避免同步 while 循环把同一项派发两次
|
||||
next.status = 'uploading'
|
||||
running += 1
|
||||
const resumeId = next.uploadId || undefined
|
||||
void runOne(next, resumeId).finally(() => {
|
||||
running -= 1
|
||||
if (!paused.value) pump()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 入队本地文件(调用方已完成类型/大小校验)。 */
|
||||
function enqueue(files: File[]): void {
|
||||
for (const file of files) {
|
||||
items.value.push({
|
||||
id: nextItemId(),
|
||||
file,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
percent: 0,
|
||||
status: paused.value ? 'paused' : 'waiting',
|
||||
error: '',
|
||||
uploadId: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始或继续派发。 */
|
||||
function start(): void {
|
||||
paused.value = false
|
||||
for (const item of items.value) {
|
||||
if (item.status === 'paused') item.status = 'waiting'
|
||||
}
|
||||
void pump()
|
||||
}
|
||||
|
||||
/** 暂停:不派新任务;进行中分片跑完。 */
|
||||
function pause(): void {
|
||||
paused.value = true
|
||||
for (const item of items.value) {
|
||||
if (item.status === 'waiting') item.status = 'paused'
|
||||
}
|
||||
}
|
||||
|
||||
/** 重试单个失败项。 */
|
||||
function retry(id: string): void {
|
||||
const item = items.value.find((row) => row.id === id)
|
||||
if (!item || item.status !== 'failed') return
|
||||
item.error = ''
|
||||
item.percent = item.uploadId ? item.percent : 0
|
||||
item.status = paused.value ? 'paused' : 'waiting'
|
||||
if (!paused.value) void pump()
|
||||
}
|
||||
|
||||
/** 重试全部失败项。 */
|
||||
function retryAll(): void {
|
||||
for (const item of items.value) {
|
||||
if (item.status === 'failed') {
|
||||
item.error = ''
|
||||
item.status = paused.value ? 'paused' : 'waiting'
|
||||
}
|
||||
}
|
||||
if (!paused.value) void pump()
|
||||
}
|
||||
|
||||
/** 移除单项并清理会话。 */
|
||||
function remove(id: string): void {
|
||||
const index = items.value.findIndex((row) => row.id === id)
|
||||
if (index < 0) return
|
||||
const [removed] = items.value.splice(index, 1)
|
||||
clearSession(removed.uploadId)
|
||||
}
|
||||
|
||||
/** 清空队列并清理本队列会话。 */
|
||||
function clear(): void {
|
||||
for (const item of items.value) {
|
||||
clearSession(item.uploadId)
|
||||
}
|
||||
items.value = []
|
||||
paused.value = false
|
||||
running = 0
|
||||
}
|
||||
|
||||
/** 读取当前剧未完成会话(24h 内)。 */
|
||||
function getPendingSessions(): UploadSessionRecord[] {
|
||||
return listPendingSessions(String(options.getDramaId()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 用本地重选文件匹配会话并入队续传。
|
||||
* @returns matched / unmatched 会话列表
|
||||
*/
|
||||
async function resumeFromSessions(
|
||||
sessions: UploadSessionRecord[],
|
||||
files: File[]
|
||||
): Promise<{ matched: UploadSessionRecord[]; unmatched: UploadSessionRecord[] }> {
|
||||
const matched: UploadSessionRecord[] = []
|
||||
const unmatched: UploadSessionRecord[] = []
|
||||
for (const session of sessions) {
|
||||
const file = matchFileForSession(session, files)
|
||||
if (!file) {
|
||||
unmatched.push(session)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const progressRes = await getUploadProgressApi(session.uploadId)
|
||||
const progress = progressRes?.data
|
||||
if (!progress) {
|
||||
unmatched.push(session)
|
||||
continue
|
||||
}
|
||||
if (String(progress.status || '').toLowerCase() === 'completed') {
|
||||
clearSession(session.uploadId)
|
||||
continue
|
||||
}
|
||||
items.value.push({
|
||||
id: nextItemId(),
|
||||
file,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0),
|
||||
status: paused.value ? 'paused' : 'waiting',
|
||||
error: '',
|
||||
uploadId: session.uploadId
|
||||
})
|
||||
matched.push(session)
|
||||
} catch (error) {
|
||||
if (isHttpNotFound(error)) {
|
||||
clearSession(session.uploadId)
|
||||
} else {
|
||||
unmatched.push(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { matched, unmatched }
|
||||
}
|
||||
|
||||
/** 放弃续传:清理给定会话。 */
|
||||
function abandonSessions(sessions: UploadSessionRecord[]): void {
|
||||
for (const session of sessions) {
|
||||
clearSession(session.uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
overallPercent,
|
||||
hasActiveUpload,
|
||||
doneItems,
|
||||
paused,
|
||||
enqueue,
|
||||
start,
|
||||
pause,
|
||||
retry,
|
||||
retryAll,
|
||||
remove,
|
||||
clear,
|
||||
getPendingSessions,
|
||||
resumeFromSessions,
|
||||
abandonSessions
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user