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'
export interface DramaClassificationItem {
id: number
id: string
name: string
code: string
nameEn: string
nameId: string
locales?: Record<string, string>
layoutType: 'GRID' | 'RANKING'
revision: string
sort: number
status: number
dramaCount: number
@@ -18,17 +21,20 @@ export interface DramaClassificationItem {
export interface DramaClassificationSaveData {
name: string
locales: Record<string, string>
code?: string
layoutType?: 'GRID' | 'RANKING'
sort?: number
status?: number
}
export interface ClassificationDramaItem {
id: number
id: string
title: string
cover: string
status: string
totalEpisodes: number
sort: number
position?: number
updatedAt: string
}
@@ -43,19 +49,19 @@ export const listClassificationsApi = () => {
}
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 })
}
export const deleteClassificationApi = (id: number) => {
export const deleteClassificationApi = (id: string) => {
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>>({
url: `/admin/drama-classifications/${id}/dramas`,
params
@@ -64,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: number,
id: string,
params: ClassificationDramaPageParams
) => {
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>({
url: `/admin/drama-classifications/${id}/dramas`,
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>({
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>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable } from 'vue-draggable-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
@@ -19,6 +20,7 @@ import {
listClassificationDramaCandidatesApi,
listClassificationDramasApi,
listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi,
updateClassificationApi
} from '@/api/admin/dramaClassification'
@@ -43,6 +45,7 @@ const activeId = ref(null)
const activeClassification = computed(
() => classifications.value.find((item) => item.id === activeId.value) || null
)
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
const loading = ref(false)
const dramas = ref([])
@@ -50,12 +53,19 @@ const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const rankingDirty = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
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 candidateVisible = ref(false)
@@ -91,10 +101,11 @@ async function loadDramas() {
const res = await listClassificationDramasApi(activeId.value, {
keyword: keyword.value || undefined,
pageIndex: pageIndex.value,
pageSize: pageSize.value
pageSize: isRanking.value ? 50 : pageSize.value
})
dramas.value = res.data?.list || []
total.value = res.data?.total || 0
rankingDirty.value = false
} finally {
loading.value = false
}
@@ -105,6 +116,7 @@ function selectClassification(row) {
activeId.value = row.id
pageIndex.value = 1
keyword.value = ''
rankingDirty.value = false
loadDramas()
}
@@ -132,6 +144,8 @@ function openEdit(row) {
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0,
status: row.status ?? 1
})
@@ -150,6 +164,10 @@ async function saveClassification() {
const payload = {
name: locales.zh,
locales,
code:
form.code ||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
layoutType: form.layoutType,
sort: form.sort,
status: form.status
}
@@ -197,7 +215,12 @@ async function removeDrama(row) {
} catch {
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('已移出')
await Promise.all([loadDramas(), loadClassifications()])
}
@@ -247,11 +270,23 @@ async function confirmBind() {
}
binding.value = true
try {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
if (isRanking.value) {
const existingIds = dramas.value.map((item) => item.id)
const selectedIds = selectedDramas.value.map((item) => item.id)
const nextIds = [...new Set([...existingIds, ...selectedIds])]
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
await Promise.all([loadDramas(), loadClassifications()])
} 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) {
if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19)
@@ -271,7 +322,7 @@ onMounted(loadClassifications)
<PageHeading
eyebrow="内容管理"
title="分类管理"
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。"
description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
@@ -293,9 +344,11 @@ onMounted(loadClassifications)
<div class="classification-toolbar__title">
{{ activeClassification?.name || '请选择分类' }}
<small>共 {{ total }} 部关联短剧</small>
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
</div>
<div class="classification-toolbar__search">
<el-input
v-if="!isRanking"
v-model="keyword"
placeholder="搜索短剧名称"
clearable
@@ -303,7 +356,15 @@ onMounted(loadClassifications)
@keyup.enter="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">
<AppIcon :icon="AppIcons.add" />
添加短剧
@@ -311,7 +372,41 @@ onMounted(loadClassifications)
</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
v-else
v-loading="loading"
:data="dramas"
row-key="id"
@@ -353,7 +448,7 @@ onMounted(loadClassifications)
</el-table-column>
</el-table>
<div class="classification-pagination">
<div v-if="!isRanking" class="classification-pagination">
<el-pagination
:current-page="pageIndex"
:page-size="pageSize"
@@ -434,6 +529,74 @@ onMounted(loadClassifications)
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 {
display: flex;
align-items: center;
@@ -64,6 +64,23 @@ const emit = defineEmits([
@update:languages="emit('update:languages', $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">
<el-form-item label="排序">
<el-input-number
@@ -29,6 +29,9 @@ const { can } = useRbac()
>
<div class="classification-list__main">
<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>
</div>
<div class="classification-list__actions" @click.stop>