refactor(workbench): clarify report states

This commit is contained in:
2026-07-13 15:43:07 +08:00
parent 3a44de941d
commit c2e1c937ae
5 changed files with 295 additions and 252 deletions
+10
View File
@@ -815,6 +815,16 @@ features/agent/components
The goal is to avoid repeating interaction and accessibility work while keeping full control over the visual language defined here. The goal is to avoid repeating interaction and accessibility work while keeping full control over the visual language defined here.
## 工况详情状态强调
生命周期和风险等级是独立语义:完成、执行中属于生命周期,正常、关注、高风险属于风险等级,二者不得通过同一套容器色相互继承。状态必须同时包含文字或图标,且只在最有用的层级表达一次。
禁止使用彩色侧边条、整卡状态底色和继承式彩色圆点。正常状态保持中性,仅用小型绿色完成标识表达生命周期结束;关注和高风险只在直接表达风险的徽标、图标或 KPI 表面使用橙色和红色。
完成报告按标题、生命周期徽标、结论、时间、关键指标和报告章节的顺序组织。报告、判断摘要和章节使用中性容器及列表符号,KPI 保留业务原始顺序,并以中性、浅橙、浅红表面对应正常、关注、高风险。
任务步骤只按流程状态着色:当前蓝色,完成绿色,待处理灰色,待人工确认橙色。主要操作使用蓝色,提交完成后使用绿色。
## Known Open Decisions ## Known Open Decisions
- Dark mode is not specified in this version. A future night-operations mode should be designed deliberately rather than generated by inverting colors. - Dark mode is not specified in this version. A future night-operations mode should be designed deliberately rather than generated by inverting colors.
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getConditionReportRiskLevel } from "./condition-report-risk";
describe("getConditionReportRiskLevel", () => {
it("maps error and warning statuses to their operational risk levels", () => {
expect(getConditionReportRiskLevel("error", "normal")).toBe("critical");
expect(getConditionReportRiskLevel("warning", "normal")).toBe("attention");
});
it("preserves the explicit risk level for lifecycle-only statuses", () => {
expect(getConditionReportRiskLevel("completed", "normal")).toBe("normal");
expect(getConditionReportRiskLevel("completed", "attention")).toBe("attention");
expect(getConditionReportRiskLevel("running", "critical")).toBe("critical");
});
});
@@ -0,0 +1,19 @@
import type {
ScheduledConditionRiskLevel,
ScheduledConditionStatus
} from "@/features/workbench/types";
export function getConditionReportRiskLevel(
status: ScheduledConditionStatus,
riskLevel: ScheduledConditionRiskLevel
): ScheduledConditionRiskLevel {
if (status === "error") {
return "critical";
}
if (status === "warning") {
return "attention";
}
return riskLevel;
}
@@ -32,22 +32,32 @@ import type {
ScheduledConditionItem, ScheduledConditionItem,
ScheduledConditionKpi, ScheduledConditionKpi,
ScheduledConditionRecord, ScheduledConditionRecord,
ScheduledConditionRiskLevel,
ScheduledWorkOrderItem ScheduledWorkOrderItem
} from "@/features/workbench/types"; } from "@/features/workbench/types";
import { import {
formatDuration, formatDuration,
formatTime, formatTime,
formatTimeRange, formatTimeRange,
getDetailAccentClassName,
getKpiStatusClassName,
getRiskLabel, getRiskLabel,
getWorkOrderCurrentStage, getWorkOrderCurrentStage,
getWorkOrderStagePillClassName, getWorkOrderStagePillClassName,
statusClassNames, statusClassNames,
statusLabels statusLabels
} from "./scheduled-condition-feed-utils"; } from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000; 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 = { type IncidentOperation = {
target: string; target: string;
@@ -64,7 +74,9 @@ type SubmittedIncidentWorkOrder = {
operations: IncidentOperation[]; operations: IncidentOperation[];
}; };
type ExecutionStepStatus = "done" | "current" | "pending"; type ExecutionStepStatus = "done" | "current" | "pending" | "confirmation";
type ExecutionBadgeTone = "active" | "confirmation" | "completed" | "pending";
type ExecutionStepMeta = { type ExecutionStepMeta = {
label: string; label: string;
@@ -97,7 +109,7 @@ export function StatusPill({
return ( return (
<span <span
className={cn( className={cn(
"rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", STATUS_PILL_CLASS_NAME,
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status], isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
className className
)} )}
@@ -112,13 +124,12 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
condition.kind === "work_order" condition.kind === "work_order"
? `${condition.id}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}` ? `${condition.id}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
: condition.id; : condition.id;
let content: React.ReactNode; const content =
condition.kind === "work_order" ? (
if (condition.kind === "work_order") { <WorkOrderDetailPanel condition={condition} />
content = <WorkOrderDetailPanel condition={condition} />; ) : (
} else { <ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />
content = <ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />; );
}
return ( return (
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
@@ -146,6 +157,7 @@ function ConditionRecordDetailPanel({
const insight = getConditionAnalysisInsight(condition); const insight = getConditionAnalysisInsight(condition);
const [submittedWorkOrder, setSubmittedWorkOrder] = useState<SubmittedIncidentWorkOrder | null>(null); const [submittedWorkOrder, setSubmittedWorkOrder] = useState<SubmittedIncidentWorkOrder | null>(null);
const header = getConditionDetailHeader(condition); const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`; const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`;
function handleSubmitIncidentWorkOrder() { function handleSubmitIncidentWorkOrder() {
@@ -161,7 +173,7 @@ function ConditionRecordDetailPanel({
return ( return (
<DetailScroll> <DetailScroll>
<DetailShell condition={condition}> <DetailShell>
<DetailHeader <DetailHeader
condition={condition} condition={condition}
icon={header.icon} icon={header.icon}
@@ -180,60 +192,62 @@ function ConditionRecordDetailPanel({
> >
{condition.status === "running" ? ( {condition.status === "running" ? (
<RunningConditionBody condition={condition} /> <RunningConditionBody condition={condition} />
) : condition.status === "error" ? (
<>
<ConditionStatusReport condition={condition} tone="red" />
<DecisionSummaryBlock
icon={ShieldAlert}
title="异常判断"
tone="red"
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}
/>
</>
) : condition.status === "warning" || condition.riskLevel === "attention" ? (
<>
<ConditionStatusReport condition={condition} tone="orange" />
<DecisionSummaryBlock
icon={Lightbulb}
title="关注说明"
tone="orange"
summary={insight.summary}
notes={insight.notes}
/>
<EscalationCriteriaList items={insight.escalationCriteria ?? []} />
<DecisionActionStrip
icon={Bot}
title="复核建议"
value={condition.recommendation ?? "建议保留为关注项,下一轮工况继续复核同一测点趋势。"}
actionLabel="继续对话"
onAction={onContinueConversation}
/>
</>
) : ( ) : (
<> <>
<ConditionStatusReport condition={condition} tone="blue" /> <ConditionStatusReport condition={condition} riskLevel={riskLevel} />
<EvidenceList items={condition.evidence ?? []} /> {riskLevel === "critical" ? (
<DecisionActionStrip <>
icon={Bot} <DecisionSummaryBlock
title="Agent 建议" icon={ShieldAlert}
value={condition.recommendation ?? "当前工况暂无新增处置建议,保持观察即可。"} title="异常判断"
actionLabel="继续对话" iconClassName="text-red-600"
onAction={onContinueConversation} 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> </motion.div>
@@ -249,7 +263,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
return ( return (
<DetailScroll> <DetailScroll>
<DetailShell condition={condition}> <DetailShell>
<DetailHeader <DetailHeader
condition={condition} condition={condition}
icon={ClipboardList} icon={ClipboardList}
@@ -310,22 +324,6 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
}; };
} }
if (condition.status === "error") {
return {
icon: ShieldAlert,
iconClassName: "bg-red-50 text-red-700",
iconSpin: false
};
}
if (condition.status === "warning" || condition.riskLevel === "attention") {
return {
icon: AlertTriangle,
iconClassName: "bg-orange-50 text-orange-700",
iconSpin: false
};
}
return { return {
icon: FileText, icon: FileText,
iconClassName: undefined, iconClassName: undefined,
@@ -342,21 +340,12 @@ function DetailScroll({ children }: { children: React.ReactNode }) {
} }
type DetailShellProps = { type DetailShellProps = {
condition: ScheduledConditionItem;
children: React.ReactNode; children: React.ReactNode;
}; };
function DetailShell({ condition, children }: DetailShellProps) { function DetailShell({ children }: DetailShellProps) {
const isWorkOrder = condition.kind === "work_order";
return ( return (
<div <div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden ring-1 ring-white/80")}>
className={cn(
"relative overflow-hidden border border-slate-200/80 bg-white/95 ring-1 ring-white/80",
"rounded-xl"
)}
>
<div className={cn("absolute inset-y-0 left-0 w-1", getDetailAccentClassName(condition.status, isWorkOrder))} aria-hidden="true" />
{children} {children}
</div> </div>
); );
@@ -367,18 +356,16 @@ type DetailHeaderProps = {
icon: typeof FileText; icon: typeof FileText;
iconClassName?: string; iconClassName?: string;
iconSpin?: boolean; iconSpin?: boolean;
children?: React.ReactNode;
}; };
function DetailHeader({ function DetailHeader({
condition, condition,
icon: Icon, icon: Icon,
iconClassName, iconClassName,
iconSpin = false, iconSpin = false
children
}: DetailHeaderProps) { }: DetailHeaderProps) {
return ( return (
<div className="bg-white/95 px-3 py-3 pl-4"> <div className="bg-white/95 px-3 py-3">
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]"> <div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
<div className="flex min-w-0 items-start gap-2.5"> <div className="flex min-w-0 items-start gap-2.5">
<span <span
@@ -393,7 +380,9 @@ function DetailHeader({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-semibold text-slate-500">{formatTime(condition.scheduledAt)}</span> <span className="font-mono text-xs font-semibold text-slate-500">{formatTime(condition.scheduledAt)}</span>
<StatusPill condition={condition} /> {condition.kind === "work_order" || condition.status === "running" || condition.status === "pending" ? (
<StatusPill condition={condition} />
) : null}
</div> </div>
<h3 className="mt-1 text-base font-semibold leading-5 text-slate-950">{condition.title}</h3> <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} {condition.detail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
@@ -402,44 +391,13 @@ function DetailHeader({
<DetailMetricGrid condition={condition} /> <DetailMetricGrid condition={condition} />
</div> </div>
{children ? <div className="mt-3">{children}</div> : null}
</div> </div>
); );
} }
type DetailTone = "violet" | "blue" | "red" | "orange";
const decisionSurfaceClassNames: Record<DetailTone, string> = {
red: "border-red-100 border-l-4 border-l-red-400",
orange: "border-orange-100 border-l-4 border-l-orange-400",
blue: "border-blue-100 border-l-4 border-l-blue-500",
violet: "border-violet-100 border-l-4 border-l-violet-500"
};
const detailDotClassNames: Record<DetailTone, string> = {
red: "bg-red-500",
orange: "bg-orange-500",
blue: "bg-blue-500",
violet: "bg-violet-500"
};
const taskExecutionBadgeClassNames: Record<DetailTone, string> = {
red: "border-red-100 bg-red-50 text-red-700",
orange: "border-orange-100 bg-orange-50 text-orange-700",
blue: "border-blue-100 bg-blue-50 text-blue-700",
violet: "border-violet-100 bg-violet-50 text-violet-700"
};
const taskExecutionCurrentStepClassNames: Record<DetailTone, string> = {
red: "border-red-100 bg-red-50/70",
orange: "border-orange-100 bg-orange-50/70",
blue: "border-blue-100 bg-blue-50/70",
violet: "border-violet-100 bg-violet-50/70"
};
function DetailDecisionBody({ children }: { children: React.ReactNode }) { function DetailDecisionBody({ children }: { children: React.ReactNode }) {
return ( return (
<div className="space-y-2.5 px-3 pb-3 pl-4"> <div className="space-y-2.5 px-3 pb-3">
{children} {children}
</div> </div>
); );
@@ -448,24 +406,24 @@ function DetailDecisionBody({ children }: { children: React.ReactNode }) {
function DecisionSummaryBlock({ function DecisionSummaryBlock({
icon: Icon, icon: Icon,
title, title,
tone, iconClassName,
summary, summary,
notes notes
}: { }: {
icon: typeof Bot; icon: typeof Bot;
title: string; title: string;
tone: DetailTone; iconClassName: string;
summary: string; summary: string;
notes: string[]; notes: string[];
}) { }) {
return ( return (
<section className={cn("rounded-xl border bg-white/95 px-3 py-3", getDecisionSurfaceClassName(tone))}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionTitle icon={Icon} title={title} /> <SectionTitle icon={Icon} title={title} iconClassName={iconClassName} />
<p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p>
<ul className="mt-2 grid gap-1.5"> <ul className="mt-2 grid gap-1.5">
{notes.map((note) => ( {notes.map((note) => (
<li key={note} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700"> <li key={note} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", getDetailDotClassName(tone))} aria-hidden="true" /> <span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" aria-hidden="true" />
<span>{note}</span> <span>{note}</span>
</li> </li>
))} ))}
@@ -480,13 +438,13 @@ function SolutionOptionsList({
options: NonNullable<ScheduledConditionAnalysisInsight["solutionOptions"]>; options: NonNullable<ScheduledConditionAnalysisInsight["solutionOptions"]>;
}) { }) {
return ( return (
<section className="rounded-xl border border-slate-200/80 bg-white/95 px-3 py-3"> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionEyebrow icon={Route} label="处置方案" /> <SectionEyebrow icon={Route} label="处置方案" />
<div className="mt-2 divide-y divide-slate-200/70 overflow-hidden rounded-xl border border-slate-200/70 bg-slate-50/80"> <div className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
{options.map((option, index) => ( {options.map((option, index) => (
<article key={option.id} className="px-2.5 py-2"> <article key={option.id} className="px-2.5 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-lg bg-red-50 text-xs font-semibold text-red-700 ring-1 ring-red-100"> <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} {index + 1}
</span> </span>
<h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4> <h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4>
@@ -526,7 +484,7 @@ function IncidentWorkOrderPanel({
const steps: ExecutionStep[] = [ const steps: ExecutionStep[] = [
{ id: "analysis", title: "事件分析", status: "done", description: "汇总异常证据、影响分区和风险等级。" }, { id: "analysis", title: "事件分析", status: "done", description: "汇总异常证据、影响分区和风险等级。" },
{ id: "solution", title: "形成方案", status: "done", description: "生成阀门、泵站和现场复核操作清单。" }, { id: "solution", title: "形成方案", status: "done", description: "生成阀门、泵站和现场复核操作清单。" },
{ id: "confirm", title: "人工确认", status: submitted ? "done" : "current", description: "调度员确认影响范围和操作边界。" }, { id: "confirm", title: "人工确认", status: submitted ? "done" : "confirmation", description: "调度员确认影响范围和操作边界。" },
{ id: "submit", title: "提交工单", status: submitted ? "done" : "pending", description: "写入系统工单并通知执行岗位。" } { id: "submit", title: "提交工单", status: submitted ? "done" : "pending", description: "写入系统工单并通知执行岗位。" }
]; ];
@@ -535,12 +493,12 @@ function IncidentWorkOrderPanel({
icon={ClipboardCheck} icon={ClipboardCheck}
title="任务执行" title="任务执行"
badge={submitted ? "已提交" : "待人工确认"} badge={submitted ? "已提交" : "待人工确认"}
badgeTone={submitted ? "completed" : "confirmation"}
summary={ summary={
submitted submitted
? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。` ? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。`
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。" : "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。"
} }
tone="red"
meta={ meta={
submittedWorkOrder submittedWorkOrder
? [ ? [
@@ -562,9 +520,9 @@ function IncidentWorkOrderPanel({
aria-disabled={submitted} aria-disabled={submitted}
onClick={onSubmitWorkOrder} onClick={onSubmitWorkOrder}
className={cn( 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", "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", "rounded-xl",
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-red-600 text-white hover:bg-red-700" 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" />} {submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
@@ -582,7 +540,7 @@ function IncidentOperationList({
submitted: boolean; submitted: boolean;
}) { }) {
return ( return (
<div className="divide-y divide-slate-200/70 overflow-hidden rounded-xl border border-slate-200/70 bg-slate-50/80"> <div className={cn(INSET_SURFACE_CLASS_NAME, "divide-y divide-slate-200/70 overflow-hidden")}>
{operations.map((operation) => ( {operations.map((operation) => (
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2"> <article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -613,12 +571,12 @@ function IncidentOperationList({
function EscalationCriteriaList({ items }: { items: string[] }) { function EscalationCriteriaList({ items }: { items: string[] }) {
return ( return (
<section className="rounded-xl border border-slate-200/80 bg-white/95 px-3 py-3"> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionEyebrow icon={AlertTriangle} label="升级条件" /> <SectionEyebrow icon={AlertTriangle} label="升级条件" />
<ol className="mt-2 divide-y divide-slate-200/70 overflow-hidden rounded-xl border border-slate-200/70 bg-slate-50/80"> <ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
{items.map((item, index) => ( {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"> <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-orange-50 text-xs font-semibold text-orange-700 ring-1 ring-orange-100"> <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} {index + 1}
</span> </span>
<span>{item}</span> <span>{item}</span>
@@ -631,12 +589,12 @@ function EscalationCriteriaList({ items }: { items: string[] }) {
function ConditionStatusReport({ function ConditionStatusReport({
condition, condition,
tone riskLevel
}: { }: {
condition: ScheduledConditionItem; condition: ScheduledConditionRecord;
tone: DetailTone; riskLevel: ScheduledConditionRiskLevel;
}) { }) {
if (condition.kind !== "condition" || condition.status === "running" || (!condition.report && !condition.kpis?.length)) { if (!condition.report && !condition.kpis?.length) {
return null; return null;
} }
@@ -645,18 +603,23 @@ function ConditionStatusReport({
const statusTime = formatTimeRange(condition.scheduledAt, condition.durationMinutes); const statusTime = formatTimeRange(condition.scheduledAt, condition.durationMinutes);
return ( return (
<section className={cn("overflow-hidden rounded-xl border bg-white/95", getDecisionSurfaceClassName(tone))}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3"> <div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={FileText} title="完成状态报告" /> <SectionTitle icon={FileText} title="完成状态报告" />
<StatusPill condition={condition} /> <LifecyclePill />
</div> </div>
<h4 className="mt-2 text-sm font-semibold leading-5 text-slate-950">{report?.title ?? condition.title}</h4> <h4 className="mt-2 min-w-0 text-sm font-semibold leading-5 text-slate-950">{report?.title ?? condition.title}</h4>
{report?.conclusion ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{report.conclusion}</p> : null} {report?.conclusion ? (
<div className="mt-2 grid gap-1.5 sm:grid-cols-3"> <div className="mt-1.5 flex min-w-0 flex-wrap items-start gap-2">
<ReportMetaTile label="报告时段" value={statusTime} /> <p className="min-w-[12rem] flex-1 text-xs leading-5 text-slate-700">{report.conclusion}</p>
<ReportMetaTile label="完成状态" value={statusLabels[condition.status]} /> <RiskBadge riskLevel={riskLevel} />
<ReportMetaTile label="风险等级" value={getRiskLabel(condition.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>
</div> </div>
@@ -674,7 +637,7 @@ function ConditionStatusReport({
{report?.sections?.length ? ( {report?.sections?.length ? (
<div className="grid gap-2 border-t border-slate-200/70 px-3 py-3"> <div className="grid gap-2 border-t border-slate-200/70 px-3 py-3">
{report.sections.map((section) => ( {report.sections.map((section) => (
<ReportSection key={section.title} title={section.title} items={section.items} tone={tone} /> <ReportSection key={section.title} title={section.title} items={section.items} />
))} ))}
</div> </div>
) : null} ) : null}
@@ -682,9 +645,40 @@ function ConditionStatusReport({
); );
} }
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 }) { function ReportMetaTile({ label, value }: { label: string; value: string }) {
return ( return (
<div className="rounded-lg border border-slate-200/70 bg-slate-50/80 px-2 py-1.5"> <div className={REPORT_META_TILE_CLASS_NAME}>
<div className="text-xs font-semibold text-slate-400">{label}</div> <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}> <div className="mt-0.5 truncate text-xs font-semibold text-slate-700" title={value}>
{value} {value}
@@ -695,20 +689,18 @@ function ReportMetaTile({ label, value }: { label: string; value: string }) {
function ReportSection({ function ReportSection({
title, title,
items, items
tone
}: { }: {
title: string; title: string;
items: string[]; items: string[];
tone: DetailTone;
}) { }) {
return ( return (
<section className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-2.5 py-2"> <section className={cn(INSET_SURFACE_CLASS_NAME, "px-2.5 py-2")}>
<div className="text-xs font-semibold text-slate-500">{title}</div> <div className="text-xs font-semibold text-slate-500">{title}</div>
<ul className="mt-1.5 grid gap-1"> <ul className="mt-1.5 grid gap-1">
{items.map((item) => ( {items.map((item) => (
<li key={item} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700"> <li key={item} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", getDetailDotClassName(tone))} aria-hidden="true" /> <span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" aria-hidden="true" />
<span>{item}</span> <span>{item}</span>
</li> </li>
))} ))}
@@ -719,7 +711,7 @@ function ReportSection({
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) { function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
return ( return (
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/80 px-2.5 py-2"> <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="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}> <div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
@@ -730,7 +722,7 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null} {kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
</div> </div>
</div> </div>
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getKpiStatusClassName(kpi.status))}> <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)} {getRiskLabel(kpi.status)}
</span> </span>
</div> </div>
@@ -745,6 +737,18 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
); );
} }
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({ function DecisionActionStrip({
icon: Icon, icon: Icon,
title, title,
@@ -763,7 +767,7 @@ function DecisionActionStrip({
onAction?: () => void; onAction?: () => void;
}) { }) {
return ( return (
<div className="flex flex-col gap-2 rounded-xl border border-slate-200/80 bg-white/95 px-3 py-3 min-[1500px]:flex-row min-[1500px]:items-center"> <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"> <div className="min-w-0 flex-1">
<SectionEyebrow icon={Icon} label={title} /> <SectionEyebrow icon={Icon} label={title} />
<p className="mt-1 text-xs leading-5 text-slate-700">{value}</p> <p className="mt-1 text-xs leading-5 text-slate-700">{value}</p>
@@ -775,7 +779,7 @@ function DecisionActionStrip({
title={actionTitle} title={actionTitle}
onClick={onAction} onClick={onAction}
className={cn( className={cn(
"flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition min-[1500px]:w-[104px]", "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", "rounded-xl",
actionDisabled actionDisabled
? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80" ? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80"
@@ -804,53 +808,61 @@ function SectionEyebrow({
); );
} }
function getDecisionSurfaceClassName(tone: DetailTone) { function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
return decisionSurfaceClassNames[tone]; 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 getDetailDotClassName(tone: DetailTone) { function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
return detailDotClassNames[tone];
}
function getTaskExecutionBadgeClassName(tone: DetailTone) {
return taskExecutionBadgeClassNames[tone];
}
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus, tone: DetailTone) {
if (status === "done") { if (status === "done") {
return "bg-green-500 text-white"; return "bg-green-500 text-white";
} }
if (status === "current") { if (status === "current") {
if (tone === "red") {
return "bg-red-600 text-white";
}
if (tone === "orange") {
return "bg-orange-500 text-white";
}
if (tone === "violet") {
return "bg-violet-600 text-white";
}
return "bg-blue-600 text-white"; return "bg-blue-600 text-white";
} }
if (status === "confirmation") {
return "bg-orange-500 text-white";
}
return "bg-slate-100 text-slate-400"; return "bg-slate-100 text-slate-400";
} }
function getTaskExecutionCurrentStepClassName(tone: DetailTone) { function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
return taskExecutionCurrentStepClassNames[tone]; 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, tone: DetailTone) { function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
if (status === "done") { if (status === "done") {
return "border-green-100 bg-green-50 text-green-700"; return "border-green-100 bg-green-50 text-green-700";
} }
if (status === "current") { if (status === "current") {
return getTaskExecutionBadgeClassName(tone); 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"; return "border-slate-200 bg-white text-slate-500";
@@ -865,21 +877,37 @@ function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
return "当前"; return "当前";
} }
if (status === "confirmation") {
return "待人工确认";
}
return "待处理"; return "待处理";
} }
function DetailMetricGrid({ condition }: { condition: ScheduledConditionItem }) { function DetailMetricGrid({ condition }: { condition: ScheduledConditionItem }) {
const durationLabel = condition.status === "running" ? "状态" : "耗时"; const durationLabel = condition.status === "running" ? "状态" : "耗时";
const durationValue = condition.status === "running" ? "实时执行" : formatDuration(condition.durationMinutes, condition.status); const durationValue = condition.status === "running" ? "实时执行" : formatDuration(condition.durationMinutes, condition.status);
const modelValue = condition.kind === "work_order" ? "预约工单" : condition.modelName ?? "未记录"; const metrics =
const sessionValue = condition.kind === "work_order" ? formatTimeRange(condition.scheduledAt, condition.durationMinutes) : condition.sessionId ?? "未记录"; 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 ( return (
<div className="grid min-w-0 grid-cols-2 gap-1.5"> <div className="grid min-w-0 grid-cols-2 gap-1.5">
<ReportMetaTile label={condition.kind === "work_order" ? "类型" : "模型"} value={modelValue} /> {metrics.map((metric) => (
<ReportMetaTile label={condition.kind === "work_order" ? "窗口" : "会话"} value={sessionValue} /> <div key={metric.label} className={metric.className}>
<ReportMetaTile label="风险" value={getRiskLabel(condition.riskLevel)} /> <ReportMetaTile label={metric.label} value={metric.value} />
<ReportMetaTile label={durationLabel} value={durationValue} /> </div>
))}
</div> </div>
); );
} }
@@ -908,8 +936,8 @@ function RunningWorkflowPanel({
icon={Loader2} icon={Loader2}
title={workflow.title} title={workflow.title}
badge="执行中" badge="执行中"
badgeTone="active"
summary={`${currentStep.title}${currentStep.detail}`} summary={`${currentStep.title}${currentStep.detail}`}
tone="blue"
meta={[ meta={[
{ label: "任务对象", value: condition.title }, { label: "任务对象", value: condition.title },
{ label: "当前进度", value: `${workflowState.progress}%` }, { label: "当前进度", value: `${workflowState.progress}%` },
@@ -920,10 +948,18 @@ function RunningWorkflowPanel({
); );
} }
function SectionTitle({ icon: Icon, title }: { icon: typeof Bot; title: string }) { function SectionTitle({
icon: Icon,
title,
iconClassName
}: {
icon: typeof Bot;
title: string;
iconClassName?: string;
}) {
return ( return (
<div className="flex items-center gap-1.5 text-xs font-semibold"> <div className="flex items-center gap-1.5 text-xs font-semibold">
<Icon size={13} aria-hidden="true" /> <Icon size={13} aria-hidden="true" className={iconClassName} />
<span>{title}</span> <span>{title}</span>
</div> </div>
); );
@@ -950,7 +986,7 @@ function TaskExecutionPanel({
summary, summary,
meta, meta,
steps, steps,
tone = "blue", badgeTone = "pending",
children children
}: { }: {
icon: typeof Bot; icon: typeof Bot;
@@ -959,15 +995,15 @@ function TaskExecutionPanel({
summary?: string; summary?: string;
meta?: ExecutionStepMeta[]; meta?: ExecutionStepMeta[];
steps: ExecutionStep[]; steps: ExecutionStep[];
tone?: DetailTone; badgeTone?: ExecutionBadgeTone;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
return ( return (
<section className={cn("overflow-hidden rounded-xl border bg-white/95", getDecisionSurfaceClassName(tone))}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3"> <div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={Icon} title={title} /> <SectionTitle icon={Icon} title={title} />
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(tone))}> <span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
{badge} {badge}
</span> </span>
</div> </div>
@@ -986,7 +1022,6 @@ function TaskExecutionPanel({
<TaskExecutionStepRow <TaskExecutionStepRow
key={step.id} key={step.id}
step={step} step={step}
tone={tone}
isLast={index === steps.length - 1} isLast={index === steps.length - 1}
/> />
))} ))}
@@ -998,11 +1033,9 @@ function TaskExecutionPanel({
function TaskExecutionStepRow({ function TaskExecutionStepRow({
step, step,
tone,
isLast isLast
}: { }: {
step: ExecutionStep; step: ExecutionStep;
tone: DetailTone;
isLast: boolean; isLast: boolean;
}) { }) {
return ( return (
@@ -1011,11 +1044,13 @@ function TaskExecutionStepRow({
{!isLast ? ( {!isLast ? (
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" /> <span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
) : null} ) : null}
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status, tone))}> <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" ? ( {step.status === "done" ? (
<CheckCircle2 size={14} aria-hidden="true" /> <CheckCircle2 size={14} aria-hidden="true" />
) : step.status === "current" ? ( ) : step.status === "current" ? (
<Loader2 size={14} aria-hidden="true" className="animate-spin" /> <Loader2 size={14} aria-hidden="true" className="animate-spin" />
) : step.status === "confirmation" ? (
<AlertTriangle size={14} aria-hidden="true" />
) : ( ) : (
<Clock3 size={14} aria-hidden="true" /> <Clock3 size={14} aria-hidden="true" />
)} )}
@@ -1024,12 +1059,12 @@ function TaskExecutionStepRow({
<span <span
className={cn( className={cn(
"min-w-0 rounded-xl border px-2.5 py-2", "min-w-0 rounded-xl border px-2.5 py-2",
step.status === "current" ? getTaskExecutionCurrentStepClassName(tone) : "border-slate-200/70 bg-slate-50/80" getTaskExecutionStepSurfaceClassName(step.status)
)} )}
> >
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <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="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, tone))}> <span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
{getTaskExecutionStepStatusLabel(step.status)} {getTaskExecutionStepStatusLabel(step.status)}
</span> </span>
</span> </span>
@@ -1068,8 +1103,8 @@ function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderI
icon={ClipboardList} icon={ClipboardList}
title="任务执行" title="任务执行"
badge={currentStage ? currentStage.title : condition.code} badge={currentStage ? currentStage.title : condition.code}
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
summary={condition.detail ?? condition.summary} summary={condition.detail ?? condition.summary}
tone="blue"
meta={[ meta={[
{ label: "来源", value: condition.source }, { label: "来源", value: condition.source },
{ label: "位置", value: condition.location }, { label: "位置", value: condition.location },
@@ -1278,4 +1313,3 @@ function getConditionAnalysisInsight(condition: ScheduledConditionItem): Schedul
] ]
}; };
} }
@@ -145,18 +145,6 @@ export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
return "正常"; return "正常";
} }
export function getKpiStatusClassName(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "border-red-100 bg-red-50 text-red-700";
}
if (riskLevel === "attention") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
return "border-green-100 bg-green-50 text-green-700";
}
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) { export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
return ( return (
condition.stages.find((stage) => stage.status === "current") ?? condition.stages.find((stage) => stage.status === "current") ??
@@ -178,30 +166,6 @@ export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage
return "border-indigo-100 bg-indigo-50 text-indigo-700"; return "border-indigo-100 bg-indigo-50 text-indigo-700";
} }
export function getDetailAccentClassName(status: ScheduledConditionStatus, isWorkOrder: boolean) {
if (isWorkOrder) {
return "bg-blue-500";
}
if (status === "completed") {
return "bg-green-500";
}
if (status === "running") {
return "bg-blue-500";
}
if (status === "warning") {
return "bg-orange-500";
}
if (status === "error") {
return "bg-red-500";
}
return "bg-slate-400";
}
export function getStatusIcon(status: ScheduledConditionStatus) { export function getStatusIcon(status: ScheduledConditionStatus) {
return statusIcons[status]; return statusIcons[status];
} }