From 43830b7162b4904bea3fc53f6fdd36ec23efcfae Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 13 Jun 2026 21:30:31 +0800 Subject: [PATCH] fix(webui): localize automation runtime labels --- tests/channels/test_websocket_http_routes.py | 16 +++++ .../src/components/settings/SettingsView.tsx | 68 ++++++++++++++----- webui/src/i18n/locales/en/common.json | 8 +++ webui/src/i18n/locales/es/common.json | 8 +++ webui/src/i18n/locales/fr/common.json | 8 +++ webui/src/i18n/locales/id/common.json | 8 +++ webui/src/i18n/locales/ja/common.json | 8 +++ webui/src/i18n/locales/ko/common.json | 8 +++ webui/src/i18n/locales/vi/common.json | 8 +++ webui/src/i18n/locales/zh-CN/common.json | 8 +++ webui/src/i18n/locales/zh-TW/common.json | 8 +++ webui/src/tests/app-layout.test.tsx | 43 +++++++++++- webui/src/tests/i18n.test.tsx | 2 + 13 files changed, 181 insertions(+), 20 deletions(-) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 10d56552..4767f9dd 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -872,11 +872,27 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( by_id = {job["id"]: job for job in disabled.json()["jobs"]} assert by_id[user_job.id]["enabled"] is False + disabled_run = await _http_get( + f"http://127.0.0.1:29932/api/webui/automations/run?id={user_job.id}", + headers=auth, + ) + assert disabled_run.status_code == 409 + protected_delete = await _http_get( "http://127.0.0.1:29932/api/webui/automations/delete?id=heartbeat", headers=auth, ) assert protected_delete.status_code == 403 + protected_disable = await _http_get( + "http://127.0.0.1:29932/api/webui/automations/disable?id=heartbeat", + headers=auth, + ) + assert protected_disable.status_code == 403 + protected_run = await _http_get( + "http://127.0.0.1:29932/api/webui/automations/run?id=heartbeat", + headers=auth, + ) + assert protected_run.status_code == 403 enabled = await _http_get( f"http://127.0.0.1:29932/api/webui/automations/enable?id={user_job.id}", diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index dbfe4947..c8c4fd93 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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)} ))} @@ -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, ): 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, +): 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, +): 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({ diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index fcdee641..04838947 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -529,6 +529,14 @@ "last": { "never": "Never", "unknown": "unknown" + }, + "history": { + "ok": "Completed", + "error": "Error", + "skipped": "Skipped" + }, + "duration": { + "lessThanSecond": "< 1 second" } }, "oauth": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index e0c091b0..c04429ee 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -529,6 +529,14 @@ "last": { "never": "Nunca", "unknown": "desconocido" + }, + "history": { + "ok": "Completada", + "error": "Error", + "skipped": "Omitida" + }, + "duration": { + "lessThanSecond": "menos de 1 segundo" } }, "oauth": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index b788b6ce..36fabcfe 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 68ea891f..522735d6 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 3b0deab4..57a3d0b1 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -529,6 +529,14 @@ "last": { "never": "未実行", "unknown": "不明" + }, + "history": { + "ok": "完了", + "error": "エラー", + "skipped": "スキップ" + }, + "duration": { + "lessThanSecond": "1 秒未満" } }, "oauth": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 8dc5edef..c1acc078 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -529,6 +529,14 @@ "last": { "never": "실행된 적 없음", "unknown": "알 수 없음" + }, + "history": { + "ok": "완료", + "error": "오류", + "skipped": "건너뜀" + }, + "duration": { + "lessThanSecond": "1초 미만" } }, "oauth": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 325c2d8c..1deddbac 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 47ec0c75..d6300380 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -529,6 +529,14 @@ "last": { "never": "从未运行", "unknown": "未知" + }, + "history": { + "ok": "完成", + "error": "错误", + "skipped": "已跳过" + }, + "duration": { + "lessThanSecond": "不到 1 秒" } }, "oauth": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 455865d0..4d1bab31 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -529,6 +529,14 @@ "last": { "never": "從未執行", "unknown": "未知" + }, + "history": { + "ok": "完成", + "error": "錯誤", + "skipped": "已略過" + }, + "duration": { + "lessThanSecond": "不到 1 秒" } }, "oauth": { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f8c1496e..a52099de 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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(); @@ -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"); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 444c7282..a84e0b68 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -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",