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

454 lines
20 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="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 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>
<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 (
<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="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>
<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 ArtifactDetailDrawer({
artifact,
block,
onClose
}: {
artifact: AnalysisArtifact;
block: AnalysisBlock;
onClose: () => void;
}) {
return (
<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>
<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 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-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";
}