feat(health): 菜单方案B重组 — 患者中心+随访关怀+配置归入系统管理+文章标签合并
方案B业务流程导向菜单优化: - "患者管理" → "患者中心",吸收日常监测/诊断/知情同意/咨询 - "诊疗服务" → "随访关怀",只保留随访相关 - 告警规则/危急值阈值 → 系统管理 - 文章分类/标签菜单软删除,合并为文章管理页内 Tab 变更文件: - 迁移 164: 重命名目录+移动叶子菜单+重建 menu_roles - ArticleManageList.tsx: 分类/标签管理合并为页内 Tab - 讨论记录 + 可视化原型 HTML Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Tabs,
|
||||
@@ -26,13 +27,20 @@ import {
|
||||
import {
|
||||
articleApi,
|
||||
articleCategoryApi,
|
||||
articleTagApi,
|
||||
type ArticleListItem,
|
||||
type ArticleStatus,
|
||||
type ArticleTagItem,
|
||||
type ArticleCategory,
|
||||
type CreateCategoryReq,
|
||||
type UpdateCategoryReq,
|
||||
type CreateTagReq,
|
||||
} from '../../api/health/articles';
|
||||
import { handleApiError } from '../../api/client';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
// --- 常量 ---
|
||||
@@ -66,12 +74,21 @@ const DEFAULT_FILTERS: ArticleFilters = {
|
||||
category_id: '',
|
||||
};
|
||||
|
||||
const PAGE_TABS = [
|
||||
{ key: 'articles', label: '文章' },
|
||||
{ key: 'categories', label: '分类管理' },
|
||||
{ key: 'tags', label: '标签管理' },
|
||||
];
|
||||
|
||||
export default function ArticleManageList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activePageTab = searchParams.get('tab') || 'articles';
|
||||
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();
|
||||
|
||||
// ---- 分页数据 Hook ----
|
||||
const {
|
||||
@@ -96,15 +113,12 @@ export default function ArticleManageList() {
|
||||
{ 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, version: number) => {
|
||||
try {
|
||||
await articleApi.delete(id, version);
|
||||
@@ -240,8 +254,6 @@ export default function ArticleManageList() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
// ---- 列定义 ----
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '标题',
|
||||
@@ -353,74 +365,211 @@ export default function ArticleManageList() {
|
||||
},
|
||||
], [navigate, renderActions]);
|
||||
|
||||
// ---- 分类管理内联 ----
|
||||
const [catList, setCatList] = useState<ArticleCategory[]>([]);
|
||||
const [catLoading, setCatLoading] = useState(false);
|
||||
const [catModalOpen, setCatModalOpen] = useState(false);
|
||||
const [catEditing, setCatEditing] = useState<ArticleCategory | null>(null);
|
||||
const [catForm] = Form.useForm();
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setCatLoading(true);
|
||||
try {
|
||||
const result = await articleCategoryApi.list();
|
||||
setCatList(result);
|
||||
} catch {
|
||||
message.error('加载分类列表失败');
|
||||
} finally {
|
||||
setCatLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePageTab === 'categories') fetchCategories();
|
||||
}, [activePageTab, fetchCategories]);
|
||||
|
||||
const catParentOptions = catList
|
||||
.filter((c) => !catEditing || c.id !== catEditing.id)
|
||||
.map((c) => ({ label: c.name, value: c.id }));
|
||||
|
||||
const catColumns = useMemo(() => [
|
||||
{ title: '分类名称', dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
|
||||
{ title: '别名', dataIndex: 'slug', key: 'slug', width: 180, render: (v?: string) => v || '-' },
|
||||
{ title: '父分类', dataIndex: 'parent_name', key: 'parent_name', width: 140,
|
||||
render: (_v: string | undefined, record: ArticleCategory) => {
|
||||
if (!record.parent_id) return '-';
|
||||
return catList.find((c) => c.id === record.parent_id)?.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 || '-' },
|
||||
{ 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={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} />
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => {
|
||||
try { await articleCategoryApi.delete(record.id); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); }
|
||||
}}>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
], [catList, catForm, fetchCategories]);
|
||||
|
||||
// ---- 标签管理内联 ----
|
||||
const [tagList, setTagList] = useState<ArticleTagItem[]>([]);
|
||||
const [tagLoading, setTagLoading] = useState(false);
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||
const [tagEditing, setTagEditing] = useState<ArticleTagItem | null>(null);
|
||||
const [tagForm] = Form.useForm();
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
setTagLoading(true);
|
||||
try {
|
||||
const result = await articleTagApi.list();
|
||||
setTagList(result);
|
||||
} catch {
|
||||
message.error('加载标签列表失败');
|
||||
} finally {
|
||||
setTagLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePageTab === 'tags') fetchTags();
|
||||
}, [activePageTab, fetchTags]);
|
||||
|
||||
const tagColumns = useMemo(() => [
|
||||
{ title: '标签名称', dataIndex: 'name', key: 'name',
|
||||
render: (name: string, record: ArticleTagItem) => (
|
||||
<Tag color={record.color || undefined} style={record.color ? {} : {
|
||||
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||
}}>{name}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '别名', dataIndex: 'slug', key: 'slug', width: 180, render: (v?: string) => v || '-' },
|
||||
{ title: '颜色', dataIndex: 'color', key: 'color', width: 100,
|
||||
render: (v?: string) => v ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, background: v, border: '1px solid rgba(0,0,0,0.1)' }} />
|
||||
<span style={{ fontSize: 12, fontFamily: 'monospace' }}>{v}</span>
|
||||
</div>
|
||||
) : '默认',
|
||||
},
|
||||
{ title: '操作', key: 'actions', width: 80,
|
||||
render: (_: unknown, record: ArticleTagItem) => (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />}
|
||||
onClick={() => { setTagEditing(record); tagForm.setFieldsValue({ name: record.name }); setTagModalOpen(true); }} />
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => {
|
||||
try { await articleTagApi.delete(record.id, record.version ?? 0); message.success('已删除'); fetchTags(); } catch { message.error('删除失败'); }
|
||||
}}>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
], [isDark, tagForm, fetchTags]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="内容管理"
|
||||
subtitle="管理健康科普文章、资讯和内容发布"
|
||||
filters={
|
||||
filters={activePageTab === 'articles' ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="搜索文章标题..."
|
||||
value={filters.keyword}
|
||||
onChange={(e) => {
|
||||
setFilters((prev) => ({ ...prev, keyword: e.target.value }));
|
||||
}}
|
||||
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);
|
||||
}}
|
||||
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={
|
||||
) : undefined}
|
||||
onResetFilters={() => { setFilters({ ...DEFAULT_FILTERS }); refresh(1); }}
|
||||
actions={activePageTab === 'articles' ? (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/health/articles/new')}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/health/articles/new')}>
|
||||
新建文章
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
) : activePageTab === 'categories' ? (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setCatEditing(null); catForm.resetFields(); catForm.setFieldsValue({ sort_order: 0 }); setCatModalOpen(true); }}>
|
||||
新建分类
|
||||
</Button>
|
||||
</AuthButton>
|
||||
) : (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setTagEditing(null); tagForm.resetFields(); setTagModalOpen(true); }}>
|
||||
新建标签
|
||||
</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} 条记录`,
|
||||
}}
|
||||
activeKey={activePageTab}
|
||||
onChange={(key) => setSearchParams({ tab: key }, { replace: true })}
|
||||
items={PAGE_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{activePageTab === 'articles' && (
|
||||
<>
|
||||
<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} 条记录` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activePageTab === 'categories' && (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={catColumns}
|
||||
dataSource={catList}
|
||||
loading={catLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activePageTab === 'tags' && (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tagColumns}
|
||||
dataSource={tagList}
|
||||
loading={tagLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 拒绝理由弹窗 */}
|
||||
<Modal
|
||||
title="拒绝文章"
|
||||
@@ -431,21 +580,75 @@ export default function ArticleManageList() {
|
||||
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: '请输入拒绝理由' }]}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 分类管理弹窗 */}
|
||||
<Modal
|
||||
title={catEditing ? '编辑分类' : '新建分类'}
|
||||
open={catModalOpen}
|
||||
onCancel={() => { setCatModalOpen(false); setCatEditing(null); catForm.resetFields(); }}
|
||||
onOk={() => catForm.submit()}
|
||||
width={520}
|
||||
>
|
||||
<Form form={catForm} onFinish={async (values: { name: string; slug?: string; parent_id?: string; sort_order?: number; description?: string }) => {
|
||||
try {
|
||||
if (catEditing) {
|
||||
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(catEditing.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('分类创建成功');
|
||||
}
|
||||
setCatModalOpen(false); setCatEditing(null); catForm.resetFields(); fetchCategories();
|
||||
} catch (err: unknown) { handleApiError(err, '操作失败'); }
|
||||
}} 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="别名"><Input placeholder="留空则自动生成" /></Form.Item>
|
||||
<Form.Item name="parent_id" label="父分类"><Select placeholder="可选" allowClear options={catParentOptions} /></Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><Input.TextArea rows={2} placeholder="请输入分类描述" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 标签管理弹窗 */}
|
||||
<Modal
|
||||
title={tagEditing ? '编辑标签' : '新建标签'}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => { setTagModalOpen(false); setTagEditing(null); tagForm.resetFields(); }}
|
||||
onOk={() => tagForm.submit()}
|
||||
width={440}
|
||||
>
|
||||
<Form form={tagForm} onFinish={async (values: { name: string; slug?: string; color?: string }) => {
|
||||
try {
|
||||
if (tagEditing) {
|
||||
await articleTagApi.update(tagEditing.id, { name: values.name, version: tagEditing.version ?? 0 });
|
||||
message.success('标签更新成功');
|
||||
} else {
|
||||
const req: CreateTagReq = { name: values.name, slug: values.slug, color: values.color };
|
||||
await articleTagApi.create(req);
|
||||
message.success('标签创建成功');
|
||||
}
|
||||
setTagModalOpen(false); setTagEditing(null); tagForm.resetFields(); fetchTags();
|
||||
} catch (err: unknown) { handleApiError(err, '操作失败'); }
|
||||
}} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
|
||||
<Input placeholder="请输入标签名称" maxLength={30} />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名"><Input placeholder="留空则自动生成" /></Form.Item>
|
||||
<Form.Item name="color" label="颜色">
|
||||
<Input type="color" style={{ width: 60, height: 36, padding: 2, cursor: 'pointer' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user