fix(webui): refine automation row layout

This commit is contained in:
chengyongru
2026-06-15 15:41:35 +08:00
parent 04496e9e28
commit 0439ecb802
11 changed files with 104 additions and 69 deletions
+93 -58
View File
@@ -3587,6 +3587,8 @@ function AutomationRow({
? `#/chat/${encodeURIComponent(job.origin.session_key)}`
: null;
const history = job.state.run_history ?? [];
const latestRun = history[history.length - 1];
const lastResult = automationLastResult(job, latestRun, locale, tx);
const canManage = !job.protected;
const canRun = canManage && job.enabled && !job.state.pending;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
@@ -3594,11 +3596,11 @@ function AutomationRow({
const needsRecreation = automationNeedsRecreation(job);
return (
<article className="rounded-[22px] border border-border/45 bg-card/86 p-4 shadow-[0_18px_65px_rgba(15,23,42,0.06)] backdrop-blur-xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<article className="rounded-[22px] border border-border/45 bg-card/90 p-4 shadow-[0_18px_55px_rgba(15,23,42,0.055)] backdrop-blur-xl">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[15px] font-medium leading-6 text-foreground">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
{job.name || job.id}
</span>
<StatusPill tone={status.tone}>{status.label}</StatusPill>
@@ -3606,11 +3608,11 @@ function AutomationRow({
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
) : null}
</div>
<p className="mt-1 line-clamp-2 max-w-[46rem] text-[13px] leading-6 text-muted-foreground">
<p className="mt-1 line-clamp-2 max-w-[58rem] text-[13px] leading-5 text-muted-foreground">
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
</p>
<div className="mt-3 grid gap-2 text-[12px] text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
<div className="mt-3 grid gap-px overflow-hidden rounded-[16px] border border-border/35 bg-border/35 text-[12px] text-muted-foreground md:grid-cols-2 xl:grid-cols-[1.25fr_0.8fr_1fr_0.85fr]">
<AutomationDetail
label={tx("settings.automations.labels.schedule", "Schedule")}
title={formatAutomationSchedule(job, locale, tx)}
@@ -3624,10 +3626,17 @@ function AutomationRow({
{formatAutomationNext(job, tx)}
</AutomationDetail>
<AutomationDetail
label={tx("settings.automations.labels.last", "Last")}
title={formatAutomationLast(job, locale, tx)}
label={tx("settings.automations.history.recent", "Last result")}
title={lastResult.title}
secondary={lastResult.secondary}
>
{formatAutomationLast(job, locale, tx)}
<span className="inline-flex min-w-0 items-center gap-1.5">
<span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", automationResultDotClass(lastResult.tone))}
aria-hidden
/>
<span className="truncate">{lastResult.primary}</span>
</span>
</AutomationDetail>
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
{originHref ? (
@@ -3659,45 +3668,6 @@ function AutomationRow({
</div>
) : null}
{history.length ? (
<div className="mt-3">
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-normal text-muted-foreground">
{tx("settings.automations.history.recent", "Recent runs")}
</div>
<div className="flex flex-wrap gap-1.5">
{history.slice(-4).map((record) => {
const statusLabel = automationRunStatusLabel(record.status, tx);
const duration = formatAutomationRunDuration(record.duration_ms, locale, tx);
const visibleLabel = record.status === "ok"
? duration
: tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", {
status: statusLabel,
duration,
});
const detail = record.error || fmtDateTime(record.run_at_ms, locale);
const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`;
return (
<span
key={`${record.run_at_ms}:${record.status}`}
className={cn(
"rounded-full px-2 py-1 text-[11px]",
record.status === "error"
? "bg-destructive/10 text-destructive"
: record.status === "skipped"
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
title={accessibleLabel}
aria-label={accessibleLabel}
>
{visibleLabel}
</span>
);
})}
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] leading-5 text-muted-foreground">
{job.created_at_ms ? (
<span>
@@ -3716,7 +3686,7 @@ function AutomationRow({
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1 rounded-full border border-border/35 bg-background/70 p-1 shadow-sm">
{canManage ? (
<>
<AppsActionButton
@@ -3772,19 +3742,28 @@ function AutomationRow({
function AutomationDetail({
label,
title,
secondary,
children,
}: {
label: string;
title?: string;
secondary?: ReactNode;
children: ReactNode;
}) {
return (
<div className="min-w-0 rounded-[14px] bg-muted/35 px-3 py-2">
<div className="text-[10.5px] font-medium uppercase leading-none text-muted-foreground/75">
<div className="min-w-0 bg-background/75 px-3 py-2.5">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{label}
</div>
<div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85" title={title}>
{children}
<div className="mt-1.5 min-w-0">
<div className="truncate text-[13px] leading-5 text-foreground/85" title={title}>
{children}
</div>
{secondary ? (
<div className="mt-0.5 truncate text-[11.5px] leading-4 text-muted-foreground" title={title}>
{secondary}
</div>
) : null}
</div>
</div>
);
@@ -4410,14 +4389,70 @@ function formatAutomationNextTitle(
return fmtDateTime(job.state.next_run_at_ms, locale);
}
function formatAutomationLast(
function automationLastResult(
job: SessionAutomationJob,
latestRun: NonNullable<SessionAutomationJob["state"]["run_history"]>[number] | undefined,
locale: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (!job.state.last_run_at_ms) return tx("settings.automations.last.never", "Never");
const status = automationRunStatusLabel(job.state.last_status, tx);
return `${fmtDateTime(job.state.last_run_at_ms, locale)} · ${status}`;
): {
primary: string;
secondary?: string;
title: string;
tone: "neutral" | "success" | "warning" | "danger";
} {
if (job.state.pending) {
const secondary = job.state.last_run_at_ms
? fmtDateTime(job.state.last_run_at_ms, locale)
: undefined;
return {
primary: tx("settings.automations.next.pending", "Running now"),
secondary,
title: secondary ?? tx("settings.automations.next.pending", "Running now"),
tone: "warning",
};
}
if (latestRun) {
const status = automationRunStatusLabel(latestRun.status, tx);
const duration = latestRun.duration_ms === undefined
? null
: formatAutomationRunDuration(latestRun.duration_ms, locale, tx);
const primary = duration
? tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", {
status,
duration,
})
: status;
const secondary = fmtDateTime(latestRun.run_at_ms, locale);
return {
primary,
secondary,
title: latestRun.error ? `${secondary} · ${latestRun.error}` : secondary,
tone: latestRun.status === "error"
? "danger"
: latestRun.status === "skipped"
? "warning"
: "success",
};
}
if (job.state.last_run_at_ms) {
const status = automationRunStatusLabel(job.state.last_status, tx);
const secondary = fmtDateTime(job.state.last_run_at_ms, locale);
return {
primary: status,
secondary,
title: secondary,
tone: job.state.last_status === "error" ? "danger" : "success",
};
}
const never = tx("settings.automations.last.never", "Never");
return { primary: never, title: never, tone: "neutral" };
}
function automationResultDotClass(tone: "neutral" | "success" | "warning" | "danger"): string {
if (tone === "success") return "bg-emerald-500";
if (tone === "warning") return "bg-amber-500";
if (tone === "danger") return "bg-destructive";
return "bg-muted-foreground/45";
}
function automationRunStatusLabel(
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Completed",
"error": "Error",
"skipped": "Skipped",
"recent": "Recent runs",
"recent": "Last result",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Completada",
"error": "Error",
"skipped": "Omitida",
"recent": "Ejecuciones recientes",
"recent": "Último resultado",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Terminée",
"error": "Erreur",
"skipped": "Ignorée",
"recent": "Exécutions récentes",
"recent": "Dernier résultat",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Selesai",
"error": "Error",
"skipped": "Dilewati",
"recent": "Eksekusi terbaru",
"recent": "Hasil terakhir",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完了",
"error": "エラー",
"skipped": "スキップ",
"recent": "最近の実行",
"recent": "最新結果",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "완료",
"error": "오류",
"skipped": "건너뜀",
"recent": "최근 실행",
"recent": "최근 결과",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Hoàn tất",
"error": "Lỗi",
"skipped": "Đã bỏ qua",
"recent": "Lượt chạy gần đây",
"recent": "Kết quả gần nhất",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完成",
"error": "错误",
"skipped": "已跳过",
"recent": "最近运行",
"recent": "最近结果",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完成",
"error": "錯誤",
"skipped": "已略過",
"recent": "最近執行",
"recent": "最近結果",
"statusWithDuration": "{{status}} · {{duration}}"
},
"meta": {
+2 -2
View File
@@ -585,8 +585,8 @@ describe("App layout", () => {
expect(screen.getByText("每日检查")).toBeInTheDocument();
expect(screen.getByText("检查仓库状态")).toBeInTheDocument();
expect(screen.getByText("每 1天")).toBeInTheDocument();
expect(screen.getByText("最近运行")).toBeInTheDocument();
expect(screen.getByText("不到 1 秒")).toBeInTheDocument();
expect(screen.getByText("最近结果")).toBeInTheDocument();
expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument();
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot");