feat(大屏绘制): 战情大屏样式优化与脱敏全覆盖

- 去掉顶栏与「生成于」时间标签;无数据时仅显示「暂无数据」、隐藏导航与标题
- 战情各页 + 导航 Tab 文本全部脱敏(响应式取后台敏感词,避免 replaceDesensitizeMap 时序竞态)
- 全页最小字号 16px,调整行高/对齐;表格加横向滚动防溢出
- 关键事件改时间线样式(灰色虚线 + 浅红空心圆圈节点),敌情三块改单行全宽
- 应对方案渲染 Markdown;处置建议由 7 列大表改为单条卡片(字段标签改 #222222)
- 通用过滤引用角标([[1]] / 资料[[1]] / (资料[[1]]) 等,解析末尾整树清洗)
- safelist + 本页兜底 CSS 修复 border 语义色回退成灰色(时间线圆圈/FooterNote 强调条)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-23 12:36:36 +08:00
1 parent fc632f26e9
commit 1a2ef0a537
12 files changed
+339 -213

No files matched your search

@@ -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 (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 任务信息 + 作战目的 */}
@@ -238,13 +239,13 @@ function DecisionReal({ data }: { data: DecisionData }) {
<div className="flex-1 px-4 py-3.5">
{data.taskTitle && (
<div className="mb-2">
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">任务标题</div>
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{ds("任务标题")}</div>
<p className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{data.taskTitle}</p>
</div>
)}
{data.taskIntro && (
<div className="border-t border-slate-100 pt-2.5 dark:border-[var(--dash-border)]">
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">任务简介</div>
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{ds("任务简介")}</div>
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{data.taskIntro}</p>
</div>
)}
@@ -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 }) {
</div>
)}
{/* 处置建议表 */}
{/* 处置建议:每条建议一张卡片(建议内容作标题 + 其余字段做带标签的两列网格),
替代原 7 列大表——内容多时更易读、不再拥挤。 */}
{data.items.length > 0 && (
<Card>
<CardTitle icon={ClipboardList} title="处置建议" />
<div className="px-4 py-3.5">
<Table head={["序号", "建议内容", "处置方式与步骤", "口径基调", "预期效果", "主责单位", "立场与原则"]}>
{data.items.map((d, i) => (
<tr key={i}>
<Td className="font-mono text-slate-400">{i + 1}</Td>
<Td className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{d.content || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.method || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{d.tone || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.effect || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.subject || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.basis || "—"}</Td>
</tr>
))}
</Table>
<div className="space-y-3 px-4 py-3.5">
{data.items.map((d, i) => (
<div
key={i}
className="rounded-md border border-slate-200 bg-slate-50/60 p-4 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]"
>
{/* 标题行:序号 + 建议内容 + 口径基调 */}
<div className="mb-3 flex items-start gap-2.5">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{i + 1}
</span>
<div className="min-w-0 flex-1 text-xs font-semibold leading-relaxed text-slate-800 dark:text-[var(--dash-text-strong)]">
{d.content || "—"}
</div>
{d.tone && (
<span className="shrink-0">
<Tag tone="amber">{d.tone}</Tag>
</span>
)}
</div>
{/* 其余字段:带标签两列网格 */}
{(d.method || d.effect || d.subject || d.basis) && (
<dl className="grid grid-cols-1 gap-x-6 gap-y-2.5 border-t border-dashed border-slate-200 pt-3 dark:border-[var(--dash-border)] sm:grid-cols-2">
{d.method && <Field label="处置方式与步骤" value={d.method} />}
{d.effect && <Field label="预期效果" value={d.effect} />}
{d.subject && <Field label="主责单位" value={d.subject} />}
{d.basis && <Field label="立场与原则" value={d.basis} />}
</dl>
)}
</div>
))}
</div>
</Card>
)}
</div>
);
}
/** 处置建议卡片里的「标签 + 值」字段(标签红色小字,值正文)。 */
function Field({ label, value }: { label: string; value: string }) {
const ds = useDesensitize();
return (
<div className="text-xs">
<dt className="mb-0.5 font-semibold text-[#222222] dark:text-[var(--dash-text-strong)]">{ds(label)}</dt>
<dd className="leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{value}</dd>
</div>
);
}
@@ -265,9 +265,8 @@ function EnemyPlaceholder() {
function EnemyReal({ data }: { data: EnemyData }) {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{data.targets.length > 0 && (
<Card className="flex flex-col lg:col-span-5">
{data.targets.length > 0 && (
<Card>
<CardTitle icon={Target} title="敌方目标" badges={<HeadBadge label="目标数" value={`${data.targets.length}`} />} />
<div className="flex-1 px-4 py-3.5">
<Table head={["名称", "类别", "说明"]}>
@@ -283,49 +282,50 @@ function EnemyReal({ data }: { data: EnemyData }) {
</Card>
)}
{data.events.length > 0 && (
<Card className="flex flex-col lg:col-span-7">
<CardTitle icon={CalendarClock} title="关键事件" />
<div className="flex-1 space-y-3.5 px-4 py-3.5">
{data.events.map((g, i) => (
<div key={i}>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{g.topic || "事件"}</span>
<Tag tone="red">{g.count}</Tag>
</div>
<ul className="space-y-2">
{g.items.map((e, j) => (
<li key={j} className="flex gap-2.5 text-xs">
<span className="w-24 shrink-0 whitespace-nowrap font-mono text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{e.time}</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{e.title}</div>
{e.brief && <div className="mt-0.5 line-clamp-2 leading-snug text-slate-500 dark:text-[var(--dash-text-sec)]">{e.brief}</div>}
</div>
</li>
))}
</ul>
</div>
))}
{data.narratives.length > 0 && (
<Card>
<CardTitle icon={Users} title="对手叙事" />
<div className="flex-1 px-4 py-3.5">
<Table head={["主题", "叙事方", "叙事意图", "类型", "概述"]}>
{data.narratives.map((n, i) => (
<tr key={i}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.topic || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.narrator || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.intention || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.type || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.summary || "—"}</Td>
</tr>
))}
</Table>
</div>
</Card>
)}
</div>
{data.narratives.length > 0 && (
{data.events.length > 0 && (
<Card>
<CardTitle icon={Users} title="对手叙事" />
<div className="px-4 py-3.5">
<Table head={["主题", "叙事方", "叙事意图", "类型", "概述"]}>
{data.narratives.map((n, i) => (
<tr key={i}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.topic || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.narrator || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.intention || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.type || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.summary || "—"}</Td>
</tr>
))}
</Table>
<CardTitle icon={CalendarClock} title="关键事件" />
{/* 时间线样式(参考示例图):左侧一条竖线,每个事件一个红点节点;时间在节点旁,
标题/摘要放进右侧浅底小卡。仅用已有字段(时间/标题/摘要),不加图片等无数据元素。 */}
<div className="space-y-5 px-4 py-3.5">
{data.events.map((g, i) => (
<div key={i}>
<div className="mb-3 text-xs font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{g.topic || "事件"}</div>
<ol className="relative ml-2 border-l-2 border-dashed border-slate-300 text-xs dark:border-[var(--dash-border)]">
{g.items.map((e, j) => (
<li key={j} className="mb-5 ml-5 last:mb-0">
<span className="absolute -left-[7px] mt-1 h-3 w-3 rounded-full border-2 border-red-400 bg-red-50 dark:bg-[var(--dash-card)]" />
{e.time && (
<div className="mb-1 font-mono text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{e.time}</div>
)}
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]">
<div className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{e.title}</div>
{e.brief && <div className="mt-1 leading-relaxed text-slate-500 dark:text-[var(--dash-text-sec)]">{e.brief}</div>}
</div>
</li>
))}
</ol>
</div>
))}
</div>
</Card>
)}
@@ -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 (
<div className="flex items-center gap-2 pt-1 text-sm font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">
<span className="h-3.5 w-1 rounded-full bg-red-600" />
{children}
{typeof children === "string" ? ds(children) : children}
</div>
);
}
@@ -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 (
<div className="flex items-center gap-2 pt-1">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{n}
</span>
<h2 className="text-sm font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">{children}</h2>
<h2 className="text-sm font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">
{typeof children === "string" ? ds(children) : children}
</h2>
</div>
);
}
@@ -345,8 +348,10 @@ function JudgeReal({ data }: { data: JudgeData }) {
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{data.responses.map((r, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{r}</span>
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<div className="min-w-0 flex-1 text-slate-600 dark:text-[var(--dash-text)]">
<MarkdownText>{r}</MarkdownText>
</div>
</li>
))}
</ol>
@@ -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 (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
@@ -268,7 +270,7 @@ function OursReal({ data }: { data: OursData }) {
{s.summary && <p className="line-clamp-3 text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{s.summary}</p>}
{s.direction && (
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-slate-400 dark:text-[var(--dash-text-dim)]">
<span className="font-semibold text-red-600">导向:</span>
<span className="font-semibold text-red-600">{ds("导向")}:</span>
{s.direction}
</p>
)}
@@ -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 }) {
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-red-50 text-red-600 dark:bg-red-500/15">
<m.icon className="h-4 w-4" />
</span>
<span className="text-xs font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">{m.label}</span>
<span className="text-xs font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">{ds(m.label)}</span>
</div>
<div className="font-mono text-3xl font-black leading-none text-slate-900 dark:text-[var(--dash-text-strong)]">{m.value}</div>
</Card>
@@ -139,7 +140,7 @@ export function SituationOverview({ data }: { data?: SqReport | null }) {
<li key={s.label} className="flex gap-2.5 text-[13px] leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<p className="text-slate-600 dark:text-[var(--dash-text)]">
<span className="font-bold text-red-600">{s.label}:</span>
<span className="font-bold text-red-600">{ds(s.label)}:</span>
{s.text}
</p>
</li>
@@ -158,14 +159,14 @@ export function SituationOverview({ data }: { data?: SqReport | null }) {
<dl className="space-y-2">
{col.rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-2 text-xs">
<dt className="shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{row.label}</dt>
<dt className="shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{ds(row.label)}</dt>
<dd className="text-right font-medium text-slate-800 dark:text-[var(--dash-text-strong)]">{row.value}</dd>
</div>
))}
</dl>
{col.focus.length > 0 && (
<div className="border-t border-dashed border-slate-100 pt-3 dark:border-[var(--dash-border)] sm:border-l sm:border-t-0 sm:pl-4 sm:pt-0">
<div className="mb-2 text-[11px] font-semibold text-red-600">{col.focusTitle}</div>
<div className="mb-2 text-[11px] font-semibold text-red-600">{ds(col.focusTitle)}</div>
<ul className="space-y-1.5">
{col.focus.map((f, i) => (
<li key={i} className="flex gap-1.5 text-xs leading-snug text-slate-600 dark:text-[var(--dash-text)]">
@@ -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<T>(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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = deepStripCitations(v);
return out as T;
}
return value;
}
/** 取 categories 容器(输入即 fetchDashboardInputJson 的返回)。 */
export function getSqCategories(input: unknown): Record<string, unknown> | null {
const o = asObj(input);
@@ -185,7 +217,11 @@ function adaptEnv(cats: Record<string, unknown>): 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),
};
});
}
@@ -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 (
<div className="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-2 py-20 text-slate-400 dark:text-[var(--dash-text-dim)]">
<Inbox className="h-10 w-10 opacity-40" />
<span className="text-sm">{label}</span>
<span className="text-sm">{ds(label)}</span>
</div>
);
}
@@ -39,6 +64,7 @@ export function CardTitle({
badges?: React.ReactNode;
action?: React.ReactNode;
}) {
const ds = useDesensitize();
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 dark:border-[var(--dash-border)]">
<div className="flex min-w-0 items-center gap-2">
@@ -46,7 +72,7 @@ export function CardTitle({
<Icon className="h-3.5 w-3.5" />
</span>
<h3 className="shrink-0 text-sm font-bold tracking-tight text-slate-900 dark:text-[var(--dash-text-strong)]">
{title}
{ds(title)}
</h3>
{badges && <div className="flex min-w-0 flex-wrap items-center gap-1.5">{badges}</div>}
</div>
@@ -57,23 +83,25 @@ export function CardTitle({
/** 标题徽标药丸(如「组织目标 12 个」,数字红色高亮)。 */
export function HeadBadge({ label, value }: { label: string; value: string }) {
const ds = useDesensitize();
return (
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5 text-[11px] text-slate-500 dark:bg-white/5 dark:text-[var(--dash-text-sec)]">
{label}
<b className="font-bold text-red-600">{value}</b>
{ds(label)}
<b className="font-bold text-red-600">{ds(value)}</b>
</span>
);
}
/** 「更多 >」操作按钮(标题右侧,可自定义文案)。 */
export function MoreLink({ label = "更多", onClick }: { label?: string; onClick?: () => void }) {
const ds = useDesensitize();
return (
<button
type="button"
onClick={onClick}
className="inline-flex shrink-0 items-center gap-0.5 text-xs font-medium text-red-600 hover:underline"
>
{label}
{ds(label)}
<ChevronRight className="h-3.5 w-3.5" />
</button>
);
@@ -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 (
<div className="mt-auto border-t border-slate-100 px-4 py-2.5 dark:border-[var(--dash-border)]">
<button
@@ -88,7 +117,7 @@ export function CardFooterLink({ children, onClick }: { children: React.ReactNod
onClick={onClick}
className="inline-flex items-center gap-0.5 text-xs font-medium text-red-600 hover:underline"
>
{children}
{dsNode(ds, children)}
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
@@ -107,9 +136,10 @@ const TONE_CLASS: Record<Tone, string> = {
/** 语义色块徽标(高/中/低、上升/持平/下降、偏高 等)。 */
export function Tag({ tone, children }: { tone: Tone; children: React.ReactNode }) {
const ds = useDesensitize();
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-semibold ${TONE_CLASS[tone]}`}>
{children}
{dsNode(ds, children)}
</span>
);
}
@@ -124,37 +154,44 @@ const VAL_TEXT: Record<Tone, string> = {
/** 语义色取值文字(键值行右侧的 高/中/充足/紧张 等,以颜色表强弱)。 */
export function Val({ tone, children }: { tone: Tone; children: React.ReactNode }) {
return <span className={`font-semibold ${VAL_TEXT[tone]}`}>{children}</span>;
const ds = useDesensitize();
return <span className={`font-semibold ${VAL_TEXT[tone]}`}>{dsNode(ds, children)}</span>;
}
/** 紧凑表格(表头灰字 + 行细分隔线,对齐设计图密集表格)。 */
export function Table({ head, children }: { head: string[]; children: React.ReactNode }) {
const ds = useDesensitize();
// 外层 overflow-x-auto:字号抬到 16px 后,多列密集表(如 8 列)超宽时横向滚动而非撑破卡片,保证布局不错乱。
// 不加负 margin:表格左右须与卡片标题/正文同在 px-4 基线上,否则整张表相对标题左移看着错位。
return (
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-left text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">
{head.map((h) => (
<th
key={h}
className="border-b border-slate-100 px-1.5 pb-2 font-semibold first:pl-0 last:pr-0 dark:border-[var(--dash-border)]"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-left text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">
{head.map((h) => (
<th
key={h}
className="border-b border-slate-100 px-1.5 pb-2 font-semibold first:pl-0 last:pr-0 dark:border-[var(--dash-border)]"
>
{ds(h)}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
/** 表格数据单元(统一内边距 + 细分隔线 + 顶对齐)。 */
export function Td({ className = "", children }: { className?: string; children?: React.ReactNode }) {
const ds = useDesensitize();
return (
<td
className={`border-b border-slate-50 px-1.5 py-2 align-top leading-relaxed first:pl-0 last:pr-0 dark:border-[var(--dash-border)] ${className}`}
>
{children}
{dsNode(ds, children)}
</td>
);
}
@@ -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 (
<div className={`mx-4 mb-3.5 rounded border-l-2 ${border} ${bg} px-3 py-2 text-[11px] leading-relaxed`}>
<span className={`font-semibold ${labelColor}`}>{label}:</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{children}</span>
<span className={`font-semibold ${labelColor}`}>{ds(label)}:</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{dsNode(ds, children)}</span>
</div>
);
}
/**
* 富文本(Markdown)渲染——战情数据里部分字段(如「应对方案」)带 Markdown 标记,
* 直接当纯文本渲染会露出 `**`/`-`/`#` 等源码。这里用与圆桌报告同一套
* {@link markdownComponents}(react-markdown + remark-gfm)渲染,并先脱敏。
* 纯文本传入也能正常渲染(按段落输出),可安全套用。
*/
export function MarkdownText({ children }: { children: string }) {
const ds = useDesensitize();
return (
<div className="dash-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{ds(children ?? "")}
</ReactMarkdown>
</div>
);
}
@@ -101,12 +101,15 @@ export function DashboardApp({
)}
</div>
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
{/* 战情多页(dashboard-agent)不展示「生成于」时间标签;会商 Step3 大屏保留。 */}
{!usingPages && (
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
</div>
)}
</header>
{/* 导航 Tab */}
@@ -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<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";
@@ -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<SqReport | null>(() => {
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: <Compass className="h-4 w-4" />, content: <SituationOverview data={safeReport} /> },
{ id: "enemy", label: "敌情", icon: <Crosshair className="h-4 w-4" />, content: <EnemyDetail data={safeReport?.enemy} /> },
{ id: "ours", label: "我情", icon: <Shield className="h-4 w-4" />, content: <OurSituation data={safeReport?.ours} /> },
{ id: "field", label: "战场环境", icon: <Mountain className="h-4 w-4" />, content: <EnvironmentDetail data={safeReport?.environment} /> },
{ id: "judge", label: "情况判断", icon: <Lightbulb className="h-4 w-4" />, content: <Judgement data={safeReport?.judgement} /> },
{ id: "decision", label: "决心建议", icon: <Flag className="h-4 w-4" />, content: <Decision data={safeReport?.decision} /> },
{ id: "disposal", label: "处置建议", icon: <FileText className="h-4 w-4" />, content: <Disposal data={safeReport?.disposal} /> },
{ 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],
[safeReport, desensitizeText],
);
// 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。
@@ -368,85 +396,8 @@ export default function DashboardAgentPage() {
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800 dark:text-slate-200">
{/* 顶栏 */}
<div className="dashboard-agent-topbar shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="flex items-center gap-2.5">
<Sparkles className="h-4.5 w-4.5 text-primary" />
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200/50 bg-[var(--brand-200)]/60 px-2 py-0.5 text-[10px] font-bold text-primary">
{(phase === "creating" || phase === "streaming") && <Loader2 className="h-3 w-3 animate-spin" />}
{view === "chat" ? "生成过程" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
{view === "page" ? (
<>
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
<button
type="button"
onClick={() => setRenderMode("app")}
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
renderMode === "app"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Boxes className="h-3.5 w-3.5" />
应用渲染
</button>
<button
type="button"
onClick={() => setRenderMode("html")}
disabled={!dashboardHtml}
title={dashboardHtml ? "用自包含 HTML 大屏渲染(与新窗口/下载一致)" : "尚无已生成的 HTML,先完成一次绘制"}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
renderMode === "html"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Code2 className="h-3.5 w-3.5" />
网页渲染
</button>
</div>
<button
type="button"
onClick={() => setView("chat")}
title="回到生成过程,加载聊天记录继续问答修改"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<Pencil className="h-3.5 w-3.5 text-slate-500" />
编辑
</button>
<button
type="button"
onClick={handleOpenNewWindow}
title={renderMode === "html" ? "新标签页打开自包含 HTML 大屏" : "新标签页打开应用内 React 大屏(全屏)"}
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<ExternalLink className="h-3.5 w-3.5 text-slate-500" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
title="查看已生成的大屏页面"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<LayoutPanelTop className="h-3.5 w-3.5 text-slate-500" />
查看大屏
</button>
)
)}
</div>
</div>
// 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 ? (
// 加载框:请求战情报告期间显示,拿到数据后再渲染大屏(避免先闪占位再跳真实数据)。
@@ -458,7 +409,12 @@ export default function DashboardAgentPage() {
</div>
</div>
) : view === "page" ? (
renderMode === "html" && htmlBlobUrl ? (
!hasData ? (
// 无数据:隐藏大屏导航栏与顶部标题,仅显示「暂无数据」。
<div className="flex flex-1 items-center justify-center text-sm text-slate-400">
暂无数据
</div>
) : renderMode === "html" && htmlBlobUrl ? (
<iframe
title="任务研讨大屏"
src={htmlBlobUrl}
@@ -467,7 +423,10 @@ export default function DashboardAgentPage() {
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<DashboardApp data={dashboardData} pages={dashPages} />
{/* 注入后台敏感词脱敏函数:让战情各页(含静态卡片标题/表头/脚注等)统一脱敏。 */}
<DesensitizeProvider value={desensitizeText}>
<DashboardApp data={dashboardData} pages={dashPages} />
</DesensitizeProvider>
</div>
)
) : inputError && dialogues.length === 0 ? (
+33
View File
@@ -34,6 +34,12 @@
@source inline("border-l-{2,4}");
@source inline("border-l-{slate,gray,primary}-{300,400,500}");
/* 战情大屏(dashboard-agent):时间线圆圈/虚线、FooterNote 左侧强调条等用到的
* 语义色边框——本项目边框色需显式 safelist,否则只生成宽度、颜色回退成灰色。
* 明、暗两套各一行。 */
@source inline("border-{red,emerald,sky,amber}-{200,300,400,500,600,700}");
@source inline("dark:border-{red,emerald,sky,amber}-{200,300,400,500,600,700}");
/* Link */
@source inline("text-{blue,primary,accent}-{500,600,700}");
@source inline("hover:text-{blue,primary,accent}-{600,700,800}");
@@ -656,6 +662,33 @@
border-color: #1a2f5a !important;
}
/* 「大屏绘制智能体」战情大屏:**全页最小字号 16px**(仅命中本页 wrapper
* .dashboard-agent-fontscale —— 加在 DashboardAgentPage 根容器上,会包住其下的
* DashboardApp 顶部标题/导航 Tab/页脚 + 七个战情子页)。把 10/11/12/13px 统一抬到 16px、
* 14px(text-sm) 抬到 18px,保留「正文 16 / 卡片标题 18(再叠 bold) / 指标大数 ≥20」的层级,
* 看上去自然不错乱;text-xl/2xl/3xl 本就 ≥16,保持原样不动。
* 作用域严格限定本页:会商共享的 DashboardApp(无此 wrapper)与其它页面零影响。 */
.dashboard-agent-fontscale
:is(.text-\[10px\], .text-\[11px\], .text-xs, .text-\[13px\]) {
font-size: 16px !important;
line-height: 1.75 !important;
}
.dashboard-agent-fontscale .text-sm {
font-size: 18px !important;
line-height: 1.65 !important;
}
/* 语义色边框兜底:本项目 border-red/emerald/sky 等颜色类未被 Tailwind 生成时只剩宽度、
* 颜色回退成灰色(时间线圆圈/虚线、FooterNote 左侧强调条)。这里在本页作用域内**直接定义**
* 这些类的 border-color(用 Tailwind v4 颜色变量,带 hex 兜底),不依赖 @source 安全列表的
* 重新生成,改完即时生效。仅命中 .dashboard-agent-fontscale 子树,不影响其它页面。 */
.dashboard-agent-fontscale .border-red-200 { border-color: var(--color-red-200, #fecaca) !important; }
.dashboard-agent-fontscale .border-red-300 { border-color: var(--color-red-300, #fca5a5) !important; }
.dashboard-agent-fontscale .border-red-400 { border-color: var(--color-red-400, #f87171) !important; }
.dashboard-agent-fontscale .border-red-500 { border-color: var(--color-red-500, #ef4444) !important; }
.dashboard-agent-fontscale .border-emerald-500 { border-color: var(--color-emerald-500, #10b981) !important; }
.dashboard-agent-fontscale .border-sky-500 { border-color: var(--color-sky-500, #0ea5e9) !important; }
.workspace-panel {
border: 1px solid #f0d7d0;
background: #ffffff;