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:
1 parent
432c12fbac
commit
2fa40c5e96
11 files changed
+496
-61
No files matched your search
@@ -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,并停止订阅。
|
||||
|
||||
Reference in new issue
Block a user