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.
## 工况详情状态强调
生命周期和风险等级是独立语义:完成、执行中属于生命周期,正常、关注、高风险属于风险等级,二者不得通过同一套容器色相互继承。状态必须同时包含文字或图标,且只在最有用的层级表达一次。
禁止使用彩色侧边条、整卡状态底色和继承式彩色圆点。正常状态保持中性,仅用小型绿色完成标识表达生命周期结束;关注和高风险只在直接表达风险的徽标、图标或 KPI 表面使用橙色和红色。
完成报告按标题、生命周期徽标、结论、时间、关键指标和报告章节的顺序组织。报告、判断摘要和章节使用中性容器及列表符号,KPI 保留业务原始顺序,并以中性、浅橙、浅红表面对应正常、关注、高风险。
任务步骤只按流程状态着色:当前蓝色,完成绿色,待处理灰色,待人工确认橙色。主要操作使用蓝色,提交完成后使用绿色。
## 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.
@@ -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,
ScheduledConditionKpi,
ScheduledConditionRecord,
ScheduledConditionRiskLevel,
ScheduledWorkOrderItem
} from "@/features/workbench/types";
import {
formatDuration,
formatTime,
formatTimeRange,
getDetailAccentClassName,
getKpiStatusClassName,
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;
@@ -64,7 +74,9 @@ type SubmittedIncidentWorkOrder = {
operations: IncidentOperation[];
};
type ExecutionStepStatus = "done" | "current" | "pending";
type ExecutionStepStatus = "done" | "current" | "pending" | "confirmation";
type ExecutionBadgeTone = "active" | "confirmation" | "completed" | "pending";
type ExecutionStepMeta = {
label: string;
@@ -97,7 +109,7 @@ export function StatusPill({
return (
<span
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],
className
)}
@@ -112,13 +124,12 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
condition.kind === "work_order"
? `${condition.id}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
: condition.id;
let content: React.ReactNode;
if (condition.kind === "work_order") {
content = <WorkOrderDetailPanel condition={condition} />;
} else {
content = <ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />;
}
const content =
condition.kind === "work_order" ? (
<WorkOrderDetailPanel condition={condition} />
) : (
<ConditionRecordDetailPanel condition={condition} onContinueConversation={onContinueConversation} />
);
return (
<AnimatePresence initial={false} mode="wait">
@@ -146,6 +157,7 @@ function ConditionRecordDetailPanel({
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() {
@@ -161,7 +173,7 @@ function ConditionRecordDetailPanel({
return (
<DetailScroll>
<DetailShell condition={condition}>
<DetailShell>
<DetailHeader
condition={condition}
icon={header.icon}
@@ -180,13 +192,15 @@ function ConditionRecordDetailPanel({
>
{condition.status === "running" ? (
<RunningConditionBody condition={condition} />
) : condition.status === "error" ? (
) : (
<>
<ConditionStatusReport condition={condition} riskLevel={riskLevel} />
{riskLevel === "critical" ? (
<>
<ConditionStatusReport condition={condition} tone="red" />
<DecisionSummaryBlock
icon={ShieldAlert}
title="异常判断"
tone="red"
iconClassName="text-red-600"
summary={insight.summary}
notes={insight.notes}
/>
@@ -204,13 +218,12 @@ function ConditionRecordDetailPanel({
onAction={onContinueConversation}
/>
</>
) : condition.status === "warning" || condition.riskLevel === "attention" ? (
) : riskLevel === "attention" ? (
<>
<ConditionStatusReport condition={condition} tone="orange" />
<DecisionSummaryBlock
icon={Lightbulb}
title="关注说明"
tone="orange"
iconClassName="text-orange-600"
summary={insight.summary}
notes={insight.notes}
/>
@@ -225,7 +238,6 @@ function ConditionRecordDetailPanel({
</>
) : (
<>
<ConditionStatusReport condition={condition} tone="blue" />
<EvidenceList items={condition.evidence ?? []} />
<DecisionActionStrip
icon={Bot}
@@ -236,6 +248,8 @@ function ConditionRecordDetailPanel({
/>
</>
)}
</>
)}
</motion.div>
</AnimatePresence>
</DetailDecisionBody>
@@ -249,7 +263,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
return (
<DetailScroll>
<DetailShell condition={condition}>
<DetailShell>
<DetailHeader
condition={condition}
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 {
icon: FileText,
iconClassName: undefined,
@@ -342,21 +340,12 @@ function DetailScroll({ children }: { children: React.ReactNode }) {
}
type DetailShellProps = {
condition: ScheduledConditionItem;
children: React.ReactNode;
};
function DetailShell({ condition, children }: DetailShellProps) {
const isWorkOrder = condition.kind === "work_order";
function DetailShell({ children }: DetailShellProps) {
return (
<div
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" />
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden ring-1 ring-white/80")}>
{children}
</div>
);
@@ -367,18 +356,16 @@ type DetailHeaderProps = {
icon: typeof FileText;
iconClassName?: string;
iconSpin?: boolean;
children?: React.ReactNode;
};
function DetailHeader({
condition,
icon: Icon,
iconClassName,
iconSpin = false,
children
iconSpin = false
}: DetailHeaderProps) {
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="flex min-w-0 items-start gap-2.5">
<span
@@ -393,7 +380,9 @@ function DetailHeader({
<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}
@@ -402,44 +391,13 @@ function DetailHeader({
<DetailMetricGrid condition={condition} />
</div>
{children ? <div className="mt-3">{children}</div> : null}
</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 }) {
return (
<div className="space-y-2.5 px-3 pb-3 pl-4">
<div className="space-y-2.5 px-3 pb-3">
{children}
</div>
);
@@ -448,24 +406,24 @@ function DetailDecisionBody({ children }: { children: React.ReactNode }) {
function DecisionSummaryBlock({
icon: Icon,
title,
tone,
iconClassName,
summary,
notes
}: {
icon: typeof Bot;
title: string;
tone: DetailTone;
iconClassName: string;
summary: string;
notes: string[];
}) {
return (
<section className={cn("rounded-xl border bg-white/95 px-3 py-3", getDecisionSurfaceClassName(tone))}>
<SectionTitle icon={Icon} title={title} />
<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={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>
</li>
))}
@@ -480,13 +438,13 @@ function SolutionOptionsList({
options: NonNullable<ScheduledConditionAnalysisInsight["solutionOptions"]>;
}) {
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="处置方案" />
<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) => (
<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-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}
</span>
<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[] = [
{ id: "analysis", 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: "写入系统工单并通知执行岗位。" }
];
@@ -535,12 +493,12 @@ function IncidentWorkOrderPanel({
icon={ClipboardCheck}
title="任务执行"
badge={submitted ? "已提交" : "待人工确认"}
badgeTone={submitted ? "completed" : "confirmation"}
summary={
submitted
? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。`
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。"
}
tone="red"
meta={
submittedWorkOrder
? [
@@ -562,9 +520,9 @@ function IncidentWorkOrderPanel({
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",
"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-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" />}
@@ -582,7 +540,7 @@ function IncidentOperationList({
submitted: boolean;
}) {
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) => (
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -613,12 +571,12 @@ function IncidentOperationList({
function EscalationCriteriaList({ items }: { items: string[] }) {
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="升级条件" />
<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) => (
<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}
</span>
<span>{item}</span>
@@ -631,12 +589,12 @@ function EscalationCriteriaList({ items }: { items: string[] }) {
function ConditionStatusReport({
condition,
tone
riskLevel
}: {
condition: ScheduledConditionItem;
tone: DetailTone;
condition: ScheduledConditionRecord;
riskLevel: ScheduledConditionRiskLevel;
}) {
if (condition.kind !== "condition" || condition.status === "running" || (!condition.report && !condition.kpis?.length)) {
if (!condition.report && !condition.kpis?.length) {
return null;
}
@@ -645,18 +603,23 @@ function ConditionStatusReport({
const statusTime = formatTimeRange(condition.scheduledAt, condition.durationMinutes);
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="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={FileText} title="完成状态报告" />
<StatusPill condition={condition} />
<LifecyclePill />
</div>
<h4 className="mt-2 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}
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
<ReportMetaTile label="报告时段" value={statusTime} />
<ReportMetaTile label="完成状态" value={statusLabels[condition.status]} />
<ReportMetaTile label="风险等级" value={getRiskLabel(condition.riskLevel)} />
<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>
@@ -674,7 +637,7 @@ function ConditionStatusReport({
{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} tone={tone} />
<ReportSection key={section.title} title={section.title} items={section.items} />
))}
</div>
) : 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 }) {
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="mt-0.5 truncate text-xs font-semibold text-slate-700" title={value}>
{value}
@@ -695,20 +689,18 @@ function ReportMetaTile({ label, value }: { label: string; value: string }) {
function ReportSection({
title,
items,
tone
items
}: {
title: string;
items: string[];
tone: DetailTone;
}) {
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>
<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={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>
</li>
))}
@@ -719,7 +711,7 @@ function ReportSection({
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
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="min-w-0">
<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}
</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)}
</span>
</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({
icon: Icon,
title,
@@ -763,7 +767,7 @@ function DecisionActionStrip({
onAction?: () => void;
}) {
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">
<SectionEyebrow icon={Icon} label={title} />
<p className="mt-1 text-xs leading-5 text-slate-700">{value}</p>
@@ -775,7 +779,7 @@ function DecisionActionStrip({
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 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",
actionDisabled
? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80"
@@ -804,53 +808,61 @@ function SectionEyebrow({
);
}
function getDecisionSurfaceClassName(tone: DetailTone) {
return decisionSurfaceClassNames[tone];
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
if (tone === "completed") {
return "border-green-100 bg-green-50 text-green-700";
}
function getDetailDotClassName(tone: DetailTone) {
return detailDotClassNames[tone];
if (tone === "confirmation") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
function getTaskExecutionBadgeClassName(tone: DetailTone) {
return taskExecutionBadgeClassNames[tone];
if (tone === "active") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus, tone: DetailTone) {
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") {
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";
}
if (status === "confirmation") {
return "bg-orange-500 text-white";
}
return "bg-slate-100 text-slate-400";
}
function getTaskExecutionCurrentStepClassName(tone: DetailTone) {
return taskExecutionCurrentStepClassNames[tone];
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
if (status === "current") {
return "border-blue-100 bg-blue-50/70";
}
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus, tone: DetailTone) {
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 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";
@@ -865,21 +877,37 @@ function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
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 modelValue = condition.kind === "work_order" ? "预约工单" : condition.modelName ?? "未记录";
const sessionValue = condition.kind === "work_order" ? formatTimeRange(condition.scheduledAt, condition.durationMinutes) : condition.sessionId ?? "未记录";
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">
<ReportMetaTile label={condition.kind === "work_order" ? "类型" : "模型"} value={modelValue} />
<ReportMetaTile label={condition.kind === "work_order" ? "窗口" : "会话"} value={sessionValue} />
<ReportMetaTile label="风险" value={getRiskLabel(condition.riskLevel)} />
<ReportMetaTile label={durationLabel} value={durationValue} />
{metrics.map((metric) => (
<div key={metric.label} className={metric.className}>
<ReportMetaTile label={metric.label} value={metric.value} />
</div>
))}
</div>
);
}
@@ -908,8 +936,8 @@ function RunningWorkflowPanel({
icon={Loader2}
title={workflow.title}
badge="执行中"
badgeTone="active"
summary={`${currentStep.title}${currentStep.detail}`}
tone="blue"
meta={[
{ label: "任务对象", value: condition.title },
{ 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 (
<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>
</div>
);
@@ -950,7 +986,7 @@ function TaskExecutionPanel({
summary,
meta,
steps,
tone = "blue",
badgeTone = "pending",
children
}: {
icon: typeof Bot;
@@ -959,15 +995,15 @@ function TaskExecutionPanel({
summary?: string;
meta?: ExecutionStepMeta[];
steps: ExecutionStep[];
tone?: DetailTone;
badgeTone?: ExecutionBadgeTone;
children?: React.ReactNode;
}) {
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="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(tone))}>
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
{badge}
</span>
</div>
@@ -986,7 +1022,6 @@ function TaskExecutionPanel({
<TaskExecutionStepRow
key={step.id}
step={step}
tone={tone}
isLast={index === steps.length - 1}
/>
))}
@@ -998,11 +1033,9 @@ function TaskExecutionPanel({
function TaskExecutionStepRow({
step,
tone,
isLast
}: {
step: ExecutionStep;
tone: DetailTone;
isLast: boolean;
}) {
return (
@@ -1011,11 +1044,13 @@ function TaskExecutionStepRow({
{!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, 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" ? (
<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" />
)}
@@ -1024,12 +1059,12 @@ function TaskExecutionStepRow({
<span
className={cn(
"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="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)}
</span>
</span>
@@ -1068,8 +1103,8 @@ function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderI
icon={ClipboardList}
title="任务执行"
badge={currentStage ? currentStage.title : condition.code}
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
summary={condition.detail ?? condition.summary}
tone="blue"
meta={[
{ label: "来源", value: condition.source },
{ label: "位置", value: condition.location },
@@ -1278,4 +1313,3 @@ function getConditionAnalysisInsight(condition: ScheduledConditionItem): Schedul
]
};
}
@@ -145,18 +145,6 @@ export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
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) {
return (
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";
}
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) {
return statusIcons[status];
}