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

View File

@@ -0,0 +1,493 @@
//! 方案 B业务流程导向菜单重组
//!
//! 在迁移 163 基础上进一步优化:
//! 1. "患者管理" → "患者中心",吸收日常监测/诊断/知情同意/咨询
//! 2. "诊疗服务" → "随访关怀",只保留随访相关
//! 3. 告警规则/危急值阈值 → 系统管理(配置项归入)
//! 4. 文章分类/文章标签软删除(降级为文章管理页内 Tab
//!
//! 注意menus 表有 trg_enforce_version 触发器,所有 UPDATE 必须带 version = version + 1
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
const DIR_PATIENT_MGMT: &str = "a0000000-0000-0000-0000-000000000005";
const DIR_CLINICAL: &str = "a0000000-0000-0000-0000-000000000006";
const DIR_MONITORING: &str = "a0000000-0000-0000-0000-000000000007";
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// ================================================================
// Part 1: 重命名目录
// ================================================================
// "患者管理" → "患者中心",图标改用 HeartOutlined代表以患者为中心
db.execute_unprepared(&format!(
"UPDATE menus SET title = '患者中心', icon = 'HeartOutlined', version = version + 1 \
WHERE id = '{DIR_PATIENT_MGMT}' AND menu_type = 'directory' AND deleted_at IS NULL"
))
.await?;
// "诊疗服务" → "随访关怀",图标改用 FormOutlined代表关怀表单
db.execute_unprepared(&format!(
"UPDATE menus SET title = '随访关怀', icon = 'FormOutlined', version = version + 1 \
WHERE id = '{DIR_CLINICAL}' AND menu_type = 'directory' AND deleted_at IS NULL"
))
.await?;
// ================================================================
// Part 2: 移动叶子菜单
// ================================================================
// 患者中心:患者(0) + 日常监测(1) + 诊断(2) + 知情同意(3) + 咨询(4) + 标签(5) + 医护(6)
for &(path, sort) in &[
("/health/patients", 0),
("/health/daily-monitoring", 1),
("/health/diagnoses", 2),
("/health/consents", 3),
("/health/consultations", 4),
("/health/tags", 5),
("/health/doctors", 6),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_PATIENT_MGMT}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 随访关怀:行动收件箱(0) + 随访任务(1) + 随访模板(2) + 排班(3,冻结) + 预约(4,冻结)
for &(path, sort) in &[
("/health/action-inbox", 0),
("/health/follow-up-tasks", 1),
("/health/follow-up-templates", 2),
("/health/schedules", 3),
("/health/appointments", 4),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_CLINICAL}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 健康监测:去掉告警规则和危急值阈值(移到系统管理),重新排序
for &(path, sort) in &[
("/health/realtime-monitor", 0),
("/health/alert-dashboard", 1),
("/health/alerts", 2),
("/health/devices", 3),
("/health/ble-gateways", 4),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_MONITORING}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 告警规则 + 危急值阈值 → 系统管理
let sys_dir = "(SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1)";
for &(path, sort) in &[
("/health/alert-rules", 8),
("/health/critical-value-thresholds", 9),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = {sys_dir}, sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// ================================================================
// Part 3: 软删除文章分类/文章标签菜单(降级为文章管理页内 Tab
// ================================================================
for path in &["/health/article-categories", "/health/article-tags"] {
db.execute_unprepared(&format!(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
))
.await?;
}
// ================================================================
// Part 4: 重建 menu_roles
// ================================================================
// 先软删除所有角色的旧关联
for code in &["doctor", "nurse", "health_manager", "operator", "admin"] {
db.execute_unprepared(&format!(
"UPDATE menu_roles SET deleted_at = NOW(), version = version + 1 \
WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}' AND deleted_at IS NULL) \
AND deleted_at IS NULL"
)).await?;
}
// 医生:工作台 + 患者中心(患者/日常监测/诊断/知情同意/咨询) + 随访关怀(行动收件箱/随访任务/随访模板) + 监测告警(仪表盘/列表) + AI(客服/分析/用量) + 消息
assign_menus_for_role(
db,
"doctor",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/daily-monitoring",
"/health/diagnoses",
"/health/consents",
"/health/consultations",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/action-inbox",
"/health/alert-dashboard",
"/health/alerts",
"/health/ai-analysis",
"/health/ai-usage",
"/ai/chat",
"/messages",
],
)
.await?;
// 护士:工作台 + 患者中心(患者/日常监测/诊断/知情同意/咨询) + 随访关怀(行动收件箱/随访任务) + 监测告警(仪表盘/列表) + 消息
assign_menus_for_role(
db,
"nurse",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/daily-monitoring",
"/health/diagnoses",
"/health/consents",
"/health/consultations",
"/health/follow-up-tasks",
"/health/action-inbox",
"/health/alert-dashboard",
"/health/alerts",
"/messages",
],
)
.await?;
// 健康管理师:几乎所有健康业务菜单
assign_menus_for_role(
db,
"health_manager",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/daily-monitoring",
"/health/diagnoses",
"/health/consents",
"/health/consultations",
"/health/tags",
"/health/doctors",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/action-inbox",
"/health/realtime-monitor",
"/health/alert-dashboard",
"/health/alerts",
"/health/alert-rules",
"/health/devices",
"/health/critical-value-thresholds",
"/health/ai-prompts",
"/health/ai-analysis",
"/health/ai-knowledge",
"/health/ai-usage",
"/health/ai-config",
"/ai/chat",
"/messages",
],
)
.await?;
// 运营:工作台 + 患者中心(患者/标签) + 监测告警(仪表盘/列表/设备) + 运营管理(全) + AI用量 + 消息
assign_menus_for_role(
db,
"operator",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/tags",
"/health/devices",
"/health/alert-dashboard",
"/health/alerts",
"/health/articles",
"/health/points-rules",
"/health/points-products",
"/health/points-orders",
"/health/offline-events",
"/health/media-library",
"/health/banners",
"/health/ai-usage",
"/messages",
],
)
.await?;
// 管理员:所有活跃菜单
assign_admin_all_menus(db).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 恢复目录名称
db.execute_unprepared(
"UPDATE menus SET title = '患者管理', icon = 'TeamOutlined', version = version + 1 \
WHERE id::text LIKE 'a0000000-0000-0000-0000-000000000005%' \
AND menu_type = 'directory' AND deleted_at IS NULL",
)
.await?;
db.execute_unprepared(
"UPDATE menus SET title = '诊疗服务', icon = 'MedicineBoxOutlined', version = version + 1 \
WHERE id::text LIKE 'a0000000-0000-0000-0000-000000000006%' \
AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
// 恢复患者管理原始 3 项
for &(path, sort) in &[
("/health/patients", 0),
("/health/tags", 1),
("/health/doctors", 2),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_PATIENT_MGMT}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 恢复诊疗服务原始 9 项
for &(path, sort) in &[
("/health/schedules", 0),
("/health/appointments", 1),
("/health/follow-up-tasks", 2),
("/health/follow-up-templates", 3),
("/health/consultations", 4),
("/health/action-inbox", 5),
("/health/diagnoses", 6),
("/health/consents", 7),
("/health/daily-monitoring", 8),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_CLINICAL}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 恢复健康监测原始 7 项
for &(path, sort) in &[
("/health/realtime-monitor", 0),
("/health/alert-dashboard", 1),
("/health/alerts", 2),
("/health/alert-rules", 3),
("/health/devices", 4),
("/health/ble-gateways", 5),
("/health/critical-value-thresholds", 6),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_MONITORING}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 恢复文章分类/标签菜单
for path in &["/health/article-categories", "/health/article-tags"] {
db.execute_unprepared(&format!(
"UPDATE menus SET deleted_at = NULL, version = version + 1 \
WHERE path = '{path}'"
))
.await?;
}
// 告警规则/阈值移回健康监测
for &(path, sort) in &[
("/health/alert-rules", 3),
("/health/critical-value-thresholds", 6),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_MONITORING}', sort_order = {sort}, version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// OAuth 移回系统管理
db.execute_unprepared(
"UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1), \
sort_order = 7, version = version + 1 WHERE path = '/health/oauth-clients' AND deleted_at IS NULL"
).await?;
// 重建 menu_roles用迁移 163 的角色分配)
for code in &["doctor", "nurse", "health_manager", "operator", "admin"] {
db.execute_unprepared(&format!(
"UPDATE menu_roles SET deleted_at = NOW(), version = version + 1 \
WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}' AND deleted_at IS NULL) \
AND deleted_at IS NULL"
)).await?;
}
assign_menus_for_role(
db,
"doctor",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/doctors",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/alert-dashboard",
"/health/alerts",
"/health/ai-analysis",
"/health/ai-usage",
"/ai/chat",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"nurse",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/follow-up-tasks",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/alert-dashboard",
"/health/alerts",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"health_manager",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/doctors",
"/health/tags",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/realtime-monitor",
"/health/alert-dashboard",
"/health/alerts",
"/health/alert-rules",
"/health/devices",
"/health/critical-value-thresholds",
"/health/ai-prompts",
"/health/ai-analysis",
"/health/ai-knowledge",
"/health/ai-usage",
"/health/ai-config",
"/ai/chat",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"operator",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/tags",
"/health/devices",
"/health/alert-dashboard",
"/health/alerts",
"/health/articles",
"/health/article-categories",
"/health/article-tags",
"/health/points-rules",
"/health/points-products",
"/health/points-orders",
"/health/offline-events",
"/health/media-library",
"/health/banners",
"/health/ai-usage",
"/messages",
],
)
.await?;
assign_admin_all_menus(db).await?;
Ok(())
}
}
async fn assign_menus_for_role(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
role_code: &str,
menu_paths: &[&str],
) -> Result<(), DbErr> {
let paths_csv: String = menu_paths
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(",");
// 绑定叶子菜单
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.path IN ({paths_csv}) AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
// 绑定目录directory 类型)
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.menu_type = 'directory' AND m.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
Ok(())
}
async fn assign_admin_all_menus(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
) -> Result<(), DbErr> {
db.execute_unprepared(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = 'admin' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
).await?;
Ok(())
}

