Files
hms/apps/web/src/pages/health/ArticleCategoryManage.tsx
iven 17b423b9b8
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
后端:
- 文章审核状态机: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 组件导入
2026-04-26 12:51:30 +08:00

274 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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>
);
}