chore(merge): 将 develop 同步到 release/v1.0.0
This commit is contained in:
commit
430ba59613
60 files changed
+6769
-426
No files matched your search
@@ -0,0 +1,263 @@
|
||||
<!-- 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>
|
||||
Reference in new issue
Block a user