264 lines
6.5 KiB
Vue
264 lines
6.5 KiB
Vue
<!-- 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 = withDefaults(
|
||
defineProps<{
|
||
modelValue: boolean
|
||
dramaId: string | number
|
||
doneItems: UploadQueueItem[]
|
||
nextEpisodeNumber: number
|
||
defaultUnlockCoin: number
|
||
existingNumbers: number[]
|
||
/** 所属短剧免费集数;不传默认 0 */
|
||
freeEpisodes?: number
|
||
}>(),
|
||
{
|
||
freeEpisodes: 0
|
||
}
|
||
)
|
||
|
||
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 PUBLISHED_STATUS = 'published'
|
||
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 nextFreeNumber(start: number, used: Set<number>): number {
|
||
let candidate = start
|
||
while (used.has(candidate)) {
|
||
candidate += 1
|
||
}
|
||
return candidate
|
||
}
|
||
|
||
function buildRows(): void {
|
||
const sorted = [...props.doneItems].sort((left, right) =>
|
||
compareNatural(baseName(left.fileName), baseName(right.fileName))
|
||
)
|
||
const used = new Set(props.existingNumbers)
|
||
let cursor = props.nextEpisodeNumber
|
||
const freeLimit = props.freeEpisodes
|
||
rows.value = sorted.map((item) => {
|
||
const number = nextFreeNumber(cursor, used)
|
||
used.add(number)
|
||
cursor = number + 1
|
||
const isFree = number <= freeLimit
|
||
return {
|
||
key: item.id,
|
||
sourceName: item.fileName,
|
||
number,
|
||
title: baseName(item.fileName),
|
||
isFree,
|
||
videoUrl: item.url || '',
|
||
durationSec: item.durationSec || 0,
|
||
saved: false,
|
||
error: ''
|
||
}
|
||
})
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (open) buildRows()
|
||
}
|
||
)
|
||
|
||
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: baseName(row.sourceName),
|
||
durationSec: row.durationSec,
|
||
videoUrl: row.videoUrl,
|
||
isFree: row.isFree,
|
||
unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin,
|
||
status: PUBLISHED_STATUS
|
||
})
|
||
row.saved = true
|
||
row.error = ''
|
||
} catch (error) {
|
||
row.error = errorText(error)
|
||
}
|
||
}
|
||
|
||
async function submit(): Promise<void> {
|
||
if (submitting.value) 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)"
|
||
>
|
||
<p class="create-episode-hint">{{ t('episodeBatch.createHint') }}</p>
|
||
<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-saved': row.saved }"
|
||
>
|
||
<b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b>
|
||
<span class="create-episode-number">{{ row.number }}</span>
|
||
<span class="create-episode-title" :title="row.title">{{ row.title }}</span>
|
||
<span class="create-episode-free" :class="row.isFree ? 'is-free' : 'is-paid'">
|
||
{{ row.isFree ? t('episodeBatch.labelFree') : t('episodeBatch.labelPaid') }}
|
||
</span>
|
||
<span class="create-episode-result">
|
||
{{ row.saved ? t('episodeBatch.createOk') : row.error }}
|
||
</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-hint {
|
||
margin: 0 0 12px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.create-episode-head,
|
||
.create-episode-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(120px, 1.2fr) 72px minmax(140px, 1.4fr) 56px minmax(80px, 0.8fr);
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.create-episode-free {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.create-episode-free.is-free {
|
||
color: var(--el-color-success);
|
||
}
|
||
|
||
.create-episode-free.is-paid {
|
||
color: var(--el-color-warning);
|
||
}
|
||
|
||
.create-episode-head {
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.create-episode-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
max-height: 420px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.create-episode-source,
|
||
.create-episode-title {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.create-episode-number {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.create-episode-result {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.create-episode-row.is-saved .create-episode-result {
|
||
color: var(--el-color-success);
|
||
}
|
||
</style>
|