Files
next-tjwater-drainage-frontend/features/workbench/components/scheduled-condition-detail-panel.tsx
T

1316 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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-transparent bg-[var(--glass-readable)]";
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-[var(--glass-menu)]";
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-[var(--glass-menu)] 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="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 "glass-danger border-red-200/80";
}
if (riskLevel === "attention") {
return "glass-warning border-orange-200/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 "glass-info border-blue-100";
}
if (status === "confirmation") {
return "glass-warning border-orange-100";
}
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: [
"连续两轮工况仍高于关注阈值。",
"关键测点完整率继续下降。",
"同一分区出现用户投诉、低压或水质联动线索。"
]
};
}