{analystSeats.map((seat, idx) => {
const color = ACCENTS[idx % ACCENTS.length];
const isActive = activeSeatId === seat.id;
diff --git a/frontend-web/src/roundtable-planning/dashboard/components/SeatShowcase.tsx b/frontend-web/src/roundtable-planning/dashboard/components/SeatShowcase.tsx
new file mode 100644
index 00000000..36562340
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/dashboard/components/SeatShowcase.tsx
@@ -0,0 +1,336 @@
+/**
+ * 席位专页「主体展示区」的多样式变体 —— 对齐参考项目 DetailView 里按席位定制的 5 种子页面
+ * (框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对 / 发布声明),但全部改成**数据驱动**:
+ * 不再写死维度/NPS/审计清单等内容,而是消费当前席位真实的 `sections` / `details` /
+ * `description`,按所选变体换一套排版,从而让每个席位专页「长得不一样」。
+ *
+ * 变体由 DetailView 按席位序号轮转分配(总结席位固定用 release),见 `SHOWCASE_VARIANTS`。
+ * 量化得分 / 风险 / 依赖 / 协同推荐仍由 DetailView 的右栏统一承载,这里只负责主体内容。
+ */
+import { useState } from "react";
+import {
+ Award,
+ Building2,
+ Check,
+ ClipboardList,
+ GitCompare,
+ LayoutGrid,
+ Sparkles,
+} from "lucide-react";
+
+import { DraftingCard } from "./DraftingCard";
+import type { SeatCard, SeatSection } from "../types";
+
+export type ShowcaseVariant = "matrix" | "gauge" | "compare" | "audit" | "release";
+
+/** 非总结席位的变体轮转序列(按席位序号取模分配,保证相邻席位不同款)。 */
+export const SHOWCASE_VARIANTS: ShowcaseVariant[] = ["matrix", "gauge", "compare", "audit"];
+
+/** 把席位内容归一成「分组小节」:优先 sections,回落 details 合成一组。 */
+function toGroups(seat: SeatCard): SeatSection[] {
+ if (seat.sections.length > 0) return seat.sections.filter((s) => s.bullets.length > 0 || s.heading);
+ if (seat.details.length > 0) return [{ heading: "汇报要点支撑明细", bullets: seat.details }];
+ return [];
+}
+
+function allBullets(groups: SeatSection[]): string[] {
+ return groups.flatMap((g) => g.bullets);
+}
+
+function EmptyShowcase() {
+ return (
+
+ 该席位暂无结构化汇报内容。
+
+ );
+}
+
+export function SeatShowcase({
+ seat,
+ variant,
+ color,
+}: {
+ seat: SeatCard;
+ variant: ShowcaseVariant;
+ color: string;
+}) {
+ const groups = toGroups(seat);
+ const bullets = allBullets(groups);
+
+ if (variant !== "release" && bullets.length === 0) return
;
+
+ switch (variant) {
+ case "matrix":
+ return
;
+ case "gauge":
+ return
;
+ case "compare":
+ return
;
+ case "audit":
+ return
;
+ case "release":
+ return
;
+ default:
+ return
;
+ }
+}
+
+// ── 变体一:框架矩阵(ref scheme)—— 维度卡片网格 + 要点对标表 ──────────────────────────
+function MatrixShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
+ const cols = groups.length >= 3 ? "md:grid-cols-3" : groups.length === 2 ? "md:grid-cols-2" : "md:grid-cols-1";
+ let rowNo = 0;
+ return (
+
+
+
+
+ 结构化框架 · 维度拆解
+
+
+ {groups.map((g, i) => (
+
+
+ 维度 {i + 1}
+
+
{g.heading || `小节 ${i + 1}`}
+
+ {g.bullets.length} 项要点
+
+
+ ))}
+
+
+
+
+
+
+ 各维度关键交付要点对标
+
+ 共 {groups.reduce((n, g) => n + g.bullets.length, 0)} 项
+
+
+
+
+
+ | 序号 |
+ 所属维度 |
+ 关键交付要点 |
+
+
+
+ {groups.flatMap((g, gi) =>
+ g.bullets.map((b, bi) => {
+ rowNo += 1;
+ return (
+
+ | {String(rowNo).padStart(2, "0")} |
+
+ {bi === 0 ? g.heading || `维度 ${gi + 1}` : ""}
+ |
+ {b} |
+
+ );
+ }),
+ )}
+
+
+
+
+
+ );
+}
+
+// ── 变体二:洞察卡阵(ref ux)—— 描述领读 + 要点卡片网格 ───────────────────────────────
+function GaugeShowcase({ seat, groups, color }: { seat: SeatCard; groups: SeatSection[]; color: string }) {
+ const bullets = allBullets(groups);
+ return (
+
+
+
+ 核心洞察聚焦 · 关键论点拆解
+
+
+
+
本席位核心论断
+
{seat.description || "(无摘要)"}
+
+
+
+ {bullets.map((b, i) => (
+
+ ))}
+
+
+ );
+}
+
+// ── 变体三:双栏对比(ref ad)—— 小节分两列彩色面板 ────────────────────────────────────
+function CompareShowcase({ groups, color }: { groups: SeatSection[]; color: string }) {
+ // ≥2 组:每组一面板;仅 1 组:把要点对半拆成两面板。
+ const panels: SeatSection[] =
+ groups.length >= 2
+ ? groups
+ : (() => {
+ const b = groups[0]?.bullets ?? [];
+ const mid = Math.ceil(b.length / 2);
+ return [
+ { heading: `${groups[0]?.heading || "汇报要点"} · 上`, bullets: b.slice(0, mid) },
+ { heading: `${groups[0]?.heading || "汇报要点"} · 下`, bullets: b.slice(mid) },
+ ].filter((p) => p.bullets.length > 0);
+ })();
+
+ const tints = [
+ { bg: "bg-emerald-50/40", border: "border-emerald-100", dot: "bg-emerald-500", title: "text-emerald-800" },
+ { bg: "bg-indigo-50/40", border: "border-indigo-100", dot: "bg-indigo-500", title: "text-indigo-800" },
+ { bg: "bg-amber-50/40", border: "border-amber-100", dot: "bg-amber-500", title: "text-amber-800" },
+ { bg: "bg-sky-50/40", border: "border-sky-100", dot: "bg-sky-500", title: "text-sky-800" },
+ ];
+
+ return (
+
+ {panels.map((p, i) => {
+ const t = tints[i % tints.length];
+ return (
+
+
+
+ {p.heading || `对比维度 ${i + 1}`}
+
+
+ {p.bullets.map((b, j) => (
+ -
+
+ {b}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+// ── 变体四:清单核对(ref risk)—— 要点可勾选核对台 ───────────────────────────────────
+function AuditShowcase({ bullets, color }: { bullets: string[]; color: string }) {
+ const [done, setDone] = useState
([]);
+ const toggle = (i: number) => setDone((p) => (p.includes(i) ? p.filter((x) => x !== i) : [...p, i]));
+
+ return (
+
+
+
+
+ 要点核对台
+
+
+ 已确认 {done.length}/{bullets.length}
+
+
+
+
+ {bullets.map((b, i) => {
+ const checked = done.includes(i);
+ return (
+
toggle(i)}
+ className={`flex cursor-pointer select-none items-start gap-3 rounded border p-3 transition-all duration-300 ${
+ checked ? "border-emerald-300 bg-emerald-50/40" : "border-slate-200 bg-white hover:border-slate-300"
+ }`}
+ >
+
+ {checked && }
+
+
+ {b}
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ── 变体五:发布声明(ref summary)—— 声明书 + 要点核验网格 + 签署 ─────────────────────
+function ReleaseShowcase({ seat, bullets }: { seat: SeatCard; bullets: string[] }) {
+ const [signed, setSigned] = useState(false);
+ return (
+
+
+
+
+ 研讨结果最终发布声明书 (Executive Release)
+
+ {seat.description || "(无总结陈述)"}
+
+ {bullets.length > 0 && (
+
+
关键结论与调和事项:
+
+ {bullets.map((b, i) => (
+
+
+ {b}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
总台最终数字签署确认栏
+
勾选此项即代表会议全席专家一致同意此交付终稿,封盘归档。
+
+
+
+
+ );
+}
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,并停止订阅。