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