feat: refactor artifact analysis workspace

This commit is contained in:
2026-08-19 16:09:51 +08:00
parent bfadc95f93
commit d2f2895c36
26 changed files with 998 additions and 2278 deletions
@@ -1,175 +1,425 @@
import ReactECharts from "echarts-for-react";
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-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,
AnalysisDocument,
AnalysisMetric
AnalysisMetric,
ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model";
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
return (
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm lg:pl-16">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent
</div>
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
<Clock3 size={13} aria-hidden="true" />
{document.generatedAt}
</div>
</header>
type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void;
onDestroy: () => void;
};
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
{document.blocks.map((block) => (
<AnalysisBlockView key={block.id} block={block} />
))}
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="relative grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-[#eef2f6] 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 AnalysisBlockView({ block }: { block: AnalysisBlock }) {
const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12";
if (block.kind === "metric-grid") {
return (
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
{block.metrics.map((metric) => (
<MetricTile key={metric.label} metric={metric} />
))}
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
return (
<header className="relative z-10 bg-white px-4 py-4 shadow-[0_1px_0_rgba(15,23,42,0.08),0_5px_18px_rgba(15,23,42,0.04)] 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>
</section>
);
}
<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(
"group min-w-0 overflow-hidden bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]",
"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 (
<AnalysisSection block={block} className={spanClass}>
<div className="h-[258px] min-h-0">
<ReactECharts
notMerge
lazyUpdate
style={{ width: "100%", height: "100%" }}
option={createChartOption(block)}
/>
</div>
</AnalysisSection>
<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 (
<AnalysisSection block={block} className={spanClass}>
<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>
<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>
</li>
))}
</ol>
</AnalysisSection>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div>
</li>
))}
</ol>
);
}
if (block.kind === "data-table") {
return (
<AnalysisSection block={block} className={spanClass}>
<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>
))}
<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>
</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>
</AnalysisSection>
))}
</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 (
<AnalysisSection block={block} className={spanClass}>
<div className="space-y-3 text-sm leading-7 text-slate-700">
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
<div className="bg-[#e7edf3] px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3>
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
</div>
</AnalysisSection>
<div className="bg-amber-50 px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul>
</div>
</section>
);
}
function AnalysisSection({
function ArtifactDetailDrawer({
artifact,
block,
className,
children
onClose
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
className: string;
children: React.ReactNode;
artifact: AnalysisArtifact;
block: AnalysisBlock;
onClose: () => void;
}) {
return (
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}>
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
<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>
<div className="min-w-0">
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
<aside aria-label="证据详情" className="absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain bg-white shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
</div>
</header>
<div className="p-4">{children}</div>
</section>
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
<X size={18} aria-hidden="true" />
</button>
</div>
<div className="space-y-5 p-5">
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
</div>
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
</div>
<div className="bg-slate-100 p-4">
<p className="text-xs font-semibold text-slate-700"></p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</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">{artifact.confidence}</dd>
</dl>
</div>
</div>
</aside>
);
}
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="relative z-20 flex min-h-14 items-center justify-between gap-3 bg-white px-3 shadow-[0_-1px_0_rgba(15,23,42,0.08),0_-6px_18px_rgba(15,23,42,0.04)]">
<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="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<div className="min-w-0 bg-white px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<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-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p>
<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,