fix(layout): 修复边栏拖拽位移累加并实现1:1光标捕获绝对跟随

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 16:49:36 +08:00
co-authored by Cursor
parent 59c3e83606
commit 7bbfddccd4
4 changed files with 323 additions and 176 deletions
+43 -11
View File
@@ -5,16 +5,19 @@ import { ResizeHandle } from './ResizeHandle';
import { ChatColumn } from './ChatColumn';
import { RightDocRail } from './RightDocRail';
import { ProjectSession } from '../../types/workbench';
import { UserRole } from '../../types/docgov';
import { UserRole, UserProfile } from '../../types/docgov';
interface AppShellProps {
sessions: ProjectSession[];
activeSessionId: string;
onSelectSession: (id: string) => void;
onNewSession: () => void;
currentUser: UserProfile;
currentRole: UserRole;
currentUserName: string;
onChangeRole: (role: UserRole, name: string) => void;
onOpenRightsModal: () => void;
onOpenSettingsModal: () => void;
chatContent: React.ReactNode;
docContent: React.ReactNode;
}
@@ -24,9 +27,12 @@ export const AppShell: React.FC<AppShellProps> = ({
activeSessionId,
onSelectSession,
onNewSession,
currentUser,
currentRole,
currentUserName,
onChangeRole,
currentUserName: _currentUserName,
onChangeRole: _onChangeRole,
onOpenRightsModal,
onOpenSettingsModal,
chatContent,
docContent,
}) => {
@@ -54,14 +60,19 @@ export const AppShell: React.FC<AppShellProps> = ({
document.documentElement.setAttribute('data-theme', next);
};
const handleResizeLeft = (deltaX: number) => {
setLeftWidth((prev) => Math.max(220, Math.min(380, prev + deltaX)));
const handleResizeLeft = (newWidth: number) => {
setLeftWidth(newWidth);
};
const handleResizeRight = (deltaX: number) => {
setRightWidth((prev) => Math.max(360, Math.min(800, prev + deltaX)));
const handleResizeRight = (newWidth: number) => {
setRightWidth(newWidth);
};
// 动态视口安全最大宽度计算(保障中间对话栏至少保留 380px)
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1440;
const maxLeftWidth = Math.min(420, Math.max(200, windowWidth - (rightCollapsed ? 0 : rightWidth) - 380));
const maxRightWidth = Math.min(850, Math.max(360, windowWidth - (leftCollapsed ? 0 : leftWidth) - 380));
return (
<div
style={{
@@ -100,17 +111,38 @@ export const AppShell: React.FC<AppShellProps> = ({
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
onNewSession={onNewSession}
currentUser={currentUser}
currentRole={currentRole}
currentUserName={currentUserName}
onChangeRole={onChangeRole}
onOpenRightsModal={onOpenRightsModal}
onOpenSettingsModal={onOpenSettingsModal}
/>
{!leftCollapsed && <ResizeHandle side="left" onResize={handleResizeLeft} />}
{!leftCollapsed && (
<ResizeHandle
side="left"
currentWidth={leftWidth}
minWidth={200}
maxWidth={maxLeftWidth}
defaultWidth={280}
onResize={handleResizeLeft}
onReset={() => setLeftWidth(280)}
/>
)}
{/* 中间栏 */}
<ChatColumn currentSession={currentSession}>{chatContent}</ChatColumn>
{!rightCollapsed && <ResizeHandle side="right" onResize={handleResizeRight} />}
{!rightCollapsed && (
<ResizeHandle
side="right"
currentWidth={rightWidth}
minWidth={360}
maxWidth={maxRightWidth}
defaultWidth={500}
onResize={handleResizeRight}
onReset={() => setRightWidth(500)}
/>
)}
{/* 右侧栏 */}
<RightDocRail width={rightWidth} collapsed={rightCollapsed}>
+110 -128
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Plus, MessageSquare, ChevronDown, User, ShieldCheck } from 'lucide-react';
import React from 'react';
import { Plus, Settings, User, ShieldCheck, MessageSquare } from 'lucide-react';
import { ProjectSession } from '../../types/workbench';
import { UserRole } from '../../types/docgov';
import { UserRole, UserProfile } from '../../types/docgov';
interface LeftRailProps {
width: number;
@@ -10,9 +10,10 @@ interface LeftRailProps {
activeSessionId: string;
onSelectSession: (sessionId: string) => void;
onNewSession: () => void;
currentUser: UserProfile;
currentRole: UserRole;
currentUserName: string;
onChangeRole: (role: UserRole, name: string) => void;
onOpenRightsModal: () => void;
onOpenSettingsModal: () => void;
}
export const LeftRail: React.FC<LeftRailProps> = ({
@@ -22,23 +23,29 @@ export const LeftRail: React.FC<LeftRailProps> = ({
activeSessionId,
onSelectSession,
onNewSession,
currentUser,
currentRole,
currentUserName,
onChangeRole,
onOpenRightsModal,
onOpenSettingsModal,
}) => {
const [roleMenuOpen, setRoleMenuOpen] = useState(false);
if (collapsed) {
return null;
}
const roleOptions: { role: UserRole; name: string; title: string; color: string }[] = [
{ role: 'author', name: '张工', title: '编制负责人', color: 'var(--brand-500)' },
{ role: 'reviewer', name: '李主管', title: '审核负责人', color: 'var(--amber-500)' },
{ role: 'approver', name: '王总监', title: '批准负责人', color: 'var(--success)' },
];
const getRoleBadgeInfo = (role: UserRole) => {
switch (role) {
case 'author':
return { title: '编制人', color: 'var(--brand-500)', bg: 'rgba(79, 107, 255, 0.12)' };
case 'reviewer':
return { title: '审核人', color: 'var(--amber-500)', bg: 'rgba(217, 119, 6, 0.12)' };
case 'approver':
return { title: '批准人', color: 'var(--success)', bg: 'rgba(16, 185, 129, 0.12)' };
default:
return { title: '查阅者', color: 'var(--text-muted)', bg: 'rgba(100, 116, 139, 0.12)' };
}
};
const currentOption = roleOptions.find((r) => r.role === currentRole) || roleOptions[0];
const roleInfo = getRoleBadgeInfo(currentRole);
return (
<aside
@@ -160,103 +167,30 @@ export const LeftRail: React.FC<LeftRailProps> = ({
})}
</div>
{/* 底部用户与法定角色切换面板 */}
{/* 底部用户账号信息与设置入口 */}
<div
style={{
padding: '12px',
padding: '10px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
{/* 角色切换下拉浮层 */}
{roleMenuOpen && (
<div
style={{
position: 'absolute',
bottom: '68px',
left: '12px',
right: '12px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-md)',
padding: '6px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
zIndex: 30,
}}
>
<div style={{ fontSize: '11px', color: 'var(--text-faint)', padding: '4px 8px' }}>
快速切换当前法定责任人身份
</div>
{roleOptions.map((opt) => (
<button
key={opt.role}
onClick={() => {
onChangeRole(opt.role, opt.name);
setRoleMenuOpen(false);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
background: opt.role === currentRole ? 'var(--bg-hover)' : 'transparent',
textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: opt.color,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
}}
>
{opt.name[0]}
</div>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
{opt.name}
</span>
</div>
<span
style={{
fontSize: '11px',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
background: 'var(--bg-subtle)',
color: opt.color,
fontWeight: 600,
}}
>
{opt.title}
</span>
</button>
))}
</div>
)}
{/* 用户信息与角色条 */}
{/* 用户信息卡片 (点击打开权限矩阵) */}
<div
onClick={() => setRoleMenuOpen(!roleMenuOpen)}
onClick={onOpenRightsModal}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
borderRadius: 'var(--r-md)',
cursor: 'pointer',
transition: 'background 0.15s ease',
transition: 'all 0.15s ease',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
@@ -264,40 +198,88 @@ export const LeftRail: React.FC<LeftRailProps> = ({
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="点击切换当前测试角色 (编制 / 审核 / 批准)"
title="点击查看账号信息与当前项目各文档法定权限矩阵"
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
<div
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: roleInfo.color,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '13px',
flexShrink: 0,
boxShadow: '0 2px 5px rgba(0,0,0,0.15)',
}}
>
{currentUser.name ? currentUser.name[0] : <User size={16} />}
</div>
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{currentUser.name}
</span>
<ShieldCheck size={12} style={{ color: roleInfo.color, flexShrink: 0 }} />
</div>
<span
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: currentOption.color,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '13px',
flexShrink: 0,
fontSize: '11px',
color: 'var(--text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<User size={16} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
{currentUserName}
</span>
<ShieldCheck size={13} style={{ color: currentOption.color }} />
</div>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
{currentOption.title}
</span>
</div>
{currentUser.position || '资深方案工程师'}
</span>
</div>
<ChevronDown size={15} style={{ color: 'var(--text-muted)' }} />
</div>
{/* 独立设置按钮 (预留系统设置与管理员后台) */}
<button
onClick={onOpenSettingsModal}
style={{
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
e.currentTarget.style.color = 'var(--text-strong)';
e.currentTarget.style.borderColor = 'var(--brand-300)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-panel)';
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.borderColor = 'var(--border)';
}}
title="系统设置与管理员后台 (规划预留)"
>
<Settings size={16} />
</button>
</div>
</aside>
);
+140 -20
View File
@@ -1,49 +1,169 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
interface ResizeHandleProps {
onResize: (deltaX: number) => void;
export interface ResizeHandleProps {
side: 'left' | 'right';
currentWidth: number;
minWidth?: number;
maxWidth?: number;
defaultWidth?: number;
onResize: (newWidth: number) => void;
onReset?: () => void;
}
export const ResizeHandle: React.FC<ResizeHandleProps> = ({ onResize, side }) => {
/**
* 纯函数:根据鼠标起始坐标与当前坐标,严格计算面板绝对宽度并夹紧在 [min, max] 之间
* 保证拖拽分割线与鼠标指针 1:1 绝对严密跟随,彻底消除任何累积抖动或漂移
*/
export function calculateResizedWidth(
startWidth: number,
startX: number,
currentX: number,
side: 'left' | 'right',
min: number,
max: number
): number {
const deltaX = side === 'left' ? currentX - startX : startX - currentX;
const targetWidth = startWidth + deltaX;
return Math.max(min, Math.min(max, targetWidth));
}
export const ResizeHandle: React.FC<ResizeHandleProps> = ({
side,
currentWidth,
minWidth = side === 'left' ? 200 : 360,
maxWidth = side === 'left' ? 420 : 850,
defaultWidth = side === 'left' ? 280 : 500,
onResize,
onReset,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// 用 ref 实时保存最新配置,避免全局事件监听器产生闭包陷阱
const dragContextRef = useRef({
startWidth: currentWidth,
startX: 0,
minWidth,
maxWidth,
side,
onResize,
});
useEffect(() => {
dragContextRef.current = {
startWidth: dragContextRef.current.startWidth,
startX: dragContextRef.current.startX,
minWidth,
maxWidth,
side,
onResize,
};
}, [minWidth, maxWidth, side, onResize]);
const handlePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = currentWidth;
dragContextRef.current.startX = startX;
dragContextRef.current.startWidth = startWidth;
setIsDragging(true);
// 全局锁定光标与禁止文字选中,防止快速拖动时鼠标移入中间对话框或文本区域导致中断
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX;
onResize(deltaX);
const { startX: sX, startWidth: sW, side: sSide, minWidth: minW, maxWidth: maxW, onResize: resizeFn } =
dragContextRef.current;
const nextWidth = calculateResizedWidth(sW, sX, moveEvent.clientX, sSide, minW, maxW);
resizeFn(nextWidth);
};
const handlePointerUp = () => {
setIsDragging(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointermove', handlePointerMove, { passive: true });
window.addEventListener('pointerup', handlePointerUp, { once: true });
};
const handleDoubleClick = () => {
if (onReset) {
onReset();
} else {
onResize(defaultWidth);
}
};
return (
<div
onPointerDown={handlePointerDown}
onDoubleClick={handleDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: '5px',
width: '10px',
margin: '0 -5px',
cursor: 'col-resize',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
zIndex: 10,
background: 'transparent',
transition: 'background 0.2s',
zIndex: isDragging ? 50 : 25,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--brand-500)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="按住鼠标左右拖拽调整宽度"
/>
title={`按住左右拖拽调节宽度 (当前: ${currentWidth}px) · 双击恢复默认宽度 (${defaultWidth}px)`}
>
{/* 视觉分割线:未悬停时为极细边框,悬停或拖拽时高亮为品牌蓝 */}
<div
style={{
width: isDragging ? '3px' : isHovered ? '2px' : '1px',
height: '100%',
backgroundColor: isDragging
? 'var(--brand-500)'
: isHovered
? 'var(--brand-500)'
: 'var(--border)',
boxShadow: isDragging ? '0 0 10px rgba(79, 107, 255, 0.7)' : 'none',
transition: isDragging ? 'none' : 'background-color 0.15s ease, width 0.15s ease',
pointerEvents: 'none',
}}
/>
{/* 拖拽中的实时宽度微标浮动提示 */}
{isDragging && (
<div
style={{
position: 'absolute',
top: '45%',
left: side === 'left' ? '16px' : undefined,
right: side === 'right' ? '16px' : undefined,
padding: '3px 8px',
borderRadius: '4px',
backgroundColor: 'var(--bg-panel, #131820)',
color: 'var(--brand-500)',
border: '1px solid var(--brand-500)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
fontSize: '11px',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 100,
animation: 'fadeIn 0.1s ease-out',
}}
>
{currentWidth} px
</div>
)}
</div>
);
};
+30 -17
View File
@@ -1,28 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateResizedWidth } from './ResizeHandle';
function clampWidth(current: number, delta: number, min: number, max: number): number {
return Math.max(min, Math.min(max, current + delta));
}
test('左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping)', () => {
const startWidth = 280;
const startX = 280;
const min = 200;
const max = 420;
test('左侧栏拖拽宽度夹紧计算测试 (Left Rail Width Clamping)', () => {
const min = 220;
const max = 380;
// 1. 鼠标向右平滑移动 +40px,宽度必须精准等于 320px (与鼠标位置 1:1 绝对重合)
assert.equal(calculateResizedWidth(startWidth, startX, 320, 'left', min, max), 320);
// 正常范围拖拽
assert.equal(clampWidth(280, 20, min, max), 300);
assert.equal(clampWidth(280, -30, min, max), 250);
// 2. 鼠标向左平滑移动 -30px,宽度必须精准等于 250px
assert.equal(calculateResizedWidth(startWidth, startX, 250, 'left', min, max), 250);
// 边界溢出夹紧
assert.equal(clampWidth(280, 200, min, max), 380);
assert.equal(clampWidth(280, -200, min, max), 220);
// 3. 鼠标大幅向右拖动超过最大界限 (x=600px),宽度被安全夹紧在 420px
assert.equal(calculateResizedWidth(startWidth, startX, 600, 'left', min, max), 420);
// 4. 鼠标大幅向左拖动低于最小界限 (x=100px),宽度被安全夹紧在 200px
assert.equal(calculateResizedWidth(startWidth, startX, 100, 'left', min, max), 200);
});
test('右侧栏拖拽宽度夹紧计算测试 (Right Rail Width Clamping)', () => {
test('右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta)', () => {
const startWidth = 500;
const startX = 940; // 假设 1440px 屏幕,右侧栏初始位置为 1440 - 500 = 940px
const min = 360;
const max = 800;
const max = 850;
assert.equal(clampWidth(500, 50, min, max), 550);
assert.equal(clampWidth(500, -200, min, max), 360);
assert.equal(clampWidth(500, 500, min, max), 800);
// 1. 鼠标向左拖动 100px (startX=940 -> currentX=840),右侧栏展开加宽至 600px (手柄仍严格在 x=840 鼠标下)
assert.equal(calculateResizedWidth(startWidth, startX, 840, 'right', min, max), 600);
// 2. 鼠标向右拖动 100px (startX=940 -> currentX=1040),右侧栏收窄缩减至 400px (手柄仍严格在 x=1040 鼠标下)
assert.equal(calculateResizedWidth(startWidth, startX, 1040, 'right', min, max), 400);
// 3. 边界溢出保护:过度向左扩展夹紧至 850px
assert.equal(calculateResizedWidth(startWidth, startX, 0, 'right', min, max), 850);
// 4. 边界溢出保护:过度向右收缩夹紧至 360px
assert.equal(calculateResizedWidth(startWidth, startX, 1400, 'right', min, max), 360);
});