feat(admin): 新增分类管理与剧单管理页面
This commit is contained in:
1 parent
1d18b7b52b
commit
a47700a980
4 files changed
+1387
No files matched your search
@@ -0,0 +1,88 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
export interface DramaClassificationItem {
|
||||
id: number
|
||||
name: string
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface DramaClassificationSaveData {
|
||||
name: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface ClassificationDramaItem {
|
||||
id: number
|
||||
title: string
|
||||
cover: string
|
||||
status: string
|
||||
totalEpisodes: number
|
||||
sort: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ClassificationDramaPageParams {
|
||||
keyword?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const listClassificationsApi = () => {
|
||||
return request.get<DramaClassificationItem[]>({ url: '/admin/drama-classifications' })
|
||||
}
|
||||
|
||||
export const createClassificationApi = (data: DramaClassificationSaveData) => {
|
||||
return request.post<number>({ url: '/admin/drama-classifications', data })
|
||||
}
|
||||
|
||||
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteClassificationApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listClassificationDramasApi = (
|
||||
id: number,
|
||||
params: ClassificationDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 尚未关联到该分类的短剧(添加弹窗用) */
|
||||
export const listClassificationDramaCandidatesApi = (
|
||||
id: number,
|
||||
params: ClassificationDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
|
||||
return request.delete<void>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
import type { ClassificationDramaItem } from './dramaClassification'
|
||||
|
||||
export interface DramaPlaylistCategoryItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
}
|
||||
|
||||
export interface DramaPlaylistCategorySaveData {
|
||||
name: string
|
||||
description?: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface PlaylistCategoryDramaPageParams {
|
||||
keyword?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const listPlaylistCategoriesApi = () => {
|
||||
return request.get<DramaPlaylistCategoryItem[]>({ url: '/admin/drama-playlist-categories' })
|
||||
}
|
||||
|
||||
export const createPlaylistCategoryApi = (data: DramaPlaylistCategorySaveData) => {
|
||||
return request.post<number>({ url: '/admin/drama-playlist-categories', data })
|
||||
}
|
||||
|
||||
export const updatePlaylistCategoryApi = (id: number, data: DramaPlaylistCategorySaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-playlist-categories/${id}`, data })
|
||||
}
|
||||
|
||||
export const deletePlaylistCategoryApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/drama-playlist-categories/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listPlaylistCategoryDramasApi = (
|
||||
id: number,
|
||||
params: PlaylistCategoryDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 尚未关联到该分类的短剧(选择弹窗用) */
|
||||
export const listPlaylistCategoryDramaCandidatesApi = (
|
||||
id: number,
|
||||
params: PlaylistCategoryDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas/candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindPlaylistCategoryDramasApi = (id: number, dramaIds: number[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
export const unbindPlaylistCategoryDramaApi = (id: number, dramaId: number) => {
|
||||
return request.delete<void>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas/${dramaId}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,618 @@
|
||||
<!-- 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 {
|
||||
bindPlaylistCategoryDramasApi,
|
||||
createPlaylistCategoryApi,
|
||||
deletePlaylistCategoryApi,
|
||||
listPlaylistCategoriesApi,
|
||||
listPlaylistCategoryDramaCandidatesApi,
|
||||
listPlaylistCategoryDramasApi,
|
||||
unbindPlaylistCategoryDramaApi,
|
||||
updatePlaylistCategoryApi
|
||||
} from '@/api/admin/dramaPlaylistCategory'
|
||||
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 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 form = reactive({ name: '', description: '', 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 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 openCreate() {
|
||||
editingId.value = null
|
||||
const maxSort = categories.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
|
||||
Object.assign(form, { name: '', description: '', sort: maxSort + 1, status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
sort: row.sort ?? 0,
|
||||
status: row.status ?? 1
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请填写剧单分类名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
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="460px"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分类名称" required>
|
||||
<el-input v-model="form.name" maxlength="20" placeholder="例如:本周必看" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="分类用途说明"
|
||||
/>
|
||||
</el-form-item>
|
||||
<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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.playlist-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;
|
||||
}
|
||||
|
||||
.playlist-list__item:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.playlist-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
text-align: center;
|
||||
border-radius: 9px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.playlist-list__actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.playlist-list__item:hover .playlist-list__actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.playlist-list__empty {
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in new issue
Block a user