From 7bbfddccd4b603f926c9599cb9a976cf5d2b0b06 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 16:49:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(layout):=20=E4=BF=AE=E5=A4=8D=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E6=8B=96=E6=8B=BD=E4=BD=8D=E7=A7=BB=E7=B4=AF=E5=8A=A0?= =?UTF-8?q?=E5=B9=B6=E5=AE=9E=E7=8E=B01:1=E5=85=89=E6=A0=87=E6=8D=95?= =?UTF-8?q?=E8=8E=B7=E7=BB=9D=E5=AF=B9=E8=B7=9F=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- web/src/components/layout/AppShell.tsx | 54 ++++- web/src/components/layout/LeftRail.tsx | 238 ++++++++++----------- web/src/components/layout/ResizeHandle.tsx | 160 ++++++++++++-- web/src/components/layout/layout.test.ts | 47 ++-- 4 files changed, 323 insertions(+), 176 deletions(-) diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 92f292f9..b97c262f 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 = ({ activeSessionId, onSelectSession, onNewSession, + currentUser, currentRole, - currentUserName, - onChangeRole, + currentUserName: _currentUserName, + onChangeRole: _onChangeRole, + onOpenRightsModal, + onOpenSettingsModal, chatContent, docContent, }) => { @@ -54,14 +60,19 @@ export const AppShell: React.FC = ({ 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 (
= ({ activeSessionId={activeSessionId} onSelectSession={onSelectSession} onNewSession={onNewSession} + currentUser={currentUser} currentRole={currentRole} - currentUserName={currentUserName} - onChangeRole={onChangeRole} + onOpenRightsModal={onOpenRightsModal} + onOpenSettingsModal={onOpenSettingsModal} /> - {!leftCollapsed && } + {!leftCollapsed && ( + setLeftWidth(280)} + /> + )} {/* 中间栏 */} {chatContent} - {!rightCollapsed && } + {!rightCollapsed && ( + setRightWidth(500)} + /> + )} {/* 右侧栏 */} diff --git a/web/src/components/layout/LeftRail.tsx b/web/src/components/layout/LeftRail.tsx index 61755d40..4913ecee 100644 --- a/web/src/components/layout/LeftRail.tsx +++ b/web/src/components/layout/LeftRail.tsx @@ -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 = ({ @@ -22,23 +23,29 @@ export const LeftRail: React.FC = ({ 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 (
- {/* 底部用户与法定角色切换面板 */} + {/* 底部用户账号信息与设置入口 */}
- {/* 角色切换下拉浮层 */} - {roleMenuOpen && ( -
-
- 快速切换当前法定责任人身份 -
- {roleOptions.map((opt) => ( - - ))} -
- )} - - {/* 用户信息与角色条 */} + {/* 用户信息卡片 (点击打开权限矩阵) */}
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 = ({ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} - title="点击切换当前测试角色 (编制 / 审核 / 批准)" + title="点击查看账号信息与当前项目各文档法定权限矩阵" > -
-
+ {currentUser.name ? currentUser.name[0] : } +
+ +
+
+ + {currentUser.name} + + +
+ - -
-
-
- - {currentUserName} - - -
- - {currentOption.title} - -
+ {currentUser.position || '资深方案工程师'} +
-
+ + {/* 独立设置按钮 (预留系统设置与管理员后台) */} +
); diff --git a/web/src/components/layout/ResizeHandle.tsx b/web/src/components/layout/ResizeHandle.tsx index e53c3587..65dafddb 100644 --- a/web/src/components/layout/ResizeHandle.tsx +++ b/web/src/components/layout/ResizeHandle.tsx @@ -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 = ({ 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 = ({ + 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 (
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)`} + > + {/* 视觉分割线:未悬停时为极细边框,悬停或拖拽时高亮为品牌蓝 */} +
+ + {/* 拖拽中的实时宽度微标浮动提示 */} + {isDragging && ( +
+ {currentWidth} px +
+ )} +
); }; diff --git a/web/src/components/layout/layout.test.ts b/web/src/components/layout/layout.test.ts index d7296444..3c2fb377 100644 --- a/web/src/components/layout/layout.test.ts +++ b/web/src/components/layout/layout.test.ts @@ -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); });