364 lines
9.4 KiB
Vue
364 lines
9.4 KiB
Vue
<!-- 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, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
|
||
import type { UploadSessionRecord } from './uploadSessions'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
modelValue: boolean
|
||
dramaId: string | number
|
||
nextEpisodeNumber: number
|
||
defaultUnlockCoin: number
|
||
existingNumbers: number[]
|
||
/** 所属短剧免费集数;不传默认 0 */
|
||
freeEpisodes?: number
|
||
}>(),
|
||
{
|
||
freeEpisodes: 0
|
||
}
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: boolean]
|
||
created: []
|
||
refreshed: []
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
|
||
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)
|
||
|
||
const startButtonLabel = computed(() =>
|
||
queue.isUploading.value ? t('episodeBatch.uploadingAction') : t('episodeBatch.startUpload')
|
||
)
|
||
|
||
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)
|
||
}
|
||
|
||
function openFilePicker(): void {
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
function onStartUpload(): void {
|
||
if (!queue.canStart.value) return
|
||
queue.start()
|
||
}
|
||
|
||
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 }))
|
||
}
|
||
}
|
||
|
||
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="VIDEO_ACCEPT_ATTR"
|
||
@change="onPickFiles"
|
||
/>
|
||
<input
|
||
ref="resumeFileInputRef"
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
:accept="VIDEO_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.started.value || !queue.items.value.length"
|
||
@click="queue.pause()"
|
||
>
|
||
{{ t('episodeBatch.pause') }}
|
||
</el-button>
|
||
<el-button :disabled="!queue.canStart.value" @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>
|
||
|
||
<p v-if="queue.showResultSummary.value" class="batch-upload-summary">
|
||
{{
|
||
t('episodeBatch.resultSummary', {
|
||
done: queue.doneItems.value.length,
|
||
failed: queue.failedCount.value
|
||
})
|
||
}}
|
||
</p>
|
||
|
||
<div class="batch-upload-list">
|
||
<UploadTaskRow
|
||
v-for="(item, index) in queue.items.value"
|
||
:key="item.id"
|
||
:item="item"
|
||
:index="index + 1"
|
||
:allow-remove="!queue.started.value && item.status === 'waiting'"
|
||
@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="!queue.canStart.value"
|
||
:loading="queue.isUploading.value"
|
||
@click="onStartUpload"
|
||
>
|
||
{{ startButtonLabel }}
|
||
</el-button>
|
||
<el-button type="success" :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"
|
||
:free-episodes="freeEpisodes"
|
||
: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-summary {
|
||
margin: 0 0 12px;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
|
||
color: var(--el-text-color-primary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.batch-upload-list {
|
||
max-height: 360px;
|
||
overflow: auto;
|
||
}
|
||
</style>
|