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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 目标?
|
||||
1183
docs/discussions/menu-prototype.html
Normal file
1183
docs/discussions/menu-prototype.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user