2026-08-08 17:37:48 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 批量测试 — 已完成结果视图(MVP mock)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-08-08 19:08:48 +08:00
|
|
|
|
import { ChevronRight } from "lucide-react";
|
2026-08-10 09:33:29 +08:00
|
|
|
|
import { useRef, useState } from "react";
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
2026-08-08 19:08:48 +08:00
|
|
|
|
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
|
2026-08-08 17:37:48 +08:00
|
|
|
|
import {
|
|
|
|
|
|
BatchCaseStatusBadge,
|
|
|
|
|
|
BatchPhasePill,
|
2026-08-10 09:33:29 +08:00
|
|
|
|
getBatchCaseStatusLabel,
|
2026-08-08 17:37:48 +08:00
|
|
|
|
} from "@/components/batch-test/batch-case-status";
|
|
|
|
|
|
import {
|
2026-08-10 09:33:29 +08:00
|
|
|
|
BATCH_ERROR_STRATEGY_LABEL,
|
|
|
|
|
|
BATCH_FAILURE_STRATEGY_LABEL,
|
2026-08-08 17:37:48 +08:00
|
|
|
|
countByStatus,
|
2026-08-10 09:33:29 +08:00
|
|
|
|
formatRunDuration,
|
2026-08-08 17:37:48 +08:00
|
|
|
|
formatRunTime,
|
|
|
|
|
|
type BatchRunSnapshot,
|
|
|
|
|
|
} from "@/data/batch-run";
|
2026-08-10 09:33:29 +08:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-08-08 17:37:48 +08:00
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
2026-08-10 09:33:29 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}) {
|
2026-08-08 19:08:48 +08:00
|
|
|
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
2026-08-10 09:33:29 +08:00
|
|
|
|
const [filter, setFilter] = useState<ResultFilter>("all");
|
|
|
|
|
|
const detailRowRefs = useRef(new Map<string, HTMLTableRowElement>());
|
2026-08-08 19:08:48 +08:00
|
|
|
|
const selectedCase =
|
|
|
|
|
|
run.cases.find((item) => item.id === selectedCaseId) ?? null;
|
2026-08-09 17:08:53 +08:00
|
|
|
|
const detailOpen = Boolean(selectedCase);
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
|
|
|
|
|
const counts = countByStatus(run.cases);
|
2026-08-10 09:33:29 +08:00
|
|
|
|
const judged = counts.pass + counts.fail + counts.error;
|
2026-08-08 17:37:48 +08:00
|
|
|
|
const total = run.cases.length;
|
|
|
|
|
|
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
|
2026-08-10 09:33:29 +08:00
|
|
|
|
const failRate =
|
|
|
|
|
|
judged === 0 ? 0 : Math.round((counts.fail / judged) * 100);
|
|
|
|
|
|
const errorRate =
|
|
|
|
|
|
judged === 0 ? 0 : Math.round((counts.error / judged) * 100);
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-09 17:08:53 +08:00
|
|
|
|
<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]",
|
2026-08-08 19:08:48 +08:00
|
|
|
|
)}
|
2026-08-09 17:08:53 +08:00
|
|
|
|
>
|
|
|
|
|
|
{/* 结果总览 */}
|
|
|
|
|
|
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<h2 className="text-base font-medium text-foreground">
|
|
|
|
|
|
{run.title}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<BatchPhasePill phase="completed" />
|
2026-08-08 19:08:48 +08:00
|
|
|
|
</div>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
{run.finishedAt && (
|
|
|
|
|
|
<p className="mt-1.5 text-xs text-muted-soft">
|
|
|
|
|
|
完成时间:{formatRunTime(run.finishedAt)}
|
2026-08-10 09:33:29 +08:00
|
|
|
|
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
2026-08-10 09:33:29 +08:00
|
|
|
|
<dl className="mt-4 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/60 px-4 py-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<dt className="text-xs text-muted-foreground">被测助手</dt>
|
|
|
|
|
|
<dd className="mt-1 truncate font-medium text-foreground">
|
|
|
|
|
|
{run.assistantName}
|
|
|
|
|
|
</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} 秒
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<dt className="text-xs text-muted-foreground">耗时 / 处理策略</dt>
|
|
|
|
|
|
<dd className="mt-1 text-xs font-medium leading-5 text-foreground">
|
|
|
|
|
|
{formatRunDuration(run.startedAt, run.finishedAt)} · 断言失败:
|
|
|
|
|
|
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
|
|
|
|
|
|
<br />
|
|
|
|
|
|
执行错误:重试 {run.config.errorRetryCount} 次,
|
|
|
|
|
|
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
|
2026-08-09 17:08:53 +08:00
|
|
|
|
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
|
|
|
|
|
|
<div className="text-center sm:text-left">
|
|
|
|
|
|
<div className="font-display text-3xl text-ink">
|
|
|
|
|
|
{counts.pass} / {judged || total} 通过
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1 text-sm font-medium text-success">
|
|
|
|
|
|
通过率 {passRate}%
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-08 19:08:48 +08:00
|
|
|
|
|
2026-08-10 09:33:29 +08:00
|
|
|
|
<PassRateDonut
|
|
|
|
|
|
pass={counts.pass}
|
|
|
|
|
|
fail={counts.fail}
|
|
|
|
|
|
error={counts.error}
|
|
|
|
|
|
/>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2 text-sm">
|
2026-08-08 19:08:48 +08:00
|
|
|
|
<LegendRow
|
2026-08-09 17:08:53 +08:00
|
|
|
|
tone="success"
|
|
|
|
|
|
label="通过"
|
|
|
|
|
|
count={counts.pass}
|
|
|
|
|
|
percent={passRate}
|
2026-08-08 19:08:48 +08:00
|
|
|
|
/>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
<LegendRow
|
|
|
|
|
|
tone="destructive"
|
2026-08-10 09:33:29 +08:00
|
|
|
|
label="断言失败"
|
2026-08-09 17:08:53 +08:00
|
|
|
|
count={counts.fail}
|
|
|
|
|
|
percent={failRate}
|
|
|
|
|
|
/>
|
2026-08-10 09:33:29 +08:00
|
|
|
|
<LegendRow
|
|
|
|
|
|
tone="warning"
|
|
|
|
|
|
label="执行错误"
|
|
|
|
|
|
count={counts.error}
|
|
|
|
|
|
percent={errorRate}
|
|
|
|
|
|
/>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
{counts.skipped > 0 && (
|
|
|
|
|
|
<LegendRow
|
|
|
|
|
|
tone="muted"
|
|
|
|
|
|
label="未执行"
|
|
|
|
|
|
count={counts.skipped}
|
|
|
|
|
|
percent={
|
|
|
|
|
|
total === 0
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: Math.round((counts.skipped / total) * 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-08 19:08:48 +08:00
|
|
|
|
</div>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</section>
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
2026-08-09 17:08:53 +08:00
|
|
|
|
{/* 用例列表 */}
|
|
|
|
|
|
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
2026-08-10 09:33:29 +08:00
|
|
|
|
<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>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</div>
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
2026-08-09 17:08:53 +08:00
|
|
|
|
<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>
|
2026-08-10 09:33:29 +08:00
|
|
|
|
{visibleCases.map((item) => {
|
|
|
|
|
|
const index = run.cases.findIndex(
|
|
|
|
|
|
(caseItem) => caseItem.id === item.id,
|
|
|
|
|
|
);
|
2026-08-09 17:08:53 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<tr
|
|
|
|
|
|
key={item.id}
|
2026-08-10 09:33:29 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)}
|
2026-08-09 17:08:53 +08:00
|
|
|
|
>
|
2026-08-10 09:33:29 +08:00
|
|
|
|
<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>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-08-10 09:33:29 +08:00
|
|
|
|
{visibleCases.length === 0 && (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td
|
|
|
|
|
|
colSpan={4}
|
|
|
|
|
|
className="px-5 py-10 text-center text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{filter === "error"
|
|
|
|
|
|
? "本次运行没有执行错误。"
|
|
|
|
|
|
: "本次运行没有断言失败用例。"}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)}
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-08-08 17:37:48 +08:00
|
|
|
|
</div>
|
2026-08-09 17:08:53 +08:00
|
|
|
|
</div>
|
2026-08-08 19:08:48 +08:00
|
|
|
|
|
2026-08-09 17:08:53 +08:00
|
|
|
|
{selectedCase && (
|
|
|
|
|
|
<BatchCaseDetailDrawer
|
|
|
|
|
|
item={selectedCase}
|
|
|
|
|
|
assistantName={run.assistantName}
|
2026-08-10 09:33:29 +08:00
|
|
|
|
onClose={closeDetail}
|
|
|
|
|
|
onEdit={() => onEditCase(selectedCase.id)}
|
|
|
|
|
|
onRerun={() => onRerunCase(selectedCase.id)}
|
2026-08-09 17:08:53 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-08 17:37:48 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 09:33:29 +08:00
|
|
|
|
function PassRateDonut({
|
|
|
|
|
|
pass,
|
|
|
|
|
|
fail,
|
|
|
|
|
|
error,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
pass: number;
|
|
|
|
|
|
fail: number;
|
|
|
|
|
|
error: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const total = pass + fail + error;
|
2026-08-08 17:37:48 +08:00
|
|
|
|
const passRatio = total === 0 ? 0 : pass / total;
|
|
|
|
|
|
const size = 112;
|
|
|
|
|
|
const stroke = 14;
|
|
|
|
|
|
const radius = (size - stroke) / 2;
|
|
|
|
|
|
const circumference = 2 * Math.PI * radius;
|
|
|
|
|
|
const passLength = circumference * passRatio;
|
2026-08-10 09:33:29 +08:00
|
|
|
|
const failLength = circumference * (total === 0 ? 0 : fail / total);
|
|
|
|
|
|
const errorLength = circumference * (total === 0 ? 0 : error / total);
|
2026-08-08 17:37:48 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="relative shrink-0"
|
|
|
|
|
|
style={{ width: size, height: size }}
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg width={size} height={size} className="-rotate-90">
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
|
className="text-surface-strong"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{total > 0 && (
|
|
|
|
|
|
<>
|
2026-08-10 09:33:29 +08:00
|
|
|
|
{pass > 0 && (
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
|
strokeDasharray={`${passLength} ${circumference}`}
|
|
|
|
|
|
strokeLinecap="butt"
|
|
|
|
|
|
className="text-success"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{fail > 0 && (
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
|
strokeDasharray={`${failLength} ${circumference}`}
|
|
|
|
|
|
strokeDashoffset={-passLength}
|
|
|
|
|
|
strokeLinecap="butt"
|
|
|
|
|
|
className="text-destructive"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{error > 0 && (
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
|
strokeDasharray={`${errorLength} ${circumference}`}
|
|
|
|
|
|
strokeDashoffset={-(passLength + failLength)}
|
|
|
|
|
|
strokeLinecap="butt"
|
|
|
|
|
|
className="text-amber-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-08 17:37:48 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
|
|
|
|
<span className="text-lg font-medium tabular-nums text-foreground">
|
|
|
|
|
|
{total === 0 ? 0 : Math.round((pass / total) * 100)}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function LegendRow({
|
|
|
|
|
|
tone,
|
|
|
|
|
|
label,
|
|
|
|
|
|
count,
|
|
|
|
|
|
percent,
|
|
|
|
|
|
}: {
|
2026-08-10 09:33:29 +08:00
|
|
|
|
tone: "success" | "destructive" | "warning" | "muted";
|
2026-08-08 17:37:48 +08:00
|
|
|
|
label: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
percent: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const dotClass = {
|
|
|
|
|
|
success: "bg-success",
|
|
|
|
|
|
destructive: "bg-destructive",
|
2026-08-10 09:33:29 +08:00
|
|
|
|
warning: "bg-amber-500",
|
2026-08-08 17:37:48 +08:00
|
|
|
|
muted: "bg-muted-soft",
|
|
|
|
|
|
}[tone];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
|
|
|
|
<span className={cn("size-2.5 rounded-full", dotClass)} />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{label} {count}({percent}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|