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

661 lines
18 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 {
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>