Files
Agent-System/web/src/components/doc/DeckDrawer.tsx
T
weichenandCursor 855363fd60 fix(web): gate demo seeds and reclaim mock deliverables
Stop shipping hard-coded demo docs/projects into production builds,
drop local mock items on authoritative empty server lists, and purge
stale demo seed residue from localStorage on boot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 02:50:34 +00:00

791 lines
30 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { DeliverableItem, DeliverableCategory } from '../../types/workbench';
import { DocLifecycleState } from '../../types/docgov';
import { DocVisibilityRule } from '../../types/rbac';
import { isDemoSeedDocId } from '../../data/demoSeedIds';
import {
X,
CheckCircle2,
Clock,
ShieldCheck,
UserCheck,
AlertTriangle,
Lock,
Globe,
Share2,
Undo2,
Trash2,
Shield,
Layers,
FileText,
} from 'lucide-react';
interface DeckDrawerProps {
isOpen: boolean;
onClose: () => void;
deliverables: DeliverableItem[];
activeDocId: string;
currentUserId: number;
onSelectDoc: (docId: string) => void;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onDeleteDoc?: (docId: string) => void;
onOpenDocVisibility?: (doc: DeliverableItem) => void;
}
export const DeckDrawer: React.FC<DeckDrawerProps> = ({
isOpen,
onClose,
deliverables,
activeDocId,
currentUserId,
onSelectDoc,
onShareToProject,
onRevokeShare,
onDeleteDoc,
onOpenDocVisibility,
}) => {
const [filterCategory, setFilterCategory] = useState<'all' | DeliverableCategory>('all');
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
const renderBadge = (
state: DocLifecycleState,
isClaimed: boolean,
isTemplateOnly?: boolean,
docId?: string,
) => {
if (docId && isDemoSeedDocId(docId)) {
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: '#64748b',
background: '#f1f5f9',
border: '1px solid #e2e8f0',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="本地演示数据,不进入服务端文档账本"
>
<FileText size={11} />
<span>本地示例(不入账本)</span>
</span>
);
}
if (isTemplateOnly || state === 'template') {
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: '#0284c7',
background: '#e0f2fe',
border: '1px solid #bae6fd',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="该文档为项目标准预置模板副本,点击可全景查阅大纲与章节规范"
>
<FileText size={11} />
<span>📄 初始模板</span>
</span>
);
}
switch (state) {
case 'approved':
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--success)',
background: 'var(--success-bg)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<CheckCircle2 size={11} />
<span>终审已生效</span>
</span>
);
case 'reviewing':
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Clock size={11} />
<span>审核中 (已锁定)</span>
</span>
);
case 'approving':
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: '#9333EA',
background: '#F3E8FF',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<ShieldCheck size={11} />
<span>批准中</span>
</span>
);
case 'rejected':
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--danger)',
background: 'var(--danger-bg)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<AlertTriangle size={11} />
<span>被驳回需修改</span>
</span>
);
case 'drafting':
default:
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--brand-600)',
background: 'var(--brand-50)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<UserCheck size={11} />
<span>编制中 {isClaimed ? '(已认领)' : '(未认领)'}</span>
</span>
);
}
};
const renderVisibilityBadge = (rule?: DocVisibilityRule, onClick?: (e: React.MouseEvent) => void) => {
if (!rule || rule.mode === 'inherit') return null;
const isDesignated = rule.mode === 'designated_only';
const isWhitelist = rule.mode === 'role_whitelist';
return (
<span
onClick={onClick}
style={{
fontSize: '10.5px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: isDesignated
? 'rgba(239, 68, 68, 0.12)'
: isWhitelist
? 'rgba(245, 158, 11, 0.12)'
: 'rgba(34, 197, 94, 0.12)',
color: isDesignated
? 'var(--danger)'
: isWhitelist
? 'var(--amber-600)'
: 'var(--success)',
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
cursor: onClick ? 'pointer' : 'default',
}}
title={`覆盖原因: ${rule.overrideReason || '权限覆盖模式'}`}
>
<Lock size={10} />
<span>{isDesignated ? '仅责任人' : isWhitelist ? '指定角色' : '公开'}</span>
</span>
);
};
const stages: { stage: string; title: string }[] = [
{ stage: 'V0.1', title: 'V0.1 需求摸底与产品定义' },
{ stage: 'V0.5', title: 'V0.5 方案成型与法规对齐' },
{ stage: 'V1.0', title: 'V1.0 商务报价与WBS工时测算' },
];
const workflowDocs = deliverables.filter((d) => d.category === 'workflow');
const sharedDocs = deliverables.filter((d) => d.category === 'shared');
const privateDocs = deliverables.filter((d) => d.category === 'private');
return (
<>
{/* 外部遮罩 */}
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 45,
background: 'rgba(15, 23, 42, 0.35)',
backdropFilter: 'blur(3px)',
}}
/>
<div
style={{
position: 'absolute',
top: '36px',
left: 0,
right: 0,
maxHeight: '560px',
background: 'var(--bg-panel)',
borderBottom: '2px solid var(--brand-500)',
boxShadow: 'var(--shadow-xl)',
zIndex: 50,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'fadeIn 0.15s ease-out',
}}
>
{/* 抽屉顶栏 */}
<div
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Layers size={16} color="var(--brand-500)" />
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
交付物分类台账全景大盘
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
(共 {deliverables.length} 份可用交付物)
</span>
</div>
{/* 大盘内分类筛选切换 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '2px',
background: 'var(--bg-panel)',
padding: '2px',
borderRadius: '6px',
border: '1px solid var(--border)',
}}
>
<button
onClick={() => setFilterCategory('all')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'all' ? 600 : 500,
background: filterCategory === 'all' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'all' ? '#ffffff' : 'var(--text-muted)',
}}
>
全部全景 ({deliverables.length})
</button>
<button
onClick={() => setFilterCategory('workflow')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'workflow' ? 600 : 500,
background: filterCategory === 'workflow' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'workflow' ? '#ffffff' : 'var(--text-muted)',
}}
>
流程受控 ({workflowDocs.length})
</button>
<button
onClick={() => setFilterCategory('shared')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'shared' ? 600 : 500,
background: filterCategory === 'shared' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'shared' ? '#ffffff' : 'var(--text-muted)',
}}
>
项目共享 ({sharedDocs.length})
</button>
<button
onClick={() => setFilterCategory('private')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'private' ? 600 : 500,
background: filterCategory === 'private' ? '#D97706' : 'transparent',
color: filterCategory === 'private' ? '#ffffff' : 'var(--text-muted)',
}}
>
我的私密 ({privateDocs.length})
</button>
</div>
</div>
<button
onClick={onClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
}}
title="关闭大盘"
>
<X size={15} />
</button>
</div>
{/* 交付物大盘内容区 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
}}
>
{/* 板块一:🏛️ 研发体系预设受控交付物 */}
{(filterCategory === 'all' || filterCategory === 'workflow') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Shield size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
研发体系预设受控交付物 (固定流程 · 严格审签)
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
共 {workflowDocs.length} 份受控文档
</span>
</div>
{stages.map((stg) => {
const docsInStage = workflowDocs.filter((d) => d.stage === stg.stage);
if (docsInStage.length === 0) return null;
return (
<div key={stg.stage} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--brand-600)', paddingLeft: '4px' }}>
{stg.title}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{docsInStage.map((doc) => {
const isActive = doc.docId === activeDocId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{renderVisibilityBadge(doc.visibilityRule, (e) => {
e.stopPropagation();
onOpenDocVisibility?.(doc);
})}
{doc.editLease?.holderType &&
(!doc.editLease.expiresAt || new Date(doc.editLease.expiresAt).getTime() > Date.now()) && (
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
}}
title={`编辑占用:${doc.editLease.holderType === 'squad' ? '项目助手' : '其他用户'}`}
>
编辑中
</span>
)}
{renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly, doc.docId)}
</div>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>编制: <b>{doc.author}</b></span>
<span>审核: <b>{doc.reviewer}</b></span>
<span>批准: <b>{doc.approver}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* 板块二:🌐 项目协作参考资料库 */}
{(filterCategory === 'all' || filterCategory === 'shared') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Globe size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
项目协作参考资料库 (团队共享 · 方案推演)
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
共 {sharedDocs.length} 份共享参考材料
</span>
</div>
{sharedDocs.length === 0 ? (
<div style={{ fontSize: '12px', color: 'var(--text-faint)', padding: '12px', textAlign: 'center' }}>
当前暂无团队成员共享的参考资料。个人私密生成物成熟后可一键共享至此处。
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{sharedDocs.map((doc) => {
const isActive = doc.docId === activeDocId;
const isCreator = doc.creatorId === currentUserId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--brand-600)',
background: 'var(--brand-50)',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Globe size={10} />
<span>项目共享</span>
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>共享人: <b>{doc.creatorName}</b></span>
<span>共享时间: <b>{doc.sharedAt || doc.updatedAt}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
{/* 共享人专属撤回操作 */}
{isCreator && onRevokeShare && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '4px' }}>
<button
onClick={(e) => {
e.stopPropagation();
onRevokeShare(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="将该共享资料撤回为个人私密草稿 (仅自己可见)"
>
<Undo2 size={11} />
<span>取消共享 (恢复私有)</span>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* 板块三:🔒 我的私密生成档案 */}
{(filterCategory === 'all' || filterCategory === 'private') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Lock size={14} color="#D97706" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
我的私密生成档案 (仅您本人可见 · 商业与推演隐私)
</span>
<span style={{ fontSize: '11px', color: '#D97706', fontWeight: 600 }}>
共 {privateDocs.length} 份私有草稿
</span>
</div>
{privateDocs.length === 0 ? (
<div style={{ fontSize: '12px', color: 'var(--text-faint)', padding: '12px', textAlign: 'center' }}>
您当前未生成私密文档。在中间栏向智能体提出测算、草拟需求时,生成的文档将自动归档于此处。
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{privateDocs.map((doc) => {
const isActive = doc.docId === activeDocId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid #D97706' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: '#D97706',
background: '#FEF3C7',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Lock size={10} />
<span>仅我可见</span>
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>创建人: <b>本人 ({doc.creatorName})</b></span>
<span>生成时间: <b>{doc.updatedAt}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '6px', marginTop: '4px' }}>
{onShareToProject && (
<button
onClick={(e) => {
e.stopPropagation();
onShareToProject(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 8px',
borderRadius: '4px',
border: '1px solid var(--brand-500)',
background: 'var(--brand-500)',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="将该私密文档一键共享给项目组成员作为协作参考资料"
>
<Share2 size={11} />
<span>一键共享给项目组</span>
</button>
)}
{onDeleteDoc && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteDoc(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 6px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--danger)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2px',
}}
title="删除此私密草稿"
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</>
);
};