- 新增带导航+侧边栏的独立页面 DashboardAgentPage:按 taskId 取/存会话, 有有效 report-json 直显大屏(DashboardApp),否则进生成过程(不自动跳)+输入框可改 - 后端 dashboard_sessions 存储(model/sql/__init__)+路由/by-task(GET/PUT 不分权) + deps/app/models 接线;新表靠 create_all 建,无迁移 - 扩展 useStep3Dashboard:buildInitialPrompt(输入任务JSON) + hydrateTranscript(恢复聊天记录) - 抽出共享 DashboardChatPanel;report-template 增 buildDashboardPromptFromJson - 输入 seam core/auth/dashboard-input:JSON 接口未就绪先用占位/演示数据兜底 - 路由 StrategyRoutes dashboard-agent + 侧边栏「问答管理→大屏绘制」 - 圆桌业务链卡片「关系图」改横向布局 - 顺带提交:AIWritingContext 登录切换处理、thread-access-error helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
353 lines
16 KiB
TypeScript
353 lines
16 KiB
TypeScript
/**
|
||
* 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<ModelOption>;
|
||
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<DashView>(() => (hasResult ? "page" : "chat"));
|
||
|
||
// 大屏页面的两种渲染:
|
||
// - "app" 应用渲染:用应用内 React 组件(DashboardApp)渲染,交互更丰富(Tab/动画/钻取)。
|
||
// - "html" 网页渲染:直接把自包含 HTML 大屏塞进 iframe,与「新窗口打开 / 分享 / 下载」一致。
|
||
const [renderMode, setRenderMode] = useState<RenderMode>("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<string | null>(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 (
|
||
<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 flex-wrap">
|
||
<div className="flex items-center gap-2.5">
|
||
<Sparkles className="w-4.5 h-4.5 text-primary" />
|
||
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 大屏多页</h3>
|
||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||
{(phase === "creating" || phase === "streaming") && <Loader2 className="w-3 h-3 animate-spin" />}
|
||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||
{view === "chat" ? "交互绘制中" : "大屏页面"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{view === "page" ? (
|
||
<>
|
||
{/* 渲染方式切换:应用渲染(React 组件)/ 网页渲染(自包含 HTML iframe) */}
|
||
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setRenderMode("app")}
|
||
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
|
||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
|
||
renderMode === "app"
|
||
? "bg-white text-primary shadow-sm border border-slate-200"
|
||
: "text-slate-500 hover:text-slate-700"
|
||
}`}
|
||
>
|
||
<Boxes className="w-3.5 h-3.5" />
|
||
应用渲染
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setRenderMode("html")}
|
||
disabled={!dashboardHtml}
|
||
title={
|
||
dashboardHtml
|
||
? "用自包含 HTML 大屏渲染(与新窗口打开 / 分享 / 下载一致)"
|
||
: "尚无已生成的 HTML 大屏,先完成一次绘制"
|
||
}
|
||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:opacity-40 disabled:cursor-not-allowed ${
|
||
renderMode === "html"
|
||
? "bg-white text-primary shadow-sm border border-slate-200"
|
||
: "text-slate-500 hover:text-slate-700"
|
||
}`}
|
||
>
|
||
<Code2 className="w-3.5 h-3.5" />
|
||
网页渲染
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleEdit}
|
||
title="回到交互区,加载上次聊天记录继续问答修改"
|
||
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"
|
||
>
|
||
<Pencil className="w-3.5 h-3.5 text-slate-500" />
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenNewWindow}
|
||
title={
|
||
renderMode === "html"
|
||
? "在新标签页打开自包含 HTML 大屏(纯网页,可分享 / 下载)"
|
||
: "在新标签页打开应用内 React 大屏(全屏,无侧边栏)"
|
||
}
|
||
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"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5 text-slate-500" />
|
||
新窗口打开
|
||
</button>
|
||
</>
|
||
) : (
|
||
hasResult && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("page")}
|
||
title="查看已生成的大屏页面"
|
||
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"
|
||
>
|
||
<LayoutPanelTop className="w-3.5 h-3.5 text-slate-500" />
|
||
查看大屏
|
||
</button>
|
||
)
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onSwitchClassic}
|
||
title="切回经典单页看板绘制"
|
||
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"
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
|
||
单页看板
|
||
</button>
|
||
<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"
|
||
>
|
||
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
|
||
返回报告
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
|
||
{view === "page" ? (
|
||
renderMode === "html" && htmlBlobUrl ? (
|
||
<iframe
|
||
title="方案研讨大屏"
|
||
src={htmlBlobUrl}
|
||
className="flex-1 min-h-0 w-full border-0 bg-white"
|
||
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
|
||
/>
|
||
) : (
|
||
<div className="flex-1 min-h-0 overflow-auto">
|
||
<DashboardApp data={dashboardData} />
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="flex-1 min-h-0 p-3">
|
||
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||
<Step2SandboxLayout
|
||
activeThreadId={activeArtifactThreadId}
|
||
isStreaming={isStreaming}
|
||
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
|
||
>
|
||
<DashboardChatPanel
|
||
phase={phase}
|
||
dialogues={dialogues}
|
||
busy={busy}
|
||
error={dashboard.error}
|
||
onAsk={ask}
|
||
onRedraw={redraw}
|
||
onPause={abort}
|
||
onOpenPreview={openDashboardPreview}
|
||
hasResult={hasResult}
|
||
selectedModel={selectedModel}
|
||
availableModels={availableModels}
|
||
modelsLoading={modelsLoading}
|
||
setSelectedModel={setSelectedModel}
|
||
/>
|
||
</Step2SandboxLayout>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|