Files
hms/apps/web/src/pages/health/ArticleManageList.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

499 lines
14 KiB
TypeScript

import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Table,
Button,
Space,
Input,
Select,
Tag,
Tabs,
Popconfirm,
message,
Modal,
Form,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
SendOutlined,
CheckOutlined,
CloseOutlined,
RollbackOutlined,
EyeOutlined,
} from '@ant-design/icons';
import {
articleApi,
articleCategoryApi,
type ArticleListItem,
type ArticleStatus,
type ArticleTagItem,
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
const STATUS_TABS: { key: string; label: string }[] = [
{ key: '', label: '全部' },
{ key: 'draft', label: '草稿' },
{ key: 'pending_review', label: '待审核' },
{ key: 'published', label: '已发布' },
{ key: 'rejected', label: '已拒绝' },
];
const STATUS_CONFIG: Record<
string,
{ label: string; color: string }
> = {
draft: { label: '草稿', color: 'default' },
pending_review: { label: '待审核', color: 'processing' },
published: { label: '已发布', color: 'success' },
rejected: { label: '已拒绝', color: 'error' },
};
export default function ArticleManageList() {
const [articles, setArticles] = useState<ArticleListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [statusTab, setStatusTab] = useState('');
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
const [keyword, setKeyword] = useState('');
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
const [rejectForm] = Form.useForm();
const isDark = useThemeMode();
const navigate = useNavigate();
const fetchArticles = useCallback(
async (p = page) => {
setLoading(true);
try {
const result = await articleApi.list({
page: p,
page_size: 20,
status: (statusTab || undefined) as ArticleStatus | undefined,
category_id: categoryId,
keyword: keyword || undefined,
});
setArticles(result.data);
setTotal(result.total);
} catch {
message.error('加载文章列表失败');
} finally {
setLoading(false);
}
},
[page, statusTab, categoryId, keyword],
);
const fetchCategories = useCallback(async () => {
try {
const cats = await articleCategoryApi.list();
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
} catch {
// 分类列表加载失败不阻塞页面
}
}, []);
useEffect(() => {
fetchArticles();
}, [fetchArticles]);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback((value: string) => {
setKeyword(value);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setPage(1);
}, 300);
}, []);
const handleDelete = async (id: string) => {
try {
await articleApi.delete(id);
message.success('文章已删除');
fetchArticles();
} catch {
message.error('删除失败');
}
};
const handleSubmit = async (record: ArticleListItem) => {
try {
await articleApi.submit(record.id, record.version);
message.success('已提交审核');
fetchArticles();
} catch {
message.error('提交审核失败');
}
};
const handleApprove = async (record: ArticleListItem) => {
try {
await articleApi.approve(record.id, record.version);
message.success('审核通过,文章已发布');
fetchArticles();
} catch {
message.error('审核操作失败');
}
};
const openRejectModal = (record: ArticleListItem) => {
setRejectingArticle(record);
rejectForm.resetFields();
setRejectModalOpen(true);
};
const handleReject = async (values: { review_note: string }) => {
if (!rejectingArticle) return;
try {
await articleApi.reject(
rejectingArticle.id,
rejectingArticle.version,
values.review_note,
);
message.success('已拒绝文章');
setRejectModalOpen(false);
fetchArticles();
} catch {
message.error('拒绝操作失败');
}
};
const handleUnpublish = async (record: ArticleListItem) => {
try {
await articleApi.unpublish(record.id, record.version);
message.success('文章已撤回为草稿');
fetchArticles();
} catch {
message.error('撤回操作失败');
}
};
const renderActions = (record: ArticleListItem) => (
<Space size={4} wrap>
{record.status === 'draft' && (
<>
<AuthButton code="health.articles.manage">
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
>
</Button>
</AuthButton>
<AuthButton code="health.articles.manage">
<Button
size="small"
type="text"
icon={<SendOutlined />}
onClick={() => handleSubmit(record)}
>
</Button>
</AuthButton>
</>
)}
{record.status === 'pending_review' && (
<>
<AuthButton code="health.articles.review">
<Button
size="small"
type="text"
icon={<CheckOutlined />}
style={{ color: '#059669' }}
onClick={() => handleApprove(record)}
>
</Button>
</AuthButton>
<AuthButton code="health.articles.review">
<Button
size="small"
type="text"
icon={<CloseOutlined />}
danger
onClick={() => openRejectModal(record)}
>
</Button>
</AuthButton>
</>
)}
{record.status === 'published' && (
<AuthButton code="health.articles.manage">
<Button
size="small"
type="text"
icon={<RollbackOutlined />}
onClick={() => handleUnpublish(record)}
>
</Button>
</AuthButton>
)}
{(record.status === 'draft' || record.status === 'rejected') && (
<AuthButton code="health.articles.manage">
<Popconfirm
title="确定删除此文章?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>
</AuthButton>
)}
</Space>
);
const columns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string, record: ArticleListItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{record.cover_image && (
<div
style={{
width: 40,
height: 40,
borderRadius: 6,
background: `url(${record.cover_image}) center/cover`,
flexShrink: 0,
}}
/>
)}
<div style={{ minWidth: 0 }}>
<div
style={{ fontWeight: 500, fontSize: 14, cursor: 'pointer' }}
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
>
{title}
</div>
{record.summary && (
<div
style={{
fontSize: 12,
color: isDark ? '#64748b' : '#94a3b8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 300,
}}
>
{record.summary}
</div>
)}
</div>
</div>
),
},
{
title: '分类',
dataIndex: 'category_name',
key: 'category_name',
width: 120,
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}></span>,
},
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 180,
render: (tags?: ArticleTagItem[]) => {
if (!tags || tags.length === 0) {
return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
}
return (
<Space size={4} wrap>
{tags.map((t) => (
<Tag
key={t.id}
style={{
fontSize: 12,
background: isDark ? '#0f172a' : '#f0f9ff',
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
color: isDark ? '#7dd3fc' : '#0369a1',
}}
>
{t.name}
</Tag>
))}
</Space>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const config = STATUS_CONFIG[status] || { label: status, color: 'default' };
return <Tag color={config.color}>{config.label}</Tag>;
},
},
{
title: '作者',
dataIndex: 'author',
key: 'author',
width: 100,
render: (v?: string) => v || '-',
},
{
title: '阅读数',
dataIndex: 'view_count',
key: 'view_count',
width: 80,
render: (v: number) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<EyeOutlined style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }} />
{v ?? 0}
</span>
),
},
{
title: '发布时间',
dataIndex: 'published_at',
key: 'published_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: ArticleListItem) => renderActions(record),
},
];
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={() => navigate('/health/articles/new')}
>
</Button>
</AuthButton>
</div>
{/* 筛选栏 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: '12px 16px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
<Input
placeholder="搜索文章标题..."
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => debouncedSearch(e.target.value)}
allowClear
style={{ width: 220, borderRadius: 8 }}
/>
<Select
value={categoryId}
onChange={(v) => {
setCategoryId(v);
setPage(1);
}}
placeholder="选择分类"
allowClear
style={{ width: 160, borderRadius: 8 }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</div>
{/* 状态标签页 + 表格 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Tabs
activeKey={statusTab}
onChange={(key) => {
setStatusTab(key);
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
style={{ padding: '0 16px', marginBottom: 0 }}
/>
<Table
columns={columns}
dataSource={articles}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchArticles(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
{/* 拒绝理由弹窗 */}
<Modal
title="拒绝文章"
open={rejectModalOpen}
onCancel={() => setRejectModalOpen(false)}
onOk={() => rejectForm.submit()}
okText="确认拒绝"
okButtonProps={{ danger: true }}
width={480}
>
<Form
form={rejectForm}
onFinish={handleReject}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form.Item
name="review_note"
label="拒绝理由"
rules={[{ required: true, message: '请输入拒绝理由' }]}
>
<Input.TextArea rows={3} placeholder="请输入拒绝理由" />
</Form.Item>
</Form>
</Modal>
</div>
);
}