View File

@@ -0,0 +1,204 @@
# 左侧菜单优化 — 多专家组头脑风暴
> 日期: 2026-05-21 | 参与者: UX 架构师、医疗业务专家、产品策略师、前端工程师
## 背景
当前 HMS 左侧菜单共 7 个顶级目录、~40 个活跃菜单项(含 7 个冻结项)。随着功能模块不断增加,菜单项数量已经让用户感到"找起来麻烦"。需要从业务流程顺序、角色定义等维度对分类和排序进行优化。
## 一、现状问题诊断(四专家组共识)
### 1.1 诊疗服务目录过于臃肿9 项,最大目录)
9 项菜单跨越 4 个不同业务阶段:排班预约(行政调度)、随访咨询(主动关怀)、诊断知情(临床决策)、日常监测(数据采集)。分组逻辑不清晰。
### 1.2 业务流程连贯性断裂
完成一个完整的"建档→评估→随访→告警处理"闭环需要在 3 个目录间来回切换 5 次。医生上午查房的典型路径告警→患者→诊断→咨询→AI分析需要穿越 4 个目录。
### 1.3 日常监测归属错误
日常监测的操作对象是"患者"(护士找患者→录入体征),却被放在"诊疗服务";而告警在"健康监测",导致"监测→告警→处理"这条核心闭环被割裂。
### 1.4 配置项与操作项混杂
随访模板(低频配置)、告警规则(低频配置)、危急值阈值(低频配置)和高频日常操作混在同一目录,增加视觉噪音。
### 1.5 运营管理包含三类不同业务
内容管理(资讯+分类+标签)、积分商城(规则+商品+订单+活动)、媒体内容(媒体库+轮播图)共用一个目录,逻辑不够清晰。
### 1.6 文章分类/文章标签可降级
这两个菜单项是文章管理的辅助配置,不需要在侧边栏独立占位。
## 二、专家方案综合
### 方案 A最小改动 — 归属调整 + 二级分组0.5 天)
保持 7 个顶级目录,仅调整归属和排序:
```
工作台 (sort=1)
├─ 仪表盘
└─ 统计报表
患者管理 (sort=2) ← 不变
├─ 患者管理
├─ 标签管理
└─ 医护管理
诊疗服务 (sort=3) ← 瘦身至 7 项
├─ 排班管理(冻结)
├─ 预约排班(冻结)
├─ 随访管理
├─ 随访模板管理
├─ 咨询管理
├─ 行动收件箱
└─ 诊断记录
健康监测 (sort=4) ← 增至 8 项
├─ 实时监控
├─ 告警仪表盘
├─ 告警列表
├─ 告警规则
├─ 日常监测 ← 从"诊疗服务"移入(监测行为归监测目录)
├─ 设备管理
├─ BLE 网关
└─ 危急值阈值
运营管理 (sort=5) ← 瘦身至 7 项
├─ 资讯管理 ← 内含分类/标签 Tab
├─ 积分规则
├─ 商品管理
├─ 订单管理
├─ 线下活动
├─ 媒体库
└─ 轮播图管理
AI 助手 (sort=6) ← 不变
系统管理 (sort=7) ← 不变
```
**改动**:日常监测移到健康监测;文章分类/文章标签降级为文章管理页内 Tab。
**效果**目录内项数更均衡2/3/7/8/7/6/8减少 2 个菜单项。
---
### 方案 B中度重组 — 业务流程导向1-2 天)⭐ 推荐
以医护人员的典型工作流顺序重新组织,同时合并配置项到系统管理:
```
工作台 (sort=1) ← 增加"快捷入口"卡片区域
├─ 仪表盘 ← 内嵌:告警摘要 + 今日待办 + 未回复咨询 + 我的随访
└─ 统计报表
患者中心 (sort=2) ← 原"患者管理"改名 + 扩充
├─ 患者管理
├─ 日常监测 ← 从"诊疗服务"移入(体征是患者数据的一部分)
├─ 诊断记录 ← 从"诊疗服务"移入(看诊即写)
├─ 知情同意 ← 从"诊疗服务"移入(建档即签)
├─ 咨询管理 ← 从"诊疗服务"移入(围绕患者沟通)
├─ 标签管理
└─ 医护管理
随访关怀 (sort=3) ← 原"诊疗服务"瘦身,聚焦主动健康管理
├─ 行动收件箱 ← 提到首位(待办驱动)
├─ 随访任务
├─ 随访模板 ← 保留在此(虽然低频,但和随访任务强关联)
├─ 排班管理(冻结)
└─ 预约排班(冻结)
监测告警 (sort=4) ← 原"健康监测"微调
├─ 实时监控
├─ 告警仪表盘
├─ 告警列表
├─ 告警规则 ← 移至系统管理(低频配置)
├─ 设备管理
├─ BLE 网关
└─ 危急值阈值 ← 移至系统管理(低频配置)
运营管理 (sort=5) ← 瘦身
├─ 资讯管理 ← 内含分类/标签 Tab
├─ 积分规则
├─ 商品管理
├─ 订单管理
├─ 线下活动
├─ 媒体库
└─ 轮播图管理
AI 助手 (sort=6) ← 不变
├─ AI 客服
├─ AI Prompt 管理
├─ AI 分析历史
├─ AI 知识库
├─ AI 用量统计
└─ AI 配置
系统管理 (sort=7) ← 吸收配置项
├─ 用户管理
├─ 权限管理
├─ 组织架构
├─ 告警规则 ← 从"监测告警"移入
├─ 危急值阈值 ← 从"监测告警"移入
├─ 工作流
├─ 消息中心
├─ 系统设置
├─ 插件管理
└─ OAuth 合作方
```
**核心改动 4 处**
1. "患者管理" → "患者中心",吸收日常监测/诊断/知情同意/咨询4 项从诊疗服务移出)
2. "诊疗服务" → "随访关怀",只保留随访相关(+ 冻结的排班预约)
3. 告警规则和危急值阈值移到系统管理(配置项和操作项分离)
4. 工作台仪表盘增加快捷入口卡片
**角色效率对比**
| 角色 | 改动前目录切换次数 | 改动后目录切换次数 | 改善 |
|------|-------------------|-------------------|------|
| 医生(上午查房) | 4-5 次 | 2-3 次 | -40% |
| 护士(日常操作) | 3-4 次 | 2 次 | -40% |
| 健康管理师 | 5-6 次 | 4 次 | -25% |
| 运营人员 | 1-2 次 | 1-2 次 | 不变 |
---
### 方案 C激进重组 — 患者中心化3-5 天)
以患者旅程为核心重新组织导航,模仿电子病历系统的"以患者为中心"导航模式。患者详情页升级为多 Tab 超级枢纽,内嵌体征/随访/咨询/诊断/同意 Tab。
**优势**:医生导航效率提升 60%+。
**劣势**:需要重构患者详情页架构,工作量大,且改变了现有的交互范式。
---
## 三、技术可行性结论(前端专家)
| 改动类型 | 是否需要改 | 工作量 |
|----------|-----------|--------|
| 后端迁移(目录重组) | 是 | 5-8 小时 |
| MainLayout.tsx | **否** | 0完全数据驱动 |
| routeConfig.ts | **否** | 0 |
| App.tsx 路由 | **否** | 0 |
| iconRegistry.tsx | 可能新增图标 | 10 分钟 |
| 文章分类/标签降级为 Tab | 是 | 2-3 小时 |
| 工作台快捷入口卡片 | 推荐 | 2-3 小时 |
**核心优势**`DynamicMenuSection` 是完全数据驱动的递归渲染组件,菜单重组的核心工作量 100% 集中在后端迁移文件,前端几乎零改动。
## 四、附加建议(专家组补充)
1. **侧边栏搜索**40 个菜单项已达到需要搜索辅助的临界点,建议在侧边栏顶部增加搜索框
2. **消息中心移到 Header**:将消息中心从系统管理移到顶部右侧通知按钮,与通知面板合并
3. **冻结菜单视觉弱化**:使用 `opacity: 0.4` + "即将开放"标注,或完全隐藏
4. **面包屑导航**:患者详情页等深层页面缺少面包屑,用户依赖浏览器后退
## 五、结论 / 待定
- **推荐方案 B业务流程导向**,兼顾改动成本和效果提升
- **可选附加**:工作台快捷入口(+0.5 天)、侧边栏搜索(+0.5 天)
- **待讨论**:告警规则/危急值阈值是否移到系统管理还是保留在监测告警目录医疗专家建议移出UX 专家建议保留以保持告警闭环完整)
- **待讨论**:方案 C 的患者中心化架构是否作为 V2 目标?

File diff suppressed because it is too large Load Diff