Files
next-tjwater-frontend/src/features/workbench/peripheral-sample/peripheral-sample-panel.tsx
T

199 lines
8.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
import { reverseGeocodePoint } from "../api/place-search-client";
import type { EventLocationAddressResolver } from "./use-event-location-address";
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 PeripheralWorkOrder
} from "./client";
const WORK_ORDER_DESCRIPTION = "录入业务工单并跟踪处理反馈,事件调查依据实际查询和现场记录开展。";
export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
mapReady,
mapRef,
onAgentTrigger,
onOpenInvestigation
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
const [context, setContext] = useState<PeripheralContext | null>(null);
const [busy, setBusy] = useState(false);
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 resolveEventAddress = useCallback<EventLocationAddressResolver>(async ([longitude, latitude], signal) => {
const result = await reverseGeocodePoint(longitude, latitude, context?.project_id ?? null, getAccessToken, signal);
return result.address.trim() || result.name;
}, [context?.project_id, getAccessToken]);
const refresh = useCallback(async () => {
if (!client.configured) return;
setBusy(true);
setError(null);
try {
setContext(await client.getContext());
setRefreshGeneration((current) => current + 1);
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setBusy(false);
}
}, [client]);
useEffect(() => {
if (open) void refresh();
}, [open, refresh]);
if (!open) return null;
return (
<aside
aria-label="外围系统"
className="workbench-shadcn-surface bg-background text-foreground 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 shadow-lg"
>
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-border px-4 sm:px-5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-muted text-foreground">
<RadioTower size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 时段演示</p>
</div>
<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}>
<X size={17} />
</Button>
</header>
<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-border px-3 py-2 sm:px-4">
<TabsList aria-label="外围系统类型" className="grid w-full grid-cols-2 bg-muted">
<TabsTrigger value="work-orders"><ClipboardList size={14} />工单系统</TabsTrigger>
<TabsTrigger value="scada"><Activity size={14} />SCADA 系统</TabsTrigger>
</TabsList>
</div>
<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">
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
<p className="font-medium">外围工单系统</p>
<p className="text-muted-foreground">{WORK_ORDER_DESCRIPTION}</p>
</div>
{!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>
{context ? (
<div className="space-y-3">
<ManagedTestResources
key={context.project_id}
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
refreshGeneration={refreshGeneration}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveEventAddress}
onAgentTrigger={onAgentTrigger}
/>
<Disclosure
label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>}
className="rounded-lg border border-border bg-background px-3.5"
>
<p className="pb-2 text-xs leading-5 text-muted-foreground">
结果未知时只查询原请求,不会重复创建或更新工单。
</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-border bg-background 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}
onOpenInvestigation={onOpenInvestigation}
/>
</TabsContent>
</Tabs>
</aside>
);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "外围工单请求失败";
}