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

811 lines
24 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, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageLabel,
languageCodes,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import { AppImage } from '@/components/AppImage'
import {
changeDramaStatusApi,
createDramaApi,
deleteDramaApi,
listDramaCategoryOptionsApi,
pageDramasApi,
updateDramaApi
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { listContentTagsApi } from '@/api/admin/contentTag'
import { statusClass } from '@/utils/status'
import { uploadFile } from '@/utils/uploader'
const router = useRouter()
const { can, hasAny } = useRbac()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const categories = ref([])
const keyword = ref('')
const statusFilter = ref(undefined)
const categoryIds = ref([])
const tags = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingCover = ref(false)
const coverPercent = ref(0)
const coverError = ref('')
const coverFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const editingLikeCount = ref(0)
const formRef = ref()
const textLanguages = ref(['zh'])
const form = reactive({
title: '',
titleLocales: { zh: '' },
categoryIds: [],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
sort: 0,
cover: ''
})
const MAX_COVER_SIZE = 5 * 1024 * 1024
const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const coverDisplayName = computed(
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面')
)
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
function coverPreviewIndex(row) {
return Math.max(0, pageCoverPreviewList.value.indexOf(row.cover))
}
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'reviewing', text: '审核中' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' },
reject: { label: '驳回', done: '已驳回' },
publish: { label: '上线', done: '已上线' },
offline: { label: '下线', done: '已下线' }
}
async function load() {
loading.value = true
try {
const res = await pageDramasApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined,
categoryIds: categoryIds.value.length ? categoryIds.value : undefined
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
async function loadCategories() {
const res = await listDramaCategoryOptionsApi()
categories.value = res.data || []
}
async function loadTags() {
const res = await listContentTagsApi(true)
tags.value = res.data || []
}
function search() {
pageIndex.value = 1
load()
}
function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
categoryIds.value = []
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function selectedCategoryIds(row) {
const values =
Array.isArray(row.categoryIds) && row.categoryIds.length
? row.categoryIds
: row.categoryId
? [row.categoryId]
: []
return values.filter((id) => Number(id) > 0)
}
function coverBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
/** 长整型计数字段在接口里是字符串,数字输入框只接受 number。 */
function toCount(value) {
const count = Number(value)
return Number.isFinite(count) ? count : 0
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function changeTextLanguages(codes) {
textLanguages.value = codes
codes.forEach((code) => {
if (form.synopsisLocales[code] == null) form.synopsisLocales[code] = ''
})
}
function openCreate() {
editingId.value = null
textLanguages.value = ['zh']
Object.assign(form, {
title: '',
titleLocales: { zh: '' },
categoryIds: [],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
sort: 0,
cover: ''
})
coverFileName.value = ''
coverError.value = ''
editingCommentCount.value = 0
editingLikeCount.value = 0
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
editingId.value = row.id
const titleLocales = localesFromColumns(row.titleLocales, row.title)
const synopsisLocales = localesFromColumns(row.synopsisLocales, row.description)
textLanguages.value = languageCodes(titleLocales)
textLanguages.value.forEach((code) => {
if (synopsisLocales[code] == null) synopsisLocales[code] = ''
})
Object.assign(form, {
title: row.title,
titleLocales,
categoryIds: selectedCategoryIds(row),
tagId: Number(row.tagId) > 0 ? row.tagId : null,
description: row.description || '',
synopsisLocales,
freeEpisodes: toCount(row.freeEpisodes ?? 3),
unlockCoin: toCount(row.unlockCoin),
favoriteCount: toCount(row.favoriteCount),
heat: toCount(row.views),
isHot: !!row.isHot,
sort: toCount(row.sort),
cover: row.cover || ''
})
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
dialogVisible.value = true
resetValidation()
}
function rowCategoryList(row) {
if (Array.isArray(row.categoryNames) && row.categoryNames.length) {
return row.categoryNames.filter(Boolean)
}
if (!row.genre) return []
return row.genre
.split('/')
.map((name) => name.trim())
.filter(Boolean)
}
async function saveDrama() {
if (saving.value || uploadingCover.value) return
const titles = payloadLocales(form.titleLocales, textLanguages.value)
const missing = textLanguages.value.find((code) => !titles[code])
if (!titles.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请输入短剧名称')
return
}
const synopses = payloadLocales(form.synopsisLocales, textLanguages.value)
const categoryNames = form.categoryIds
.map((id) => categories.value.find((item) => item.id === id)?.name)
.filter(Boolean)
const payload = {
...form,
title: titles.zh,
titleLocales: titles,
description: synopses.zh || '',
synopsisLocales: synopses,
genre: categoryNames.join(' / '),
tagId: form.tagId || 0
}
saving.value = true
try {
if (editingId.value) {
await updateDramaApi(editingId.value, payload)
ElMessage.success('短剧信息已更新')
} else {
await createDramaApi(payload)
ElMessage.success('短剧草稿已保存')
pageIndex.value = 1
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
function beforeCoverUpload(file) {
coverError.value = ''
if (!COVER_TYPES.includes(file.type)) {
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_COVER_SIZE) {
coverError.value = '封面大小不能超过 5MB'
return false
}
return true
}
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
async function uploadCover(options) {
uploadingCover.value = true
coverPercent.value = 0
coverError.value = ''
try {
const asset = await uploadFile(options.file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent)
})
form.cover = asset.url
coverFileName.value = options.file.name
ElMessage.success('封面已上传')
} catch {
coverError.value = '封面上传失败,请检查网络后重试'
} finally {
uploadingCover.value = false
}
}
function removeCover() {
if (uploadingCover.value) return
form.cover = ''
coverFileName.value = ''
coverError.value = ''
}
function beforeDialogClose(done) {
if (saving.value || uploadingCover.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
return
}
done()
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除短剧「${row.title}」吗?删除后无法恢复。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
distinguishCancelAndClose: true
})
} catch {
return
}
await deleteDramaApi(row.id)
ElMessage.success('短剧已删除')
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
await load()
}
/** 审核发布动作:驳回需要填写原因,其余二次确认。 */
async function runAction(row, action) {
const meta = actionMeta[action]
let reason = ''
try {
if (action === 'reject') {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回「${row.title}」`, {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPlaceholder: '例如:封面素材不合规'
})
reason = value
} else {
await ElMessageBox.confirm(`确定将「${row.title}」${meta.label}吗?`, '操作确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
}
} catch {
return
}
await changeDramaStatusApi(row.id, action, reason)
ElMessage.success(meta.done)
await load()
}
function reviewActions(row) {
const canReview = can('drama:review')
if (row.status === 'draft' || row.status === 'offline') {
return hasAny('drama:update', 'drama:review') ? ['submit'] : []
}
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : []
if (row.status === 'approved') return canReview ? ['publish'] : []
if (row.status === 'published') return canReview ? ['offline'] : []
return []
}
onMounted(async () => {
await Promise.all([loadCategories(), loadTags()])
await load()
})
</script>
<template>
<div class="page-heading"
><div
><div class="eyebrow">内容管理</div><h1>短剧管理</h1
><p>管理短剧信息、封面、剧集和发布状态。</p></div
><el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate"
><AppIcon :icon="AppIcons.add" />新建短剧</el-button
></div
>
<section class="panel library-panel">
<div class="library-toolbar"
><div class="drama-filters"
><el-input
v-model="keyword"
placeholder="搜索短剧名称"
clearable
@keyup.enter="search"
@clear="search"
>
<template #prefix><AppIcon :icon="AppIcons.search" /></template> </el-input
><el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search"
><el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code" /></el-select
><el-select
v-model="categoryIds"
class="drama-multi-select"
multiple
clearable
placeholder="全部类型"
@change="search"
><el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id" /></el-select
><el-button @click="resetFilters">重置</el-button></div
><span class="table-result-count">共 {{ total }} 部短剧</span></div
>
<el-table
v-loading="loading"
:data="list"
row-key="id"
empty-text="没有符合条件的短剧"
class="drama-el-table"
>
<el-table-column label="短剧名称" min-width="220"
><template #default="{ row }"
><div class="drama-cell"
><AppImage
v-if="row.cover"
:src="row.cover"
:preview-list="pageCoverPreviewList"
:initial-index="coverPreviewIndex(row)"
fit="cover"
class="drama-cover-image"
:alt="row.title"
/><div v-else class="drama-cover">暂无封面</div
><div
><b>{{ row.title }}</b></div
></div
></template
></el-table-column
>
<el-table-column label="简介" min-width="200" show-overflow-tooltip
><template #default="{ row }"
><span v-if="row.description">{{ row.description }}</span
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column label="类型" min-width="200"
><template #default="{ row }"
><div v-if="rowCategoryList(row).length" class="tag-wrap"
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
name
}}</el-tag></div
><span v-else class="table-empty-text">—</span></template
>
</el-table-column>
<el-table-column label="标签" min-width="120"
><template #default="{ row }"
><span v-if="row.tagName">{{ row.tagName }}</span
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column label="集数" width="90"
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
>
<el-table-column label="热度" width="110"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
>
<el-table-column label="收藏" width="90"
><template #default="{ row }">{{
formatCount(row.favoriteCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="点赞" width="90"
><template #default="{ row }">{{
formatCount(row.likeCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="评论" width="90"
><template #default="{ row }">{{
formatCount(row.commentCount ?? 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 prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="审核" width="140"
><template #default="{ row }"
><div class="table-actions"
><el-button
v-for="action in reviewActions(row)"
:key="action"
:type="action === 'reject' || action === 'offline' ? 'warning' : 'success'"
size="small"
@click="runAction(row, action)"
>{{ actionMeta[action].label }}</el-button
><span v-if="!reviewActions(row).length" class="table-empty-text">—</span></div
></template
></el-table-column
>
<el-table-column label="操作" width="200" fixed="right"
><template #default="{ row }"
><div class="table-actions"
><el-button
v-if="can('drama:update')"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('episode:list')"
type="success"
size="small"
@click="router.push({ name: 'episodes', query: { drama: row.id } })"
>剧集</el-button
><el-button
v-if="can('drama:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
></div
></template
></el-table-column
>
</el-table>
<div class="pagination"
><el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="total"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/></div>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑短剧' : '新建短剧'"
width="760px"
class="drama-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
<section class="editor-section">
<h3 class="editor-section-title">封面</h3>
<div class="cover-editor">
<div class="cover-preview-card">
<img v-if="form.cover" :src="form.cover" alt="短剧封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无封面</span>
</div>
<div v-if="uploadingCover" class="cover-upload-mask">
<span>上传中</span>
<b>{{ coverPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
{{ form.cover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
</div>
<p class="cover-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<el-alert
v-if="coverError"
:title="coverError"
type="error"
:closable="false"
show-icon
/>
<div class="cover-actions">
<el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeCoverUpload"
:http-request="uploadCover"
:disabled="uploadingCover"
>
<el-button :loading="uploadingCover" :disabled="uploadingCover">
<AppIcon :icon="AppIcons.upload" />
{{ form.cover ? '更换封面' : '选择封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.cover"
type="danger"
:disabled="uploadingCover"
@click="removeCover"
>
移除
</el-button>
<a
v-if="form.cover"
class="cover-original-link"
:href="form.cover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">基础信息</h3>
<LocaleTextFields
:languages="textLanguages"
:locales="form.titleLocales"
@update:locales="form.titleLocales = $event"
label="名称"
:maxlength="80"
@update:languages="changeTextLanguages"
/>
<div class="editor-grid editor-grid--two">
<el-form-item label="类型">
<el-select
v-model="form.categoryIds"
class="drama-multi-select"
multiple
filterable
clearable
placeholder="可不选"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagId" clearable filterable placeholder="可不选">
<el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<LocaleTextFields
:languages="textLanguages"
:locales="form.synopsisLocales"
@update:locales="form.synopsisLocales = $event"
label="简介"
:maxlength="1000"
multiline
:show-languages="false"
:required="false"
/>
</section>
<section class="editor-section">
<h3 class="editor-section-title">付费设置</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="免费集数">
<el-input-number
v-model="form.freeEpisodes"
class="editor-number"
controls-position="right"
:min="0"
:max="200"
/>
</el-form-item>
<el-form-item label="单集解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="form.sort"
class="editor-number"
controls-position="right"
:min="0"
:max="99999"
:precision="0"
/>
<p class="readonly-stat-hint">数值越大越靠前,相同权重按 ID 倒序</p>
</el-form-item>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">互动数据</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="收藏数">
<el-input-number
v-model="form.favoriteCount"
class="editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="点赞数">
<div class="readonly-stat">{{ formatCount(editingLikeCount) }}</div>
<p class="readonly-stat-hint">全剧分集汇总,只读</p>
</el-form-item>
<el-form-item label="热度">
<el-input-number
v-model="form.heat"
class="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>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingCover" @click="dialogVisible = false"
>取消</el-button
>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingCover"
@click="saveDrama"
>
{{ editingId ? '保存修改' : '保存草稿' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.drama-multi-select {
min-width: 220px;
}
.drama-multi-select :deep(.el-select__wrapper) {
height: auto;
min-height: 32px;
padding-top: 3px;
padding-bottom: 3px;
}
.drama-multi-select :deep(.el-select__selection) {
flex-wrap: wrap;
row-gap: 4px;
}
.drama-editor-form .drama-multi-select {
width: 100%;
min-width: 0;
}
.drama-editor-form :deep(.el-form-item) {
margin-right: 0;
}
.drama-editor-form :deep(.el-form-item__content) {
min-width: 0;
}
.drama-editor-form :deep(.el-input),
.drama-editor-form :deep(.el-textarea),
.drama-editor-form :deep(.el-select) {
width: 100%;
}
.tag-wrap {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>