feat(web): 三级可折叠侧边栏菜单 — 健康管理 18 项归入 6 个子分组
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增 CollapsibleSubGroup 组件,支持子分组的展开/折叠渲染
- DynamicMenuSection 改为递归检测子目录,支持多级嵌套
- 当前路径所在子分组自动展开
- 折叠侧边栏时子分组显示为图标 + Tooltip
- 兼容 menu_type='page' 类型
- 数据库插入 6 个子分组(患者医护/预约排班/随访咨询/积分运营/内容运营/AI 分析)
This commit is contained in:
iven
2026-04-26 13:37:57 +08:00
parent 7ab57ea1b2
commit 9f546a519b
2 changed files with 279 additions and 17 deletions

View File

@@ -132,6 +132,73 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
);
});
// 可折叠子分组3 级菜单)
const CollapsibleSubGroup = memo(function CollapsibleSubGroup({
directory,
collapsed,
currentPath,
onNavigate,
}: {
directory: MenuInfo;
collapsed: boolean;
currentPath: string;
onNavigate: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const visibleChildren = directory.children?.filter((c) => c.visible !== false) || [];
const hasActive = visibleChildren.some((c) => currentPath === (c.path || c.id));
useEffect(() => {
if (hasActive) setExpanded(true);
}, [hasActive]);
if (collapsed) {
return (
<Tooltip title={directory.title} placement="right">
<div
onClick={() => {
const first = visibleChildren[0];
if (first) onNavigate(first.path || first.id);
}}
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
>
<span className="erp-sidebar-item-icon">{getIcon(directory.icon)}</span>
</div>
</Tooltip>
);
}
return (
<div>
<div
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
onClick={() => setExpanded((e) => !e)}
>
<span className="erp-sidebar-submenu-arrow">
<RightOutlined
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
</span>
<span className="erp-sidebar-submenu-label">{directory.title}</span>
</div>
{expanded && visibleChildren.map((child) => (
<SidebarMenuItem
key={child.id}
item={{
key: child.path || child.id,
icon: getIcon(child.icon),
label: child.title,
}}
isActive={currentPath === (child.path || child.id)}
collapsed={collapsed}
onClick={() => onNavigate(child.path || child.id)}
indented
/>
))}
</div>
);
});
// 插件子菜单组
const SidebarSubMenu = memo(function SidebarSubMenu({
group,
@@ -195,7 +262,12 @@ const SidebarSubMenu = memo(function SidebarSubMenu({
);
});
// 动态菜单渲染
// 判断是否为可点击的叶子菜单类型
function isLeafType(menuType: string): boolean {
return menuType === 'menu' || menuType === 'page';
}
// 动态菜单渲染(支持多级嵌套)
const DynamicMenuSection = memo(function DynamicMenuSection({
menus,
collapsed,
@@ -211,30 +283,45 @@ const DynamicMenuSection = memo(function DynamicMenuSection({
<>
{menus.map((menu) => {
if (menu.menu_type === 'directory') {
const visibleChildren = menu.children?.filter((c) => c.visible !== false) || [];
return (
<div key={menu.id}>
{!collapsed && <div className="erp-sidebar-group">{menu.title}</div>}
<div className="erp-sidebar-menu">
{menu.children
?.filter((child) => child.visible !== false)
.map((child) => (
<SidebarMenuItem
key={child.id}
item={{
key: child.path || child.id,
icon: getIcon(child.icon),
label: child.title,
}}
isActive={currentPath === (child.path || child.id)}
collapsed={collapsed}
onClick={() => onNavigate(child.path || child.id)}
/>
))}
{visibleChildren.map((child) => {
if (child.menu_type === 'directory') {
return (
<CollapsibleSubGroup
key={child.id}
directory={child}
collapsed={collapsed}
currentPath={currentPath}
onNavigate={onNavigate}
/>
);
}
if (isLeafType(child.menu_type)) {
return (
<SidebarMenuItem
key={child.id}
item={{
key: child.path || child.id,
icon: getIcon(child.icon),
label: child.title,
}}
isActive={currentPath === (child.path || child.id)}
collapsed={collapsed}
onClick={() => onNavigate(child.path || child.id)}
/>
);
}
return null;
})}
</div>
</div>
);
}
if (menu.menu_type === 'menu' && menu.visible !== false) {
if (isLeafType(menu.menu_type) && menu.visible !== false) {
return (
<SidebarMenuItem
key={menu.id}

View File

@@ -0,0 +1,175 @@
# 三级可折叠侧边栏菜单 — 论证与实施计划
## Context
HMS 健康管理平台的侧边栏菜单在"健康管理"分组下已有 18 个平铺菜单项。随着功能继续增长AI 模块、专科模块等),这个问题会持续恶化。用户提出将菜单改为可折叠的 3 级目录结构。
## 1. 论证分析
### 1.1 当前问题
| 指标 | 现状 | 趋势 |
|------|------|------|
| 健康管理下菜单项 | 18 个 | 还会增加AI、血透、OCR |
| 侧边栏可视区域 | ~600px 高度 | 固定 |
| 单项高度 | ~40px | 固定 |
| 满屏可显示项数 | ~15 个 | — |
| 溢出 | 是,需滚动 | 严重恶化 |
**结论18 个平铺项已超出可视区域,必须滚动才能看到底部菜单。** 这违反了信息架构的基本原则——用户应在首屏看到完整的导航结构。
### 1.2 方案对比
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| A. 维持现状 | 平铺 2 级菜单 | 简单、无改动 | 菜单项越多越难用,不可接受 |
| B. 3 级可折叠 | 在 directory 下再嵌套 directory | 归类清晰、按需展开、**数据库已支持** | 前端需改造渲染逻辑 |
| C. Tab 切换 | 顶部 Tab 分域(患者/预约/管理) | 分区明确 | 破坏侧边栏导航范式,改动大 |
| D. 搜索导航 | 搜索框替代层级导航 | 适合超多菜单 | 学习成本高,不适合当前规模 |
**推荐方案 B3 级可折叠目录。**
核心理由:
1. **数据库零改动**`menus` 表的 `parent_id` 自引用和 `build_tree()` 递归构建已支持 N 级嵌套
2. **前端改动小**`DynamicMenuSection` 只需加递归渲染,`SidebarSubMenu` 的展开/折叠逻辑可直接复用
3. **向后兼容** — 没有子分组的 directory 仍按原有方式渲染2 级),有子分组的自动变为 3 级
4. **可扩展** — 未来第 4 级也自然支持
### 1.3 提出的分组结构
将"健康管理"下的 18 个菜单项按业务域归入 5 个子分组:
```
健康管理
├── 患者管理 (icon: TeamOutlined)
│ ├── 患者列表
│ ├── 医护档案
│ └── 健康档案
├── 预约排班 (icon: CalendarOutlined)
│ ├── 预约管理
│ └── 排班管理
├── 随访咨询 (icon: PhoneOutlined)
│ ├── 随访管理
│ └── 咨询管理
├── 健康数据 (icon: HeartOutlined)
│ ├── 体征监测
│ ├── 化验报告
│ ├── 健康趋势
│ ├── 诊断记录
│ ├── 过敏管理
│ └── 血透记录
├── 内容运营 (icon: FileTextOutlined)
│ ├── 文章管理
│ ├── 分类管理
│ └── 标签管理
└── 综合管理 (icon: TrophyOutlined)
├── 积分管理
├── 线下活动
└── 统计分析
```
其他顶级分组仪表盘、系统管理、工作流、消息中心、AI 智能分析)保持不变。
### 1.4 反对意见与回应
**反对:增加一级嵌套增加点击次数。**
回应:折叠状态下子分组只占一行(~40px18 项从 ~720px 压缩到 ~200px。用户只需展开自己关注的子域实际点击次数不会增加反而因为分类清晰减少了寻找时间。
**反对3 级菜单对用户认知负担更重。**
回应:当前 18 个平铺项的认知负担远大于 5 个分类名称。分组名称("患者管理"、"预约排班")本身是业务语义,不需要额外学习。
## 2. 技术分析
### 2.1 后端:零改动
- `menus``parent_id` 自引用已支持任意层级
- `build_tree()` 递归构建已生成完整嵌套 JSON
- `MenuInfo.children` 前端类型已是递归结构
- **不需要修改任何 Rust 代码**
### 2.2 数据层:插入子分组记录
`menus` 表中为"健康管理"目录插入 6 个子 directory然后将现有菜单项的 `parent_id` 指向对应子 directory。
```
INSERT 新记录: 6 个 sub-directory (parent_id = 健康管理目录的 id)
UPDATE 现有记录: 18 个菜单项的 parent_id → 对应子 directory 的 id
```
可通过 SQL 迁移或直接 UPDATE 实现。
### 2.3 前端改造DynamicMenuSection 递归化
**当前代码**[MainLayout.tsx:199-256](apps/web/src/layouts/MainLayout.tsx#L199-L256)
- 遍历 `menus`directory → 渲染标题 + 遍历 children
- children 只渲染为 `SidebarMenuItem`(叶子节点),不再检查嵌套
**改造目标**
- directory 有 children → 检查 children 中是否有 sub-directory
- 如果有 sub-directory → 用 `SidebarSubMenu` 的展开/折叠模式渲染
- 递归调用自身,支持任意深度
**复用的模式**`SidebarSubMenu`[MainLayout.tsx:136-196](apps/web/src/layouts/MainLayout.tsx#L136-L196))的展开/折叠逻辑:
- `useState(true)` 管理展开状态
- `RightOutlined` 箭头旋转动画
- 折叠状态下 Tooltip 显示子菜单列表
- `hasActive` 检测高亮
### 2.4 关键文件
| 文件 | 改动 |
|------|------|
| `apps/web/src/layouts/MainLayout.tsx` | `DynamicMenuSection` 改为递归渲染 |
| 数据库SQL 迁移或直接 UPDATE | 插入 6 个子 directory + 更新 18 项 parent_id |
## 3. 实施步骤
### Step 1: 数据层 — 菜单重组
直接用 SQL 更新现有菜单数据(不需要新建迁移文件,因为这是数据变更不是 schema 变更):
1. 查出"健康管理"目录的 `id`
2. INSERT 6 个 sub-directory 记录(`menu_type = 'directory'``parent_id` 指向健康管理)
3. UPDATE 18 个菜单项的 `parent_id` 指向对应 sub-directory
### Step 2: 前端 — DynamicMenuSection 递归化
`DynamicMenuSection` 改为支持递归渲染:
1. 提取一个通用 `MenuNode` 组件,接收 `MenuInfo` 递归渲染
2. `menu_type === 'directory'` → 渲染分组标题 + 递归渲染 children
3. children 中如果是 directory → 用展开/折叠样式(复用 SidebarSubMenu 的箭头模式)
4. children 中如果是 menu → 渲染 `SidebarMenuItem`
5. 折叠侧边栏时sub-directory 显示 Tooltip首项可点击
### Step 3: 样式微调
- sub-directory 标题增加左侧缩进(`padding-left: 24px`
- 展开动画过渡
- 活跃路径自动展开父级目录
### Step 4: 验证
1. `pnpm build` 前端编译通过
2. 浏览器验证:侧边栏显示 3 级结构
3. 子分组可展开/折叠
4. 折叠侧边栏时 Tooltip 正确显示
5. 当前页面所在分组自动展开
6. 其他顶级分组(系统管理、工作流等)不受影响
## 4. 工作量估计
| 步骤 | 预计时间 |
|------|---------|
| Step 1 数据重组 | 30 分钟 |
| Step 2 前端递归化 | 1-2 小时 |
| Step 3 样式微调 | 30 分钟 |
| Step 4 验证 | 30 分钟 |
| **总计** | **3-4 小时** |
## 5. 验证方式
- `pnpm build` 编译无错误
- 浏览器实际操作:展开/折叠/导航/折叠侧边栏
- 确认所有原有路由可正常访问
- 确认插件菜单不受影响