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:
committed by
weichen
co-authored by
Cursor
parent
2b9678a496
commit
deee4dcee1
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user