feat(web): 三级可折叠侧边栏菜单 — 健康管理 18 项归入 6 个子分组
- 新增 CollapsibleSubGroup 组件,支持子分组的展开/折叠渲染 - DynamicMenuSection 改为递归检测子目录,支持多级嵌套 - 当前路径所在子分组自动展开 - 折叠侧边栏时子分组显示为图标 + Tooltip - 兼容 menu_type='page' 类型 - 数据库插入 6 个子分组(患者医护/预约排班/随访咨询/积分运营/内容运营/AI 分析)
This commit is contained in:
@@ -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,13 +283,25 @@ 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) => (
|
||||
{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={{
|
||||
@@ -229,12 +313,15 @@ const DynamicMenuSection = memo(function DynamicMenuSection({
|
||||
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}
|
||||
|
||||
175
plans/skill-smooth-pebble.md
Normal file
175
plans/skill-smooth-pebble.md
Normal 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. 搜索导航 | 搜索框替代层级导航 | 适合超多菜单 | 学习成本高,不适合当前规模 |
|
||||
|
||||
**推荐方案 B:3 级可折叠目录。**
|
||||
|
||||
核心理由:
|
||||
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 反对意见与回应
|
||||
|
||||
**反对:增加一级嵌套增加点击次数。**
|
||||
回应:折叠状态下子分组只占一行(~40px),18 项从 ~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` 编译无错误
|
||||
- 浏览器实际操作:展开/折叠/导航/折叠侧边栏
|
||||
- 确认所有原有路由可正常访问
|
||||
- 确认插件菜单不受影响
|
||||
Reference in New Issue
Block a user