106 lines
9.0 KiB
TypeScript
106 lines
9.0 KiB
TypeScript
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||
import { Check, FileText, X } from "lucide-react";
|
||
import { AgentChartView } from "@/features/agent";
|
||
import { DeferredRender } from "@/shared/ui/deferred-render";
|
||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||
import { Button } from "@/shared/ui/button";
|
||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
|
||
|
||
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
|
||
group: ChartGroup;
|
||
tracked?: boolean;
|
||
tile?: boolean;
|
||
reviewing: boolean;
|
||
onCloseChart: (id: string) => void;
|
||
references: (ids: string[]) => ReactNode;
|
||
}) {
|
||
const card = useRef<HTMLDivElement>(null);
|
||
const [plotHeight, setPlotHeight] = useState(220);
|
||
useLayoutEffect(() => {
|
||
if (tile) return;
|
||
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
|
||
if (!viewport || typeof ResizeObserver === "undefined") return;
|
||
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
|
||
resize();
|
||
const observer = new ResizeObserver(resize);
|
||
observer.observe(viewport);
|
||
return () => observer.disconnect();
|
||
}, [tile]);
|
||
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
|
||
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
|
||
const multiple = group.items.length > 1;
|
||
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
|
||
const gaps = selected.command.chart.scada?.gaps ?? [];
|
||
const missingCurrent = gaps.filter((gap) => gap.day_offset === 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
|
||
const missingHistory = gaps.filter((gap) => gap.day_offset > 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
|
||
const gapNotice = gaps.length ? <p role="status" aria-label="监测数据缺口" className="shrink-0 text-xs text-muted-foreground">
|
||
{[missingCurrent ? `当前缺测 ${missingCurrent} 点` : "", missingHistory ? `历史缺测 ${missingHistory} 点` : "",
|
||
gaps.some((gap) => gap.reason.startsWith("outside_frozen_")) ? "部分数据不在冻结范围内" : "",
|
||
gaps.some((gap) => gap.reason === "no_scheduled_samples") ? "窗口内无计划采样点" : ""].filter(Boolean).join(";")},已保留空槽。
|
||
</p> : null;
|
||
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
|
||
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
|
||
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
|
||
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
|
||
</div>
|
||
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
|
||
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
|
||
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
|
||
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
|
||
onClick={() => setSelectedId(item.command.chart_id)}>
|
||
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
|
||
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
|
||
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
|
||
</Button>)}
|
||
</div>
|
||
</aside>;
|
||
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
|
||
{gapNotice}
|
||
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
|
||
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
|
||
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
</div> : null}
|
||
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
|
||
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
|
||
{multiple ? objectButtons(true) : null}
|
||
</div>
|
||
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
|
||
{gapNotice}
|
||
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
|
||
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
|
||
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
|
||
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
|
||
</div>
|
||
{multiple ? objectButtons() : null}
|
||
</div>
|
||
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
|
||
</div>;
|
||
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
|
||
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
|
||
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
|
||
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
|
||
<div className="min-w-0">
|
||
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
|
||
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
|
||
</div>
|
||
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
|
||
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
|
||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
|
||
</Popover> : null}
|
||
<FloatingWindow title={title} fillContent>{() => <DeferredRender label={`正在加载图表 ${title}`} className="h-full min-h-0">{content(true)}</DeferredRender>}</FloatingWindow>
|
||
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
|
||
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
|
||
<DeferredRender label={`正在加载图表 ${title}`} className={tile ? "h-full min-h-0" : undefined}>{content(tile, tile)}</DeferredRender>
|
||
</CardContent>
|
||
</Card>;
|
||
}
|