602 lines
16 KiB
Vue
602 lines
16 KiB
Vue
<!-- Author: fruit.com.cn -->
|
||
<!-- 分类管理:运营自定义分类(与「类型」是两个概念),并关联任意多部短剧 -->
|
||
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { AppIcon } from '@/components/AppIcon'
|
||
import { AppIcons } from '@/constants/icons'
|
||
import {
|
||
bindClassificationDramasApi,
|
||
createClassificationApi,
|
||
deleteClassificationApi,
|
||
listClassificationDramaCandidatesApi,
|
||
listClassificationDramasApi,
|
||
listClassificationsApi,
|
||
unbindClassificationDramaApi,
|
||
updateClassificationApi
|
||
} from '@/api/admin/dramaClassification'
|
||
import { statusClass } from '@/utils/status'
|
||
import { useRbac } from '@/stores/rbac'
|
||
import PageHeading from '@/views/components/PageHeading.vue'
|
||
|
||
const { can } = useRbac()
|
||
|
||
const STATUS_TEXT = {
|
||
draft: '草稿',
|
||
reviewing: '审核中',
|
||
approved: '待发布',
|
||
published: '已上线',
|
||
offline: '已下线'
|
||
}
|
||
|
||
const classifications = ref([])
|
||
const activeId = ref(null)
|
||
const activeClassification = computed(
|
||
() => classifications.value.find((item) => item.id === activeId.value) || null
|
||
)
|
||
|
||
const loading = ref(false)
|
||
const dramas = ref([])
|
||
const total = ref(0)
|
||
const pageIndex = ref(1)
|
||
const pageSize = ref(10)
|
||
const keyword = ref('')
|
||
|
||
const dialogVisible = ref(false)
|
||
const saving = ref(false)
|
||
const editingId = ref(null)
|
||
const form = reactive({ name: '', sort: 1, status: 1 })
|
||
|
||
const candidateVisible = ref(false)
|
||
const candidateLoading = ref(false)
|
||
const candidates = ref([])
|
||
const candidateTotal = ref(0)
|
||
const candidatePage = ref(1)
|
||
const candidatePageSize = ref(10)
|
||
const candidateKeyword = ref('')
|
||
const selectedDramas = ref([])
|
||
const binding = ref(false)
|
||
|
||
async function loadClassifications() {
|
||
const res = await listClassificationsApi()
|
||
classifications.value = res.data || []
|
||
if (activeId.value && !classifications.value.some((item) => item.id === activeId.value)) {
|
||
activeId.value = classifications.value[0]?.id ?? null
|
||
}
|
||
if (!activeId.value) {
|
||
activeId.value = classifications.value[0]?.id ?? null
|
||
}
|
||
await loadDramas()
|
||
}
|
||
|
||
async function loadDramas() {
|
||
if (!activeId.value) {
|
||
dramas.value = []
|
||
total.value = 0
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res = await listClassificationDramasApi(activeId.value, {
|
||
keyword: keyword.value || undefined,
|
||
pageIndex: pageIndex.value,
|
||
pageSize: pageSize.value
|
||
})
|
||
dramas.value = res.data?.list || []
|
||
total.value = res.data?.total || 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function selectClassification(row) {
|
||
if (activeId.value === row.id) return
|
||
activeId.value = row.id
|
||
pageIndex.value = 1
|
||
keyword.value = ''
|
||
loadDramas()
|
||
}
|
||
|
||
function search() {
|
||
pageIndex.value = 1
|
||
loadDramas()
|
||
}
|
||
|
||
function changePage(value) {
|
||
pageIndex.value = value
|
||
loadDramas()
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
const maxSort = classifications.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
|
||
Object.assign(form, { name: '', sort: maxSort + 1, status: 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 })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function saveClassification() {
|
||
if (!form.name.trim()) {
|
||
ElMessage.warning('请填写分类名称')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
|
||
if (editingId.value) {
|
||
await updateClassificationApi(editingId.value, payload)
|
||
ElMessage.success('分类已更新')
|
||
} else {
|
||
const res = await createClassificationApi(payload)
|
||
activeId.value = res.data
|
||
pageIndex.value = 1
|
||
ElMessage.success('分类已创建')
|
||
}
|
||
dialogVisible.value = false
|
||
await loadClassifications()
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function confirmDelete(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定删除分类「${row.name}」吗?关联关系会一起删除,短剧本身不受影响。`,
|
||
'删除确认',
|
||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
await deleteClassificationApi(row.id)
|
||
if (activeId.value === row.id) {
|
||
activeId.value = null
|
||
}
|
||
ElMessage.success('分类已删除')
|
||
await loadClassifications()
|
||
}
|
||
|
||
async function removeDrama(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定把「${row.title}」从「${activeClassification.value?.name}」移出吗?`,
|
||
'移除确认',
|
||
{ confirmButtonText: '确认移出', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
await unbindClassificationDramaApi(activeId.value, row.id)
|
||
ElMessage.success('已移出')
|
||
await Promise.all([loadDramas(), loadClassifications()])
|
||
}
|
||
|
||
function openCandidates() {
|
||
candidateVisible.value = true
|
||
candidatePage.value = 1
|
||
candidateKeyword.value = ''
|
||
selectedDramas.value = []
|
||
loadCandidates()
|
||
}
|
||
|
||
async function loadCandidates() {
|
||
if (!activeId.value) return
|
||
candidateLoading.value = true
|
||
try {
|
||
const res = await listClassificationDramaCandidatesApi(activeId.value, {
|
||
keyword: candidateKeyword.value || undefined,
|
||
pageIndex: candidatePage.value,
|
||
pageSize: candidatePageSize.value
|
||
})
|
||
candidates.value = res.data?.list || []
|
||
candidateTotal.value = res.data?.total || 0
|
||
} finally {
|
||
candidateLoading.value = false
|
||
}
|
||
}
|
||
|
||
function candidateSearch() {
|
||
candidatePage.value = 1
|
||
loadCandidates()
|
||
}
|
||
|
||
function changeCandidatePage(value) {
|
||
candidatePage.value = value
|
||
loadCandidates()
|
||
}
|
||
|
||
function onSelectionChange(rows) {
|
||
selectedDramas.value = rows
|
||
}
|
||
|
||
async function confirmBind() {
|
||
if (!selectedDramas.value.length) {
|
||
ElMessage.warning('请先选择要关联的短剧')
|
||
return
|
||
}
|
||
binding.value = true
|
||
try {
|
||
const res = await bindClassificationDramasApi(
|
||
activeId.value,
|
||
selectedDramas.value.map((item) => item.id)
|
||
)
|
||
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
|
||
candidateVisible.value = false
|
||
await Promise.all([loadDramas(), loadClassifications()])
|
||
} finally {
|
||
binding.value = false
|
||
}
|
||
}
|
||
|
||
function formatTime(value) {
|
||
if (!value) return '—'
|
||
return String(value).replace('T', ' ').slice(0, 19)
|
||
}
|
||
|
||
onMounted(loadClassifications)
|
||
</script>
|
||
|
||
<template>
|
||
<PageHeading
|
||
eyebrow="内容管理"
|
||
title="分类管理"
|
||
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。"
|
||
>
|
||
<el-button
|
||
v-if="can('drama:create')"
|
||
type="primary"
|
||
class="primary-button"
|
||
@click="openCreate"
|
||
>
|
||
<AppIcon :icon="AppIcons.add" />
|
||
新建分类
|
||
</el-button>
|
||
</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>
|
||
|
||
<section class="panel classification-layout__main">
|
||
<div class="classification-toolbar">
|
||
<div class="classification-toolbar__title">
|
||
{{ activeClassification?.name || '请选择分类' }}
|
||
<small>共 {{ total }} 部关联短剧</small>
|
||
</div>
|
||
<div class="classification-toolbar__search">
|
||
<el-input
|
||
v-model="keyword"
|
||
placeholder="搜索短剧名称"
|
||
clearable
|
||
style="width: 200px"
|
||
@keyup.enter="search"
|
||
@clear="search"
|
||
/>
|
||
<el-button type="primary" plain @click="search">查询</el-button>
|
||
<el-button
|
||
v-if="can('drama:update')"
|
||
:disabled="!activeId"
|
||
@click="openCandidates"
|
||
>
|
||
<AppIcon :icon="AppIcons.add" />
|
||
添加短剧
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="dramas"
|
||
row-key="id"
|
||
border
|
||
class="drama-el-table"
|
||
empty-text="该分类下还没有关联短剧"
|
||
>
|
||
<el-table-column label="封面" width="88">
|
||
<template #default="{ row }">
|
||
<img v-if="row.cover" :src="row.cover" class="classification-cover" alt="封面" />
|
||
<div v-else class="classification-cover classification-cover--empty">无</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="title" label="短剧名称" min-width="200" />
|
||
<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-column label="更新时间" width="180">
|
||
<template #default="{ row }">{{ formatTime(row.updatedAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="can('drama:update')"
|
||
type="danger"
|
||
size="small"
|
||
@click="removeDrama(row)"
|
||
>移出</el-button
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="classification-pagination">
|
||
<el-pagination
|
||
:current-page="pageIndex"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
layout="total, prev, pager, next"
|
||
@current-change="changePage"
|
||
/>
|
||
</div>
|
||
</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>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.classification-layout {
|
||
display: grid;
|
||
grid-template-columns: 260px minmax(0, 1fr);
|
||
gap: 16px;
|
||
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 {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
.classification-list__item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 8px 12px 8px 16px;
|
||
cursor: pointer;
|
||
color: var(--el-text-color-regular);
|
||
border-left: 3px solid transparent;
|
||
}
|
||
|
||
.classification-list__item:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
.classification-list__item.is-active {
|
||
background: var(--el-color-primary-light-9);
|
||
border-left-color: var(--el-color-primary);
|
||
color: var(--el-color-primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.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;
|
||
text-align: center;
|
||
border-radius: 9px;
|
||
background: var(--el-fill-color);
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.classification-list__actions {
|
||
display: none;
|
||
gap: 2px;
|
||
}
|
||
|
||
.classification-list__item:hover .classification-list__actions {
|
||
display: flex;
|
||
}
|
||
|
||
.classification-list__empty {
|
||
padding: 16px;
|
||
color: var(--el-text-color-secondary);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.classification-layout__main {
|
||
padding: 16px;
|
||
}
|
||
|
||
.classification-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.classification-toolbar__title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.classification-toolbar__title small {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.classification-toolbar__search {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.classification-cover {
|
||
width: 48px;
|
||
height: 64px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.classification-cover--empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
.classification-pagination {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.classification-form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
</style>
|