From 6b3620c3c4c4b8c39c1cd8bb632bce48df52cfea Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 21:58:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=89=A7=E9=9B=86=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E7=BB=AD=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/locales/en.ts | 49 +++ src/locales/zh-CN.ts | 47 +++ .../EpisodeManagementView.vue | 58 +++- .../upload/BatchUploadDialog.vue | 319 ++++++++++++++++++ .../upload/CreateEpisodesDialog.vue | 241 +++++++++++++ .../upload/UploadTaskRow.vue | 119 +++++++ .../upload/uploadSessions.ts | 72 ++++ .../upload/useUploadQueue.ts | 310 +++++++++++++++++ .../contentManagement/upload/videoLimit.ts | 3 + 9 files changed, 1210 insertions(+), 8 deletions(-) create mode 100644 src/views/contentManagement/upload/BatchUploadDialog.vue create mode 100644 src/views/contentManagement/upload/CreateEpisodesDialog.vue create mode 100644 src/views/contentManagement/upload/UploadTaskRow.vue create mode 100644 src/views/contentManagement/upload/uploadSessions.ts create mode 100644 src/views/contentManagement/upload/useUploadQueue.ts create mode 100644 src/views/contentManagement/upload/videoLimit.ts diff --git a/src/locales/en.ts b/src/locales/en.ts index 5043eef..ab44e71 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -527,5 +527,54 @@ export default { inputPasswordDemo: { title: 'InputPassword', inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' + }, + episodeBatch: { + title: 'Batch upload episodes', + entry: 'Batch upload', + selectFiles: 'Select videos', + pause: 'Pause', + resume: 'Resume', + retry: 'Retry', + retryAll: 'Retry all', + remove: 'Remove', + hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each; up to 2 files in parallel.', + invalidType: 'Only MP4, MOV, MKV, WEBM are allowed', + oversize: 'File exceeds 500MB and was not queued', + overallProgress: 'Overall progress', + empty: 'No videos selected', + createEpisodes: 'Create episodes', + closeWhileUploading: + 'Uploads are still in progress. Closing will stop dispatching new tasks. Continue?', + resumeTitle: 'Unfinished uploads found', + resumeConfirm: + '{count} unfinished upload(s) found. Resume? Re-select the same local files to match.', + resumeYes: 'Pick files to resume', + resumeNo: 'Discard', + resumeAbandoned: 'Unfinished uploads discarded', + resumeMatched: 'Matched {count} file(s) and resumed', + resumeUnmatched: '{count} session(s) could not match local files; pick again manually', + createTitle: 'Create episodes in batch', + colSource: 'Source file', + colNumber: 'Episode No.', + colTitle: 'Title', + colFree: 'Free', + colResult: 'Result', + numberConflict: 'Episode number conflict', + missingVideo: 'Missing video URL', + createOk: 'Created', + createFailed: 'Create failed', + createAllSuccess: 'All episodes created', + createPartial: 'Some episodes failed; you can retry', + fixConflict: 'Resolve episode number conflicts first', + submitCreate: 'Submit', + retryFailed: 'Retry failed', + status: { + waiting: 'Waiting', + uploading: 'Uploading', + merging: 'Merging', + done: 'Done', + failed: 'Failed', + paused: 'Paused' + } } } diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 535f9f7..a66b726 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -521,5 +521,52 @@ export default { inputPasswordDemo: { title: '密码输入框', inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' + }, + episodeBatch: { + title: '批量上传剧集', + entry: '批量上传', + selectFiles: '选择视频', + pause: '暂停', + resume: '继续', + retry: '重试', + retryAll: '全部重试', + remove: '移除', + hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;同时最多上传 2 个文件。', + invalidType: '仅支持 MP4、MOV、MKV、WEBM', + oversize: '文件超过 500MB,未入队', + overallProgress: '总进度', + empty: '尚未选择视频', + createEpisodes: '批量创建分集', + closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?', + resumeTitle: '发现未完成上传', + resumeConfirm: '发现 {count} 个未完成上传,是否续传?请重新选择相同本地文件以匹配。', + resumeYes: '选择文件续传', + resumeNo: '放弃', + resumeAbandoned: '已放弃未完成上传', + resumeMatched: '已匹配 {count} 个文件并开始续传', + resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选', + createTitle: '批量创建分集', + colSource: '源文件', + colNumber: '集号', + colTitle: '标题', + colFree: '免费', + colResult: '结果', + numberConflict: '集号冲突', + missingVideo: '缺少视频地址', + createOk: '已创建', + createFailed: '创建失败', + createAllSuccess: '全部分集已创建', + createPartial: '部分分集创建失败,可重试', + fixConflict: '请先解决集号冲突', + submitCreate: '提交创建', + retryFailed: '重试失败项', + status: { + waiting: '等待中', + uploading: '上传中', + merging: '合并中', + done: '已完成', + failed: '失败', + paused: '已暂停' + } } } diff --git a/src/views/contentManagement/EpisodeManagementView.vue b/src/views/contentManagement/EpisodeManagementView.vue index 0a5e5fd..bc7f1f2 100644 --- a/src/views/contentManagement/EpisodeManagementView.vue +++ b/src/views/contentManagement/EpisodeManagementView.vue @@ -16,10 +16,15 @@ import { import { useRbac } from '@/stores/rbac' import { formatCount } from '@/utils/format' import { statusClass } from '@/utils/status' +import { useI18n } from '@/hooks/web/useI18n' import { probeVideoDuration, uploadFile } from '@/utils/uploader' +import BatchUploadDialog from './upload/BatchUploadDialog.vue' +import { listPendingSessions } from './upload/uploadSessions' +import { MAX_VIDEO_SIZE } from './upload/videoLimit' const route = useRoute() const { can } = useRbac() +const { t } = useI18n() const loading = ref(false) const dramas = ref([]) @@ -27,6 +32,8 @@ const episodes = ref([]) const selectedDramaId = ref(undefined) const dialogVisible = ref(false) +const batchUploadVisible = ref(false) +const batchNextNumber = computed(() => nextNumber()) const saving = ref(false) const uploadingVideo = ref(false) const videoPercent = ref(0) @@ -55,7 +62,6 @@ const statusOptions = [ { code: 'offline', text: '已下线' } ] -const MAX_VIDEO_SIZE = 500 * 1024 * 1024 const VIDEO_EXTENSIONS = ['mp4', 'mov'] const formRules = { number: [{ required: true, message: '请输入集号', trigger: 'change' }], @@ -120,6 +126,19 @@ function nextNumber() { return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1 } +const existingEpisodeNumbers = computed(() => + episodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n)) +) + +function openBatchUpload() { + if (!selectedDramaId.value) return + batchUploadVisible.value = true +} + +async function onBatchEpisodesCreated() { + await loadEpisodes() +} + function openCreate() { editingId.value = null Object.assign(form, { @@ -304,11 +323,20 @@ async function move(index, offset) { async function onDramaChange() { await loadEpisodes() + openBatchIfPendingSessions() +} + +/** 进入页面或切换短剧时,若有 24h 内未完成上传则打开批量上传弹窗以提示续传。 */ +function openBatchIfPendingSessions() { + if (!selectedDramaId.value || !can('episode:upload')) return + const pending = listPendingSessions(String(selectedDramaId.value)) + if (pending.length) batchUploadVisible.value = true } onMounted(async () => { await loadDramas() await loadEpisodes() + openBatchIfPendingSessions() }) @@ -317,13 +345,17 @@ onMounted(async () => { >
内容管理

剧集管理

为短剧维护剧集信息、视频地址与播放顺序。

新增剧集
+ {{ t('episodeBatch.entry') }}新增剧集
@@ -672,4 +704,14 @@ onMounted(async () => { > + diff --git a/src/views/contentManagement/upload/BatchUploadDialog.vue b/src/views/contentManagement/upload/BatchUploadDialog.vue new file mode 100644 index 0000000..130be68 --- /dev/null +++ b/src/views/contentManagement/upload/BatchUploadDialog.vue @@ -0,0 +1,319 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/CreateEpisodesDialog.vue b/src/views/contentManagement/upload/CreateEpisodesDialog.vue new file mode 100644 index 0000000..13b771e --- /dev/null +++ b/src/views/contentManagement/upload/CreateEpisodesDialog.vue @@ -0,0 +1,241 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/UploadTaskRow.vue b/src/views/contentManagement/upload/UploadTaskRow.vue new file mode 100644 index 0000000..24033d8 --- /dev/null +++ b/src/views/contentManagement/upload/UploadTaskRow.vue @@ -0,0 +1,119 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/uploadSessions.ts b/src/views/contentManagement/upload/uploadSessions.ts new file mode 100644 index 0000000..513ac63 --- /dev/null +++ b/src/views/contentManagement/upload/uploadSessions.ts @@ -0,0 +1,72 @@ +// Author: fruit.com.cn +/** 剧集批量上传断点会话:localStorage 读写与清理 */ + +export const UPLOAD_SESSION_KEY = 'reel_upload_sessions' +export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +export interface UploadSessionRecord { + uploadId: string + fileName: string + fileSize: number + lastModified: number + dramaId: string + bizType: string + createdAt: number +} + +/** 读取全部会话,解析失败时返回空数组。 */ +export function loadUploadSessions(): UploadSessionRecord[] { + try { + const raw = localStorage.getItem(UPLOAD_SESSION_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as UploadSessionRecord[]) : [] + } catch { + return [] + } +} + +/** 覆盖写入全部会话。 */ +export function saveUploadSessions(sessions: UploadSessionRecord[]): void { + localStorage.setItem(UPLOAD_SESSION_KEY, JSON.stringify(sessions)) +} + +/** 按 dramaId 过滤且未超过 TTL 的未完成会话。 */ +export function listPendingSessions(dramaId: string, now = Date.now()): UploadSessionRecord[] { + const dramaKey = String(dramaId) + return loadUploadSessions().filter( + (item) => + String(item.dramaId) === dramaKey && + now - Number(item.createdAt || 0) <= UPLOAD_SESSION_TTL_MS + ) +} + +/** 新增或按 uploadId 覆盖一条会话。 */ +export function upsertUploadSession(record: UploadSessionRecord): void { + const next = loadUploadSessions().filter((item) => item.uploadId !== record.uploadId) + next.push(record) + saveUploadSessions(next) +} + +/** 按 uploadId 移除会话。 */ +export function removeUploadSession(uploadId: string): void { + if (!uploadId) return + saveUploadSessions(loadUploadSessions().filter((item) => item.uploadId !== uploadId)) +} + +/** 批量移除会话。 */ +export function removeUploadSessions(uploadIds: string[]): void { + if (!uploadIds.length) return + const idSet = new Set(uploadIds) + saveUploadSessions(loadUploadSessions().filter((item) => !idSet.has(item.uploadId))) +} + +/** 用文件名 + size + lastModified 匹配本地文件。 */ +export function matchFileForSession(session: UploadSessionRecord, files: File[]): File | undefined { + return files.find( + (file) => + file.name === session.fileName && + file.size === session.fileSize && + file.lastModified === session.lastModified + ) +} diff --git a/src/views/contentManagement/upload/useUploadQueue.ts b/src/views/contentManagement/upload/useUploadQueue.ts new file mode 100644 index 0000000..dd0312b --- /dev/null +++ b/src/views/contentManagement/upload/useUploadQueue.ts @@ -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([]) + 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 { + 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 + } +} diff --git a/src/views/contentManagement/upload/videoLimit.ts b/src/views/contentManagement/upload/videoLimit.ts new file mode 100644 index 0000000..a649d8a --- /dev/null +++ b/src/views/contentManagement/upload/videoLimit.ts @@ -0,0 +1,3 @@ +// Author: fruit.com.cn +/** 剧集视频大小上限,单文件上传与批量上传共用。 */ +export const MAX_VIDEO_SIZE = 500 * 1024 * 1024