feat: add project-aware agent workspace
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchAccessToken } from "../api/request-context";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
|
||||
import type { components } from "./generated/schema";
|
||||
@@ -32,7 +33,7 @@ export class PeripheralSampleClient {
|
||||
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
|
||||
private scenarioProjection: PeripheralScenarioState | null = null;
|
||||
|
||||
constructor(private readonly getAccessToken?: AccessTokenProvider) {}
|
||||
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
|
||||
|
||||
get configured() {
|
||||
return Boolean(this.baseUrl);
|
||||
@@ -216,7 +217,7 @@ export class PeripheralSampleClient {
|
||||
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const projectId = await this.resolveProjectId(signal);
|
||||
const accessToken = await this.getAccessToken?.();
|
||||
const accessToken = await getWorkbenchAccessToken(this.getAccessToken);
|
||||
return createWorkbenchHeaders(accessToken, projectId, json);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ClipboardList, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { ManagedTestResources } from "./managed-test-resources";
|
||||
import { ScadaSystemSection } from "./scada-system-section";
|
||||
import { WorkbenchNotice } from "../components/workbench-panel-ui";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
type PeripheralContext,
|
||||
type PeripheralTestDataRecord,
|
||||
type PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
|
||||
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
|
||||
export function PeripheralSamplePanel({
|
||||
open,
|
||||
onClose,
|
||||
getAccessToken,
|
||||
onAgentTrigger,
|
||||
onScadaAnomalyTrigger
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
|
||||
}) {
|
||||
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
|
||||
const client = clientRef.current;
|
||||
@@ -28,6 +38,8 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [receiptRequestId, setReceiptRequestId] = useState("");
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
const [scadaRefreshGeneration, setScadaRefreshGeneration] = useState(0);
|
||||
const [activeSystem, setActiveSystem] = useState<"work-orders" | "scada">("work-orders");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client.configured) return;
|
||||
@@ -51,84 +63,119 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="外围工单测试"
|
||||
aria-label="外围系统"
|
||||
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(680px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]"
|
||||
>
|
||||
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4 sm:px-5">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white">
|
||||
<ClipboardList size={17} />
|
||||
<RadioTower size={17} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold">外围工单测试</h2>
|
||||
<p className="text-xs text-slate-500">创建、更新过程、回执核对与事件上报</p>
|
||||
<h2 className="text-sm font-semibold">外围系统</h2>
|
||||
<p className="text-xs text-slate-500">工单协同与 SCADA 最新数据</p>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新外围工单" onClick={() => void refresh()} disabled={busy}>
|
||||
<RefreshCw size={16} className={cn(busy && "animate-spin")} />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="刷新当前外围系统"
|
||||
onClick={() => {
|
||||
if (activeSystem === "work-orders") void refresh();
|
||||
else setScadaRefreshGeneration((current) => current + 1);
|
||||
}}
|
||||
disabled={activeSystem === "work-orders" && busy}
|
||||
>
|
||||
<RefreshCw size={16} className={cn(activeSystem === "work-orders" && busy && "animate-spin")} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭外围工单测试" onClick={onClose}>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭外围系统" onClick={onClose}>
|
||||
<X size={17} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<div className="mb-3 grid gap-2">
|
||||
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
|
||||
{!client.configured ? (
|
||||
<WorkbenchNotice tone="warning">
|
||||
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{client.configured && !context ? (
|
||||
<WorkbenchNotice role="status" tone="neutral">正在核验项目授权与工单接口。</WorkbenchNotice>
|
||||
) : null}
|
||||
<Tabs
|
||||
value={activeSystem}
|
||||
onValueChange={(value) => setActiveSystem(value as "work-orders" | "scada")}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="shrink-0 border-b border-[var(--surface-divider)] px-3 py-2 sm:px-4">
|
||||
<TabsList aria-label="外围系统类型" className="grid w-full grid-cols-2 bg-[var(--surface-control)]">
|
||||
<TabsTrigger value="work-orders"><ClipboardList size={14} />工单系统</TabsTrigger>
|
||||
<TabsTrigger value="scada"><Activity size={14} />SCADA 系统</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{context ? (
|
||||
<div className="space-y-3">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
refreshGeneration={refreshGeneration}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<Disclosure
|
||||
label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>}
|
||||
className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
<p className="pb-2 text-xs leading-5 text-slate-500">
|
||||
结果未知时只查询原请求,不会重复创建或更新工单。
|
||||
</p>
|
||||
<div className="flex gap-2 pb-3">
|
||||
<Input
|
||||
aria-label="提交请求号"
|
||||
value={receiptRequestId}
|
||||
onChange={(event) => setReceiptRequestId(event.target.value.trim())}
|
||||
placeholder="request_id"
|
||||
className="h-10 min-w-0 flex-1 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!receiptRequestId || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.getReceipt(receiptRequestId);
|
||||
setError("回执已确认,请刷新工单列表查看最新状态。");
|
||||
} catch (nextError) {
|
||||
setError(errorMessage(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>查询</Button>
|
||||
</div>
|
||||
</Disclosure>
|
||||
<TabsContent value="work-orders" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<div className="mb-3 grid gap-2">
|
||||
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
|
||||
{!client.configured ? (
|
||||
<WorkbenchNotice tone="warning">
|
||||
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{client.configured && !context ? (
|
||||
<WorkbenchNotice role="status" tone="neutral">正在核验项目授权与工单接口。</WorkbenchNotice>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{context ? (
|
||||
<div className="space-y-3">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
refreshGeneration={refreshGeneration}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<Disclosure
|
||||
label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>}
|
||||
className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
<p className="pb-2 text-xs leading-5 text-slate-500">
|
||||
结果未知时只查询原请求,不会重复创建或更新工单。
|
||||
</p>
|
||||
<div className="flex gap-2 pb-3">
|
||||
<Input
|
||||
aria-label="提交请求号"
|
||||
value={receiptRequestId}
|
||||
onChange={(event) => setReceiptRequestId(event.target.value.trim())}
|
||||
placeholder="request_id"
|
||||
className="h-10 min-w-0 flex-1 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!receiptRequestId || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.getReceipt(receiptRequestId);
|
||||
setError("回执已确认,请刷新工单列表查看最新状态。");
|
||||
} catch (nextError) {
|
||||
setError(errorMessage(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>查询</Button>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scada" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<ScadaSystemSection
|
||||
active={activeSystem === "scada"}
|
||||
getAccessToken={getAccessToken}
|
||||
refreshGeneration={scadaRefreshGeneration}
|
||||
peripheralClient={client}
|
||||
canSimulate={Boolean(
|
||||
context?.can_write && context.managed_resources.test_data === "available"
|
||||
)}
|
||||
onAgentTrigger={onScadaAnomalyTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PeripheralTestDataRecord } from "./client";
|
||||
import { buildScadaAnomalyInvestigation } from "./scada-anomaly-agent-trigger";
|
||||
|
||||
describe("SCADA anomaly Agent trigger", () => {
|
||||
it("keeps the persisted synthetic reading as the external task source", () => {
|
||||
const record = {
|
||||
record_id: "50000000-0000-4000-8000-000000000017",
|
||||
revision: 3,
|
||||
point_id: "PRESSURE-17",
|
||||
observed_at: "2026-10-22T02:03:00Z",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
notes: "[SCADA_ANOMALY] 压力突降"
|
||||
} as PeripheralTestDataRecord;
|
||||
|
||||
expect(buildScadaAnomalyInvestigation(record)).toMatchObject({
|
||||
kind: "scada_anomaly",
|
||||
title: "SCADA 异常调查 · PRESSURE-17",
|
||||
summary: expect.stringContaining("0.08 MPa"),
|
||||
occurredAt: "2026-10-22T02:03:00Z",
|
||||
source: {
|
||||
kind: "external_system",
|
||||
external_id: record.record_id,
|
||||
external_revision: 3,
|
||||
event_kind: "scada_anomaly"
|
||||
},
|
||||
triggerKind: "event"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CreateInvestigationInput } from "../hooks/use-operational-tasks";
|
||||
import type { PeripheralTestDataRecord } from "./client";
|
||||
|
||||
export function buildScadaAnomalyInvestigation(
|
||||
record: PeripheralTestDataRecord
|
||||
): CreateInvestigationInput {
|
||||
const valueLabel = record.value === null
|
||||
? "无有效读数"
|
||||
: `${record.value}${record.unit ? ` ${record.unit}` : ""}`;
|
||||
const anomalyDetail = record.notes.replace("[SCADA_ANOMALY]", "").trim();
|
||||
|
||||
return {
|
||||
kind: "scada_anomaly",
|
||||
title: `SCADA 异常调查 · ${record.point_id}`,
|
||||
summary: `${record.point_id} 在 ${record.observed_at} 记录模拟异常值 ${valueLabel}。${anomalyDetail}`,
|
||||
detail: `由外围 SCADA 合成读数触发,点位 ${record.point_id},读数 ${valueLabel},发生时间 ${record.observed_at}。`,
|
||||
occurredAt: record.observed_at,
|
||||
priority: "normal",
|
||||
source: {
|
||||
kind: "external_system",
|
||||
external_id: record.record_id,
|
||||
external_revision: record.revision,
|
||||
event_kind: "scada_anomaly"
|
||||
},
|
||||
triggerKind: "event"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
|
||||
import { ScadaSystemSection } from "./scada-system-section";
|
||||
|
||||
describe("SCADA peripheral system", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists a synthetic abnormal reading before triggering Agent handling", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{
|
||||
device_id: "PRESSURE-17",
|
||||
device_type: "pressure",
|
||||
measurement_unit: "MPa",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "15min",
|
||||
observed_at: "2026-10-22T01:02:03Z",
|
||||
monitored_value: 0.31,
|
||||
cleaned_value: 0.305
|
||||
}],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
observed_count: 1,
|
||||
latest_observed_at: "2026-10-22T01:02:03Z"
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const record: PeripheralTestDataRecord = {
|
||||
record_id: "50000000-0000-4000-8000-000000000017",
|
||||
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
|
||||
revision: 1,
|
||||
label: "SCADA 异常模拟 · PRESSURE-17",
|
||||
point_id: "PRESSURE-17",
|
||||
observed_at: "2026-10-22T02:03:00Z",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
quality: "valid",
|
||||
notes: "[SCADA_ANOMALY] 压力突降",
|
||||
origin: "synthetic",
|
||||
created_at: "2026-10-22T02:03:01Z",
|
||||
updated_at: "2026-10-22T02:03:01Z",
|
||||
created_by: "tester",
|
||||
updated_by: "tester"
|
||||
};
|
||||
const createTestData = vi.fn().mockResolvedValue(record);
|
||||
const peripheralClient = {
|
||||
listTestData: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
|
||||
createTestData
|
||||
} as unknown as PeripheralSampleClient;
|
||||
const onAgentTrigger = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScadaSystemSection
|
||||
active
|
||||
refreshGeneration={0}
|
||||
peripheralClient={peripheralClient}
|
||||
canSimulate
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("PRESSURE-17", { exact: true });
|
||||
await user.click(screen.getByRole("button", { name: "设定读数" }));
|
||||
await user.type(screen.getByPlaceholderText("输入需要演练的异常值"), "0.08");
|
||||
await user.type(screen.getByPlaceholderText("例如:压力突降,偏离近一小时稳定区间"), "压力突降");
|
||||
await user.click(screen.getByRole("button", { name: "保存并触发 Agent" }));
|
||||
|
||||
await waitFor(() => expect(createTestData).toHaveBeenCalledOnce());
|
||||
expect(createTestData).toHaveBeenCalledWith(expect.objectContaining({
|
||||
point_id: "PRESSURE-17",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
quality: "valid",
|
||||
notes: "[SCADA_ANOMALY] 压力突降"
|
||||
}));
|
||||
expect(onAgentTrigger).toHaveBeenCalledWith(record);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 15_000;
|
||||
const SCADA_ANOMALY_MARKER = "[SCADA_ANOMALY]";
|
||||
|
||||
export function ScadaSystemSection({
|
||||
active,
|
||||
getAccessToken,
|
||||
refreshGeneration,
|
||||
peripheralClient,
|
||||
canSimulate,
|
||||
onAgentTrigger
|
||||
}: {
|
||||
active: boolean;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
refreshGeneration: number;
|
||||
peripheralClient: PeripheralSampleClient;
|
||||
canSimulate: boolean;
|
||||
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
|
||||
}) {
|
||||
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const handledRefreshRef = useRef(refreshGeneration);
|
||||
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);
|
||||
const [simulationEditorOpen, setSimulationEditorOpen] = useState(false);
|
||||
const [simulationPointId, setSimulationPointId] = useState("");
|
||||
const [simulationValue, setSimulationValue] = useState("");
|
||||
const [simulationObservedAt, setSimulationObservedAt] = useState(() => toLocalInput(new Date()));
|
||||
const [simulationReason, setSimulationReason] = useState("");
|
||||
const [simulationBusy, setSimulationBusy] = useState(false);
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null);
|
||||
const [simulations, setSimulations] = useState<PeripheralTestDataRecord[]>([]);
|
||||
const [triggerRetry, setTriggerRetry] = useState<PeripheralTestDataRecord | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!active) return;
|
||||
requestRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
const sequence = ++requestSequenceRef.current;
|
||||
requestRef.current = controller;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await clientRef.current.getLatestReadings(controller.signal);
|
||||
if (sequence !== requestSequenceRef.current) return;
|
||||
setSnapshot(next);
|
||||
setError(null);
|
||||
setRefreshedAt(new Date());
|
||||
} catch (nextError) {
|
||||
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
|
||||
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败");
|
||||
} finally {
|
||||
if (sequence === requestSequenceRef.current) setBusy(false);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const refreshSimulations = useCallback(async () => {
|
||||
if (!active || !canSimulate) return;
|
||||
try {
|
||||
const page = await peripheralClient.listTestData();
|
||||
setSimulations(page.items.filter((item) => item.notes.startsWith(SCADA_ANOMALY_MARKER)));
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数读取失败");
|
||||
}
|
||||
}, [active, canSimulate, peripheralClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void refresh();
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
requestRef.current?.abort();
|
||||
};
|
||||
}, [active, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || handledRefreshRef.current === refreshGeneration) return;
|
||||
handledRefreshRef.current = refreshGeneration;
|
||||
void refresh();
|
||||
void refreshSimulations();
|
||||
}, [active, refresh, refreshGeneration, refreshSimulations]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSimulations();
|
||||
}, [refreshSimulations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!simulationPointId && snapshot?.items[0]) {
|
||||
setSimulationPointId(snapshot.items[0].device_id);
|
||||
}
|
||||
}, [simulationPointId, snapshot]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const query = filter.trim().toLocaleLowerCase("zh-CN");
|
||||
if (!query) return snapshot?.items ?? [];
|
||||
return (snapshot?.items ?? []).filter((item) =>
|
||||
[item.device_id, item.device_type, item.measurement_unit, item.transmission_mode]
|
||||
.some((value) => value.toLocaleLowerCase("zh-CN").includes(query))
|
||||
);
|
||||
}, [filter, snapshot]);
|
||||
|
||||
const selectedSimulationDevice = snapshot?.items.find(
|
||||
(item) => item.device_id === simulationPointId
|
||||
) ?? null;
|
||||
const parsedSimulationValue = Number(simulationValue);
|
||||
const canSaveSimulation = canSimulate && selectedSimulationDevice !== null
|
||||
&& simulationValue.trim() !== "" && Number.isFinite(parsedSimulationValue)
|
||||
&& Boolean(simulationObservedAt) && Boolean(simulationReason.trim()) && !simulationBusy;
|
||||
|
||||
const saveSimulation = async (triggerAgent: boolean) => {
|
||||
if (!canSaveSimulation || !selectedSimulationDevice) return;
|
||||
setSimulationBusy(true);
|
||||
setSimulationError(null);
|
||||
setTriggerRetry(null);
|
||||
try {
|
||||
const record = await peripheralClient.createTestData({
|
||||
label: `SCADA 异常模拟 · ${selectedSimulationDevice.device_id}`,
|
||||
point_id: selectedSimulationDevice.device_id,
|
||||
observed_at: new Date(simulationObservedAt).toISOString(),
|
||||
value: parsedSimulationValue,
|
||||
unit: selectedSimulationDevice.measurement_unit || "未配置",
|
||||
quality: "valid",
|
||||
notes: `${SCADA_ANOMALY_MARKER} ${simulationReason.trim()}`
|
||||
});
|
||||
setSimulations((current) => [record, ...current.filter((item) => item.record_id !== record.record_id)]);
|
||||
setSimulationEditorOpen(false);
|
||||
setSimulationValue("");
|
||||
setSimulationReason("");
|
||||
setSimulationObservedAt(toLocalInput(new Date()));
|
||||
if (triggerAgent) {
|
||||
if (!onAgentTrigger) throw new Error("Agent 异常处理入口未配置");
|
||||
try {
|
||||
await onAgentTrigger(record);
|
||||
} catch (triggerError) {
|
||||
setTriggerRetry(record);
|
||||
setSimulationError(
|
||||
`模拟读数已保存,但 Agent 任务未建立:${triggerError instanceof Error ? triggerError.message : "触发失败"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数保存失败");
|
||||
} finally {
|
||||
setSimulationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retryAgentTrigger = async () => {
|
||||
if (!triggerRetry || !onAgentTrigger) return;
|
||||
setSimulationBusy(true);
|
||||
setSimulationError(null);
|
||||
try {
|
||||
await onAgentTrigger(triggerRetry);
|
||||
setTriggerRetry(null);
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "Agent 任务触发失败");
|
||||
} finally {
|
||||
setSimulationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" lang="zh-CN">
|
||||
<WorkbenchNotice tone="info" title="项目 SCADA 数据源">
|
||||
展示当前项目中每个设备最后一条已持久化记录。页面可见时每 15 秒刷新,不据此推断设备在线状态。
|
||||
</WorkbenchNotice>
|
||||
{error ? (
|
||||
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
|
||||
{error}。已加载的数据会继续保留。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{simulationError ? (
|
||||
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成">
|
||||
<p>{simulationError}</p>
|
||||
{triggerRetry ? (
|
||||
<Button
|
||||
className="mt-2"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={simulationBusy}
|
||||
onClick={() => void retryAgentTrigger()}
|
||||
>
|
||||
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
重试触发 Agent
|
||||
</Button>
|
||||
) : null}
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
aria-label="SCADA 最新数据概览"
|
||||
className="surface-control grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-[var(--surface-divider)] px-1 py-3"
|
||||
>
|
||||
<div className="px-3">
|
||||
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">已取得最新记录</p>
|
||||
<p className="mt-1 text-xl font-semibold leading-7 text-slate-900 tabular-nums">
|
||||
{snapshot ? snapshot.observed_count : "—"}
|
||||
<span className="ml-1 text-xs font-medium text-[var(--ink-muted)]">
|
||||
/ {snapshot ? snapshot.total : "—"} 个设备
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">最近记录时间</p>
|
||||
<p className="mt-1 text-sm font-semibold leading-7 text-slate-900 tabular-nums">
|
||||
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<WorkbenchSection
|
||||
title="异常读数演练"
|
||||
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warning"
|
||||
actions={canSimulate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-expanded={simulationEditorOpen}
|
||||
onClick={() => setSimulationEditorOpen((current) => !current)}
|
||||
disabled={simulationBusy || !snapshot?.items.length}
|
||||
>
|
||||
{simulationEditorOpen ? <X size={13} /> : <Plus size={13} />}
|
||||
{simulationEditorOpen ? "收起" : "设定读数"}
|
||||
</Button>
|
||||
) : null}
|
||||
bodyClassName="space-y-3"
|
||||
>
|
||||
{!canSimulate ? (
|
||||
<WorkbenchNotice tone="warning" title="合成读数存储不可用">
|
||||
需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
|
||||
{simulationEditorOpen && canSimulate ? (
|
||||
<div className="rounded-xl bg-[var(--surface-control)] p-3 ring-1 ring-inset ring-[var(--surface-divider)]">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldSelect
|
||||
label="SCADA 设备"
|
||||
value={simulationPointId}
|
||||
onValueChange={setSimulationPointId}
|
||||
disabled={simulationBusy}
|
||||
options={(snapshot?.items ?? []).map((item) => ({
|
||||
value: item.device_id,
|
||||
label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}`
|
||||
}))}
|
||||
/>
|
||||
<DateTimeField
|
||||
label="模拟发生时间"
|
||||
value={simulationObservedAt}
|
||||
onChange={setSimulationObservedAt}
|
||||
disabled={simulationBusy}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
模拟读数{selectedSimulationDevice?.measurement_unit
|
||||
? `(${selectedSimulationDevice.measurement_unit})`
|
||||
: ""}
|
||||
<Input
|
||||
className="mt-1.5 h-10 bg-[var(--surface-reading)] text-sm tabular-nums"
|
||||
inputMode="decimal"
|
||||
value={simulationValue}
|
||||
onChange={(event) => setSimulationValue(event.target.value)}
|
||||
disabled={simulationBusy}
|
||||
placeholder="输入需要演练的异常值"
|
||||
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
异常说明
|
||||
<Textarea
|
||||
className="mt-1.5 min-h-20 bg-[var(--surface-reading)]"
|
||||
value={simulationReason}
|
||||
onChange={(event) => setSimulationReason(event.target.value)}
|
||||
disabled={simulationBusy}
|
||||
placeholder="例如:压力突降,偏离近一小时稳定区间"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canSaveSimulation}
|
||||
onClick={() => void saveSimulation(true)}
|
||||
>
|
||||
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
保存并触发 Agent
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canSaveSimulation}
|
||||
onClick={() => void saveSimulation(false)}
|
||||
>
|
||||
保存模拟读数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{simulations.length ? (
|
||||
<ol aria-label="SCADA 模拟异常列表" className="space-y-2">
|
||||
{simulations.slice(0, 5).map((item) => (
|
||||
<li
|
||||
key={item.record_id}
|
||||
className="flex min-w-0 items-start gap-3 rounded-xl bg-[var(--surface-control)] px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
|
||||
>
|
||||
<StatusBadge tone="warning">模拟异常</StatusBadge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-xs font-semibold text-slate-900">{item.point_id}</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-[11px] leading-4 text-slate-700">
|
||||
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : canSimulate && !simulationEditorOpen ? (
|
||||
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">尚未设定 SCADA 模拟异常。</p>
|
||||
) : null}
|
||||
</WorkbenchSection>
|
||||
|
||||
<WorkbenchSection
|
||||
title="最新测点值"
|
||||
description={refreshedAt
|
||||
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
|
||||
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
|
||||
icon={<Activity size={15} />}
|
||||
tone="teal"
|
||||
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
|
||||
bodyClassName="p-0"
|
||||
>
|
||||
<div className="border-b border-[var(--surface-divider)] p-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)]"
|
||||
/>
|
||||
<Input
|
||||
aria-label="筛选 SCADA 设备"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="筛选设备编号、类型或单位"
|
||||
className="h-10 bg-[var(--surface-reading)] pl-9 text-xs md:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!snapshot && busy ? (
|
||||
<p role="status" className="px-4 py-8 text-center text-xs text-[var(--ink-muted)]">
|
||||
正在读取 SCADA 最新数据
|
||||
</p>
|
||||
) : visibleItems.length === 0 ? (
|
||||
<WorkbenchEmptyState
|
||||
icon={<Database size={16} />}
|
||||
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
|
||||
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
|
||||
className="m-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[calc(100dvh-390px)] min-h-48 overflow-auto overscroll-contain">
|
||||
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--surface-control)] text-[11px] text-[var(--ink-muted)]">
|
||||
<tr className="border-b border-[var(--surface-divider)]">
|
||||
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleItems.map((item) => (
|
||||
<tr key={item.device_id} className="border-b border-[var(--surface-divider)]/70 last:border-b-0">
|
||||
<th scope="row" className="px-3 py-2.5 text-left font-medium text-slate-800">
|
||||
<span className="block font-mono text-[12px]">{item.device_id}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal text-[var(--ink-muted)]">
|
||||
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
|
||||
</span>
|
||||
</th>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-slate-900 tabular-nums">
|
||||
{formatValue(item.monitored_value, item.measurement_unit)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-[var(--ink-muted)] tabular-nums">
|
||||
{formatValue(item.cleaned_value, item.measurement_unit)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{item.observed_at ? (
|
||||
<time dateTime={item.observed_at} className="text-slate-700">
|
||||
{formatObservedTime(item.observed_at)}
|
||||
</time>
|
||||
) : (
|
||||
<StatusBadge tone="neutral">无记录</StatusBadge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && snapshot.total > snapshot.items.length ? (
|
||||
<p className="border-t border-[var(--surface-divider)] px-3 py-2 text-[11px] text-[var(--ink-muted)]">
|
||||
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
|
||||
</p>
|
||||
) : null}
|
||||
</WorkbenchSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, unit: string) {
|
||||
if (value === null) return "—";
|
||||
const formatted = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 4 }).format(value);
|
||||
return `${formatted}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatObservedTime(value: string | null) {
|
||||
if (!value) return "暂无记录";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "时间无效";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatClock(value: Date) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function toLocalInput(value: Date) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function deviceTypeLabel(value: string) {
|
||||
return ({
|
||||
pressure: "压力",
|
||||
pipe_flow: "流量",
|
||||
quality: "水质",
|
||||
demand: "需水量",
|
||||
reservoir_liquid_level: "水库液位",
|
||||
tank_liquid_level: "水箱液位",
|
||||
fixed_pump: "定速泵",
|
||||
variable_pump: "变频泵"
|
||||
} as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
Reference in New Issue
Block a user