Files
deerflow-code/frontend-web/src/roundtable-planning/components/Step3FileBrowseMode.tsx
T
2026-07-19 16:08:40 +08:00

358 lines
15 KiB
TypeScript

/**
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
type SessionArtifactFile,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
interface SeatDelivery {
sender: string;
content: string;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export interface Step3FileBrowseModeProps {
consensusText: string;
hasConsensus: boolean;
consensusDocTitle: string;
seatDeliveries: SeatDelivery[];
artifactsBySeat: SeatArtifacts[];
seatStubMessages: Record<string, unknown[]>;
openArtifactPreview: (threadId: string, path: string) => void;
/** 进入模式时默认打开的文件(由「查看」触发)。 */
initialFile: { threadId: string; file: SessionArtifactFile };
onBack: () => void;
}
export function Step3FileBrowseMode({
consensusText,
hasConsensus,
consensusDocTitle,
seatDeliveries,
artifactsBySeat,
seatStubMessages,
openArtifactPreview,
initialFile,
onBack,
}: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "roundtable-step3-file-browse-layout",
panelIds: ["content", "artifact"],
});
useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path);
}, [activeFile.file.path, activeFile.threadId, openArtifactPreview]);
const handleSelectFile = useCallback(
(threadId: string, file: SessionArtifactFile) => {
setActiveFile({ threadId, file });
openArtifactPreview(threadId, file.path);
},
[openArtifactPreview],
);
const handleDownload = useCallback((threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const activeSeatMessages = useMemo(
() => seatStubMessages[activeFile.threadId] ?? [],
[seatStubMessages, activeFile.threadId],
);
const activeDisplayName = useMemo(() => {
const seat = artifactsBySeat.find((s) => s.threadId === activeFile.threadId);
return seat?.displayName ?? "席位";
}, [artifactsBySeat, activeFile.threadId]);
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-5 h-5 text-primary shrink-0" />
<h3 className="text-sm font-black text-slate-900 tracking-tight truncate">
文件预览 · 沙箱查看
</h3>
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg truncate max-w-[240px]">
{activeDisplayName} / {activeFile.file.name}
</span>
</div>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shrink-0"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
<div className="flex-1 min-h-0 p-3">
{leftCollapsed ? (
<div className="h-full min-h-0 flex gap-3">
<aside className="w-[52px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开侧栏"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<CollapsedIconButton
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
onClick={() => setLeftCollapsed(false)}
/>
<CollapsedIconButton
icon={<FileText className="w-4 h-4 text-primary" />}
title="生成的报告文件"
onClick={() => setLeftCollapsed(false)}
/>
</aside>
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</div>
) : (
<ResizablePanelGroup
orientation="horizontal"
className="h-full min-h-0 gap-3"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="content" defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
方案内容
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起侧栏"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
总控最终结论
</h4>
{hasConsensus && (
<span className="text-[9px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-1.5 py-0.5 rounded-full">
已共识
</span>
)}
</div>
<div className="mb-2 flex justify-end">
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
size="sm"
/>
</div>
{consensusText ? (
<div className="max-h-32 overflow-y-auto text-[11px] text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{seatDeliveries.length > 0 && (
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<h4 className="text-xs font-bold text-slate-900 mb-2">
各席位交付
<span className="ml-1.5 text-[10px] font-mono text-slate-400">
{seatDeliveries.length}
</span>
</h4>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{seatDeliveries.map((s, i) => (
<li key={`${s.sender}-${i}`} className="text-[11px] text-slate-600 truncate">
{s.sender}
</li>
))}
</ul>
</section>
)}
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 min-h-0 flex flex-col">
<h4 className="text-xs font-bold text-slate-900 mb-2 flex items-center gap-1.5 shrink-0">
<Download className="w-3.5 h-3.5 text-primary" />
生成的报告文件
</h4>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl overflow-hidden">
<div className="px-2.5 py-1.5 bg-slate-50 border-b border-slate-100 text-[10.5px] font-bold text-slate-800 truncate">
{seat.displayName}
</div>
{seat.loading ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[10px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin" />
加载中…
</div>
) : seat.error ? (
<p className="px-2.5 py-2 text-[10px] text-rose-600">{seat.error}</p>
) : seat.files.length === 0 ? (
<p className="px-2.5 py-2 text-[10px] text-slate-400 italic">无文件</p>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => {
const isActive =
activeFile.threadId === seat.threadId &&
activeFile.file.path === f.path;
return (
<li key={f.path}>
<div
className={cn(
"px-2 py-1.5 flex items-center justify-between gap-1",
isActive && "bg-[var(--brand-200)]/25",
)}
>
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className="flex items-center gap-1.5 min-w-0 text-left flex-1"
title={f.name}
>
<FileText className="w-3 h-3 text-slate-400 shrink-0" />
<span className="text-[10.5px] font-medium text-slate-800 truncate">
{f.name}
</span>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className={cn(
"p-1 rounded transition",
isActive
? "text-primary bg-white/80"
: "text-slate-500 hover:text-primary hover:bg-slate-100",
)}
title="在沙箱中查看"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-500 hover:text-primary p-1 rounded hover:bg-slate-100 transition"
title="下载"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</section>
</aside>
</ResizablePanel>
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel id="artifact" defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
</div>
);
}
function CollapsedIconButton({
icon,
title,
onClick,
}: {
icon: React.ReactNode;
title: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30"
>
{icon}
</button>
);
}