fix(webui): localize automation runtime labels

This commit is contained in:
chengyongru
2026-06-13 23:06:28 +08:00
parent e08462ca30
commit 43830b7162
13 changed files with 181 additions and 20 deletions
+50 -18
View File
@@ -3590,7 +3590,7 @@ function AutomationRow({
)}
title={record.error || fmtDateTime(record.run_at_ms, locale)}
>
{record.status} · {formatAutomationRunDuration(record.duration_ms)}
{automationRunStatusLabel(record.status, tx)} · {formatAutomationRunDuration(record.duration_ms, locale, tx)}
</span>
))}
</div>
@@ -3767,7 +3767,7 @@ function formatAutomationSchedule(
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return tx("settings.automations.schedule.every", "Every {{duration}}", {
duration: formatAutomationInterval(job.schedule.every_ms),
duration: formatAutomationInterval(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
@@ -3797,27 +3797,59 @@ function formatAutomationLast(
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 = job.state.last_status || tx("settings.automations.last.unknown", "unknown");
const status = automationRunStatusLabel(job.state.last_status, tx);
return `${fmtDateTime(job.state.last_run_at_ms, locale)} · ${status}`;
}
function formatAutomationInterval(ms: number): string {
const units: Array<[string, number]> = [
["d", 86_400_000],
["h", 3_600_000],
["m", 60_000],
["s", 1000],
];
for (const [suffix, size] of units) {
if (ms >= size && ms % size === 0) return `${ms / size}${suffix}`;
}
return `${Math.round(ms / 1000)}s`;
function automationRunStatusLabel(
status: string | null | undefined,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (status === "ok") return tx("settings.automations.history.ok", "Completed");
if (status === "error") return tx("settings.automations.history.error", "Error");
if (status === "skipped") return tx("settings.automations.history.skipped", "Skipped");
return status || tx("settings.automations.last.unknown", "unknown");
}
function formatAutomationRunDuration(ms: number | undefined): string {
if (!ms || ms < 1000) return "<1s";
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.round(ms / 60_000)}m`;
function formatAutomationUnit(
value: number,
unit: Intl.NumberFormatOptions["unit"],
locale: string,
maximumFractionDigits = 0,
): string {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits,
}).format(value);
}
function formatAutomationInterval(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale);
}
const fallbackUnit = ms < 60_000 ? "second" : "minute";
const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000;
return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1);
}
function formatAutomationRunDuration(
ms: number | undefined,
locale: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (!ms || ms < 1000) {
return tx("settings.automations.duration.lessThanSecond", "< 1 second");
}
if (ms < 60_000) return formatAutomationUnit(ms / 1000, "second", locale, 1);
return formatAutomationUnit(ms / 60_000, "minute", locale, 1);
}
function AppsCatalogSettings({
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "Never",
"unknown": "unknown"
},
"history": {
"ok": "Completed",
"error": "Error",
"skipped": "Skipped"
},
"duration": {
"lessThanSecond": "< 1 second"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "Nunca",
"unknown": "desconocido"
},
"history": {
"ok": "Completada",
"error": "Error",
"skipped": "Omitida"
},
"duration": {
"lessThanSecond": "menos de 1 segundo"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "Jamais",
"unknown": "inconnu"
},
"history": {
"ok": "Terminée",
"error": "Erreur",
"skipped": "Ignorée"
},
"duration": {
"lessThanSecond": "moins de 1 seconde"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "Belum pernah",
"unknown": "tidak dikenal"
},
"history": {
"ok": "Selesai",
"error": "Error",
"skipped": "Dilewati"
},
"duration": {
"lessThanSecond": "kurang dari 1 detik"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "未実行",
"unknown": "不明"
},
"history": {
"ok": "完了",
"error": "エラー",
"skipped": "スキップ"
},
"duration": {
"lessThanSecond": "1 秒未満"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "실행된 적 없음",
"unknown": "알 수 없음"
},
"history": {
"ok": "완료",
"error": "오류",
"skipped": "건너뜀"
},
"duration": {
"lessThanSecond": "1초 미만"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "Chưa từng chạy",
"unknown": "không xác định"
},
"history": {
"ok": "Hoàn tất",
"error": "Lỗi",
"skipped": "Đã bỏ qua"
},
"duration": {
"lessThanSecond": "dưới 1 giây"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "从未运行",
"unknown": "未知"
},
"history": {
"ok": "完成",
"error": "错误",
"skipped": "已跳过"
},
"duration": {
"lessThanSecond": "不到 1 秒"
}
},
"oauth": {
+8
View File
@@ -529,6 +529,14 @@
"last": {
"never": "從未執行",
"unknown": "未知"
},
"history": {
"ok": "完成",
"error": "錯誤",
"skipped": "已略過"
},
"duration": {
"lessThanSecond": "不到 1 秒"
}
},
"oauth": {
+41 -2
View File
@@ -405,7 +405,43 @@ describe("App layout", () => {
await i18n.changeLanguage("zh-CN");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [] },
"/api/webui/automations": {
jobs: [
{
id: "job-zh",
name: "每日检查",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: {
message: "检查仓库状态",
kind: "agent_turn",
session_key: "websocket:chat-a",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
last_run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: [
{
run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
status: "ok",
duration_ms: 500,
},
],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "发布准备",
preview: "检查发布阻塞项",
},
},
],
},
});
render(<App />);
@@ -419,7 +455,10 @@ describe("App layout", () => {
expect(screen.getByText("统一查看 cron 提醒、周期性 agent 任务、一次性自动任务和受保护的系统自动任务。")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "刷新" })).toBeInTheDocument();
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.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot");
});
+2
View File
@@ -58,6 +58,8 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.automations.systemTask",
"settings.automations.labels.schedule",
"settings.automations.status.active",
"settings.automations.history.ok",
"settings.automations.duration.lessThanSecond",
"settings.automations.deleteTitle",
"settings.sections.interface",
"settings.sections.localPreferences",