初始化
This commit is contained in:
1 parent
34474bd5fc
commit
b638b541d0
3008 files changed
+837699
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 「大屏绘制智能体」独立页面(带导航 + 侧边栏,挂在 StrategyRoutes 下)。
|
||||
*
|
||||
* 按外部 `taskId`(URL `?taskId=` / 路由 state / 登录暂存的任务详情 id)存取:
|
||||
* - 进入时按 taskId 拉取已存会话(`/api/dashboard-sessions/by-task`,**不按 user 分权**)。
|
||||
* - **已有有效结果(可解析的 report-json)** → 直接展示大屏页面(应用内 `DashboardApp`)。
|
||||
* - **没有有效结果** → 进入「生成过程」视图:自动让智能体分析登录拉取的业务 JSON
|
||||
* (现为占位适配,接口就绪后改后台转换 + 智能体校验)→ 边抽边在右侧沙箱显示,
|
||||
* **完成不自动跳**;底部输入框可随时改。
|
||||
* - 聊天记录 + 最新 report-json 防抖回存(按 taskId 共享,跨用户可见)。
|
||||
*
|
||||
* 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` +
|
||||
* `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Inbox, Loader2, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { fetchDashboardInputJson } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
|
||||
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Compass, Crosshair, Shield, Mountain, Lightbulb, Flag, FileText } from "lucide-react";
|
||||
|
||||
import { DashboardApp, type DashboardPage } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import { SituationOverview } from "@/roundtable-planning/dashboard-agent/SituationOverview";
|
||||
import { EnemyDetail } from "@/roundtable-planning/dashboard-agent/EnemyDetail";
|
||||
import { OurSituation } from "@/roundtable-planning/dashboard-agent/OurSituation";
|
||||
import { EnvironmentDetail } from "@/roundtable-planning/dashboard-agent/EnvironmentDetail";
|
||||
import { Judgement } from "@/roundtable-planning/dashboard-agent/Judgement";
|
||||
import { Decision } from "@/roundtable-planning/dashboard-agent/Decision";
|
||||
import { Disposal } from "@/roundtable-planning/dashboard-agent/Disposal";
|
||||
import { parseSqReport, type SqReport } from "@/roundtable-planning/dashboard-agent/sq-report";
|
||||
import { DesensitizeProvider } from "@/roundtable-planning/dashboard-agent/ui";
|
||||
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
|
||||
import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
buildDashboardPromptFromJson,
|
||||
parseReportJson,
|
||||
renderDashboardHtml,
|
||||
type DashboardTheme,
|
||||
} from "@/roundtable-planning/lib/report-template";
|
||||
import {
|
||||
getDashboardSessionByTask,
|
||||
putDashboardSessionByTask,
|
||||
} from "@/roundtable-planning/api/dashboard-sessions";
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用**指定脱敏函数**深度遍历对象/数组(仅替换字符串值),返回新值(不改入参)。
|
||||
* 与 `desensitizeDeep` 的差别:脱敏函数由调用方传入——这里直接用 `/api/sensitive-words`
|
||||
* 查询结果构造(见组件内 `desensitizeText`),避免依赖全局 `DESENSITIZE_MAP` 的异步回灌时序。
|
||||
*/
|
||||
function deepDesensitize<T>(value: T, fn: (s: string) => string): T {
|
||||
if (typeof value === "string") return fn(value) as unknown as T;
|
||||
if (Array.isArray(value)) return value.map((v) => deepDesensitize(v, fn)) as unknown as T;
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = deepDesensitize(v, fn);
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type DashView = "chat" | "page";
|
||||
type RenderMode = "app" | "html";
|
||||
|
||||
/**
|
||||
* 战情报告是否含可展示内容。`parseSqReport` **永远**返回非 null 的 SqReport 对象(各类目可为 null),
|
||||
* 故判空必须看「是否至少有一个类目非空」,不能用 `!!report`——否则全类目皆空时仍误判为有数据,
|
||||
* 进而渲染出 DEMO 兜底的大屏标题 + 空导航 Tab。全空 → 整页走空状态(不渲染大屏标题/导航/页脚)。
|
||||
*/
|
||||
function sqReportHasContent(r: SqReport | null): boolean {
|
||||
if (!r) return false;
|
||||
return !!(r.enemy || r.ours || r.environment || r.judgement || r.decision || r.disposal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态页面阶段开关(先按设计图搭总览版式,动态数据后续再接):
|
||||
* - 默认落在「大屏页面」视图,直接展示 SituationOverview 静态版式;
|
||||
* - 不自动调用智能体抽取(避免静态阶段空跑)。
|
||||
* 接入动态数据后置为 false 即恢复原有「请求数据→智能体抽取→查看大屏」流程。
|
||||
*/
|
||||
const STATIC_PREVIEW = true;
|
||||
|
||||
/** 从 URL / 路由 state / 登录暂存任务详情解析 taskId。 */
|
||||
function useTaskId(): string {
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
return useMemo(() => {
|
||||
const fromQuery = searchParams.get("taskId")?.trim();
|
||||
if (fromQuery) return fromQuery;
|
||||
const fromState = (location.state as { taskId?: string } | null)?.taskId;
|
||||
if (fromState && String(fromState).trim()) return String(fromState).trim();
|
||||
const detail = readCopTaskDetail();
|
||||
if (detail?.id != null && String(detail.id).trim()) return String(detail.id).trim();
|
||||
return "";
|
||||
}, [searchParams, location.state]);
|
||||
}
|
||||
|
||||
export default function DashboardAgentPage() {
|
||||
const taskId = useTaskId();
|
||||
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const { theme } = useTheme();
|
||||
const themeMode: DashboardTheme = theme === "dark" ? "dark" : "light";
|
||||
|
||||
// 输入 JSON(登录拉取/占位)——auto-start 时由 buildInitialPrompt 闭包读取。
|
||||
const inputRef = useRef<unknown>(null);
|
||||
// 战情报告解析结果(selectSqReport → 各页面展示模型);解析失败/无数据 → null(页面回退占位)。
|
||||
const [sqReport, setSqReport] = useState<SqReport | null>(null);
|
||||
|
||||
const dashboard = useStep3Dashboard({
|
||||
intentReady: null,
|
||||
seatDeliveries: [],
|
||||
consensusText: "",
|
||||
selectedModel,
|
||||
themeMode,
|
||||
buildInitialPrompt: () => {
|
||||
const inp = inputRef.current;
|
||||
return inp ? buildDashboardPromptFromJson(inp) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
phase,
|
||||
dialogues,
|
||||
data,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openDashboardPreview,
|
||||
start,
|
||||
hydrateTranscript,
|
||||
} = dashboard;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
// 静态阶段默认进「大屏页面」,直接展示静态版式(见 STATIC_PREVIEW)。
|
||||
const [view, setView] = useState<DashView>(STATIC_PREVIEW ? "page" : "chat");
|
||||
const [renderMode, setRenderMode] = useState<RenderMode>("app");
|
||||
// 真实接口请求失败/无数据时的提示(真实模式不拿假数据兜底,改为提示+重试)。
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
|
||||
// 默认选中第一个模型。
|
||||
useEffect(() => {
|
||||
if (availableModels.length === 0) return;
|
||||
if (!availableModels.some((m) => m.name === selectedModel)) {
|
||||
setSelectedModel(availableModels[0].name);
|
||||
}
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
// init 只跑一次:start / hydrateTranscript 用 ref 持有,effect 只依赖 [taskId]。
|
||||
// 否则它们的 identity 会随 selectedModel/themeMode 变化重建(模型挂载后异步加载即触发),
|
||||
// 让 effect 在 await 期间 cleanup → 二次进入被 didInitRef 挡掉 → 首跑的 finally 因 cancelled
|
||||
// 跳过 setLoading(false),loading 永远停在 true(白转圈,卡在「正在加载任务大屏…」)。
|
||||
const startRef = useRef(start);
|
||||
startRef.current = start;
|
||||
const hydrateTranscriptRef = useRef(hydrateTranscript);
|
||||
hydrateTranscriptRef.current = hydrateTranscript;
|
||||
|
||||
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程 ──
|
||||
// 抽成可复用函数:首屏 effect 跑一次 + 「重试」按钮可再调(真实接口请求失败后重新拉取)。
|
||||
const initialize = useCallback(async () => {
|
||||
if (!taskId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true); // 请求数据期间显示加载效果(「正在请求任务数据…」)
|
||||
setInputError(null);
|
||||
try {
|
||||
// 会话 + 输入 JSON 并行取,但**都 await 完成后**才决定是否启动模型抽取——
|
||||
// 即「先请求数据,待接口返回后再交给模型抽取」。输入写进 inputRef 供续问/重画复用。
|
||||
const [session, input] = await Promise.all([
|
||||
getDashboardSessionByTask(taskId).catch(() => null),
|
||||
fetchDashboardInputJson(taskId).catch(() => null),
|
||||
]);
|
||||
inputRef.current = input;
|
||||
// 解析战情报告 → 各页面展示模型(容错:异常/无数据均不抛错,页面回退占位)。
|
||||
try {
|
||||
const parsed = parseSqReport(input);
|
||||
// 调试:在浏览器控制台打印「接口原始 JSON」与「解析后的战情数据」,便于核对解析是否正确。
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[大屏绘制] taskId=%s\n原始输入 JSON:", taskId, input, "\n解析后 sqReport:", parsed);
|
||||
setSqReport(parsed);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[大屏绘制] parseSqReport 解析失败:", err, "\n原始输入:", input);
|
||||
setSqReport(null);
|
||||
}
|
||||
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
|
||||
const validReport = session?.reportJson && parseReportJson(session.reportJson);
|
||||
if (validReport) {
|
||||
// 有有效结果 → 恢复并直接展示大屏页面。
|
||||
hydrateTranscriptRef.current(savedDialogues, session!.reportJson);
|
||||
setView("page");
|
||||
} else if (savedDialogues.length > 0) {
|
||||
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。
|
||||
hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null);
|
||||
// 静态阶段仍停在「大屏页面」看版式(历史对话可点「编辑」查看)。
|
||||
setView(STATIC_PREVIEW ? "page" : "chat");
|
||||
} else {
|
||||
// 无任何记录 → 接口返回了真实数据才启动模型抽取;否则提示(真实模式不拿假数据兜底)。
|
||||
// 静态阶段(STATIC_PREVIEW):不自动抽取,停在「大屏页面」展示静态版式。
|
||||
if (STATIC_PREVIEW) {
|
||||
setView("page");
|
||||
} else {
|
||||
setView("chat");
|
||||
if (input) {
|
||||
startRef.current();
|
||||
} else {
|
||||
setInputError("未获取到任务数据:接口请求失败或未返回内容,请稍后重试。");
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重建本回调。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
const didInitRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didInitRef.current) return;
|
||||
didInitRef.current = true;
|
||||
void initialize();
|
||||
}, [initialize]);
|
||||
|
||||
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
|
||||
useEffect(() => {
|
||||
if (!taskId || loading) return;
|
||||
if (dialogues.length === 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
void putDashboardSessionByTask(taskId, {
|
||||
transcript: dialogues,
|
||||
reportJson: data ? JSON.stringify(data) : undefined,
|
||||
}).catch(() => {
|
||||
/* 回存失败不打扰用户 */
|
||||
});
|
||||
}, 800);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [taskId, loading, dialogues, data]);
|
||||
|
||||
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
|
||||
useEffect(() => {
|
||||
setPreviewEnabled(view === "chat");
|
||||
return () => setPreviewEnabled(false);
|
||||
}, [view, setPreviewEnabled]);
|
||||
|
||||
// 展示层脱敏函数:**直接用 `/api/sensitive-words` 查询结果**构造(不走全局 DESENSITIZE_MAP),
|
||||
// 避免与 `replaceDesensitizeMap` 的 effect 时序竞态——与侧边栏/导航栏菜单脱敏同一手法。
|
||||
// 后台为空(或未加载)时回退到全局种子 `desensitize`。供大屏所有 react 页面 + 导航 Tab 文本共用。
|
||||
const { data: sensitiveWords } = useSensitiveWords();
|
||||
const desensitizeText = useMemo(() => {
|
||||
const map = wordsToMap(sensitiveWords ?? []);
|
||||
return Object.keys(map).length > 0 ? makeDesensitizer(map) : desensitize;
|
||||
}, [sensitiveWords]);
|
||||
|
||||
// ── 大屏渲染数据(深度脱敏后渲染,覆盖大屏所有展示文本) ──
|
||||
const report = useMemo(() => (data ? deepDesensitize(data, desensitizeText) : null), [data, desensitizeText]);
|
||||
const dashboardData = useMemo(
|
||||
() => (report ? adaptReportData(report, formatNow()) : undefined),
|
||||
[report],
|
||||
);
|
||||
const dashboardHtml = useMemo(
|
||||
() => (report ? renderDashboardHtml(report, { generatedAt: formatNow(), theme: themeMode }) : null),
|
||||
[report, themeMode],
|
||||
);
|
||||
const hasResult = !!dashboardData || !!dashboardHtml;
|
||||
|
||||
// 展示层脱敏:所有页面文本经深度替换后再渲染(与会商大屏一致),脱敏函数来自后台敏感词查询。
|
||||
const safeReport = useMemo<SqReport | null>(() => {
|
||||
if (!sqReport) return null;
|
||||
try {
|
||||
return deepDesensitize(sqReport, desensitizeText) as SqReport;
|
||||
} catch {
|
||||
return sqReport; // 脱敏异常不影响展示
|
||||
}
|
||||
}, [sqReport, desensitizeText]);
|
||||
|
||||
// 是否有可展示的真实数据:战情报告**至少一个类目非空**(safeReport 恒为非 null 对象,不能用
|
||||
// `!!safeReport` 判空,见 sqReportHasContent),或已生成的大屏(report-json / HTML)。
|
||||
// 三者皆无 → 不渲染大屏的导航栏与标题,改显示整页空状态。
|
||||
const hasData = sqReportHasContent(safeReport) || !!dashboardData || !!dashboardHtml;
|
||||
|
||||
// 战情多页:总览 + 六个详情页;详情页传入对应的(脱敏后)真实数据(无则页面回退占位)。
|
||||
// 导航 Tab 文本(label)也经 desensitizeText 脱敏后再传给 DashboardApp 渲染导航栏。
|
||||
const dashPages: DashboardPage[] = useMemo(
|
||||
() => [
|
||||
{ id: "overview", label: desensitizeText("总览"), icon: <Compass className="h-4 w-4" />, content: <SituationOverview data={safeReport} /> },
|
||||
{ id: "enemy", label: desensitizeText("敌情"), icon: <Crosshair className="h-4 w-4" />, content: <EnemyDetail data={safeReport?.enemy} /> },
|
||||
{ id: "ours", label: desensitizeText("我情"), icon: <Shield className="h-4 w-4" />, content: <OurSituation data={safeReport?.ours} /> },
|
||||
{ id: "field", label: desensitizeText("战场环境"), icon: <Mountain className="h-4 w-4" />, content: <EnvironmentDetail data={safeReport?.environment} /> },
|
||||
{ id: "judge", label: desensitizeText("情况判断"), icon: <Lightbulb className="h-4 w-4" />, content: <Judgement data={safeReport?.judgement} /> },
|
||||
{ id: "decision", label: desensitizeText("决心建议"), icon: <Flag className="h-4 w-4" />, content: <Decision data={safeReport?.decision} /> },
|
||||
{ id: "disposal", label: desensitizeText("处置建议"), icon: <FileText className="h-4 w-4" />, content: <Disposal data={safeReport?.disposal} /> },
|
||||
],
|
||||
[safeReport, desensitizeText],
|
||||
);
|
||||
|
||||
// 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。
|
||||
const [htmlBlobUrl, setHtmlBlobUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !dashboardHtml) {
|
||||
setHtmlBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
|
||||
setHtmlBlobUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [dashboardHtml]);
|
||||
|
||||
const isStreaming =
|
||||
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
|
||||
|
||||
// 「重新分析 / 修改」前都**重新请求一次最新数据**(数据可能已变化);返回拉到的最新输入或 null。
|
||||
const refreshInput = useCallback(async (): Promise<unknown | null> => {
|
||||
if (!taskId) return null;
|
||||
const fresh = await fetchDashboardInputJson(taskId, { forceRefresh: true }).catch(() => null);
|
||||
if (fresh) inputRef.current = fresh; // 拉到才覆盖;失败保留上一次,避免清空可用数据
|
||||
return fresh;
|
||||
}, [taskId]);
|
||||
|
||||
// 防并发:刷新+生成进行中时再次点击直接忽略。
|
||||
const refreshingRef = useRef(false);
|
||||
|
||||
// 重新生成/编辑统一闸门:**只有请求到最新数据才继续生成**,拿不到则提示并取消(不拿旧/空数据跑模型)。
|
||||
const guardedGenerate = useCallback(
|
||||
(run: () => void, cancelHint: string) => {
|
||||
if (refreshingRef.current) return;
|
||||
refreshingRef.current = true;
|
||||
void (async () => {
|
||||
const loadingId = toast.loading("正在请求最新任务数据…");
|
||||
try {
|
||||
const fresh = await refreshInput();
|
||||
if (!fresh) {
|
||||
toast.error(cancelHint, { id: loadingId });
|
||||
return;
|
||||
}
|
||||
toast.dismiss(loadingId);
|
||||
run();
|
||||
} finally {
|
||||
refreshingRef.current = false;
|
||||
}
|
||||
})();
|
||||
},
|
||||
[refreshInput],
|
||||
);
|
||||
|
||||
const handleRedraw = useCallback(
|
||||
() => guardedGenerate(redraw, "未获取到最新任务数据,已取消重新生成,请稍后重试"),
|
||||
[guardedGenerate, redraw],
|
||||
);
|
||||
|
||||
const handleAsk = useCallback(
|
||||
(message: string) => guardedGenerate(() => ask(message), "未获取到最新任务数据,已取消修改,请稍后重试"),
|
||||
[guardedGenerate, ask],
|
||||
);
|
||||
|
||||
const handleOpenNewWindow = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (renderMode === "html" && dashboardHtml) {
|
||||
const blobUrl = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
return;
|
||||
}
|
||||
const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`;
|
||||
try {
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i -= 1) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k);
|
||||
}
|
||||
if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData));
|
||||
} catch {
|
||||
/* localStorage 不可用:新窗口回落 DEMO */
|
||||
}
|
||||
const base = `${window.location.origin}${window.location.pathname}`;
|
||||
window.open(`${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center p-8 text-center text-sm text-slate-500">
|
||||
<div>
|
||||
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
|
||||
缺少任务标识(taskId)。请通过带 <code className="px-1">?taskId=</code> 的链接进入大屏绘制页。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// dashboard-agent-fontscale:本页字号下限 16px(见 globals.css,仅作用于本页)。
|
||||
<div className="dashboard-agent-fontscale flex h-full min-h-0 flex-col text-slate-800 dark:text-slate-200">
|
||||
{/* 主体 */}
|
||||
{loading ? (
|
||||
// 加载框:请求战情报告期间显示,拿到数据后再渲染大屏(避免先闪占位再跳真实数据)。
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-slate-700">正在请求任务数据…</div>
|
||||
<div className="mt-1 text-xs text-slate-400">拉取接口返回后展示大屏</div>
|
||||
</div>
|
||||
</div>
|
||||
) : view === "page" ? (
|
||||
!hasData ? (
|
||||
// 无数据:不渲染大屏标题/导航 Tab/页脚,整页改为一个干净的空状态(图标 + 文案 + 重试)。
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute inset-0 -z-10 rounded-full bg-rose-100/50 blur-2xl dark:bg-rose-500/10" />
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-rose-100 bg-gradient-to-b from-rose-50 to-white text-rose-500 shadow-sm dark:border-[var(--dash-border)] dark:from-[var(--dash-card)] dark:to-[var(--dash-page)] dark:text-[var(--dash-primary)]">
|
||||
<Inbox className="h-9 w-9" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-1.5 text-base font-bold text-slate-700 dark:text-[var(--dash-text-strong)]">
|
||||
暂无大屏数据
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void initialize()}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-600 shadow-sm transition hover:border-rose-200 hover:text-rose-600 dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)] dark:text-[var(--dash-text)] dark:hover:text-[var(--dash-primary)]"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
重新请求
|
||||
</button>
|
||||
</div>
|
||||
) : renderMode === "html" && htmlBlobUrl ? (
|
||||
<iframe
|
||||
title="任务研讨大屏"
|
||||
src={htmlBlobUrl}
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{/* 注入后台敏感词脱敏函数:让战情各页(含静态卡片标题/表头/脚注等)统一脱敏。 */}
|
||||
<DesensitizeProvider value={desensitizeText}>
|
||||
<DashboardApp data={dashboardData} pages={dashPages} />
|
||||
</DesensitizeProvider>
|
||||
</div>
|
||||
)
|
||||
) : inputError && dialogues.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-slate-500">
|
||||
<div className="max-w-sm">
|
||||
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
|
||||
<div className="mb-4 text-slate-600">{inputError}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void initialize()}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-slate-500" />
|
||||
重新请求
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<section className="h-full min-h-0 overflow-hidden rounded-2xl border border-slate-200/60 bg-white">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
|
||||
>
|
||||
<DashboardChatPanel
|
||||
phase={phase}
|
||||
dialogues={dialogues}
|
||||
busy={busy}
|
||||
error={dashboard.error}
|
||||
onAsk={handleAsk}
|
||||
onRedraw={handleRedraw}
|
||||
onPause={abort}
|
||||
onOpenPreview={openDashboardPreview}
|
||||
hasResult={hasResult}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
subtitle="分析任务原始数据,提炼为大屏结构化数据(右侧实时显示抽取过程)"
|
||||
doneHint="数据已就绪,点右上「查看大屏」查看。"
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 大屏多页「新窗口打开」的独立全屏宿主页 —— 渲染应用内 React 大屏(`DashboardApp`)。
|
||||
*
|
||||
* 浏览器新标签页是一份全新文档,React 数据无法经 props 直接穿过去,故走 localStorage:
|
||||
* - Step3 在点「新窗口打开」时把当前 `DashboardDataValue` 写进 localStorage(键由 URL `?k=` 带过来);
|
||||
* - 本页读出该键、解析后注入 `<DashboardApp data={...} />`;读不到则回落 DEMO(与应用内一致)。
|
||||
*
|
||||
* 路由挂在 `App.tsx` 顶层(`PageRoutes` 之外、`AppProviders` 之下),天然无页面侧边栏 /
|
||||
* workspace 导航,整屏只渲染大屏本体——正是「新窗口打开」想要的纯净大屏视图。
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import type { DashboardDataValue } from "@/roundtable-planning/dashboard/data-context";
|
||||
|
||||
/** localStorage 键前缀:Step3 写入、本页读取;前缀统一便于清理历史键。 */
|
||||
export const DASHBOARD_VIEW_STORAGE_PREFIX = "rt-dash-view:";
|
||||
|
||||
export default function DashboardStandalonePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const data = useMemo<DashboardDataValue | undefined>(() => {
|
||||
const key = searchParams.get("k");
|
||||
if (!key || typeof window === "undefined") return undefined;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as DashboardDataValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return <DashboardApp data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 派活链路流程图弹窗 —— 预览/自测页(Phase 7 临时入口)。
|
||||
*
|
||||
* 路由:/page/workspace/roundtable/chain-preview
|
||||
*
|
||||
* 用假数据 + 定时器模拟后台作业推进(初始化 → 总控派活 → 席位逐个发言 → 共识 →
|
||||
* 结果绘制 → 完成),驱动 `DispatchChainModal` 演示节点 / 连线的加载动画与状态流转。
|
||||
* 后续 Phase 8 接通真实 `streamJob` 后,本页可删除(连同 WorkspaceRoutes 里的路由)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DispatchChainModal } from "../components/DispatchChainModal";
|
||||
import {
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
getAvatarTypeFor,
|
||||
getDisplayNameFor,
|
||||
} from "../lib/roundtable-constants";
|
||||
import type {
|
||||
DispatchChainNode,
|
||||
JobPhase,
|
||||
JobStatus,
|
||||
} from "../lib/job-types";
|
||||
import "../styles/roundtable-planning.css";
|
||||
|
||||
interface SimFrame {
|
||||
phase: JobPhase;
|
||||
status: JobStatus;
|
||||
cycle: number;
|
||||
consensus: number;
|
||||
activeAgentId: string | null;
|
||||
doneAgentIds: string[];
|
||||
}
|
||||
|
||||
const AGENTS = DEFAULT_SELECTED_AGENTS;
|
||||
|
||||
/** 预先生成一条完整的推进时间线(每帧约 1.1s)。 */
|
||||
function buildFrames(): SimFrame[] {
|
||||
const ids = AGENTS.map((a) => a.agent_id);
|
||||
const frames: SimFrame[] = [];
|
||||
|
||||
frames.push({ phase: "initializing", status: "running", cycle: 0, consensus: 0, activeAgentId: null, doneAgentIds: [] });
|
||||
frames.push({ phase: "leader_thinking", status: "running", cycle: 1, consensus: 12, activeAgentId: null, doneAgentIds: [] });
|
||||
|
||||
// 第 1 轮:逐个席位发言
|
||||
ids.forEach((id, i) => {
|
||||
frames.push({
|
||||
phase: "dispatching",
|
||||
status: "running",
|
||||
cycle: 1,
|
||||
consensus: 12 + Math.round(((i + 1) / ids.length) * 50),
|
||||
activeAgentId: id,
|
||||
doneAgentIds: ids.slice(0, i),
|
||||
});
|
||||
});
|
||||
|
||||
// 第 2 轮:总控再决策(此时第 1 轮席位都已发言)
|
||||
frames.push({ phase: "leader_thinking", status: "running", cycle: 2, consensus: 78, activeAgentId: null, doneAgentIds: [...ids] });
|
||||
// 共识达成
|
||||
frames.push({ phase: "consensus", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||
// 结果绘制中
|
||||
frames.push({ phase: "report_drawing", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||
// 完成
|
||||
frames.push({ phase: "report_done", status: "done", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
export default function DispatchChainPreviewPage() {
|
||||
const frames = useMemo(buildFrames, []);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 自动播放:逐帧推进,跑到最后一帧停 2.2s 再循环
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const isLast = idx >= frames.length - 1;
|
||||
timerRef.current = setTimeout(
|
||||
() => setIdx((i) => (i >= frames.length - 1 ? 0 : i + 1)),
|
||||
isLast ? 2200 : 1100,
|
||||
);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [idx, playing, frames.length]);
|
||||
|
||||
const frame = frames[idx];
|
||||
|
||||
// 由当前帧推导各席位节点状态
|
||||
const nodes: DispatchChainNode[] = AGENTS.map((a, i) => {
|
||||
const state =
|
||||
frame.doneAgentIds.includes(a.agent_id)
|
||||
? "done"
|
||||
: frame.activeAgentId === a.agent_id
|
||||
? "active"
|
||||
: "pending";
|
||||
return {
|
||||
agentId: a.agent_id,
|
||||
name: getDisplayNameFor(a),
|
||||
avatarType: getAvatarTypeFor(a.agent_id, i),
|
||||
order: i + 1,
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-6 bg-muted/30 p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-lg font-bold">派活链路流程图 · 预览</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
模拟后台作业推进(假数据)。帧 {idx + 1}/{frames.length} · {frame.phase}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPlaying((p) => !p)}>
|
||||
{playing ? "暂停" : "继续"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIdx((i) => (i > 0 ? i - 1 : i));
|
||||
setPlaying(false);
|
||||
}}
|
||||
>
|
||||
上一帧
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIdx((i) => (i < frames.length - 1 ? i + 1 : i));
|
||||
setPlaying(false);
|
||||
}}
|
||||
>
|
||||
下一帧
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIdx(0);
|
||||
setPlaying(true);
|
||||
}}
|
||||
>
|
||||
重新播放
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DispatchChainModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="新能源汽车出口策略研讨(预览)"
|
||||
status={frame.status}
|
||||
phase={frame.phase}
|
||||
cycle={frame.cycle}
|
||||
nodes={nodes}
|
||||
consensusPercentage={frame.consensus}
|
||||
onView={() => toast.info("(预览)点击「查看」→ Phase 8 会进入正在进行的任务")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user