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
+91 -56
View File
@@ -3587,6 +3587,8 @@ function AutomationRow({
? `#/chat/${encodeURIComponent(job.origin.session_key)}` ? `#/chat/${encodeURIComponent(job.origin.session_key)}`
: null; : null;
const history = job.state.run_history ?? []; const history = job.state.run_history ?? [];
const latestRun = history[history.length - 1];
const lastResult = automationLastResult(job, latestRun, locale, tx);
const canManage = !job.protected; const canManage = !job.protected;
const canRun = canManage && job.enabled && !job.state.pending; const canRun = canManage && job.enabled && !job.state.pending;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
@@ -3594,11 +3596,11 @@ function AutomationRow({
const needsRecreation = automationNeedsRecreation(job); const needsRecreation = automationNeedsRecreation(job);
return ( 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"> <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-4 lg:flex-row lg:items-start lg:justify-between"> <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="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[15px] font-medium leading-6 text-foreground"> <span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
{job.name || job.id} {job.name || job.id}
</span> </span>
<StatusPill tone={status.tone}>{status.label}</StatusPill> <StatusPill tone={status.tone}>{status.label}</StatusPill>
@@ -3606,11 +3608,11 @@ function AutomationRow({
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill> <StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
) : null} ) : null}
</div> </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")} {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
</p> </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 <AutomationDetail
label={tx("settings.automations.labels.schedule", "Schedule")} label={tx("settings.automations.labels.schedule", "Schedule")}
title={formatAutomationSchedule(job, locale, tx)} title={formatAutomationSchedule(job, locale, tx)}
@@ -3624,10 +3626,17 @@ function AutomationRow({
{formatAutomationNext(job, tx)} {formatAutomationNext(job, tx)}
</AutomationDetail> </AutomationDetail>
<AutomationDetail <AutomationDetail
label={tx("settings.automations.labels.last", "Last")} label={tx("settings.automations.history.recent", "Last result")}
title={formatAutomationLast(job, locale, tx)} 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>
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}> <AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
{originHref ? ( {originHref ? (
@@ -3659,45 +3668,6 @@ function AutomationRow({
</div> </div>
) : null} ) : 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"> <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 ? ( {job.created_at_ms ? (
<span> <span>
@@ -3716,7 +3686,7 @@ function AutomationRow({
</div> </div>
</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 ? ( {canManage ? (
<> <>
<AppsActionButton <AppsActionButton
@@ -3772,20 +3742,29 @@ function AutomationRow({
function AutomationDetail({ function AutomationDetail({
label, label,
title, title,
secondary,
children, children,
}: { }: {
label: string; label: string;
title?: string; title?: string;
secondary?: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="min-w-0 rounded-[14px] bg-muted/35 px-3 py-2"> <div className="min-w-0 bg-background/75 px-3 py-2.5">
<div className="text-[10.5px] font-medium uppercase leading-none text-muted-foreground/75"> <div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{label} {label}
</div> </div>
<div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85" title={title}> <div className="mt-1.5 min-w-0">
<div className="truncate text-[13px] leading-5 text-foreground/85" title={title}>
{children} {children}
</div> </div>
{secondary ? (
<div className="mt-0.5 truncate text-[11.5px] leading-4 text-muted-foreground" title={title}>
{secondary}
</div>
) : null}
</div>
</div> </div>
); );
} }
@@ -4410,14 +4389,70 @@ function formatAutomationNextTitle(
return fmtDateTime(job.state.next_run_at_ms, locale); return fmtDateTime(job.state.next_run_at_ms, locale);
} }
function formatAutomationLast( function automationLastResult(
job: SessionAutomationJob, job: SessionAutomationJob,
latestRun: NonNullable<SessionAutomationJob["state"]["run_history"]>[number] | undefined,
locale: string, locale: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => 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"); 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 status = automationRunStatusLabel(job.state.last_status, tx);
return `${fmtDateTime(job.state.last_run_at_ms, locale)} · ${status}`; 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( function automationRunStatusLabel(
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Completed", "ok": "Completed",
"error": "Error", "error": "Error",
"skipped": "Skipped", "skipped": "Skipped",
"recent": "Recent runs", "recent": "Last result",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Completada", "ok": "Completada",
"error": "Error", "error": "Error",
"skipped": "Omitida", "skipped": "Omitida",
"recent": "Ejecuciones recientes", "recent": "Último resultado",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Terminée", "ok": "Terminée",
"error": "Erreur", "error": "Erreur",
"skipped": "Ignorée", "skipped": "Ignorée",
"recent": "Exécutions récentes", "recent": "Dernier résultat",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Selesai", "ok": "Selesai",
"error": "Error", "error": "Error",
"skipped": "Dilewati", "skipped": "Dilewati",
"recent": "Eksekusi terbaru", "recent": "Hasil terakhir",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完了", "ok": "完了",
"error": "エラー", "error": "エラー",
"skipped": "スキップ", "skipped": "スキップ",
"recent": "最近の実行", "recent": "最新結果",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "완료", "ok": "완료",
"error": "오류", "error": "오류",
"skipped": "건너뜀", "skipped": "건너뜀",
"recent": "최근 실행", "recent": "최근 결과",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "Hoàn tất", "ok": "Hoàn tất",
"error": "Lỗi", "error": "Lỗi",
"skipped": "Đã bỏ qua", "skipped": "Đã bỏ qua",
"recent": "Lượt chạy gần đây", "recent": "Kết quả gần nhất",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完成", "ok": "完成",
"error": "错误", "error": "错误",
"skipped": "已跳过", "skipped": "已跳过",
"recent": "最近运行", "recent": "最近结果",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+1 -1
View File
@@ -570,7 +570,7 @@
"ok": "完成", "ok": "完成",
"error": "錯誤", "error": "錯誤",
"skipped": "已略過", "skipped": "已略過",
"recent": "最近執行", "recent": "最近結果",
"statusWithDuration": "{{status}} · {{duration}}" "statusWithDuration": "{{status}} · {{duration}}"
}, },
"meta": { "meta": {
+2 -2
View File
@@ -585,8 +585,8 @@ describe("App layout", () => {
expect(screen.getByText("每日检查")).toBeInTheDocument(); expect(screen.getByText("每日检查")).toBeInTheDocument();
expect(screen.getByText("检查仓库状态")).toBeInTheDocument(); expect(screen.getByText("检查仓库状态")).toBeInTheDocument();
expect(screen.getByText("每 1天")).toBeInTheDocument(); expect(screen.getByText("每 1天")).toBeInTheDocument();
expect(screen.getByText("最近运行")).toBeInTheDocument(); expect(screen.getByText("最近结果")).toBeInTheDocument();
expect(screen.getByText("不到 1 秒")).toBeInTheDocument(); expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument();
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument(); expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot"); expect(document.title).toBe("自动任务 · nanobot");