399 lines
12 KiB
Vue
399 lines
12 KiB
Vue
<!-- Author: fruit.com.cn -->
|
||
|
||
<script setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { AppIcons } from '@/constants/icons'
|
||
import { pageDramasApi } from '@/api/admin/drama'
|
||
import { deleteEpisodeApi, listEpisodesApi, sortEpisodesApi } from '@/api/admin/episode'
|
||
import { useRbac } from '@/stores/rbac'
|
||
import { formatCount } from '@/utils/format'
|
||
import { statusClass } from '@/utils/status'
|
||
import { useI18n } from '@/hooks/web/useI18n'
|
||
import BatchUploadDialog from './upload/BatchUploadDialog.vue'
|
||
import EpisodeFormDialog from './upload/EpisodeFormDialog.vue'
|
||
import { listPendingSessions } from './upload/uploadSessions'
|
||
|
||
const DEFAULT_PAGE_SIZE = 100
|
||
const PAGE_SIZE_OPTIONS = [50, 100, 200]
|
||
|
||
const route = useRoute()
|
||
const { can } = useRbac()
|
||
const { t } = useI18n()
|
||
|
||
const loading = ref(false)
|
||
const dramas = ref([])
|
||
const episodes = ref([])
|
||
const selectedDramaId = ref(undefined)
|
||
const pageIndex = ref(1)
|
||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||
const batchUploadVisible = ref(false)
|
||
const previewVisible = ref(false)
|
||
const previewEpisode = ref(null)
|
||
const formDialogRef = ref()
|
||
|
||
const batchNextNumber = computed(() => nextNumber())
|
||
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
|
||
const existingEpisodeNumbers = computed(() =>
|
||
episodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
|
||
)
|
||
|
||
const pagedEpisodes = computed(() => {
|
||
const start = (pageIndex.value - 1) * pageSize.value
|
||
return episodes.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
const showPagination = computed(() => {
|
||
const total = episodes.value.length
|
||
if (total <= 0) return false
|
||
return Math.ceil(total / pageSize.value) > 1
|
||
})
|
||
|
||
async function loadDramas() {
|
||
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
|
||
dramas.value = res.data?.list || []
|
||
// 剧 id 是字符串(雪花 id 过 Number 会被四舍五入),这里按字符串比较
|
||
const fromQuery = route.query.drama ? String(route.query.drama) : ''
|
||
if (fromQuery && dramas.value.some((item) => item.id === fromQuery)) {
|
||
selectedDramaId.value = fromQuery
|
||
} else if (!selectedDramaId.value && dramas.value.length) {
|
||
selectedDramaId.value = dramas.value[0].id
|
||
}
|
||
}
|
||
|
||
async function loadEpisodes() {
|
||
if (!selectedDramaId.value) {
|
||
episodes.value = []
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res = await listEpisodesApi(selectedDramaId.value)
|
||
episodes.value = res.data || []
|
||
clampPageIndex()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 刷新后若当前页越界(如删除末页最后一条),夹到最后一页。 */
|
||
function clampPageIndex() {
|
||
const total = episodes.value.length
|
||
const maxPage = Math.max(1, Math.ceil(total / pageSize.value) || 1)
|
||
if (pageIndex.value > maxPage) pageIndex.value = maxPage
|
||
}
|
||
|
||
function nextNumber() {
|
||
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
|
||
}
|
||
|
||
function openBatchUpload() {
|
||
if (!selectedDramaId.value) return
|
||
batchUploadVisible.value = true
|
||
}
|
||
|
||
async function onBatchEpisodesCreated() {
|
||
await loadEpisodes()
|
||
}
|
||
|
||
function openCreate() {
|
||
formDialogRef.value?.openCreate()
|
||
}
|
||
|
||
function openEdit(row) {
|
||
formDialogRef.value?.openEdit(row)
|
||
}
|
||
|
||
async function confirmDelete(row) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除第 ${row.number} 集「${row.title}」吗?`, '删除确认', {
|
||
confirmButtonText: '确认删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
confirmButtonClass: 'el-button--danger'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
await deleteEpisodeApi(row.id)
|
||
ElMessage.success('剧集已删除')
|
||
await loadEpisodes()
|
||
}
|
||
|
||
function openPreview(row) {
|
||
if (!row.videoUrl) {
|
||
ElMessage.warning('该剧集还没有上传视频')
|
||
return
|
||
}
|
||
previewEpisode.value = row
|
||
previewVisible.value = true
|
||
}
|
||
|
||
/** 鼠标移入播放、移出回到首帧,列表里就能直接看到视频内容 */
|
||
function playThumb(event) {
|
||
const video = event.currentTarget
|
||
video.play?.()?.catch(() => {})
|
||
}
|
||
|
||
function stopThumb(event) {
|
||
const video = event.currentTarget
|
||
try {
|
||
video.pause()
|
||
video.currentTime = 0
|
||
} catch {
|
||
// 元数据还没就绪时忽略
|
||
}
|
||
}
|
||
|
||
function episodeIndexInFullList(row) {
|
||
return episodes.value.findIndex((item) => item.id === row.id)
|
||
}
|
||
|
||
function canMoveUp(row) {
|
||
return episodeIndexInFullList(row) > 0
|
||
}
|
||
|
||
function canMoveDown(row) {
|
||
const index = episodeIndexInFullList(row)
|
||
return index >= 0 && index < episodes.value.length - 1
|
||
}
|
||
|
||
/** 上移/下移后把新顺序整表提交,由后端重排 sort。 */
|
||
async function move(row, offset) {
|
||
const index = episodeIndexInFullList(row)
|
||
const target = index + offset
|
||
if (index < 0 || target < 0 || target >= episodes.value.length) return
|
||
const next = [...episodes.value]
|
||
const [moved] = next.splice(index, 1)
|
||
next.splice(target, 0, moved)
|
||
await sortEpisodesApi(next.map((item) => item.id))
|
||
ElMessage.success('播放顺序已更新')
|
||
await loadEpisodes()
|
||
}
|
||
|
||
async function onDramaChange() {
|
||
pageIndex.value = 1
|
||
await loadEpisodes()
|
||
openBatchIfPendingSessions()
|
||
}
|
||
|
||
function handleSizeChange() {
|
||
pageIndex.value = 1
|
||
}
|
||
|
||
/** 进入页面或切换短剧时,若有 24h 内未完成上传则打开批量上传弹窗以提示续传。 */
|
||
function openBatchIfPendingSessions() {
|
||
if (!selectedDramaId.value || !can('episode:upload')) return
|
||
const pending = listPendingSessions(String(selectedDramaId.value))
|
||
if (pending.length) batchUploadVisible.value = true
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadDramas()
|
||
await loadEpisodes()
|
||
openBatchIfPendingSessions()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-heading"
|
||
><div
|
||
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
|
||
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
|
||
><div class="page-heading-actions">
|
||
<el-button v-if="can('episode:upload')" :disabled="!selectedDramaId" @click="openBatchUpload"
|
||
><AppIcon :icon="AppIcons.upload" />{{ t('episodeBatch.entry') }}</el-button
|
||
><el-button
|
||
v-if="can('episode:upload')"
|
||
type="primary"
|
||
class="primary-button"
|
||
:disabled="!selectedDramaId"
|
||
@click="openCreate"
|
||
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
|
||
></div
|
||
></div
|
||
>
|
||
<section class="panel episode-panel">
|
||
<div class="episode-context">
|
||
<div class="episode-context-info">
|
||
<span class="episode-context-label">当前短剧</span>
|
||
<b class="episode-context-title">{{ selectedDrama?.title || '暂无短剧' }}</b>
|
||
<small class="episode-context-count"
|
||
>共 {{ selectedDrama?.episodes ?? episodes.length }} 集 · 收藏
|
||
{{ formatCount(selectedDrama?.favoriteCount ?? 0) }} · 热度
|
||
{{ formatCount(selectedDrama?.views ?? 0) }}</small
|
||
>
|
||
</div>
|
||
<div class="episode-switcher">
|
||
<span class="episode-context-label">切换短剧</span>
|
||
<el-select
|
||
v-model="selectedDramaId"
|
||
class="drama-select"
|
||
placeholder="请选择短剧"
|
||
@change="onDramaChange"
|
||
>
|
||
<el-option
|
||
v-for="item in dramas"
|
||
:key="item.id"
|
||
:label="`${item.title}(${item.episodes} 集)`"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
<div class="episode-toolbar"
|
||
><div
|
||
><h2>剧集列表({{ episodes.length }})</h2><p>按列表顺序播放</p></div
|
||
></div>
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="pagedEpisodes"
|
||
row-key="id"
|
||
empty-text="该短剧暂无剧集"
|
||
class="drama-el-table episode-el-table"
|
||
>
|
||
<el-table-column label="集数" width="90"
|
||
><template #default="{ row }">{{
|
||
String(row.number).padStart(2, '0')
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column label="剧集名称" min-width="240"
|
||
><template #default="{ row }"
|
||
><div class="episode-table-title"
|
||
><b>{{ row.title }}</b
|
||
><small v-if="!row.videoUrl">未上传视频</small></div
|
||
></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column label="视频" width="150"
|
||
><template #default="{ row }"
|
||
><video
|
||
v-if="row.videoUrl"
|
||
:src="row.videoUrl"
|
||
class="episode-video-thumb"
|
||
muted
|
||
playsinline
|
||
preload="metadata"
|
||
title="点击放大播放"
|
||
@mouseenter="playThumb"
|
||
@mouseleave="stopThumb"
|
||
@click="openPreview(row)"
|
||
></video
|
||
><span v-else class="table-empty-text">—</span></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column prop="duration" label="时长" width="110" />
|
||
<el-table-column label="付费" width="120"
|
||
><template #default="{ row }"
|
||
><el-tag :type="row.isFree ? 'success' : 'warning'" effect="light">{{
|
||
row.isFree ? '免费' : `${row.unlockCoin} 金币`
|
||
}}</el-tag></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column label="播放量" width="120"
|
||
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="点赞(本集)" width="120"
|
||
><template #default="{ row }">{{
|
||
formatCount(row.likeCount ?? 0)
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column label="评论(本集)" width="120"
|
||
><template #default="{ row }">{{
|
||
formatCount(row.commentCount ?? 0)
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column label="所属剧收藏" width="120"
|
||
><template #default="{ row }">{{
|
||
formatCount(row.favoriteCount ?? 0)
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column label="所属剧热度" width="120"
|
||
><template #default="{ row }">{{ formatCount(row.heat ?? 0) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="状态" width="110"
|
||
><template #default="{ row }"
|
||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||
row.statusText
|
||
}}</el-tag></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column label="操作" width="250" fixed="right"
|
||
><template #default="{ row }"
|
||
><div class="table-actions"
|
||
><el-button
|
||
v-if="can('episode:upload')"
|
||
type="primary"
|
||
size="small"
|
||
@click="openEdit(row)"
|
||
>编辑</el-button
|
||
><el-button
|
||
v-if="can('episode:sort')"
|
||
size="small"
|
||
:disabled="!canMoveUp(row)"
|
||
@click="move(row, -1)"
|
||
>上移</el-button
|
||
><el-button
|
||
v-if="can('episode:sort')"
|
||
size="small"
|
||
:disabled="!canMoveDown(row)"
|
||
@click="move(row, 1)"
|
||
>下移</el-button
|
||
><el-button
|
||
v-if="can('episode:upload')"
|
||
type="danger"
|
||
size="small"
|
||
@click="confirmDelete(row)"
|
||
>删除</el-button
|
||
></div
|
||
></template
|
||
></el-table-column
|
||
>
|
||
</el-table>
|
||
<div v-if="showPagination" class="pagination"
|
||
><el-pagination
|
||
v-model:current-page="pageIndex"
|
||
v-model:page-size="pageSize"
|
||
:page-sizes="PAGE_SIZE_OPTIONS"
|
||
:total="episodes.length"
|
||
:pager-count="7"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
background
|
||
@size-change="handleSizeChange"
|
||
/></div>
|
||
</section>
|
||
<EpisodeFormDialog
|
||
ref="formDialogRef"
|
||
:drama-options="selectedDrama"
|
||
:next-number="batchNextNumber"
|
||
@saved="loadEpisodes"
|
||
/>
|
||
<el-dialog
|
||
v-model="previewVisible"
|
||
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
|
||
width="720px"
|
||
destroy-on-close
|
||
>
|
||
<video
|
||
v-if="previewEpisode?.videoUrl"
|
||
:src="previewEpisode.videoUrl"
|
||
controls
|
||
autoplay
|
||
class="episode-video-player"
|
||
></video>
|
||
<el-empty v-else description="该剧集还没有上传视频" />
|
||
</el-dialog>
|
||
<BatchUploadDialog
|
||
v-if="selectedDramaId"
|
||
v-model="batchUploadVisible"
|
||
:drama-id="selectedDramaId"
|
||
:next-episode-number="batchNextNumber"
|
||
:default-unlock-coin="selectedDrama?.unlockCoin ?? 0"
|
||
:free-episodes="selectedDrama?.freeEpisodes ?? 0"
|
||
:existing-numbers="existingEpisodeNumbers"
|
||
@refreshed="loadEpisodes"
|
||
@created="onBatchEpisodesCreated"
|
||
/>
|
||
</template>
|