Files
template-nuxt4/app/pages/admin/article-categories.vue
2026-04-29 01:33:33 +08:00

517 lines
16 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<template>
<div class="article-categories-page">
<div class="page-header">
<div>
<h2 class="page-title">🗂 文章分类</h2>
<p class="page-desc">统一维护文章分类层级排序与展示状态</p>
</div>
<a-space>
<a-button @click="navigateTo('/admin/articles')">返回文章管理</a-button>
<a-button :loading="loading" @click="loadCategories">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新增分类
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :md="8" :xs="12">
<div class="stat-card blue">
<div class="stat-icon">🗂</div>
<div class="stat-info">
<div class="stat-value">{{ categories.length }}</div>
<div class="stat-label">全部分类</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card green">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ enabledCount }}</div>
<div class="stat-label">启用中</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card orange">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ recommendCount }}</div>
<div class="stat-label">推荐分类</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 分类列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 140px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">禁用</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索分类名称 / 标识 / 路径"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedCategories"
:loading="loading"
:pagination="tablePagination"
row-key="categoryId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="category-info-cell">
<div class="category-title-row">
<span class="category-title">{{ record.title || '-' }}</span>
<a-tag v-if="record.parentId" color="blue">上级{{ resolveParentName(record.parentId) }}</a-tag>
</div>
<div class="category-meta">
<span v-if="record.categoryCode">标识{{ record.categoryCode }}</span>
<span v-if="record.path" class="meta-item">路径{{ record.path }}</span>
</div>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag>{{ typeText(record.type) }}</a-tag>
</template>
<template v-if="column.key === 'sortNumber'">
<span>{{ record.sortNumber ?? 0 }}</span>
</template>
<template v-if="column.key === 'count'">
<span>{{ record.count ?? 0 }}</span>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 0 ? 'success' : 'default'">
{{ record.status === 0 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'flags'">
<div class="flag-list">
<a-tag v-if="record.recommend" color="gold">推荐</a-tag>
<a-tag v-if="record.showIndex" color="green">首页</a-tag>
<a-tag v-if="record.hide" color="default">隐藏</a-tag>
<span v-if="!record.recommend && !record.showIndex && !record.hide" class="text-gray">-</span>
</div>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此分类?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingCategory?.categoryId ? '编辑分类' : '新增分类'"
width="720px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="分类名称" required>
<a-input
v-model:value="formData.title"
:maxlength="80"
placeholder="请输入分类名称"
show-count
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类标识">
<a-input
v-model:value="formData.categoryCode"
:maxlength="60"
placeholder="例如 news / tutorial"
show-count
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上级分类">
<a-select
v-model:value="formData.parentId"
:options="parentOptions"
allow-clear
option-filter-prop="label"
placeholder="无上级则留空"
show-search
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类类型">
<a-select v-model:value="formData.type">
<a-select-option :value="0">列表</a-select-option>
<a-select-option :value="1">单页</a-select-option>
<a-select-option :value="2">外链</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="访问路径">
<a-input v-model:value="formData.path" placeholder="例如 /news 或 https://example.com" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序值">
<a-input-number v-model:value="formData.sortNumber" :min="0" :precision="0" class="w-full" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">禁用</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="推荐分类">
<a-switch v-model:checked="formRecommend" />
<span class="switch-tip">用于前台推荐位</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="首页显示">
<a-switch v-model:checked="formShowIndex" />
<span class="switch-tip">首页导航可见</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="是否隐藏">
<a-switch v-model:checked="formHide" />
<span class="switch-tip">仅注册不展示</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
listAppArticleCategory as listCmsArticleCategory,
addAppArticleCategory as addCmsArticleCategory,
updateAppArticleCategory as updateCmsArticleCategory,
removeAppArticleCategory as removeCmsArticleCategory,
} from '@/api/app/articleCategory'
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '文章分类 - 平台管理' })
const loading = ref(false)
const saving = ref(false)
const categories = ref<CmsArticleCategory[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const columns = [
{ title: '分类信息', key: 'info', width: 360 },
{ title: '类型', key: 'type', width: 100 },
{ title: '排序', key: 'sortNumber', width: 90 },
{ title: '文章数', key: 'count', width: 90 },
{ title: '状态', key: 'status', width: 90 },
{ title: '标记', key: 'flags', width: 180 },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 140 },
]
const filteredCategories = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return [...categories.value]
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.categoryCode, item.path]
.some(value => String(value || '').toLowerCase().includes(keyword))
})
.sort((a, b) => {
const sortDiff = (a.sortNumber || 0) - (b.sortNumber || 0)
if (sortDiff !== 0) return sortDiff
return (b.categoryId || 0) - (a.categoryId || 0)
})
})
const pagedCategories = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredCategories.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredCategories.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
const enabledCount = computed(() => categories.value.filter(item => item.status === 0).length)
const recommendCount = computed(() => categories.value.filter(item => !!item.recommend).length)
const showFormModal = ref(false)
const editingCategory = ref<CmsArticleCategory | null>(null)
const formData = reactive<CmsArticleCategory>({
title: '',
categoryCode: '',
parentId: undefined,
type: 0,
path: '',
sortNumber: 0,
status: 0,
})
const formRecommend = ref(false)
const formShowIndex = ref(false)
const formHide = ref(false)
const parentOptions = computed(() =>
categories.value
.filter(item => item.categoryId && item.categoryId !== editingCategory.value?.categoryId)
.map(item => ({
value: item.categoryId,
label: item.title || `分类 ${item.categoryId}`,
}))
)
async function loadCategories() {
loading.value = true
try {
const list = await listCmsArticleCategory()
categories.value = list || []
ensurePaginationInRange()
} catch (e: any) {
message.error(e?.message || '加载分类列表失败')
} finally {
loading.value = false
}
}
function ensurePaginationInRange() {
const total = filteredCategories.value.length
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
ensurePaginationInRange()
}
function resetForm() {
Object.assign(formData, {
categoryId: undefined,
title: '',
categoryCode: '',
parentId: undefined,
type: 0,
path: '',
sortNumber: 0,
status: 0,
})
formRecommend.value = false
formShowIndex.value = false
formHide.value = false
}
function handleCreate() {
editingCategory.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticleCategory) {
editingCategory.value = record
Object.assign(formData, {
categoryId: record.categoryId,
title: record.title || '',
categoryCode: record.categoryCode || '',
parentId: record.parentId,
type: record.type ?? 0,
path: record.path || '',
sortNumber: record.sortNumber ?? 0,
status: record.status ?? 0,
})
formRecommend.value = !!record.recommend
formShowIndex.value = !!record.showIndex
formHide.value = !!record.hide
showFormModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入分类名称')
return
}
saving.value = true
try {
const data: CmsArticleCategory = {
...formData,
recommend: formRecommend.value ? 1 : 0,
showIndex: formShowIndex.value ? 1 : 0,
hide: formHide.value ? 1 : 0,
}
if (editingCategory.value?.categoryId) {
await updateCmsArticleCategory(data)
message.success('分类已更新')
} else {
await addCmsArticleCategory(data)
message.success('分类已创建')
}
showFormModal.value = false
await loadCategories()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticleCategory) {
try {
await removeCmsArticleCategory(record.categoryId)
message.success('分类已删除')
await loadCategories()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
function resolveParentName(parentId?: number) {
if (!parentId) return '-'
return categories.value.find(item => item.categoryId === parentId)?.title || `分类 ${parentId}`
}
function typeText(type?: number) {
const map: Record<number, string> = {
0: '列表',
1: '单页',
2: '外链',
}
return map[type ?? 0] || '列表'
}
watch([filteredCategories, () => pagination.pageSize], () => {
ensurePaginationInRange()
})
onMounted(() => loadCategories())
</script>
<style scoped>
.article-categories-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.category-info-cell { display: flex; flex-direction: column; gap: 6px; }
.category-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.category-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.category-meta { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.meta-item { margin-left: 8px; }
.flag-list { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.switch-tip { display: block; margin-top: 6px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
.w-full { width: 100%; }
</style>