后端: - 文章审核状态机: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 组件导入
499 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|