/** * Step3「结果绘制 · 大屏多页」—— 两视图: * * ① 交互区 (view="chat"):左侧大屏绘制智能体的聊天记录 + 输入框(模型问答改页面), * 右侧复用 `Step2SandboxLayout` 沙箱「代码预览」实时展示数据抽取过程(report-json * 边出边以代码态显示)。生成完成自动切到 ②。 * ② 大屏页面 (view="page"):应用内直接渲染参考大屏 React 组件(`DashboardApp`,真实 * 数据经 `adaptReportData` 注入)。顶部按钮:重新绘制(回交互区并重画)、新窗口打开 * (把自包含 HTML 大屏在新标签页打开)、单页看板、返回报告。 * * 数据抽取/聊天/沙箱链路全部复用既有 `useStep3Dashboard`(流式问答、stub 沙箱、续看), * 只是把「完成后的产物」从 iframe HTML 预览换成应用内 React 大屏;HTML 沙箱那条路保留, * 供「新窗口打开」与并行的经典模式使用。 */ import { useEffect, useMemo, useState, } from "react"; import { ArrowLeft, Boxes, CheckCircle, Code2, ExternalLink, LayoutPanelTop, Loader2, Pencil, Sparkles, } from "lucide-react"; import { desensitizeDeep } from "@/lib/desensitize"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage"; import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; import { parseReportJson, renderDashboardHtml, type DashboardTheme, } from "@/roundtable-planning/lib/report-template"; import { useTheme } from "@/contexts/ThemeContext"; interface Step3DashboardModeProps { selectedModel: string; availableModels: ReadonlyArray; modelsLoading: boolean; setSelectedModel: (model: string) => void; savedReport?: DraftStep3Snapshot | null; dashboard: UseStep3DashboardReturn; /** 切回经典「单页看板」绘制模式。 */ onSwitchClassic: () => void; /** 返回最终方案展示页。 */ onBack: () => void; } function formatNow(): string { const d = new Date(); const pad = (n: number) => n.toString().padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } type DashView = "chat" | "page"; type RenderMode = "app" | "html"; export function Step3DashboardMode({ selectedModel, availableModels, modelsLoading, setSelectedModel, savedReport, dashboard, onSwitchClassic, onBack, }: Step3DashboardModeProps) { const { phase, dialogues, seatStubMessages, activeArtifactThreadId, html, busy, redraw, ask, abort, setPreviewEnabled, openDashboardPreview, } = dashboard; // 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。 // 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载) // 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。 const report = useMemo(() => { const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null); return raw ? desensitizeDeep(raw) : raw; }, [dashboard.data, savedReport?.dashboardJson]); // 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。 const dashboardData = useMemo( () => (report ? adaptReportData(report, formatNow()) : undefined), [report], ); // 网页渲染主题跟随应用主题:renderDashboardHtml 是确定性的,主题切换时按 report // 当场重渲(而非用 hook 在生成时烤死主题的 `html`),从而 iframe 大屏色随父元素变化。 // 仅 light/dark 两套(lightblue 归 light,与页面 themeMode 映射一致)。 const { theme } = useTheme(); const dashThemeMode: DashboardTheme = theme === "dark" ? "dark" : "light"; const dashboardHtml = useMemo( () => report ? renderDashboardHtml(report, { generatedAt: formatNow(), theme: dashThemeMode }) : html, [report, dashThemeMode, html], ); const hasResult = !!dashboardData || !!dashboardHtml; // 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。 const [view, setView] = useState(() => (hasResult ? "page" : "chat")); // 大屏页面的两种渲染: // - "app" 应用渲染:用应用内 React 组件(DashboardApp)渲染,交互更丰富(Tab/动画/钻取)。 // - "html" 网页渲染:直接把自包含 HTML 大屏塞进 iframe,与「新窗口打开 / 分享 / 下载」一致。 const [renderMode, setRenderMode] = useState("app"); // 网页渲染:用 blob URL 作 iframe `src`(而非 `srcDoc`)。 // 原因:`srcdoc` 文档的 base URL 继承自父页面,大屏内的纯 CSS `:target` 导航锚点 // (#overview / #seat-x)会被解析成「父页面URL#seat-x」,导致点击导航时整个 iframe // 跳去加载主应用并命中 HashRouter 的 404(Page not found)。blob: 文档有自己的 URL, // 片段锚点在 iframe 内部解析,:target 切页正常 —— 与「新窗口打开」一致。 // // 必须在 **effect 内部**创建/回收 blob:StrictMode(dev) 会 setup→cleanup→setup 双触发 // effect,若用 useMemo 跨两次 render 共享同一个 URL,cleanup 会把它 revoke 掉而 iframe // 仍指向它 → 报「该文件可能已被移至别处」。effect 内每次 run 各自建 URL、cleanup 只回收 // 本 run 的,最后提交的那个存活,故 StrictMode-safe。dashboardHtml 随主题变 → blob 重建。 const [htmlBlobUrl, setHtmlBlobUrl] = useState(null); useEffect(() => { if (typeof window === "undefined" || !dashboardHtml) { setHtmlBlobUrl(null); return; } const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" })); setHtmlBlobUrl(url); return () => URL.revokeObjectURL(url); }, [dashboardHtml]); // 绘制完成**不再**自动切到大屏页面:用户正在交互区看抽取过程/继续问答时,强行跳走很打断。 // 想看成品由用户手动点顶栏「查看大屏」。没有产物时初始即落交互区(见上 useState 初值)。 // 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。 useEffect(() => { setPreviewEnabled(view === "chat"); return () => setPreviewEnabled(false); }, [view, setPreviewEnabled]); // 新窗口打开:跟随当前渲染方式—— // - 网页渲染:把自包含 HTML 大屏(blob)直接在新标签页打开(纯网页,无应用壳); // - 应用渲染:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏 // (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。 const handleOpenNewWindow = () => { if (typeof window === "undefined") return; if (renderMode === "html" && dashboardHtml) { const blob = new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }); const blobUrl = URL.createObjectURL(blob); window.open(blobUrl, "_blank", "noopener,noreferrer"); // 延迟回收,给新标签页足够时间加载(过早 revoke 会打不开)。 window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); return; } const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`; try { // 先清掉历史键,避免 localStorage 堆积;再写本次数据。 for (let i = window.localStorage.length - 1; i >= 0; i -= 1) { const k = window.localStorage.key(i); if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k); } if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData)); } catch { // localStorage 不可用(隐私模式/超额):忽略,新窗口将回落 DEMO 数据。 } // HashRouter:在当前页基址上拼 hash 路由,带上数据键。 const base = `${window.location.origin}${window.location.pathname}`; const url = `${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`; window.open(url, "_blank", "noopener,noreferrer"); }; // 「编辑」:回到交互区接着上次的聊天记录继续问答修改(不丢弃上下文、不重画)。 const handleEdit = () => { setView("chat"); }; const isStreaming = phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true); return (
{/* 顶栏 */}

结果绘制 · 大屏多页

{(phase === "creating" || phase === "streaming") && } {phase === "done" && } {view === "chat" ? "交互绘制中" : "大屏页面"}
{view === "page" ? ( <> {/* 渲染方式切换:应用渲染(React 组件)/ 网页渲染(自包含 HTML iframe) */}
) : ( hasResult && ( ) )}
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */} {view === "page" ? ( renderMode === "html" && htmlBlobUrl ? (