状态 |
类型 |
用途 |
open |
boolean |
控制抽屉的打开/关闭 |
btnPos |
{x, y} |
浮动按钮的位置坐标 |
dragged |
boolean |
标记是否刚完成拖拽(防止拖拽后触发点击) |
dragging.current |
Ref
| 标记是否正在拖拽中 |
offset.current |
Ref<{x, y}> |
鼠标相对按钮的偏移量 |
实现思路
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);
工作原理:
- 拖拽过程中
dragged
设为 true
- 鼠标释放时,延迟 100ms 后才重置
dragged
- 点击事件触发时,如果
dragged
为 true
,则忽略点击
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); // 自动关闭抽屉
};
流程:
- 用户点击菜单项
- 调用父组件的
onMenuClick
回调
- 派发 Redux action 更新业务流程
- 关闭抽屉
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.onMenuClick
和 dispatch(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
:用户信息状态