1316 lines
44 KiB
TypeScript
1316 lines
44 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
AlertTriangle,
|
||
Bot,
|
||
CalendarClock,
|
||
CheckCircle2,
|
||
ClipboardCheck,
|
||
ClipboardList,
|
||
Clock3,
|
||
FileText,
|
||
Lightbulb,
|
||
ListChecks,
|
||
Loader2,
|
||
MessageSquare,
|
||
Route,
|
||
Send,
|
||
ShieldAlert,
|
||
Wrench
|
||
} from "lucide-react";
|
||
import { AnimatePresence, motion } from "motion/react";
|
||
import { useEffect, useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||
import {
|
||
getRunningWorkflowDefinition,
|
||
getRunningWorkflowState,
|
||
getWorkflowStepStatus
|
||
} from "@/features/workbench/data/running-workflows";
|
||
import type {
|
||
ScheduledConditionAnalysisInsight,
|
||
ScheduledConditionItem,
|
||
ScheduledConditionKpi,
|
||
ScheduledConditionRecord,
|
||
ScheduledConditionRiskLevel,
|
||
ScheduledWorkOrderItem
|
||
} from "@/features/workbench/types";
|
||
import {
|
||
formatDuration,
|
||
formatTime,
|
||
formatTimeRange,
|
||
getRiskLabel,
|
||
getWorkOrderCurrentStage,
|
||
getWorkOrderStagePillClassName,
|
||
statusClassNames,
|
||
statusLabels
|
||
} from "./scheduled-condition-feed-utils";
|
||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||
|
||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
||
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/80 bg-white/95";
|
||
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-slate-50/80";
|
||
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-slate-50/80 px-2 py-1.5";
|
||
|
||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||
attention: "border-orange-200 bg-orange-50 text-orange-700",
|
||
critical: "border-red-200 bg-red-50 text-red-700"
|
||
};
|
||
|
||
type IncidentOperation = {
|
||
target: string;
|
||
elementType: string;
|
||
action: string;
|
||
command: string;
|
||
verification: string;
|
||
};
|
||
|
||
type SubmittedIncidentWorkOrder = {
|
||
code: string;
|
||
submittedAt: number;
|
||
notifiedTeams: string[];
|
||
operations: IncidentOperation[];
|
||
};
|
||
|
||
type ExecutionStepStatus = "done" | "current" | "pending" | "confirmation";
|
||
|
||
type ExecutionBadgeTone = "active" | "confirmation" | "completed" | "pending";
|
||
|
||
type ExecutionStepMeta = {
|
||
label: string;
|
||
value: string;
|
||
};
|
||
|
||
type ExecutionStep = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
status: ExecutionStepStatus;
|
||
meta?: ExecutionStepMeta[];
|
||
};
|
||
|
||
type ConditionDetailPanelProps = {
|
||
condition: ScheduledConditionItem;
|
||
onContinueConversation: () => void;
|
||
};
|
||
|
||
export function StatusPill({
|
||
condition,
|
||
className
|
||
}: {
|
||
condition: ScheduledConditionItem;
|
||
className?: string;
|
||
}) {
|
||
const isWorkOrder = condition.kind === "work_order";
|
||
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
||
|
||
return (
|
||
<span
|
||
className={cn(
|
||
STATUS_PILL_CLASS_NAME,
|
||
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
|
||
className
|
||
)}
|
||
>
|
||
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function ConditionDetailPanel({ condition, onContinueConversation }: ConditionDetailPanelProps) {
|
||
const detailKey =
|
||
condition.kind === "work_order"
|
||
? `${condition.id}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
|
||
: condition.id;
|
||
const content =
|
||
condition.kind === "work_order" ? (
|
||
<WorkOrderDetailPanel condition={condition} />
|
||
) : (
|
||
<ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />
|
||
);
|
||
|
||
return (
|
||
<AnimatePresence initial={false} mode="wait">
|
||
<motion.div
|
||
key={detailKey}
|
||
className="min-h-0"
|
||
initial={{ opacity: 0, y: 8, filter: "blur(2px)" }}
|
||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||
exit={{ opacity: 0, y: -6, filter: "blur(1px)" }}
|
||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||
>
|
||
{content}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
);
|
||
}
|
||
|
||
function ConditionRecordDetailPanel({
|
||
condition,
|
||
onContinueConversation
|
||
}: {
|
||
condition: ScheduledConditionRecord;
|
||
onContinueConversation: () => void;
|
||
}) {
|
||
const insight = getConditionAnalysisInsight(condition);
|
||
const [submittedWorkOrder, setSubmittedWorkOrder] = useState<SubmittedIncidentWorkOrder | null>(null);
|
||
const header = getConditionDetailHeader(condition);
|
||
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
|
||
const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`;
|
||
|
||
function handleSubmitIncidentWorkOrder() {
|
||
const nextWorkOrder = createSubmittedIncidentWorkOrder(condition);
|
||
setSubmittedWorkOrder(nextWorkOrder);
|
||
showMapNotice({
|
||
id: `incident-work-order-${condition.id}`,
|
||
tone: "success",
|
||
title: "系统工单已提交",
|
||
message: `${nextWorkOrder.code} 已通知 ${nextWorkOrder.notifiedTeams.join("、")}。`
|
||
});
|
||
}
|
||
|
||
return (
|
||
<DetailScroll>
|
||
<DetailShell>
|
||
<DetailHeader
|
||
condition={condition}
|
||
icon={header.icon}
|
||
iconClassName={header.iconClassName}
|
||
iconSpin={header.iconSpin}
|
||
/>
|
||
<DetailDecisionBody>
|
||
<AnimatePresence initial={false} mode="wait">
|
||
<motion.div
|
||
key={bodyKey}
|
||
className="space-y-2.5"
|
||
initial={{ opacity: 0, y: 10, scale: 0.992, filter: "blur(2px)" }}
|
||
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
|
||
exit={{ opacity: 0, y: -8, scale: 0.996, filter: "blur(1px)" }}
|
||
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
|
||
>
|
||
{condition.status === "running" ? (
|
||
<RunningConditionBody condition={condition} />
|
||
) : (
|
||
<>
|
||
<ConditionStatusReport condition={condition} riskLevel={riskLevel} />
|
||
{riskLevel === "critical" ? (
|
||
<>
|
||
<DecisionSummaryBlock
|
||
icon={ShieldAlert}
|
||
title="异常判断"
|
||
iconClassName="text-red-600"
|
||
summary={insight.summary}
|
||
notes={insight.notes}
|
||
/>
|
||
<SolutionOptionsList options={insight.solutionOptions ?? []} />
|
||
<IncidentWorkOrderPanel
|
||
condition={condition}
|
||
submittedWorkOrder={submittedWorkOrder}
|
||
onSubmitWorkOrder={handleSubmitIncidentWorkOrder}
|
||
/>
|
||
<DecisionActionStrip
|
||
icon={Bot}
|
||
title="Agent 继续分析"
|
||
value={condition.recommendation ?? "建议打开 Agent 会话,结合异常证据继续判断处置优先级。"}
|
||
actionLabel="继续分析"
|
||
onAction={onContinueConversation}
|
||
/>
|
||
</>
|
||
) : riskLevel === "attention" ? (
|
||
<>
|
||
<DecisionSummaryBlock
|
||
icon={Lightbulb}
|
||
title="关注说明"
|
||
iconClassName="text-orange-600"
|
||
summary={insight.summary}
|
||
notes={insight.notes}
|
||
/>
|
||
<EscalationCriteriaList items={insight.escalationCriteria ?? []} />
|
||
<DecisionActionStrip
|
||
icon={Bot}
|
||
title="复核建议"
|
||
value={condition.recommendation ?? "建议保留为关注项,下一轮工况继续复核同一测点趋势。"}
|
||
actionLabel="继续对话"
|
||
onAction={onContinueConversation}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<EvidenceList items={condition.evidence ?? []} />
|
||
<DecisionActionStrip
|
||
icon={Bot}
|
||
title="Agent 建议"
|
||
value={condition.recommendation ?? "当前工况暂无新增处置建议,保持观察即可。"}
|
||
actionLabel="继续对话"
|
||
onAction={onContinueConversation}
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</DetailDecisionBody>
|
||
</DetailShell>
|
||
</DetailScroll>
|
||
);
|
||
}
|
||
|
||
function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||
const currentStage = getWorkOrderCurrentStage(condition);
|
||
|
||
return (
|
||
<DetailScroll>
|
||
<DetailShell>
|
||
<DetailHeader
|
||
condition={condition}
|
||
icon={ClipboardList}
|
||
iconClassName="bg-blue-50 text-blue-700"
|
||
/>
|
||
<DetailDecisionBody>
|
||
<WorkOrderLifecyclePanel condition={condition} />
|
||
<WorkOrderReplyRequirements condition={condition} />
|
||
<DecisionActionStrip
|
||
icon={CalendarClock}
|
||
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
|
||
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
|
||
actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"}
|
||
actionDisabled
|
||
actionTitle="工单流程由调度和现场班组流转"
|
||
/>
|
||
</DetailDecisionBody>
|
||
</DetailShell>
|
||
</DetailScroll>
|
||
);
|
||
}
|
||
|
||
function RunningConditionBody({ condition }: { condition: ScheduledConditionRecord }) {
|
||
const runningEvidence = (condition.evidence ?? []).slice(0, 2);
|
||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||
|
||
useEffect(() => {
|
||
setNowMs(Date.now());
|
||
const interval = window.setInterval(() => {
|
||
setNowMs(Date.now());
|
||
}, RUNNING_WORKFLOW_TICK_MS);
|
||
|
||
return () => window.clearInterval(interval);
|
||
}, [condition.id]);
|
||
|
||
return (
|
||
<>
|
||
<RunningWorkflowPanel condition={condition} nowMs={nowMs} />
|
||
<RunningEvidencePreview items={runningEvidence} />
|
||
<DecisionActionStrip
|
||
icon={Bot}
|
||
title="阶段判断"
|
||
value={condition.recommendation ?? "等待本轮工况完成后再确认是否生成处置建议。"}
|
||
actionLabel="等待完成"
|
||
actionDisabled
|
||
actionTitle="任务执行中,完成后可继续对话"
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function getConditionDetailHeader(condition: ScheduledConditionRecord) {
|
||
if (condition.status === "running") {
|
||
return {
|
||
icon: Loader2,
|
||
iconClassName: "bg-blue-600 text-white",
|
||
iconSpin: true
|
||
};
|
||
}
|
||
|
||
return {
|
||
icon: FileText,
|
||
iconClassName: undefined,
|
||
iconSpin: false
|
||
};
|
||
}
|
||
|
||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type DetailShellProps = {
|
||
children: React.ReactNode;
|
||
};
|
||
|
||
function DetailShell({ children }: DetailShellProps) {
|
||
return (
|
||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden ring-1 ring-white/80")}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type DetailHeaderProps = {
|
||
condition: ScheduledConditionItem;
|
||
icon: typeof FileText;
|
||
iconClassName?: string;
|
||
iconSpin?: boolean;
|
||
};
|
||
|
||
function DetailHeader({
|
||
condition,
|
||
icon: Icon,
|
||
iconClassName,
|
||
iconSpin = false
|
||
}: DetailHeaderProps) {
|
||
return (
|
||
<div className="bg-white/95 px-3 py-3">
|
||
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
|
||
<div className="flex min-w-0 items-start gap-2.5">
|
||
<span
|
||
className={cn(
|
||
"grid h-9 w-9 shrink-0 place-items-center bg-slate-100 text-slate-600",
|
||
"rounded-lg",
|
||
iconClassName
|
||
)}
|
||
>
|
||
<Icon size={16} aria-hidden="true" className={iconSpin ? "animate-spin" : undefined} />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-mono text-xs font-semibold text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||
{condition.kind === "work_order" || condition.status === "running" || condition.status === "pending" ? (
|
||
<StatusPill condition={condition} />
|
||
) : null}
|
||
</div>
|
||
<h3 className="mt-1 text-base font-semibold leading-5 text-slate-950">{condition.title}</h3>
|
||
{condition.detail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<DetailMetricGrid condition={condition} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailDecisionBody({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="space-y-2.5 px-3 pb-3">
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DecisionSummaryBlock({
|
||
icon: Icon,
|
||
title,
|
||
iconClassName,
|
||
summary,
|
||
notes
|
||
}: {
|
||
icon: typeof Bot;
|
||
title: string;
|
||
iconClassName: string;
|
||
summary: string;
|
||
notes: string[];
|
||
}) {
|
||
return (
|
||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
|
||
<SectionTitle icon={Icon} title={title} iconClassName={iconClassName} />
|
||
<p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p>
|
||
<ul className="mt-2 grid gap-1.5">
|
||
{notes.map((note) => (
|
||
<li key={note} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" aria-hidden="true" />
|
||
<span>{note}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SolutionOptionsList({
|
||
options
|
||
}: {
|
||
options: NonNullable<ScheduledConditionAnalysisInsight["solutionOptions"]>;
|
||
}) {
|
||
return (
|
||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
|
||
<SectionEyebrow icon={Route} label="处置方案" />
|
||
<div className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
|
||
{options.map((option, index) => (
|
||
<article key={option.id} className="px-2.5 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||
{index + 1}
|
||
</span>
|
||
<h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4>
|
||
</div>
|
||
<dl className="mt-2 grid gap-1.5 text-xs leading-5">
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">适用</dt>
|
||
<dd className="text-slate-700">{option.scenario}</dd>
|
||
</div>
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">动作</dt>
|
||
<dd className="text-slate-700">{option.action}</dd>
|
||
</div>
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">代价</dt>
|
||
<dd className="text-slate-700">{option.tradeoff}</dd>
|
||
</div>
|
||
</dl>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function IncidentWorkOrderPanel({
|
||
condition,
|
||
submittedWorkOrder,
|
||
onSubmitWorkOrder
|
||
}: {
|
||
condition: ScheduledConditionItem;
|
||
submittedWorkOrder: SubmittedIncidentWorkOrder | null;
|
||
onSubmitWorkOrder: () => void;
|
||
}) {
|
||
const operations = submittedWorkOrder?.operations ?? createIncidentOperations(condition);
|
||
const submitted = Boolean(submittedWorkOrder);
|
||
const steps: ExecutionStep[] = [
|
||
{ id: "analysis", title: "事件分析", status: "done", description: "汇总异常证据、影响分区和风险等级。" },
|
||
{ id: "solution", title: "形成方案", status: "done", description: "生成阀门、泵站和现场复核操作清单。" },
|
||
{ id: "confirm", title: "人工确认", status: submitted ? "done" : "confirmation", description: "调度员确认影响范围和操作边界。" },
|
||
{ id: "submit", title: "提交工单", status: submitted ? "done" : "pending", description: "写入系统工单并通知执行岗位。" }
|
||
];
|
||
|
||
return (
|
||
<TaskExecutionPanel
|
||
icon={ClipboardCheck}
|
||
title="任务执行"
|
||
badge={submitted ? "已提交" : "待人工确认"}
|
||
badgeTone={submitted ? "completed" : "confirmation"}
|
||
summary={
|
||
submitted
|
||
? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。`
|
||
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。"
|
||
}
|
||
meta={
|
||
submittedWorkOrder
|
||
? [
|
||
{ label: "系统工单", value: submittedWorkOrder.code },
|
||
{ label: "通知对象", value: submittedWorkOrder.notifiedTeams.join("、") }
|
||
]
|
||
: [
|
||
{ label: "来源", value: condition.title },
|
||
{ label: "状态", value: "待人工确认" },
|
||
{ label: "风险等级", value: getRiskLabel(condition.riskLevel) }
|
||
]
|
||
}
|
||
steps={steps}
|
||
>
|
||
<IncidentOperationList operations={operations} submitted={submitted} />
|
||
<button
|
||
type="button"
|
||
disabled={submitted}
|
||
aria-disabled={submitted}
|
||
onClick={onSubmitWorkOrder}
|
||
className={cn(
|
||
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
||
"rounded-xl",
|
||
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
||
)}
|
||
>
|
||
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
||
<span>{submitted ? "工单已提交" : "人工确认并提交工单"}</span>
|
||
</button>
|
||
</TaskExecutionPanel>
|
||
);
|
||
}
|
||
|
||
function IncidentOperationList({
|
||
operations,
|
||
submitted
|
||
}: {
|
||
operations: IncidentOperation[];
|
||
submitted: boolean;
|
||
}) {
|
||
return (
|
||
<div className={cn(INSET_SURFACE_CLASS_NAME, "divide-y divide-slate-200/70 overflow-hidden")}>
|
||
{operations.map((operation) => (
|
||
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<SectionEyebrow icon={Wrench} label={operation.action} />
|
||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
|
||
{submitted ? "已通知" : operation.elementType}
|
||
</span>
|
||
</div>
|
||
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">对象</dt>
|
||
<dd className="text-slate-700">{operation.target}</dd>
|
||
</div>
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">指令</dt>
|
||
<dd className="text-slate-700">{operation.command}</dd>
|
||
</div>
|
||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||
<dt className="font-semibold text-slate-400">复核</dt>
|
||
<dd className="text-slate-700">{operation.verification}</dd>
|
||
</div>
|
||
</dl>
|
||
</article>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EscalationCriteriaList({ items }: { items: string[] }) {
|
||
return (
|
||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
|
||
<SectionEyebrow icon={AlertTriangle} label="升级条件" />
|
||
<ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
|
||
{items.map((item, index) => (
|
||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 px-2 py-1.5 text-xs leading-5 text-slate-700">
|
||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||
{index + 1}
|
||
</span>
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ConditionStatusReport({
|
||
condition,
|
||
riskLevel
|
||
}: {
|
||
condition: ScheduledConditionRecord;
|
||
riskLevel: ScheduledConditionRiskLevel;
|
||
}) {
|
||
if (!condition.report && !condition.kpis?.length) {
|
||
return null;
|
||
}
|
||
|
||
const report = condition.report;
|
||
const kpis = condition.kpis ?? [];
|
||
const statusTime = formatTimeRange(condition.scheduledAt, condition.durationMinutes);
|
||
|
||
return (
|
||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<SectionTitle icon={FileText} title="完成状态报告" />
|
||
<LifecyclePill />
|
||
</div>
|
||
<h4 className="mt-2 min-w-0 text-sm font-semibold leading-5 text-slate-950">{report?.title ?? condition.title}</h4>
|
||
{report?.conclusion ? (
|
||
<div className="mt-1.5 flex min-w-0 flex-wrap items-start gap-2">
|
||
<p className="min-w-[12rem] flex-1 text-xs leading-5 text-slate-700">{report.conclusion}</p>
|
||
<RiskBadge riskLevel={riskLevel} />
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-5 text-slate-500">
|
||
<Clock3 size={13} aria-hidden="true" />
|
||
<span>报告时段</span>
|
||
<span className="font-mono font-semibold tabular-nums text-slate-700">{statusTime}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{kpis.length > 0 ? (
|
||
<div className="px-3 py-3">
|
||
<SectionEyebrow icon={ListChecks} label="关键指标" />
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
{kpis.map((kpi) => (
|
||
<KpiTile key={kpi.id} kpi={kpi} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{report?.sections?.length ? (
|
||
<div className="grid gap-2 border-t border-slate-200/70 px-3 py-3">
|
||
{report.sections.map((section) => (
|
||
<ReportSection key={section.title} title={section.title} items={section.items} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function LifecyclePill() {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
|
||
<CheckCircle2 size={12} aria-hidden="true" />
|
||
<span>完成</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
|
||
const Icon = riskLevel === "critical" ? ShieldAlert : riskLevel === "attention" ? AlertTriangle : CheckCircle2;
|
||
const label = getRiskLabel(riskLevel);
|
||
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
|
||
getRiskIndicatorClassName(riskLevel)
|
||
)}
|
||
aria-label={`风险等级:${label}`}
|
||
>
|
||
<Icon size={12} aria-hidden="true" />
|
||
<span>{label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||
return riskIndicatorClassNames[riskLevel];
|
||
}
|
||
|
||
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className={REPORT_META_TILE_CLASS_NAME}>
|
||
<div className="text-xs font-semibold text-slate-400">{label}</div>
|
||
<div className="mt-0.5 truncate text-xs font-semibold text-slate-700" title={value}>
|
||
{value}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReportSection({
|
||
title,
|
||
items
|
||
}: {
|
||
title: string;
|
||
items: string[];
|
||
}) {
|
||
return (
|
||
<section className={cn(INSET_SURFACE_CLASS_NAME, "px-2.5 py-2")}>
|
||
<div className="text-xs font-semibold text-slate-500">{title}</div>
|
||
<ul className="mt-1.5 grid gap-1">
|
||
{items.map((item) => (
|
||
<li key={item} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" aria-hidden="true" />
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||
return (
|
||
<div className={cn("min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||
{kpi.label}
|
||
</div>
|
||
<div className="mt-1 flex items-baseline gap-1">
|
||
<span className="font-mono text-lg font-semibold leading-5 text-slate-900">{kpi.value}</span>
|
||
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
||
</div>
|
||
</div>
|
||
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
|
||
{getRiskLabel(kpi.status)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
||
{kpi.threshold || kpi.baseline ? (
|
||
<div className="mt-1.5 space-y-0.5 text-xs leading-4 text-slate-500">
|
||
{kpi.threshold ? <div>阈值:{kpi.threshold}</div> : null}
|
||
{kpi.baseline ? <div>基线:{kpi.baseline}</div> : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||
if (riskLevel === "critical") {
|
||
return "border-red-200/80 bg-red-50/80";
|
||
}
|
||
|
||
if (riskLevel === "attention") {
|
||
return "border-orange-200/80 bg-orange-50/80";
|
||
}
|
||
|
||
return "border-slate-200/70 bg-slate-50/80";
|
||
}
|
||
|
||
function DecisionActionStrip({
|
||
icon: Icon,
|
||
title,
|
||
value,
|
||
actionLabel,
|
||
actionDisabled = false,
|
||
actionTitle,
|
||
onAction
|
||
}: {
|
||
icon: typeof Bot;
|
||
title: string;
|
||
value: string;
|
||
actionLabel: string;
|
||
actionDisabled?: boolean;
|
||
actionTitle?: string;
|
||
onAction?: () => void;
|
||
}) {
|
||
return (
|
||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "flex flex-col gap-2 px-3 py-3 min-[1500px]:flex-row min-[1500px]:items-center")}>
|
||
<div className="min-w-0 flex-1">
|
||
<SectionEyebrow icon={Icon} label={title} />
|
||
<p className="mt-1 text-xs leading-5 text-slate-700">{value}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={actionDisabled}
|
||
aria-disabled={actionDisabled}
|
||
title={actionTitle}
|
||
onClick={onAction}
|
||
className={cn(
|
||
"flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors min-[1500px]:w-[104px]",
|
||
"rounded-xl",
|
||
actionDisabled
|
||
? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80"
|
||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||
)}
|
||
>
|
||
<MessageSquare size={14} aria-hidden="true" />
|
||
<span>{actionLabel}</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionEyebrow({
|
||
icon: Icon,
|
||
label
|
||
}: {
|
||
icon?: typeof Bot;
|
||
label: string;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||
{Icon ? <Icon size={13} aria-hidden="true" /> : null}
|
||
<span>{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
|
||
if (tone === "completed") {
|
||
return "border-green-100 bg-green-50 text-green-700";
|
||
}
|
||
|
||
if (tone === "confirmation") {
|
||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||
}
|
||
|
||
if (tone === "active") {
|
||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||
}
|
||
|
||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||
}
|
||
|
||
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||
if (status === "done") {
|
||
return "bg-green-500 text-white";
|
||
}
|
||
|
||
if (status === "current") {
|
||
return "bg-blue-600 text-white";
|
||
}
|
||
|
||
if (status === "confirmation") {
|
||
return "bg-orange-500 text-white";
|
||
}
|
||
|
||
return "bg-slate-100 text-slate-400";
|
||
}
|
||
|
||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||
if (status === "current") {
|
||
return "border-blue-100 bg-blue-50/70";
|
||
}
|
||
|
||
if (status === "confirmation") {
|
||
return "border-orange-100 bg-orange-50/70";
|
||
}
|
||
|
||
return "border-slate-200/70 bg-slate-50/80";
|
||
}
|
||
|
||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||
if (status === "done") {
|
||
return "border-green-100 bg-green-50 text-green-700";
|
||
}
|
||
|
||
if (status === "current") {
|
||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||
}
|
||
|
||
if (status === "confirmation") {
|
||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||
}
|
||
|
||
return "border-slate-200 bg-white text-slate-500";
|
||
}
|
||
|
||
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
||
if (status === "done") {
|
||
return "已完成";
|
||
}
|
||
|
||
if (status === "current") {
|
||
return "当前";
|
||
}
|
||
|
||
if (status === "confirmation") {
|
||
return "待人工确认";
|
||
}
|
||
|
||
return "待处理";
|
||
}
|
||
|
||
function DetailMetricGrid({ condition }: { condition: ScheduledConditionItem }) {
|
||
const durationLabel = condition.status === "running" ? "状态" : "耗时";
|
||
const durationValue = condition.status === "running" ? "实时执行" : formatDuration(condition.durationMinutes, condition.status);
|
||
const metrics =
|
||
condition.kind === "condition"
|
||
? [
|
||
{ label: "模型", value: condition.modelName ?? "未记录" },
|
||
{ label: "会话", value: condition.sessionId ?? "未记录" },
|
||
{ label: durationLabel, value: durationValue, className: "col-span-2" }
|
||
]
|
||
: [
|
||
{ label: "类型", value: "预约工单" },
|
||
{ label: "窗口", value: formatTimeRange(condition.scheduledAt, condition.durationMinutes) },
|
||
{ label: "风险", value: getRiskLabel(condition.riskLevel) },
|
||
{ label: durationLabel, value: durationValue }
|
||
];
|
||
|
||
return (
|
||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||
{metrics.map((metric) => (
|
||
<div key={metric.label} className={metric.className}>
|
||
<ReportMetaTile label={metric.label} value={metric.value} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RunningWorkflowPanel({
|
||
condition,
|
||
nowMs
|
||
}: {
|
||
condition: ScheduledConditionRecord;
|
||
nowMs: number;
|
||
}) {
|
||
const workflow = getRunningWorkflowDefinition(condition);
|
||
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
|
||
const currentStepIndex = workflowState.currentStepIndex;
|
||
const currentStep = workflow.steps[currentStepIndex] ?? workflow.steps[0];
|
||
const nextStep = workflow.steps[currentStepIndex + 1];
|
||
const steps: ExecutionStep[] = workflow.steps.map((step, index) => ({
|
||
id: step.id,
|
||
title: step.title,
|
||
description: step.detail,
|
||
status: getWorkflowStepStatus(condition, index, currentStepIndex)
|
||
}));
|
||
|
||
return (
|
||
<TaskExecutionPanel
|
||
icon={Loader2}
|
||
title={workflow.title}
|
||
badge="执行中"
|
||
badgeTone="active"
|
||
summary={`${currentStep.title}:${currentStep.detail}`}
|
||
meta={[
|
||
{ label: "任务对象", value: condition.title },
|
||
{ label: "当前进度", value: `${workflowState.progress}%` },
|
||
{ label: "下一步", value: nextStep?.title ?? workflow.reportLabel }
|
||
]}
|
||
steps={steps}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SectionTitle({
|
||
icon: Icon,
|
||
title,
|
||
iconClassName
|
||
}: {
|
||
icon: typeof Bot;
|
||
title: string;
|
||
iconClassName?: string;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||
<Icon size={13} aria-hidden="true" className={iconClassName} />
|
||
<span>{title}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailSectionCard({
|
||
children,
|
||
className
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div className={cn("rounded-xl border border-slate-200/80 bg-slate-50/90 px-2.5 py-2.5 text-slate-500", className)}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TaskExecutionPanel({
|
||
icon: Icon,
|
||
title,
|
||
badge,
|
||
summary,
|
||
meta,
|
||
steps,
|
||
badgeTone = "pending",
|
||
children
|
||
}: {
|
||
icon: typeof Bot;
|
||
title: string;
|
||
badge: string;
|
||
summary?: string;
|
||
meta?: ExecutionStepMeta[];
|
||
steps: ExecutionStep[];
|
||
badgeTone?: ExecutionBadgeTone;
|
||
children?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<SectionTitle icon={Icon} title={title} />
|
||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||
{badge}
|
||
</span>
|
||
</div>
|
||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||
{meta?.length ? (
|
||
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
|
||
{meta.map((item) => (
|
||
<ReportMetaTile key={`${item.label}-${item.value}`} label={item.label} value={item.value} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<ol className="grid gap-2 px-3 py-3">
|
||
{steps.map((step, index) => (
|
||
<TaskExecutionStepRow
|
||
key={step.id}
|
||
step={step}
|
||
isLast={index === steps.length - 1}
|
||
/>
|
||
))}
|
||
</ol>
|
||
{children ? <div className="grid gap-2 border-t border-slate-200/70 px-3 py-3">{children}</div> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function TaskExecutionStepRow({
|
||
step,
|
||
isLast
|
||
}: {
|
||
step: ExecutionStep;
|
||
isLast: boolean;
|
||
}) {
|
||
return (
|
||
<li className="grid grid-cols-[28px_1fr] gap-2">
|
||
<span className="relative flex justify-center">
|
||
{!isLast ? (
|
||
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
||
) : null}
|
||
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
|
||
{step.status === "done" ? (
|
||
<CheckCircle2 size={14} aria-hidden="true" />
|
||
) : step.status === "current" ? (
|
||
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
|
||
) : step.status === "confirmation" ? (
|
||
<AlertTriangle size={14} aria-hidden="true" />
|
||
) : (
|
||
<Clock3 size={14} aria-hidden="true" />
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"min-w-0 rounded-xl border px-2.5 py-2",
|
||
getTaskExecutionStepSurfaceClassName(step.status)
|
||
)}
|
||
>
|
||
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
|
||
{getTaskExecutionStepStatusLabel(step.status)}
|
||
</span>
|
||
</span>
|
||
{step.meta?.length ? (
|
||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||
{step.meta.map((item) => (
|
||
<span key={`${item.label}-${item.value}`} className="contents">
|
||
<span className="font-semibold text-slate-400">{item.label}</span>
|
||
<span className={item.label === "时间" ? "font-mono text-slate-700" : "text-slate-700"}>{item.value}</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
<span className="mt-1.5 block text-xs leading-5 text-slate-700">{step.description}</span>
|
||
</span>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||
const duration = formatDuration(condition.durationMinutes, condition.status);
|
||
const currentStage = getWorkOrderCurrentStage(condition);
|
||
const steps = condition.stages.map((stage) => ({
|
||
id: stage.id,
|
||
title: stage.title,
|
||
status: stage.status,
|
||
description: stage.description,
|
||
meta: [
|
||
{ label: "责任人", value: stage.owner },
|
||
{ label: "时间", value: stage.timeLabel }
|
||
]
|
||
}));
|
||
|
||
return (
|
||
<TaskExecutionPanel
|
||
icon={ClipboardList}
|
||
title="任务执行"
|
||
badge={currentStage ? currentStage.title : condition.code}
|
||
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
|
||
summary={condition.detail ?? condition.summary}
|
||
meta={[
|
||
{ label: "来源", value: condition.source },
|
||
{ label: "位置", value: condition.location },
|
||
{ label: "派发人", value: condition.dispatcher },
|
||
{ label: "执行班组", value: condition.assignee },
|
||
{ label: "优先级", value: condition.priority === "urgent" ? "紧急" : "普通" },
|
||
{ label: "预计耗时", value: duration }
|
||
]}
|
||
steps={steps}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function WorkOrderReplyRequirements({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||
return (
|
||
<DetailSectionCard>
|
||
<SectionTitle icon={ListChecks} title="复令要求" />
|
||
<div className="mt-2 grid grid-cols-[72px_1fr] gap-2 text-xs leading-5">
|
||
<span className="font-semibold text-slate-400">执行窗口</span>
|
||
<span className="font-mono font-semibold text-slate-800">{formatTimeRange(condition.scheduledAt, condition.durationMinutes)}</span>
|
||
<span className="font-semibold text-slate-400">复令时限</span>
|
||
<span className="text-slate-700">执行完成后 {condition.replyWindowMinutes} 分钟内</span>
|
||
</div>
|
||
<ul className="mt-2 space-y-1.5">
|
||
{condition.replyRequirements.map((item, index) => (
|
||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||
{index + 1}
|
||
</span>
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</DetailSectionCard>
|
||
);
|
||
}
|
||
|
||
function RunningEvidencePreview({ items }: { items: string[] }) {
|
||
return (
|
||
<DetailSectionCard>
|
||
<SectionTitle icon={ListChecks} title="采集中证据" />
|
||
<ol className="space-y-1.5">
|
||
{items.length > 0 ? (
|
||
items.map((item, index) => (
|
||
<li key={item} className="grid grid-cols-[18px_1fr_auto] items-start gap-2 text-xs leading-5 text-slate-700">
|
||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-blue-50 text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||
{index + 1}
|
||
</span>
|
||
<span className="line-clamp-1">{item}</span>
|
||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
||
</li>
|
||
))
|
||
) : (
|
||
<li className="text-xs leading-5 text-slate-500">正在等待首批证据返回。</li>
|
||
)}
|
||
</ol>
|
||
</DetailSectionCard>
|
||
);
|
||
}
|
||
|
||
function EvidenceList({ items }: { items: string[] }) {
|
||
return (
|
||
<DetailSectionCard>
|
||
<SectionTitle icon={ListChecks} title="关键证据" />
|
||
<ol className="space-y-1.5">
|
||
{items.length > 0 ? (
|
||
items.map((item, index) => (
|
||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||
{index + 1}
|
||
</span>
|
||
<span>{item}</span>
|
||
</li>
|
||
))
|
||
) : (
|
||
<li className="text-xs leading-5 text-slate-500">暂无关键证据记录。</li>
|
||
)}
|
||
</ol>
|
||
</DetailSectionCard>
|
||
);
|
||
}
|
||
|
||
function createSubmittedIncidentWorkOrder(condition: ScheduledConditionItem): SubmittedIncidentWorkOrder {
|
||
const operations = createIncidentOperations(condition);
|
||
const submittedAt = Date.now();
|
||
const codeSeed = `${condition.id}-${submittedAt}`.replace(/[^a-zA-Z0-9]/g, "").slice(-8).toUpperCase();
|
||
|
||
return {
|
||
code: `INC-WO-${codeSeed}`,
|
||
submittedAt,
|
||
notifiedTeams: ["调度执行岗", "现场抢修班组", "模型复核岗"],
|
||
operations
|
||
};
|
||
}
|
||
|
||
function createIncidentOperations(condition: ScheduledConditionItem): IncidentOperation[] {
|
||
const baseVerification =
|
||
condition.status === "error" ? "执行后 5 分钟内复核压力、流量和影响用户反馈。" : "下一轮工况继续跟踪偏差是否回落。";
|
||
|
||
if (condition.title.includes("智能调度")) {
|
||
return [
|
||
{
|
||
target: "华山路沿线阀门组 VG-11",
|
||
elementType: "阀门",
|
||
action: "阀门开度调整",
|
||
command: "按方案将边界阀门预置到 45% 开度,执行前确认上下游压力。",
|
||
verification: "确认高区末端压力不低于 0.22MPa。"
|
||
},
|
||
{
|
||
target: "北辰二级泵站 P-02",
|
||
elementType: "泵站",
|
||
action: "泵站出水复核",
|
||
command: "保持当前泵组组合,复核出水压力和频率波动。",
|
||
verification: baseVerification
|
||
}
|
||
];
|
||
}
|
||
|
||
if (condition.title.includes("SCADA")) {
|
||
return [
|
||
{
|
||
target: "SCADA 异常测点组",
|
||
elementType: "测点",
|
||
action: "异常测点隔离",
|
||
command: "调度计算暂不采用异常测点,使用相邻测点插补边界。",
|
||
verification: "数据维护岗确认通信链路恢复后再解除隔离。"
|
||
},
|
||
{
|
||
target: "异常 DMA 现场巡检点",
|
||
elementType: "巡检点",
|
||
action: "现场复核",
|
||
command: "通知巡检班组核对压力表、流量计和阀井状态。",
|
||
verification: baseVerification
|
||
}
|
||
];
|
||
}
|
||
|
||
return [
|
||
{
|
||
target: "疑似影响区上下游阀门",
|
||
elementType: "阀门",
|
||
action: "阀门边界确认",
|
||
command: "核对阀门当前开度,暂不执行大范围关断,保留应急隔离方案。",
|
||
verification: "确认影响区压力恢复趋势和用户侧反馈。"
|
||
},
|
||
{
|
||
target: "关联泵站与分区边界",
|
||
elementType: "泵站/边界",
|
||
action: "调度边界复核",
|
||
command: "使用最新泵站出水、阀门开度和 SCADA 流量刷新模拟边界。",
|
||
verification: baseVerification
|
||
}
|
||
];
|
||
}
|
||
|
||
function getConditionAnalysisInsight(condition: ScheduledConditionItem): ScheduledConditionAnalysisInsight {
|
||
if (condition.kind === "condition" && condition.analysisInsight) {
|
||
return condition.analysisInsight;
|
||
}
|
||
|
||
if (condition.status === "error") {
|
||
return {
|
||
summary: "Agent 判断这条工况未完整闭环,需要先确认异常来源,再决定是否进入调度处置。",
|
||
notes: [
|
||
"优先核对遥测源新鲜度和关键测点完整率。",
|
||
"结合历史基线复核压力/流量偏差是否持续。",
|
||
"处置前应先确认影响范围,避免误触发联动动作。"
|
||
],
|
||
solutionOptions: [
|
||
{
|
||
id: "fallback-source",
|
||
title: "数据源复核",
|
||
scenario: "遥测源超时或数据完整率不足。",
|
||
action: "重试关键测点并标记缺失源。",
|
||
tradeoff: "判断更稳妥,但会延后处置。"
|
||
},
|
||
{
|
||
id: "fallback-model",
|
||
title: "模型比对",
|
||
scenario: "压力或流量偏差集中在局部分区。",
|
||
action: "对比基线工况并圈定疑似影响范围。",
|
||
tradeoff: "响应更快,但依赖模型边界条件质量。"
|
||
},
|
||
{
|
||
id: "fallback-manual",
|
||
title: "人工复核",
|
||
scenario: "异常持续或伴随用户侧反馈。",
|
||
action: "交由调度员确认阀门、泵站和巡检动作。",
|
||
tradeoff: "闭环完整,但会占用人工资源。"
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
return {
|
||
summary: "Agent 将这条工况标记为关注项,当前偏差不足以触发自动处置。",
|
||
notes: [
|
||
"保留当前证据,下一轮继续比对同一测点趋势。",
|
||
"重点观察偏差是否扩大,避免单点噪声误判。",
|
||
"需要时可在 Agent 面板追问偏差来源。"
|
||
],
|
||
escalationCriteria: [
|
||
"连续两轮工况仍高于关注阈值。",
|
||
"关键测点完整率继续下降。",
|
||
"同一分区出现用户投诉、低压或水质联动线索。"
|
||
]
|
||
};
|
||
}
|