feat(圆桌): Step3 大屏多页模板重构 + 独立 dashboard 组件化

- 新增 roundtable-planning/dashboard 模块化大屏(DashboardApp + 各分析卡片
  组件 + data-context + 类型/样式)
- Step3DashboardMode 接入新 dashboard,report-template 大屏 HTML 模板重写
- useStep3Dashboard 配套调整

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
董宏凯 committed 2026-06-13 15:32:49 +08:00
1 parent 146c8e76f0
commit 432c12fbac
16 files changed
+2642 -321

No files matched your search

@@ -1,20 +1,29 @@
/**
* Step3「结果绘制 · 大屏多页模式(模式二)」—— 与模式一同款交互(2026-06-13 重构)。
* Step3「结果绘制 · 大屏多页」—— 两视图:
*
* 布局/交互对齐模式一(Step3ReportMode):左侧流式对话气泡 + 右侧沙箱(复用
* `Step2SandboxLayout`,大屏 HTML 以 write_file 虚拟 stub 进沙箱 → 代码/预览切换、
* 下载、新窗口打开全部可用)+ 底部修改要求输入框(同线程续轮修订全量 report-json)。
* ① 交互区 (view="chat"):左侧大屏绘制智能体的聊天记录 + 输入框(模型问答改页面),
* 右侧复用 `Step2SandboxLayout` 沙箱「代码预览」实时展示数据抽取过程(report-json
* 边出边以代码态显示)。生成完成自动切到 ②。
* ② 大屏页面 (view="page"):应用内直接渲染参考大屏 React 组件(`DashboardApp`,真实
* 数据经 `adaptReportData` 注入)。顶部按钮:重新绘制(回交互区并重画)、新窗口打开
* (把自包含 HTML 大屏在新标签页打开)、单页看板、返回报告。
*
* 与模式一的差别只在产物管线:模型输出 1-2KB ```report-json,前端模板渲染
* (lib/report-template)—— 快一个数量级、零截断;链路(initReportThread +
* streamMultiAgent)与模式一完全同源。
* 数据抽取/聊天/沙箱链路全部复用既有 `useStep3Dashboard`(流式问答、stub 沙箱、续看),
* 只是把「完成后的产物」从 iframe HTML 预览换成应用内 React 大屏;HTML 沙箱那条路保留,
* 供「新窗口打开」与并行的经典模式使用。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import {
ArrowLeft,
CheckCircle,
Eye,
LayoutDashboard,
ExternalLink,
LayoutPanelTop,
Loader2,
Pause,
RefreshCw,
@@ -22,15 +31,18 @@ import {
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLocalSettings } from "@/core/settings";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { isDashboardHtml, stripReportFence } from "@/roundtable-planning/lib/report-template";
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
interface Step3DashboardModeProps {
selectedModel: string;
@@ -38,7 +50,6 @@ interface Step3DashboardModeProps {
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
savedReport?: DraftStep3Snapshot | null;
/** 页面层常驻的大屏生成流(2026-06-13 提升:切走不断流,进入续看)。 */
dashboard: UseStep3DashboardReturn;
/** 切回经典「单页看板」绘制模式。 */
onSwitchClassic: () => void;
@@ -46,6 +57,14 @@ interface Step3DashboardModeProps {
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";
export function Step3DashboardMode({
selectedModel,
availableModels,
@@ -56,154 +75,115 @@ export function Step3DashboardMode({
onSwitchClassic,
onBack,
}: Step3DashboardModeProps) {
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
dashThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
busy,
html,
start,
busy,
redraw,
ask,
abort,
hydrateSaved,
setPreviewEnabled,
openDashboardPreview,
} = dashboard;
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
// 产物/进行中会立即接管沙箱续看。
// 真实数据 → 大屏数据:优先本轮实时生成,其次草稿快照;都没有则 undefined(用 DEMO)。
const dashboardData = useMemo(() => {
const report = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return report ? adaptReportData(report, formatNow()) : undefined;
}, [dashboard.data, savedReport?.dashboardJson]);
const hasResult = !!dashboardData || !!html;
// 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。
const [view, setView] = useState<DashView>(() => (hasResult ? "page" : "chat"));
// 完成即自动切到大屏页面(含问答修订后的再次完成)。
const prevPhaseRef = useRef(phase);
useEffect(() => {
dashboard.setPreviewEnabled(true);
return () => dashboard.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (phase === "done" && prevPhaseRef.current !== "done") setView("page");
prevPhaseRef.current = phase;
}, [phase]);
// 续看语义:只兜底"从未启动过"(idle);**卸载不再 abort**(切走不断流)。
// 优先 dashboardJson(结构化数据,按当前主题渲染);旧版 marker html 兜底。
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
useEffect(() => {
if (phase !== "idle") return;
if (savedReport?.dashboardJson) {
dashboard.hydrateFromJson(savedReport.dashboardJson);
} else if (savedReport?.html && isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport.html);
} else {
start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
setPreviewEnabled(view === "chat");
return () => setPreviewEnabled(false);
}, [view, setPreviewEnabled]);
const [revision, setRevision] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useAutoScroll(scrollRef, [dialogues]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const text = revision.trim();
if (!text) return;
setRevision("");
ask(text);
// 新窗口打开:把自包含 HTML 大屏(renderDashboardHtml 产物)在新标签页打开。
const handleOpenNewWindow = () => {
if (!html || typeof window === "undefined") return;
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
};
// 气泡显示:report-json 生 JSON 替换成一句人话(对齐 flow-json 的处理)。
const displayDialogues = useMemo(
() =>
dialogues.map((d) => {
const content = d.content ?? "";
if (!content.includes("```report-json")) return d;
return { ...d, content: stripReportFence(content, d.streaming === true) };
}),
[dialogues],
);
const handleRedraw = () => {
setView("chat");
redraw();
};
const isStreaming = busy || dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "大屏数据提炼中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
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.5 flex-wrap">
{/* 顶栏 */}
<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">
<LayoutDashboard className="w-5 h-5 text-primary" />
<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 === "creating" || phase === "streaming") && <Loader2 className="w-3 h-3 animate-spin" />}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
{view === "chat" ? "交互绘制中" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title="切换大屏绘制使用的推理模型"
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary disabled:opacity-50"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
{busy && (
<button
type="button"
onClick={abort}
title="暂停生成"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
暂停
</button>
{view === "page" ? (
<>
<button
type="button"
onClick={handleRedraw}
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"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<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"
>
<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>
)
)}
{html && (
<button
type="button"
onClick={openDashboardPreview}
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"
>
<Eye className="w-3.5 h-3.5 text-slate-500" />
打开大屏预览
</button>
)}
<button
type="button"
onClick={() => redraw()}
disabled={busy}
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"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={onSwitchClassic}
title="切回经典单页看板绘制(模型自由发挥整页 HTML)"
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" />
@@ -220,92 +200,241 @@ export function Step3DashboardMode({
</div>
</div>
{/* 主体:对话 + 沙箱(复用第 2 步分栏,与模式一一致) */}
<div className="flex-1 min-h-0 p-3">
<section className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? dashThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col">
{/* 对话流 */}
<div
ref={scrollRef}
className="custom-scrollbar flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4"
>
{displayDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
<Loader2 className="w-7 h-7 animate-spin text-primary" />
<span className="text-xs font-medium">正在初始化大屏绘制…</span>
</div>
)}
{displayDialogues.map((d) => (
<div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(d.avatarType ?? "blue", "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-2 text-xs font-semibold text-slate-700">
{d.sender}
<span
className={cn(
"rounded-full px-1.5 py-px text-[9.5px] font-bold",
d.streaming
? "bg-primary/10 text-primary"
: "bg-slate-100 text-slate-400",
)}
>
{d.confidence}
</span>
<span className="text-[9.5px] font-mono text-slate-300">{d.time}</span>
</div>
<div className="prose prose-sm max-w-none break-words text-[12.5px] leading-relaxed text-slate-700">
<MarkdownContent>{d.content || "…"}</MarkdownContent>
{d.streaming && (
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse rounded bg-primary align-middle" />
)}
</div>
</div>
</div>
))}
{error && phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-600">
{error}
</div>
)}
</div>
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
{view === "page" ? (
<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>
);
}
{/* 修改要求输入框(与模式一同款交互:随时可发,busy 时先打断再续) */}
<form
onSubmit={handleSubmit}
className="shrink-0 border-t border-slate-100 bg-slate-50/60 px-4 py-3"
>
<div className="flex items-end gap-2">
<textarea
value={revision}
onChange={(e) => setRevision(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
rows={2}
placeholder="输入修改要求,例如:把 KPI 换成成本相关指标 / 风险图改成雷达图 / 增加一个验收阶段…"
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs outline-none transition focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<button
type="submit"
disabled={!revision.trim()}
className="shrink-0 flex h-9 items-center gap-1.5 rounded-xl bg-primary px-4 text-xs font-bold text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="size-3.5" />
发送
</button>
</div>
</form>
// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ──────────────────────────
function DashboardChatPanel({
phase,
dialogues,
busy,
error,
onAsk,
onRedraw,
onPause,
onOpenPreview,
hasResult,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3DashboardReturn["phase"];
dialogues: UseStep3DashboardReturn["dialogues"];
busy: boolean;
error: string | null;
onAsk: (message: string) => void;
onRedraw: () => void;
onPause: () => void;
onOpenPreview: () => void;
hasResult: boolean;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [text, setText] = useState("");
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
</Step2SandboxLayout>
</section>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
数据已就绪,已切换到大屏页面。
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
@@ -0,0 +1,163 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*
* 大屏主视图(源自参考 App.tsx)。2026-06-13 改造:
* - 接受 `data` prop(圆桌真实数据,adaptReportData 产出),用 DashboardDataProvider 注入;
* - 标题/副标题/导航 Tab 由数据动态生成(原为写死的 6 个固定席位);
* - 不传 data 时回落参考 demo(DEMO_DATA)。
*/
import './dashboard.css';
import { useState } from 'react';
import { TabType } from './types';
import {
DashboardDataProvider,
DEMO_DATA,
type DashboardDataValue,
} from './data-context';
import { MetricsHeader } from './components/MetricsHeader';
import { ConsensusPanel } from './components/ConsensusPanel';
import { BrandScores } from './components/BrandScores';
import { RiskDonut } from './components/RiskDonut';
import { SeatDeliveryOverview } from './components/SeatDeliveryOverview';
import { Roadmap } from './components/Roadmap';
import { AnalyticsCenter } from './components/AnalyticsCenter';
import { DetailView } from './components/DetailView';
import { motion, AnimatePresence } from 'motion/react';
import {
Compass, LayoutGrid, Users, Megaphone, ShieldAlert, FileText,
Clock, Activity,
} from 'lucide-react';
const TAB_ICONS = [LayoutGrid, Users, Megaphone, ShieldAlert, FileText, Activity];
export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// 导航 Tab:总览 + 各席位(由数据动态生成,原参考为写死的固定 6 项)。
const tabs: TabType[] = ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
const getTabLabel = (tab: TabType) =>
tab === 'overview' ? '总览' : data.SEAT_CARDS.find((c) => c.id === tab)?.title ?? tab;
const getTabIcon = (tab: TabType) => {
const cls = 'w-4 h-4';
if (tab === 'overview') return <Compass className={cls} />;
const idx = data.SEAT_CARDS.findIndex((c) => c.id === tab);
const Ico = TAB_ICONS[(idx >= 0 ? idx : 0) % TAB_ICONS.length];
return <Ico className={cls} />;
};
return (
<DashboardDataProvider value={data}>
<div className="min-h-screen bg-white font-sans text-slate-800 relative pb-16 [background-image:radial-gradient(#f1f5f9_1.2px,transparent_1.2px)] [background-size:20px_20px]">
{/* 顶部彩条 */}
<div className="h-1 bg-gradient-to-r from-rose-600 via-amber-500 to-sky-600 w-full" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 space-y-6">
{/* Header */}
<header className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-rose-100">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black tracking-tight font-display flex items-center gap-1.5 text-rose-900">
{data.meta.title}
</h1>
</div>
{data.meta.subtitle && (
<p className="text-xs sm:text-sm text-slate-500 font-sans tracking-wide">
{data.meta.subtitle}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white border border-slate-200 shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
</header>
{/* 导航 Tab */}
<nav className="flex items-center justify-start overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex space-x-1.5 bg-slate-100/80 backdrop-blur-xs p-1 border border-slate-200 rounded-md shrink-0">
{tabs.map((tab) => {
const active = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-sm transition-all duration-200 cursor-pointer whitespace-nowrap ${
active
? 'bg-white text-rose-700 shadow-sm border border-rose-100 font-bold scale-[1.01]'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{getTabIcon(tab)}
<span>{getTabLabel(tab)}</span>
</button>
);
})}
</div>
</nav>
{/* Body */}
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
<motion.div
key="overview-dashboard"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
className="space-y-6"
>
<MetricsHeader />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<div className="lg:col-span-5 h-full">
<ConsensusPanel onExploreClick={() => setActiveTab(tabs[tabs.length - 1] ?? 'overview')} />
</div>
<div className="lg:col-span-4 h-full">
<BrandScores />
</div>
<div className="lg:col-span-3 h-full">
<RiskDonut />
</div>
</div>
<AnalyticsCenter />
<SeatDeliveryOverview onSeatSelect={setActiveTab} activeSeatId={activeTab} />
<Roadmap />
</motion.div>
) : (
<motion.div
key="seat-details"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
>
<DetailView
activeTab={activeTab}
onBackToOverview={() => setActiveTab('overview')}
onNavigate={setActiveTab}
/>
</motion.div>
)}
</AnimatePresence>
<footer className="pt-8 border-t border-slate-200/60 mt-12 text-center text-xs text-slate-400 font-sans flex flex-col sm:flex-row items-center justify-between gap-4">
<span>圆桌多智能体研讨 · 方案大屏</span>
<div className="flex gap-4 font-mono text-[10px] tracking-wider uppercase">
<span>Engineering Grid: True</span>
<span>Roundtable Console</span>
</div>
</footer>
</div>
</div>
</DashboardDataProvider>
);
}
@@ -0,0 +1,286 @@
/**
* 数据研判中心 —— 读 context.CHARTS(最多 3 个),按 type 通用渲染(line/bar/donut/radar)。
* 参考项目原是写死的 3 张新能源图表;这里改成数据驱动,SVG 数学沿用参考/report-template
* 的画法(折线带渐变面积、环形分段、雷达多边形、水平条),雷达/环形的标签走 HTML 旁注
* 避免 SVG 文字溢出。
*/
import { useState } from "react";
import { useDashboardData, ACCENTS } from "../data-context";
import { DashChart } from "../types";
import { DraftingCard } from "./DraftingCard";
import { Activity, TrendingUp, PieChart, Award, BarChart3 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
const PRIMARY = ACCENTS[0];
// ── 折线(趋势):单序列,归一化到画布 ──────────────────────────────────────────────
function LineChartSvg({ items }: { items: DashChart["items"] }) {
const [hover, setHover] = useState<number | null>(null);
const W = 460;
const H = 180;
const padL = 36;
const padR = 16;
const padT = 16;
const padB = 30;
const data = items.slice(0, 10);
const max = Math.max(...data.map((i) => i.value), 1);
const min = Math.min(...data.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / Math.max(1, data.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = data.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
return (
<div className="relative w-full h-[180px] bg-slate-50/30 rounded border border-slate-100 p-1 flex items-center justify-center">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-full">
{[0, 0.25, 0.5, 0.75, 1].map((k, i) => {
const gy = padT + k * (H - padT - padB);
return <line key={i} x1={padL} y1={gy} x2={W - padR} y2={gy} stroke="#F1F5F9" strokeWidth="1.2" strokeDasharray="2 2" />;
})}
<polygon
points={`${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}`}
fill={`${PRIMARY}14`}
/>
<polyline points={pts} fill="none" stroke={PRIMARY} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
{data.map((it, i) => {
const isH = hover === i;
return (
<g key={i}>
{isH && <line x1={x(i)} y1={padT} x2={x(i)} y2={H - padB} stroke="#E2E8F0" strokeWidth="1" />}
<circle
cx={x(i)}
cy={y(it.value)}
r={isH ? 5.5 : 3.5}
fill="#fff"
stroke={PRIMARY}
strokeWidth="2"
className="cursor-pointer transition-all"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
/>
<text x={x(i)} y={H - 8} textAnchor="middle" className={`text-[8.5px] font-mono ${isH ? "fill-slate-900" : "fill-slate-400"}`}>
{it.label}
</text>
</g>
);
})}
</svg>
<AnimatePresence>
{hover !== null && (
<motion.div
initial={{ opacity: 0, scale: 0.93, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.93, y: 10 }}
className="absolute bg-slate-950/90 text-white rounded p-2 text-[10px] space-y-1 z-20 pointer-events-none border border-slate-700 shadow-lg left-1/2 -translate-x-1/2 bottom-12"
>
<div className="font-bold text-amber-400 border-b border-slate-800 pb-1">{data[hover].label}</div>
<div className="font-mono font-bold text-sky-400">{data[hover].value}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ── 环形(占比) ───────────────────────────────────────────────────────────────────
function DonutChartSvg({ items }: { items: DashChart["items"] }) {
const [hover, setHover] = useState<number | null>(null);
const data = items.slice(0, 6);
const total = data.reduce((s, i) => s + Math.max(0, i.value), 0) || 1;
const R = 50;
const SW = 14;
const C = 2 * Math.PI * R;
let acc = 0;
const segs = data.map((it, idx) => {
const len = (Math.max(0, it.value) / total) * C;
const seg = { len, offset: -acc, color: ACCENTS[idx % ACCENTS.length], idx };
acc += len;
return seg;
});
return (
<div className="flex flex-row items-center justify-between gap-2.5 h-[180px]">
<div className="relative w-36 h-36 flex items-center justify-center shrink-0">
<svg viewBox="0 0 140 140" className="w-full h-full select-none">
<circle cx="70" cy="70" r={R} fill="none" stroke="#E2E8F0" strokeWidth={SW} transform="rotate(-90 70 70)" />
{segs.map((s) => (
<circle
key={s.idx}
cx="70"
cy="70"
r={R}
fill="none"
stroke={s.color}
strokeWidth={hover === s.idx ? SW + 3 : SW}
strokeDasharray={`${s.len.toFixed(2)} ${(C - s.len).toFixed(2)}`}
strokeDashoffset={s.offset.toFixed(2)}
transform="rotate(-90 70 70)"
className="cursor-pointer transition-all duration-300"
onMouseEnter={() => setHover(s.idx)}
onMouseLeave={() => setHover(null)}
/>
))}
<text x="70" y="68" textAnchor="middle" className="fill-slate-800 text-[22px] font-extrabold font-mono">
{data.length}
</text>
<text x="70" y="82" textAnchor="middle" className="fill-slate-400 text-[9px]">
类
</text>
</svg>
</div>
<div className="flex-1 space-y-2 max-h-[170px] overflow-y-auto pr-1">
{data.map((item, idx) => (
<div
key={item.label}
className={`flex items-center justify-between p-1 rounded-sm cursor-pointer transition-all border-l-[3px] ${
hover === idx ? "bg-slate-50 font-bold" : "border-transparent"
}`}
style={{ borderLeftColor: hover === idx ? ACCENTS[idx % ACCENTS.length] : undefined }}
onMouseEnter={() => setHover(idx)}
onMouseLeave={() => setHover(null)}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: ACCENTS[idx % ACCENTS.length] }} />
<span className="text-[10px] text-slate-700 truncate">{item.label}</span>
</div>
<span className="text-[10.5px] font-bold font-mono text-slate-900">{item.value}</span>
</div>
))}
</div>
</div>
);
}
// ── 雷达(多维):标签 HTML 旁注 ─────────────────────────────────────────────────────
function RadarChartSvg({ items }: { items: DashChart["items"] }) {
const data = items.slice(0, 8);
const n = Math.max(data.length, 3);
const cx = 100;
const cy = 100;
const R = 65;
const max = Math.max(...data.map((i) => i.value), 1);
const pt = (i: number, r: number): [number, number] => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
const ptS = (i: number, r: number) => pt(i, r).map((v) => v.toFixed(1)).join(",");
return (
<div className="flex items-center gap-2">
<div className="relative w-40 h-40 flex items-center justify-center p-1 bg-slate-50/40 rounded border border-slate-100 shrink-0">
<svg viewBox="0 0 200 200" className="w-40 h-40">
{[0.25, 0.5, 0.75, 1].map((k, i) => (
<polygon
key={i}
points={Array.from({ length: n }, (_, idx) => ptS(idx, R * k)).join(" ")}
fill="none"
stroke="#E2E8F0"
strokeWidth="1"
/>
))}
{Array.from({ length: n }, (_, i) => {
const [px, py] = pt(i, R);
return <line key={i} x1={cx} y1={cy} x2={px.toFixed(1)} y2={py.toFixed(1)} stroke="#E2E8F0" strokeWidth="1" />;
})}
<polygon
points={data.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}
fill={`${PRIMARY}26`}
stroke={PRIMARY}
strokeWidth="2"
/>
{data.map((it, i) => {
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
return <circle key={i} cx={px.toFixed(1)} cy={py.toFixed(1)} r="2.6" fill={PRIMARY} />;
})}
</svg>
</div>
<div className="flex-1 space-y-1 text-[10px]">
{data.map((it, i) => (
<div key={i} className="flex justify-between items-center gap-1 border-b border-slate-50 pb-0.5">
<span className="text-slate-500 truncate">{it.label}</span>
<b className="font-mono text-slate-800">{it.value}</b>
</div>
))}
</div>
</div>
);
}
// ── 水平条(排名) ──────────────────────────────────────────────────────────────────
function BarChartSvg({ items }: { items: DashChart["items"] }) {
const data = items.slice(0, 8);
const max = Math.max(...data.map((i) => i.value), 1);
return (
<div className="space-y-2.5 py-1">
{data.map((it, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10.5px]">
<span className="text-slate-600 truncate">{it.label}</span>
<b className="font-mono text-slate-900">{it.value}</b>
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: ACCENTS[i % ACCENTS.length] }}
initial={{ width: 0 }}
animate={{ width: `${(it.value / max) * 100}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
</div>
</div>
))}
</div>
);
}
function ChartCard({ chart }: { chart: DashChart }) {
const Icon = chart.type === "line" ? TrendingUp : chart.type === "donut" ? PieChart : chart.type === "radar" ? Award : BarChart3;
return (
<DraftingCard className="flex flex-col">
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5">
<div className="p-1.5 bg-rose-50/80 border border-rose-100 rounded-sm text-rose-600">
<Icon className="w-3.5 h-3.5" />
</div>
<h4 className="text-xs font-black text-slate-800 tracking-wide font-sans">{chart.title || "图表"}</h4>
</div>
{chart.type === "line" ? (
<LineChartSvg items={chart.items} />
) : chart.type === "donut" ? (
<DonutChartSvg items={chart.items} />
) : chart.type === "radar" ? (
<RadarChartSvg items={chart.items} />
) : (
<BarChartSvg items={chart.items} />
)}
</div>
</DraftingCard>
);
}
export function AnalyticsCenter() {
const { CHARTS } = useDashboardData();
if (CHARTS.length === 0) return null;
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100/90">
<h3 className="text-base font-extrabold text-slate-800 tracking-tight font-sans flex items-center gap-2">
<Activity className="w-5 h-5 text-rose-600 animate-pulse" />
数据研判中心
<span className="text-xs font-normal text-slate-400 font-sans hidden sm:inline">多维度数据可视化研判</span>
</h3>
<span className="text-[10px] px-2 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 font-mono rounded">
STATUS: REAL-TIME CONSOLE
</span>
</div>
<div className={`grid grid-cols-1 gap-5 ${CHARTS.length >= 3 ? "lg:grid-cols-3" : CHARTS.length === 2 ? "lg:grid-cols-2" : ""}`}>
{CHARTS.map((chart, i) => (
<ChartCard key={i} chart={chart} />
))}
</div>
</div>
);
}
@@ -0,0 +1,249 @@
/**
* 多维核心竞争力排行榜 —— 读 context.BRAND_SCORES(真实数据由 bar/radar 图表或各席位
* 达成度推导)。marketShare/strengths/vehicles 真实数据常缺省,按存在性条件渲染:
* - 仅当有任一 brand 带 marketShare 时显示“综合评分/市场份额”切换与份额列;
* - 展开抽屉里 strengths/vehicles 为空则给占位说明。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { BrandScore } from "../types";
import { motion, AnimatePresence } from "motion/react";
import { Trophy, ChevronRight, Zap, Car } from "lucide-react";
interface BrandScoresProps {
onBrandSelect?: (brand: BrandScore) => void;
}
const shareNum = (s?: string) => (s ? parseFloat(s.replace("%", "")) || 0 : 0);
export function BrandScores({ onBrandSelect }: BrandScoresProps) {
const { BRAND_SCORES } = useDashboardData();
const hasShare = BRAND_SCORES.some((b) => !!b.marketShare);
const [hoveredBrand, setHoveredBrand] = useState<string | null>(null);
const [selectedBrand, setSelectedBrand] = useState<BrandScore | null>(BRAND_SCORES[0] ?? null);
const [viewType, setViewType] = useState<"score" | "share">("score");
if (BRAND_SCORES.length === 0) {
return (
<DraftingCard className="h-full flex items-center justify-center" id="brand-scores-card">
<span className="text-xs text-slate-400">暂无可对比的竞争力排行数据。</span>
</DraftingCard>
);
}
const handleBrandClick = (brand: BrandScore) => {
setSelectedBrand(selectedBrand?.name === brand.name ? null : brand);
onBrandSelect?.(brand);
};
const effectiveView = hasShare ? viewType : "score";
const maxShare = Math.max(...BRAND_SCORES.map((b) => shareNum(b.marketShare)), 1);
const sortedBrands = [...BRAND_SCORES].sort((a, b) =>
effectiveView === "score" ? b.score - a.score : shareNum(b.marketShare) - shareNum(a.marketShare),
);
const getRankBadge = (rankIndex: number) => {
if (rankIndex === 0)
return (
<div className="relative flex items-center justify-center w-7 h-7 rounded-sm bg-amber-50 text-amber-700 font-mono text-xs font-bold border border-amber-200 ring-2 ring-amber-100/50">
01
<Trophy className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500 fill-amber-500" />
</div>
);
if (rankIndex === 1)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50 text-slate-600 font-mono text-xs font-bold border border-slate-200 ring-2 ring-slate-100/50">
02
</div>
);
if (rankIndex === 2)
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-rose-50 text-rose-700 font-mono text-xs font-bold border border-rose-200 ring-2 ring-rose-100/30">
03
</div>
);
return (
<div className="flex items-center justify-center w-7 h-7 rounded-sm bg-slate-50/80 text-slate-400 font-mono text-xs font-medium border border-slate-200">
{String(rankIndex + 1).padStart(2, "0")}
</div>
);
};
return (
<DraftingCard className="h-full flex flex-col justify-between" id="brand-scores-card">
<div>
<div className="flex flex-col md:flex-row md:items-center justify-between pb-4 border-b border-rose-100/85 mb-5 gap-3">
<div className="flex items-center gap-2">
<div className="p-1 px-2 bg-rose-50 border border-rose-200 rounded text-rose-700">
<Trophy className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-black text-slate-900 tracking-tight font-sans">多维核心竞争力排行榜</h3>
<p className="text-[10px] text-slate-400 font-sans tracking-wide">多角度研讨加权估算指数</p>
</div>
</div>
{hasShare && (
<div className="flex bg-slate-50 border border-slate-200/60 p-0.5 rounded-sm select-none shrink-0 self-start md:self-auto">
{(["score", "share"] as const).map((vt) => (
<button
key={vt}
onClick={() => setViewType(vt)}
className={`px-2.5 py-1 text-[11px] font-bold rounded-xs cursor-pointer transition-all ${
viewType === vt ? "bg-white text-rose-700 border border-slate-200/40" : "text-slate-500 hover:text-slate-800"
}`}
>
{vt === "score" ? "综合评分" : "市场份额"}
</button>
))}
</div>
)}
</div>
<div className="space-y-4">
{sortedBrands.map((brand, index) => {
const isSelected = selectedBrand?.name === brand.name;
const currentPercent =
effectiveView === "score" ? brand.score : (shareNum(brand.marketShare) / maxShare) * 100;
return (
<div
key={brand.name}
className={`group rounded-sm border transition-all duration-300 p-2.5 cursor-pointer relative ${
isSelected
? "bg-gradient-to-b from-rose-50/15 to-white border-rose-300 shadow-sm ring-1 ring-rose-200/40"
: "bg-white border-slate-200 hover:bg-slate-50/40 hover:border-slate-300"
}`}
onClick={() => handleBrandClick(brand)}
onMouseEnter={() => setHoveredBrand(brand.name)}
onMouseLeave={() => setHoveredBrand(null)}
>
{isSelected && <div className="absolute left-0 top-0 bottom-0 w-[3px] bg-rose-600 rounded-l-xs" />}
<div className="flex items-center gap-3">
{getRankBadge(index)}
<div
className="flex items-center justify-center w-8 h-8 rounded-sm font-extrabold text-xs select-none shrink-0 border"
style={{ backgroundColor: `${brand.color}15`, color: brand.color, borderColor: `${brand.color}40` }}
>
{brand.name.substring(0, 1)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1 text-xs">
<span className="font-extrabold text-slate-800 flex items-center gap-1.5 group-hover:text-rose-950 transition-colors truncate">
{brand.name}
{brand.score >= 85 && (
<span className="text-[9px] bg-amber-50 border border-amber-200 text-amber-700 px-1 rounded-xs font-mono shrink-0">
LEADING
</span>
)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{hasShare && (
<span className="text-[10px] text-slate-400 font-mono">
{effectiveView === "score" ? `份额: ${brand.marketShare ?? "—"}` : `指数: ${brand.score}`}
</span>
)}
<span className="font-bold text-slate-900 font-mono text-sm tracking-tight">
{effectiveView === "score" ? brand.score : brand.marketShare}
</span>
</div>
</div>
<div className="w-full h-2.5 bg-slate-100 rounded-sm overflow-hidden relative flex items-center p-0.5 border border-slate-200/50">
<div className="absolute inset-0 flex justify-between pointer-events-none px-1">
{[...Array(10)].map((_, i) => (
<div key={i} className="w-[1.2px] h-full bg-slate-200/80 last:hidden" />
))}
</div>
<motion.div
className="h-full rounded-xs relative"
style={{ background: `linear-gradient(90deg, ${brand.color}b0 0%, ${brand.color} 100%)` }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(0, Math.min(100, currentPercent))}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
<div className="absolute inset-x-0 top-0 h-[40%] bg-white/10 rounded-sm" />
</motion.div>
</div>
</div>
<ChevronRight
className={`w-3.5 h-3.5 text-slate-400 group-hover:text-rose-600 transition-all ${
isSelected ? "rotate-90 text-rose-500" : ""
}`}
/>
</div>
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="pt-3.5 mt-2.5 border-t border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-3.5">
<div className="bg-slate-50/80 p-2.5 rounded-sm border border-slate-200/50">
<div className="flex items-center gap-1.5 mb-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<span className="text-[10px] font-bold text-slate-700 tracking-wider">竞争优势 / 亮点</span>
</div>
{brand.strengths && brand.strengths.length > 0 ? (
<ul className="space-y-1.5">
{brand.strengths.map((str, i) => (
<li key={i} className="text-[11px] text-slate-600 flex items-start gap-1">
<span className="text-emerald-500 font-bold shrink-0 mt-0.5">✓</span>
<span className="leading-tight">{str}</span>
</li>
))}
</ul>
) : (
<span className="text-[10px] text-slate-400 italic">综合指数 {brand.score} 分</span>
)}
</div>
<div className="bg-slate-50/80 p-2.5 rounded-sm border border-slate-200/50 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 mb-2">
<Car className="w-3.5 h-3.5 text-sky-500" />
<span className="text-[10px] font-bold text-slate-700 tracking-wider">代表性产物 / 车型</span>
</div>
{brand.vehicles && brand.vehicles.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-1">
{brand.vehicles.map((v, i) => (
<span
key={i}
className="text-[10px] px-2 py-0.5 bg-white border border-slate-200 text-slate-700 rounded-sm font-semibold"
>
{v}
</span>
))}
</div>
) : (
<span className="text-[10px] text-slate-400 italic block mt-1">详情见对应席位专页。</span>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
<div className="mt-6 border-t border-dashed border-slate-200/85 pt-3 text-[10px] text-slate-400 flex items-center justify-between gap-2">
<span className="italic">* 排序基于多维综合指标评议。</span>
<span className="px-1.5 py-0.5 bg-slate-50 border border-slate-200 rounded-xs font-mono text-[9px] text-slate-500">
AUTO CO-RANK
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,60 @@
/**
* 总控最终共识 —— 读 context.consensus(headline 进玫瑰红竖条摘要,points 列表,
* 每条按序号循环配图标/色,替换参考里写死的 5 条新能源结论)。
*/
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { Target, CheckCircle2, AlertCircle, HelpCircle, Activity } from "lucide-react";
interface ConsensusPanelProps {
onExploreClick?: () => void;
}
const POINT_ICONS = [Target, Activity, CheckCircle2, AlertCircle, HelpCircle];
const POINT_COLORS = ["text-rose-600", "text-blue-600", "text-emerald-600", "text-amber-600", "text-sky-600"];
export function ConsensusPanel({ onExploreClick }: ConsensusPanelProps) {
const { consensus } = useDashboardData();
return (
<DraftingCard className="h-full flex flex-col justify-between" id="consensus-panel">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100">
<div className="w-2.5 h-2.5 bg-rose-600 rounded-xs animate-pulse" />
<h2 className="text-lg font-bold text-slate-800 tracking-tight font-sans">总控最终共识</h2>
</div>
{consensus.headline && (
<p className="mt-4 text-sm font-semibold text-slate-700 leading-relaxed bg-rose-50/40 p-3.5 rounded-sm border-l-2 border-rose-500">
{consensus.headline}
</p>
)}
<ul className="mt-5 space-y-4">
{consensus.points.map((pt, idx) => {
const Icon = POINT_ICONS[idx % POINT_ICONS.length];
return (
<li key={idx} className="flex gap-3 text-xs leading-relaxed group">
<Icon className={`w-4.5 h-4.5 mt-0.5 shrink-0 ${POINT_COLORS[idx % POINT_COLORS.length]}`} />
<div className="text-slate-600 group-hover:text-slate-900 transition-colors">{pt}</div>
</li>
);
})}
</ul>
</div>
<div className="mt-6 pt-4 border-t border-slate-100 flex justify-between items-center bg-slate-50/50 -mx-5 -mb-5 px-5 py-4 rounded-b-sm">
<span className="text-[11px] text-slate-400 font-mono tracking-wider uppercase">
Roundtable Final Consensus
</span>
<button
onClick={onExploreClick}
className="text-xs text-rose-600 hover:text-rose-700 font-semibold cursor-pointer flex items-center gap-1 group transition-all"
>
查看研讨详情
<span className="group-hover:translate-x-1 transition-transform">→</span>
</button>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,213 @@
/**
* 席位专页 —— 读 context.SEAT_CARDS 按 activeTab 取当前席位,数据驱动渲染:
* - 左栏:使命概述 + 结构化小节(sections);
* - 右栏:席位评定量化得分(metrics)+ 风险(risks)+ 席位依赖双向 chips(dependsOn /
* 反查谁承接本席位)+ 协同席位推荐。
* 参考项目原本是每个固定 tab 写死一套交互沙盘,真实数据下改为通用结构化展示。
*/
import { useDashboardData, ACCENTS } from "../data-context";
import { TabType, SeatCard } from "../types";
import { DraftingCard } from "./DraftingCard";
import { Settings, ShieldAlert } from "lucide-react";
interface DetailViewProps {
activeTab: TabType;
onBackToOverview?: () => void;
/** 跳到其它席位(依赖 chips / 协同推荐)。缺省回落到返回总览。 */
onNavigate?: (id: TabType) => void;
}
const RISK_STYLE = {
high: "border-red-200 bg-red-50/30 text-red-800",
medium: "border-amber-200 bg-amber-50/30 text-amber-800",
low: "border-emerald-200 bg-emerald-50/30 text-emerald-800",
} as const;
const RISK_LABEL = { high: "高风险阻碍点", medium: "中风险偏离项", low: "低风险关注项" } as const;
export function DetailView({ activeTab, onBackToOverview, onNavigate }: DetailViewProps) {
const { SEAT_CARDS } = useDashboardData();
const seat = SEAT_CARDS.find((s) => s.id === activeTab);
const go = (id: TabType) => (onNavigate ? onNavigate(id) : onBackToOverview?.());
if (!seat) {
return <div className="p-8 text-center text-slate-500">加载席位数据失败,请返回总览页。</div>;
}
const idx = SEAT_CARDS.findIndex((s) => s.id === seat.id);
const color = ACCENTS[(idx >= 0 ? idx : 0) % ACCENTS.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);
const supports = SEAT_CARDS.filter((s) => s.dependsOn.includes(seat.id));
const recommended = SEAT_CARDS.filter((s) => s.id !== seat.id).slice(0, 3);
return (
<div className="space-y-6">
{/* 顶栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-3 border-b border-slate-200">
<div className="flex items-center gap-3">
<button
onClick={onBackToOverview}
className="text-xs text-slate-400 hover:text-rose-600 transition-colors font-medium cursor-pointer border border-slate-200 bg-white px-2.5 py-1.5 rounded-sm"
>
← 返回总览研讨看盘
</button>
<div className="h-4 w-[1px] bg-slate-200 hidden sm:block" />
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-bold text-slate-900 tracking-tight font-sans">{seat.title} · 席位专页</h2>
{seat.completeness > 0 && (
<span className="text-[10px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1.5 rounded-sm font-sans">
达成度: {seat.completeness}%
</span>
)}
</div>
{seat.analyst && <span className="text-xs text-slate-400 font-sans block mt-0.5">席任分析官:{seat.analyst}</span>}
</div>
</div>
<span className="text-xs font-mono text-slate-400 uppercase tracking-widest hidden md:inline">
Seat node: {String(seat.id).toUpperCase()}
</span>
</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>
))
) : (
<DraftingCard hoverEffect={false}>
<p className="text-xs text-slate-400 text-center py-4">该席位暂无结构化小节内容。</p>
</DraftingCard>
)}
</div>
{/* 右栏:得分 + 风险 + 依赖 + 推荐 */}
<div className="space-y-6">
{seat.metrics.length > 0 && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider">席位评定量化得分</h4>
</div>
<div className="space-y-4">
{seat.metrics.map((met, i) => (
<div key={i} className="p-4 bg-slate-50 border border-slate-100 rounded-md">
<div className="flex justify-between items-baseline mb-2">
<span className="text-[11px] text-slate-500">{met.label}</span>
<span className="text-2xl font-black font-mono tracking-tight" style={{ color }}>
{met.value}
<small className="text-xs font-normal text-slate-400 ml-0.5">分</small>
</span>
</div>
<div className="w-full h-1.5 bg-slate-200/70 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${met.value}%`, backgroundColor: color }} />
</div>
</div>
))}
</div>
</DraftingCard>
)}
{seat.risks.length > 0 && (
<DraftingCard hoverEffect={false} className="border-amber-200 bg-amber-50/5">
<div className="flex items-center gap-1.5 pb-3 border-b border-amber-100 mb-4">
<ShieldAlert className="w-4 h-4 text-amber-600" />
<h4 className="text-xs font-bold text-amber-800 uppercase tracking-wider">此专项下属风险与关注点</h4>
</div>
<div className="space-y-3">
{seat.risks.map((risk) => (
<div key={risk.id} className={`p-3 rounded border text-xs text-left leading-relaxed ${RISK_STYLE[risk.level]}`}>
<div className="flex items-center gap-1.5 mb-1 font-bold">
<span
className={`w-1.5 h-1.5 rounded-full ${
risk.level === "high" ? "bg-red-500 animate-ping" : risk.level === "low" ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
<span>{RISK_LABEL[risk.level]}</span>
</div>
{risk.text}
</div>
))}
</div>
</DraftingCard>
)}
{(deps.length > 0 || supports.length > 0) && (
<DraftingCard hoverEffect={false}>
<div className="pb-3 border-b border-rose-100 mb-4">
<h4 className="text-xs font-bold text-slate-800 uppercase tracking-wider">席位依赖关系</h4>
</div>
<div className="flex flex-wrap gap-2">
{deps.map((d) => (
<button
key={d.id}
onClick={() => go(d.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-rose-400 text-rose-600 rounded-sm hover:bg-rose-50 transition-colors"
>
依赖 → {d.title}
</button>
))}
{supports.map((s) => (
<button
key={s.id}
onClick={() => go(s.id)}
className="text-[11px] font-semibold px-2.5 py-1 border border-dashed border-sky-400 text-sky-600 rounded-sm hover:bg-sky-50 transition-colors"
>
{s.title} ← 承接本席位
</button>
))}
</div>
</DraftingCard>
)}
{recommended.length > 0 && (
<DraftingCard hoverEffect={false} className="bg-slate-50/50">
<div className="pb-2.5 border-b border-slate-200 mb-3.5">
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-wider">研讨协同席位推荐</h4>
</div>
<div className="space-y-2">
{recommended.map((other) => (
<button
key={other.id}
onClick={() => go(other.id)}
className="w-full p-2 bg-white hover:bg-slate-50 border border-slate-200 rounded cursor-pointer flex justify-between items-center text-xs text-slate-700 hover:text-rose-600 hover:border-slate-300 transition-all"
>
<span className="font-semibold truncate">{other.title}</span>
<span className="text-[10px] text-slate-400 shrink-0">点击切出 →</span>
</button>
))}
</div>
</DraftingCard>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
/**
* 蓝图工程图风卡片(源自参考项目,原样保留):白底 + 四角玫瑰红角标 + 极淡绘图网格 overlay。
*/
import type React from "react";
interface DraftingCardProps {
children: React.ReactNode;
className?: string;
id?: string;
hoverEffect?: boolean;
onClick?: () => void;
}
export function DraftingCard({
children,
className = "",
id,
hoverEffect = true,
onClick,
}: DraftingCardProps) {
return (
<div
id={id || undefined}
onClick={onClick}
className={`relative bg-white rounded-sm p-5 border border-slate-200/80 transition-all duration-300 shadow-[0_3px_10px_rgba(15,23,42,0.04)] ${
hoverEffect
? "hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] hover:-translate-y-[2px] hover:border-slate-300"
: ""
} ${className}`}
>
{/* 左上角标(品牌正红 #F5222D,对齐设计原图) */}
<div
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 -mt-[1px] -ml-[1px]"
style={{ borderColor: "#F5222D" }}
/>
{/* 右下角标 */}
<div
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 -mb-[1px] -mr-[1px]"
style={{ borderColor: "#F5222D" }}
/>
{/* 极淡绘图网格 overlay */}
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,white,rgba(255,255,255,0.6))] pointer-events-none rounded-sm opacity-30" />
<div className="relative z-10">{children}</div>
</div>
);
}
@@ -0,0 +1,59 @@
/**
* 顶部 KPI 带 —— 读 context.METRIC_DATA(参考原本写死 6 个 + 按 id 配图标,
* 改成按序号循环取图标 + 用十六进制色 inline 着色,适配任意数量真实 KPI)。
*/
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { TrendingUp, Users, AlertTriangle, Layers, Award, Calendar } from "lucide-react";
interface MetricsHeaderProps {
onCardClick?: (metricId: string) => void;
}
const ICONS = [Users, TrendingUp, AlertTriangle, Layers, Award, Calendar];
export function MetricsHeader({ onCardClick }: MetricsHeaderProps) {
const { METRIC_DATA } = useDashboardData();
if (METRIC_DATA.length === 0) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{METRIC_DATA.map((metric, idx) => {
const Icon = ICONS[idx % ICONS.length];
return (
<DraftingCard
key={metric.id}
className="flex flex-col justify-between py-4 px-5 min-h-[115px] cursor-pointer"
onClick={() => onCardClick?.(metric.id)}
>
<div className="flex justify-between items-start gap-2">
<span className="text-xs text-slate-500 font-sans tracking-wide font-medium">
{metric.label}
</span>
<Icon className="w-5 h-5 shrink-0" style={{ color: metric.color }} />
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-3xl font-bold text-slate-800 tracking-tight font-sans">
{metric.value}
</span>
{metric.unit && <span className="text-xs text-slate-400 font-medium ml-0.5">{metric.unit}</span>}
{metric.trend && (
<span
className={`inline-flex items-center text-xs font-semibold px-1 rounded-sm ml-2 ${
metric.trend.type === "up"
? "text-emerald-600 bg-emerald-50"
: "text-rose-600 bg-rose-50"
}`}
>
{metric.trend.type === "up" ? "▲" : "▼"}
</span>
)}
</div>
</DraftingCard>
);
})}
</div>
);
}
@@ -0,0 +1,115 @@
/**
* 核心风险分类分布 —— 读 context.RISK_CATEGORIES(真实数据由各席位风险按 高/中/低
* 聚合)+ riskTotal(镂空中心大数字)。无风险时给友好空态。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { RiskCategory } from "../types";
import { ShieldAlert } from "lucide-react";
interface RiskDonutProps {
onCategoryHover?: (category: RiskCategory | null) => void;
}
export function RiskDonut({ onCategoryHover }: RiskDonutProps) {
const { RISK_CATEGORIES, riskTotal } = useDashboardData();
const [activeCategory, setActiveCategory] = useState<RiskCategory | null>(null);
const handleMouseEnter = (cat: RiskCategory) => {
setActiveCategory(cat);
onCategoryHover?.(cat);
};
const handleMouseLeave = () => {
setActiveCategory(null);
onCategoryHover?.(null);
};
const empty = RISK_CATEGORIES.length === 0;
return (
<DraftingCard className="h-full flex flex-col justify-between" id="risk-donut-card">
<div>
<div className="flex items-center gap-2 pb-4 border-b border-rose-100 mb-5">
<ShieldAlert className="w-5 h-5 text-rose-600" />
<h3 className="text-sm font-bold text-slate-800 tracking-tight font-sans">核心风险分类分布</h3>
</div>
{empty ? (
<div className="flex flex-col items-center justify-center gap-2 text-slate-400 py-12">
<ShieldAlert className="w-8 h-8 text-emerald-400" />
<span className="text-xs">本轮研讨未识别到显式风险项。</span>
</div>
) : (
<>
<div className="flex flex-row items-center gap-5 py-2">
{/* 风险总数(去掉百分比圆环,保留大数字,对齐设计原图) */}
<div className="flex flex-col items-center justify-center shrink-0 px-3 py-2 border-r border-slate-100">
<span className="text-4xl font-extrabold text-slate-800 font-mono leading-none">
{activeCategory ? `${activeCategory.percentage}%` : riskTotal}
</span>
<span className="text-[10px] text-slate-400 font-medium tracking-wider mt-1.5 text-center max-w-[60px] leading-tight">
{activeCategory ? activeCategory.name : "项核心风险"}
</span>
</div>
{/* 图例 */}
<div className="space-y-3 flex-1 min-w-0">
{RISK_CATEGORIES.map((cat) => {
const isSelected = activeCategory?.name === cat.name;
return (
<div
key={cat.name}
className={`flex items-start gap-2.5 p-1.5 rounded-xs transition-colors cursor-pointer ${
isSelected ? "bg-slate-50 border-l-2" : "border-l-2 border-transparent"
}`}
style={{ borderLeftColor: isSelected ? cat.color : undefined }}
onMouseEnter={() => handleMouseEnter(cat)}
onMouseLeave={handleMouseLeave}
>
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0" style={{ backgroundColor: cat.color }} />
<div className="text-left">
<div className="flex justify-between items-baseline gap-2">
<span className="text-xs font-semibold text-slate-800">{cat.name}</span>
<span className="text-xs font-bold text-slate-900 font-mono">{cat.value}%</span>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 悬停细项抽屉 */}
<div className="mt-4 min-h-[90px] bg-slate-50 border border-slate-100 rounded-sm p-3 flex flex-col justify-center">
{activeCategory ? (
<div className="space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider block" style={{ color: activeCategory.color }}>
{activeCategory.name}包含细项:
</span>
<ul className="text-[11px] text-slate-600 list-disc list-inside space-y-0.5">
{activeCategory.details.slice(0, 3).map((det, i) => (
<li key={i} className="truncate">{det}</li>
))}
</ul>
</div>
) : (
<div className="flex items-center gap-2.5 text-slate-400 justify-center">
<ShieldAlert className="w-4 h-4" />
<span className="text-xs">悬停上方分类标签,查看各项底层细节。</span>
</div>
)}
</div>
</>
)}
</div>
<div className="mt-5 border-t border-dashed border-slate-100 pt-3 text-[10px] text-slate-400 flex items-center justify-between">
<span>风险按高/中/低等级聚合占比</span>
<span className="font-semibold text-rose-600 px-1 bg-rose-50 border border-rose-200 rounded-xs">
共 {riskTotal} 项
</span>
</div>
</DraftingCard>
);
}
@@ -0,0 +1,97 @@
/**
* 执行路线 —— 读 context.ROADMAP_PHASES(参考按空格拆 title 取 tag/name,改成 id 作 tag、
* title 作 name;period/highlights 真实数据可能缺省,分别条件渲染,无 highlights 不展开)。
*/
import { useState } from "react";
import { useDashboardData } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
import { motion } from "motion/react";
export function Roadmap() {
const { ROADMAP_PHASES } = useDashboardData();
const [activePhaseId, setActivePhaseId] = useState<string | null>(null);
if (ROADMAP_PHASES.length === 0) return null;
const togglePhase = (id: string) => setActivePhaseId(activePhaseId === id ? null : id);
return (
<div className="space-y-5">
<div className="flex items-center justify-between pb-1 border-b border-rose-100">
<h3 className="text-base font-bold text-slate-800 tracking-tight font-sans flex items-center gap-2">
执行路线
<span className="text-xs font-normal text-slate-400 font-sans">点击阶段卡片展开关键里程碑</span>
</h3>
<span className="text-[10px] text-rose-600 font-mono font-medium px-2 py-0.5 bg-rose-50 border border-rose-200/50">
共 {ROADMAP_PHASES.length} 个阶段
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{ROADMAP_PHASES.map((phase) => {
const hasHighlights = phase.highlights.length > 0;
const isExpanded = activePhaseId === phase.id;
return (
<DraftingCard
key={phase.id}
className={`flex flex-col justify-between transition-all duration-300 text-left overflow-hidden group min-h-[160px] border-l-[5px] border-l-rose-600 hover:border-l-rose-700 ${
hasHighlights ? "cursor-pointer" : ""
} ${isExpanded ? "bg-slate-50 border-rose-400 shadow-md ring-1 ring-rose-300/40" : ""}`}
onClick={hasHighlights ? () => togglePhase(phase.id) : undefined}
>
<div>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="space-y-0.5">
<span className="text-xs font-extrabold text-rose-600 font-sans block uppercase tracking-wide">
{phase.id}
</span>
<h4 className="text-sm font-extrabold text-slate-800 font-sans">{phase.title}</h4>
</div>
{phase.period && (
<span className="text-[11px] font-bold text-slate-400 font-mono bg-slate-100 border border-slate-200 px-2 py-0.5 rounded-sm shrink-0">
{phase.period}
</span>
)}
</div>
<p className="text-xs text-slate-600 leading-relaxed font-sans mt-2.5">{phase.description}</p>
{hasHighlights && isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mt-4 pt-3.5 border-t border-slate-200/80 space-y-2 overflow-hidden"
>
<span className="text-[10px] font-bold text-rose-600 uppercase tracking-widest block mb-1">
此阶段关键里程碑:
</span>
<ul className="space-y-2">
{phase.highlights.map((hlt, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-slate-700">
<CheckCircle2 className="w-3.5 h-3.5 text-rose-500 shrink-0 mt-0.5" />
<span>{hlt}</span>
</li>
))}
</ul>
</motion.div>
)}
</div>
<div className="mt-4 pt-2.5 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-400">
<span className="font-mono uppercase tracking-wider">Timeline Segment {phase.id}</span>
{hasHighlights && (
<div className="flex items-center gap-1 font-semibold text-rose-600 hover:text-rose-700">
<span>{isExpanded ? "收起" : "详细指标"}</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</div>
)}
</div>
</DraftingCard>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,141 @@
/**
* 席位交付总览 —— 读 context.SEAT_CARDS。参考按固定 id 配图标/颜色并写死“总结”全宽行;
* 这里改成:按序号循环取强调色,isSummary 席位全宽置底(无则全部进网格)。
*/
import { useDashboardData, ACCENTS } from "../data-context";
import { DraftingCard } from "./DraftingCard";
import { TabType } from "../types";
import { ArrowRight, AlertTriangle, AlertCircle, FileText } from "lucide-react";
interface SeatDeliveryOverviewProps {
onSeatSelect: (seatId: TabType) => void;
activeSeatId?: TabType;
}
export function SeatDeliveryOverview({ onSeatSelect, activeSeatId }: SeatDeliveryOverviewProps) {
const { SEAT_CARDS } = useDashboardData();
const analystSeats = SEAT_CARDS.filter((c) => !c.isSummary);
const summarySeat = SEAT_CARDS.find((c) => c.isSummary);
if (SEAT_CARDS.length === 0) return null;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-baseline justify-between gap-2">
<h3 className="text-base font-bold text-slate-800 tracking-tight font-sans flex items-center gap-2">
席位交付总览
<span className="text-xs font-normal text-slate-400 font-sans">点击卡片快速切入对应专属席位页</span>
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{analystSeats.map((seat, idx) => {
const color = ACCENTS[idx % ACCENTS.length];
const isActive = activeSeatId === seat.id;
return (
<DraftingCard
key={seat.id}
className={`flex flex-col justify-between h-full cursor-pointer relative ${
isActive ? "ring-2 ring-rose-500/70 bg-rose-50/5 border-rose-300" : ""
}`}
onClick={() => onSeatSelect(seat.id)}
>
<div>
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
<div className="flex items-center gap-2 min-w-0">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-bold text-slate-800 font-sans truncate">{seat.title}</span>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-slate-400 font-sans hover:text-slate-600 shrink-0">
<span>席位页</span>
<ArrowRight className="w-3 h-3" />
</div>
</div>
{seat.analyst && (
<div className="mt-3 text-slate-400 text-[10px] uppercase font-mono font-bold tracking-wider truncate">
汇报人: {seat.analyst}
</div>
)}
<p className="mt-2 text-xs text-slate-600 leading-relaxed line-clamp-3">{seat.description}</p>
{seat.metrics.length > 0 && (
<div className="mt-4 space-y-2.5">
{seat.metrics.slice(0, 2).map((met, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-[10px] font-medium text-slate-500">
<span className="truncate">{met.label}</span>
<span className="font-bold text-slate-800 font-mono shrink-0">{met.value}</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${met.value}%`, backgroundColor: ACCENTS[i % ACCENTS.length] }}
/>
</div>
</div>
))}
</div>
)}
</div>
{seat.risks.length > 0 && (
<div className="mt-5 pt-3.5 border-t border-slate-100 space-y-1.5">
{seat.risks.slice(0, 3).map((risk) => (
<div
key={risk.id}
className={`text-[9px] px-2 py-0.5 rounded-sm flex items-center gap-1 border-l-2 ${
risk.level === "high"
? "bg-red-50 text-red-700 border-red-500 border border-red-200/50"
: risk.level === "low"
? "bg-emerald-50 text-emerald-700 border-emerald-500 border border-emerald-200/50"
: "bg-amber-50 text-amber-700 border-amber-500 border border-amber-200/50"
}`}
>
{risk.level === "high" ? (
<AlertCircle className="w-3 h-3 text-red-500 shrink-0" />
) : (
<AlertTriangle className={`w-3 h-3 shrink-0 ${risk.level === "low" ? "text-emerald-500" : "text-amber-500"}`} />
)}
<span className="truncate">{risk.text}</span>
</div>
))}
</div>
)}
</DraftingCard>
);
})}
</div>
{summarySeat && (
<DraftingCard
className={`cursor-pointer ${activeSeatId === summarySeat.id ? "ring-2 ring-rose-500 bg-rose-50/10" : ""}`}
onClick={() => onSeatSelect(summarySeat.id)}
>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-start md:items-center gap-3">
<div className="p-2.5 bg-sky-100 border border-sky-300 rounded-sm">
<FileText className="w-5 h-5 text-sky-600" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-500" />
<h4 className="text-sm font-bold text-slate-950 font-sans">{summarySeat.title}</h4>
<span className="text-[10px] px-2 py-0.5 bg-sky-100/50 text-sky-800 rounded-full font-mono border border-sky-200/40 font-semibold uppercase tracking-wider">
决策中心终审
</span>
</div>
<p className="mt-1.5 text-xs text-slate-600 leading-relaxed max-w-4xl">{summarySeat.description}</p>
</div>
</div>
<div className="shrink-0 flex items-center gap-1 text-xs text-sky-600 hover:text-sky-700 font-semibold group bg-sky-50 px-3.5 py-2.5 rounded-sm border border-sky-100">
<span>进入 {summarySeat.title} 专页</span>
<span className="group-hover:translate-x-1 transition-transform">→</span>
</div>
</div>
</DraftingCard>
)}
</div>
);
}
@@ -0,0 +1,37 @@
/**
* 大屏多页(蓝图工程图风)自定义工具类 —— 补齐参考项目里依赖的、本仓 Tailwind v4
* 默认不带的几个 utility(其余 rose/slate/font-mono 等均走 Tailwind 原生)。
* 仅在 DashboardApp 子树内通过 import 引入,作用域不污染全局。
*/
/* DraftingCard 的极淡绘图网格 overlay(参考 `bg-grid-slate-100`)。 */
.bg-grid-slate-100 {
background-image:
linear-gradient(to right, rgba(241, 245, 249, 0.9) 1px, transparent 1px),
linear-gradient(to bottom, rgba(241, 245, 249, 0.9) 1px, transparent 1px);
background-size: 16px 16px;
}
/* 缓慢自转(Settings/Sliders 图标,参考 `animate-spin-slow`)。 */
@keyframes rt-dash-spin-slow {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: rt-dash-spin-slow 6s linear infinite;
}
/* 隐藏横向 Tab 滚动条(参考 `scrollbar-none`)。 */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* range 滑块在白底蓝图风下的细节微调(DetailView 权重沙盘若启用)。 */
.rt-dash input[type="range"] {
accent-color: currentColor;
}
@@ -0,0 +1,439 @@
/**
* 大屏多页数据上下文 —— DashboardApp 通过 Provider 注入,各组件用 `useDashboardData()`
* 读取,避免把同一份数据逐层穿透 props(对齐参考项目用静态 `data.ts` 的写法,但改成
* 运行时可注入真实圆桌数据)。
*
* 三块内容:
* 1) `DashboardDataValue` 契约 + Context/Provider/hook;
* 2) `DEMO_DATA`:不传真实数据时的回落演示数据(≈ 参考项目 data.ts,验证视觉用);
* 3) `adaptReportData(ReportData → DashboardDataValue)`:把 Step3 的 report-json 真实
* 数据映射成大屏组件消费的形状(KPI/共识/图表/席位/路线/风险/排行)。
*/
import { createContext, useContext, type ReactNode } from "react";
import type { ReportData, ReportSeat } from "@/roundtable-planning/lib/report-template";
import type {
BrandScore,
ConsensusData,
DashChart,
MetricItem,
RiskCategory,
RoadmapPhase,
SeatCard,
} from "./types";
export interface DashboardDataValue {
meta: { title: string; subtitle: string; generatedAt: string };
/** 顶部 KPI 带。 */
METRIC_DATA: MetricItem[];
/** 总控最终共识。 */
consensus: ConsensusData;
/** 多维竞争力排行(真实数据由图表/席位推导,可为空)。 */
BRAND_SCORES: BrandScore[];
/** 核心风险分类分布(真实数据由各席位风险按等级聚合)。 */
RISK_CATEGORIES: RiskCategory[];
/** 识别到的风险总数(环形图镂空中心的大数字)。 */
riskTotal: number;
/** 数据研判中心图表(最多 3 个)。 */
CHARTS: DashChart[];
/** 席位交付。 */
SEAT_CARDS: SeatCard[];
/** 执行路线阶段。 */
ROADMAP_PHASES: RoadmapPhase[];
}
// ── 取色:图表/KPI/席位强调色序(对齐 report-template 的 light accents) ──────────────
export const ACCENTS = ["#E11D48", "#4F46E5", "#059669", "#D97706", "#0284C7", "#7C3AED"];
const RISK_COLORS = { high: "#DC2626", medium: "#D97706", low: "#059669" } as const;
const Ctx = createContext<DashboardDataValue | null>(null);
export function DashboardDataProvider({
value,
children,
}: {
value: DashboardDataValue;
children: ReactNode;
}) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useDashboardData(): DashboardDataValue {
const v = useContext(Ctx);
if (!v) throw new Error("useDashboardData 必须在 <DashboardDataProvider> 内使用");
return v;
}
// ── adaptReportData:真实 report-json → 大屏数据 ────────────────────────────────────
/** 一个席位是否“总结/总控”性质(总览页全宽置底)。 */
function looksLikeSummary(name: string): boolean {
return /总结|总控|结论|综述|summary|总览/i.test(name);
}
/** 从席位指标取一个“达成度”代表值(首个指标,缺省 0)。 */
function seatCompleteness(seat: ReportSeat): number {
return seat.metrics.length > 0 ? Math.round(seat.metrics[0].value) : 0;
}
function adaptSeat(seat: ReportSeat, idx: number): SeatCard {
const details = seat.sections.flatMap((s) => s.bullets).slice(0, 6);
return {
id: seat.id,
title: seat.name,
completeness: seatCompleteness(seat),
description: seat.summary,
sections: seat.sections.map((s) => ({ heading: s.heading, bullets: s.bullets })),
details,
metrics: seat.metrics.map((m) => ({ label: m.label, value: Math.round(m.value) })),
risks: seat.risks.map((r, i) => ({
id: `${seat.id}-r${i}`,
text: r.label,
level: r.level === "mid" ? "medium" : r.level,
})),
dependsOn: [...seat.dependsOn],
isSummary: looksLikeSummary(seat.name),
};
}
/** 排行榜:优先用 bar/radar 图表,否则按各席位达成度推导,保证中列总有内容。 */
function deriveBrandScores(report: ReportData): BrandScore[] {
const ranking = report.charts.find((c) => c.type === "bar" || c.type === "radar");
if (ranking && ranking.items.length > 0) {
return ranking.items.slice(0, 8).map((it, i) => ({
name: it.label,
score: Math.max(0, Math.min(100, Math.round(it.value))),
color: ACCENTS[i % ACCENTS.length],
}));
}
return report.seats
.filter((s) => !looksLikeSummary(s.name))
.map((s, i) => ({
name: s.name,
score: seatCompleteness(s),
color: ACCENTS[i % ACCENTS.length],
}))
.filter((b) => b.score > 0);
}
/** 风险分布:把所有席位风险按 高/中/低 聚合成三类(环形图 + 图例)。 */
function deriveRiskCategories(report: ReportData): RiskCategory[] {
const buckets: Record<"high" | "medium" | "low", string[]> = { high: [], medium: [], low: [] };
for (const s of report.seats) {
for (const r of s.risks) {
const lv = r.level === "mid" ? "medium" : r.level;
buckets[lv].push(r.label);
}
}
const total = buckets.high.length + buckets.medium.length + buckets.low.length;
if (total === 0) return [];
const defs: Array<{ key: "high" | "medium" | "low"; name: string }> = [
{ key: "high", name: "高风险" },
{ key: "medium", name: "中风险" },
{ key: "low", name: "低风险" },
];
return defs
.filter((d) => buckets[d.key].length > 0)
.map((d) => {
const pct = Math.round((buckets[d.key].length / total) * 100);
return {
name: d.name,
value: pct,
percentage: pct,
color: RISK_COLORS[d.key],
details: buckets[d.key],
};
});
}
/** report-json → 大屏数据。generatedAt 由调用方传当前时间字符串。 */
export function adaptReportData(report: ReportData, generatedAt = ""): DashboardDataValue {
const seats = report.seats.map(adaptSeat);
const riskCount = report.seats.reduce((n, s) => n + s.risks.length, 0);
const METRIC_DATA: MetricItem[] = report.kpis.map((k, i) => ({
id: String(i + 1),
label: k.label,
value: k.value,
unit: k.unit ?? "",
trend: k.trend === "up" ? { type: "up", value: "" } : k.trend === "down" ? { type: "down", value: "" } : undefined,
color: ACCENTS[i % ACCENTS.length],
}));
return {
meta: { title: report.title, subtitle: report.subtitle, generatedAt },
METRIC_DATA,
consensus: { headline: report.consensus.headline, points: [...report.consensus.points] },
BRAND_SCORES: deriveBrandScores(report),
RISK_CATEGORIES: deriveRiskCategories(report),
riskTotal: riskCount,
CHARTS: report.charts.slice(0, 3).map((c) => ({ type: c.type, title: c.title, items: [...c.items] })),
SEAT_CARDS: seats,
ROADMAP_PHASES: report.timeline.map((t, i) => ({
id: `S${i + 1}`,
title: t.label,
description: t.desc,
highlights: [],
})),
};
}
// ── DEMO_DATA:参考项目演示数据(无真实数据时回落,验证视觉用) ─────────────────────────
const DEMO_SEATS: SeatCard[] = [
{
id: "scheme",
title: "方案设计",
analyst: "战略研究部组长",
completeness: 92,
description:
"提出四阶段×五维度分析框架(市场总量、品牌格局、车型梯队、价格带分布、技术路线),为全研讨提供方法论骨架。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
},
],
details: [
"全面统筹:建立从 2019 至 2026 年的宏观行业全景沙盘。",
"五个抓手:总量增长、品牌洗牌、车型裂变、价格卡位、混动 vs 纯电。",
"定量对标:收录各品牌细分销量、排量与续航参数底座。",
],
metrics: [
{ label: "框架完整性评分", value: 92 },
{ label: "指标覆盖度评分", value: 88 },
],
risks: [
{ id: "s1", text: "缺少独立政策/监管维度,难评估双积分变动。", level: "medium" },
{ id: "s2", text: "四阶段转折判断(2025 直接成熟)可能过早。", level: "medium" },
],
dependsOn: [],
},
{
id: "ux",
title: "用户体验分析师",
analyst: "用户体验与产品定义专家",
completeness: 90,
description: "揭示用户需求三级跳(能跑就行 → 智能体验决胜 → 场景化生态决胜),量化智驾/座舱对忠诚度的拉动。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
},
],
details: [
"主线转换:智能座舱 + 高阶智驾合占约 55% 决策权重。",
"NPS 大分化:理想 72-80 领跑;特斯拉受本土生态挤压承压。",
"体验场景:女性副驾、露营、多宠家庭、第二办公空间重塑价值。",
],
metrics: [{ label: "体验洞察深度", value: 90 }],
risks: [
{ id: "u1", text: "NPS 估算非全量官方普查,缺乏跨品牌同一标准。", level: "high" },
{ id: "u2", text: "用户体验画像缺乏大跨度样本与消费分层解读。", level: "medium" },
],
dependsOn: ["scheme"],
},
{
id: "ad",
title: "广告规划",
analyst: "数字营销与渠道设计总监",
completeness: 85,
description: "洞察从功能教育向心智共鸣与强渠道体系变迁的三级跳,定量对比销售渠道重塑的竞争力量。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
},
],
details: [
"渠道重塑:直营商超 vs 深度分销,多品牌网络是跨级竞争护城河。",
"超级效率:理想凭产品高清晰度在零广告下收获超高带看流。",
"破圈公式:问界融合华为声誉 + 电子消费展厅,低边际获客成本。",
],
metrics: [{ label: "营销策略覆盖度", value: 85 }],
risks: [
{ id: "a1", text: "营销费用保密性高,华为问界联合体费用拆分困难。", level: "high" },
{ id: "a2", text: "各品牌生命周期不同,直接拉平效率易产生偏差。", level: "medium" },
],
dependsOn: ["scheme", "ux"],
},
{
id: "risk",
title: "风险审查",
analyst: "合规与风险首席防线官",
completeness: 88,
description: "识别预测中的系统性不确定点,严防幸存者偏差与线性推算,将宏观政策与供应链黑天鹅嵌入共识。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
},
],
details: [
"黑天鹅预埋:注入汇率剧烈波动、矿产断供等破坏性测试。",
"数据证伪:逐行审查各席位数据来源与统计口径。",
"修正方案:小米销量改为情景上限,蔚来高亏损调为防守模型。",
],
metrics: [{ label: "风险识别全面度", value: 88 }],
risks: [
{ id: "r1", text: "海外芯片制裁与底层传感器半导体对冲考量不足。", level: "high" },
{ id: "r2", text: "2026 购置税退坡的心理压力与提前释放未被消化。", level: "high" },
],
dependsOn: ["scheme", "ux", "ad"],
},
{
id: "summary",
title: "总结陈述",
analyst: "战略决策终审发言人",
completeness: 0,
description:
"整合各席位共识与冲突,拉平决策共识,向执委会交付高战略敏感度、高实用度、防抖性强的演变总账。",
sections: [
{
heading: "汇报要点支撑明细",
bullets: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
},
],
details: [
"大一统格局:确定第一/第二/第三梯队三层大分化事实。",
"调和冲突:对四大风险与框架偏差合理定性,保留边界条件。",
"执行指引:为 2026 淘汰赛终局提供防守反击与智能化升维依据。",
],
metrics: [],
risks: [],
dependsOn: ["scheme", "ux", "ad", "risk"],
isSummary: true,
},
];
export const DEMO_DATA: DashboardDataValue = {
meta: {
title: "新能源乘用车市场格局与竞争分析",
subtitle: "四阶段 × 五维度框架,覆盖品牌/车型销量、市场份额及竞争态势演变(2019-2026)",
generatedAt: "2026-06-13 01:15",
},
METRIC_DATA: [
{ id: "1", label: "研讨参与席位", value: 5, unit: "个", color: ACCENTS[0] },
{ id: "2", label: "共识达成度", value: 92, unit: "%", trend: { type: "up", value: "" }, color: ACCENTS[4] },
{ id: "3", label: "识别关键风险", value: 12, unit: "项", color: ACCENTS[2] },
{ id: "4", label: "分析框架维度", value: 5, unit: "个", color: ACCENTS[3] },
{ id: "5", label: "覆盖品牌", value: 8, unit: "个", color: ACCENTS[1] },
{ id: "6", label: "覆盖时间跨度", value: 8, unit: "年", color: ACCENTS[5] },
],
consensus: {
headline:
"中国新能源乘用车市场经历四阶段演变,已形成比亚迪/特斯拉领跑、理想/问界追赶的三层阵营格局,竞争主线从功能转向智能体验与品牌心智。",
points: [
"三层阵营清晰定格:第一梯队比亚迪/特斯拉领跑,第二梯队理想/问界深度追赶,第三梯队蔚来/小鹏/小米差异化拼杀。",
"智能体验主导决策权重:智驾 + 座舱合占购车决策权重约 55%,用户体验完成“能跑就行 → 场景化生态”升维。",
"渠道与品牌三级跳跃:营销重塑历经功能教育 → 产品差异化 → 品牌心智驱动,直营/智选渠道成为最锋利切割手段。",
"宏观与微观风险需加条件限定:方法论与数据置信度中等,地缘政治与购置税退坡波动强烈,结论需声明边界。",
"核心争执调和:调降蔚来换电保值乐观度,小米长期出货改为多情景上限,半导体与地缘加入测试环境。",
],
},
BRAND_SCORES: [
{ name: "比亚迪", score: 92, color: "#E11D48", marketShare: "28.5%", strengths: ["垂直整合产业链", "混动纯电双轮驱动", "规模效应极致成本"], vehicles: ["秦L DM-i", "宋PLUS", "海鸥", "仰望U8"] },
{ name: "理想", score: 82, color: "#0D9488", marketShare: "8.4%", strengths: ["奶爸车定位精准", "增程解决里程焦虑", "全家共享车内生态"], vehicles: ["理想L9", "理想L8", "理想L7", "理想MEGA"] },
{ name: "问界", score: 80, color: "#D97706", marketShare: "7.8%", strengths: ["华为智选智能生态", "领先 ADS 智驾算法", "华为渠道深度导入"], vehicles: ["问界M9", "问界M7", "问界M5"] },
{ name: "特斯拉", score: 78, color: "#2563EB", marketShare: "12.2%", strengths: ["全球顶尖科技属性", "标准化极简生产", "超充网络自建"], vehicles: ["Model Y", "Model 3"] },
{ name: "蔚来", score: 68, color: "#0EA5E9", marketShare: "4.5%", strengths: ["换电护城河", "VIP 会员社交圈", "多品牌矩阵发力"] },
{ name: "小鹏", score: 65, color: "#64748B", marketShare: "3.9%", strengths: ["端到端智驾自研", "MONA 高性价比", "出海欧洲先锋"] },
],
RISK_CATEGORIES: [
{ name: "方法论/数据风险", value: 35, percentage: 35, color: "#E11D48", details: ["NPS 估算缺乏统一标准", "营销费用披露模糊难拆分", "座舱权重打分偏差大"] },
{ name: "外部环境/地缘风险", value: 30, percentage: 30, color: "#0D9488", details: ["欧美反补贴冲击出口链", "2026 购置税退坡转折", "电池关键矿物贸易壁垒"] },
{ name: "格局时效性风险", value: 20, percentage: 20, color: "#2563EB", details: ["小米等新势力爆发超预期", "智驾端到端可能绝杀长尾", "燃油车反攻引发价格下探"] },
{ name: "营销效率偏差风险", value: 15, percentage: 15, color: "#D97706", details: ["忽略品牌生命周期差异", "问界借母品牌 IP 难核算", "传统经销商转型评估不足"] },
],
riskTotal: 12,
CHARTS: [
{
type: "line",
title: "核心渗透率走势 (2019-2026)",
items: [
{ label: "2019", value: 4.7 },
{ label: "2020", value: 5.4 },
{ label: "2021", value: 14.8 },
{ label: "2022", value: 25.6 },
{ label: "2023", value: 35.7 },
{ label: "2024", value: 38.6 },
{ label: "2025", value: 42.5 },
{ label: "2026", value: 48.5 },
],
},
{
type: "donut",
title: "2026 阵营市场份额沙盘",
items: [
{ label: "比亚迪", value: 28.5 },
{ label: "特斯拉", value: 12.2 },
{ label: "理想汽车", value: 8.4 },
{ label: "华为问界", value: 7.8 },
{ label: "蔚来汽车", value: 4.5 },
{ label: "其他品牌", value: 38.6 },
],
},
{
type: "radar",
title: "比亚迪五维核心战力",
items: [
{ label: "性价比", value: 95 },
{ label: "三电技术", value: 92 },
{ label: "智能座舱", value: 75 },
{ label: "营销渠道", value: 95 },
{ label: "用户忠诚", value: 82 },
],
},
],
SEAT_CARDS: DEMO_SEATS,
ROADMAP_PHASES: [
{
id: "S1",
title: "政策驱动期",
period: "2019-2020",
description: "补贴退坡与突发事件交织,特斯拉上海工厂国产化落地,新势力在生死边缘探索。",
highlights: ["年销量约 120 万辆", "整体渗透率 <5%", "多针对 B 端或尝鲜消费者", "特斯拉催促全产业链启动"],
},
{
id: "S2",
title: "爆发成长期",
period: "2021-2022",
description: "市场由政策拉动转为市场驱动,比亚迪 DM-i 大爆发,家庭智能 SUV 与微型车两极爆发。",
highlights: ["比亚迪月销攀升至 20 万辆", "渗透率冲破 25%+", "宏光 MINI、理想 ONE 大单品", "锂价飞天、芯片荒逼迫自主替代"],
},
{
id: "S3",
title: "淘汰洗牌期",
period: "2023-2024",
description: "价格战见骨,合资燃油主力败退,长尾品牌因资金链断裂出局,智驾开城热潮席卷行业。",
highlights: ["合资折扣创历史新高", "多品牌停产裁员", "城市 NOA 爆发式落地", "渗透率切入 35%"],
},
{
id: "S4",
title: "成熟分化期",
period: "2025-2026",
description: "增速中枢向下,进入健康毛利质量竞争,大出海进入深水区,AI 深度决定品牌宿命。",
highlights: ["月渗透向 45-50% 冲击", "AI 端到端成基本标配", "弱势车企转代工或解体", "智能座舱与底盘融合"],
},
],
};
@@ -0,0 +1,104 @@
/**
* 大屏多页(蓝图工程图风)数据类型 —— 源自参考项目 `types.ts`,针对圆桌真实数据改造:
* - `TabType` 由写死的 6 个固定席位联合类型,放开成 `string`('overview' + 任意席位 id);
* - `MetricItem.color` 由 Tailwind class 串改成十六进制色(适配“按主题/序号自动取色”);
* - `SeatCard` 增加 `sections`/`dependsOn`/`isSummary`,承接真实数据的结构化小节与依赖;
* - `SeatRisk.level` 增补 `'low'`(真实契约是 high|mid|low,mid→medium)。
*/
/** 'overview' = 总览页;其余为席位 id(真实数据里是任意 string)。 */
export type TabType = string;
export interface MetricItem {
id: string;
label: string;
value: string | number;
unit: string;
trend?: {
type: "up" | "down";
value: string;
};
/** 十六进制强调色(图标/描边)。 */
color: string;
}
/** 总控共识:一句话结论 + 若干要点。 */
export interface ConsensusData {
headline: string;
points: string[];
}
export interface BrandScore {
name: string;
/** 0-100 综合指数。 */
score: number;
color: string;
/** 市场份额(如 "28.5%");真实数据可能缺省。 */
marketShare?: string;
/** 竞争优势/亮点;真实数据可能缺省。 */
strengths?: string[];
/** 代表车型/产物;真实数据可能缺省。 */
vehicles?: string[];
}
export interface RiskCategory {
name: string;
/** 在环形图里的占比权重(0-100)。 */
value: number;
percentage: number;
color: string;
details: string[];
}
/** 通用图表(数据研判中心):line=趋势 / bar=排名 / donut=占比 / radar=多维。 */
export interface DashChart {
type: "line" | "bar" | "donut" | "radar";
title: string;
items: { label: string; value: number }[];
}
export interface SeatRisk {
id: string;
text: string;
level: "high" | "medium" | "low";
}
export interface SeatMetric {
label: string;
/** 0-100。 */
value: number;
}
export interface SeatSection {
heading: string;
bullets: string[];
}
export interface SeatCard {
id: TabType;
title: string;
analyst?: string;
/** 达成度/完整度 0-100(取主指标),用于卡片进度条。 */
completeness: number;
/** 一两句结论摘要。 */
description: string;
/** 结构化小节(席位专页主体)。 */
sections: SeatSection[];
/** 扁平要点(总览卡片速览 / 兼容旧渲染)。 */
details: string[];
risks: SeatRisk[];
metrics: SeatMetric[];
/** 依赖的其它席位 id(产物承接),渲染成互跳 chips。 */
dependsOn: string[];
/** 是否“总结/总控”席位(总览页里全宽置底展示)。 */
isSummary?: boolean;
}
export interface RoadmapPhase {
id: string;
title: string;
/** 时间区间(如 "2019-2020");真实数据可能缺省。 */
period?: string;
description: string;
highlights: string[];
}
@@ -31,6 +31,7 @@ import {
isDashboardHtml,
parseReportJson,
renderDashboardHtml,
type ReportData,
} from "@/roundtable-planning/lib/report-template";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
@@ -59,6 +60,8 @@ export interface UseStep3DashboardReturn {
busy: boolean;
/** 当前大屏 HTML(已渲染;无则 null)。 */
html: string | null;
/** 当前大屏的结构化数据(report-json 解析结果)—— 供应用内 React 大屏直接渲染。 */
data: ReportData | null;
start: () => void;
redraw: () => void;
/** 用户输入修改要求 → 同线程续轮,输出修订版全量数据并重渲染。 */
@@ -122,6 +125,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const [seatStubMessages, setSeatStubMessages] = useState<Record<string, StubAiMessage[]>>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(null);
const [html, setHtml] = useState<string | null>(null);
const [data, setData] = useState<ReportData | null>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
@@ -228,11 +232,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台——
// 面板停留在其它流的虚拟文件上时本视图查不到内容、显示空白);面板关着
// 则保持关闭(尊重"进第三步默认关沙箱"),用户点按钮再开。
// 进入大屏「交互区」即视为用户想看抽取过程:有已记录的预览锚点(流式 stub /
// 已渲染 HTML)就**强制接管沙箱**展示本流产物(之前只在面板已开时接管,会导致
// 中途进交互区看不到抽取过程的空白)。离开交互区(enabled=false)只让出预览权,
// 不主动关面板(沙箱可继续停留,切回经典/其它流时各自接管)。
const last = lastAutoOpenRef.current;
if (enabled && last && artifactsOpenRef.current) {
if (enabled && last) {
openDashboardArtifact(last.threadId, last.messageId, last.toolCallId, { force: true });
}
},
@@ -323,6 +328,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
htmlRef.current = rendered;
setHtml(rendered);
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
openDashboardArtifact(threadId, messageId, toolCallId);
@@ -451,9 +457,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
const hydrateFromJson = useCallback(
(jsonStr: string) => {
const data = parseReportJson(jsonStr);
if (!data) return;
const rendered = renderDashboardHtml(data, { generatedAt: formatNow(), theme: themeMode });
const parsed = parseReportJson(jsonStr);
if (!parsed) return;
setData(parsed); // 应用内 React 大屏直接渲染(hydrateSaved 只管 HTML 沙箱路径)
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
hydrateSaved(rendered);
},
[themeMode, hydrateSaved],
@@ -484,6 +491,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
activeArtifactThreadId,
busy,
html,
data,
start,
redraw,
ask: (m: string) => void ask(m),
@@ -105,21 +105,84 @@ export function isDashboardHtml(html: string | null | undefined): boolean {
* stripFlowFence)。块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导看右侧沙箱。
*/
export function stripReportFence(text: string, streaming: boolean): string {
const start = text.lastIndexOf(FENCE);
if (start < 0) return text;
const after = text.slice(start + FENCE.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder =
close < 0 && streaming
? "\n\n> 📊 正在输出大屏结构化数据,右侧沙箱稍后自动渲染…"
: "\n\n> 📊 已生成大屏数据,右侧沙箱已渲染预览(可继续输入修改要求调整)。";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
// 优先 ```report-json,其次 ```json(模型常用);都没有则裸 JSON 对象(含契约关键键)。
let start = text.lastIndexOf(FENCE);
let fenceLen = FENCE.length;
if (start < 0) {
const j = text.toLowerCase().lastIndexOf("```json");
if (j >= 0) {
start = j;
fenceLen = "```json".length;
}
}
if (start >= 0) {
const after = text.slice(start + fenceLen);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
return `${text.slice(0, start).trimEnd()}${close < 0 && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
}
// 裸 JSON(无围栏):首个 { 起,看起来是本契约才替换成提示。
const first = text.indexOf("{");
if (first >= 0 && looksLikeReportJson(text.slice(first))) {
const last = text.lastIndexOf("}");
const closed = last > first;
const tail = closed ? text.slice(last + 1) : "";
return `${text.slice(0, first).trimEnd()}${!closed && streaming ? STREAM_MSG : DONE_MSG}${tail}`;
}
return text;
}
// ── 解析(容错:取最后一个 report-json 代码块;缺字段给安全默认) ─────────────────
const FENCE = "```report-json";
const STREAM_MSG = "\n\n> 📊 正在输出大屏结构化数据,右侧沙箱稍后自动渲染…";
const DONE_MSG = "\n\n> 📊 已生成大屏数据,右侧沙箱已渲染预览(可继续输入修改要求调整)。";
/** 文本是否像本契约 JSON(含关键键),避免把无关 {} 误当数据。 */
function looksLikeReportJson(s: string): boolean {
return /"(seats|consensus|kpis|charts|timeline)"\s*:/.test(s);
}
/** 收集所有 ```lang\n...``` 代码块(lang 可空)。 */
function collectFencedBlocks(text: string): Array<{ lang: string; body: string }> {
const blocks: Array<{ lang: string; body: string }> = [];
const re = /```([^\n`]*)\r?\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
blocks.push({ lang: m[1].trim().toLowerCase(), body: m[2].trim() });
}
return blocks;
}
/** 扫描出所有顶层平衡的 {…} 对象(字符串内的花括号不计),用于从夹杂文字里捞裸 JSON。 */
function balancedJsonObjects(text: string): string[] {
const out: string[] = [];
let depth = 0;
let start = -1;
let inStr = false;
let esc = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') inStr = true;
else if (ch === "{") {
if (depth === 0) start = i;
depth++;
} else if (ch === "}" && depth > 0) {
depth--;
if (depth === 0 && start >= 0) {
out.push(text.slice(start, i + 1));
start = -1;
}
}
}
return out;
}
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
@@ -134,16 +197,34 @@ function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
/** 从模型回复里提取并归一化 report-json(找不到 / 解析失败 → null)。 */
/**
* 从模型回复里提取并归一化 report-json(找不到 / 解析失败 → null)。
* 容错:模型未必严格用 ```report-json 围栏(DeepSeek 等常用 ```json 甚至裸 JSON)——
* 依次尝试 report-json / json / 无标注 代码块(同级取最后一个),再退到未闭合围栏内容、
* 整段裸 JSON 对象。每个候选都经 coerce 校验,命中契约形状才返回。
*/
export function extractReportData(text: string | null | undefined): ReportData | null {
if (!text) return null;
const start = text.lastIndexOf(FENCE);
if (start < 0) return null;
const after = text.slice(start + FENCE.length);
const close = after.indexOf("```");
const body = (close >= 0 ? after.slice(0, close) : after).trim();
if (!body) return null;
return parseReportJson(body);
const candidates: string[] = [];
const rank = (lang: string) =>
lang === "report-json" ? 0 : lang === "json" || lang === "jsonc" ? 1 : lang === "" ? 2 : 3;
collectFencedBlocks(text)
.map((b, i) => ({ b, i }))
.sort((x, y) => rank(x.b.lang) - rank(y.b.lang) || y.i - x.i)
.forEach(({ b }) => candidates.push(b.body));
// 未闭合围栏(流式截断):取 report-json fence 之后到结尾。
const f = text.lastIndexOf(FENCE);
if (f >= 0) candidates.push(text.slice(f + FENCE.length).trim());
// 裸 JSON(无围栏 / 夹在文字里):所有顶层平衡 {…} 对象,靠后的先试,契约形状的优先。
const objs = balancedJsonObjects(text);
for (const o of objs.filter(looksLikeReportJson).reverse()) candidates.push(o);
for (const o of objs.reverse()) candidates.push(o);
for (const body of candidates) {
const data = body ? parseReportJson(body) : null;
if (data) return data;
}
return null;
}
/** 解析裸 JSON 字符串(草稿/后台作业存的 step3.dashboardJson)并归一化。 */
@@ -442,88 +523,92 @@ function svgDonut(chart: ReportChart): string {
.join("");
// 中心:总分类数 + 标签(对齐设计图环形图的镂空中心)。
const center = `<text x="70" y="68" text-anchor="middle" class="dc-num">${items.length}</text><text x="70" y="82" text-anchor="middle" class="dc-cap">类</text>`;
const legend = items
.map(
(it, i) =>
`<div class="lg"><i style="background:${P.accents[i % P.accents.length]}"></i><span>${esc(it.label)}</span><b>${esc(String(it.value))}</b></div>`,
)
.join("");
return `<div class="donut"><svg viewBox="0 0 140 140" role="img"><circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--track)" stroke-width="${SW}"/>${segs}${center}</svg><div class="lgs">${legend}</div></div>`;
return `<svg viewBox="0 0 140 140" role="img"><circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--track)" stroke-width="${SW}"/>${segs}${center}</svg>`;
}
// 折线图(趋势):多取前 8 点;自动归一化到画布。设计图的销量趋势用折线。
function svgLine(chart: ReportChart): string {
const items = chart.items.slice(0, 8);
if (items.length < 2) return svgBar(chart);
const W = 300;
const H = 150;
const padL = 30;
const padR = 12;
const padT = 12;
const padB = 24;
const W = 460;
const H = 180;
const padL = 36;
const padR = 16;
const padT = 16;
const padB = 30;
const max = Math.max(...items.map((i) => i.value), 1);
const min = Math.min(...items.map((i) => i.value), 0);
const span = max - min || 1;
const x = (i: number) => padL + (i / (items.length - 1)) * (W - padL - padR);
const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
const pts = items.map((it, i) => `${x(i).toFixed(1)},${y(it.value).toFixed(1)}`).join(" ");
const grid = [0, 0.5, 1]
const grid = [0, 0.25, 0.5, 0.75, 1]
.map((k) => {
const gy = (padT + k * (H - padT - padB)).toFixed(1);
return `<line x1="${padL}" y1="${gy}" x2="${W - padR}" y2="${gy}" class="gr"/>`;
})
.join("");
const area = `<polygon points="${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}" fill="${P.primary}14"/>`;
const area = `<polygon points="${padL},${(H - padB).toFixed(1)} ${pts} ${(W - padR).toFixed(1)},${(H - padB).toFixed(1)}" fill="${P.primary}12"/>`;
const dots = items
.map((it, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(it.value).toFixed(1)}" r="2.6" fill="${P.primary}"/>`)
.map((it, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(it.value).toFixed(1)}" r="3" fill="#fff" stroke="${P.primary}" stroke-width="2"/>`)
.join("");
const labels = items
.map((it, i) => `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle" class="cl">${esc(it.label)}</text>`)
.map((it, i) => `<text x="${x(i).toFixed(1)}" y="${H - 10}" text-anchor="middle" class="cl">${esc(it.label)}</text>`)
.join("");
return `<svg viewBox="0 0 ${W} ${H}" role="img">${grid}${area}<polyline points="${pts}" fill="none" stroke="${P.primary}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>${dots}${labels}</svg>`;
return `<svg viewBox="0 0 ${W} ${H}" role="img">${grid}${area}<polyline points="${pts}" fill="none" stroke="${P.primary}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>${dots}${labels}</svg>`;
}
// 雷达:只画网格 + 数据多边形 + 顶点(**不在 SVG 内放文字标签**,标签由 chartLegend
// 作 HTML 旁注,避免 SVG 文字溢出/重叠——这是参考实现不错乱的关键)。
function svgRadar(chart: ReportChart): string {
const items = chart.items.slice(0, 6);
const n = Math.max(items.length, 3);
const cx = 110;
const cy = 95;
const R = 70;
const cx = 100;
const cy = 100;
const R = 72;
const max = Math.max(...items.map((i) => i.value), 1);
const pt = (i: number, r: number) => {
const pt = (i: number, r: number): [number, number] => {
const a = (Math.PI * 2 * i) / n - Math.PI / 2;
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
const grid = [0.33, 0.66, 1]
.map(
(k) =>
`<polygon points="${Array.from({ length: n }, (_, i) => pt(i, R * k)).join(" ")}" class="gr"/>`,
)
const ptS = (i: number, r: number) => pt(i, r).map((v) => v.toFixed(1)).join(",");
const grid = [0.25, 0.5, 0.75, 1]
.map((k) => `<polygon points="${Array.from({ length: n }, (_, i) => ptS(i, R * k)).join(" ")}" class="gr"/>`)
.join("");
const axes = Array.from(
{ length: n },
(_, i) => `<line x1="${cx}" y1="${cy}" x2="${pt(i, R).replace(",", '" y2="')}" class="gr"/>`,
).join("");
const poly = `<polygon points="${items.map((it, i) => pt(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}33" stroke="${P.primary}" stroke-width="2"/>`;
const labels = items
const axes = Array.from({ length: n }, (_, i) => {
const [px, py] = pt(i, R);
return `<line x1="${cx}" y1="${cy}" x2="${px.toFixed(1)}" y2="${py.toFixed(1)}" class="gr"/>`;
}).join("");
const poly = `<polygon points="${items.map((it, i) => ptS(i, (Math.max(0, it.value) / max) * R)).join(" ")}" fill="${P.primary}26" stroke="${P.primary}" stroke-width="2"/>`;
const dots = items
.map((it, i) => {
const [x, y] = pt(i, R + 16).split(",");
return `<text x="${x}" y="${y}" class="cl" text-anchor="middle">${esc(it.label)}</text>`;
const [px, py] = pt(i, (Math.max(0, it.value) / max) * R);
return `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="${P.primary}"/>`;
})
.join("");
return `<svg viewBox="0 0 220 200" role="img">${grid}${axes}${poly}${labels}</svg>`;
return `<svg viewBox="0 0 200 200" role="img">${grid}${axes}${poly}${dots}</svg>`;
}
// 图表旁注(色点 + 名称 + 值):donut 用分类色点、radar 用主色点。
function chartLegend(chart: ReportChart, singleColor: boolean): string {
return `<div class="clist">${chart.items
.slice(0, 6)
.map(
(it, i) =>
`<div class="cli"><span class="cdot" style="background:${singleColor ? P.primary : P.accents[i % P.accents.length]}"></span><span class="cnm">${esc(it.label)}</span><b>${esc(String(it.value))}</b></div>`,
)
.join("")}</div>`;
}
function chartHtml(chart: ReportChart): string {
const body =
chart.type === "donut"
? svgDonut(chart)
: chart.type === "radar"
? svgRadar(chart)
: chart.type === "line"
? svgLine(chart)
: svgBar(chart);
return `<div class="card chart"><h3>${esc(chart.title || "图表")}</h3>${body}</div>`;
const t = `<h4 class="ctitle">${esc(chart.title || "图表")}</h4>`;
if (chart.type === "donut")
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cdonut">${svgDonut(chart)}</div>${chartLegend(chart, false)}</div></div>`;
if (chart.type === "radar")
return `<div class="card chart">${t}<div class="cbody"><div class="cgraph cradar">${svgRadar(chart)}</div>${chartLegend(chart, true)}</div></div>`;
if (chart.type === "line")
return `<div class="card chart">${t}<div class="cgraph wide">${svgLine(chart)}</div></div>`;
return `<div class="card chart">${t}<div class="cgraph wide">${svgBar(chart)}</div></div>`;
}
const RISK_LABEL = { high: "高", mid: "中", low: "低" } as const;
@@ -571,9 +656,35 @@ export function renderDashboardHtml(
): string {
P = PALETTES[opts?.theme ?? "dark"];
const seats = data.seats;
// 导航图标(内联 SVG,currentColor 跟随选中态变红):总览=指南针,席位按序循环。
const SVG = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
const NAV_COMPASS = SVG(
'<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
);
const NAV_ICONS = [
SVG('<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>'),
SVG('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
SVG('<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>'),
SVG('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
SVG('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/>'),
SVG('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'),
];
const nav =
`<a class="tab" href="#overview">总览</a>` +
seats.map((s) => `<a class="tab" href="#${esc(s.id)}">${esc(s.name)}</a>`).join("");
`<a class="tab" href="#overview">${NAV_COMPASS}<span>总览</span></a>` +
seats
.map(
(s, i) =>
`<a class="tab" href="#${esc(s.id)}">${NAV_ICONS[i % NAV_ICONS.length]}<span>${esc(s.name)}</span></a>`,
)
.join("");
// 导航当前页高亮(纯 CSS):用 :has 反查 #page:target → 高亮对应 tab;
// overview 在「无任何 :target」时默认高亮。每个 seat 生成一组规则。
const sel = (id: string) => `.tab[href="#${id}"]`;
const ovBase = `body:not(:has(.page:target)) ${sel("overview")},body:has(#overview:target) ${sel("overview")}`;
const seatBase = seats.map((s) => `body:has(#${esc(s.id)}:target) ${sel(s.id)}`).join(",");
const allActive = seatBase ? `${ovBase},${seatBase}` : ovBase;
const navActiveCss = `${allActive}{background:#fff;color:var(--primary);box-shadow:0 1px 5px rgba(15,23,42,.1);font-weight:700;border-color:${P.primary}33}`;
const KPI_ICONS = ["📊", "📈", "⚠️", "🧩", "🏆", "📅"];
const kpis = data.kpis
@@ -607,20 +718,21 @@ export function renderDashboardHtml(
const consensusPoints = data.consensus.points.map((p) => `<li>${esc(p)}</li>`).join("");
const chartsBlock = data.charts.length
? `<div class="charts">${data.charts.map(chartHtml).join("")}</div>`
const console = data.charts.length
? `<section class="console">
<div class="chead"><h2 class="sec">📊 数据研判中心</h2><span class="status">STATUS: REAL-TIME CONSOLE</span></div>
<div class="charts3">${data.charts.map(chartHtml).join("")}</div>
</section>`
: "";
const overview = `<section class="page" id="overview">
<div class="kpis">${kpis}</div>
<div class="ovgrid">
<div class="card consensus"><h3>总控最终共识</h3>
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
<ul>${consensusPoints}</ul>
<a class="more" href="#consensus-modal">查看研讨详情 →</a>
</div>
${chartsBlock}
<div class="card consensus"><h3>总控最终共识</h3>
<p class="headline">${esc(data.consensus.headline || "(未提供)")}</p>
<ul>${consensusPoints}</ul>
<a class="more" href="#consensus-modal">查看研讨详情 →</a>
</div>
<h2 class="sec">席位交付总览 <small>点击卡片进入对应席位专页</small></h2>
${console}
<h2 class="sec">📋 席位交付总览 <small>点击卡片进入对应席位专页</small></h2>
<div class="seats">${seatCards}</div>
${timeline}
</section>`;
@@ -638,17 +750,35 @@ export function renderDashboardHtml(
.join("")}</ul></div>`,
)
.join("");
// 右栏:席位评定量化得分(大数字"X分",对齐设计图右侧栏)。
const scores = s.metrics.length
? `<div class="card"><h3>席位评定量化得分</h3><div class="scores">${s.metrics
.map(
(m) =>
`<div class="score"><span class="slbl">${esc(m.label)}</span><div class="sbig" style="color:${color}">${m.value}<small>分</small></div><div class="track"><div class="fill" style="width:${m.value}%;background:${color}"></div></div></div>`,
)
.join("")}</div></div>`
: "";
const risksCard = s.risks.length
? `<div class="card"><h3>风险提示 / 关注点</h3>${riskChips(s.risks)}</div>`
: "";
const depCard = `<div class="card"><h3>席位依赖关系</h3>${depChips(s, seats) || '<p class="empty">无显式依赖</p>'}</div>`;
return `<section class="page" id="${esc(s.id)}" style="--ac:${color}">
<div class="pbar">
<a class="back" href="#overview">← 返回总览</a>
<div class="pnav"><a href="#${esc(prev.id)}">‹ ${esc(prev.name)}</a><a href="#${esc(next.id)}">${esc(next.name)} ›</a></div>
</div>
<header class="phead"><i></i><div><h2>${esc(s.name)}</h2><p>${esc(s.summary)}</p></div></header>
${depChips(s, seats)}
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
<div class="grid2">
${s.metrics.length ? `<div class="card"><h3>量化指标</h3>${metricBars(s.metrics)}</div>` : ""}
${s.risks.length ? `<div class="card"><h3>风险提示</h3>${riskChips(s.risks)}</div>` : ""}
<header class="phead"><i></i><div><h2>${esc(s.name)} <span class="ptag">SEAT · ${esc(s.id.toUpperCase())}</span></h2><p>${esc(s.summary)}</p></div></header>
<div class="dgrid">
<div class="dmain">
<div class="card"><h3>席位核心交付 · 使命概述</h3><p class="lead">${esc(s.summary || "(无摘要)")}</p></div>
<div class="psections">${sections || '<div class="card sect"><p class="empty">(该席位无结构化小节)</p></div>'}</div>
</div>
<div class="dside">
${scores}
${risksCard}
${depCard}
</div>
</div>
</section>`;
})
@@ -673,21 +803,27 @@ export function renderDashboardHtml(
--primary:${P.primary};--link:${P.link};--heading:${P.heading};--track:${P.trackBg};--bracket:${P.bracket}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Inter","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:var(--txt);min-height:100vh;
font-size:13px;background:${P.bgGradient};background-color:${P.bgPage};background-attachment:fixed}
body::before{content:"";position:fixed;inset:0;pointer-events:none;
background:linear-gradient(${P.gridLine} 1px,transparent 1px),linear-gradient(90deg,${P.gridLine} 1px,transparent 1px);
background-size:22px 22px}
.top{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;align-items:center;gap:12px;
padding:13px 26px;background:${P.navBg};backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.top h1{font-size:18px;font-weight:800;letter-spacing:.5px;background:${P.titleColor};
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
.top .sub{font-size:11.5px;color:var(--dim);padding:2px 8px;border:1px solid var(--line);border-radius:4px}
.top .meta{margin-left:auto;font-size:10.5px;color:var(--dim);font-family:"JetBrains Mono",Consolas,monospace;letter-spacing:.5px}
nav{position:sticky;top:51px;z-index:4;display:flex;flex-wrap:wrap;gap:7px;padding:9px 26px;
background:${P.navBg};backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.tab{font-size:12px;font-weight:600;padding:5px 13px;border:1px solid var(--line);border-radius:4px;color:var(--dim);text-decoration:none;transition:.15s;background:var(--track)}
.tab:hover{color:#fff;border-color:var(--primary);background:var(--primary)}
main{padding:22px 26px 48px;max-width:1500px;margin:0 auto}
font-size:13px;background:${P.bgPage}}
/* 蓝图圆点网格底纹(对齐设计原图的 radial dots) */
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
background-image:radial-gradient(${P.gridLine} 1.3px,transparent 1.3px);background-size:20px 20px}
.accentbar{position:relative;z-index:2;height:4px;background:linear-gradient(90deg,${P.accents[0]},${P.accents[3]} 50%,${P.accents[4]})}
main{position:relative;z-index:1;padding:20px 26px 48px;max-width:1500px;margin:0 auto}
/* Header:标题 + 副标题(换行)+ 生成时间药丸(非吸顶,居中容器内) */
.top{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:14px;
padding-bottom:16px;margin-bottom:20px;border-bottom:1px solid ${P.primary}24}
.top .ttl{min-width:0}
.top h1{font-size:22px;font-weight:800;letter-spacing:.3px;color:var(--strong);line-height:1.25}
.top .sub{margin-top:6px;font-size:12.5px;color:var(--dim);letter-spacing:.3px}
.top .meta{font-size:11px;color:var(--dim);font-family:"JetBrains Mono",Consolas,monospace;letter-spacing:.5px;
background:#fff;border:1px solid var(--line);border-radius:6px;padding:6px 12px;white-space:nowrap}
/* Nav:浅灰圆角胶囊容器 + 图标文字 tab(对齐设计原图) */
nav{position:relative;z-index:1;margin-bottom:22px}
.navwrap{display:inline-flex;flex-wrap:wrap;gap:6px;background:var(--track);border:1px solid var(--line);border-radius:10px;padding:5px}
.tab{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 12px;
border:1px solid transparent;border-radius:7px;color:var(--dim);text-decoration:none;transition:.15s;background:transparent}
.tab:hover{color:var(--strong);background:rgba(255,255,255,.6)}
.tab svg{width:14px;height:14px;flex:none}
.page{display:none;animation:fi .25s ease}
#overview{display:block}
.page:target{display:block}
@@ -787,11 +923,50 @@ body:has(.page:target) #overview:not(:target){display:none}
.mbody li{display:flex;gap:9px;line-height:1.65}.mbody li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--primary);margin-top:7px;flex:none}
.mclose{position:absolute;top:10px;right:16px;font-size:22px;color:var(--dim);text-decoration:none;line-height:1}
.mclose:hover{color:var(--primary)}
/* ── 导航当前页高亮(注入纯 CSS :target 规则) ── */
${navActiveCss}
/* ── 数据研判中心:三列网格 + 图表尺寸约束(修每行一个、巨大) ── */
.console{margin-bottom:20px}
.chead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:13px}
.chead .sec{margin:0}
.status{font-size:10px;font-family:"JetBrains Mono",Consolas,monospace;color:var(--dim);background:var(--track);border:1px solid var(--line);padding:3px 8px;border-radius:4px;letter-spacing:.5px;white-space:nowrap}
.charts3{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}
@media(max-width:1100px){.charts3{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.charts3{grid-template-columns:1fr}}
.chart{display:flex;flex-direction:column}
.ctitle{font-size:12.5px;font-weight:700;color:var(--strong);margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}
.ctitle::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--primary);flex:none}
.cbody{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.cgraph{flex:none}
.cgraph svg{display:block}
.cgraph.cdonut svg{width:128px;height:128px}
.cgraph.cradar svg{width:150px;height:150px}
.cgraph.wide{width:100%}
.cgraph.wide svg{width:100%;height:auto;max-height:210px}
.clist{flex:1;min-width:120px;display:grid;gap:7px;align-content:center}
.cli{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--txt)}
.cli .cdot{width:9px;height:9px;border-radius:2px;flex:none}
.cli .cnm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cli b{color:var(--strong);font-family:"JetBrains Mono",Consolas,monospace;font-weight:700}
/* ── 席位专页:左右网格 + 卡片纵向间距(修上下无间距) ── */
.dgrid{display:grid;grid-template-columns:2fr 1fr;gap:14px;align-items:start}
@media(max-width:980px){.dgrid{grid-template-columns:1fr}}
.dmain{display:grid;gap:14px;align-content:start}
.dside{display:grid;gap:14px;align-content:start}
.lead{font-size:12.5px;color:var(--txt);line-height:1.7;margin:0}
.scores{display:grid;gap:16px}
.score .slbl{font-size:11px;color:var(--dim);display:block;margin-bottom:6px}
.sbig{font-size:30px;font-weight:800;font-family:"JetBrains Mono",Consolas,monospace;line-height:1;margin-bottom:8px}
.sbig small{font-size:12px;font-weight:500;color:var(--dim);margin-left:2px}
.empty{font-size:12px;color:var(--dim)}
</style></head>
<body>
<header class="top"><h1>${esc(data.title)}</h1><span class="sub">${esc(data.subtitle)}</span><span class="meta">${generated}</span></header>
<nav>${nav}</nav>
<div class="accentbar"></div>
<main>
<header class="top"><div class="ttl"><h1>${esc(data.title)}</h1>${
data.subtitle ? `<p class="sub">${esc(data.subtitle)}</p>` : ""
}</div>${generated ? `<span class="meta">⏱ ${generated}</span>` : ""}</header>
<nav><div class="navwrap">${nav}</div></nav>
${overview}
${seatPages}
</main>