Files
ai-video-fullstack/frontend/src/components/batch-test/batch-run-completed.tsx
2026-08-10 13:49:24 +08:00

449 lines
16 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 { ChevronDown, ChevronRight, Settings2 } from "lucide-react";
import { useRef, useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import {
BatchCaseStatusBadge,
getBatchCaseStatusLabel,
} from "@/components/batch-test/batch-case-status";
import {
BATCH_ERROR_STRATEGY_LABEL,
BATCH_FAILURE_STRATEGY_LABEL,
countByStatus,
formatRunDuration,
formatRunTime,
type BatchRunSnapshot,
} from "@/data/batch-run";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ResultFilter = "all" | "fail" | "error";
function stopReasonLabel(run: BatchRunSnapshot): string {
if (run.stopReason === "manual") return "已手动停止";
if (run.stopReason === "assertion_failure") return "因断言失败停止";
if (run.stopReason === "execution_error") return "因执行错误停止";
return "";
}
export function BatchRunCompletedView({
run,
onEditCase,
onRerunCase,
}: {
run: BatchRunSnapshot;
onEditCase: (caseId: string) => void;
onRerunCase: (caseId: string) => void;
}) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [filter, setFilter] = useState<ResultFilter>("all");
const detailRowRefs = useRef(new Map<string, HTMLTableRowElement>());
const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null;
const detailOpen = Boolean(selectedCase);
const counts = countByStatus(run.cases);
const total = run.cases.length;
const outcome = getRunOutcome(run, counts);
const visibleCases =
filter === "fail"
? run.cases.filter((item) => item.status === "fail")
: filter === "error"
? run.cases.filter((item) => item.status === "error")
: run.cases;
function closeDetail() {
const currentId = selectedCaseId;
setSelectedCaseId(null);
if (!currentId) return;
window.requestAnimationFrame(() => {
detailRowRefs.current.get(currentId)?.focus();
});
}
function handleCaseRowKeyDown(
event: React.KeyboardEvent<HTMLTableRowElement>,
caseId: string,
) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setSelectedCaseId(caseId);
}
return (
<div className="flex h-full min-h-0 w-full overflow-hidden bg-background">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
<div
className={cn(
"flex flex-col gap-4",
detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
)}
>
{/* 结果总览 */}
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-medium text-foreground">
{run.title}
</h2>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
{formatRunTime(run.finishedAt)}
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
</p>
)}
</div>
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full px-3 py-1 text-xs font-medium",
outcome.className,
)}
>
{outcome.label}
</span>
</div>
<div className="mt-5 rounded-xl border border-hairline bg-canvas-soft/60 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-medium tracking-wide text-muted-soft uppercase">
</p>
<p className="mt-1 font-display text-3xl text-ink">
<span className="tabular-nums">{counts.pass}</span>
<span className="mx-1.5 text-muted-soft">/</span>
<span className="tabular-nums">{total}</span>
<span className="ml-2 text-2xl"></span>
</p>
</div>
<div className="grid grid-cols-3 gap-x-5 gap-y-2 text-sm sm:shrink-0">
<ResultCount tone="success" label="通过" count={counts.pass} />
<ResultCount
tone="destructive"
label="断言失败"
count={counts.fail}
/>
<ResultCount
tone="warning"
label="执行错误"
count={counts.error}
/>
{counts.skipped > 0 && (
<ResultCount
tone="muted"
label="未执行"
count={counts.skipped}
/>
)}
</div>
</div>
<ResultDistribution counts={counts} total={total} />
</div>
<dl
className={cn(
"mt-4 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2",
!detailOpen && "lg:grid-cols-4",
)}
>
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 break-words font-medium text-foreground">
{run.assistantName}
</dd>
</div>
<div className="min-w-0">
<dt className="text-xs text-muted-foreground"></dt>
<dd
className="mt-1 break-words font-medium text-foreground"
title={run.config.evaluatorModelResourceName}
>
{run.config.evaluatorModelResourceName}
</dd>
{run.config.evaluatorModel && (
<dd
className="mt-0.5 break-all font-mono text-[11px] text-muted-soft"
title={run.config.evaluatorModel}
>
{run.config.evaluatorModel}
</dd>
)}
</div>
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium text-foreground">
{run.config.suiteCount} · {total}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium text-foreground">
{run.config.concurrency} · {run.config.timeoutSecs}
<span className="mx-1.5 text-muted-soft">·</span>
{formatRunDuration(run.startedAt, run.finishedAt)}
</dd>
</div>
</dl>
<details className="group mt-4 border-t border-hairline pt-3">
<summary className="flex w-fit cursor-pointer list-none items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-canvas-soft hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
<Settings2 size={13} />
<ChevronDown
size={13}
className="transition-transform group-open:rotate-180"
/>
</summary>
<div className="mt-3 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/50 px-4 py-3 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
<p>
<span className="font-medium text-foreground"></span>
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
</p>
<p>
<span className="font-medium text-foreground"></span>
{run.config.errorRetryCount}
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
</p>
</div>
</details>
</section>
{/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground"></h3>
<div
className="flex items-center gap-2"
role="group"
aria-label="结果筛选"
>
<Button
type="button"
size="sm"
variant={filter === "all" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "all"}
onClick={() => setFilter("all")}
>
{total}
</Button>
<Button
type="button"
size="sm"
variant={filter === "fail" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "fail"}
onClick={() => setFilter("fail")}
>
{counts.fail}
</Button>
<Button
type="button"
size="sm"
variant={filter === "error" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "error"}
onClick={() => setFilter("error")}
>
{counts.error}
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead>
<tr className="border-b border-hairline text-xs text-muted-soft">
<th className="w-12 px-5 py-2.5 font-medium">#</th>
<th className="px-3 py-2.5 font-medium"></th>
<th className="w-28 px-3 py-2.5 font-medium"></th>
<th className="w-24 px-3 py-2.5 font-medium"></th>
</tr>
</thead>
<tbody>
{visibleCases.map((item) => {
const index = run.cases.findIndex(
(caseItem) => caseItem.id === item.id,
);
return (
<tr
key={item.id}
ref={(element) => {
if (element) {
detailRowRefs.current.set(item.id, element);
} else {
detailRowRefs.current.delete(item.id);
}
}}
role="button"
tabIndex={0}
aria-label={`查看用例 ${item.name} 详情,状态:${getBatchCaseStatusLabel(item.status)}`}
aria-expanded={selectedCaseId === item.id}
aria-controls="batch-case-detail"
onClick={() => setSelectedCaseId(item.id)}
onKeyDown={(event) =>
handleCaseRowKeyDown(event, item.id)
}
className={cn(
"cursor-pointer border-b border-hairline transition-colors outline-none last:border-b-0 hover:bg-canvas-soft/70 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
selectedCaseId === item.id && "bg-canvas-soft",
)}
>
<td className="w-12 px-5 py-3 tabular-nums text-muted-foreground">
{index + 1}
</td>
<td className="px-3 py-3 font-medium text-foreground">
{item.name}
</td>
<td className="w-28 px-3 py-3">
<BatchCaseStatusBadge status={item.status} />
</td>
<td className="w-24 px-3 py-2">
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<ChevronRight size={14} />
</span>
</td>
</tr>
);
})}
{visibleCases.length === 0 && (
<tr>
<td
colSpan={4}
className="px-5 py-10 text-center text-sm text-muted-foreground"
>
{filter === "error"
? "本次运行没有执行错误。"
: "本次运行没有断言失败用例。"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</div>
</div>
{selectedCase && (
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={closeDetail}
onEdit={() => onEditCase(selectedCase.id)}
onRerun={() => onRerunCase(selectedCase.id)}
/>
)}
</div>
);
}
type BatchCounts = ReturnType<typeof countByStatus>;
function getRunOutcome(run: BatchRunSnapshot, counts: BatchCounts) {
if (run.status === "cancelled") {
return {
label: "运行已停止",
className: "bg-surface-strong text-muted-foreground",
};
}
if (counts.error > 0) {
return {
label: "存在执行错误",
className: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
};
}
if (counts.fail > 0) {
return {
label: "存在断言失败",
className: "bg-destructive/10 text-destructive",
};
}
if (counts.skipped > 0) {
return {
label: "部分未执行",
className: "bg-surface-strong text-muted-foreground",
};
}
if (counts.pass > 0) {
return {
label: "全部通过",
className: "bg-success/10 text-success",
};
}
return {
label: "暂无结果",
className: "bg-surface-strong text-muted-foreground",
};
}
function ResultCount({
tone,
label,
count,
}: {
tone: "success" | "destructive" | "warning" | "muted";
label: string;
count: number;
}) {
const dotClass = {
success: "bg-success",
destructive: "bg-destructive",
warning: "bg-amber-500",
muted: "bg-muted-soft",
}[tone];
return (
<div className="flex min-w-0 items-center gap-2">
<span
className={cn("size-2 shrink-0 rounded-full", dotClass)}
aria-hidden
/>
<span className="truncate text-muted-foreground">{label}</span>
<span className="ml-auto font-medium tabular-nums text-foreground">
{count}
</span>
</div>
);
}
function ResultDistribution({
counts,
total,
}: {
counts: BatchCounts;
total: number;
}) {
const segments = [
{ key: "pass", count: counts.pass, className: "bg-success" },
{ key: "fail", count: counts.fail, className: "bg-destructive" },
{ key: "error", count: counts.error, className: "bg-amber-500" },
{ key: "skipped", count: counts.skipped, className: "bg-muted-soft" },
].filter((item) => item.count > 0);
return (
<div
className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface-strong"
role="img"
aria-label={`结果分布:通过 ${counts.pass},断言失败 ${counts.fail},执行错误 ${counts.error},未执行 ${counts.skipped}`}
>
{total > 0 &&
segments.map((segment) => (
<span
key={segment.key}
className={segment.className}
style={{ flexBasis: 0, flexGrow: segment.count }}
aria-hidden
/>
))}
</div>
);
}