From 1a2ef0a537cfb6a5f100cf1217abe1ac84542895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 23 Jun 2026 12:36:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=A7=E5=B1=8F=E7=BB=98=E5=88=B6):=20?= =?UTF-8?q?=E6=88=98=E6=83=85=E5=A4=A7=E5=B1=8F=E6=A0=B7=E5=BC=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=E8=84=B1=E6=95=8F=E5=85=A8=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉顶栏与「生成于」时间标签;无数据时仅显示「暂无数据」、隐藏导航与标题 - 战情各页 + 导航 Tab 文本全部脱敏(响应式取后台敏感词,避免 replaceDesensitizeMap 时序竞态) - 全页最小字号 16px,调整行高/对齐;表格加横向滚动防溢出 - 关键事件改时间线样式(灰色虚线 + 浅红空心圆圈节点),敌情三块改单行全宽 - 应对方案渲染 Markdown;处置建议由 7 列大表改为单条卡片(字段标签改 #222222) - 通用过滤引用角标([[1]] / 资料[[1]] / (资料[[1]]) 等,解析末尾整树清洗) - safelist + 本页兜底 CSS 修复 border 语义色回退成灰色(时间线圆圈/FooterNote 强调条) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard-agent/Decision.tsx | 7 +- .../dashboard-agent/Disposal.tsx | 61 ++++-- .../dashboard-agent/EnemyDetail.tsx | 82 ++++----- .../dashboard-agent/EnvironmentDetail.tsx | 5 +- .../dashboard-agent/Judgement.tsx | 13 +- .../dashboard-agent/OurSituation.tsx | 4 +- .../dashboard-agent/SituationOverview.tsx | 11 +- .../dashboard-agent/sq-report.ts | 43 ++++- .../dashboard-agent/ui.tsx | 107 ++++++++--- .../dashboard/DashboardApp.tsx | 13 +- .../pages/DashboardAgentPage.tsx | 173 +++++++----------- frontend-web/src/styles/globals.css | 33 ++++ 12 files changed, 339 insertions(+), 213 deletions(-) diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/Decision.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/Decision.tsx index fcf8d0a0..90263232 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/Decision.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/Decision.tsx @@ -11,7 +11,7 @@ */ import { ClipboardList, Crosshair, Target, ListChecks, Flag, Clock, ShieldCheck, AlertTriangle, type LucideIcon } from "lucide-react"; -import { Card, CardTitle, Table, Td, Tag, NoData, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; +import { Card, CardTitle, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; import type { DecisionData } from "@/roundtable-planning/dashboard-agent/sq-report"; // ── 占位数据 ────────────────────────────────────────────────────────────────── @@ -229,6 +229,7 @@ function DecisionPlaceholder() { // ── 真实数据渲染 ────────────────────────────────────────────────────────────── function DecisionReal({ data }: { data: DecisionData }) { + const ds = useDesensitize(); return (
{/* 任务信息 + 作战目的 */} @@ -238,13 +239,13 @@ function DecisionReal({ data }: { data: DecisionData }) {
{data.taskTitle && (
-
任务标题
+
{ds("任务标题")}

{data.taskTitle}

)} {data.taskIntro && (
-
任务简介
+
{ds("任务简介")}

{data.taskIntro}

)} diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx index b7440f2c..875e302b 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx @@ -22,7 +22,7 @@ import { type LucideIcon, } from "lucide-react"; -import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; +import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; import type { DisposalData } from "@/roundtable-planning/dashboard-agent/sq-report"; // ── 占位数据 ────────────────────────────────────────────────────────────────── @@ -219,27 +219,56 @@ function DisposalReal({ data }: { data: DisposalData }) {
)} - {/* 处置建议表 */} + {/* 处置建议:每条建议一张卡片(建议内容作标题 + 其余字段做带标签的两列网格), + 替代原 7 列大表——内容多时更易读、不再拥挤。 */} {data.items.length > 0 && ( -
- - {data.items.map((d, i) => ( - - - - - - - - - - ))} -
{i + 1}{d.content || "—"}{d.method || "—"}{d.tone || "—"}{d.effect || "—"}{d.subject || "—"}{d.basis || "—"}
+
+ {data.items.map((d, i) => ( +
+ {/* 标题行:序号 + 建议内容 + 口径基调 */} +
+ + {i + 1} + +
+ {d.content || "—"} +
+ {d.tone && ( + + {d.tone} + + )} +
+ {/* 其余字段:带标签两列网格 */} + {(d.method || d.effect || d.subject || d.basis) && ( +
+ {d.method && } + {d.effect && } + {d.subject && } + {d.basis && } +
+ )} +
+ ))}
)}
); } + +/** 处置建议卡片里的「标签 + 值」字段(标签红色小字,值正文)。 */ +function Field({ label, value }: { label: string; value: string }) { + const ds = useDesensitize(); + return ( +
+
{ds(label)}
+
{value}
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/EnemyDetail.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/EnemyDetail.tsx index 89dfff1d..290c4dcd 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/EnemyDetail.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/EnemyDetail.tsx @@ -265,9 +265,8 @@ function EnemyPlaceholder() { function EnemyReal({ data }: { data: EnemyData }) { return (
-
- {data.targets.length > 0 && ( - + {data.targets.length > 0 && ( + } />
@@ -283,49 +282,50 @@ function EnemyReal({ data }: { data: EnemyData }) { )} - {data.events.length > 0 && ( - - -
- {data.events.map((g, i) => ( -
-
- {g.topic || "事件"} - {g.count} -
-
    - {g.items.map((e, j) => ( -
  • - {e.time} -
    -
    {e.title}
    - {e.brief &&
    {e.brief}
    } -
    -
  • - ))} -
-
- ))} + {data.narratives.length > 0 && ( + + +
+
+ {data.narratives.map((n, i) => ( + + + + + + + + ))} +
{n.topic || "—"}{n.narrator || "—"}{n.intention || "—"}{n.type || "—"}{n.summary || "—"}
)} -
- {data.narratives.length > 0 && ( + {data.events.length > 0 && ( - -
- - {data.narratives.map((n, i) => ( - - - - - - - - ))} -
{n.topic || "—"}{n.narrator || "—"}{n.intention || "—"}{n.type || "—"}{n.summary || "—"}
+ + {/* 时间线样式(参考示例图):左侧一条竖线,每个事件一个红点节点;时间在节点旁, + 标题/摘要放进右侧浅底小卡。仅用已有字段(时间/标题/摘要),不加图片等无数据元素。 */} +
+ {data.events.map((g, i) => ( +
+
{g.topic || "事件"}
+
    + {g.items.map((e, j) => ( +
  1. + + {e.time && ( +
    {e.time}
    + )} +
    +
    {e.title}
    + {e.brief &&
    {e.brief}
    } +
    +
  2. + ))} +
+
+ ))}
)} diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/EnvironmentDetail.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/EnvironmentDetail.tsx index 5fd94717..96e7c91a 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/EnvironmentDetail.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/EnvironmentDetail.tsx @@ -32,7 +32,7 @@ import { type LucideIcon, } from "lucide-react"; -import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; +import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; import type { EnvData, EnvSection } from "@/roundtable-planning/dashboard-agent/sq-report"; // ── 占位数据 ────────────────────────────────────────────────────────────────── @@ -329,10 +329,11 @@ function EnvCard({ field }: { field: EnvField }) { /** 行级区段标题(红色竖条 + 文字)。 */ function SectionLabel({ children }: { children: React.ReactNode }) { + const ds = useDesensitize(); return (
- {children} + {typeof children === "string" ? ds(children) : children}
); } diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/Judgement.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/Judgement.tsx index 0642bc62..0d8ba8aa 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/Judgement.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/Judgement.tsx @@ -11,7 +11,7 @@ */ import { Swords, ShieldX, ShieldCheck, ShieldAlert, Package, Scale, Landmark, Clock, AlertTriangle, Gavel, type LucideIcon } from "lucide-react"; -import { Card, CardTitle, FooterNote, Table, Td, Tag, Val, NoData, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; +import { Card, CardTitle, FooterNote, Table, Td, Tag, Val, NoData, MarkdownText, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui"; import type { JudgeData } from "@/roundtable-planning/dashboard-agent/sq-report"; // ── 占位数据 ────────────────────────────────────────────────────────────────── @@ -255,12 +255,15 @@ function JudgePlaceholder() { /** 红色编号区段标题(① 敌我优劣势分析 …)。 */ function NumberedHeading({ n, children }: { n: number; children: React.ReactNode }) { + const ds = useDesensitize(); return (
{n} -

{children}

+

+ {typeof children === "string" ? ds(children) : children} +

); } @@ -345,8 +348,10 @@ function JudgeReal({ data }: { data: JudgeData }) {
    {data.responses.map((r, i) => (
  1. - {i + 1} - {r} + {i + 1} +
    + {r} +
  2. ))}
diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/OurSituation.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/OurSituation.tsx index c5b3ff34..d39d75c8 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/OurSituation.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/OurSituation.tsx @@ -17,6 +17,7 @@ import { Table, Td, NoData, + useDesensitize, type Tone, } from "@/roundtable-planning/dashboard-agent/ui"; import type { OursData } from "@/roundtable-planning/dashboard-agent/sq-report"; @@ -250,6 +251,7 @@ function CalendarSection({ title, items }: { title: string; items: CalendarItem[ // ── 真实数据渲染 ────────────────────────────────────────────────────────────── function OursReal({ data }: { data: OursData }) { + const ds = useDesensitize(); const keywords = Array.from(new Set(data.narratives.flatMap((n) => n.tags))).slice(0, 24); return (
@@ -268,7 +270,7 @@ function OursReal({ data }: { data: OursData }) { {s.summary &&

{s.summary}

} {s.direction && (

- 导向: + {ds("导向")}: {s.direction}

)} diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/SituationOverview.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/SituationOverview.tsx index 2e908899..ef72b5d8 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/SituationOverview.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/SituationOverview.tsx @@ -24,7 +24,7 @@ import { type LucideIcon, } from "lucide-react"; -import { Card, CardTitle, FooterNote, NoData } from "@/roundtable-planning/dashboard-agent/ui"; +import { Card, CardTitle, FooterNote, NoData, useDesensitize } from "@/roundtable-planning/dashboard-agent/ui"; import type { SqReport } from "@/roundtable-planning/dashboard-agent/sq-report"; // ── 聚合 ────────────────────────────────────────────────────────────────────── @@ -44,6 +44,7 @@ interface OverviewColumn { // ── 主组件 ──────────────────────────────────────────────────────────────────── export function SituationOverview({ data }: { data?: SqReport | null }) { + const ds = useDesensitize(); const r = data; const hasAny = !!r && (!!r.enemy || !!r.ours || !!r.environment || !!r.judgement || !!r.decision || !!r.disposal); @@ -123,7 +124,7 @@ export function SituationOverview({ data }: { data?: SqReport | null }) { - {m.label} + {ds(m.label)}
{m.value}
@@ -139,7 +140,7 @@ export function SituationOverview({ data }: { data?: SqReport | null }) {
  • - {s.label}: + {ds(s.label)}: {s.text}

  • @@ -158,14 +159,14 @@ export function SituationOverview({ data }: { data?: SqReport | null }) {
    {col.rows.map((row) => (
    -
    {row.label}
    +
    {ds(row.label)}
    {row.value}
    ))}
    {col.focus.length > 0 && (
    -
    {col.focusTitle}
    +
    {ds(col.focusTitle)}
      {col.focus.map((f, i) => (
    • diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/sq-report.ts b/frontend-web/src/roundtable-planning/dashboard-agent/sq-report.ts index b0d7bff8..8e1e947a 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/sq-report.ts +++ b/frontend-web/src/roundtable-planning/dashboard-agent/sq-report.ts @@ -28,6 +28,38 @@ function strArr(v: unknown): string[] { return asArray(v).map(asStr).filter((s) => s.trim().length > 0); } +/** + * 过滤掉自由文本里的「引用角标」标记,做得**通用**: + * - 独立角标:`[[1]]`、`[[12]]`、`[[1,3]]` 等(方括号里任意非括号内容); + * - 带中文标签:`资料[[1]]`、`来源[[2]]`、`参考[[3]]` …; + * - 括号包裹:`(资料[[1]])`、`(来源[[12]])`、`([[3]])` …(中英文括号)。 + * 删除后再清理残留的空括号与多余空白。用于所有展示文本(解析末尾整树清洗)。 + */ +function stripCitations(s: string): string { + if (!s) return s; + return s + // 1) 括号包裹且内部含 [[..]] 的整体删除(不跨越其它括号) + .replace(/[((][^(())]*?\[\[[^[\]]+\]\][^(())]*?[))]/g, "") + // 2) 可选中文标签 + [[..]],或独立 [[..]] + .replace(/(?:资料|来源|参考|出处|引用|文献|注|脚注)?\s*\[\[[^[\]]+\]\]/g, "") + // 3) 残留空括号 + 多余空白 + .replace(/[((]\s*[))]/g, "") + .replace(/[ \t]{2,}/g, " ") + .trim(); +} + +/** 深度清洗:递归把对象/数组里所有字符串过一遍 {@link stripCitations},返回新值。 */ +function deepStripCitations(value: T): T { + if (typeof value === "string") return stripCitations(value) as unknown as T; + if (Array.isArray(value)) return value.map(deepStripCitations) as unknown as T; + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = deepStripCitations(v); + return out as T; + } + return value; +} + /** 取 categories 容器(输入即 fetchDashboardInputJson 的返回)。 */ export function getSqCategories(input: unknown): Record | null { const o = asObj(input); @@ -185,7 +217,11 @@ function adaptEnv(cats: Record): EnvData | null { const toSections = (raw: unknown): EnvSection[] => Object.entries(asObj(raw)) .filter(([k]) => k !== "type") - .map(([title, v]) => ({ title, paragraphs: strArr(v) })) + // 段落里过滤掉「引用角标」([[1]] / 资料[[1]] / (资料[[1]]) 等),过滤后为空的段落丢弃 + .map(([title, v]) => ({ + title: stripCitations(title), + paragraphs: strArr(v).map(stripCitations).filter((p) => p.length > 0), + })) .filter((s) => s.paragraphs.length > 0); const info = toSections(cats["信息环境"]); const human = toSections(cats["人文环境"]); @@ -278,12 +314,13 @@ export function parseSqReport(input: unknown): SqReport { if (!cats) { return { enemy: null, ours: null, environment: null, judgement: null, decision: null, disposal: null }; } - return { + // 整树清洗:所有类目的所有字符串字段统一过滤引用角标(通用,不止战场环境)。 + return deepStripCitations({ enemy: adaptEnemy(cats), ours: adaptOurs(cats), environment: adaptEnv(cats), judgement: adaptJudge(cats), decision: adaptDecision(cats), disposal: adaptDisposal(cats), - }; + }); } diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/ui.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/ui.tsx index 5e20b994..7be18190 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/ui.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/ui.tsx @@ -4,14 +4,39 @@ * **dashboard-agent 独占**:仅供战情系列静态页(SituationOverview / EnemyDetail / …) * 复用,不影响会商共享的 DashboardApp / dashboard 组件群。配色对齐设计图(白底 + 红色强调)。 */ +import { createContext, useContext, type ReactNode } from "react"; import { ChevronRight, Inbox, type LucideIcon } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { desensitize } from "@/lib/desensitize"; +import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; + +/** + * 战情页**展示层脱敏函数**的 Context。 + * + * 由 `DashboardAgentPage` 注入「后台敏感词(/api/sensitive-words)」版本(与「敏感词管理」 + * 配置页同源),让本文件所有公共 UI 片段渲染的**静态文案**(卡片标题 / 表头 / 徽标 / 脚注 等, + * 内含 目标→MB、叙事→XS 这类映射词)也统一脱敏;缺省回退全局种子 {@link desensitize}。 + * 数据文本已在数据层脱敏,这里再过一遍是幂等的(替换词为拉丁码,不会二次命中中文原词)。 + */ +const DesensitizeContext = createContext<(s: string) => string>(desensitize); +export const DesensitizeProvider = DesensitizeContext.Provider; +export function useDesensitize() { + return useContext(DesensitizeContext); +} +/** children 若是纯字符串则脱敏,否则原样返回(元素/数字等不动)。 */ +function dsNode(fn: (s: string) => string, node: ReactNode): ReactNode { + return typeof node === "string" ? fn(node) : node; +} /** 空数据占位(接口返回为空 / 该类目缺失时显示)。 */ export function NoData({ label = "暂无数据" }: { label?: string }) { + const ds = useDesensitize(); return (
      - {label} + {ds(label)}
      ); } @@ -39,6 +64,7 @@ export function CardTitle({ badges?: React.ReactNode; action?: React.ReactNode; }) { + const ds = useDesensitize(); return (
      @@ -46,7 +72,7 @@ export function CardTitle({

      - {title} + {ds(title)}

      {badges &&
      {badges}
      }
      @@ -57,23 +83,25 @@ export function CardTitle({ /** 标题徽标药丸(如「组织目标 12 个」,数字红色高亮)。 */ export function HeadBadge({ label, value }: { label: string; value: string }) { + const ds = useDesensitize(); return ( - {label} - {value} + {ds(label)} + {ds(value)} ); } /** 「更多 >」操作按钮(标题右侧,可自定义文案)。 */ export function MoreLink({ label = "更多", onClick }: { label?: string; onClick?: () => void }) { + const ds = useDesensitize(); return ( ); @@ -81,6 +109,7 @@ export function MoreLink({ label = "更多", onClick }: { label?: string; onClic /** 卡片底部「查看完整… >」链接条(顶部分隔线 + 红色链接)。 */ export function CardFooterLink({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) { + const ds = useDesensitize(); return (
      @@ -107,9 +136,10 @@ const TONE_CLASS: Record = { /** 语义色块徽标(高/中/低、上升/持平/下降、偏高 等)。 */ export function Tag({ tone, children }: { tone: Tone; children: React.ReactNode }) { + const ds = useDesensitize(); return ( - {children} + {dsNode(ds, children)} ); } @@ -124,37 +154,44 @@ const VAL_TEXT: Record = { /** 语义色取值文字(键值行右侧的 高/中/充足/紧张 等,以颜色表强弱)。 */ export function Val({ tone, children }: { tone: Tone; children: React.ReactNode }) { - return {children}; + const ds = useDesensitize(); + return {dsNode(ds, children)}; } /** 紧凑表格(表头灰字 + 行细分隔线,对齐设计图密集表格)。 */ export function Table({ head, children }: { head: string[]; children: React.ReactNode }) { + const ds = useDesensitize(); + // 外层 overflow-x-auto:字号抬到 16px 后,多列密集表(如 8 列)超宽时横向滚动而非撑破卡片,保证布局不错乱。 + // 不加负 margin:表格左右须与卡片标题/正文同在 px-4 基线上,否则整张表相对标题左移看着错位。 return ( - - - - {head.map((h) => ( - - ))} - - - {children} -
      - {h} -
      +
      + + + + {head.map((h) => ( + + ))} + + + {children} +
      + {ds(h)} +
      +
      ); } /** 表格数据单元(统一内边距 + 细分隔线 + 顶对齐)。 */ export function Td({ className = "", children }: { className?: string; children?: React.ReactNode }) { + const ds = useDesensitize(); return ( - {children} + {dsNode(ds, children)} ); } @@ -178,10 +215,28 @@ export function FooterNote({ : "text-red-600"; const bg = tone === "green" ? "bg-emerald-50 dark:bg-emerald-500/10" : tone === "blue" ? "bg-sky-50 dark:bg-sky-500/10" : "bg-red-50 dark:bg-red-500/10"; + const ds = useDesensitize(); return (
      - {label}: - {children} + {ds(label)}: + {dsNode(ds, children)} +
      + ); +} + +/** + * 富文本(Markdown)渲染——战情数据里部分字段(如「应对方案」)带 Markdown 标记, + * 直接当纯文本渲染会露出 `**`/`-`/`#` 等源码。这里用与圆桌报告同一套 + * {@link markdownComponents}(react-markdown + remark-gfm)渲染,并先脱敏。 + * 纯文本传入也能正常渲染(按段落输出),可安全套用。 + */ +export function MarkdownText({ children }: { children: string }) { + const ds = useDesensitize(); + return ( +
      + + {ds(children ?? "")} +
      ); } diff --git a/frontend-web/src/roundtable-planning/dashboard/DashboardApp.tsx b/frontend-web/src/roundtable-planning/dashboard/DashboardApp.tsx index 91816cd3..c636df23 100644 --- a/frontend-web/src/roundtable-planning/dashboard/DashboardApp.tsx +++ b/frontend-web/src/roundtable-planning/dashboard/DashboardApp.tsx @@ -101,12 +101,15 @@ export function DashboardApp({ )}
      -
      -
      - - 生成于 {data.meta.generatedAt} + {/* 战情多页(dashboard-agent)不展示「生成于」时间标签;会商 Step3 大屏保留。 */} + {!usingPages && ( +
      +
      + + 生成于 {data.meta.generatedAt} +
      -
      + )} {/* 导航 Tab */} diff --git a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx index a7374625..bbafd103 100644 --- a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx @@ -15,21 +15,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; -import { - ArrowLeft, - Boxes, - Code2, - ExternalLink, - LayoutPanelTop, - Loader2, - Pencil, - RefreshCw, - Sparkles, -} from "lucide-react"; +import { Loader2, RefreshCw, Sparkles } from "lucide-react"; import { fetchDashboardInputJson } from "@/core/auth/dashboard-input"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; -import { desensitizeDeep } from "@/lib/desensitize"; +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"; @@ -43,6 +34,7 @@ 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"; @@ -66,6 +58,22 @@ function formatNow(): string { 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(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 = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = deepDesensitize(v, fn); + return out as T; + } + return value; +} + type DashView = "chat" | "page"; type RenderMode = "app" | "html"; @@ -175,8 +183,14 @@ export default function DashboardAgentPage() { inputRef.current = input; // 解析战情报告 → 各页面展示模型(容错:异常/无数据均不抛错,页面回退占位)。 try { - setSqReport(parseSqReport(input)); - } catch { + 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) ?? []; @@ -239,8 +253,17 @@ export default function DashboardAgentPage() { 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 ? desensitizeDeep(data) : null), [data]); + const report = useMemo(() => (data ? deepDesensitize(data, desensitizeText) : null), [data, desensitizeText]); const dashboardData = useMemo( () => (report ? adaptReportData(report, formatNow()) : undefined), [report], @@ -251,28 +274,33 @@ export default function DashboardAgentPage() { ); const hasResult = !!dashboardData || !!dashboardHtml; - // 展示层脱敏:所有页面文本经 desensitizeDeep 深度替换后再渲染(与会商大屏一致)。 + // 展示层脱敏:所有页面文本经深度替换后再渲染(与会商大屏一致),脱敏函数来自后台敏感词查询。 const safeReport = useMemo(() => { if (!sqReport) return null; try { - return desensitizeDeep(sqReport) as SqReport; + return deepDesensitize(sqReport, desensitizeText) as SqReport; } catch { return sqReport; // 脱敏异常不影响展示 } - }, [sqReport]); + }, [sqReport, desensitizeText]); + + // 是否有可展示的真实数据:战情报告(safeReport)或已生成的大屏(report-json / HTML)。 + // 三者皆无 → 不渲染大屏的导航栏与标题,仅显示「暂无数据」。 + const hasData = !!safeReport || !!dashboardData || !!dashboardHtml; // 战情多页:总览 + 六个详情页;详情页传入对应的(脱敏后)真实数据(无则页面回退占位)。 + // 导航 Tab 文本(label)也经 desensitizeText 脱敏后再传给 DashboardApp 渲染导航栏。 const dashPages: DashboardPage[] = useMemo( () => [ - { id: "overview", label: "总览", icon: , content: }, - { id: "enemy", label: "敌情", icon: , content: }, - { id: "ours", label: "我情", icon: , content: }, - { id: "field", label: "战场环境", icon: , content: }, - { id: "judge", label: "情况判断", icon: , content: }, - { id: "decision", label: "决心建议", icon: , content: }, - { id: "disposal", label: "处置建议", icon: , content: }, + { id: "overview", label: desensitizeText("总览"), icon: , content: }, + { id: "enemy", label: desensitizeText("敌情"), icon: , content: }, + { id: "ours", label: desensitizeText("我情"), icon: , content: }, + { id: "field", label: desensitizeText("战场环境"), icon: , content: }, + { id: "judge", label: desensitizeText("情况判断"), icon: , content: }, + { id: "decision", label: desensitizeText("决心建议"), icon: , content: }, + { id: "disposal", label: desensitizeText("处置建议"), icon: , content: }, ], - [safeReport], + [safeReport, desensitizeText], ); // 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。 @@ -368,85 +396,8 @@ export default function DashboardAgentPage() { } return ( -
      - {/* 顶栏 */} -
      -
      - -

      大屏绘制智能体

      - - {(phase === "creating" || phase === "streaming") && } - {view === "chat" ? "生成过程" : "大屏页面"} - -
      - -
      - {view === "page" ? ( - <> -
      - - -
      - - - - ) : ( - hasResult && ( - - ) - )} -
      -
      - + // dashboard-agent-fontscale:本页字号下限 16px(见 globals.css,仅作用于本页)。 +
      {/* 主体 */} {loading ? ( // 加载框:请求战情报告期间显示,拿到数据后再渲染大屏(避免先闪占位再跳真实数据)。 @@ -458,7 +409,12 @@ export default function DashboardAgentPage() {
      ) : view === "page" ? ( - renderMode === "html" && htmlBlobUrl ? ( + !hasData ? ( + // 无数据:隐藏大屏导航栏与顶部标题,仅显示「暂无数据」。 +
      + 暂无数据 +
      + ) : renderMode === "html" && htmlBlobUrl ? (