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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('episodeBatch.hint') }}
+
+
+
+
+ {{ t('episodeBatch.overallProgress') }}
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+ {{ t('episodeBatch.createEpisodes') }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ t('episodeBatch.colSource') }}
+ {{ t('episodeBatch.colNumber') }}
+ {{ t('episodeBatch.colTitle') }}
+ {{ t('episodeBatch.colFree') }}
+ {{ t('episodeBatch.colResult') }}
+
+
+
+ {{ row.sourceName }}
+
+
+
+
+ {{
+ row.saved
+ ? t('episodeBatch.createOk')
+ : row.error || (conflictOf(row) ? t('episodeBatch.numberConflict') : '')
+ }}
+
+
+
+
+ {{ t('common.cancel') }}
+
+ {{ hasFailed() ? t('episodeBatch.retryFailed') : t('episodeBatch.submitCreate') }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ item.fileName }}
+ {{ sizeLabel }}
+ {{ statusLabel }}
+
+
+
{{ item.error }}
+
+
+ {{ t('episodeBatch.retry') }}
+
+
+ {{ t('episodeBatch.remove') }}
+
+
+
+
+
+
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