feat(classification): 分类支持榜单布局与顺序维护
web-ci / build (push) Failing after 12s

This commit is contained in:
Zhou Weidong committed 2026-11-02 14:47:59 +08:00
1 parent 23026dc371
commit 9ce014e7b3
4 files changed
+217 -20

No files matched your search

+23 -9
View File
@@ -4,11 +4,14 @@ import request from '@/axios'
import type { PageResult } from './types' import type { PageResult } from './types'
export interface DramaClassificationItem { export interface DramaClassificationItem {
id: number id: string
name: string name: string
code: string
nameEn: string nameEn: string
nameId: string nameId: string
locales?: Record<string, string> locales?: Record<string, string>
layoutType: 'GRID' | 'RANKING'
revision: string
sort: number sort: number
status: number status: number
dramaCount: number dramaCount: number
@@ -18,17 +21,20 @@ export interface DramaClassificationItem {
export interface DramaClassificationSaveData { export interface DramaClassificationSaveData {
name: string name: string
locales: Record<string, string> locales: Record<string, string>
code?: string
layoutType?: 'GRID' | 'RANKING'
sort?: number sort?: number
status?: number status?: number
} }
export interface ClassificationDramaItem { export interface ClassificationDramaItem {
id: number id: string
title: string title: string
cover: string cover: string
status: string status: string
totalEpisodes: number totalEpisodes: number
sort: number sort: number
position?: number
updatedAt: string updatedAt: string
} }
@@ -43,19 +49,19 @@ export const listClassificationsApi = () => {
} }
export const createClassificationApi = (data: DramaClassificationSaveData) => { export const createClassificationApi = (data: DramaClassificationSaveData) => {
return request.post<number>({ url: '/admin/drama-classifications', data }) return request.post<string>({ url: '/admin/drama-classifications', data })
} }
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => { export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data }) return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
} }
export const deleteClassificationApi = (id: number) => { export const deleteClassificationApi = (id: string) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` }) return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
} }
/** 分类下已关联的短剧 */ /** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => { export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({ return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`, url: `/admin/drama-classifications/${id}/dramas`,
params params
@@ -64,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
/** 尚未关联到该分类的短剧(添加弹窗用) */ /** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = ( export const listClassificationDramaCandidatesApi = (
id: number, id: string,
params: ClassificationDramaPageParams params: ClassificationDramaPageParams
) => { ) => {
return request.get<PageResult<ClassificationDramaItem>>({ return request.get<PageResult<ClassificationDramaItem>>({
@@ -74,7 +80,7 @@ export const listClassificationDramaCandidatesApi = (
} }
/** 批量关联短剧,返回实际新增数量 */ /** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => { export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
return request.post<number>({ return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`, url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds } data: { dramaIds }
@@ -82,8 +88,16 @@ export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
} }
/** 从分类移除短剧 */ /** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: number, dramaId: number) => { export const unbindClassificationDramaApi = (id: string, dramaId: string) => {
return request.delete<void>({ return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}` url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
}) })
} }
/** 原子保存推荐榜完整内容与顺序 */
export const saveRankingItemsApi = (id: string, dramaIds: string[]) => {
return request.put<void>({
url: `/admin/drama-classifications/${id}/ranking-items`,
data: { dramaIds }
})
}
+174 -11
View File
@@ -4,6 +4,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable } from 'vue-draggable-plus'
import { AppIcon } from '@/components/AppIcon' import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { import {
@@ -19,6 +20,7 @@ import {
listClassificationDramaCandidatesApi, listClassificationDramaCandidatesApi,
listClassificationDramasApi, listClassificationDramasApi,
listClassificationsApi, listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi, unbindClassificationDramaApi,
updateClassificationApi updateClassificationApi
} from '@/api/admin/dramaClassification' } from '@/api/admin/dramaClassification'
@@ -43,6 +45,7 @@ const activeId = ref(null)
const activeClassification = computed( const activeClassification = computed(
() => classifications.value.find((item) => item.id === activeId.value) || null () => classifications.value.find((item) => item.id === activeId.value) || null
) )
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
const loading = ref(false) const loading = ref(false)
const dramas = ref([]) const dramas = ref([])
@@ -50,12 +53,19 @@ const total = ref(0)
const pageIndex = ref(1) const pageIndex = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const keyword = ref('') const keyword = ref('')
const rankingDirty = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const editingId = ref(null) const editingId = ref(null)
const languages = ref(['zh']) const languages = ref(['zh'])
const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 }) const emptyForm = () => ({
locales: { zh: '' },
code: '',
layoutType: 'GRID',
sort: 1,
status: 1
})
const form = reactive(emptyForm()) const form = reactive(emptyForm())
const candidateVisible = ref(false) const candidateVisible = ref(false)
@@ -91,10 +101,11 @@ async function loadDramas() {
const res = await listClassificationDramasApi(activeId.value, { const res = await listClassificationDramasApi(activeId.value, {
keyword: keyword.value || undefined, keyword: keyword.value || undefined,
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize.value pageSize: isRanking.value ? 50 : pageSize.value
}) })
dramas.value = res.data?.list || [] dramas.value = res.data?.list || []
total.value = res.data?.total || 0 total.value = res.data?.total || 0
rankingDirty.value = false
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -105,6 +116,7 @@ function selectClassification(row) {
activeId.value = row.id activeId.value = row.id
pageIndex.value = 1 pageIndex.value = 1
keyword.value = '' keyword.value = ''
rankingDirty.value = false
loadDramas() loadDramas()
} }
@@ -132,6 +144,8 @@ function openEdit(row) {
languages.value = languageCodes(locales) languages.value = languageCodes(locales)
Object.assign(form, { Object.assign(form, {
locales, locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0, sort: row.sort ?? 0,
status: row.status ?? 1 status: row.status ?? 1
}) })
@@ -150,6 +164,10 @@ async function saveClassification() {
const payload = { const payload = {
name: locales.zh, name: locales.zh,
locales, locales,
code:
form.code ||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
layoutType: form.layoutType,
sort: form.sort, sort: form.sort,
status: form.status status: form.status
} }
@@ -197,7 +215,12 @@ async function removeDrama(row) {
} catch { } catch {
return return
} }
await unbindClassificationDramaApi(activeId.value, row.id) if (isRanking.value) {
const remaining = dramas.value.filter((item) => item.id !== row.id).map((item) => item.id)
await saveRankingItemsApi(activeId.value, remaining)
} else {
await unbindClassificationDramaApi(activeId.value, row.id)
}
ElMessage.success('已移出') ElMessage.success('已移出')
await Promise.all([loadDramas(), loadClassifications()]) await Promise.all([loadDramas(), loadClassifications()])
} }
@@ -247,11 +270,23 @@ async function confirmBind() {
} }
binding.value = true binding.value = true
try { try {
const res = await bindClassificationDramasApi( if (isRanking.value) {
activeId.value, const existingIds = dramas.value.map((item) => item.id)
selectedDramas.value.map((item) => item.id) const selectedIds = selectedDramas.value.map((item) => item.id)
) const nextIds = [...new Set([...existingIds, ...selectedIds])]
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`) if (nextIds.length > 50) {
ElMessage.warning('推荐榜最多 50 部短剧')
return
}
await saveRankingItemsApi(activeId.value, nextIds)
ElMessage.success(`推荐榜已增加 ${selectedIds.length} 部短剧`)
} else {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
}
candidateVisible.value = false candidateVisible.value = false
await Promise.all([loadDramas(), loadClassifications()]) await Promise.all([loadDramas(), loadClassifications()])
} finally { } finally {
@@ -259,6 +294,22 @@ async function confirmBind() {
} }
} }
function onRankingChange() {
dramas.value = dramas.value.map((item, index) => ({ ...item, position: index + 1 }))
rankingDirty.value = true
}
async function saveRankingOrder() {
if (!activeId.value || !dramas.value.length) return
await saveRankingItemsApi(
activeId.value,
dramas.value.map((item) => item.id)
)
rankingDirty.value = false
ElMessage.success('推荐榜顺序已保存')
await loadClassifications()
}
function formatTime(value) { function formatTime(value) {
if (!value) return '—' if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19) return String(value).replace('T', ' ').slice(0, 19)
@@ -271,7 +322,7 @@ onMounted(loadClassifications)
<PageHeading <PageHeading
eyebrow="内容管理" eyebrow="内容管理"
title="分类管理" title="分类管理"
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。" description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
> >
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate"> <el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" /> <AppIcon :icon="AppIcons.add" />
@@ -293,9 +344,11 @@ onMounted(loadClassifications)
<div class="classification-toolbar__title"> <div class="classification-toolbar__title">
{{ activeClassification?.name || '请选择分类' }} {{ activeClassification?.name || '请选择分类' }}
<small>共 {{ total }} 部关联短剧</small> <small>共 {{ total }} 部关联短剧</small>
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
</div> </div>
<div class="classification-toolbar__search"> <div class="classification-toolbar__search">
<el-input <el-input
v-if="!isRanking"
v-model="keyword" v-model="keyword"
placeholder="搜索短剧名称" placeholder="搜索短剧名称"
clearable clearable
@@ -303,7 +356,15 @@ onMounted(loadClassifications)
@keyup.enter="search" @keyup.enter="search"
@clear="search" @clear="search"
/> />
<el-button type="primary" plain @click="search">查询</el-button> <el-button v-if="!isRanking" type="primary" plain @click="search">查询</el-button>
<el-button
v-if="isRanking && can('drama:update')"
type="primary"
:disabled="!rankingDirty"
@click="saveRankingOrder"
>
保存排序
</el-button>
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates"> <el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
<AppIcon :icon="AppIcons.add" /> <AppIcon :icon="AppIcons.add" />
添加短剧 添加短剧
@@ -311,7 +372,41 @@ onMounted(loadClassifications)
</div> </div>
</div> </div>
<VueDraggable
v-if="isRanking"
v-model="dramas"
item-key="id"
handle=".ranking-row__drag"
class="ranking-list"
@end="onRankingChange"
>
<div v-for="(row, index) in dramas" :key="row.id" class="ranking-row">
<button class="ranking-row__drag" type="button" title="拖动调整排名">
<AppIcon :icon="AppIcons.drag" />
</button>
<span class="ranking-row__rank">{{ index + 1 }}</span>
<img v-if="row.cover" :src="row.cover" class="ranking-row__cover" alt="封面" />
<div v-else class="ranking-row__cover ranking-row__cover--empty">无</div>
<div class="ranking-row__content">
<strong>{{ row.title }}</strong>
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }} · {{ row.totalEpisodes || 0 }} 集
</span>
</div>
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>
移出
</el-button>
</div>
<el-empty v-if="!dramas.length" description="推荐榜还没有短剧" />
</VueDraggable>
<el-table <el-table
v-else
v-loading="loading" v-loading="loading"
:data="dramas" :data="dramas"
row-key="id" row-key="id"
@@ -353,7 +448,7 @@ onMounted(loadClassifications)
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="classification-pagination"> <div v-if="!isRanking" class="classification-pagination">
<el-pagination <el-pagination
:current-page="pageIndex" :current-page="pageIndex"
:page-size="pageSize" :page-size="pageSize"
@@ -434,6 +529,74 @@ onMounted(loadClassifications)
border-radius: 4px; border-radius: 4px;
} }
.ranking-list {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--el-border-color);
}
.ranking-row {
display: grid;
grid-template-columns: 36px 48px 52px minmax(0, 1fr) auto;
min-height: 72px;
padding: 8px 12px;
background: var(--el-bg-color);
align-items: center;
gap: 12px;
}
.ranking-row + .ranking-row {
border-top: 1px solid var(--el-border-color-lighter);
}
.ranking-row__drag {
display: inline-flex;
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: grab;
background: transparent;
border: 0;
align-items: center;
justify-content: center;
}
.ranking-row__rank {
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
text-align: center;
}
.ranking-row__cover {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 4px;
}
.ranking-row__cover--empty {
display: flex;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
align-items: center;
justify-content: center;
}
.ranking-row__content {
display: grid;
min-width: 0;
gap: 6px;
}
.ranking-row__content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-cover--empty { .classification-cover--empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -64,6 +64,23 @@ const emit = defineEmits([
@update:languages="emit('update:languages', $event)" @update:languages="emit('update:languages', $event)"
@update:locales="emit('update:form', { ...form, locales: $event })" @update:locales="emit('update:form', { ...form, locales: $event })"
/> />
<el-form-item label="展示样式">
<el-radio-group
:model-value="form.layoutType"
@update:model-value="emit('update:form', { ...form, layoutType: $event })"
>
<el-radio-button label="GRID">普通分类</el-radio-button>
<el-radio-button label="RANKING">推荐榜</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="分类编码">
<el-input
:model-value="form.code"
maxlength="64"
placeholder="例如 RECOMMEND_RANKING"
@update:model-value="emit('update:form', { ...form, code: $event.toUpperCase() })"
/>
</el-form-item>
<div class="classification-form-row"> <div class="classification-form-row">
<el-form-item label="排序"> <el-form-item label="排序">
<el-input-number <el-input-number
@@ -29,6 +29,9 @@ const { can } = useRbac()
> >
<div class="classification-list__main"> <div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span> <span class="classification-list__name">{{ item.name }}</span>
<el-tag v-if="item.layoutType === 'RANKING'" size="small" type="warning">
推荐榜
</el-tag>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span> <span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div> </div>
<div class="classification-list__actions" @click.stop> <div class="classification-list__actions" @click.stop>