# NavbarFloat.tsx 文档 ## 文件职责 `NavbarFloat` 是浮动导航菜单组件,负责: 1. **浮动按钮**:提供一个可拖拽的圆形浮动按钮 2. **抽屉菜单**:点击按钮后展开侧边抽屉,显示导航菜单 3. **拖拽功能**:支持用户拖动按钮到屏幕任意位置 4. **响应式定位**:窗口大小变化时自动调整按钮位置 5. **用户信息显示**:在抽屉底部显示登录状态和用户信息 ## 实现方式 ### 1. 技术栈 - **React**:组件化开发 - **Redux**:全局状态管理(用户信息、业务流程) - **Ant Design**:Menu、Button、Drawer、Space、Row 组件 - **原生 DOM 事件**:实现拖拽功能 ### 2. 组件结构 ``` NavbarFloat ├── 浮动按钮(可拖拽) │ └── MenuUnfoldOutlined 图标 └── Drawer 抽屉 ├── Menu 导航菜单 │ ├── 患者管理 │ ├── 检查 │ ├── 急诊 │ ├── 处理 │ └── 打印 └── 底部区域 ├── 配置按钮 └── MeButton(用户信息) ``` ### 3. 状态管理 | 状态 | 类型 | 用途 | | ------------------ | ------------ | ---------------------------------------- | | `open` | boolean | 控制抽屉的打开/关闭 | | `btnPos` | {x, y} | 浮动按钮的位置坐标 | | `dragged` | boolean | 标记是否刚完成拖拽(防止拖拽后触发点击) | | `dragging.current` | Ref | 标记是否正在拖拽中 | | `offset.current` | Ref<{x, y}> | 鼠标相对按钮的偏移量 | ## 实现思路 ### 1. 拖拽功能实现 采用原生事件实现拖拽,完整流程: ```typescript // 1. 鼠标按下 - 初始化拖拽 const onMouseDown = (e: React.MouseEvent) => { dragging.current = true; setDragged(false); // 记录鼠标相对按钮的偏移量 offset.current = { x: e.clientX - btnPos.x, y: e.clientY - btnPos.y, }; // 添加全局事件监听 document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; // 2. 鼠标移动 - 更新位置 const onMouseMove = (e: MouseEvent) => { if (!dragging.current) return; let x = e.clientX - offset.current.x; let y = e.clientY - offset.current.y; // 限制在窗口范围内 x = Math.max(0, Math.min(window.innerWidth - 64, x)); y = Math.max(0, Math.min(window.innerHeight - 64, y)); setBtnPos({ x, y }); setDragged(true); // 标记为已拖拽 }; // 3. 鼠标释放 - 结束拖拽 const onMouseUp = () => { dragging.current = false; setTimeout(() => setDragged(false), 100); // 延迟重置,防止立即触发点击 // 移除全局事件监听 document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; ``` **关键技术**: - **偏移量计算**:记录鼠标相对按钮的偏移,拖动时保持相对位置不变 - **边界限制**:使用 `Math.max/min` 限制按钮在窗口内 - **事件清理**:在 `onMouseUp` 中移除全局事件,防止内存泄漏 - **拖拽标记**:通过 `dragged` 状态防止拖拽后立即触发点击 ### 2. 防止拖拽后触发点击 使用 `dragged` 状态和延迟重置机制: ```typescript // 按钮点击事件 onClick={(e) => { e.stopPropagation(); if (dragged) return; // 如果刚完成拖拽,不打开抽屉 setOpen(true); }} // 在 onMouseUp 中延迟重置 setTimeout(() => setDragged(false), 100); ``` **工作原理**: 1. 拖拽过程中 `dragged` 设为 `true` 2. 鼠标释放时,延迟 100ms 后才重置 `dragged` 3. 点击事件触发时,如果 `dragged` 为 `true`,则忽略点击 ### 3. 响应式位置调整 监听窗口大小变化,自动调整按钮位置: ```typescript React.useEffect(() => { const updatePosition = () => { setBtnPos({ x: window.innerWidth - 100, y: window.innerHeight - 100, }); }; updatePosition(); // 初始化位置 window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('resize', updatePosition); }; }, []); ``` **策略**: - 初始位置:窗口右下角附近(宽度-100, 高度-100) - 窗口变化:重新计算位置,保持在可见区域 - 清理监听:组件卸载时移除事件监听 ### 4. 抽屉菜单交互 ```typescript // 菜单项点击 const handleClick: MenuProps['onClick'] = (e) => { if (props.onMenuClick) { props.onMenuClick(e.key as string); } dispatch(setBusinessFlow(e.key as string)); setOpen(false); // 自动关闭抽屉 }; ``` **流程**: 1. 用户点击菜单项 2. 调用父组件的 `onMenuClick` 回调 3. 派发 Redux action 更新业务流程 4. 关闭抽屉 ### 5. 条件渲染优化 ```typescript {!open && (
浮动按钮
)} ``` **原因**: - 抽屉打开时隐藏浮动按钮 - 避免视觉干扰 - 减少不必要的 DOM 元素 ## 边界 ### 输入 - **Props**: - `position?: 'left' | 'right'`:抽屉展开方向(默认 'left') - `className?: string`:自定义样式类名 - `onMenuClick?: (key: string) => void`:菜单项点击回调 - **Redux State**: - `userInfo.name`:用户名 - `userInfo.avatar`:用户头像 URL - 登录状态(通过 `isLoggedIn` selector 计算) ### 输出 - **渲染输出**: - 浮动按钮(抽屉关闭时) - 抽屉菜单(包含导航和用户信息) - **事件派发**: - `onMenuClick(key)`:通知父组件菜单项被点击 - `setBusinessFlow(key)`:更新 Redux 业务流程状态 ### 职责边界 #### 负责 - ✅ 浮动按钮的显示和拖拽 - ✅ 抽屉菜单的展开/收起 - ✅ 菜单项的渲染 - ✅ 按钮位置的计算和限制 - ✅ 防止拖拽后误触发点击 - ✅ 用户信息的显示 #### 不负责 - ❌ 业务流程的实际切换逻辑(由父组件或 Redux middleware 处理) - ❌ 用户登录/登出功能(由 MeButton 和相关页面处理) - ❌ 菜单项的权限控制(当前没有实现权限判断) - ❌ 国际化(菜单项文本是硬编码的中文) ## 涉及概念 ### 1. 拖拽(Drag and Drop) 通过鼠标事件实现元素拖拽功能。 **核心事件**: - `onMouseDown`:开始拖拽 - `onMouseMove`:拖拽中 - `onMouseUp`:结束拖拽 **关键点**: - 使用全局事件(document 上监听)确保在快速移动时也能捕获 - 计算偏移量保持拖拽的平滑性 - 清理事件监听防止内存泄漏 ### 2. 事件冒泡和阻止(Event Propagation) ```typescript onClick={(e) => { e.stopPropagation(); // 阻止事件冒泡 // ... }} ``` **用途**: - 防止点击按钮时触发外层元素的事件 - 精确控制事件处理范围 ### 3. 防抖延迟(Debounce Delay) ```typescript setTimeout(() => setDragged(false), 100); ``` **目的**: - 给拖拽结束和点击事件之间留出时间差 - 防止误触发 ### 4. Ref 的使用(useRef) ```typescript const dragging = useRef(false); const offset = useRef({ x: 0, y: 0 }); ``` **优势**: - 不触发重新渲染(相比 useState) - 在事件处理函数中访问最新值 - 适合存储不需要触发视图更新的数据 ### 5. 副作用清理(Effect Cleanup) ```typescript useEffect(() => { // 设置 window.addEventListener('resize', updatePosition); return () => { // 清理 window.removeEventListener('resize', updatePosition); }; }, []); ``` **重要性**: - 防止内存泄漏 - 避免在组件卸载后执行回调 - 符合 React 最佳实践 ### 6. 条件渲染(Conditional Rendering) ```typescript {!open && } ``` **策略**: - 根据状态决定是否渲染 - 优化性能和用户体验 ### 7. 定位策略(Positioning Strategy) ```typescript style={{ position: 'fixed', // 固定定位 left: btnPos.x, // 动态 x 坐标 top: btnPos.y, // 动态 y 坐标 zIndex: 1100, // 确保在最上层 }} ``` **CSS 定位**: - `fixed`:相对于视口定位,不随滚动移动 - `zIndex`:控制层叠顺序 - 动态坐标:通过 state 控制位置 ### 8. 边界检测(Boundary Detection) ```typescript x = Math.max(0, Math.min(window.innerWidth - 64, x)); y = Math.max(0, Math.min(window.innerHeight - 64, y)); ``` **算法**: - `Math.max(0, x)`:确保不小于 0(左/上边界) - `Math.min(窗口尺寸 - 按钮尺寸, x)`:确保不超出右/下边界 - 组合使用实现完整的边界限制 ### 9. 用户体验优化 - **拖拽反馈**:`cursor: 'grab'` 提示可拖拽 - **视觉分离**:抽屉打开时隐藏浮动按钮 - **自动关闭**:点击菜单后自动关闭抽屉 - **遮罩关闭**:`maskClosable={true}` 允许点击遮罩关闭 ### 10. 抽屉组件(Drawer) Ant Design 的侧边抽屉组件。 **关键属性**: - `placement`:展开方向(left/right/top/bottom) - `open`:控制显示状态 - `onClose`:关闭回调 - `closable`:是否显示关闭按钮 - `maskClosable`:点击遮罩是否关闭 ## 注意事项 ### 1. 硬编码问题 - **菜单项文本**:未使用国际化系统 ```typescript label: '患者'; // 应该使用 ``` - **菜单项配置**:与 BusinessZone 不一致 - NavbarFloat 使用 'examination' 和 'emergency' - BasicLayout 使用 'exam' 和 'process' - 可能导致路由不匹配 ### 2. 缺少权限控制 当前所有菜单项都是可点击的,没有根据权限动态禁用,与 BusinessZone 的设计不一致。 ### 3. 拖拽在移动端不可用 使用的是鼠标事件(mousedown/mousemove/mouseup),在触摸设备上无法工作。 **改进建议**:添加触摸事件支持(touchstart/touchmove/touchend) ### 4. 初始位置计算时机 ```typescript React.useEffect(() => { const updatePosition = () => { setBtnPos({ x: window.innerWidth - 100, y: window.innerHeight - 100, }); }; updatePosition(); // 在 useEffect 中调用 }, []); ``` **潜在问题**:首次渲染时使用默认位置 `{x: 100, y: 100}`,然后立即更新,可能导致闪烁。 ### 5. 事件监听器内存泄漏风险 在 `onMouseDown` 中添加全局事件监听,但如果组件在拖拽过程中卸载,监听器不会被清理。 **改进建议**:在 useEffect 的清理函数中也移除这些监听器。 ### 6. Redux action 冗余 同时调用 `props.onMenuClick` 和 `dispatch(setBusinessFlow)`: ```typescript if (props.onMenuClick) { props.onMenuClick(e.key as string); } dispatch(setBusinessFlow(e.key as string)); ``` **问题**: - 父组件可能也会 dispatch 相同的 action - 造成重复的状态更新 **建议**:只调用 `onMenuClick`,由父组件决定是否更新状态 ### 7. 按钮尺寸硬编码 按钮宽高固定为 64px,在不同屏幕尺寸下可能不够灵活。 ### 8. 配置按钮功能未实现 底部的"配置"按钮点击后调用 `onMenuClick('settings')`,但 BasicLayout 的 contentMap 中没有 'settings' 对应的页面。 ## 使用场景 NavbarFloat 专门设计用于**中等屏幕**(如平板电脑): - **大屏(xl/lg)**:使用 NavMenu 侧边栏 - **中屏(md)**:使用 NavbarFloat 浮动按钮 - **小屏(sm/xs)**:使用 TabBar 底部导航 在 BasicLayout 中的使用: ```typescript ``` ## 相关文件 - `src/layouts/BasicLayout.tsx`:主布局组件,使用 NavbarFloat - `src/layouts/NavMenu.tsx`:大屏侧边导航(功能相似) - `src/pages/security/components/MeButton.tsx`:用户信息按钮 - `src/states/BusinessFlowSlice.ts`:业务流程状态管理 - `src/states/user_info.ts`:用户信息状态