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

+58 -202
View File
@@ -6,6 +6,12 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
bindClassificationDramasApi,
createClassificationApi,
@@ -18,6 +24,8 @@ import {
} from '@/api/admin/dramaClassification'
import { statusClass } from '@/utils/status'
import { useRbac } from '@/stores/rbac'
import ClassificationDramaDialogs from './ClassificationDramaDialogs.vue'
import ClassificationSideList from './ClassificationSideList.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -46,7 +54,9 @@ const keyword = ref('')
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', sort: 1, status: 1 })
const languages = ref(['zh'])
const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 })
const form = reactive(emptyForm())
const candidateVisible = ref(false)
const candidateLoading = ref(false)
@@ -110,25 +120,39 @@ function changePage(value) {
function openCreate() {
editingId.value = null
languages.value = ['zh']
const maxSort = classifications.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
Object.assign(form, { name: '', sort: maxSort + 1, status: 1 })
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function saveClassification() {
if (!form.name.trim()) {
ElMessage.warning('请填写分类名称')
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
const payload = {
name: locales.zh,
locales,
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updateClassificationApi(editingId.value, payload)
ElMessage.success('分类已更新')
@@ -256,48 +280,13 @@ onMounted(loadClassifications)
</PageHeading>
<div class="classification-layout">
<aside class="panel classification-layout__side">
<div class="classification-layout__side-title">全部分类</div>
<el-scrollbar height="560px">
<ul class="classification-list">
<li
v-for="item in classifications"
:key="item.id"
class="classification-list__item"
:class="{ 'is-active': item.id === activeId }"
@click="selectClassification(item)"
>
<div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div>
<div class="classification-list__actions" @click.stop>
<el-button
v-if="can('drama:update')"
text
circle
size="small"
@click="openEdit(item)"
>
<AppIcon :icon="AppIcons.edit" :size="15" />
</el-button>
<el-button
v-if="can('drama:delete')"
text
circle
size="small"
@click="confirmDelete(item)"
>
<AppIcon :icon="AppIcons.delete" :size="15" />
</el-button>
</div>
</li>
<li v-if="!classifications.length" class="classification-list__empty">
还没有分类,点右上角「新建分类」开始
</li>
</ul>
</el-scrollbar>
</aside>
<ClassificationSideList
:classifications="classifications"
:active-id="activeId"
@select="selectClassification"
@edit="openEdit"
@remove="confirmDelete"
/>
<section class="panel classification-layout__main">
<div class="classification-toolbar">
@@ -376,77 +365,27 @@ onMounted(loadClassifications)
</section>
</div>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑分类' : '新建分类'" width="460px">
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:本周必看" />
</el-form-item>
<div class="classification-form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveClassification">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="candidateVisible" title="添加关联短剧" width="760px">
<div class="classification-toolbar__search" style="margin-bottom: 12px">
<el-input
v-model="candidateKeyword"
placeholder="搜索短剧名称"
clearable
style="width: 220px"
@keyup.enter="candidateSearch"
@clear="candidateSearch"
/>
<el-button type="primary" plain @click="candidateSearch">查询</el-button>
</div>
<el-table
v-loading="candidateLoading"
:data="candidates"
row-key="id"
border
max-height="380"
empty-text="没有可关联的短剧"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="短剧名称" min-width="220" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
</el-table>
<div class="classification-pagination">
<el-pagination
:current-page="candidatePage"
:page-size="candidatePageSize"
:total="candidateTotal"
layout="total, prev, pager, next"
@current-change="changeCandidatePage"
/>
</div>
<template #footer>
<el-button @click="candidateVisible = false">取消</el-button>
<el-button type="primary" :loading="binding" @click="confirmBind">
确定关联{{ selectedDramas.length ? `(${selectedDramas.length})` : '' }}
</el-button>
</template>
</el-dialog>
<ClassificationDramaDialogs
v-model:editor-visible="dialogVisible"
v-model:languages="languages"
v-model:candidate-visible="candidateVisible"
v-model:candidate-keyword="candidateKeyword"
:editing-id="editingId"
:form="form"
:saving="saving"
:candidate-loading="candidateLoading"
:candidates="candidates"
:candidate-page="candidatePage"
:candidate-page-size="candidatePageSize"
:candidate-total="candidateTotal"
:binding="binding"
:selected-count="selectedDramas.length"
@save="saveClassification"
@search="candidateSearch"
@page="changeCandidatePage"
@selection="onSelectionChange"
@confirm="confirmBind"
/>
</template>
<style scoped>
@@ -457,83 +396,6 @@ onMounted(loadClassifications)
align-items: start;
}
.classification-layout__side {
padding: 12px 0 8px;
}
.classification-layout__side-title {
padding: 0 16px 10px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.classification-list {
padding: 0;
margin: 0;
list-style: none;
}
.classification-list__item {
display: flex;
padding: 8px 12px 8px 16px;
color: var(--el-text-color-regular);
cursor: pointer;
border-left: 3px solid transparent;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.classification-list__item:hover {
background: var(--el-fill-color-light);
}
.classification-list__item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.classification-list__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.classification-list__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-list__count {
min-width: 24px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color);
border-radius: 9px;
}
.classification-list__actions {
display: none;
gap: 2px;
}
.classification-list__item:hover .classification-list__actions {
display: flex;
}
.classification-list__empty {
padding: 16px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.classification-layout__main {
padding: 16px;
}
@@ -585,10 +447,4 @@ onMounted(loadClassifications)
justify-content: flex-end;
margin-top: 14px;
}
.classification-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
</style>