594 lines
26 KiB
TypeScript
594 lines
26 KiB
TypeScript
import ReactECharts from "echarts-for-react";
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
Check,
|
||
ChevronRight,
|
||
Clock3,
|
||
Database,
|
||
FileSearch,
|
||
Focus,
|
||
Map as MapIcon,
|
||
Minimize2,
|
||
PanelRightClose,
|
||
PanelRightOpen,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
Trash2,
|
||
X
|
||
} from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { showMapNotice } from "@/features/map/core";
|
||
import { cn } from "@/shared/ui/cn";
|
||
import type {
|
||
AnalysisArtifact,
|
||
AnalysisArtifactAction,
|
||
AnalysisBlock,
|
||
AnalysisMetric,
|
||
ArtifactRequestedView,
|
||
WorkbenchSurfaceMode
|
||
} from "./workspace-model";
|
||
|
||
type AnalysisArtifactWorkspaceProps = {
|
||
artifact: AnalysisArtifact;
|
||
surfaceMode: WorkbenchSurfaceMode;
|
||
mapSplitAvailable: boolean;
|
||
onSetView: (view: ArtifactRequestedView) => void;
|
||
onCollapse: () => void;
|
||
onDestroy: () => void;
|
||
};
|
||
|
||
export function AnalysisArtifactWorkspace({
|
||
artifact,
|
||
surfaceMode,
|
||
mapSplitAvailable,
|
||
onSetView,
|
||
onCollapse,
|
||
onDestroy
|
||
}: AnalysisArtifactWorkspaceProps) {
|
||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
|
||
const selectedBlock = useMemo(
|
||
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
|
||
[artifact.blocks, selectedBlockId]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setSelectedBlockId(null);
|
||
}, [artifact.id]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedBlock) return;
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") setSelectedBlockId(null);
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [selectedBlock]);
|
||
|
||
return (
|
||
<article
|
||
aria-label={`${artifact.title}分析成果`}
|
||
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||
lang="zh-CN"
|
||
>
|
||
<ArtifactHeader artifact={artifact} />
|
||
|
||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||
<section aria-labelledby="artifact-summary-heading">
|
||
<div className="mb-3 flex items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
|
||
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
|
||
</div>
|
||
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
|
||
</div>
|
||
{metricBlock?.kind === "metric-grid" ? (
|
||
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
|
||
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
|
||
<div className="mb-3 flex items-center justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
|
||
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
|
||
</div>
|
||
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
|
||
</div>
|
||
<div className="grid grid-cols-12 gap-3.5">
|
||
{evidenceBlocks.map((block) => (
|
||
<AnalysisBlockView
|
||
key={block.id}
|
||
block={block}
|
||
selected={block.id === selectedBlockId}
|
||
onSelect={() => setSelectedBlockId(block.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<ArtifactProvenance artifact={artifact} />
|
||
</div>
|
||
|
||
<ArtifactActionBar
|
||
artifact={artifact}
|
||
surfaceMode={surfaceMode}
|
||
mapSplitAvailable={mapSplitAvailable}
|
||
detailOpen={Boolean(selectedBlock)}
|
||
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
|
||
onSetView={onSetView}
|
||
onCollapse={onCollapse}
|
||
onDestroy={onDestroy}
|
||
/>
|
||
|
||
{selectedBlock ? (
|
||
<ArtifactDetailDrawer
|
||
artifact={artifact}
|
||
block={selectedBlock}
|
||
onClose={() => setSelectedBlockId(null)}
|
||
/>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||
return (
|
||
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
|
||
<div className="flex items-start justify-between gap-5">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
|
||
<Sparkles size={14} aria-hidden="true" />
|
||
Agent Artifact
|
||
</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>{artifactTypeLabel(artifact.type)}</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>R{artifact.revision}</span>
|
||
</div>
|
||
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
|
||
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||
<LifecycleBadge lifecycle={artifact.lifecycle} />
|
||
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||
<Clock3 size={13} aria-hidden="true" />
|
||
{artifact.generatedAt}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
|
||
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
|
||
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function AnalysisBlockView({
|
||
block,
|
||
selected,
|
||
onSelect
|
||
}: {
|
||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||
selected: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
const spanClass = block.span === 5
|
||
? "col-span-12 2xl:col-span-5"
|
||
: block.span === 7
|
||
? "col-span-12 2xl:col-span-7"
|
||
: "col-span-12";
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby={`${block.id}-title`}
|
||
className={cn(
|
||
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
|
||
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||
spanClass,
|
||
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
|
||
aria-label={`查看${block.title}详情`}
|
||
onClick={onSelect}
|
||
>
|
||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700">
|
||
<FileSearch size={16} aria-hidden="true" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
|
||
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
|
||
</span>
|
||
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
||
</button>
|
||
<div className="border-t border-slate-100 p-4">
|
||
<AnalysisBlockBody block={block} />
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
|
||
if (block.kind === "chart") {
|
||
return (
|
||
<div className="h-[258px] min-h-0">
|
||
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (block.kind === "process-flow") {
|
||
return (
|
||
<ol className="space-y-0" aria-label={block.title}>
|
||
{block.nodes.map((node, index) => (
|
||
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
|
||
<span className={cn(
|
||
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||
)}>
|
||
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||
</span>
|
||
<div className="min-w-0 pt-0.5">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||
</div>
|
||
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
);
|
||
}
|
||
|
||
if (block.kind === "data-table") {
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{block.rows.map((row, index) => (
|
||
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
|
||
}
|
||
|
||
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||
return (
|
||
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
|
||
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
|
||
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
|
||
<Database size={14} aria-hidden="true" />
|
||
数据来源
|
||
</h3>
|
||
<ul className="flex min-w-0 flex-wrap gap-1.5">
|
||
{artifact.sources.map((source) => (
|
||
<li key={source} className="surface-reading rounded-md border px-2.5 py-1 text-xs text-slate-600">{source}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
|
||
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
|
||
<AlertTriangle size={14} aria-hidden="true" />
|
||
分析限制
|
||
</h3>
|
||
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
|
||
{artifact.limitations.map((item) => (
|
||
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ArtifactDetailDrawer({
|
||
artifact,
|
||
block,
|
||
onClose
|
||
}: {
|
||
artifact: AnalysisArtifact;
|
||
block: AnalysisBlock;
|
||
onClose: () => void;
|
||
}) {
|
||
const blockFacts = getEvidenceBlockFacts(block);
|
||
|
||
return (
|
||
<aside
|
||
aria-label="证据详情"
|
||
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[440px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-18px_0_44px_rgba(15,23,42,0.14)]"
|
||
>
|
||
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
|
||
<div className="flex min-w-0 items-start gap-3">
|
||
<span className="surface-reading grid h-9 w-9 shrink-0 place-items-center rounded-lg border text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.08)]">
|
||
<FileSearch size={17} aria-hidden="true" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
|
||
<span className="uppercase tracking-[0.12em]">证据详情</span>
|
||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
|
||
</div>
|
||
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭证据详情"
|
||
onClick={onClose}
|
||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-500 hover:bg-slate-200/70 hover:text-slate-900 active:scale-95"
|
||
>
|
||
<X size={17} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-6 px-5 py-5">
|
||
<section aria-labelledby="evidence-purpose-heading">
|
||
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500">证据说明</p>
|
||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||
{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}
|
||
</p>
|
||
</section>
|
||
|
||
<section className="material-tone-agent rounded-xl border px-4 py-3.5" aria-labelledby="evidence-relation-heading">
|
||
<div className="flex items-center gap-2 text-xs font-semibold text-blue-900">
|
||
<Sparkles size={14} aria-hidden="true" />
|
||
<h4 id="evidence-relation-heading">Agent 关联判断</h4>
|
||
</div>
|
||
<p className="mt-2 text-sm leading-6 text-slate-700">结合当前模块与其余证据,{artifact.summary}</p>
|
||
</section>
|
||
|
||
<section aria-labelledby="evidence-data-heading">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500">数据概览</h4>
|
||
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
|
||
</div>
|
||
<dl className="surface-well mt-2 grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-slate-300/70">
|
||
{blockFacts.map((fact) => (
|
||
<div key={fact.label} className="surface-reading min-w-0 px-3.5 py-3">
|
||
<dt className="text-[11px] text-slate-500">{fact.label}</dt>
|
||
<dd className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums" title={fact.value}>{fact.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</section>
|
||
|
||
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
|
||
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500">分析上下文</h4>
|
||
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
|
||
<dt className="text-slate-500">分析时间</dt>
|
||
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
|
||
<dt className="text-slate-500">分析范围</dt>
|
||
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
|
||
<dt className="text-slate-500">综合置信度</dt>
|
||
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
|
||
</dl>
|
||
</section>
|
||
|
||
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-source-heading">
|
||
<div className="flex items-center gap-2">
|
||
<Database size={14} className="text-slate-500" aria-hidden="true" />
|
||
<h4 id="evidence-source-heading" className="text-xs font-semibold text-slate-700">本成果数据来源</h4>
|
||
</div>
|
||
<ul className="mt-3 flex flex-wrap gap-2">
|
||
{artifact.sources.map((source) => (
|
||
<li key={source} className="surface-control rounded-md border px-2.5 py-1.5 text-xs text-slate-600">{source}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
|
||
<section className="material-tone-warning rounded-xl border px-4 py-3.5" aria-labelledby="evidence-limit-heading">
|
||
<div className="flex items-center gap-2 text-xs font-semibold text-amber-900">
|
||
<AlertTriangle size={14} aria-hidden="true" />
|
||
<h4 id="evidence-limit-heading">使用限制</h4>
|
||
</div>
|
||
<ul className="mt-2 space-y-1.5 text-xs leading-5 text-amber-950/75">
|
||
{artifact.limitations.map((item) => <li key={item}>• {item}</li>)}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function evidenceBlockKindLabel(block: AnalysisBlock) {
|
||
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
|
||
if (block.kind === "process-flow") return "流程证据";
|
||
if (block.kind === "data-table") return "明细数据";
|
||
if (block.kind === "narrative") return "分析说明";
|
||
return "指标摘要";
|
||
}
|
||
|
||
function getEvidenceBlockFacts(block: AnalysisBlock): Array<{ label: string; value: string }> {
|
||
if (block.kind === "chart") {
|
||
return [
|
||
{ label: "数据序列", value: `${block.series.length} 组` },
|
||
{ label: "时间 / 类别点", value: `${block.categories.length} 个` },
|
||
{ label: "计量单位", value: block.unit },
|
||
{ label: "图表类型", value: block.chartType === "line" ? "趋势曲线" : "对比柱图" }
|
||
];
|
||
}
|
||
if (block.kind === "data-table") {
|
||
return [
|
||
{ label: "证据记录", value: `${block.rows.length} 条` },
|
||
{ label: "数据字段", value: `${block.columns.length} 个` },
|
||
{ label: "展示方式", value: "明细表格" },
|
||
{ label: "证据状态", value: "已纳入结论" }
|
||
];
|
||
}
|
||
if (block.kind === "process-flow") {
|
||
return [
|
||
{ label: "流程步骤", value: `${block.nodes.length} 项` },
|
||
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length} 项` },
|
||
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length} 项` },
|
||
{ label: "展示方式", value: "处置流程" }
|
||
];
|
||
}
|
||
if (block.kind === "narrative") {
|
||
return [
|
||
{ label: "分析段落", value: `${block.paragraphs.length} 段` },
|
||
{ label: "展示方式", value: "分析说明" },
|
||
{ label: "证据状态", value: "已纳入结论" },
|
||
{ label: "内容来源", value: "Agent 生成" }
|
||
];
|
||
}
|
||
return [
|
||
{ label: "关键指标", value: `${block.metrics.length} 项` },
|
||
{ label: "展示方式", value: "指标摘要" },
|
||
{ label: "证据状态", value: "已纳入结论" },
|
||
{ label: "内容来源", value: "Agent 生成" }
|
||
];
|
||
}
|
||
|
||
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
|
||
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false
|
||
});
|
||
return `${formatter.format(new Date(range.start))} 至 ${formatter.format(new Date(range.end))}`;
|
||
}
|
||
|
||
function ArtifactActionBar({
|
||
artifact,
|
||
surfaceMode,
|
||
mapSplitAvailable,
|
||
detailOpen,
|
||
onToggleDetail,
|
||
onSetView,
|
||
onCollapse,
|
||
onDestroy
|
||
}: {
|
||
artifact: AnalysisArtifact;
|
||
surfaceMode: WorkbenchSurfaceMode;
|
||
mapSplitAvailable: boolean;
|
||
detailOpen: boolean;
|
||
onToggleDetail: () => void;
|
||
onSetView: (view: ArtifactRequestedView) => void;
|
||
onCollapse: () => void;
|
||
onDestroy: () => void;
|
||
}) {
|
||
return (
|
||
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||
<div className="flex min-w-0 items-center gap-1">
|
||
{artifact.mapRelation !== "none" ? (
|
||
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
|
||
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
|
||
</button>
|
||
) : null}
|
||
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
|
||
证据详情
|
||
</button>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
|
||
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
|
||
</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
|
||
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
|
||
>
|
||
<ShieldCheck size={15} aria-hidden="true" />
|
||
{action.label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||
return (
|
||
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||
</div>
|
||
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
|
||
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
|
||
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
|
||
}
|
||
|
||
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
|
||
if (type === "diagnosis") return "异常诊断";
|
||
if (type === "simulation") return "方案模拟";
|
||
return "指标分析";
|
||
}
|
||
|
||
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
|
||
if (relation === "required") return "强关联";
|
||
if (relation === "optional") return "可选";
|
||
return "无空间依赖";
|
||
}
|
||
|
||
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||
return {
|
||
animationDuration: 260,
|
||
animationEasing: "cubicOut",
|
||
color: block.series.map((series) => series.color),
|
||
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||
series: block.series.map((series) => ({
|
||
name: series.name,
|
||
type: series.type ?? block.chartType,
|
||
data: series.values,
|
||
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||
symbol: "circle",
|
||
symbolSize: 6,
|
||
barMaxWidth: 24,
|
||
lineStyle: { width: 2.5 },
|
||
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||
}))
|
||
};
|
||
}
|
||
|
||
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||
if (tone === "danger") return "bg-rose-500";
|
||
if (tone === "warning") return "bg-amber-500";
|
||
if (tone === "success") return "bg-emerald-500";
|
||
if (tone === "info") return "bg-blue-600";
|
||
return "bg-slate-400";
|
||
}
|