feat(admin): 内容管理支持多语言和标签

This commit is contained in:
Zhou Weidong committed 2026-10-24 17:30:52 +08:00
1 parent eaee765239
commit 2b2c6bd708
14 files changed
+1080 -458

No files matched your search

@@ -5,6 +5,13 @@ 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,
@@ -16,6 +23,7 @@ import {
} 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'
@@ -30,8 +38,7 @@ const categories = ref([])
const keyword = ref('')
const statusFilter = ref(undefined)
const categoryIds = ref([])
const dramaTypes = ref([])
const hotFilter = ref(undefined)
const tags = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
@@ -45,11 +52,14 @@ 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: [],
dramaTypes: ['LIVE'],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
@@ -62,16 +72,6 @@ const form = reactive({
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) : '尚未选择封面')
)
@@ -90,11 +90,6 @@ const statusOptions = [
{ code: 'offline', text: '已下线' }
]
const dramaTypeOptions = [
{ value: 'ANIME', label: '漫剧' },
{ value: 'LIVE', label: '真人剧' }
]
const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' },
@@ -111,9 +106,7 @@ async function load() {
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
categoryIds: categoryIds.value.length ? categoryIds.value : undefined
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -127,6 +120,11 @@ async function loadCategories() {
categories.value = res.data || []
}
async function loadTags() {
const res = await listContentTagsApi(true)
tags.value = res.data || []
}
function search() {
pageIndex.value = 1
load()
@@ -136,8 +134,6 @@ function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
categoryIds.value = []
dramaTypes.value = []
hotFilter.value = undefined
search()
}
@@ -146,24 +142,50 @@ function handleSizeChange() {
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: [],
dramaTypes: ['LIVE'],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
@@ -182,41 +204,35 @@ function openCreate() {
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,
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'],
titleLocales,
categoryIds: selectedCategoryIds(row),
tagId: Number(row.tagId) > 0 ? row.tagId : null,
description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3,
unlockCoin: row.unlockCoin ?? 0,
favoriteCount: row.favoriteCount ?? 0,
heat: row.views ?? 0,
synopsisLocales,
freeEpisodes: toCount(row.freeEpisodes ?? 3),
unlockCoin: toCount(row.unlockCoin),
favoriteCount: toCount(row.favoriteCount),
heat: toCount(row.views),
isHot: !!row.isHot,
sort: row.sort ?? 0,
sort: toCount(row.sort),
cover: row.cover || ''
})
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
editingCommentCount.value = row.commentCount ?? 0
editingLikeCount.value = row.likeCount ?? 0
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
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)
@@ -228,25 +244,26 @@ function rowCategoryList(row) {
.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 {
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: form.title.trim(),
genre: categoryNames.join(' / ')
title: titles.zh,
titleLocales: titles,
description: synopses.zh || '',
synopsisLocales: synopses,
genre: categoryNames.join(' / '),
tagId: form.tagId || 0
}
saving.value = true
try {
@@ -370,7 +387,7 @@ function reviewActions(row) {
}
onMounted(async () => {
await loadCategories()
await Promise.all([loadCategories(), loadTags()])
await load()
})
</script>
@@ -413,22 +430,6 @@ onMounted(async () => {
: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
>
@@ -465,24 +466,17 @@ onMounted(async () => {
>
<el-table-column label="类型" min-width="200"
><template #default="{ row }"
><div class="tag-wrap"
><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
></template
><span v-else class="table-empty-text">—</span></template
>
</el-table-column>
<el-table-column label="内容类型" min-width="180"
<el-table-column label="标签" min-width="120"
><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
><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"
@@ -513,12 +507,6 @@ onMounted(async () => {
}}</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 }"
@@ -576,141 +564,12 @@ onMounted(async () => {
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑短剧' : '新建短剧'"
width="680px"
width="760px"
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>
<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">
@@ -776,6 +635,117 @@ onMounted(async () => {
</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">基础信息</h3>
<LocaleTextFields
:languages="textLanguages"
:locales="form.titleLocales"
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"
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>
@@ -816,6 +786,20 @@ onMounted(async () => {
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;