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

602 lines
16 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, 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>