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,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>
|
||||
Reference in new issue
Block a user