You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

162 lines
4.7 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Flock 查价可视化进度(以 Worker 回写的 rpa_stage 为准,失败时标出卡点)
*/
"use client";
import { useEffect, useState } from "react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
FLOCK_RPA_STAGE_LABELS,
FLOCK_RPA_STAGES,
flockStageIndex,
mapFlockErrorToStage,
type FlockRpaStage,
} from "@/lib/flock/rpa-progress";
const DISPLAY_STAGES: FlockRpaStage[] = [
"queued",
"lock",
"login",
"open_quote",
"fill_form",
"submit",
"wait_quote",
];
export interface FlockQuoteProgressProps {
active: boolean;
quoteId?: string | null;
startedAtMs?: number | null;
/** Worker 回写阶段 */
rpaStage?: string | null;
rpaStageLabel?: string | null;
/** 失败后的错误原文(用于把进度钉在卡点并展示 hint */
errorMessage?: string | null;
failed?: boolean;
}
export function FlockQuoteProgress({
active,
quoteId,
startedAtMs,
rpaStage,
rpaStageLabel,
errorMessage,
failed = false,
}: FlockQuoteProgressProps) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active && !failed) return;
const t = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(t);
}, [active, failed]);
if (!active && !failed) return null;
const mapped = failed ? mapFlockErrorToStage(errorMessage) : null;
// 优先 Worker 回写的 rpa_stage禁止失败时用默认「互斥锁」覆盖真实进度
const stageRaw = (
rpaStage && (FLOCK_RPA_STAGES as readonly string[]).includes(rpaStage)
? rpaStage
: (mapped?.stage ?? "queued")
) as string;
const currentStage: FlockRpaStage = (
FLOCK_RPA_STAGES as readonly string[]
).includes(stageRaw)
? (stageRaw as FlockRpaStage)
: "queued";
const currentIdx = Math.max(0, flockStageIndex(currentStage));
const label =
(failed ? mapped?.hint : null) ??
rpaStageLabel ??
FLOCK_RPA_STAGE_LABELS[currentStage];
const elapsed = Math.max(0, now - (startedAtMs ?? now));
return (
<div
className={[
"rounded-lg border p-4 shadow-card",
failed
? "border-red-300 bg-red-50"
: "border-[#1890FF]/25 bg-[#1890FF]/5",
].join(" ")}
role="status"
aria-live="polite"
data-testid="flock-quote-progress"
data-rpa-stage={currentStage}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div
className={[
"flex items-center gap-2 text-sm font-semibold",
failed ? "text-red-700" : "text-[#1F2937]",
].join(" ")}
>
{!failed ? (
<LoadingSpinner size={16} className="text-[#1890FF]" />
) : null}
{failed ? "Flock 查价失败 — 已定位卡点" : "正在查询 Flock Freight 实时报价"}
</div>
<span className="text-xs tabular-nums text-text-secondary">
{(elapsed / 1000).toFixed(1)}s
</span>
</div>
<ol className="space-y-2">
{DISPLAY_STAGES.map((stage) => {
const i = flockStageIndex(stage);
const done = !failed && i < currentIdx;
const current = i === currentIdx || (failed && stage === currentStage);
const stuck = failed && stage === currentStage;
return (
<li
key={stage}
className={[
"flex items-start gap-2 text-sm",
stuck
? "font-semibold text-red-600"
: current
? "font-medium text-[#1890FF]"
: done
? "text-[#1F2937]"
: "text-text-disabled",
].join(" ")}
>
<span aria-hidden className="mt-0.5 w-4 shrink-0 text-center">
{stuck ? "✕" : done ? "✓" : current ? "●" : "○"}
</span>
<span>
{FLOCK_RPA_STAGE_LABELS[stage]}
{current && !failed ? "…" : null}
{stuck ? "(卡在这里)" : null}
</span>
</li>
);
})}
</ol>
<p
className={[
"mt-3 text-xs",
failed ? "font-medium text-red-700" : "text-text-secondary",
].join(" ")}
>
{label}
</p>
{quoteId ? (
<p className="mt-2 break-all text-[11px] text-text-secondary">
{quoteId}
</p>
) : null}
{!failed ? (
<p className="mt-1 text-[11px] text-text-disabled">
RPA Worker Flock
JWT_SECRET .rpa/flock-stalls/
</p>
) : null}
</div>
);
}