feat(web): implement responsive three-column shell with resizable rails and theme switch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent 2b9678a496
commit deee4dcee1
7 changed files with 739 additions and 0 deletions
+122
View File
@@ -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<AppShellProps> = ({
sessions,
activeSessionId,
onSelectSession,
onNewSession,
currentRole,
currentUserName,
onChangeRole,
chatContent,
docContent,
}) => {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [leftWidth, setLeftWidth] = useState<number>(280);
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(false);
const [rightWidth, setRightWidth] = useState<number>(500);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(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 (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'var(--bg-app)',
}}
>
<Topbar
currentSession={currentSession}
theme={theme}
onToggleTheme={handleToggleTheme}
leftCollapsed={leftCollapsed}
onToggleLeft={() => setLeftCollapsed(!leftCollapsed)}
rightCollapsed={rightCollapsed}
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
/>
<div
style={{
flex: 1,
display: 'flex',
overflow: 'hidden',
width: '100%',
height: 'calc(100vh - 52px)',
}}
>
{/* 左侧栏 */}
<LeftRail
width={leftWidth}
collapsed={leftCollapsed}
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
onNewSession={onNewSession}
currentRole={currentRole}
currentUserName={currentUserName}
onChangeRole={onChangeRole}
/>
{!leftCollapsed && <ResizeHandle side="left" onResize={handleResizeLeft} />}
{/* 中间栏 */}
<ChatColumn currentSession={currentSession}>{chatContent}</ChatColumn>
{!rightCollapsed && <ResizeHandle side="right" onResize={handleResizeRight} />}
{/* 右侧栏 */}
<RightDocRail width={rightWidth} collapsed={rightCollapsed}>
{docContent}
</RightDocRail>
</div>
</div>
);
};
+26
View File
@@ -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<ChatColumnProps> = ({ children }) => {
return (
<main
style={{
flex: 1,
minWidth: '400px',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-app)',
overflow: 'hidden',
position: 'relative',
}}
>
{children}
</main>
);
};
+304
View File
@@ -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<LeftRailProps> = ({
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 (
<aside
style={{
width: `${width}px`,
minWidth: `${width}px`,
height: '100%',
background: 'var(--bg-panel)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
}}
>
{/* 顶部操作区 */}
<div style={{ padding: '14px 12px 10px', borderBottom: '1px solid var(--border-light)' }}>
<button
onClick={onNewSession}
style={{
width: '100%',
height: '36px',
borderRadius: 'var(--r-md)',
background: 'var(--brand-grad)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontWeight: 600,
fontSize: '13px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.28)',
transition: 'all 0.15s ease',
}}
>
<Plus size={16} />
<span>新建立项会话</span>
</button>
</div>
{/* 会话列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ padding: '4px 6px', fontSize: '11px', fontWeight: 600, color: 'var(--text-faint)' }}>
项目会话 (一项目一会话)
</div>
{sessions.map((sess) => {
const isActive = sess.sessionId === activeSessionId;
return (
<div
key={sess.sessionId}
onClick={() => onSelectSession(sess.sessionId)}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'transparent',
border: isActive ? '1px solid var(--brand-100)' : '1px solid transparent',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '4px',
transition: 'all 0.15s var(--ease)',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
style={{
fontSize: '13px',
fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--text-strong)' : 'var(--text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{sess.title}
</span>
<span
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: isActive ? 'var(--brand-50)' : 'var(--bg-subtle)',
color: isActive ? 'var(--brand-600)' : 'var(--text-muted)',
}}
>
{sess.stage}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px', color: 'var(--text-muted)' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sess.deviceType}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
<MessageSquare size={11} />
{sess.messageCount}
</span>
</div>
</div>
);
})}
</div>
{/* 底部用户与法定角色切换面板 */}
<div
style={{
padding: '12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
position: 'relative',
}}
>
{/* 角色切换下拉浮层 */}
{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)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="点击切换当前测试角色 (编制 / 审核 / 批准)"
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: currentOption.color,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '13px',
flexShrink: 0,
}}
>
<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>
</div>
<ChevronDown size={15} style={{ color: 'var(--text-muted)' }} />
</div>
</div>
</aside>
);
};
@@ -0,0 +1,49 @@
import React from 'react';
interface ResizeHandleProps {
onResize: (deltaX: number) => void;
side: 'left' | 'right';
}
export const ResizeHandle: React.FC<ResizeHandleProps> = ({ 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 (
<div
onPointerDown={handlePointerDown}
style={{
width: '5px',
cursor: 'col-resize',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
zIndex: 10,
background: 'transparent',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--brand-500)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="按住鼠标左右拖拽调整宽度"
/>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
interface RightDocRailProps {
width: number;
collapsed: boolean;
children: React.ReactNode;
}
export const RightDocRail: React.FC<RightDocRailProps> = ({ width, collapsed, children }) => {
if (collapsed) {
return null;
}
return (
<section
style={{
width: `${width}px`,
minWidth: `${width}px`,
height: '100%',
background: 'var(--bg-panel)',
borderLeft: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
}}
>
{children}
</section>
);
};
+178
View File
@@ -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<TopbarProps> = ({
currentSession,
theme,
onToggleTheme,
leftCollapsed,
onToggleLeft,
rightCollapsed,
onToggleRight,
}) => {
return (
<header
style={{
height: '52px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-panel)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: '12px',
flexShrink: 0,
zIndex: 20,
}}
>
{/* 折叠左栏按钮 */}
<button
onClick={onToggleLeft}
title={leftCollapsed ? '展开项目会话栏' : '收起项目会话栏'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
}}
>
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
{/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '28px',
height: '28px',
borderRadius: '8px',
background: 'var(--brand-grad)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 800,
fontSize: '14px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.35)',
}}
>
水
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: 'var(--text-strong)', lineHeight: 1.2 }}>
水木东方
</span>
<span style={{ fontSize: '10px', color: 'var(--brand-500)', fontWeight: 600, letterSpacing: '0.5px' }}>
AI 协同工作台
</span>
</div>
</div>
<div style={{ width: '1px', height: '20px', background: 'var(--border)', margin: '0 4px' }} />
{/* 项目面包屑与阶段徽章 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>当前项目:</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)', whiteSpace: 'nowrap' }}>
{currentSession.title}
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
border: '1px solid var(--brand-100)',
whiteSpace: 'nowrap',
}}
>
{currentSession.stageLabel}
</span>
</div>
{/* 协同小队提示 */}
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: 'var(--r-pill)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text-muted)',
}}
>
<Sparkles size={13} style={{ color: 'var(--amber-500)' }} />
<span>智能参谋、法规专家、方案总工</span>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--success)',
boxShadow: '0 0 6px var(--success)',
}}
title="系统服务在线"
/>
</div>
{/* 主题切换按钮 */}
<button
onClick={onToggleTheme}
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
}}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
{/* 折叠右栏按钮 */}
<button
onClick={onToggleRight}
title={rightCollapsed ? '展开受控文档栏' : '收起受控文档栏'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
}}
>
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
</button>
</header>
);
};
+28
View File
@@ -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);
});