89 lines
5.7 KiB
TypeScript
89 lines
5.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
|
|
import { Button } from "@/shared/ui/button";
|
|
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
|
|
|
|
export function ScadaMonitoringNotice({ result, animationId, processing, onOpenInvestigation }: {
|
|
result: ScadaMonitoringResult | null | undefined;
|
|
animationId: string | null;
|
|
processing: boolean;
|
|
onOpenInvestigation?: (taskId: string) => void;
|
|
}) {
|
|
const reduced = useReducedMotion();
|
|
const [animating, setAnimating] = useState(false);
|
|
useEffect(() => {
|
|
if (!animationId || reduced) return;
|
|
setAnimating(true);
|
|
const timer = window.setTimeout(() => setAnimating(false), 2600);
|
|
return () => window.clearTimeout(timer);
|
|
}, [animationId, reduced]);
|
|
const recovered = result?.incident?.action === "resolved";
|
|
const ongoing = result?.incident?.action === "ongoing";
|
|
const abnormal = Boolean(result && !recovered && (result.task_id || ["attention", "suspected_burst"].includes(result.status)));
|
|
const suspectedBurst = abnormal && result?.status === "suspected_burst";
|
|
const normal = result?.status === "normal" || recovered;
|
|
const tone: StatusTone = suspectedBurst ? "danger" : abnormal ? "warning" : normal ? "success" : processing ? "info" : "neutral";
|
|
const Icon = !result ? processing ? LoaderCircle : Clock3 : abnormal ? TriangleAlert : normal ? CheckCircle2 : CircleHelp;
|
|
const title = !result ? processing ? "正在监测" : "监测待机"
|
|
: recovered ? "异常已恢复" : ongoing ? "异常持续跟踪" : suspectedBurst ? "发现疑似爆管" : abnormal ? "发现指标异常"
|
|
: normal ? "监测正常" : "监测数据不足";
|
|
const note = !result ? processing ? "正在核对本周期数据" : "等待播放或单步监测"
|
|
: result.task_id ? recovered ? "告警已解除" : ongoing ? "沿用原调查" : "已启动调查"
|
|
: normal ? "本周期未发现异常" : abnormal ? "请留意后续监测结果" : "暂无法形成监测结论";
|
|
const hint = processing ? result ? "正在监测下一周期" : "处理完成后更新结果"
|
|
: abnormal ? "诊断详情见调查报告" : recovered ? "可回看本次调查"
|
|
: result && !normal ? "待后续周期继续监测" : "异常时自动启动调查";
|
|
const emphasis = animating && abnormal && !reduced;
|
|
return (
|
|
<section aria-label="最新监测结果" role="status" aria-busy={processing}
|
|
aria-atomic="true"
|
|
data-animation-state={emphasis ? "running" : "idle"} data-announcement-id={animationId ?? undefined}
|
|
data-status-tone={tone}
|
|
className={cn(
|
|
"relative isolate grid h-40 shrink-0 grid-rows-[1.25rem_1fr_2.25rem] gap-2 overflow-hidden rounded-lg bg-[var(--status-soft)] p-3.5 text-foreground ring-1 ring-inset ring-[var(--status-border)]",
|
|
`status-tone-${tone}`
|
|
)}>
|
|
{emphasis ? <motion.div key={`${animationId}-surface`} aria-hidden="true"
|
|
data-slot="scada-alert-emphasis" className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
|
|
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.55, 0] }}
|
|
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
|
|
<div data-slot="scada-notice-header" className="flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/75">
|
|
<span>调查监测</span>
|
|
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
|
|
<span className="sr-only">最新监测 </span>
|
|
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })}
|
|
</time> : <span>尚无监测结果</span>}
|
|
</div>
|
|
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
|
|
<span className="relative shrink-0" aria-hidden="true">
|
|
{emphasis ? <motion.span key={animationId} data-slot="scada-alert-pulse"
|
|
className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
|
|
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: [0, 0.65, 0], scale: [0.8, 1.35, 1.65] }}
|
|
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
|
|
<StatusIcon tone={tone} size="md" className="size-9 bg-background/70"
|
|
activity={!result && processing ? "loading" : "static"}>
|
|
<motion.span key={`${animationId}-icon`} animate={emphasis ? { rotate: [0, -12, 12, -6, 0] } : { rotate: 0 }}
|
|
transition={{ duration: 0.5 }}><Icon size={18} /></motion.span>
|
|
</StatusIcon>
|
|
</span>
|
|
<div data-slot="scada-notice-summary" className="min-w-0 flex-1 space-y-0.5 text-xs leading-5">
|
|
<p className="font-semibold">{title}</p>
|
|
<p className="text-foreground/75">{note}</p>
|
|
</div>
|
|
</div>
|
|
<div data-slot="scada-notice-footer" className="flex min-w-0 items-center justify-between gap-2 border-t border-[var(--status-border)] pt-1.5">
|
|
<p className="text-[11px] leading-4 text-foreground/75">{hint}</p>
|
|
<div data-slot="scada-notice-action" className="flex h-8 w-[5.5rem] shrink-0 justify-end">
|
|
{result?.task_id && onOpenInvestigation ? <Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
|
|
onClick={() => onOpenInvestigation(result.task_id!)}>
|
|
查看调查<ArrowUpRight size={12} aria-hidden="true" />
|
|
</Button> : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|