Files
hms/apps/web/src/pages/health/ArticleManageList.tsx
iven 1e7a5f5498
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
refactor(web): 列表页统一迁移 — PageContainer + usePaginatedData + 格式化规范
8 个列表页迁移至统一模式:
- PatientList / DoctorList / AppointmentList / FollowUpTaskList
- ConsultationList / AlertList / ArticleManageList
- PointsRuleList / PointsProductList / PointsOrderList

统一使用:
- PageContainer 组件(标题/筛选/操作/暗色模式)
- usePaginatedData hook(分页/筛选/搜索)
- EntityName 组件(UUID→姓名兜底)
- 共享 formatDateTime/formatDate/formatRelative
- 移除手动 isDark 暗色模式处理
2026-04-28 08:17:55 +08:00

452 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Table,
Button,
Space,
Input,
Select,
Tag,
Tabs,
Popconfirm,
message,
Modal,
Form,
} from 'antd';
import {
PlusOutlined,
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 { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { formatDateTime } from '../../utils/format';
// --- 常量 ---
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' },
};
// --- 筛选器 ---
interface ArticleFilters {
keyword: string;
status: string;
category_id: string;
}
const DEFAULT_FILTERS: ArticleFilters = {
keyword: '',
status: '',
category_id: '',
};
export default function ArticleManageList() {
const navigate = useNavigate();
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
const [rejectForm] = Form.useForm();
// ---- 分页数据 Hook ----
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<ArticleListItem, ArticleFilters>(
async (p, pageSize, f) => {
const result = await articleApi.list({
page: p,
page_size: pageSize,
status: (f.status || undefined) as ArticleStatus | undefined,
category_id: f.category_id || undefined,
keyword: f.keyword || undefined,
});
return result;
},
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
// ---- 分类列表 ----
useEffect(() => {
articleCategoryApi.list()
.then((cats) => setCategories(cats.map((c) => ({ id: c.id, name: c.name }))))
.catch(() => {});
}, []);
// ---- 操作 ----
const handleDelete = async (id: string) => {
try {
await articleApi.delete(id);
message.success('文章已删除');
refresh();
} catch {
message.error('删除失败');
}
};
const handleSubmit = async (record: ArticleListItem) => {
try {
await articleApi.submit(record.id, record.version);
message.success('已提交审核');
refresh();
} catch {
message.error('提交审核失败');
}
};
const handleApprove = async (record: ArticleListItem) => {
try {
await articleApi.approve(record.id, record.version);
message.success('审核通过,文章已发布');
refresh();
} 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);
refresh();
} catch {
message.error('拒绝操作失败');
}
};
const handleUnpublish = async (record: ArticleListItem) => {
try {
await articleApi.unpublish(record.id, record.version);
message.success('文章已撤回为草稿');
refresh();
} 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: 'var(--ant-color-text-secondary, #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: 'var(--ant-color-text-quaternary, #cbd5e1)' }}></span>,
},
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 180,
render: (tags?: ArticleTagItem[]) => {
if (!tags || tags.length === 0) return '-';
return (
<Space size={4} wrap>
{tags.map((t) => (
<Tag key={t.id}>{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 }} />
{v ?? 0}
</span>
),
},
{
title: '发布时间',
dataIndex: 'published_at',
key: 'published_at',
width: 170,
render: (v: string) => (v ? formatDateTime(v) : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: ArticleListItem) => renderActions(record),
},
];
return (
<PageContainer
title="内容管理"
subtitle="管理健康科普文章、资讯和内容发布"
filters={
<>
<Input
placeholder="搜索文章标题..."
value={filters.keyword}
onChange={(e) => {
setFilters((prev) => ({ ...prev, keyword: e.target.value }));
}}
allowClear
style={{ width: 220 }}
/>
<Select
value={filters.category_id || undefined}
onChange={(v) => {
setFilters((prev) => ({ ...prev, category_id: v ?? '' }));
refresh(1);
}}
placeholder="选择分类"
allowClear
style={{ width: 160 }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</>
}
onResetFilters={() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}}
actions={
<AuthButton code="health.articles.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/health/articles/new')}
>
</Button>
</AuthButton>
}
loading={loading}
>
<Tabs
activeKey={filters.status}
onChange={(key) => {
setFilters((prev) => ({ ...prev, status: key }));
refresh(1);
}}
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
style={{ marginBottom: 0 }}
/>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={(pagination) => refresh(pagination.current ?? 1)}
pagination={{
current: page,
pageSize: 20,
total,
showTotal: (t) => `${t} 条记录`,
}}
/>
{/* 拒绝理由弹窗 */}
<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>
</PageContainer>
);
}