661 lines
18 KiB
Vue
661 lines
18 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 {
|
||
languageCodes,
|
||
languageLabel,
|
||
localesFromColumns,
|
||
payloadLocales
|
||
} from '@/constants/contentLanguages'
|
||
import {
|
||
bindPlaylistCategoryDramasApi,
|
||
createPlaylistCategoryApi,
|
||
deletePlaylistCategoryApi,
|
||
listPlaylistCategoriesApi,
|
||
listPlaylistCategoryDramaCandidatesApi,
|
||
listPlaylistCategoryDramasApi,
|
||
unbindPlaylistCategoryDramaApi,
|
||
updatePlaylistCategoryApi
|
||
} from '@/api/admin/dramaPlaylistCategory'
|
||
import { statusClass } from '@/utils/status'
|
||
import { useRbac } from '@/stores/rbac'
|
||
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
|
||
import PageHeading from '@/views/components/PageHeading.vue'
|
||
|
||
const { can } = useRbac()
|
||
|
||
const STATUS_TEXT = {
|
||
draft: '草稿',
|
||
reviewing: '审核中',
|
||
approved: '待发布',
|
||
published: '已上线',
|
||
offline: '已下线'
|
||
}
|
||
|
||
const categories = ref([])
|
||
const activeId = ref(null)
|
||
const activeCategory = computed(
|
||
() => categories.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 languages = ref(['zh'])
|
||
const emptyForm = () => ({
|
||
locales: { zh: '' },
|
||
descriptionLocales: { zh: '' },
|
||
sort: 1,
|
||
status: 1
|
||
})
|
||
const form = reactive(emptyForm())
|
||
|
||
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 loadCategories() {
|
||
const res = await listPlaylistCategoriesApi()
|
||
categories.value = res.data || []
|
||
if (activeId.value && !categories.value.some((item) => item.id === activeId.value)) {
|
||
activeId.value = categories.value[0]?.id ?? null
|
||
}
|
||
if (!activeId.value) {
|
||
activeId.value = categories.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 listPlaylistCategoryDramasApi(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 selectCategory(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 changeLanguages(codes) {
|
||
languages.value = codes
|
||
codes.forEach((code) => {
|
||
if (form.descriptionLocales[code] == null) form.descriptionLocales[code] = ''
|
||
})
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
languages.value = ['zh']
|
||
const maxSort = categories.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
|
||
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function openEdit(row) {
|
||
editingId.value = row.id
|
||
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
|
||
languages.value = languageCodes(locales)
|
||
const descriptionLocales = localesFromColumns(
|
||
row.descriptionLocales,
|
||
row.description,
|
||
row.descriptionEn,
|
||
row.descriptionId
|
||
)
|
||
languages.value.forEach((code) => {
|
||
if (descriptionLocales[code] == null) descriptionLocales[code] = ''
|
||
})
|
||
Object.assign(form, {
|
||
locales,
|
||
descriptionLocales,
|
||
sort: row.sort ?? 0,
|
||
status: row.status ?? 1
|
||
})
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function saveCategory() {
|
||
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: locales.zh,
|
||
locales,
|
||
descriptionLocales: payloadLocales(form.descriptionLocales, languages.value),
|
||
sort: form.sort,
|
||
status: form.status
|
||
}
|
||
if (editingId.value) {
|
||
await updatePlaylistCategoryApi(editingId.value, payload)
|
||
ElMessage.success('剧单分类已更新')
|
||
} else {
|
||
const res = await createPlaylistCategoryApi(payload)
|
||
activeId.value = res.data
|
||
pageIndex.value = 1
|
||
ElMessage.success('剧单分类已创建')
|
||
}
|
||
dialogVisible.value = false
|
||
await loadCategories()
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function confirmDelete(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定删除剧单分类「${row.name}」吗?关联关系会一起删除,短剧本身不受影响。`,
|
||
'删除确认',
|
||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
await deletePlaylistCategoryApi(row.id)
|
||
if (activeId.value === row.id) {
|
||
activeId.value = null
|
||
}
|
||
ElMessage.success('剧单分类已删除')
|
||
await loadCategories()
|
||
}
|
||
|
||
async function removeDrama(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定把「${row.title}」从「${activeCategory.value?.name}」移出吗?`,
|
||
'移除确认',
|
||
{ confirmButtonText: '确认移出', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
await unbindPlaylistCategoryDramaApi(activeId.value, row.id)
|
||
ElMessage.success('已移出')
|
||
await Promise.all([loadDramas(), loadCategories()])
|
||
}
|
||
|
||
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 listPlaylistCategoryDramaCandidatesApi(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 bindPlaylistCategoryDramasApi(
|
||
activeId.value,
|
||
selectedDramas.value.map((item) => item.id)
|
||
)
|
||
ElMessage.success(`已加入 ${res.data ?? selectedDramas.value.length} 部短剧`)
|
||
candidateVisible.value = false
|
||
await Promise.all([loadDramas(), loadCategories()])
|
||
} finally {
|
||
binding.value = false
|
||
}
|
||
}
|
||
|
||
function formatTime(value) {
|
||
if (!value) return '—'
|
||
return String(value).replace('T', ' ').slice(0, 19)
|
||
}
|
||
|
||
onMounted(loadCategories)
|
||
</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="playlist-layout">
|
||
<aside class="panel playlist-layout__side">
|
||
<div class="playlist-layout__side-title">剧单分类</div>
|
||
<el-scrollbar height="560px">
|
||
<ul class="playlist-list">
|
||
<li
|
||
v-for="item in categories"
|
||
:key="item.id"
|
||
class="playlist-list__item"
|
||
:class="{ 'is-active': item.id === activeId }"
|
||
@click="selectCategory(item)"
|
||
>
|
||
<div class="playlist-list__main">
|
||
<span class="playlist-list__name">{{ item.name }}</span>
|
||
<span class="playlist-list__count">{{ item.dramaCount || 0 }}</span>
|
||
</div>
|
||
<div class="playlist-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="!categories.length" class="playlist-list__empty">
|
||
还没有剧单分类,点右上角「新建分类」开始
|
||
</li>
|
||
</ul>
|
||
</el-scrollbar>
|
||
</aside>
|
||
|
||
<section class="panel playlist-layout__main">
|
||
<div class="playlist-toolbar">
|
||
<div class="playlist-toolbar__title">
|
||
{{ activeCategory?.name || '请选择剧单分类' }}
|
||
<small>共 {{ total }} 部短剧</small>
|
||
<small v-if="activeCategory?.description">{{ activeCategory.description }}</small>
|
||
</div>
|
||
<div class="playlist-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="playlist-cover" alt="封面" />
|
||
<div v-else class="playlist-cover playlist-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="playlist-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="640px"
|
||
>
|
||
<el-form label-position="top">
|
||
<LocaleTextFields
|
||
:languages="languages"
|
||
:locales="form.locales"
|
||
@update:locales="form.locales = $event"
|
||
label="名称"
|
||
:maxlength="64"
|
||
@update:languages="changeLanguages"
|
||
/>
|
||
<LocaleTextFields
|
||
:languages="languages"
|
||
:locales="form.descriptionLocales"
|
||
@update:locales="form.descriptionLocales = $event"
|
||
label="描述"
|
||
:maxlength="255"
|
||
multiline
|
||
:show-languages="false"
|
||
:required="false"
|
||
/>
|
||
<div class="playlist-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="saveCategory">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="candidateVisible" title="选择短剧" width="760px">
|
||
<div class="playlist-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="playlist-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>
|
||
.playlist-layout {
|
||
display: grid;
|
||
grid-template-columns: 260px minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
|
||
.playlist-layout__side {
|
||
padding: 12px 0 8px;
|
||
}
|
||
|
||
.playlist-layout__side-title {
|
||
padding: 0 16px 10px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
.playlist-list {
|
||
padding: 0;
|
||
margin: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
.playlist-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;
|
||
}
|
||
|
||
.playlist-list__item:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
.playlist-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);
|
||
}
|
||
|
||
.playlist-list__main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.playlist-list__name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.playlist-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;
|
||
}
|
||
|
||
.playlist-list__actions {
|
||
display: none;
|
||
gap: 2px;
|
||
}
|
||
|
||
.playlist-list__item:hover .playlist-list__actions {
|
||
display: flex;
|
||
}
|
||
|
||
.playlist-list__empty {
|
||
padding: 16px;
|
||
line-height: 1.6;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.playlist-layout__main {
|
||
padding: 16px;
|
||
}
|
||
|
||
.playlist-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.playlist-toolbar__title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.playlist-toolbar__title small {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.playlist-toolbar__search {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.playlist-cover {
|
||
width: 48px;
|
||
height: 64px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.playlist-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);
|
||
}
|
||
|
||
.playlist-pagination {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.playlist-form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
</style>
|