Files
ReelShortWeb/src/views/contentManagement/EpisodeManagementView.vue
T

399 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>