初始化2
This commit is contained in:
516
app/pages/admin/article-categories.vue
Normal file
516
app/pages/admin/article-categories.vue
Normal 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>
|
||||
Reference in New Issue
Block a user