From deee4dcee18ec8103a192b3756a700a4a1e9c6ca Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 14:44:54 +0800 Subject: [PATCH] feat(web): implement responsive three-column shell with resizable rails and theme switch Co-authored-by: Cursor --- web/src/components/layout/AppShell.tsx | 122 +++++++++ web/src/components/layout/ChatColumn.tsx | 26 ++ web/src/components/layout/LeftRail.tsx | 304 +++++++++++++++++++++ web/src/components/layout/ResizeHandle.tsx | 49 ++++ web/src/components/layout/RightDocRail.tsx | 32 +++ web/src/components/layout/Topbar.tsx | 178 ++++++++++++ web/src/components/layout/layout.test.ts | 28 ++ 7 files changed, 739 insertions(+) create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/components/layout/ChatColumn.tsx create mode 100644 web/src/components/layout/LeftRail.tsx create mode 100644 web/src/components/layout/ResizeHandle.tsx create mode 100644 web/src/components/layout/RightDocRail.tsx create mode 100644 web/src/components/layout/Topbar.tsx create mode 100644 web/src/components/layout/layout.test.ts diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 00000000..92f292f9 --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { Topbar } from './Topbar'; +import { LeftRail } from './LeftRail'; +import { ResizeHandle } from './ResizeHandle'; +import { ChatColumn } from './ChatColumn'; +import { RightDocRail } from './RightDocRail'; +import { ProjectSession } from '../../types/workbench'; +import { UserRole } from '../../types/docgov'; + +interface AppShellProps { + sessions: ProjectSession[]; + activeSessionId: string; + onSelectSession: (id: string) => void; + onNewSession: () => void; + currentRole: UserRole; + currentUserName: string; + onChangeRole: (role: UserRole, name: string) => void; + chatContent: React.ReactNode; + docContent: React.ReactNode; +} + +export const AppShell: React.FC = ({ + sessions, + activeSessionId, + onSelectSession, + onNewSession, + currentRole, + currentUserName, + onChangeRole, + chatContent, + docContent, +}) => { + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + const [leftWidth, setLeftWidth] = useState(280); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightWidth, setRightWidth] = useState(500); + const [rightCollapsed, setRightCollapsed] = useState(false); + + const currentSession = + sessions.find((s) => s.sessionId === activeSessionId) || sessions[0] || { + sessionId: 'sess-default', + projectId: 1, + title: '未命名立项项目', + deviceType: '医疗器械', + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 0, + updatedAt: '刚刚', + }; + + const handleToggleTheme = () => { + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + document.documentElement.setAttribute('data-theme', next); + }; + + const handleResizeLeft = (deltaX: number) => { + setLeftWidth((prev) => Math.max(220, Math.min(380, prev + deltaX))); + }; + + const handleResizeRight = (deltaX: number) => { + setRightWidth((prev) => Math.max(360, Math.min(800, prev + deltaX))); + }; + + return ( +
+ setLeftCollapsed(!leftCollapsed)} + rightCollapsed={rightCollapsed} + onToggleRight={() => setRightCollapsed(!rightCollapsed)} + /> + +
+ {/* 左侧栏 */} + + + {!leftCollapsed && } + + {/* 中间栏 */} + {chatContent} + + {!rightCollapsed && } + + {/* 右侧栏 */} + + {docContent} + +
+
+ ); +}; diff --git a/web/src/components/layout/ChatColumn.tsx b/web/src/components/layout/ChatColumn.tsx new file mode 100644 index 00000000..cec2f294 --- /dev/null +++ b/web/src/components/layout/ChatColumn.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ProjectSession } from '../../types/workbench'; + +interface ChatColumnProps { + currentSession: ProjectSession; + children: React.ReactNode; +} + +export const ChatColumn: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/web/src/components/layout/LeftRail.tsx b/web/src/components/layout/LeftRail.tsx new file mode 100644 index 00000000..61755d40 --- /dev/null +++ b/web/src/components/layout/LeftRail.tsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react'; +import { Plus, MessageSquare, ChevronDown, User, ShieldCheck } from 'lucide-react'; +import { ProjectSession } from '../../types/workbench'; +import { UserRole } from '../../types/docgov'; + +interface LeftRailProps { + width: number; + collapsed: boolean; + sessions: ProjectSession[]; + activeSessionId: string; + onSelectSession: (sessionId: string) => void; + onNewSession: () => void; + currentRole: UserRole; + currentUserName: string; + onChangeRole: (role: UserRole, name: string) => void; +} + +export const LeftRail: React.FC = ({ + width, + collapsed, + sessions, + activeSessionId, + onSelectSession, + onNewSession, + currentRole, + currentUserName, + onChangeRole, +}) => { + 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 currentOption = roleOptions.find((r) => r.role === currentRole) || roleOptions[0]; + + return ( + + ); +}; diff --git a/web/src/components/layout/ResizeHandle.tsx b/web/src/components/layout/ResizeHandle.tsx new file mode 100644 index 00000000..e53c3587 --- /dev/null +++ b/web/src/components/layout/ResizeHandle.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +interface ResizeHandleProps { + onResize: (deltaX: number) => void; + side: 'left' | 'right'; +} + +export const ResizeHandle: React.FC = ({ onResize, side }) => { + const handlePointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + const startX = e.clientX; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const deltaX = side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX; + onResize(deltaX); + }; + + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }; + + return ( +
{ + e.currentTarget.style.background = 'var(--brand-500)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent'; + }} + title="按住鼠标左右拖拽调整宽度" + /> + ); +}; diff --git a/web/src/components/layout/RightDocRail.tsx b/web/src/components/layout/RightDocRail.tsx new file mode 100644 index 00000000..05a78d0c --- /dev/null +++ b/web/src/components/layout/RightDocRail.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface RightDocRailProps { + width: number; + collapsed: boolean; + children: React.ReactNode; +} + +export const RightDocRail: React.FC = ({ width, collapsed, children }) => { + if (collapsed) { + return null; + } + + return ( +
+ {children} +
+ ); +}; diff --git a/web/src/components/layout/Topbar.tsx b/web/src/components/layout/Topbar.tsx new file mode 100644 index 00000000..09f8fa4f --- /dev/null +++ b/web/src/components/layout/Topbar.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react'; +import { ProjectSession } from '../../types/workbench'; + +interface TopbarProps { + currentSession: ProjectSession; + theme: 'dark' | 'light'; + onToggleTheme: () => void; + leftCollapsed: boolean; + onToggleLeft: () => void; + rightCollapsed: boolean; + onToggleRight: () => void; +} + +export const Topbar: React.FC = ({ + currentSession, + theme, + onToggleTheme, + leftCollapsed, + onToggleLeft, + rightCollapsed, + onToggleRight, +}) => { + return ( +
+ {/* 折叠左栏按钮 */} + + + {/* Logo */} +
+
+ 水 +
+
+ + 水木东方 + + + AI 协同工作台 + +
+
+ +
+ + {/* 项目面包屑与阶段徽章 */} +
+ 当前项目: + + {currentSession.title} + + + {currentSession.stageLabel} + +
+ + {/* 协同小队提示 */} +
+ + 智能参谋、法规专家、方案总工 +
+
+ + {/* 主题切换按钮 */} + + + {/* 折叠右栏按钮 */} + +
+ ); +}; diff --git a/web/src/components/layout/layout.test.ts b/web/src/components/layout/layout.test.ts new file mode 100644 index 00000000..d7296444 --- /dev/null +++ b/web/src/components/layout/layout.test.ts @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +function clampWidth(current: number, delta: number, min: number, max: number): number { + return Math.max(min, Math.min(max, current + delta)); +} + +test('左侧栏拖拽宽度夹紧计算测试 (Left Rail Width Clamping)', () => { + const min = 220; + const max = 380; + + // 正常范围拖拽 + assert.equal(clampWidth(280, 20, min, max), 300); + assert.equal(clampWidth(280, -30, min, max), 250); + + // 边界溢出夹紧 + assert.equal(clampWidth(280, 200, min, max), 380); + assert.equal(clampWidth(280, -200, min, max), 220); +}); + +test('右侧栏拖拽宽度夹紧计算测试 (Right Rail Width Clamping)', () => { + const min = 360; + const max = 800; + + assert.equal(clampWidth(500, 50, min, max), 550); + assert.equal(clampWidth(500, -200, min, max), 360); + assert.equal(clampWidth(500, 500, min, max), 800); +});