10 changed files with 595 additions and 471 deletions

No files matched your search

+5
View File
@@ -1,4 +1,9 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then
echo "commit message contains AI attribution, remove it before committing" >&2
exit 1
fi
pnpm commitlint --edit "$1" pnpm commitlint --edit "$1"
+7 -5
View File
@@ -537,7 +537,7 @@ export default {
retry: 'Retry', retry: 'Retry',
retryAll: 'Retry all', retryAll: 'Retry all',
remove: 'Remove', remove: 'Remove',
hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each; up to 2 files in parallel.', hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each',
invalidType: 'Only MP4, MOV, MKV, WEBM are allowed', invalidType: 'Only MP4, MOV, MKV, WEBM are allowed',
oversize: 'File exceeds 500MB and was not queued', oversize: 'File exceeds 500MB and was not queued',
overallProgress: 'Overall progress', overallProgress: 'Overall progress',
@@ -554,19 +554,21 @@ export default {
resumeMatched: 'Matched {count} file(s) and resumed', resumeMatched: 'Matched {count} file(s) and resumed',
resumeUnmatched: '{count} session(s) could not match local files; pick again manually', resumeUnmatched: '{count} session(s) could not match local files; pick again manually',
createTitle: 'Create episodes in batch', createTitle: 'Create episodes in batch',
createHint:
'Title uses the video file name; episode numbers are assigned automatically. Free vs paid follows the drama free-episode count; episodes beyond that are paid. You can edit them later in episode settings.',
colSource: 'Source file', colSource: 'Source file',
colNumber: 'Episode No.', colNumber: 'Episode No.',
colTitle: 'Title', colTitle: 'Title',
colFree: 'Free', colFree: 'Pricing',
labelFree: 'Free',
labelPaid: 'Paid',
colResult: 'Result', colResult: 'Result',
numberConflict: 'Episode number conflict',
missingVideo: 'Missing video URL', missingVideo: 'Missing video URL',
createOk: 'Created', createOk: 'Created',
createFailed: 'Create failed', createFailed: 'Create failed',
createAllSuccess: 'All episodes created', createAllSuccess: 'All episodes created',
createPartial: 'Some episodes failed; you can retry', createPartial: 'Some episodes failed; you can retry',
fixConflict: 'Resolve episode number conflicts first', submitCreate: 'Create',
submitCreate: 'Submit',
retryFailed: 'Retry failed', retryFailed: 'Retry failed',
status: { status: {
waiting: 'Waiting', waiting: 'Waiting',
+7 -5
View File
@@ -531,7 +531,7 @@ export default {
retry: '重试', retry: '重试',
retryAll: '全部重试', retryAll: '全部重试',
remove: '移除', remove: '移除',
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;同时最多上传 2 个文件。', hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB',
invalidType: '仅支持 MP4、MOV、MKV、WEBM', invalidType: '仅支持 MP4、MOV、MKV、WEBM',
oversize: '文件超过 500MB,未入队', oversize: '文件超过 500MB,未入队',
overallProgress: '总进度', overallProgress: '总进度',
@@ -546,19 +546,21 @@ export default {
resumeMatched: '已匹配 {count} 个文件并开始续传', resumeMatched: '已匹配 {count} 个文件并开始续传',
resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选', resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选',
createTitle: '批量创建分集', createTitle: '批量创建分集',
createHint:
'标题取视频文件名,集号自动顺延分配;免费集数由短剧管理中的「免费集数」配置决定,超出部分为收费集;创建后可在分集编辑中修改',
colSource: '源文件', colSource: '源文件',
colNumber: '集号', colNumber: '集号',
colTitle: '标题', colTitle: '标题',
colFree: '免费', colFree: '收费类型',
labelFree: '免费',
labelPaid: '收费',
colResult: '结果', colResult: '结果',
numberConflict: '集号冲突',
missingVideo: '缺少视频地址', missingVideo: '缺少视频地址',
createOk: '已创建', createOk: '已创建',
createFailed: '创建失败', createFailed: '创建失败',
createAllSuccess: '全部分集已创建', createAllSuccess: '全部分集已创建',
createPartial: '部分分集创建失败,可重试', createPartial: '部分分集创建失败,可重试',
fixConflict: '请先解决集号冲突', submitCreate: '创建',
submitCreate: '提交创建',
retryFailed: '重试失败项', retryFailed: '重试失败项',
status: { status: {
waiting: '等待中', waiting: '等待中',
@@ -1,26 +1,19 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama' import { pageDramasApi } from '@/api/admin/drama'
import { import { deleteEpisodeApi, listEpisodesApi, sortEpisodesApi } from '@/api/admin/episode'
createEpisodeApi,
deleteEpisodeApi,
listEpisodesApi,
sortEpisodesApi,
updateEpisodeApi
} from '@/api/admin/episode'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status' import { statusClass } from '@/utils/status'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
import BatchUploadDialog from './upload/BatchUploadDialog.vue' import BatchUploadDialog from './upload/BatchUploadDialog.vue'
import EpisodeFormDialog from './upload/EpisodeFormDialog.vue'
import { listPendingSessions } from './upload/uploadSessions' import { listPendingSessions } from './upload/uploadSessions'
import { MAX_VIDEO_SIZE } from './upload/videoLimit'
const route = useRoute() const route = useRoute()
const { can } = useRbac() const { can } = useRbac()
@@ -30,72 +23,17 @@ const loading = ref(false)
const dramas = ref([]) const dramas = ref([])
const episodes = ref([]) const episodes = ref([])
const selectedDramaId = ref(undefined) const selectedDramaId = ref(undefined)
const dialogVisible = ref(false)
const batchUploadVisible = ref(false) const batchUploadVisible = ref(false)
const batchNextNumber = computed(() => nextNumber())
const saving = ref(false)
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const videoError = ref('')
const videoFileName = ref('')
const previewVisible = ref(false) const previewVisible = ref(false)
const previewEpisode = ref(null) const previewEpisode = ref(null)
const editingId = ref(null) const formDialogRef = ref()
const editingCommentCount = ref(0)
const formRef = ref()
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
likeCount: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const VIDEO_EXTENSIONS = ['mp4', 'mov']
const formRules = {
number: [{ required: true, message: '请输入集号', trigger: 'change' }],
title: [{ required: true, message: '请输入剧集标题', trigger: 'blur' }],
videoUrl: [
{
validator: (_rule, value, callback) => {
if (form.status === 'published' && !value) {
callback(new Error('上线剧集必须上传视频'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const batchNextNumber = computed(() => nextNumber())
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value)) const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
const videoDisplayName = computed( const existingEpisodeNumbers = computed(() =>
() => videoFileName.value || (form.videoUrl ? episodeBasename(form.videoUrl) : '尚未选择视频') episodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
) )
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
async function loadDramas() { async function loadDramas() {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 }) const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
dramas.value = res.data?.list || [] dramas.value = res.data?.list || []
@@ -126,10 +64,6 @@ function nextNumber() {
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1 return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
} }
const existingEpisodeNumbers = computed(() =>
episodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
)
function openBatchUpload() { function openBatchUpload() {
if (!selectedDramaId.value) return if (!selectedDramaId.value) return
batchUploadVisible.value = true batchUploadVisible.value = true
@@ -140,132 +74,11 @@ async function onBatchEpisodesCreated() {
} }
function openCreate() { function openCreate() {
editingId.value = null formDialogRef.value?.openCreate()
Object.assign(form, {
number: nextNumber(),
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: selectedDrama.value?.unlockCoin ?? 0,
likeCount: 0,
status: 'draft'
})
videoFileName.value = ''
videoError.value = ''
editingCommentCount.value = 0
dialogVisible.value = true
resetValidation()
} }
function openEdit(row) { function openEdit(row) {
editingId.value = row.id formDialogRef.value?.openEdit(row)
Object.assign(form, {
number: row.number,
title: row.title,
durationSec: row.durationSec || 0,
videoUrl: row.videoUrl || '',
isFree: !!row.isFree,
unlockCoin: row.unlockCoin || 0,
likeCount: row.likeCount ?? 0,
status: row.status
})
videoFileName.value = row.videoUrl ? episodeBasename(row.videoUrl) : ''
videoError.value = ''
editingCommentCount.value = row.commentCount ?? 0
dialogVisible.value = true
resetValidation()
}
async function saveEpisode() {
if (saving.value || uploadingVideo.value) return
try {
await formRef.value?.validate()
} catch {
return
}
const payload = {
dramaId: selectedDramaId.value,
number: form.number,
title: form.title.trim(),
durationSec: form.durationSec || 0,
videoUrl: form.videoUrl.trim(),
isFree: form.isFree,
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
likeCount: form.likeCount,
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateEpisodeApi(editingId.value, payload)
ElMessage.success('剧集已更新')
} else {
await createEpisodeApi(payload)
ElMessage.success('剧集已创建')
}
dialogVisible.value = false
await loadEpisodes()
} finally {
saving.value = false
}
}
function beforeVideoUpload(file) {
videoError.value = ''
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase()
if (!VIDEO_EXTENSIONS.includes(extension)) {
videoError.value = '视频仅支持 MP4 或 MOV 格式'
return false
}
if (file.size > MAX_VIDEO_SIZE) {
videoError.value = '视频大小不能超过 500MB'
return false
}
return true
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
videoError.value = ''
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
form.videoUrl = asset.url
videoFileName.value = options.file.name
if (duration > 0) form.durationSec = duration
await resetValidation()
ElMessage.success('视频已上传')
} catch {
videoError.value = '视频上传失败,请检查网络后重试'
} finally {
uploadingVideo.value = false
}
}
function removeVideo() {
if (uploadingVideo.value) return
form.videoUrl = ''
videoFileName.value = ''
videoError.value = ''
resetValidation()
}
function beforeDialogClose(done) {
if (saving.value || uploadingVideo.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '视频正在上传,请稍候')
return
}
done()
} }
async function confirmDelete(row) { async function confirmDelete(row) {
@@ -497,198 +310,12 @@ onMounted(async () => {
> >
</el-table> </el-table>
</section> </section>
<el-dialog <EpisodeFormDialog
v-model="dialogVisible" ref="formDialogRef"
:title="editingId ? '编辑剧集' : '新增剧集'" :drama-options="selectedDrama"
width="680px" :next-number="batchNextNumber"
class="episode-editor-dialog" @saved="loadEpisodes"
:close-on-click-modal="false" />
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="episode-editor-form"
>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">基础信息</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="集号" prop="number">
<el-input-number
v-model="form.number"
class="episode-editor-number"
controls-position="right"
:min="1"
:max="999"
/>
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="form.durationSec"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
</div>
<el-form-item label="剧集标题" prop="title">
<el-input v-model="form.title" maxlength="100" placeholder="例如:命运的相遇" />
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">视频文件</h3>
<el-form-item prop="videoUrl">
<div class="episode-video-editor">
<div class="episode-video-preview-card">
<video
v-if="form.videoUrl"
:src="form.videoUrl"
controls
preload="metadata"
class="episode-editor-video"
></video>
<el-upload
v-else
drag
class="episode-video-empty-upload"
:show-file-list="false"
accept=".mp4,.mov"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<div class="episode-video-empty-copy">
<AppIcon :icon="AppIcons.videoCameraFront" :size="30" />
<b>拖放或点击上传</b>
<span>MP4、MOV</span>
</div>
</el-upload>
<div v-if="uploadingVideo" class="episode-video-upload-mask">
<span>上传中</span>
<b>{{ videoPercent }}%</b>
</div>
</div>
<div class="episode-video-meta">
<div class="episode-video-status">
<el-tag :type="form.videoUrl ? 'success' : 'info'" effect="light">
{{ form.videoUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="episode-video-filename" :title="videoDisplayName">
{{ videoDisplayName }}
</span>
</div>
<p class="episode-video-hint">支持 MP4、MOV,单个文件不超过 500MB。</p>
<el-alert
v-if="videoError"
:title="videoError"
type="error"
:closable="false"
show-icon
/>
<div class="episode-video-actions">
<el-upload
v-if="form.videoUrl"
:show-file-list="false"
accept=".mp4,.mov"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<el-button :loading="uploadingVideo" :disabled="uploadingVideo">
<AppIcon :icon="AppIcons.upload" />
更换视频
</el-button>
</el-upload>
<el-button
v-if="form.videoUrl"
type="danger"
:disabled="uploadingVideo"
@click="removeVideo"
>
移除
</el-button>
<a
v-if="form.videoUrl"
class="episode-video-link"
:href="form.videoUrl"
target="_blank"
rel="noopener noreferrer"
>
查看视频
</a>
</div>
</div>
</div>
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">互动数据</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="本集点赞数">
<el-input-number
v-model="form.likeCount"
class="episode-editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="本集评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">按集实时统计,不可编辑</p>
</el-form-item>
</div>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">付费与状态</h3>
<div class="episode-editor-grid episode-editor-grid--three">
<el-form-item label="是否免费">
<el-switch v-model="form.isFree" />
</el-form-item>
<el-form-item v-show="!form.isFree" label="解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
</div>
</section>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingVideo"
@click="saveEpisode"
>
保存
</el-button>
</template>
</el-dialog>
<el-dialog <el-dialog
v-model="previewVisible" v-model="previewVisible"
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`" :title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
@@ -710,6 +337,7 @@ onMounted(async () => {
:drama-id="selectedDramaId" :drama-id="selectedDramaId"
:next-episode-number="batchNextNumber" :next-episode-number="batchNextNumber"
:default-unlock-coin="selectedDrama?.unlockCoin ?? 0" :default-unlock-coin="selectedDrama?.unlockCoin ?? 0"
:free-episodes="selectedDrama?.freeEpisodes ?? 0"
:existing-numbers="existingEpisodeNumbers" :existing-numbers="existingEpisodeNumbers"
@refreshed="loadEpisodes" @refreshed="loadEpisodes"
@created="onBatchEpisodesCreated" @created="onBatchEpisodesCreated"
@@ -9,16 +9,23 @@ import { useI18n } from '@/hooks/web/useI18n'
import CreateEpisodesDialog from './CreateEpisodesDialog.vue' import CreateEpisodesDialog from './CreateEpisodesDialog.vue'
import UploadTaskRow from './UploadTaskRow.vue' import UploadTaskRow from './UploadTaskRow.vue'
import { useUploadQueue } from './useUploadQueue' import { useUploadQueue } from './useUploadQueue'
import { MAX_VIDEO_SIZE } from './videoLimit' import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
import type { UploadSessionRecord } from './uploadSessions' import type { UploadSessionRecord } from './uploadSessions'
const props = defineProps<{ const props = withDefaults(
modelValue: boolean defineProps<{
dramaId: string | number modelValue: boolean
nextEpisodeNumber: number dramaId: string | number
defaultUnlockCoin: number nextEpisodeNumber: number
existingNumbers: number[] defaultUnlockCoin: number
}>() existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
@@ -28,9 +35,6 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm']
const ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*'
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value) set: (value: boolean) => emit('update:modelValue', value)
@@ -208,7 +212,7 @@ function onEpisodesCreated(): void {
type="file" type="file"
multiple multiple
hidden hidden
:accept="ACCEPT_ATTR" :accept="VIDEO_ACCEPT_ATTR"
@change="onPickFiles" @change="onPickFiles"
/> />
<input <input
@@ -216,7 +220,7 @@ function onEpisodesCreated(): void {
type="file" type="file"
multiple multiple
hidden hidden
:accept="ACCEPT_ATTR" :accept="VIDEO_ACCEPT_ATTR"
@change="onResumeFiles" @change="onResumeFiles"
/> />
@@ -275,6 +279,7 @@ function onEpisodesCreated(): void {
:done-items="queue.doneItems.value" :done-items="queue.doneItems.value"
:next-episode-number="nextEpisodeNumber" :next-episode-number="nextEpisodeNumber"
:default-unlock-coin="defaultUnlockCoin" :default-unlock-coin="defaultUnlockCoin"
:free-episodes="freeEpisodes"
:existing-numbers="existingNumbers" :existing-numbers="existingNumbers"
@settled="emit('refreshed')" @settled="emit('refreshed')"
@created="onEpisodesCreated" @created="onEpisodesCreated"
@@ -1,5 +1,5 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<!-- 批量创建分集:自然排序预填集号与标题,冲突标红,失败可重试 --> <!-- 批量创建分集确认清单:标题取文件名,集号自动顺延,失败可重试 -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
@@ -8,14 +8,21 @@ import { createEpisodeApi } from '@/api/admin/episode'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import type { UploadQueueItem } from './useUploadQueue' import type { UploadQueueItem } from './useUploadQueue'
const props = defineProps<{ const props = withDefaults(
modelValue: boolean defineProps<{
dramaId: string | number modelValue: boolean
doneItems: UploadQueueItem[] dramaId: string | number
nextEpisodeNumber: number doneItems: UploadQueueItem[]
defaultUnlockCoin: number nextEpisodeNumber: number
existingNumbers: number[] defaultUnlockCoin: number
}>() existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
@@ -37,7 +44,7 @@ interface EpisodeDraft {
error: string error: string
} }
const DRAFT_STATUS = 'draft' const PUBLISHED_STATUS = 'published'
const rows = ref<EpisodeDraft[]>([]) const rows = ref<EpisodeDraft[]>([])
const submitting = ref(false) const submitting = ref(false)
@@ -51,22 +58,39 @@ function compareNatural(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }) 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 { function buildRows(): void {
const sorted = [...props.doneItems].sort((left, right) => const sorted = [...props.doneItems].sort((left, right) =>
compareNatural(baseName(left.fileName), baseName(right.fileName)) compareNatural(baseName(left.fileName), baseName(right.fileName))
) )
let number = props.nextEpisodeNumber const used = new Set(props.existingNumbers)
rows.value = sorted.map((item) => ({ let cursor = props.nextEpisodeNumber
key: item.id, const freeLimit = props.freeEpisodes
sourceName: item.fileName, rows.value = sorted.map((item) => {
number: number++, const number = nextFreeNumber(cursor, used)
title: baseName(item.fileName), used.add(number)
isFree: true, cursor = number + 1
videoUrl: item.url || '', const isFree = number <= freeLimit
durationSec: item.durationSec || 0, return {
saved: false, key: item.id,
error: '' sourceName: item.fileName,
})) number,
title: baseName(item.fileName),
isFree,
videoUrl: item.url || '',
durationSec: item.durationSec || 0,
saved: false,
error: ''
}
})
} }
watch( watch(
@@ -76,12 +100,6 @@ watch(
} }
) )
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 { function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message if (error instanceof Error && error.message) return error.message
return t('episodeBatch.createFailed') return t('episodeBatch.createFailed')
@@ -96,12 +114,12 @@ async function createOne(row: EpisodeDraft): Promise<void> {
await createEpisodeApi({ await createEpisodeApi({
dramaId: props.dramaId as unknown as number, dramaId: props.dramaId as unknown as number,
number: row.number, number: row.number,
title: row.title.trim(), title: baseName(row.sourceName),
durationSec: row.durationSec, durationSec: row.durationSec,
videoUrl: row.videoUrl, videoUrl: row.videoUrl,
isFree: row.isFree, isFree: row.isFree,
unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin, unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin,
status: DRAFT_STATUS status: PUBLISHED_STATUS
}) })
row.saved = true row.saved = true
row.error = '' row.error = ''
@@ -112,10 +130,6 @@ async function createOne(row: EpisodeDraft): Promise<void> {
async function submit(): Promise<void> { async function submit(): Promise<void> {
if (submitting.value) return if (submitting.value) return
if (rows.value.some((row) => conflictOf(row))) {
ElMessage.warning(t('episodeBatch.fixConflict'))
return
}
submitting.value = true submitting.value = true
try { try {
for (const row of rows.value) { for (const row of rows.value) {
@@ -150,6 +164,7 @@ function hasFailed(): boolean {
:close-on-click-modal="false" :close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<p class="create-episode-hint">{{ t('episodeBatch.createHint') }}</p>
<div class="create-episode-head"> <div class="create-episode-head">
<span>{{ t('episodeBatch.colSource') }}</span> <span>{{ t('episodeBatch.colSource') }}</span>
<span>{{ t('episodeBatch.colNumber') }}</span> <span>{{ t('episodeBatch.colNumber') }}</span>
@@ -162,24 +177,16 @@ function hasFailed(): boolean {
v-for="row in rows" v-for="row in rows"
:key="row.key" :key="row.key"
class="create-episode-row" class="create-episode-row"
:class="{ 'is-conflict': conflictOf(row), 'is-saved': row.saved }" :class="{ 'is-saved': row.saved }"
> >
<b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b> <b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b>
<el-input-number <span class="create-episode-number">{{ row.number }}</span>
v-model="row.number" <span class="create-episode-title" :title="row.title">{{ row.title }}</span>
:min="1" <span class="create-episode-free" :class="row.isFree ? 'is-free' : 'is-paid'">
:max="999" {{ row.isFree ? t('episodeBatch.labelFree') : t('episodeBatch.labelPaid') }}
:disabled="row.saved" </span>
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"> <span class="create-episode-result">
{{ {{ row.saved ? t('episodeBatch.createOk') : row.error }}
row.saved
? t('episodeBatch.createOk')
: row.error || (conflictOf(row) ? t('episodeBatch.numberConflict') : '')
}}
</span> </span>
</div> </div>
</div> </div>
@@ -193,14 +200,33 @@ function hasFailed(): boolean {
</template> </template>
<style scoped> <style scoped>
.create-episode-hint {
margin: 0 0 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.create-episode-head, .create-episode-head,
.create-episode-row { .create-episode-row {
display: grid; display: grid;
grid-template-columns: minmax(120px, 1.2fr) 120px minmax(140px, 1.4fr) 48px minmax(80px, 0.8fr); grid-template-columns: minmax(120px, 1.2fr) 72px minmax(140px, 1.4fr) 56px minmax(80px, 0.8fr);
gap: 8px; gap: 8px;
align-items: center; 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 { .create-episode-head {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -215,26 +241,22 @@ function hasFailed(): boolean {
overflow: auto; overflow: auto;
} }
.create-episode-source { .create-episode-source,
.create-episode-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.create-episode-number {
font-variant-numeric: tabular-nums;
}
.create-episode-result { .create-episode-result {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 12px; 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 { .create-episode-row.is-saved .create-episode-result {
color: var(--el-color-success); color: var(--el-color-success);
} }
@@ -0,0 +1,280 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集新建/编辑弹窗:表单校验、单集视频上传与保存 -->
<script setup>
import { nextTick, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode'
import { formatCount } from '@/utils/format'
import EpisodeVideoUploader from './EpisodeVideoUploader.vue'
const props = defineProps({
dramaOptions: { type: Object, default: null },
nextNumber: { type: Number, default: 1 },
row: { type: Object, default: null }
})
const emit = defineEmits(['saved'])
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingVideo = ref(false)
const videoFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const formRef = ref()
const uploaderRef = ref()
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
likeCount: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const formRules = {
number: [{ required: true, message: '请输入集号', trigger: 'change' }],
title: [{ required: true, message: '请输入剧集标题', trigger: 'blur' }],
videoUrl: [
{
validator: (_rule, value, callback) => {
if (form.status === 'published' && !value) {
callback(new Error('上线剧集必须上传视频'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function openCreate() {
editingId.value = null
Object.assign(form, {
number: props.nextNumber,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: props.dramaOptions?.unlockCoin ?? 0,
likeCount: 0,
status: 'draft'
})
videoFileName.value = ''
uploaderRef.value?.clearError()
editingCommentCount.value = 0
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
const target = row || props.row
if (!target) return
editingId.value = target.id
Object.assign(form, {
number: target.number,
title: target.title,
durationSec: target.durationSec || 0,
videoUrl: target.videoUrl || '',
isFree: !!target.isFree,
unlockCoin: target.unlockCoin || 0,
likeCount: target.likeCount ?? 0,
status: target.status
})
videoFileName.value = target.videoUrl ? episodeBasename(target.videoUrl) : ''
uploaderRef.value?.clearError()
editingCommentCount.value = target.commentCount ?? 0
dialogVisible.value = true
resetValidation()
}
async function saveEpisode() {
if (saving.value || uploadingVideo.value) return
try {
await formRef.value?.validate()
} catch {
return
}
const dramaId = props.dramaOptions?.id
if (!dramaId) return
const payload = {
dramaId,
number: form.number,
title: form.title.trim(),
durationSec: form.durationSec || 0,
videoUrl: form.videoUrl.trim(),
isFree: form.isFree,
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
likeCount: form.likeCount,
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateEpisodeApi(editingId.value, payload)
ElMessage.success('剧集已更新')
} else {
await createEpisodeApi(payload)
ElMessage.success('剧集已创建')
}
dialogVisible.value = false
emit('saved')
} finally {
saving.value = false
}
}
function beforeDialogClose(done) {
if (saving.value || uploadingVideo.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '视频正在上传,请稍候')
return
}
done()
}
defineExpose({ openCreate, openEdit })
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑剧集' : '新增剧集'"
width="680px"
class="episode-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="episode-editor-form"
>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">基础信息</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="集号" prop="number">
<el-input-number
v-model="form.number"
class="episode-editor-number"
controls-position="right"
:min="1"
:max="999"
/>
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="form.durationSec"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
</div>
<el-form-item label="剧集标题" prop="title">
<el-input v-model="form.title" maxlength="100" placeholder="例如:命运的相遇" />
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">视频文件</h3>
<el-form-item prop="videoUrl">
<EpisodeVideoUploader
ref="uploaderRef"
v-model:video-url="form.videoUrl"
v-model:duration-sec="form.durationSec"
v-model:file-name="videoFileName"
v-model:uploading="uploadingVideo"
@change="resetValidation"
/>
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">互动数据</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="本集点赞数">
<el-input-number
v-model="form.likeCount"
class="episode-editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="本集评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">按集实时统计,不可编辑</p>
</el-form-item>
</div>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">付费与状态</h3>
<div class="episode-editor-grid episode-editor-grid--three">
<el-form-item label="是否免费">
<el-switch v-model="form.isFree" />
</el-form-item>
<el-form-item v-show="!form.isFree" label="解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
</div>
</section>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingVideo"
@click="saveEpisode"
>
保存
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,172 @@
<!-- Author: fruit.com.cn -->
<!-- 单集视频上传:格式/大小校验、分片上传、预览与移除 -->
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
const props = defineProps({
videoUrl: { type: String, default: '' },
durationSec: { type: Number, default: 0 },
fileName: { type: String, default: '' }
})
const emit = defineEmits([
'update:videoUrl',
'update:durationSec',
'update:fileName',
'update:uploading',
'change'
])
const { t } = useI18n()
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const videoError = ref('')
const videoDisplayName = computed(
() => props.fileName || (props.videoUrl ? episodeBasename(props.videoUrl) : '尚未选择视频')
)
watch(uploadingVideo, (value) => emit('update:uploading', value))
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
function clearError() {
videoError.value = ''
}
function beforeVideoUpload(file) {
videoError.value = ''
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase()
if (!VIDEO_EXTENSIONS.includes(extension)) {
videoError.value = t('episodeBatch.invalidType')
return false
}
if (file.size > MAX_VIDEO_SIZE) {
videoError.value = '视频大小不能超过 500MB'
return false
}
return true
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
videoError.value = ''
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
emit('update:videoUrl', asset.url)
emit('update:fileName', options.file.name)
if (duration > 0) emit('update:durationSec', duration)
emit('change')
ElMessage.success('视频已上传')
} catch {
videoError.value = '视频上传失败,请检查网络后重试'
} finally {
uploadingVideo.value = false
}
}
function removeVideo() {
if (uploadingVideo.value) return
emit('update:videoUrl', '')
emit('update:fileName', '')
videoError.value = ''
emit('change')
}
defineExpose({ clearError, uploadingVideo })
</script>
<template>
<div class="episode-video-editor">
<div class="episode-video-preview-card">
<video
v-if="videoUrl"
:src="videoUrl"
controls
preload="metadata"
class="episode-editor-video"
></video>
<el-upload
v-else
drag
class="episode-video-empty-upload"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<div class="episode-video-empty-copy">
<AppIcon :icon="AppIcons.videoCameraFront" :size="30" />
<b>拖放或点击上传</b>
<span>MP4、MOV、MKV、WEBM</span>
</div>
</el-upload>
<div v-if="uploadingVideo" class="episode-video-upload-mask">
<span>上传中</span>
<b>{{ videoPercent }}%</b>
</div>
</div>
<div class="episode-video-meta">
<div class="episode-video-status">
<el-tag :type="videoUrl ? 'success' : 'info'" effect="light">
{{ videoUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="episode-video-filename" :title="videoDisplayName">
{{ videoDisplayName }}
</span>
</div>
<p class="episode-video-hint">{{ t('episodeBatch.hint') }}</p>
<el-alert v-if="videoError" :title="videoError" type="error" :closable="false" show-icon />
<div class="episode-video-actions">
<el-upload
v-if="videoUrl"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<el-button :loading="uploadingVideo" :disabled="uploadingVideo">
<AppIcon :icon="AppIcons.upload" />
更换视频
</el-button>
</el-upload>
<el-button v-if="videoUrl" type="danger" :disabled="uploadingVideo" @click="removeVideo">
移除
</el-button>
<a
v-if="videoUrl"
class="episode-video-link"
:href="videoUrl"
target="_blank"
rel="noopener noreferrer"
>
查看视频
</a>
</div>
</div>
</div>
</template>
@@ -108,6 +108,8 @@ const canRemove = computed(
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: flex-end;
margin-left: auto;
} }
.upload-task-row.is-failed { .upload-task-row.is-failed {
@@ -1,3 +1,9 @@
// Author: fruit.com.cn // Author: fruit.com.cn
/** 剧集视频大小上限,单文件上传与批量上传共用。 */ /** 剧集视频大小上限,单文件上传与批量上传共用。 */
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024 export const MAX_VIDEO_SIZE = 500 * 1024 * 1024
/** 允许的视频扩展名(小写,不含点),单文件与批量上传同源。 */
export const VIDEO_EXTENSIONS: string[] = ['mp4', 'mov', 'mkv', 'webm']
/** el-upload / input[type=file] 的 accept 属性,与 VIDEO_EXTENSIONS 保持一致。 */
export const VIDEO_ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*'