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:
iven
2026-05-21 08:13:23 +08:00
parent 2644926fb6
commit b8c84ed9af
4 changed files with 2141 additions and 58 deletions

View File

@@ -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>
);
}