feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -1,28 +1,19 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CircleGauge, Database, History, LoaderCircle, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import { ClipboardList, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { SyntheticDemoSection } from "./synthetic-demo-section";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { ManagedTestResources } from "./managed-test-resources";
|
||||
import { configuredHistoryWindow, type HistoryTimeMode } from "./history-window";
|
||||
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import { WorkbenchNotice } from "../components/workbench-panel-ui";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
PeripheralSampleError,
|
||||
type PeripheralContext,
|
||||
type PeripheralObservations,
|
||||
type PeripheralPointPage,
|
||||
type PeripheralScenario,
|
||||
type PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
|
||||
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
|
||||
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -31,127 +22,109 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
}) {
|
||||
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
|
||||
const client = clientRef.current;
|
||||
const initialHistory = useRef(configuredHistoryWindow(env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE));
|
||||
const [context, setContext] = useState<PeripheralContext | null>(null);
|
||||
const [scenario, setScenario] = useState<PeripheralScenario>("normal");
|
||||
const [monitoring, setMonitoring] = useState("未读取");
|
||||
const [points, setPoints] = useState<PeripheralPointPage["items"]>([]);
|
||||
const [pointId, setPointId] = useState("");
|
||||
const [historyMode, setHistoryMode] = useState<HistoryTimeMode>(
|
||||
env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE
|
||||
);
|
||||
const [historyStart, setHistoryStart] = useState(initialHistory.current.start);
|
||||
const [historyEnd, setHistoryEnd] = useState(initialHistory.current.end);
|
||||
const [observations, setObservations] = useState<PeripheralObservations | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [receiptRequestId, setReceiptRequestId] = useState("");
|
||||
const [inventory, setInventory] = useState({ workOrders: 0, testData: 0 });
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client.configured) return;
|
||||
setBusy("refresh"); setError(null);
|
||||
const [contextResult, scenarioResult, pointResult, alertResult] = await Promise.allSettled([
|
||||
client.getContext(), client.getScenario(), client.listPoints(), client.listAlerts()
|
||||
]);
|
||||
if (contextResult.status === "fulfilled") setContext(contextResult.value);
|
||||
if (scenarioResult.status === "fulfilled") setScenario(scenarioResult.value.scenario);
|
||||
if (pointResult.status === "fulfilled") {
|
||||
setPoints(pointResult.value.items);
|
||||
setPointId((current) => current || pointResult.value.items[0]?.point_id || "");
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setContext(await client.getContext());
|
||||
setRefreshGeneration((current) => current + 1);
|
||||
} catch (nextError) {
|
||||
setError(errorMessage(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (pointResult.status === "fulfilled" && alertResult.status === "fulfilled") {
|
||||
const alertText = alertResult.value.availability === "not_configured"
|
||||
? "告警来源未配置"
|
||||
: `${alertResult.value.items.length} 条告警`;
|
||||
setMonitoring(`${pointResult.value.items.length} 个测点 · ${alertText} · ${originLabel(pointResult.value.origin)}`);
|
||||
} else {
|
||||
setMonitoring("监测来源当前不可用,可切回正常场景后刷新");
|
||||
}
|
||||
const firstFailure = [contextResult, scenarioResult].find((result) => result.status === "rejected");
|
||||
if (firstFailure?.status === "rejected") setError(errorMessage(firstFailure.reason));
|
||||
setBusy(null);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => { if (open) void refresh(); }, [open, refresh]);
|
||||
useEffect(() => {
|
||||
if (open) void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside aria-label="外围样板管理" className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(820px,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)]">
|
||||
<aside
|
||||
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"><CircleGauge 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></div>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新外围样板" onClick={() => void refresh()} disabled={Boolean(busy)}><RefreshCw size={16} className={cn(busy === "refresh" && "animate-spin")} /></Button>
|
||||
<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 sm:grid-cols-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}
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white">
|
||||
<ClipboardList 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>
|
||||
</div>
|
||||
<SampleOverview
|
||||
configured={client.configured}
|
||||
context={context}
|
||||
points={points.length}
|
||||
scenario={scenario}
|
||||
resources={inventory.workOrders + inventory.testData}
|
||||
/>
|
||||
{context ? (
|
||||
<div className="mt-3 grid items-start gap-3 md:grid-cols-[minmax(250px,0.78fr)_minmax(390px,1.22fr)]">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<WorkbenchSection title="连接与演练场景" description={monitoring}
|
||||
icon={<RadioTower size={15} />} tone="teal" meta={<StatusBadge tone={context.can_write ? "info" : "neutral"}>{context.can_write ? "可写样板" : "只读"}</StatusBadge>}>
|
||||
<FieldSelect label="场景" value={scenario} disabled={!context.can_write || Boolean(busy)} options={context.scenarios.map((item) => ({ value: item, label: scenarioLabel(item) }))} onValueChange={async (value) => {
|
||||
const next = value as PeripheralScenario;
|
||||
setBusy("scenario"); setError(null);
|
||||
try { setScenario((await client.setScenario(next)).scenario); await refresh(); }
|
||||
catch (nextError) { setError(errorMessage(nextError)); }
|
||||
finally { setBusy(null); }
|
||||
}} />
|
||||
</WorkbenchSection>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新外围工单" onClick={() => void refresh()} disabled={busy}>
|
||||
<RefreshCw size={16} className={cn(busy && "animate-spin")} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭外围工单测试" onClick={onClose}>
|
||||
<X size={17} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<WorkbenchSection title="历史监测回放"
|
||||
description={`UTC 历史窗口 · ${historyMode === "demo" ? "固定演示时段" : historyMode === "recent" ? `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` : "自定义时段"}。仅查询外围数据。`}
|
||||
icon={<History size={15} />} tone="teal">
|
||||
<FieldSelect label="时间模式" value={historyMode} options={[
|
||||
{ value: "demo", label: "演示历史窗口" },
|
||||
{ value: "recent", label: `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` },
|
||||
{ value: "custom", label: "自定义窗口" }
|
||||
]} onValueChange={(value) => {
|
||||
const next = value as HistoryTimeMode;
|
||||
setHistoryMode(next); setObservations(null);
|
||||
if (next !== "custom") {
|
||||
const window = configuredHistoryWindow(next);
|
||||
setHistoryStart(window.start); setHistoryEnd(window.end);
|
||||
}
|
||||
}} />
|
||||
<FieldSelect className="mt-3" label="测点" value={pointId} placeholder="选择测点" options={points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} onValueChange={(value) => { setPointId(value); setObservations(null); }} />
|
||||
<div className="mt-2 grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1">
|
||||
<DateTimeField label="开始(UTC)" value={historyStart} onChange={(value) => { setHistoryStart(value); setHistoryMode("custom"); setObservations(null); }} />
|
||||
<DateTimeField label="结束(UTC)" value={historyEnd} onChange={(value) => { setHistoryEnd(value); setHistoryMode("custom"); setObservations(null); }} />
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2 w-full" disabled={!pointId || Boolean(busy)} onClick={async () => {
|
||||
setBusy("observations"); setError(null);
|
||||
try { setObservations(await client.listObservations(pointId, `${historyStart}:00Z`, `${historyEnd}:00Z`)); }
|
||||
catch (nextError) { setError(errorMessage(nextError)); }
|
||||
finally { setBusy(null); }
|
||||
}}>{busy === "observations" ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}读取历史读数</Button>
|
||||
{observations ? <ObservationTable observations={observations} /> : null}
|
||||
</WorkbenchSection>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<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}
|
||||
</div>
|
||||
|
||||
{context ? (
|
||||
<div className="space-y-3">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
onInventoryChange={setInventory}
|
||||
refreshGeneration={refreshGeneration}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<SyntheticDemoSection client={client} />
|
||||
<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 border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
|
||||
</div>
|
||||
<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 border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
||||
/>
|
||||
<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}
|
||||
</div>
|
||||
@@ -159,44 +132,6 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
);
|
||||
}
|
||||
|
||||
function SampleOverview({ configured, context, points, scenario, resources }: {
|
||||
configured: boolean;
|
||||
context: PeripheralContext | null;
|
||||
points: number;
|
||||
scenario: PeripheralScenario;
|
||||
resources: number;
|
||||
}) {
|
||||
const items = [
|
||||
{
|
||||
icon: <RadioTower size={14} />,
|
||||
label: "外围服务",
|
||||
value: !configured ? "未配置" : context ? "已连接" : "校验中",
|
||||
detail: context?.can_write ? "可维护样板" : context ? "只读访问" : ""
|
||||
},
|
||||
{
|
||||
icon: <History size={14} />,
|
||||
label: "演练场景",
|
||||
value: configured ? scenarioLabel(scenario) : "无",
|
||||
detail: `${points} 个监测点`
|
||||
},
|
||||
{
|
||||
icon: <Database size={14} />,
|
||||
label: "样板资源",
|
||||
value: context ? `${resources} 项` : "未读取",
|
||||
detail: "工单源记录与测试数据"
|
||||
}
|
||||
];
|
||||
return <section aria-label="外围样板概览" className="grid grid-cols-3 overflow-hidden rounded-[14px] border border-slate-200/80 bg-[var(--surface-reading)] shadow-[0_2px_8px_rgba(15,23,42,0.08)]">
|
||||
{items.map((item, index) => <div key={item.label} className={cn("flex min-w-0 items-center justify-center gap-3 px-2 py-3 sm:justify-start sm:px-3.5", index > 0 && "border-l border-slate-200/80")}>
|
||||
<span className="hidden size-8 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-100 sm:grid">{item.icon}</span>
|
||||
<div className="min-w-0 text-center sm:text-left"><p className="truncate text-[10px] font-medium text-slate-500">{item.label}</p><p className="truncate text-sm font-semibold tabular-nums text-slate-800">{item.value}</p>{item.detail ? <p className="hidden truncate text-[10px] text-slate-400 sm:block">{item.detail}</p> : null}</div>
|
||||
</div>)}
|
||||
</section>;
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "外围工单请求失败";
|
||||
}
|
||||
|
||||
function ObservationTable({ observations }: { observations: PeripheralObservations }) {
|
||||
return <div className="mt-3 overflow-hidden rounded-lg bg-white ring-1 ring-slate-200"><div className="flex items-center justify-between gap-2 bg-slate-50 px-2.5 py-2 text-[11px] text-slate-600"><span>{originLabel(observations.origin)} · {observations.source_id}</span><span className="tabular-nums">{observations.truncated ? "结果已截断" : `${observations.items.length} 条`}</span></div><div className="max-h-52 overflow-auto"><table className="w-full text-left text-[11px]"><thead className="sticky top-0 bg-white text-slate-500"><tr><th className="px-2.5 py-2 font-medium">时间</th><th className="px-2 py-2 text-right font-medium">读数</th><th className="px-2 py-2 font-medium">质量</th></tr></thead><tbody>{observations.items.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{item.observed_at.replace("T", " ").replace("Z", " UTC")}</td><td className="whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums">{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>{!observations.items.length ? <p className="p-3 text-center text-xs text-slate-500">此历史窗口没有读数。</p> : null}</div></div>;
|
||||
}
|
||||
function errorMessage(error: unknown) { return error instanceof Error ? error.message : "外围样板请求失败"; }
|
||||
function originLabel(origin: "historical_replay" | "synthetic") { return origin === "historical_replay" ? "真实项目样本回放" : "合成演练"; }
|
||||
function scenarioLabel(value: PeripheralScenario) { return ({ normal: "正常", missing_data: "数据缺失", unavailable: "服务不可用", response_lost: "响应丢失", duplicate_feedback: "重复反馈", conflicting_feedback: "冲突反馈", late_state: "迟到状态" } satisfies Record<PeripheralScenario, string>)[value]; }
|
||||
|
||||
Reference in New Issue
Block a user