From 2fa40c5e96aeecc20975c1b0a8c58cd45aa0ddfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 16:32:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=9C=86=E6=A1=8C):=20=E5=A4=A7=E5=B1=8F?= =?UTF-8?q?=E5=A4=9A=E9=A1=B5=E5=B8=AD=E4=BD=8D=E5=A4=9A=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?+=E6=96=B0=E7=AA=97=E5=8F=A3React=E9=A1=B5+=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E4=BC=98=E5=8C=96+=E5=88=87=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新窗口打开改为渲染应用内 React 大屏(独立全屏路由 /roundtable-dashboard, 经 localStorage 传数据,无侧边栏),替代原自包含 HTML blob - 总览页布局:上半两列(总控共识+竞争力排行),核心风险分布下移到数据研判中心后 - 席位交付/执行路线网格列数自适应(grid.ts):不足4个卡片撑满整行 - 席位专页主体多样式变体(SeatShowcase):框架矩阵/洞察卡阵/双栏对比/清单核对/ 发布声明,按席位序号轮转分配,总结席位固定发布声明,全部数据驱动 - 修复切换历史记录时大屏/报告不刷新:渲染期复位入口闸门+hydrate依赖草稿id, hydrateSaved清残留data;数据切换时大屏自动回总览页 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/App.tsx | 7 +- .../components/Step3DashboardMode.tsx | 28 +- .../dashboard/DashboardApp.tsx | 19 +- .../dashboard/components/DetailView.tsx | 49 +-- .../dashboard/components/Roadmap.tsx | 3 +- .../components/SeatDeliveryOverview.tsx | 3 +- .../dashboard/components/SeatShowcase.tsx | 336 ++++++++++++++++++ .../src/roundtable-planning/dashboard/grid.ts | 21 ++ .../hooks/useStep3Dashboard.ts | 8 +- .../pages/DashboardStandalonePage.tsx | 36 ++ .../pages/RoundtablePlanningPage.tsx | 47 ++- 11 files changed, 496 insertions(+), 61 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/dashboard/components/SeatShowcase.tsx create mode 100644 frontend-web/src/roundtable-planning/dashboard/grid.ts create mode 100644 frontend-web/src/roundtable-planning/pages/DashboardStandalonePage.tsx diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 6ead2099..fa49e65a 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -30,6 +30,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; +import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -120,10 +121,14 @@ export function App() { } /> } /> + } + /> } /> } /> ); -} +} diff --git a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx index 7d0a5ec6..bd67b3bf 100644 --- a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx @@ -39,6 +39,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; +import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; @@ -113,13 +114,25 @@ export function Step3DashboardMode({ return () => setPreviewEnabled(false); }, [view, setPreviewEnabled]); - // 新窗口打开:把自包含 HTML 大屏(renderDashboardHtml 产物)在新标签页打开。 + // 新窗口打开:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏 + // (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。 const handleOpenNewWindow = () => { - if (!html || typeof window === "undefined") return; - const blob = new Blob([html], { type: "text/html;charset=utf-8" }); - const url = URL.createObjectURL(blob); + if (typeof window === "undefined") 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"); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); }; const handleRedraw = () => { @@ -159,9 +172,8 @@ export function Step3DashboardMode({ + + + ); +} diff --git a/frontend-web/src/roundtable-planning/dashboard/grid.ts b/frontend-web/src/roundtable-planning/dashboard/grid.ts new file mode 100644 index 00000000..e02cd530 --- /dev/null +++ b/frontend-web/src/roundtable-planning/dashboard/grid.ts @@ -0,0 +1,21 @@ +/** + * 大屏卡片网格列数自适应 —— 一行最多 4 个;不足 4 个时撑满整行(2 个就各占半行), + * 避免固定 `lg:grid-cols-4` 在卡片少时右侧大片留白。席位交付总览 / 执行路线共用。 + * + * 返回的列数类全部为字面量,确保 Tailwind JIT 能在编译期扫描到。配合外层固定 + * `grid grid-cols-1 ... gap-4` 使用(base 单列,md/lg 按数量铺开)。 + */ +export function autoGridColsClass(count: number): string { + switch (Math.min(Math.max(count, 0), 4)) { + case 1: + return "lg:grid-cols-1"; + case 2: + return "md:grid-cols-2 lg:grid-cols-2"; + case 3: + return "md:grid-cols-2 lg:grid-cols-3"; + case 4: + return "md:grid-cols-2 lg:grid-cols-4"; + default: + return ""; + } +} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts index 8eaf9224..00840f1a 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts @@ -425,6 +425,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da runningRef.current = false; htmlRef.current = savedHtml; setHtml(savedHtml); + // 纯 HTML 恢复路径(旧版 marker 存稿):清掉上一条草稿残留的结构化 data, + // 否则应用内 React 大屏的 dashboardData memo 会优先用旧 data、不随切换刷新。 + // (hydrateFromJson 会在调用本函数后再 setData(parsed) 覆盖回正确数据。) + setData(null); const synthThreadId = "roundtable-dashboard-restored"; liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成 setDashThreadId(synthThreadId); @@ -459,9 +463,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da (jsonStr: string) => { const parsed = parseReportJson(jsonStr); if (!parsed) return; - setData(parsed); // 应用内 React 大屏直接渲染(hydrateSaved 只管 HTML 沙箱路径) const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode }); - hydrateSaved(rendered); + hydrateSaved(rendered); // 先恢复 HTML 沙箱路径(内部会 setData(null) 清残留) + setData(parsed); // 再写回本草稿结构化数据 → 应用内 React 大屏直接渲染(批量更新,最终生效) }, [themeMode, hydrateSaved], ); diff --git a/frontend-web/src/roundtable-planning/pages/DashboardStandalonePage.tsx b/frontend-web/src/roundtable-planning/pages/DashboardStandalonePage.tsx new file mode 100644 index 00000000..7cb27775 --- /dev/null +++ b/frontend-web/src/roundtable-planning/pages/DashboardStandalonePage.tsx @@ -0,0 +1,36 @@ +/** + * 大屏多页「新窗口打开」的独立全屏宿主页 —— 渲染应用内 React 大屏(`DashboardApp`)。 + * + * 浏览器新标签页是一份全新文档,React 数据无法经 props 直接穿过去,故走 localStorage: + * - Step3 在点「新窗口打开」时把当前 `DashboardDataValue` 写进 localStorage(键由 URL `?k=` 带过来); + * - 本页读出该键、解析后注入 ``;读不到则回落 DEMO(与应用内一致)。 + * + * 路由挂在 `App.tsx` 顶层(`PageRoutes` 之外、`AppProviders` 之下),天然无页面侧边栏 / + * workspace 导航,整屏只渲染大屏本体——正是「新窗口打开」想要的纯净大屏视图。 + */ +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; +import type { DashboardDataValue } from "@/roundtable-planning/dashboard/data-context"; + +/** localStorage 键前缀:Step3 写入、本页读取;前缀统一便于清理历史键。 */ +export const DASHBOARD_VIEW_STORAGE_PREFIX = "rt-dash-view:"; + +export default function DashboardStandalonePage() { + const [searchParams] = useSearchParams(); + + const data = useMemo(() => { + const key = searchParams.get("k"); + if (!key || typeof window === "undefined") return undefined; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return undefined; + return JSON.parse(raw) as DashboardDataValue; + } catch { + return undefined; + } + }, [searchParams]); + + return ; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index ecde250b..3b61d56f 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -966,11 +966,26 @@ export default function RoundtablePlanningPage() { // 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步 // 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。 const step3EnteredRef = useRef(false); - useEffect(() => { - if (currentStep !== 3 || step3EnteredRef.current) return; + // step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。 + const step3ReportRef = useRef(step3Report); + step3ReportRef.current = step3Report; + + // 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次 + // (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变, + // 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。 + const prevDraftIdRef = useRef(drafts.currentDraftId); + if (prevDraftIdRef.current !== drafts.currentDraftId) { + prevDraftIdRef.current = drafts.currentDraftId; + step3EnteredRef.current = false; + } + + // 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它, + // 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。 + const hydrateStep3Flows = useCallback(() => { step3EnteredRef.current = true; + const rep = step3ReportRef.current; // ① 方案总结报告 - const savedSum = step3Report?.summaryReport; + const savedSum = rep?.summaryReport; if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) { summaryFlow.hydrateSaved(savedSum); } else { @@ -978,30 +993,38 @@ export default function RoundtablePlanningPage() { } // ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker), // 那不是经典产物 → 经典照常启动。 - if (step3Report?.html && !isDashboardHtml(step3Report.html)) { - reportFlow.hydrateSaved(step3Report); + if (rep?.html && !isDashboardHtml(rep.html)) { + reportFlow.hydrateSaved(rep); } else { reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果 } // ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。 - if (step3Report?.dashboardJson) { - dashboardFlow.hydrateFromJson(step3Report.dashboardJson); - } else if (step3Report?.html && isDashboardHtml(step3Report.html)) { - dashboardFlow.hydrateSaved(step3Report.html); + if (rep?.dashboardJson) { + dashboardFlow.hydrateFromJson(rep.dashboardJson); + } else if (rep?.html && isDashboardHtml(rep.html)) { + dashboardFlow.hydrateSaved(rep.html); } else { dashboardFlow.start(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentStep]); - // 切换草稿:停掉旧草稿的在途 Step3 流(防串台),并复位入口闸门让新草稿重新编排。 + }, [summaryFlow, reportFlow, dashboardFlow]); + + // 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**, + // 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。 useEffect(() => { summaryFlow.abort(); reportFlow.abort(); dashboardFlow.abort(); - step3EnteredRef.current = false; // eslint-disable-next-line react-hooks/exhaustive-deps }, [drafts.currentDraftId]); + // 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。 + useEffect(() => { + if (currentStep !== 3 || step3EnteredRef.current) return; + hydrateStep3Flows(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep, drafts.currentDraftId]); + // ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ── // 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地 // 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。