初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

View File

@@ -0,0 +1,516 @@
<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 @click="loadCategories" :loading="loading">
<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 :xs="12" :md="8">
<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 :xs="12" :md="8">
<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 :xs="12" :md="8">
<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"
style="width: 240px"
placeholder="搜索分类名称 / 标识 / 路径"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedCategories"
:loading="loading"
:pagination="tablePagination"
row-key="categoryId"
@change="handleTableChange"
size="middle"
>
<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 type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此分类?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:title="editingCategory?.categoryId ? '编辑分类' : '新增分类'"
width="720px"
:confirm-loading="saving"
@ok="handleSave"
@cancel="showFormModal = false"
>
<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"
placeholder="请输入分类名称"
:maxlength="80"
show-count
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类标识">
<a-input
v-model:value="formData.categoryCode"
placeholder="例如 news / tutorial"
:maxlength="60"
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"
allow-clear
show-search
option-filter-prop="label"
placeholder="无上级则留空"
:options="parentOptions"
/>
</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 setup lang="ts">
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>