8 个列表页迁移至统一模式: - PatientList / DoctorList / AppointmentList / FollowUpTaskList - ConsultationList / AlertList / ArticleManageList - PointsRuleList / PointsProductList / PointsOrderList 统一使用: - PageContainer 组件(标题/筛选/操作/暗色模式) - usePaginatedData hook(分页/筛选/搜索) - EntityName 组件(UUID→姓名兜底) - 共享 formatDateTime/formatDate/formatRelative - 移除手动 isDark 暗色模式处理
452 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|