Files
next-tjwater-frontend/src/features/workbench/workspace/analysis-document-view.tsx
T

594 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
}