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
@@ -527,5 +527,54 @@ export default {
|
|||||||
inputPasswordDemo: {
|
inputPasswordDemo: {
|
||||||
title: 'InputPassword',
|
title: 'InputPassword',
|
||||||
inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus'
|
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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,5 +521,52 @@ export default {
|
|||||||
inputPasswordDemo: {
|
inputPasswordDemo: {
|
||||||
title: '密码输入框',
|
title: '密码输入框',
|
||||||
inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装'
|
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: '已暂停'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,15 @@ import {
|
|||||||
import { useRbac } from '@/stores/rbac'
|
import { useRbac } from '@/stores/rbac'
|
||||||
import { formatCount } from '@/utils/format'
|
import { formatCount } from '@/utils/format'
|
||||||
import { statusClass } from '@/utils/status'
|
import { statusClass } from '@/utils/status'
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
|
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 route = useRoute()
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dramas = ref([])
|
const dramas = ref([])
|
||||||
@@ -27,6 +32,8 @@ const episodes = ref([])
|
|||||||
const selectedDramaId = ref(undefined)
|
const selectedDramaId = ref(undefined)
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
const batchUploadVisible = ref(false)
|
||||||
|
const batchNextNumber = computed(() => nextNumber())
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploadingVideo = ref(false)
|
const uploadingVideo = ref(false)
|
||||||
const videoPercent = ref(0)
|
const videoPercent = ref(0)
|
||||||
@@ -55,7 +62,6 @@ const statusOptions = [
|
|||||||
{ code: 'offline', text: '已下线' }
|
{ code: 'offline', text: '已下线' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024
|
|
||||||
const VIDEO_EXTENSIONS = ['mp4', 'mov']
|
const VIDEO_EXTENSIONS = ['mp4', 'mov']
|
||||||
const formRules = {
|
const formRules = {
|
||||||
number: [{ required: true, message: '请输入集号', trigger: 'change' }],
|
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
|
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() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -304,11 +323,20 @@ async function move(index, offset) {
|
|||||||
|
|
||||||
async function onDramaChange() {
|
async function onDramaChange() {
|
||||||
await loadEpisodes()
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadDramas()
|
await loadDramas()
|
||||||
await loadEpisodes()
|
await loadEpisodes()
|
||||||
|
openBatchIfPendingSessions()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -317,13 +345,17 @@ onMounted(async () => {
|
|||||||
><div
|
><div
|
||||||
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
|
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
|
||||||
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
|
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
|
||||||
><el-button
|
><div class="page-heading-actions">
|
||||||
v-if="can('episode:upload')"
|
<el-button v-if="can('episode:upload')" :disabled="!selectedDramaId" @click="openBatchUpload"
|
||||||
type="primary"
|
><AppIcon :icon="AppIcons.upload" />{{ t('episodeBatch.entry') }}</el-button
|
||||||
class="primary-button"
|
><el-button
|
||||||
:disabled="!selectedDramaId"
|
v-if="can('episode:upload')"
|
||||||
@click="openCreate"
|
type="primary"
|
||||||
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
|
class="primary-button"
|
||||||
|
:disabled="!selectedDramaId"
|
||||||
|
@click="openCreate"
|
||||||
|
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
|
||||||
|
></div
|
||||||
></div
|
></div
|
||||||
>
|
>
|
||||||
<section class="panel episode-panel">
|
<section class="panel episode-panel">
|
||||||
@@ -672,4 +704,14 @@ onMounted(async () => {
|
|||||||
></video>
|
></video>
|
||||||
<el-empty v-else description="该剧集还没有上传视频" />
|
<el-empty v-else description="该剧集还没有上传视频" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<BatchUploadDialog
|
||||||
|
v-if="selectedDramaId"
|
||||||
|
v-model="batchUploadVisible"
|
||||||
|
:drama-id="selectedDramaId"
|
||||||
|
:next-episode-number="batchNextNumber"
|
||||||
|
:default-unlock-coin="selectedDrama?.unlockCoin ?? 0"
|
||||||
|
:existing-numbers="existingEpisodeNumbers"
|
||||||
|
@refreshed="loadEpisodes"
|
||||||
|
@created="onBatchEpisodesCreated"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 剧集批量上传弹窗:多选、队列、总进度、续传与批量建集入口 -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { AppIcons } from '@/constants/icons'
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
|
import CreateEpisodesDialog from './CreateEpisodesDialog.vue'
|
||||||
|
import UploadTaskRow from './UploadTaskRow.vue'
|
||||||
|
import { useUploadQueue } from './useUploadQueue'
|
||||||
|
import { MAX_VIDEO_SIZE } from './videoLimit'
|
||||||
|
import type { UploadSessionRecord } from './uploadSessions'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
dramaId: string | number
|
||||||
|
nextEpisodeNumber: number
|
||||||
|
defaultUnlockCoin: number
|
||||||
|
existingNumbers: number[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
created: []
|
||||||
|
refreshed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm']
|
||||||
|
const ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*'
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dramaKey = computed(() => String(props.dramaId || ''))
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const resumeFileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const rejectedNames = ref<string[]>([])
|
||||||
|
const pendingSessions = ref<UploadSessionRecord[]>([])
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const resumePromptShown = ref(false)
|
||||||
|
|
||||||
|
const queue = useUploadQueue({ getDramaId: () => dramaKey.value })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.dramaId,
|
||||||
|
() => {
|
||||||
|
queue.clear()
|
||||||
|
resumePromptShown.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const canCreateEpisodes = computed(() => queue.doneItems.value.length > 0)
|
||||||
|
|
||||||
|
function extensionOf(fileName: string): string {
|
||||||
|
const parts = String(fileName || '').split('.')
|
||||||
|
return (parts.pop() || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFile(file: File): string {
|
||||||
|
if (!VIDEO_EXTENSIONS.includes(extensionOf(file.name))) {
|
||||||
|
return t('episodeBatch.invalidType')
|
||||||
|
}
|
||||||
|
if (file.size > MAX_VIDEO_SIZE) {
|
||||||
|
return t('episodeBatch.oversize')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickFiles(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files || [])
|
||||||
|
input.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
const accepted: File[] = []
|
||||||
|
const rejected: string[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
const reason = validateFile(file)
|
||||||
|
if (reason) {
|
||||||
|
rejected.push(`${file.name}: ${reason}`)
|
||||||
|
} else {
|
||||||
|
accepted.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rejectedNames.value = rejected
|
||||||
|
if (!accepted.length) return
|
||||||
|
queue.enqueue(accepted)
|
||||||
|
queue.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker(): void {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||||
|
if (!queue.hasActiveUpload.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => queue.hasActiveUpload.value,
|
||||||
|
(active) => {
|
||||||
|
if (active) {
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function promptResumeIfNeeded(): Promise<void> {
|
||||||
|
if (resumePromptShown.value || !dramaKey.value) return
|
||||||
|
const sessions = queue.getPendingSessions()
|
||||||
|
if (!sessions.length) return
|
||||||
|
resumePromptShown.value = true
|
||||||
|
pendingSessions.value = sessions
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('episodeBatch.resumeConfirm', { count: sessions.length }),
|
||||||
|
t('episodeBatch.resumeTitle'),
|
||||||
|
{
|
||||||
|
confirmButtonText: t('episodeBatch.resumeYes'),
|
||||||
|
cancelButtonText: t('episodeBatch.resumeNo'),
|
||||||
|
type: 'info'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resumeFileInputRef.value?.click()
|
||||||
|
} catch {
|
||||||
|
queue.abandonSessions(sessions)
|
||||||
|
pendingSessions.value = []
|
||||||
|
ElMessage.info(t('episodeBatch.resumeAbandoned'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResumeFiles(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files || [])
|
||||||
|
input.value = ''
|
||||||
|
const sessions = pendingSessions.value
|
||||||
|
if (!files.length || !sessions.length) return
|
||||||
|
const { matched, unmatched } = await queue.resumeFromSessions(sessions, files)
|
||||||
|
pendingSessions.value = []
|
||||||
|
if (unmatched.length) {
|
||||||
|
ElMessage.warning(t('episodeBatch.resumeUnmatched', { count: unmatched.length }))
|
||||||
|
}
|
||||||
|
if (matched.length) {
|
||||||
|
ElMessage.success(t('episodeBatch.resumeMatched', { count: matched.length }))
|
||||||
|
queue.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeClose(done: () => void): void {
|
||||||
|
if (!queue.hasActiveUpload.value) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessageBox.confirm(t('episodeBatch.closeWhileUploading'), t('common.reminder'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('common.ok'),
|
||||||
|
cancelButtonText: t('common.cancel')
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
queue.pause()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (open) => {
|
||||||
|
if (open) {
|
||||||
|
void promptResumeIfNeeded()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreateDialog(): void {
|
||||||
|
if (!canCreateEpisodes.value) return
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEpisodesCreated(): void {
|
||||||
|
createVisible.value = false
|
||||||
|
emit('created')
|
||||||
|
visible.value = false
|
||||||
|
queue.clear()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="t('episodeBatch.title')"
|
||||||
|
width="720px"
|
||||||
|
class="batch-upload-dialog"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:before-close="beforeClose"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
:accept="ACCEPT_ATTR"
|
||||||
|
@change="onPickFiles"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="resumeFileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
:accept="ACCEPT_ATTR"
|
||||||
|
@change="onResumeFiles"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="batch-upload-toolbar">
|
||||||
|
<el-button type="primary" @click="openFilePicker">
|
||||||
|
<AppIcon :icon="AppIcons.upload" />
|
||||||
|
{{ t('episodeBatch.selectFiles') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="!queue.items.value.length" @click="queue.pause()">
|
||||||
|
{{ t('episodeBatch.pause') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="!queue.items.value.length" @click="queue.start()">
|
||||||
|
{{ t('episodeBatch.resume') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:disabled="!queue.items.value.some((item) => item.status === 'failed')"
|
||||||
|
@click="queue.retryAll()"
|
||||||
|
>
|
||||||
|
{{ t('episodeBatch.retryAll') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="batch-upload-hint">{{ t('episodeBatch.hint') }}</p>
|
||||||
|
|
||||||
|
<div v-if="rejectedNames.length" class="batch-upload-rejected">
|
||||||
|
<p v-for="name in rejectedNames" :key="name">{{ name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="queue.items.value.length" class="batch-upload-overall">
|
||||||
|
<span>{{ t('episodeBatch.overallProgress') }}</span>
|
||||||
|
<el-progress :percentage="queue.overallPercent.value" :stroke-width="12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="batch-upload-list">
|
||||||
|
<UploadTaskRow
|
||||||
|
v-for="item in queue.items.value"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
@retry="queue.retry"
|
||||||
|
@remove="queue.remove"
|
||||||
|
/>
|
||||||
|
<el-empty v-if="!queue.items.value.length" :description="t('episodeBatch.empty')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canCreateEpisodes" @click="openCreateDialog">
|
||||||
|
{{ t('episodeBatch.createEpisodes') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<CreateEpisodesDialog
|
||||||
|
v-model="createVisible"
|
||||||
|
:drama-id="dramaId"
|
||||||
|
:done-items="queue.doneItems.value"
|
||||||
|
:next-episode-number="nextEpisodeNumber"
|
||||||
|
:default-unlock-coin="defaultUnlockCoin"
|
||||||
|
:existing-numbers="existingNumbers"
|
||||||
|
@settled="emit('refreshed')"
|
||||||
|
@created="onEpisodesCreated"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.batch-upload-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-rejected {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--el-color-danger) 8%, transparent);
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-rejected p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-overall {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 批量创建分集:自然排序预填集号与标题,冲突标红,失败可重试 -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createEpisodeApi } from '@/api/admin/episode'
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
|
import type { UploadQueueItem } from './useUploadQueue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
dramaId: string | number
|
||||||
|
doneItems: UploadQueueItem[]
|
||||||
|
nextEpisodeNumber: number
|
||||||
|
defaultUnlockCoin: number
|
||||||
|
existingNumbers: number[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
settled: []
|
||||||
|
created: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface EpisodeDraft {
|
||||||
|
key: string
|
||||||
|
sourceName: string
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
isFree: boolean
|
||||||
|
videoUrl: string
|
||||||
|
durationSec: number
|
||||||
|
saved: boolean
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT_STATUS = 'draft'
|
||||||
|
const rows = ref<EpisodeDraft[]>([])
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
function baseName(fileName: string): string {
|
||||||
|
const dot = fileName.lastIndexOf('.')
|
||||||
|
if (dot <= 0) return fileName
|
||||||
|
return fileName.slice(0, dot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNatural(left: string, right: string): number {
|
||||||
|
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(): void {
|
||||||
|
const sorted = [...props.doneItems].sort((left, right) =>
|
||||||
|
compareNatural(baseName(left.fileName), baseName(right.fileName))
|
||||||
|
)
|
||||||
|
let number = props.nextEpisodeNumber
|
||||||
|
rows.value = sorted.map((item) => ({
|
||||||
|
key: item.id,
|
||||||
|
sourceName: item.fileName,
|
||||||
|
number: number++,
|
||||||
|
title: baseName(item.fileName),
|
||||||
|
isFree: true,
|
||||||
|
videoUrl: item.url || '',
|
||||||
|
durationSec: item.durationSec || 0,
|
||||||
|
saved: false,
|
||||||
|
error: ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) buildRows()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function conflictOf(row: EpisodeDraft): boolean {
|
||||||
|
if (row.saved) return false
|
||||||
|
const duplicated = rows.value.filter((item) => item.number === row.number).length > 1
|
||||||
|
return duplicated || props.existingNumbers.includes(Number(row.number))
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) return error.message
|
||||||
|
return t('episodeBatch.createFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOne(row: EpisodeDraft): Promise<void> {
|
||||||
|
if (!row.videoUrl) {
|
||||||
|
row.error = t('episodeBatch.missingVideo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createEpisodeApi({
|
||||||
|
dramaId: props.dramaId as unknown as number,
|
||||||
|
number: row.number,
|
||||||
|
title: row.title.trim(),
|
||||||
|
durationSec: row.durationSec,
|
||||||
|
videoUrl: row.videoUrl,
|
||||||
|
isFree: row.isFree,
|
||||||
|
unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin,
|
||||||
|
status: DRAFT_STATUS
|
||||||
|
})
|
||||||
|
row.saved = true
|
||||||
|
row.error = ''
|
||||||
|
} catch (error) {
|
||||||
|
row.error = errorText(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
if (submitting.value) return
|
||||||
|
if (rows.value.some((row) => conflictOf(row))) {
|
||||||
|
ElMessage.warning(t('episodeBatch.fixConflict'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
for (const row of rows.value) {
|
||||||
|
if (!row.saved) await createOne(row)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
emit('settled')
|
||||||
|
if (rows.value.every((row) => row.saved)) {
|
||||||
|
ElMessage.success(t('episodeBatch.createAllSuccess'))
|
||||||
|
emit('created')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.warning(t('episodeBatch.createPartial'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFailed(): boolean {
|
||||||
|
return rows.value.some((row) => !row.saved && !!row.error)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="t('episodeBatch.createTitle')"
|
||||||
|
width="760px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="create-episode-head">
|
||||||
|
<span>{{ t('episodeBatch.colSource') }}</span>
|
||||||
|
<span>{{ t('episodeBatch.colNumber') }}</span>
|
||||||
|
<span>{{ t('episodeBatch.colTitle') }}</span>
|
||||||
|
<span>{{ t('episodeBatch.colFree') }}</span>
|
||||||
|
<span>{{ t('episodeBatch.colResult') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="create-episode-list">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.key"
|
||||||
|
class="create-episode-row"
|
||||||
|
:class="{ 'is-conflict': conflictOf(row), 'is-saved': row.saved }"
|
||||||
|
>
|
||||||
|
<b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b>
|
||||||
|
<el-input-number
|
||||||
|
v-model="row.number"
|
||||||
|
:min="1"
|
||||||
|
:max="999"
|
||||||
|
:disabled="row.saved"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<el-input v-model="row.title" maxlength="100" :disabled="row.saved" />
|
||||||
|
<el-switch v-model="row.isFree" :disabled="row.saved" />
|
||||||
|
<span class="create-episode-result">
|
||||||
|
{{
|
||||||
|
row.saved
|
||||||
|
? t('episodeBatch.createOk')
|
||||||
|
: row.error || (conflictOf(row) ? t('episodeBatch.numberConflict') : '')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="closeDialog">{{ t('common.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submit">
|
||||||
|
{{ hasFailed() ? t('episodeBatch.retryFailed') : t('episodeBatch.submitCreate') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-episode-head,
|
||||||
|
.create-episode-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 1.2fr) 120px minmax(140px, 1.4fr) 48px minmax(80px, 0.8fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-source {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-result {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-row.is-conflict :deep(.el-input-number) {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-danger) inset;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-row.is-conflict .create-episode-result {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-episode-row.is-saved .create-episode-result {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 批量上传队列单行:文件名、大小、进度、状态与操作 -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
|
import type { UploadQueueItem } from './useUploadQueue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UploadQueueItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: [id: string]
|
||||||
|
remove: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const BYTES_PER_KB = 1024
|
||||||
|
const BYTES_PER_MB = 1024 * 1024
|
||||||
|
|
||||||
|
const sizeLabel = computed(() => {
|
||||||
|
const size = props.item.fileSize
|
||||||
|
if (size >= BYTES_PER_MB) {
|
||||||
|
return `${(size / BYTES_PER_MB).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
return `${Math.max(1, Math.round(size / BYTES_PER_KB))} KB`
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusLabel = computed(() => t(`episodeBatch.status.${props.item.status}`))
|
||||||
|
|
||||||
|
const statusType = computed(() => {
|
||||||
|
switch (props.item.status) {
|
||||||
|
case 'done':
|
||||||
|
return 'success'
|
||||||
|
case 'failed':
|
||||||
|
return 'danger'
|
||||||
|
case 'paused':
|
||||||
|
return 'warning'
|
||||||
|
case 'merging':
|
||||||
|
case 'uploading':
|
||||||
|
return ''
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRetry = computed(() => props.item.status === 'failed')
|
||||||
|
const canRemove = computed(
|
||||||
|
() => props.item.status !== 'uploading' && props.item.status !== 'merging'
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upload-task-row" :class="{ 'is-failed': item.status === 'failed' }">
|
||||||
|
<div class="upload-task-row__meta">
|
||||||
|
<b class="upload-task-row__name" :title="item.fileName">{{ item.fileName }}</b>
|
||||||
|
<small>{{ sizeLabel }}</small>
|
||||||
|
<el-tag :type="statusType" effect="light" size="small">{{ statusLabel }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="item.percent"
|
||||||
|
:status="
|
||||||
|
item.status === 'failed' ? 'exception' : item.status === 'done' ? 'success' : undefined
|
||||||
|
"
|
||||||
|
:stroke-width="10"
|
||||||
|
/>
|
||||||
|
<p v-if="item.error" class="upload-task-row__error">{{ item.error }}</p>
|
||||||
|
<div class="upload-task-row__actions">
|
||||||
|
<el-button v-if="canRetry" size="small" type="primary" @click="emit('retry', item.id)">
|
||||||
|
{{ t('episodeBatch.retry') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="canRemove" size="small" type="danger" text @click="emit('remove', item.id)">
|
||||||
|
{{ t('episodeBatch.remove') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-task-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-task-row__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-task-row__name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-task-row__error {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-task-row__actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-task-row.is-failed {
|
||||||
|
background: color-mix(in srgb, var(--el-color-danger) 6%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
/** 剧集视频大小上限,单文件上传与批量上传共用。 */
|
||||||
|
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024
|
||||||
Reference in new issue
Block a user