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:
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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user