feat(圆桌): 大屏多页席位多样式+新窗口React页+布局优化+切历史记录刷新

- 新窗口打开改为渲染应用内 React 大屏(独立全屏路由 /roundtable-dashboard,
  经 localStorage 传数据,无侧边栏),替代原自包含 HTML blob
- 总览页布局:上半两列(总控共识+竞争力排行),核心风险分布下移到数据研判中心后
- 席位交付/执行路线网格列数自适应(grid.ts):不足4个卡片撑满整行
- 席位专页主体多样式变体(SeatShowcase):框架矩阵/洞察卡阵/双栏对比/清单核对/
  发布声明,按席位序号轮转分配,总结席位固定发布声明,全部数据驱动
- 修复切换历史记录时大屏/报告不刷新:渲染期复位入口闸门+hydrate依赖草稿id,
  hydrateSaved清残留data;数据切换时大屏自动回总览页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-13 16:32:32 +08:00
1 parent 432c12fbac
commit 2fa40c5e96
11 files changed
+496 -61

No files matched your search

+6 -1
View File
@@ -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() {
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
}
@@ -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({
<button
type="button"
onClick={handleOpenNewWindow}
disabled={!html}
title={html ? "在新标签页打开自包含 HTML 大屏" : "暂无可打开的大屏"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
title="在新标签页打开 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" />
新窗口打开
@@ -8,7 +8,7 @@
* - 不传 data 时回落参考 demo(DEMO_DATA)。
*/
import './dashboard.css';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { TabType } from './types';
import {
DashboardDataProvider,
@@ -34,6 +34,12 @@ const TAB_ICONS = [LayoutGrid, Users, Megaphone, ShieldAlert, FileText, Activity
export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// 数据切换(切历史记录/重绘 → data 引用变化)时复位到总览:避免停留在上一份数据
// 钻入的某个席位详情页,而新数据里无同名席位导致「加载席位数据失败」。
useEffect(() => {
setActiveTab('overview');
}, [data]);
// 导航 Tab:总览 + 各席位(由数据动态生成,原参考为写死的固定 6 项)。
const tabs: TabType[] = ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
@@ -114,20 +120,21 @@ export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }
>
<MetricsHeader />
{/* 上半部分:总控最终共识 + 多维核心竞争力排行(核心风险分布下移到数据研判中心之后) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<div className="lg:col-span-5 h-full">
<div className="lg:col-span-7 h-full">
<ConsensusPanel onExploreClick={() => setActiveTab(tabs[tabs.length - 1] ?? 'overview')} />
</div>
<div className="lg:col-span-4 h-full">
<div className="lg:col-span-5 h-full">
<BrandScores />
</div>
<div className="lg:col-span-3 h-full">
<RiskDonut />
</div>
</div>
<AnalyticsCenter />
{/* 核心风险分类分布:移到数据研判中心下方,整行展示 */}
<RiskDonut />
<SeatDeliveryOverview onSeatSelect={setActiveTab} activeSeatId={activeTab} />
<Roadmap />
@@ -8,6 +8,7 @@
import { useDashboardData, ACCENTS } from "../data-context";
import { TabType, SeatCard } from "../types";
import { DraftingCard } from "./DraftingCard";
import { SeatShowcase, SHOWCASE_VARIANTS, type ShowcaseVariant } from "./SeatShowcase";
import { Settings, ShieldAlert } from "lucide-react";
interface DetailViewProps {
@@ -37,6 +38,13 @@ export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailVi
const idx = SEAT_CARDS.findIndex((s) => s.id === seat.id);
const color = ACCENTS[(idx >= 0 ? idx : 0) % ACCENTS.length];
// 主体展示样式:总结席位固定「发布声明」,其余按「非总结席位序号」轮转分配,
// 让相邻席位专页换不同款式(框架矩阵 / 洞察卡阵 / 双栏对比 / 清单核对)。
const analystIdx = SEAT_CARDS.filter((s) => !s.isSummary).findIndex((s) => s.id === seat.id);
const variant: ShowcaseVariant = seat.isSummary
? "release"
: SHOWCASE_VARIANTS[(analystIdx >= 0 ? analystIdx : 0) % SHOWCASE_VARIANTS.length];
// 依赖双向:本席位依赖谁 / 谁承接本席位。
const byId = new Map(SEAT_CARDS.map((s) => [s.id, s] as const));
const deps = seat.dependsOn.map((d) => byId.get(d)).filter((s): s is SeatCard => !!s);
@@ -73,40 +81,21 @@ export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailVi
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左栏:概述 + 小节 */}
{/* 左栏:使命概述 + 按变体定制的主体展示区(发布声明变体自带声明书,不重复概述卡) */}
<div className="lg:col-span-2 space-y-6">
<DraftingCard hoverEffect={false}>
<div className="flex items-center gap-2 pb-3.5 border-b border-rose-100 mb-4">
<Settings className="w-4 h-4 text-rose-600 animate-spin-slow" />
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">此席位核心交付 · 使命概述</h3>
</div>
<p className="text-xs text-slate-600 leading-relaxed font-sans bg-slate-50 p-3.5 rounded-sm border border-slate-200/40">
{seat.description || "(无摘要)"}
</p>
</DraftingCard>
{seat.sections.length > 0 ? (
seat.sections.map((sec, i) => (
<DraftingCard hoverEffect={false} key={i}>
<div className="flex items-center gap-2 pb-3.5 border-b border-rose-100 mb-4">
<span className="w-2 h-2 rounded-sm" style={{ backgroundColor: color }} />
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">{sec.heading || "汇报要点"}</h3>
</div>
<ul className="space-y-2 text-xs text-slate-600 font-sans leading-relaxed">
{sec.bullets.map((b, j) => (
<li key={j} className="flex gap-2 bg-slate-50/40 p-2.5 rounded-sm border border-slate-200/20">
<span className="text-rose-500 font-bold shrink-0">{`0${j + 1}.`}</span>
<span>{b}</span>
</li>
))}
</ul>
</DraftingCard>
))
) : (
{variant !== "release" && (
<DraftingCard hoverEffect={false}>
<p className="text-xs text-slate-400 text-center py-4">该席位暂无结构化小节内容。</p>
<div className="flex items-center gap-2 pb-3.5 border-b border-rose-100 mb-4">
<Settings className="w-4 h-4 text-rose-600 animate-spin-slow" />
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">此席位核心交付 · 使命概述</h3>
</div>
<p className="text-xs text-slate-600 leading-relaxed font-sans bg-slate-50 p-3.5 rounded-sm border border-slate-200/40">
{seat.description || "(无摘要)"}
</p>
</DraftingCard>
)}
<SeatShowcase key={seat.id} seat={seat} variant={variant} color={color} />
</div>
{/* 右栏:得分 + 风险 + 依赖 + 推荐 */}
@@ -4,6 +4,7 @@
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
import { motion } from "motion/react";
@@ -28,7 +29,7 @@ export function Roadmap() {
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`grid grid-cols-1 ${autoGridColsClass(ROADMAP_PHASES.length)} gap-4`}>
{ROADMAP_PHASES.map((phase) => {
const hasHighlights = phase.highlights.length > 0;
const isExpanded = activePhaseId === phase.id;
@@ -3,6 +3,7 @@
* 这里改成:按序号循环取强调色,isSummary 席位全宽置底(无则全部进网格)。
*/
import { useDashboardData, ACCENTS } from "../data-context";
import { autoGridColsClass } from "../grid";
import { DraftingCard } from "./DraftingCard";
import { TabType } from "../types";
import { ArrowRight, AlertTriangle, AlertCircle, FileText } from "lucide-react";
@@ -27,7 +28,7 @@ export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliver
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`grid grid-cols-1 ${autoGridColsClass(analystSeats.length)} gap-4`}>
{analystSeats.map((seat, idx) => {
const color = ACCENTS[idx % ACCENTS.length];
const isActive = activeSeatId === seat.id;
@@ -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 (
<DraftingCard hoverEffect={false}>
<p className="py-6 text-center text-xs text-slate-400">该席位暂无结构化汇报内容。</p>
</DraftingCard>
);
}
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 <EmptyShowcase />;
switch (variant) {
case "matrix":
return <MatrixShowcase groups={groups} color={color} />;
case "gauge":
return <GaugeShowcase seat={seat} groups={groups} color={color} />;
case "compare":
return <CompareShowcase groups={groups} color={color} />;
case "audit":
return <AuditShowcase bullets={bullets} color={color} />;
case "release":
return <ReleaseShowcase seat={seat} bullets={bullets} />;
default:
return <EmptyShowcase />;
}
}
// ── 变体一:框架矩阵(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 (
<div className="space-y-5">
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-center gap-1.5 border-b border-rose-100 pb-3.5 text-xs font-bold uppercase tracking-widest text-rose-700">
<Building2 className="h-4 w-4 text-emerald-600" />
<span>结构化框架 · 维度拆解</span>
</div>
<div className={`grid grid-cols-1 gap-3 ${cols}`}>
{groups.map((g, i) => (
<div
key={i}
className="rounded-sm border border-slate-200 bg-slate-50/60 p-3 text-center"
style={{ borderTopColor: color, borderTopWidth: 2 }}
>
<div className="mb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">
维度 {i + 1}
</div>
<div className="truncate text-xs font-semibold text-slate-800">{g.heading || `小节 ${i + 1}`}</div>
<span className="mt-1.5 inline-block rounded-full border border-current bg-white/70 px-1.5 py-0.5 font-mono text-[9px] text-slate-500">
{g.bullets.length} 项要点
</span>
</div>
))}
</div>
</DraftingCard>
<DraftingCard hoverEffect={false}>
<div className="overflow-hidden rounded-md border border-slate-200/60 bg-slate-50">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 p-3">
<span className="text-xs font-bold text-slate-700">各维度关键交付要点对标</span>
<span className="rounded-sm bg-slate-200/50 px-2 py-0.5 font-mono text-[10px] text-slate-600">
共 {groups.reduce((n, g) => n + g.bullets.length, 0)} 项
</span>
</div>
<table className="w-full text-left text-xs">
<thead className="border-b border-slate-200 bg-slate-50 font-sans text-[10px] uppercase tracking-wider text-slate-500">
<tr>
<th className="px-3 py-2.5">序号</th>
<th className="px-4 py-2.5">所属维度</th>
<th className="px-4 py-2.5">关键交付要点</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700">
{groups.flatMap((g, gi) =>
g.bullets.map((b, bi) => {
rowNo += 1;
return (
<tr key={`${gi}-${bi}`} className={rowNo % 2 === 0 ? "bg-white/50" : ""}>
<td className="px-3 py-2.5 font-mono font-semibold text-slate-400">{String(rowNo).padStart(2, "0")}</td>
<td className="px-4 py-2.5 font-semibold" style={{ color }}>
{bi === 0 ? g.heading || `维度 ${gi + 1}` : ""}
</td>
<td className="px-4 py-2.5 leading-relaxed">{b}</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
</DraftingCard>
</div>
);
}
// ── 变体二:洞察卡阵(ref ux)—— 描述领读 + 要点卡片网格 ───────────────────────────────
function GaugeShowcase({ seat, groups, color }: { seat: SeatCard; groups: SeatSection[]; color: string }) {
const bullets = allBullets(groups);
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5" style={{ color }} />
<span className="text-xs font-bold text-slate-800">核心洞察聚焦 · 关键论点拆解</span>
</div>
<div
className="mb-5 rounded-md border p-4"
style={{ backgroundColor: `${color}0d`, borderColor: `${color}33` }}
>
<span className="mb-1 block text-[10px] font-bold uppercase tracking-wider text-slate-400">本席位核心论断</span>
<p className="text-xs font-medium leading-relaxed text-slate-700">{seat.description || "(无摘要)"}</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{bullets.map((b, i) => (
<div key={i} className="overflow-hidden rounded-sm border border-slate-200 bg-white">
<div className="h-1 w-full" style={{ backgroundColor: color }} />
<div className="flex gap-2.5 p-3">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] font-bold text-white"
style={{ backgroundColor: color }}
>
{i + 1}
</span>
<p className="text-[11px] leading-relaxed text-slate-600">{b}</p>
</div>
</div>
))}
</div>
</DraftingCard>
);
}
// ── 变体三:双栏对比(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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{panels.map((p, i) => {
const t = tints[i % tints.length];
return (
<div key={i} className={`rounded-md border p-4 ${t.bg} ${t.border}`}>
<h4 className={`mb-3 flex items-center gap-1.5 text-xs font-bold ${t.title}`}>
<GitCompare className="h-3.5 w-3.5" style={{ color }} />
<span className="truncate">{p.heading || `对比维度 ${i + 1}`}</span>
</h4>
<ul className="space-y-3 text-xs leading-relaxed">
{p.bullets.map((b, j) => (
<li key={j} className="flex gap-2">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${t.dot}`} />
<span className="text-slate-600">{b}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
// ── 变体四:清单核对(ref risk)—— 要点可勾选核对台 ───────────────────────────────────
function AuditShowcase({ bullets, color }: { bullets: string[]; color: string }) {
const [done, setDone] = useState<number[]>([]);
const toggle = (i: number) => setDone((p) => (p.includes(i) ? p.filter((x) => x !== i) : [...p, i]));
return (
<DraftingCard hoverEffect={false}>
<div className="mb-4 flex items-baseline justify-between border-b border-slate-100 pb-3">
<span className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-800">
<ClipboardList className="h-4 w-4" style={{ color }} />
要点核对台
</span>
<span className="font-mono text-[10px] text-slate-400">
已确认 {done.length}/{bullets.length}
</span>
</div>
<div className="space-y-2.5">
{bullets.map((b, i) => {
const checked = done.includes(i);
return (
<div
key={i}
onClick={() => 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"
}`}
>
<div
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors ${
checked ? "border-emerald-600 bg-emerald-500 text-white" : "border-slate-300 bg-white"
}`}
>
{checked && <Check className="h-3.5 w-3.5" />}
</div>
<span
className={`text-xs leading-relaxed ${
checked ? "text-slate-400 line-through" : "text-slate-700"
}`}
>
{b}
</span>
</div>
);
})}
</div>
</DraftingCard>
);
}
// ── 变体五:发布声明(ref summary)—— 声明书 + 要点核验网格 + 签署 ─────────────────────
function ReleaseShowcase({ seat, bullets }: { seat: SeatCard; bullets: string[] }) {
const [signed, setSigned] = useState(false);
return (
<div className="space-y-4">
<DraftingCard hoverEffect={false} className="border-sky-200/65 bg-sky-50/40">
<h4 className="mb-2.5 flex items-center gap-1.5 text-xs font-bold text-sky-800">
<Award className="h-4 w-4 text-sky-600" />
研讨结果最终发布声明书 (Executive Release)
</h4>
<p className="text-xs leading-relaxed text-slate-600">{seat.description || "(无总结陈述)"}</p>
{bullets.length > 0 && (
<div className="mt-4 space-y-2 border-t border-sky-100 pt-3">
<span className="block text-[10px] font-bold uppercase tracking-widest text-sky-700">关键结论与调和事项:</span>
<div className="grid grid-cols-1 gap-2 text-[11px] text-slate-600 md:grid-cols-2">
{bullets.map((b, i) => (
<div key={i} className="flex items-start gap-1.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<span className="leading-relaxed">{b}</span>
</div>
))}
</div>
</div>
)}
</DraftingCard>
<div className="flex flex-col items-center justify-center space-y-4 rounded-lg border border-dashed border-slate-200 bg-white/50 p-5 text-center">
<div className="max-w-md">
<LayoutGrid className="mx-auto mb-2 h-8 w-8 text-sky-600" />
<h5 className="text-sm font-bold text-slate-800">总台最终数字签署确认栏</h5>
<p className="mt-1 text-xs text-slate-400">勾选此项即代表会议全席专家一致同意此交付终稿,封盘归档。</p>
</div>
<button
type="button"
onClick={() => setSigned((s) => !s)}
className={`flex items-center gap-2 rounded-sm border px-5 py-2.5 text-xs font-semibold transition-all ${
signed
? "border-emerald-700 bg-emerald-600 text-white hover:bg-emerald-700"
: "border-slate-300 bg-white text-slate-700 hover:border-rose-400 hover:text-rose-600"
}`}
>
{signed ? (
<>
<Check className="h-4 w-4" />
<span>【已电子签发:执委会终审确认】</span>
</>
) : (
<span>确认签名并完成终稿归档发布</span>
)}
</button>
</div>
</div>
);
}
@@ -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 "";
}
}
@@ -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],
);
@@ -0,0 +1,36 @@
/**
* 大屏多页「新窗口打开」的独立全屏宿主页 —— 渲染应用内 React 大屏(`DashboardApp`)。
*
* 浏览器新标签页是一份全新文档,React 数据无法经 props 直接穿过去,故走 localStorage:
* - Step3 在点「新窗口打开」时把当前 `DashboardDataValue` 写进 localStorage(键由 URL `?k=` 带过来);
* - 本页读出该键、解析后注入 `<DashboardApp data={...} />`;读不到则回落 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<DashboardDataValue | undefined>(() => {
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 <DashboardApp data={data} />;
}
@@ -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,并停止订阅。