feat: refactor artifact analysis workspace
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user