feat: expand intelligent investigation workspace
This commit is contained in:
@@ -29,7 +29,7 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
|
||||
priority: "high",
|
||||
assignee: null,
|
||||
scheduled_at: null,
|
||||
origin: "synthetic",
|
||||
origin: "system_data",
|
||||
work_order_type: "general",
|
||||
location: null,
|
||||
scenario_code: null,
|
||||
@@ -43,6 +43,7 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
|
||||
});
|
||||
|
||||
const createWorkOrderInput: Omit<PeripheralCreateWorkOrder, "request_id"> = {
|
||||
origin: "system_data",
|
||||
title: "演示 测试工单",
|
||||
summary: "外围联调",
|
||||
status: "in_progress",
|
||||
|
||||
@@ -256,8 +256,9 @@ function isManagedResult(value: unknown): value is PeripheralWorkOrder | Periphe
|
||||
const item = value as Record<string, unknown>;
|
||||
if (item.deleted === true) return typeof item.resource_id === "string" &&
|
||||
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
|
||||
return (typeof item.work_order_id === "string" || typeof item.record_id === "string") &&
|
||||
typeof item.revision === "number" && item.origin === "synthetic";
|
||||
if (typeof item.revision !== "number") return false;
|
||||
if (typeof item.work_order_id === "string") return item.origin === "system_data";
|
||||
return typeof item.record_id === "string" && item.origin === "synthetic";
|
||||
}
|
||||
|
||||
export class PeripheralSampleError extends Error {
|
||||
|
||||
@@ -266,7 +266,7 @@ export interface components {
|
||||
* Origin
|
||||
* @constant
|
||||
*/
|
||||
origin: "synthetic";
|
||||
origin: "system_data";
|
||||
/**
|
||||
* Project Id
|
||||
* Format: uuid
|
||||
@@ -329,6 +329,12 @@ export interface components {
|
||||
feedback_requirements?: string[];
|
||||
/** Location */
|
||||
location?: string | null;
|
||||
/**
|
||||
* Origin
|
||||
* @default system_data
|
||||
* @constant
|
||||
*/
|
||||
origin: "system_data";
|
||||
/**
|
||||
* Priority
|
||||
* @default normal
|
||||
@@ -961,7 +967,7 @@ export interface components {
|
||||
* Origin
|
||||
* @constant
|
||||
*/
|
||||
origin: "synthetic";
|
||||
origin: "system_data";
|
||||
/**
|
||||
* Priority
|
||||
* @enum {string}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
CalendarClock,
|
||||
ClipboardList,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -18,7 +18,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
|
||||
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import { WorkbenchNotice } from "../components/workbench-panel-ui";
|
||||
import type { ScheduledWorkOrderItem } from "../types";
|
||||
import type {
|
||||
PeripheralContext,
|
||||
@@ -67,22 +67,20 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchSection
|
||||
title="工单接口测试"
|
||||
description="分别验证预约工单、投诉与爆管事件工单及后端同步。"
|
||||
icon={<ClipboardList size={15} />}
|
||||
tone="teal"
|
||||
bodyClassName="space-y-3"
|
||||
actions={(
|
||||
<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
|
||||
<section aria-label="工单管理" className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">工单管理</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">预约安排、投诉调查与爆管处置,按类型查看和维护。</p>
|
||||
</div>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="刷新工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
|
||||
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{capabilities.work_orders !== "available" ? (
|
||||
<WorkbenchNotice tone="warning" title="测试工单存储待供给">
|
||||
缺少 peripheral_sample.work_orders 或 work_order_revisions。
|
||||
<WorkbenchNotice tone="warning" title="工单服务未就绪">
|
||||
当前项目尚未配置工单存储,请联系管理员。
|
||||
</WorkbenchNotice>
|
||||
) : (
|
||||
<WorkOrders
|
||||
@@ -94,7 +92,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
)}
|
||||
</WorkbenchSection>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +208,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
scheduled_at: new Date(eventOccurredAt).toISOString(),
|
||||
work_order_type: eventKind,
|
||||
location: eventLocation.trim(),
|
||||
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
|
||||
scenario_code: null,
|
||||
origin: "system_data",
|
||||
feedback_requirements: []
|
||||
});
|
||||
setEventEditorOpen(false);
|
||||
@@ -220,16 +219,17 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
});
|
||||
|
||||
const canSave = canWrite && !busy && title.trim() && summary.trim();
|
||||
const editorInputClassName = "mt-1 h-9 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)]";
|
||||
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
|
||||
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
|
||||
|
||||
const orderEditor = editorOpen ? (
|
||||
<section
|
||||
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
|
||||
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
|
||||
className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200"
|
||||
className="mt-4 border-t border-border pt-4"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !busy) {
|
||||
if (event.key === "Escape" && !busy && !event.defaultPrevented
|
||||
&& event.currentTarget.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeEditor();
|
||||
@@ -238,19 +238,19 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
>
|
||||
<EditorHeading title={editing ? "编辑工单" : "新增预约工单"} onClose={closeEditor} disabled={Boolean(busy)} />
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
|
||||
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
|
||||
<label className="block text-xs font-medium text-slate-600">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<DateTimeField label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
|
||||
<label className="block text-xs font-medium text-slate-600">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<FieldSelect contentClassName="workbench-shadcn-surface" label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
|
||||
<FieldSelect contentClassName="workbench-shadcn-surface" label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
|
||||
<label className="block text-xs font-medium text-muted-foreground">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-muted-foreground">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<DateTimeField contentClassName="workbench-shadcn-surface" label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
|
||||
<label className="block text-xs font-medium text-muted-foreground">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
</div>
|
||||
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
|
||||
{editing ? <p className="mt-2 text-[11px] text-muted-foreground">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
|
||||
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
|
||||
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
|
||||
@@ -282,6 +282,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
assignee: assignee.trim() || null,
|
||||
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
|
||||
work_order_type: "general",
|
||||
origin: "system_data",
|
||||
location: location.trim() || null,
|
||||
scenario_code: scenarioCode.trim() || null,
|
||||
feedback_requirements: requirements
|
||||
@@ -320,11 +321,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-semibold text-slate-800">分类工单</h4>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
|
||||
<Sparkles size={13} />投诉与爆管上报
|
||||
@@ -333,17 +330,18 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
<Plus size={13} />新增预约工单
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
|
||||
</div>
|
||||
|
||||
{eventEditorOpen ? (
|
||||
<div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
|
||||
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<FieldSelect label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
|
||||
<DateTimeField label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
|
||||
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
|
||||
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
|
||||
</div>
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
|
||||
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
|
||||
@@ -364,25 +362,25 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
|
||||
{editorOpen && !editing ? orderEditor : null}
|
||||
|
||||
<div aria-label="分类工单列表" className="mt-3 space-y-3">
|
||||
<div aria-label="分类工单列表" className="mt-4 divide-y divide-border">
|
||||
{orderGroups.map((group) => (
|
||||
<Disclosure
|
||||
key={group.id}
|
||||
aria-label={`${group.title}列表`}
|
||||
initiallyOpen
|
||||
className="rounded-xl border border-slate-200/90 bg-white px-3"
|
||||
className="py-3 [&>summary]:rounded-md [&>summary]:py-1"
|
||||
label={(
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-xs font-semibold text-slate-800">{group.title}</h5>
|
||||
<p className="mt-0.5 text-[11px] font-normal leading-4 text-slate-500">{group.description}</p>
|
||||
<h5 className="text-sm font-semibold text-foreground">{group.title}</h5>
|
||||
<p className="mt-0.5 text-[11px] font-normal leading-4 text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{group.items.length ? (
|
||||
<ol className="space-y-2 border-t border-slate-200/80 py-3">
|
||||
<ol className="space-y-3 pt-3">
|
||||
{group.items.map((item, index) => (
|
||||
<li key={item.work_order_id}>
|
||||
<WorkOrderCard
|
||||
@@ -406,7 +404,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mb-3 border-t border-slate-200/80 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">暂无{group.title}</p>
|
||||
<p className="py-4 text-xs text-muted-foreground">暂无{group.title}</p>
|
||||
)}
|
||||
</Disclosure>
|
||||
))}
|
||||
@@ -435,16 +433,16 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
|
||||
deleteButtonRef.current?.focus();
|
||||
};
|
||||
return (
|
||||
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
|
||||
<article className="rounded-lg border border-border bg-card p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-slate-400">{workOrderTypeLabel(item.work_order_type)} {String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">#{String(index + 1).padStart(2, "0")}</span>
|
||||
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
|
||||
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
|
||||
</div>
|
||||
<h6 className="mt-1 text-balance text-xs font-semibold text-slate-900">{item.title}</h6>
|
||||
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
|
||||
<h6 className="mt-1 text-balance text-xs font-semibold text-foreground">{item.title}</h6>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{item.summary}</p>
|
||||
</div>
|
||||
{!editing ? <div className="flex shrink-0 gap-0.5">
|
||||
<Button id={`work-order-edit-${item.work_order_id}`} size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={(event) => onEdit(event.currentTarget)}><Pencil size={14} /></Button>
|
||||
@@ -477,15 +475,19 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
|
||||
</div>
|
||||
) : null}
|
||||
{editor}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-200/80 pt-2 text-[10px] text-slate-500">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border pt-2 text-[10px] text-muted-foreground">
|
||||
<span>{priorityLabel(item.priority)}优先级</span>
|
||||
{item.location ? <span>位置:{item.location}</span> : null}
|
||||
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
|
||||
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatTimestamp(item.scheduled_at)}</span> : null}
|
||||
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatLocalMonthDayTime(item.scheduled_at)}</span> : null}
|
||||
<span className="font-mono">r{item.revision}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{workOrder ? <WorkOrderReportPanel condition={workOrder} /> : <EventWorkOrderDetails item={item} />}
|
||||
{workOrder ? (
|
||||
<Disclosure aria-label={`工单详情 ${item.title}`} label="查看工单详情" className="border-t border-border pt-1">
|
||||
<WorkOrderReportPanel condition={workOrder} />
|
||||
</Disclosure>
|
||||
) : <EventWorkOrderDetails item={item} />}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -493,33 +495,33 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
|
||||
|
||||
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
|
||||
return (
|
||||
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<p className="text-[10px] font-semibold text-slate-500">事件信息</p>
|
||||
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="border-t border-border pt-3">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground">事件信息</p>
|
||||
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
|
||||
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
|
||||
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatTimestamp(item.scheduled_at) : "未记录"} />
|
||||
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatLocalMonthDayTime(item.scheduled_at) : "未记录"} />
|
||||
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
|
||||
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
|
||||
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
|
||||
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
|
||||
</dl>
|
||||
{item.activity?.length ? (
|
||||
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-slate-200 pt-3">
|
||||
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-border pt-3">
|
||||
{item.activity.map((entry) => (
|
||||
<li key={entry.sequence} className="rounded-lg bg-slate-50 px-2.5 py-2 text-[11px] leading-4 text-slate-600">
|
||||
<span className="font-semibold text-slate-800">{entry.title}</span>
|
||||
<li key={entry.sequence} className="rounded-lg bg-muted/50 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{entry.title}</span>
|
||||
<span className="ml-2">{entry.detail}</span>
|
||||
<span className="mt-1 block text-[10px] text-slate-400">{formatTimestamp(entry.occurred_at)} · {entry.actor}</span>
|
||||
<span className="mt-1 block text-[10px] text-muted-foreground">{formatLocalMonthDayTime(entry.occurred_at)} · {entry.actor}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : <p className="mt-3 border-t border-slate-200 pt-3 text-[11px] text-slate-500">暂无事件活动记录</p>}
|
||||
) : <p className="mt-3 border-t border-border pt-3 text-[11px] text-muted-foreground">暂无事件活动记录</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
|
||||
return <div className="min-w-0 rounded-lg bg-slate-50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-slate-800">{value}</dd></div>;
|
||||
return <div className="min-w-0 rounded-lg bg-muted/50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-muted-foreground">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-foreground">{value}</dd></div>;
|
||||
}
|
||||
|
||||
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
|
||||
@@ -549,7 +551,7 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
|
||||
}
|
||||
|
||||
function EditorHeading({ title, onClose, disabled = false }: { title: string; onClose: () => void; disabled?: boolean }) {
|
||||
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
|
||||
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-foreground">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
|
||||
}
|
||||
|
||||
const WORK_ORDER_STATUS_OPTIONS = [
|
||||
@@ -566,9 +568,7 @@ function toLocalInput(value: Date) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
function formatTimestamp(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
|
||||
function message(error: unknown) { return error instanceof Error ? error.message : "外围工单请求失败"; }
|
||||
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
|
||||
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
|
||||
const WORK_ORDER_DESCRIPTION = "录入业务工单并跟踪处理反馈,事件调查依据实际查询和现场记录开展。";
|
||||
|
||||
export function PeripheralSamplePanel({
|
||||
open,
|
||||
@@ -64,15 +64,15 @@ export function PeripheralSamplePanel({
|
||||
return (
|
||||
<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)]"
|
||||
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-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">
|
||||
<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-slate-500">工单协同与 SCADA 最新数据</p>
|
||||
<p className="text-xs text-muted-foreground">工单协同与 SCADA 最新数据</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -96,8 +96,8 @@ export function PeripheralSamplePanel({
|
||||
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)]">
|
||||
<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>
|
||||
@@ -105,7 +105,10 @@ export function PeripheralSamplePanel({
|
||||
|
||||
<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>
|
||||
<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,当前不会连接外围工单服务。
|
||||
@@ -128,9 +131,9 @@ export function PeripheralSamplePanel({
|
||||
/>
|
||||
<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)]"
|
||||
className="rounded-lg border border-border bg-background px-3.5"
|
||||
>
|
||||
<p className="pb-2 text-xs leading-5 text-slate-500">
|
||||
<p className="pb-2 text-xs leading-5 text-muted-foreground">
|
||||
结果未知时只查询原请求,不会重复创建或更新工单。
|
||||
</p>
|
||||
<div className="flex gap-2 pb-3">
|
||||
@@ -139,7 +142,7 @@ export function PeripheralSamplePanel({
|
||||
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"
|
||||
className="h-10 min-w-0 flex-1 rounded-lg border-border bg-background font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -185,9 +185,9 @@ export function ScadaSystemSection({
|
||||
|
||||
return (
|
||||
<div className="space-y-3" lang="zh-CN">
|
||||
<WorkbenchNotice tone="info" title="项目 SCADA 数据源">
|
||||
展示当前项目中每个设备最后一条已持久化记录。页面可见时每 15 秒刷新,不据此推断设备在线状态。
|
||||
</WorkbenchNotice>
|
||||
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
|
||||
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
|
||||
</p>
|
||||
{error ? (
|
||||
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
|
||||
{error}。已加载的数据会继续保留。
|
||||
@@ -213,25 +213,112 @@ export function ScadaSystemSection({
|
||||
|
||||
<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"
|
||||
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border 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">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p>
|
||||
<p className="mt-1 text-xl font-semibold leading-7 text-foreground tabular-nums">
|
||||
{snapshot ? snapshot.observed_count : "—"}
|
||||
<span className="ml-1 text-xs font-medium text-[var(--ink-muted)]">
|
||||
<span className="ml-1 text-xs font-medium text-muted-foreground">
|
||||
/ {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">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">最近记录时间</p>
|
||||
<p className="mt-1 text-sm font-semibold leading-7 text-foreground tabular-nums">
|
||||
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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-border 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-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
aria-label="筛选 SCADA 设备"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="筛选设备编号、类型或单位"
|
||||
className="h-10 bg-card pl-9 text-xs md:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!snapshot && busy ? (
|
||||
<p role="status" className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
正在读取 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-80 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-muted text-[11px] text-muted-foreground">
|
||||
<tr className="border-b border-border">
|
||||
<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-border last:border-b-0">
|
||||
<th scope="row" className="px-3 py-2.5 text-left font-medium text-foreground">
|
||||
<span className="block font-mono text-[12px]">{item.device_id}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
|
||||
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
|
||||
</span>
|
||||
</th>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-foreground tabular-nums">
|
||||
{formatValue(item.monitored_value, item.measurement_unit)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-muted-foreground 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-foreground">
|
||||
{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-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
|
||||
</p>
|
||||
) : null}
|
||||
</WorkbenchSection>
|
||||
|
||||
<WorkbenchSection
|
||||
title="异常读数演练"
|
||||
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
|
||||
@@ -258,9 +345,10 @@ export function ScadaSystemSection({
|
||||
) : null}
|
||||
|
||||
{simulationEditorOpen && canSimulate ? (
|
||||
<div className="rounded-xl bg-[var(--surface-control)] p-3 ring-1 ring-inset ring-[var(--surface-divider)]">
|
||||
<div className="border-b border-border pb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="SCADA 设备"
|
||||
value={simulationPointId}
|
||||
onValueChange={setSimulationPointId}
|
||||
@@ -271,18 +359,19 @@ export function ScadaSystemSection({
|
||||
}))}
|
||||
/>
|
||||
<DateTimeField
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="模拟发生时间"
|
||||
value={simulationObservedAt}
|
||||
onChange={setSimulationObservedAt}
|
||||
disabled={simulationBusy}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
<label className="mt-3 block text-xs font-medium text-muted-foreground">
|
||||
模拟读数{selectedSimulationDevice?.measurement_unit
|
||||
? `(${selectedSimulationDevice.measurement_unit})`
|
||||
: ""}
|
||||
<Input
|
||||
className="mt-1.5 h-10 bg-[var(--surface-reading)] text-sm tabular-nums"
|
||||
className="mt-1.5 h-10 bg-card text-sm tabular-nums"
|
||||
inputMode="decimal"
|
||||
value={simulationValue}
|
||||
onChange={(event) => setSimulationValue(event.target.value)}
|
||||
@@ -291,17 +380,17 @@ export function ScadaSystemSection({
|
||||
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
<label className="mt-3 block text-xs font-medium text-muted-foreground">
|
||||
异常说明
|
||||
<Textarea
|
||||
className="mt-1.5 min-h-20 bg-[var(--surface-reading)]"
|
||||
className="mt-1.5 min-h-20 bg-card"
|
||||
value={simulationReason}
|
||||
onChange={(event) => setSimulationReason(event.target.value)}
|
||||
disabled={simulationBusy}
|
||||
placeholder="例如:压力突降,偏离近一小时稳定区间"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
<p className="mt-2 text-[11px] leading-5 text-muted-foreground">
|
||||
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -330,15 +419,15 @@ export function ScadaSystemSection({
|
||||
{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)]"
|
||||
className="flex min-w-0 items-start gap-3 rounded-xl bg-muted 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)]">
|
||||
<p className="font-mono text-xs font-semibold text-foreground">{item.point_id}</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-[11px] leading-4 text-slate-700">
|
||||
<p className="mt-1 break-words text-[11px] leading-4 text-foreground">
|
||||
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -346,96 +435,10 @@ export function ScadaSystemSection({
|
||||
))}
|
||||
</ol>
|
||||
) : canSimulate && !simulationEditorOpen ? (
|
||||
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">尚未设定 SCADA 模拟异常。</p>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">尚未设定 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Database, LoaderCircle } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
type PeripheralDemoDataset
|
||||
} from "./client";
|
||||
|
||||
export function SyntheticDemoSection({ client }: { client: PeripheralSampleClient }) {
|
||||
const [dataset, setDataset] = useState<PeripheralDemoDataset | null>(null);
|
||||
const [pointId, setPointId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const observations = useMemo(
|
||||
() => dataset?.observations.filter((item) => item.point_id === pointId) ?? [],
|
||||
[dataset, pointId]
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await client.getDemoData();
|
||||
setDataset(next);
|
||||
setPointId((current) => current || next.points[0]?.point_id || "");
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "合成演示样本读取失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
label={<span className="flex min-w-0 items-center gap-3"><span className="grid size-7 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-800"><Database size={15} /></span><span className="min-w-0"><span className="block text-sm font-semibold text-slate-900">合成演示样本</span><span className="block truncate text-xs font-normal text-slate-500">固定种子 · 虚拟资产 · 非控制指令</span></span></span>}
|
||||
className="overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&>summary]:min-h-14 [&>summary]:py-3"
|
||||
>
|
||||
<div className="-mx-3.5 border-t border-slate-200/80 p-3.5">
|
||||
<p className="text-xs leading-5 text-slate-600">样本由外围服务从持久化数据集读取,浏览器不会生成或写入数据。所有资产、告警和运行事件均用于演示,不代表现场状态,也不是控制指令。</p>
|
||||
<Button variant="outline" className="mt-3 w-full" disabled={loading} onClick={() => void load()}>
|
||||
{loading ? <LoaderCircle className="animate-spin" /> : <Database />}
|
||||
{dataset ? "重新读取固定样本" : "手动读取合成样本"}
|
||||
</Button>
|
||||
{error ? <p className="mt-2 rounded-lg bg-rose-50 p-2.5 text-xs leading-5 text-rose-900 ring-1 ring-inset ring-rose-200">{error}。真实历史回放与核查管理不受影响。</p> : null}
|
||||
{dataset ? <DemoDatasetView dataset={dataset} pointId={pointId} onPointChange={setPointId} observations={observations} /> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoDatasetView({ dataset, pointId, onPointChange, observations }: {
|
||||
dataset: PeripheralDemoDataset;
|
||||
pointId: string;
|
||||
onPointChange: (value: string) => void;
|
||||
observations: PeripheralDemoDataset["observations"];
|
||||
}) {
|
||||
return <div className="mt-3 space-y-3">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50/65 p-3 text-xs text-cyan-950">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<Meta label="性质" value="合成演示" />
|
||||
<Meta label="生成器" value={dataset.generator_version} />
|
||||
<Meta label="固定种子" value={dataset.seed} />
|
||||
<Meta label="来源修订" value={dataset.source_revision} />
|
||||
</div>
|
||||
<p className="mt-2 break-words leading-5">固定窗口:{formatTimestamp(dataset.window.start)} 至 {formatTimestamp(dataset.window.end)}</p>
|
||||
<p className="mt-1 leading-5">摘要:{dataset.points.length} 个虚拟测点,{dataset.observations.length} 条读数,{dataset.device_states.length} 条状态,{dataset.alerts.length} 条告警,{dataset.operations.length} 条运行事件。</p>
|
||||
</div>
|
||||
<FieldSelect label="虚拟测点" value={pointId} onValueChange={onPointChange} options={dataset.points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} />
|
||||
<div className="max-h-48 overflow-auto rounded-lg bg-white ring-1 ring-slate-200">
|
||||
<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.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{formatTimestamp(item.observed_at)}</td><td className={cn("whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums", item.value === null && "text-amber-700")}>{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
<DemoList title={`设备状态 ${dataset.device_states.length}`} items={dataset.device_states.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${item.state}`, detail: `${item.asset_type} · ${formatTimestamp(item.observed_at)}` }))} />
|
||||
<DemoList title={`演示告警 ${dataset.alerts.length}`} items={dataset.alerts.map((item) => ({ id: item.alert_id, title: `${item.asset_id} · ${item.code}`, detail: `${item.message} · ${formatTimestamp(item.occurred_at)}` }))} />
|
||||
<DemoList title={`运行事件 ${dataset.operations.length}`} items={dataset.operations.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${operationLabel(item.operation)}`, detail: `${item.note} · ${formatTimestamp(item.occurred_at)}` }))} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) { return <div><span className="block text-[11px] text-cyan-700">{label}</span><span className="mt-0.5 block break-all font-medium">{value}</span></div>; }
|
||||
function DemoList({ title, items }: { title: string; items: Array<{ id: string; title: string; detail: string }> }) { return <Disclosure label={title} className="rounded-lg bg-white px-3 py-2 ring-1 ring-slate-200"><div className="mt-2 space-y-2">{items.map((item) => <div key={item.id} className="border-t border-slate-100 pt-2 text-[11px]"><p className="font-medium text-slate-700">{item.title}</p><p className="mt-1 leading-5 text-slate-500">{item.detail}</p></div>)}</div></Disclosure>; }
|
||||
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC"); }
|
||||
function operationLabel(value: PeripheralDemoDataset["operations"][number]["operation"]) { return ({ pump_start: "泵启动", pump_stop: "泵停止", valve_open: "阀门开启", valve_close: "阀门关闭" } satisfies Record<typeof value, string>)[value]; }
|
||||
@@ -19,7 +19,7 @@ const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrde
|
||||
work_order_type: "water_quality_complaint",
|
||||
location: "演示小区",
|
||||
scenario_code: WATER_QUALITY_SCENARIO_CODE,
|
||||
origin: "synthetic",
|
||||
origin: "system_data",
|
||||
created_at: "2026-09-22T02:00:00Z",
|
||||
updated_at: "2026-09-22T02:00:00Z",
|
||||
created_by: "user",
|
||||
@@ -38,14 +38,18 @@ describe("water-quality work-order trigger", () => {
|
||||
expect(first?.trigger_id).toBe(
|
||||
"peripheral.work_order.created:60000000-0000-4000-8000-000000000001:50000000-0000-4000-8000-000000000001:1"
|
||||
);
|
||||
expect(first?.payload.origin).toBe("synthetic");
|
||||
expect(first?.payload.origin).toBe("system_data");
|
||||
});
|
||||
|
||||
it("does not trigger general, draft, revised, or unknown-scenario work orders", () => {
|
||||
it("does not trigger general, draft, or revised work orders", () => {
|
||||
expect(toWaterQualityAgentTrigger(order({ work_order_type: "general" }))).toBeNull();
|
||||
expect(toWaterQualityAgentTrigger(order({ status: "draft" }))).toBeNull();
|
||||
expect(toWaterQualityAgentTrigger(order({ revision: 2 }))).toBeNull();
|
||||
expect(toWaterQualityAgentTrigger(order({ scenario_code: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves business provenance without requiring a demo scenario", () => {
|
||||
const trigger = toWaterQualityAgentTrigger(order({ scenario_code: null }));
|
||||
expect(trigger?.payload).toMatchObject({ origin: "system_data", scenario_code: null });
|
||||
});
|
||||
|
||||
it("maps the event into a structured condition task linked to the Agent session", () => {
|
||||
@@ -59,6 +63,7 @@ describe("water-quality work-order trigger", () => {
|
||||
id: "peripheral-water-quality-50000000-0000-4000-8000-000000000001",
|
||||
kind: "condition",
|
||||
taskId: "water-quality-complaint",
|
||||
dataSource: "backend",
|
||||
sessionId: "agent-session-water-quality",
|
||||
status: "running",
|
||||
riskLevel: "attention",
|
||||
|
||||
@@ -10,8 +10,7 @@ export function toWaterQualityAgentTrigger(
|
||||
if (
|
||||
workOrder.revision !== 1 ||
|
||||
workOrder.status !== "open" ||
|
||||
workOrder.work_order_type !== "water_quality_complaint" ||
|
||||
workOrder.scenario_code !== WATER_QUALITY_SCENARIO_CODE
|
||||
workOrder.work_order_type !== "water_quality_complaint"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -35,14 +34,14 @@ export function toWaterQualityAgentTrigger(
|
||||
summary: workOrder.summary,
|
||||
location: workOrder.location,
|
||||
priority: workOrder.priority,
|
||||
scenario_code: WATER_QUALITY_SCENARIO_CODE,
|
||||
origin: "synthetic"
|
||||
scenario_code: workOrder.scenario_code,
|
||||
origin: "system_data"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function waterQualityTriggerPrompt(workOrder: PeripheralWorkOrder) {
|
||||
return `外围样板新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
|
||||
return `外围系统新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
|
||||
}
|
||||
|
||||
export function toWaterQualityConditionTask(
|
||||
@@ -54,7 +53,7 @@ export function toWaterQualityConditionTask(
|
||||
id: `peripheral-water-quality-${workOrder.work_order_id}`,
|
||||
kind: "condition",
|
||||
taskId: "water-quality-complaint",
|
||||
dataSource: "mock",
|
||||
dataSource: "backend",
|
||||
sessionId,
|
||||
title: "水质投诉调查",
|
||||
summary: workOrder.summary,
|
||||
@@ -64,7 +63,7 @@ export function toWaterQualityConditionTask(
|
||||
updatedAt: now.getTime(),
|
||||
durationMinutes: 15,
|
||||
detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`,
|
||||
recommendation: "优先校验上游监测点数据质量;若上游指标正常,再转向二次供水设施和运维记录核查。",
|
||||
recommendation: "根据工单和实际查询结果核查原因,保留数据来源与待现场核验事项。",
|
||||
evidence: [
|
||||
`来源工单:${workOrder.work_order_id}`,
|
||||
`投诉位置:${workOrder.location ?? "待确认"}`,
|
||||
|
||||
Reference in New Issue
Block a user