825 lines
25 KiB
Vue
825 lines
25 KiB
Vue
<!-- 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 { 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 { 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 dramaTypes = ref([])
|
||
const hotFilter = ref(undefined)
|
||
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 form = reactive({
|
||
title: '',
|
||
categoryIds: [],
|
||
dramaTypes: ['LIVE'],
|
||
description: '',
|
||
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 formRules = {
|
||
title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }],
|
||
categoryIds: [
|
||
{ type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' }
|
||
],
|
||
dramaTypes: [
|
||
{ type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' }
|
||
]
|
||
}
|
||
|
||
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 dramaTypeOptions = [
|
||
{ value: 'ANIME', label: '漫剧' },
|
||
{ value: 'LIVE', label: '真人剧' }
|
||
]
|
||
|
||
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,
|
||
dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined,
|
||
isHot: hotFilter.value
|
||
})
|
||
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 || []
|
||
}
|
||
|
||
function search() {
|
||
pageIndex.value = 1
|
||
load()
|
||
}
|
||
|
||
function resetFilters() {
|
||
keyword.value = ''
|
||
statusFilter.value = undefined
|
||
categoryIds.value = []
|
||
dramaTypes.value = []
|
||
hotFilter.value = undefined
|
||
search()
|
||
}
|
||
|
||
function handleSizeChange() {
|
||
pageIndex.value = 1
|
||
load()
|
||
}
|
||
|
||
function coverBasename(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, {
|
||
title: '',
|
||
categoryIds: [],
|
||
dramaTypes: ['LIVE'],
|
||
description: '',
|
||
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
|
||
Object.assign(form, {
|
||
title: row.title,
|
||
categoryIds:
|
||
Array.isArray(row.categoryIds) && row.categoryIds.length
|
||
? row.categoryIds
|
||
: row.categoryId
|
||
? [row.categoryId]
|
||
: [],
|
||
dramaTypes:
|
||
Array.isArray(row.dramaTypes) && row.dramaTypes.length
|
||
? row.dramaTypes
|
||
: row.dramaType
|
||
? [row.dramaType]
|
||
: ['LIVE'],
|
||
description: row.description || '',
|
||
freeEpisodes: row.freeEpisodes ?? 3,
|
||
unlockCoin: row.unlockCoin ?? 0,
|
||
favoriteCount: row.favoriteCount ?? 0,
|
||
heat: row.views ?? 0,
|
||
isHot: !!row.isHot,
|
||
sort: row.sort ?? 0,
|
||
cover: row.cover || ''
|
||
})
|
||
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
|
||
coverError.value = ''
|
||
editingCommentCount.value = row.commentCount ?? 0
|
||
editingLikeCount.value = row.likeCount ?? 0
|
||
dialogVisible.value = true
|
||
resetValidation()
|
||
}
|
||
|
||
function dramaTypeLabel(value) {
|
||
return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧'
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
function rowDramaTypes(row) {
|
||
if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes
|
||
return row.dramaType ? [row.dramaType] : []
|
||
}
|
||
|
||
async function saveDrama() {
|
||
if (saving.value || uploadingCover.value) return
|
||
try {
|
||
await formRef.value?.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
const categoryNames = form.categoryIds
|
||
.map((id) => categories.value.find((item) => item.id === id)?.name)
|
||
.filter(Boolean)
|
||
const payload = {
|
||
...form,
|
||
title: form.title.trim(),
|
||
genre: categoryNames.join(' / ')
|
||
}
|
||
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 loadCategories()
|
||
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-select
|
||
v-model="dramaTypes"
|
||
class="drama-multi-select"
|
||
multiple
|
||
clearable
|
||
placeholder="全部内容类型"
|
||
@change="search"
|
||
><el-option
|
||
v-for="item in dramaTypeOptions"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value" /></el-select
|
||
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
|
||
><el-option label="仅热门" :value="true" /><el-option
|
||
label="非热门"
|
||
:value="false" /></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 class="tag-wrap"
|
||
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
|
||
name
|
||
}}</el-tag></div
|
||
></template
|
||
>
|
||
</el-table-column>
|
||
<el-table-column label="内容类型" min-width="180"
|
||
><template #default="{ row }"
|
||
><div class="tag-wrap"
|
||
><el-tag
|
||
v-for="type in rowDramaTypes(row)"
|
||
:key="type"
|
||
:type="type === 'ANIME' ? 'warning' : 'info'"
|
||
effect="light"
|
||
>{{ dramaTypeLabel(type) }}</el-tag
|
||
></div
|
||
></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 label="热门" width="90"
|
||
><template #default="{ row }"
|
||
><el-tag v-if="row.isHot" type="danger" effect="light">热门</el-tag
|
||
><span v-else class="table-empty-text">—</span></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="680px"
|
||
class="drama-editor-dialog"
|
||
:close-on-click-modal="false"
|
||
:before-close="beforeDialogClose"
|
||
>
|
||
<el-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
class="drama-editor-form"
|
||
>
|
||
<section class="editor-section">
|
||
<h3 class="editor-section-title">基础信息</h3>
|
||
<div class="editor-grid editor-grid--two">
|
||
<el-form-item label="短剧名称" prop="title">
|
||
<el-input v-model="form.title" maxlength="80" placeholder="请输入短剧名称" />
|
||
</el-form-item>
|
||
<el-form-item label="类型" prop="categoryIds">
|
||
<el-select
|
||
v-model="form.categoryIds"
|
||
class="drama-multi-select"
|
||
multiple
|
||
filterable
|
||
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="内容类型" prop="dramaTypes">
|
||
<el-select
|
||
v-model="form.dramaTypes"
|
||
class="drama-multi-select"
|
||
multiple
|
||
filterable
|
||
placeholder="请选择内容类型"
|
||
>
|
||
<el-option
|
||
v-for="item in dramaTypeOptions"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="简介">
|
||
<el-input
|
||
v-model="form.description"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="1000"
|
||
show-word-limit
|
||
placeholder="简要介绍剧情"
|
||
/>
|
||
</el-form-item>
|
||
</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>
|
||
</div>
|
||
<div class="editor-grid editor-grid--two">
|
||
<el-form-item label="是否热门">
|
||
<el-switch v-model="form.isHot" />
|
||
</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>
|
||
|
||
<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>
|
||
</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;
|
||
}
|
||
|
||
.tag-wrap {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
</style>
|