NavbarFloat.tsx.md 12 KB

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. 状态管理

实现思路

1. 拖拽功能实现

采用原生事件实现拖拽,完整流程:

// 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 状态和延迟重置机制:

// 按钮点击事件
onClick={(e) => {
  e.stopPropagation();
  if (dragged) return; // 如果刚完成拖拽,不打开抽屉
  setOpen(true);
}}

// 在 onMouseUp 中延迟重置
setTimeout(() => setDragged(false), 100);

工作原理

  1. 拖拽过程中 dragged 设为 true
  2. 鼠标释放时,延迟 100ms 后才重置 dragged
  3. 点击事件触发时,如果 draggedtrue,则忽略点击

3. 响应式位置调整

监听窗口大小变化,自动调整按钮位置:

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. 抽屉菜单交互

// 菜单项点击
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. 条件渲染优化

{!open && (
  <div>浮动按钮</div>
)}

原因

  • 抽屉打开时隐藏浮动按钮
  • 避免视觉干扰
  • 减少不必要的 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)

onClick={(e) => {
  e.stopPropagation(); // 阻止事件冒泡
  // ...
}}

用途

  • 防止点击按钮时触发外层元素的事件
  • 精确控制事件处理范围

3. 防抖延迟(Debounce Delay)

setTimeout(() => setDragged(false), 100);

目的

  • 给拖拽结束和点击事件之间留出时间差
  • 防止误触发

4. Ref 的使用(useRef)

const dragging = useRef(false);
const offset = useRef({ x: 0, y: 0 });

优势

  • 不触发重新渲染(相比 useState)
  • 在事件处理函数中访问最新值
  • 适合存储不需要触发视图更新的数据

5. 副作用清理(Effect Cleanup)

useEffect(() => {
  // 设置
  window.addEventListener('resize', updatePosition);

  return () => {
    // 清理
    window.removeEventListener('resize', updatePosition);
  };
}, []);

重要性

  • 防止内存泄漏
  • 避免在组件卸载后执行回调
  • 符合 React 最佳实践

6. 条件渲染(Conditional Rendering)

{!open && <FloatingButton />}

策略

  • 根据状态决定是否渲染
  • 优化性能和用户体验

7. 定位策略(Positioning Strategy)

style={{
  position: 'fixed',  // 固定定位
  left: btnPos.x,      // 动态 x 坐标
  top: btnPos.y,       // 动态 y 坐标
  zIndex: 1100,        // 确保在最上层
}}

CSS 定位

  • fixed:相对于视口定位,不随滚动移动
  • zIndex:控制层叠顺序
  • 动态坐标:通过 state 控制位置

8. 边界检测(Boundary Detection)

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. 硬编码问题

  • 菜单项文本:未使用国际化系统

    label: '患者'; // 应该使用 <FormattedMessage>
    
    • 菜单项配置:与 BusinessZone 不一致
    • NavbarFloat 使用 'examination' 和 'emergency'
    • BasicLayout 使用 'exam' 和 'process'
    • 可能导致路由不匹配

    2. 缺少权限控制

    当前所有菜单项都是可点击的,没有根据权限动态禁用,与 BusinessZone 的设计不一致。

    3. 拖拽在移动端不可用

    使用的是鼠标事件(mousedown/mousemove/mouseup),在触摸设备上无法工作。

    改进建议:添加触摸事件支持(touchstart/touchmove/touchend)

    4. 初始位置计算时机

    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.onMenuClickdispatch(setBusinessFlow)

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 中的使用:

<NavbarFloat
  className="fixed hidden sm:hidden xs:hidden md:block lg:hidden xl:hidden"
  position="right"
  onMenuClick={handleMenuClick}
/>

相关文件

  • src/layouts/BasicLayout.tsx:主布局组件,使用 NavbarFloat
  • src/layouts/NavMenu.tsx:大屏侧边导航(功能相似)
  • src/pages/security/components/MeButton.tsx:用户信息按钮
  • src/states/BusinessFlowSlice.ts:业务流程状态管理
  • src/states/user_info.ts:用户信息状态
状态 类型 用途
open boolean 控制抽屉的打开/关闭
btnPos {x, y} 浮动按钮的位置坐标
dragged boolean 标记是否刚完成拖拽(防止拖拽后触发点击)
dragging.current Ref 标记是否正在拖拽中
offset.current Ref<{x, y}> 鼠标相对按钮的偏移量