feat(ui): 升级顶栏为极简级联面包屑架构,自适应 320px 极窄视窗

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 17:56:30 +08:00
co-authored by Cursor
parent 473fe7ec76
commit a7ee616afc
+363 -222
View File
@@ -1,7 +1,17 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { DeliverableItem, DeliverableCategory } from '../../types/workbench';
import { DocLifecycleState } from '../../types/docgov';
import { FolderKanban, FileDown, Maximize2, Minimize2, Lock, Globe, Shield } from 'lucide-react';
import {
FileDown,
Maximize2,
Minimize2,
Lock,
Globe,
Shield,
ChevronDown,
Layers,
Check,
} from 'lucide-react';
interface SlimTabBarProps {
deliverables: DeliverableItem[];
@@ -26,6 +36,52 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
isFullscreen,
onToggleFullscreen,
}) => {
// 下拉菜单显示状态
const [showCategoryMenu, setShowCategoryMenu] = useState<boolean>(false);
const [showDocMenu, setShowDocMenu] = useState<boolean>(false);
// 容器宽度监听(用于右侧大盘按钮在空间充裕时展示文字,窄栏时优雅收缩为纯数字徽标)
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(600);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// 点击外部及 Escape 键自动收起下拉浮层
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.dropdown-trigger') && !target.closest('.dropdown-menu')) {
setShowCategoryMenu(false);
setShowDocMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowCategoryMenu(false);
setShowDocMenu(false);
}
};
window.addEventListener('mousedown', handleClickOutside);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
const getStatusDot = (state: DocLifecycleState) => {
switch (state) {
case 'approved':
@@ -47,6 +103,7 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
const privateDocs = deliverables.filter((d) => d.category === 'private');
const visibleTabs = deliverables.filter((d) => d.category === activeCategory);
const activeDoc = deliverables.find((d) => d.docId === activeDocId) || visibleTabs[0];
const handleCategoryClick = (cat: DeliverableCategory) => {
onSelectCategory(cat);
@@ -56,8 +113,38 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
}
};
// 分类属性映射
const categoryConfig: Record<
DeliverableCategory,
{ label: string; icon: React.ReactNode; color: string; count: number }
> = {
workflow: {
label: '流程受控',
icon: <Shield size={12} />,
color: 'var(--brand-500)',
count: workflowDocs.length,
},
shared: {
label: '项目共享',
icon: <Globe size={12} />,
color: 'var(--brand-500)',
count: sharedDocs.length,
},
private: {
label: '我的私密',
icon: <Lock size={12} />,
color: '#D97706',
count: privateDocs.length,
},
};
const currentCatInfo = categoryConfig[activeCategory] || categoryConfig.workflow;
const docStatus = activeDoc ? getStatusDot(activeDoc.lifecycleState) : null;
const isCompact = containerWidth < 460;
return (
<div
ref={containerRef}
style={{
height: '36px',
borderBottom: '1px solid var(--border)',
@@ -65,267 +152,321 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: '8px',
overflowX: 'auto',
gap: '6px',
flexShrink: 0,
userSelect: 'none',
position: 'relative',
zIndex: 10,
width: '100%',
}}
>
{/* 分类切换分段 Pill */}
<div
style={{
display: 'flex',
alignItems: 'center',
background: 'var(--bg-panel)',
padding: '2px',
borderRadius: '6px',
border: '1px solid var(--border)',
gap: '2px',
flexShrink: 0,
}}
>
{/* 1. 分类级联选择器 (Breadcrumb Category Step) */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => handleCategoryClick('workflow')}
className="dropdown-trigger"
onClick={() => {
setShowCategoryMenu(!showCategoryMenu);
setShowDocMenu(false);
}}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'workflow' ? 600 : 500,
background: activeCategory === 'workflow' ? 'var(--brand-500)' : 'transparent',
color: activeCategory === 'workflow' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="研发体系预设受控交付物 (全员可见 · 严格审签)"
>
<Shield size={12} />
<span>流程受控</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'workflow' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'workflow' ? '#ffffff' : 'var(--text-faint)',
}}
>
{workflowDocs.length}
</span>
</button>
<button
onClick={() => handleCategoryClick('shared')}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'shared' ? 600 : 500,
background: activeCategory === 'shared' ? 'var(--brand-500)' : 'transparent',
color: activeCategory === 'shared' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="由成员对话推演并共享至项目的协作参考资料"
>
<Globe size={12} />
<span>项目共享</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'shared' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'shared' ? '#ffffff' : 'var(--text-faint)',
}}
>
{sharedDocs.length}
</span>
</button>
<button
onClick={() => handleCategoryClick('private')}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'private' ? 600 : 500,
background: activeCategory === 'private' ? '#D97706' : 'transparent',
color: activeCategory === 'private' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="您在对话中要求助手生成的私密文档 (仅您本人可见)"
>
<Lock size={12} />
<span>我的私密</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'private' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'private' ? '#ffffff' : 'var(--text-faint)',
}}
>
{privateDocs.length}
</span>
</button>
</div>
<div style={{ width: '1px', height: '18px', background: 'var(--border)', flexShrink: 0 }} />
{/* 交付物 Tabs (当前分类下的文档) */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', flex: 1, overflowX: 'auto' }}>
{visibleTabs.length === 0 ? (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', padding: '0 8px' }}>
{activeCategory === 'private'
? '暂无个人私密生成文档 (在对话中要求助手生成即可)'
: activeCategory === 'shared'
? '暂无项目共享参考资料'
: '暂无流程文档'}
</div>
) : (
visibleTabs.map((doc) => {
const isActive = doc.docId === activeDocId;
const status = getStatusDot(doc.lifecycleState);
return (
<div
key={doc.docId}
onClick={() => onSelectDoc(doc.docId)}
style={{
height: '28px',
padding: '0 10px',
borderRadius: '6px 6px 0 0',
background: isActive ? 'var(--bg-panel)' : 'transparent',
border: isActive ? '1px solid var(--border)' : '1px solid transparent',
borderBottom: isActive ? '1px solid var(--bg-panel)' : '1px solid transparent',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--text-strong)' : 'var(--text-muted)',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text-muted)';
}}
title={`${doc.title} (${doc.category === 'private' ? '仅我可见' : doc.category === 'shared' ? '项目共享' : status.label})`}
>
{doc.category === 'private' ? (
<Lock size={12} color="#D97706" />
) : doc.category === 'shared' ? (
<Globe size={12} color="var(--brand-500)" />
) : (
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: status.color,
boxShadow: `0 0 5px ${status.color}`,
}}
/>
)}
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{doc.title}
</span>
</div>
);
})
)}
</div>
{/* 右侧全局操作区 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto', flexShrink: 0 }}>
{/* 全部交付物大盘抽屉入口 */}
<button
onClick={onToggleDeckDrawer}
style={{
height: '26px',
padding: '0 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-panel)',
borderRadius: '5px',
border: '1px solid var(--border)',
color: 'var(--text)',
background: showCategoryMenu ? 'var(--bg-hover)' : 'var(--bg-panel)',
color: currentCatInfo.color,
fontSize: '11px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
title="展开当前项目各阶段全部交付物状态大盘"
title="点击切换交付物空间分类"
>
<FolderKanban size={13} style={{ color: 'var(--brand-500)' }} />
<span>全景大盘 ({deliverables.length}) ▾</span>
{currentCatInfo.icon}
<span>{currentCatInfo.label}</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: 'var(--bg-subtle)',
color: 'var(--text-faint)',
}}
>
{currentCatInfo.count}
</span>
<ChevronDown size={10} style={{ color: 'var(--text-muted)' }} />
</button>
{/* 导出受控 Word 按钮 */}
{showCategoryMenu && (
<div
className="dropdown-menu"
style={{
position: 'absolute',
left: 0,
top: '28px',
width: '150px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '6px',
boxShadow: 'var(--shadow-lg)',
zIndex: 100,
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
{(['workflow', 'shared', 'private'] as DeliverableCategory[]).map((cat) => {
const info = categoryConfig[cat];
const isSelected = activeCategory === cat;
return (
<button
key={cat}
onClick={() => {
handleCategoryClick(cat);
setShowCategoryMenu(false);
}}
style={{
height: '28px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
background: isSelected ? 'var(--bg-subtle)' : 'transparent',
color: isSelected ? info.color : 'var(--text)',
fontSize: '11.5px',
fontWeight: isSelected ? 600 : 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
{info.icon}
<span>{info.label}</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-faint)' }}>{info.count} 份</span>
</button>
);
})}
</div>
)}
</div>
{/* 面包屑斜杠 */}
<span style={{ color: 'var(--text-faint)', fontSize: '11px', flexShrink: 0 }}>/</span>
{/* 2. 当前文档级联下拉选择器 (占满中间自适应空间,带文本截断与状态点) */}
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<button
className="dropdown-trigger"
onClick={() => {
setShowDocMenu(!showDocMenu);
setShowCategoryMenu(false);
}}
style={{
height: '24px',
width: '100%',
padding: '0 8px',
borderRadius: '5px',
border: '1px solid var(--border)',
background: showDocMenu ? 'var(--bg-hover)' : 'var(--bg-panel)',
color: 'var(--text-strong)',
fontSize: '11.5px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
transition: 'background 0.15s ease',
}}
title={activeDoc ? `${activeDoc.title} (点击下拉快速切换文档)` : '请选择文档'}
>
{activeDoc?.category === 'private' ? (
<Lock size={12} color="#D97706" style={{ flexShrink: 0 }} />
) : activeDoc?.category === 'shared' ? (
<Globe size={12} color="var(--brand-500)" style={{ flexShrink: 0 }} />
) : docStatus ? (
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: docStatus.color,
boxShadow: `0 0 4px ${docStatus.color}`,
flexShrink: 0,
}}
/>
) : null}
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{activeDoc ? activeDoc.title : '请选择文档'}
</span>
<ChevronDown size={11} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
</button>
{showDocMenu && (
<div
className="dropdown-menu"
style={{
position: 'absolute',
left: 0,
top: '28px',
width: '100%',
minWidth: '240px',
maxWidth: '380px',
maxHeight: '280px',
overflowY: 'auto',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '6px',
boxShadow: 'var(--shadow-lg)',
zIndex: 100,
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<div style={{ padding: '4px 8px', fontSize: '10px', color: 'var(--text-faint)', fontWeight: 600 }}>
{currentCatInfo.label}清单 ({visibleTabs.length} 份):
</div>
{visibleTabs.length === 0 ? (
<div style={{ padding: '10px 8px', fontSize: '11px', color: 'var(--text-faint)', textAlign: 'center' }}>
暂无文档
</div>
) : (
visibleTabs.map((doc) => {
const isCur = doc.docId === activeDocId;
const st = getStatusDot(doc.lifecycleState);
return (
<button
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
setShowDocMenu(false);
}}
style={{
padding: '6px 8px',
borderRadius: '4px',
border: 'none',
background: isCur ? 'var(--bg-subtle)' : 'transparent',
color: isCur ? 'var(--brand-500)' : 'var(--text)',
fontSize: '11px',
fontWeight: isCur ? 600 : 400,
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.12s ease',
}}
>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: st.color,
flexShrink: 0,
}}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.title}
</span>
{isCur && <Check size={11} color="var(--brand-500)" style={{ flexShrink: 0 }} />}
</button>
);
})
)}
</div>
)}
</div>
{/* 3. 右侧全局紧凑操作区 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', flexShrink: 0 }}>
{/* 全景大盘抽屉入口 (宽屏带文字,窄屏收缩为纯图标+数字) */}
<button
onClick={onToggleDeckDrawer}
style={{
height: '24px',
padding: isCompact ? '0 6px' : '0 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-strong)',
display: 'flex',
alignItems: 'center',
gap: '3px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 600,
transition: 'all 0.15s ease',
}}
title={`展开全景大盘 (当前共 ${deliverables.length} 份交付物)`}
>
<Layers size={12} color="var(--brand-500)" />
{!isCompact && <span>全景大盘</span>}
<span style={{ fontSize: '10px', color: 'var(--text-faint)' }}>({deliverables.length})</span>
</button>
{/* 导出 DOCX 图标 */}
{onExportDocx && (
<button
onClick={onExportDocx}
style={{
height: '26px',
padding: '0 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-50)',
border: '1px solid var(--brand-100)',
color: 'var(--brand-600)',
fontSize: '11px',
fontWeight: 600,
width: '24px',
height: '24px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
gap: '4px',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="导出为符合严肃合规模板的受控 DOCX 交付物"
title="导出为符合合规模板的 DOCX 受控文件"
>
<FileDown size={13} />
<span>导出 DOCX</span>
<FileDown size={12} />
</button>
)}
{/* 全屏切换 */}
{/* 全屏切换图标 */}
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
style={{
width: '26px',
height: '26px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'transparent',
width: '24px',
height: '24px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title={isFullscreen ? '退出全屏' : '全屏沉浸阅读与编辑'}
title={isFullscreen ? '退出全屏' : '全屏沉浸阅读'}
>
{isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
{isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
</button>
)}
</div>