后端: - 文章审核状态机:draft → pending_review → published(含 reject/unpublish) - 文章分类 CRUD(article_category entity + service + handler) - 文章标签 CRUD(article_tag + article_article_tag 关联) - 文章修订版快照(article_revision) - 阅读计数、排序、slug、审核备注 - 新增 health.articles.review 权限 前端: - ArticleManageList:状态标签页 + 分类筛选 + 关键字搜索 + 审核操作 - ArticleEditor:Wangeditor 富文本编辑器 + 元数据侧栏 - ArticleCategoryManage:分类 CRUD + 父子层级 - ArticleTagManage:标签 CRUD 修复: - diagnosis_service/health_data_service/dialysis_service: 补充 key_version 字段 - ArticleCategoryManage: 补充 Select 组件导入
274 lines
7.6 KiB
TypeScript
274 lines
7.6 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Space,
|
||
Modal,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Select,
|
||
Popconfirm,
|
||
message,
|
||
} from 'antd';
|
||
import {
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
articleCategoryApi,
|
||
type ArticleCategory,
|
||
type CreateCategoryReq,
|
||
type UpdateCategoryReq,
|
||
} from '../../api/health/articles';
|
||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||
import { AuthButton } from '../../components/AuthButton';
|
||
|
||
export default function ArticleCategoryManage() {
|
||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editingCategory, setEditingCategory] = useState<ArticleCategory | null>(null);
|
||
const [form] = Form.useForm();
|
||
const isDark = useThemeMode();
|
||
|
||
const fetchCategories = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await articleCategoryApi.list();
|
||
setCategories(result);
|
||
} catch {
|
||
message.error('加载分类列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchCategories();
|
||
}, [fetchCategories]);
|
||
|
||
const openCreateModal = () => {
|
||
setEditingCategory(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({ sort_order: 0 });
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const openEditModal = (record: ArticleCategory) => {
|
||
setEditingCategory(record);
|
||
form.setFieldsValue({
|
||
name: record.name,
|
||
slug: record.slug,
|
||
parent_id: record.parent_id,
|
||
sort_order: record.sort_order,
|
||
description: record.description,
|
||
});
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const closeModal = () => {
|
||
setModalOpen(false);
|
||
setEditingCategory(null);
|
||
form.resetFields();
|
||
};
|
||
|
||
const handleSubmit = async (values: {
|
||
name: string;
|
||
slug?: string;
|
||
parent_id?: string;
|
||
sort_order?: number;
|
||
description?: string;
|
||
}) => {
|
||
try {
|
||
if (editingCategory) {
|
||
const req: UpdateCategoryReq = {
|
||
name: values.name,
|
||
slug: values.slug,
|
||
parent_id: values.parent_id,
|
||
sort_order: values.sort_order,
|
||
description: values.description,
|
||
};
|
||
await articleCategoryApi.update(editingCategory.id, req);
|
||
message.success('分类更新成功');
|
||
} else {
|
||
const req: CreateCategoryReq = {
|
||
name: values.name,
|
||
slug: values.slug,
|
||
parent_id: values.parent_id,
|
||
sort_order: values.sort_order,
|
||
description: values.description,
|
||
};
|
||
await articleCategoryApi.create(req);
|
||
message.success('分类创建成功');
|
||
}
|
||
closeModal();
|
||
fetchCategories();
|
||
} catch (err: unknown) {
|
||
const errorMsg =
|
||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||
'操作失败';
|
||
message.error(errorMsg);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
try {
|
||
await articleCategoryApi.delete(id);
|
||
message.success('分类已删除');
|
||
fetchCategories();
|
||
} catch {
|
||
message.error('删除失败,可能该分类下还有文章');
|
||
}
|
||
};
|
||
|
||
// 构建父分类选项(排除自身)
|
||
const parentOptions = categories
|
||
.filter((c) => !editingCategory || c.id !== editingCategory.id)
|
||
.map((c) => ({ label: c.name, value: c.id }));
|
||
|
||
const columns = [
|
||
{
|
||
title: '分类名称',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
render: (name: string) => (
|
||
<span style={{ fontWeight: 500 }}>{name}</span>
|
||
),
|
||
},
|
||
{
|
||
title: '别名 (Slug)',
|
||
dataIndex: 'slug',
|
||
key: 'slug',
|
||
width: 180,
|
||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||
},
|
||
{
|
||
title: '父分类',
|
||
dataIndex: 'parent_name',
|
||
key: 'parent_name',
|
||
width: 140,
|
||
render: (_v: string | undefined, record: ArticleCategory) => {
|
||
if (!record.parent_id) return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||
const parent = categories.find((c) => c.id === record.parent_id);
|
||
return parent?.name || record.parent_id;
|
||
},
|
||
},
|
||
{
|
||
title: '排序',
|
||
dataIndex: 'sort_order',
|
||
key: 'sort_order',
|
||
width: 80,
|
||
render: (v: number) => v ?? 0,
|
||
},
|
||
{
|
||
title: '描述',
|
||
dataIndex: 'description',
|
||
key: 'description',
|
||
ellipsis: true,
|
||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 120,
|
||
render: (_: unknown, record: ArticleCategory) => (
|
||
<AuthButton code="health.articles.manage">
|
||
<Space size={4}>
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<EditOutlined />}
|
||
onClick={() => openEditModal(record)}
|
||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||
/>
|
||
<Popconfirm
|
||
title="确定删除此分类?"
|
||
description="删除后不可恢复,关联文章将变为未分类"
|
||
onConfirm={() => handleDelete(record.id)}
|
||
>
|
||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||
</Popconfirm>
|
||
</Space>
|
||
</AuthButton>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* 页面标题和工具栏 */}
|
||
<div className="erp-page-header">
|
||
<div>
|
||
<h4>分类管理</h4>
|
||
<div className="erp-page-subtitle">管理文章分类,支持多级分类结构</div>
|
||
</div>
|
||
<AuthButton code="health.articles.manage">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||
新建分类
|
||
</Button>
|
||
</AuthButton>
|
||
</div>
|
||
|
||
{/* 表格容器 */}
|
||
<div
|
||
style={{
|
||
background: isDark ? '#111827' : '#FFFFFF',
|
||
borderRadius: 12,
|
||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={categories}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
/>
|
||
</div>
|
||
|
||
{/* 新建/编辑分类弹窗 */}
|
||
<Modal
|
||
title={editingCategory ? '编辑分类' : '新建分类'}
|
||
open={modalOpen}
|
||
onCancel={closeModal}
|
||
onOk={() => form.submit()}
|
||
width={520}
|
||
>
|
||
<Form
|
||
form={form}
|
||
onFinish={handleSubmit}
|
||
layout="vertical"
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
<Form.Item
|
||
name="name"
|
||
label="分类名称"
|
||
rules={[{ required: true, message: '请输入分类名称' }]}
|
||
>
|
||
<Input placeholder="请输入分类名称" maxLength={50} />
|
||
</Form.Item>
|
||
<Form.Item name="slug" label="别名 (Slug)">
|
||
<Input placeholder="例如: health-tips(留空则自动生成)" />
|
||
</Form.Item>
|
||
<Form.Item name="parent_id" label="父分类">
|
||
<Select
|
||
placeholder="选择父分类(可选)"
|
||
allowClear
|
||
options={parentOptions}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<Input.TextArea rows={2} placeholder="请输入分类描述" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|