i18n: add zh-CN for cron page and validation errors (#29315)

* i18n: add zh-CN for cron page and validation errors

* cron: treat unexpected delivery statuses as unknown

* test(cron): align validation tests with i18n keys

---------

Co-authored-by: 周鹤0668001310 <zhou.he3@xydigit.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
BUGKillerKing
2026-03-01 22:05:51 +08:00
committed by GitHub
parent d0ca02e963
commit 8c98cf05b2
7 changed files with 705 additions and 234 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315)
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.

View File

@@ -120,4 +120,212 @@ export const en: TranslationMap = {
ptBR: "Português (Brazilian Portuguese)",
de: "Deutsch (German)",
},
cron: {
summary: {
enabled: "Enabled",
yes: "Yes",
no: "No",
jobs: "Jobs",
nextWake: "Next wake",
refreshing: "Refreshing...",
refresh: "Refresh",
},
jobs: {
title: "Jobs",
subtitle: "All scheduled jobs stored in the gateway.",
shownOf: "{shown} shown of {total}",
searchJobs: "Search jobs",
searchPlaceholder: "Name, description, or agent",
enabled: "Enabled",
all: "All",
sort: "Sort",
nextRun: "Next run",
recentlyUpdated: "Recently updated",
name: "Name",
direction: "Direction",
ascending: "Ascending",
descending: "Descending",
noMatching: "No matching jobs.",
loading: "Loading...",
loadMore: "Load more jobs",
},
runs: {
title: "Run history",
subtitleAll: "Latest runs across all jobs.",
subtitleJob: "Latest runs for {title}.",
scope: "Scope",
allJobs: "All jobs",
selectedJob: "Selected job",
searchRuns: "Search runs",
searchPlaceholder: "Summary, error, or job",
newestFirst: "Newest first",
oldestFirst: "Oldest first",
status: "Status",
delivery: "Delivery",
clear: "Clear",
allStatuses: "All statuses",
allDelivery: "All delivery",
selectJobHint: "Select a job to inspect run history.",
noMatching: "No matching runs.",
loadMore: "Load more runs",
runStatusOk: "OK",
runStatusError: "Error",
runStatusSkipped: "Skipped",
runStatusUnknown: "Unknown",
deliveryDelivered: "Delivered",
deliveryNotDelivered: "Not delivered",
deliveryUnknown: "Unknown",
deliveryNotRequested: "Not requested",
},
form: {
editJob: "Edit Job",
newJob: "New Job",
updateSubtitle: "Update the selected scheduled job.",
createSubtitle: "Create a scheduled wakeup or agent run.",
required: "Required",
requiredSr: "required",
basics: "Basics",
basicsSub: "Name it, choose the assistant, and set enabled state.",
fieldName: "Name",
description: "Description",
agentId: "Agent ID",
namePlaceholder: "Morning brief",
descriptionPlaceholder: "Optional context for this job",
agentPlaceholder: "main or ops",
agentHelp: "Start typing to pick a known agent, or enter a custom one.",
schedule: "Schedule",
scheduleSub: "Control when this job runs.",
every: "Every",
at: "At",
cronOption: "Cron",
runAt: "Run at",
unit: "Unit",
minutes: "Minutes",
hours: "Hours",
days: "Days",
expression: "Expression",
expressionPlaceholder: "0 7 * * *",
everyAmountPlaceholder: "30",
timezoneOptional: "Timezone (optional)",
timezonePlaceholder: "America/Los_Angeles",
timezoneHelp: "Pick a common timezone or enter any valid IANA timezone.",
jitterHelp: "Need jitter? Use Advanced → Stagger window / Stagger unit.",
execution: "Execution",
executionSub: "Choose when to wake, and what this job should do.",
session: "Session",
main: "Main",
isolated: "Isolated",
sessionHelp: "Main posts a system event. Isolated runs a dedicated agent turn.",
wakeMode: "Wake mode",
now: "Now",
nextHeartbeat: "Next heartbeat",
wakeModeHelp: "Now triggers immediately. Next heartbeat waits for the next cycle.",
payloadKind: "What should run?",
systemEvent: "Post message to main timeline",
agentTurn: "Run assistant task (isolated)",
systemEventHelp:
"Sends your text to the gateway main timeline (good for reminders/triggers).",
agentTurnHelp: "Starts an assistant run in its own session using your prompt.",
timeoutSeconds: "Timeout (seconds)",
timeoutPlaceholder: "Optional, e.g. 90",
timeoutHelp:
"Optional. Leave blank to use the gateway default timeout behavior for this run.",
mainTimelineMessage: "Main timeline message",
assistantTaskPrompt: "Assistant task prompt",
deliverySection: "Delivery",
deliverySub: "Choose where run summaries are sent.",
resultDelivery: "Result delivery",
announceDefault: "Announce summary (default)",
webhookPost: "Webhook POST",
noneInternal: "None (internal)",
deliveryHelp: "Announce posts a summary to chat. None keeps execution internal.",
webhookUrl: "Webhook URL",
channel: "Channel",
webhookPlaceholder: "https://example.com/cron",
channelHelp: "Choose which connected channel receives the summary.",
webhookHelp: "Send run summaries to a webhook endpoint.",
to: "To",
toPlaceholder: "+1555... or chat id",
toHelp: "Optional recipient override (chat id, phone, or user id).",
advanced: "Advanced",
advancedHelp:
"Optional overrides for delivery guarantees, schedule jitter, and model controls.",
deleteAfterRun: "Delete after run",
deleteAfterRunHelp: "Best for one-shot reminders that should auto-clean up.",
clearAgentOverride: "Clear agent override",
clearAgentHelp: "Force this job to use the gateway default assistant.",
exactTiming: "Exact timing (no stagger)",
exactTimingHelp: "Run on exact cron boundaries with no spread.",
staggerWindow: "Stagger window",
staggerUnit: "Stagger unit",
staggerPlaceholder: "30",
seconds: "Seconds",
model: "Model",
modelPlaceholder: "openai/gpt-5.2",
modelHelp: "Start typing to pick a known model, or enter a custom one.",
thinking: "Thinking",
thinkingPlaceholder: "low",
thinkingHelp: "Use a suggested level or enter a provider-specific value.",
bestEffortDelivery: "Best effort delivery",
bestEffortHelp: "Do not fail the job if delivery itself fails.",
cantAddYet: "Can't add job yet",
fillRequired: "Fill the required fields below to enable submit.",
fixFields: "Fix {count} field to continue.",
fixFieldsPlural: "Fix {count} fields to continue.",
saving: "Saving...",
saveChanges: "Save changes",
addJob: "Add job",
cancel: "Cancel",
},
jobList: {
allJobs: "all jobs",
selectJob: "(select a job)",
enabled: "enabled",
disabled: "disabled",
edit: "Edit",
clone: "Clone",
disable: "Disable",
enable: "Enable",
run: "Run",
history: "History",
remove: "Remove",
},
jobDetail: {
system: "System",
prompt: "Prompt",
delivery: "Delivery",
agent: "Agent",
},
jobState: {
status: "Status",
next: "Next",
last: "Last",
},
runEntry: {
noSummary: "No summary.",
runAt: "Run at",
openRunChat: "Open run chat",
next: "Next {rel}",
due: "Due {rel}",
},
errors: {
nameRequired: "Name is required.",
scheduleAtInvalid: "Enter a valid date/time.",
everyAmountInvalid: "Interval must be greater than 0.",
cronExprRequired: "Cron expression is required.",
staggerAmountInvalid: "Stagger must be greater than 0.",
systemTextRequired: "System text is required.",
agentMessageRequired: "Agent message is required.",
timeoutInvalid: "If set, timeout must be greater than 0 seconds.",
webhookUrlRequired: "Webhook URL is required.",
webhookUrlInvalid: "Webhook URL must start with http:// or https://.",
invalidRunTime: "Invalid run time.",
invalidIntervalAmount: "Invalid interval amount.",
cronExprRequiredShort: "Cron expression required.",
invalidStaggerAmount: "Invalid stagger amount.",
systemEventTextRequired: "System event text required.",
agentMessageRequiredShort: "Agent message required.",
nameRequiredShort: "Name required.",
},
},
};

View File

@@ -119,4 +119,209 @@ export const zh_CN: TranslationMap = {
ptBR: "Português (巴西葡萄牙语)",
de: "Deutsch (德语)",
},
cron: {
summary: {
enabled: "已启用",
yes: "是",
no: "否",
jobs: "任务数",
nextWake: "下次唤醒",
refreshing: "刷新中...",
refresh: "刷新",
},
jobs: {
title: "任务列表",
subtitle: "网关中存储的所有定时任务。",
shownOf: "显示 {shown} / 共 {total}",
searchJobs: "搜索任务",
searchPlaceholder: "名称、描述或代理",
enabled: "启用状态",
all: "全部",
sort: "排序",
nextRun: "下次运行",
recentlyUpdated: "最近更新",
name: "名称",
direction: "方向",
ascending: "升序",
descending: "降序",
noMatching: "没有匹配的任务。",
loading: "加载中...",
loadMore: "加载更多任务",
},
runs: {
title: "运行历史",
subtitleAll: "所有任务的最新运行记录。",
subtitleJob: "{title} 的最新运行记录。",
scope: "范围",
allJobs: "所有任务",
selectedJob: "已选任务",
searchRuns: "搜索运行",
searchPlaceholder: "摘要、错误或任务",
newestFirst: "最新优先",
oldestFirst: "最早优先",
status: "状态",
delivery: "投递",
clear: "清除",
allStatuses: "全部状态",
allDelivery: "全部投递",
selectJobHint: "请选择一个任务以查看运行历史。",
noMatching: "没有匹配的运行记录。",
loadMore: "加载更多运行",
runStatusOk: "成功",
runStatusError: "错误",
runStatusSkipped: "已跳过",
runStatusUnknown: "未知",
deliveryDelivered: "已投递",
deliveryNotDelivered: "未投递",
deliveryUnknown: "未知",
deliveryNotRequested: "未请求",
},
form: {
editJob: "编辑任务",
newJob: "新建任务",
updateSubtitle: "更新所选定时任务。",
createSubtitle: "创建定时唤醒或代理运行。",
required: "必填",
requiredSr: "必填",
basics: "基本信息",
basicsSub: "命名、选择助手并设置启用状态。",
fieldName: "名称",
description: "描述",
agentId: "代理 ID",
namePlaceholder: "晨间简报",
descriptionPlaceholder: "此任务的可选说明",
agentPlaceholder: "main 或 ops",
agentHelp: "输入以选择已知代理,或输入自定义 ID。",
schedule: "调度",
scheduleSub: "控制任务运行时间。",
every: "每隔",
at: "指定时间",
cronOption: "Cron",
runAt: "运行时间",
unit: "单位",
minutes: "分钟",
hours: "小时",
days: "天",
expression: "表达式",
expressionPlaceholder: "0 7 * * *",
everyAmountPlaceholder: "30",
timezoneOptional: "时区(可选)",
timezonePlaceholder: "America/Los_Angeles",
timezoneHelp: "选择常用时区或输入有效的 IANA 时区。",
jitterHelp: "需要抖动?使用高级 → 抖动窗口 / 抖动单位。",
execution: "执行",
executionSub: "选择唤醒时机和任务执行内容。",
session: "会话",
main: "主会话",
isolated: "隔离会话",
sessionHelp: "主会话发布系统事件。隔离会话运行独立的代理轮次。",
wakeMode: "唤醒模式",
now: "立即",
nextHeartbeat: "下次心跳",
wakeModeHelp: "立即模式立即触发。下次心跳等待下一个周期。",
payloadKind: "执行内容",
systemEvent: "发布消息到主时间线",
agentTurn: "运行助手任务(隔离)",
systemEventHelp: "将文本发送到网关主时间线(适用于提醒/触发)。",
agentTurnHelp: "使用您的提示在独立会话中启动助手运行。",
timeoutSeconds: "超时(秒)",
timeoutPlaceholder: "可选,如 90",
timeoutHelp: "可选。留空以使用网关默认超时行为。",
mainTimelineMessage: "主时间线消息",
assistantTaskPrompt: "助手任务提示",
deliverySection: "投递",
deliverySub: "选择运行摘要的发送位置。",
resultDelivery: "结果投递",
announceDefault: "发布摘要(默认)",
webhookPost: "Webhook POST",
noneInternal: "无(仅内部)",
deliveryHelp: "发布将摘要发送到聊天。无保持执行仅内部。",
webhookUrl: "Webhook URL",
channel: "频道",
webhookPlaceholder: "https://example.com/cron",
channelHelp: "选择接收摘要的已连接频道。",
webhookHelp: "将运行摘要发送到 Webhook 端点。",
to: "收件人",
toPlaceholder: "+1555... 或聊天 ID",
toHelp: "可选收件人覆盖(聊天 ID、电话或用户 ID。",
advanced: "高级",
advancedHelp: "投递保证、调度抖动和模型控制的可选覆盖。",
deleteAfterRun: "运行后删除",
deleteAfterRunHelp: "适用于应自动清理的一次性提醒。",
clearAgentOverride: "清除代理覆盖",
clearAgentHelp: "强制此任务使用网关默认助手。",
exactTiming: "精确时间(无抖动)",
exactTimingHelp: "在精确的 cron 边界运行,无分散。",
staggerWindow: "抖动窗口",
staggerUnit: "抖动单位",
staggerPlaceholder: "30",
seconds: "秒",
model: "模型",
modelPlaceholder: "openai/gpt-5.2",
modelHelp: "输入以选择已知模型,或输入自定义模型。",
thinking: "思考",
thinkingPlaceholder: "low",
thinkingHelp: "使用建议级别或输入提供商特定值。",
bestEffortDelivery: "尽力投递",
bestEffortHelp: "投递失败时不使任务失败。",
cantAddYet: "暂无法添加任务",
fillRequired: "填写下方必填项以启用提交。",
fixFields: "修复 {count} 个字段以继续。",
fixFieldsPlural: "修复 {count} 个字段以继续。",
saving: "保存中...",
saveChanges: "保存更改",
addJob: "添加任务",
cancel: "取消",
},
jobList: {
allJobs: "所有任务",
selectJob: "(选择任务)",
enabled: "已启用",
disabled: "已禁用",
edit: "编辑",
clone: "克隆",
disable: "禁用",
enable: "启用",
run: "运行",
history: "历史",
remove: "删除",
},
jobDetail: {
system: "系统",
prompt: "提示",
delivery: "投递",
agent: "代理",
},
jobState: {
status: "状态",
next: "下次",
last: "上次",
},
runEntry: {
noSummary: "无摘要。",
runAt: "运行于",
openRunChat: "打开运行聊天",
next: "下次 {rel}",
due: "到期 {rel}",
},
errors: {
nameRequired: "名称为必填项。",
scheduleAtInvalid: "请输入有效的日期/时间。",
everyAmountInvalid: "间隔必须大于 0。",
cronExprRequired: "Cron 表达式为必填项。",
staggerAmountInvalid: "抖动值必须大于 0。",
systemTextRequired: "系统文本为必填项。",
agentMessageRequired: "代理消息为必填项。",
timeoutInvalid: "若设置超时,必须大于 0 秒。",
webhookUrlRequired: "Webhook URL 为必填项。",
webhookUrlInvalid: "Webhook URL 必须以 http:// 或 https:// 开头。",
invalidRunTime: "无效的运行时间。",
invalidIntervalAmount: "无效的间隔值。",
cronExprRequiredShort: "Cron 表达式为必填。",
invalidStaggerAmount: "无效的抖动值。",
systemEventTextRequired: "系统事件文本为必填。",
agentMessageRequiredShort: "代理消息为必填。",
nameRequiredShort: "名称为必填。",
},
},
};

View File

@@ -343,11 +343,11 @@ describe("cron controller", () => {
deliveryMode: "webhook",
deliveryTo: "ftp://bad",
});
expect(errors.name).toBeDefined();
expect(errors.cronExpr).toBeDefined();
expect(errors.payloadText).toBeDefined();
expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds.");
expect(errors.deliveryTo).toBeDefined();
expect(errors.name).toBe("cron.errors.nameRequired");
expect(errors.cronExpr).toBe("cron.errors.cronExprRequired");
expect(errors.payloadText).toBe("cron.errors.agentMessageRequired");
expect(errors.timeoutSeconds).toBe("cron.errors.timeoutInvalid");
expect(errors.deliveryTo).toBe("cron.errors.webhookUrlInvalid");
});
it("blocks add/update submit when validation errors exist", async () => {

View File

@@ -1,3 +1,4 @@
import { t } from "../../i18n/index.ts";
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
import { toNumber } from "../format.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
@@ -95,28 +96,28 @@ export function normalizeCronFormState(form: CronFormState): CronFormState {
export function validateCronForm(form: CronFormState): CronFieldErrors {
const errors: CronFieldErrors = {};
if (!form.name.trim()) {
errors.name = "Name is required.";
errors.name = "cron.errors.nameRequired";
}
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) {
errors.scheduleAt = "Enter a valid date/time.";
errors.scheduleAt = "cron.errors.scheduleAtInvalid";
}
} else if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) {
errors.everyAmount = "Interval must be greater than 0.";
errors.everyAmount = "cron.errors.everyAmountInvalid";
}
} else {
if (!form.cronExpr.trim()) {
errors.cronExpr = "Cron expression is required.";
errors.cronExpr = "cron.errors.cronExprRequired";
}
if (!form.scheduleExact) {
const staggerAmount = form.staggerAmount.trim();
if (staggerAmount) {
const stagger = toNumber(staggerAmount, 0);
if (stagger <= 0) {
errors.staggerAmount = "Stagger must be greater than 0.";
errors.staggerAmount = "cron.errors.staggerAmountInvalid";
}
}
}
@@ -124,24 +125,24 @@ export function validateCronForm(form: CronFormState): CronFieldErrors {
if (!form.payloadText.trim()) {
errors.payloadText =
form.payloadKind === "systemEvent"
? "System text is required."
: "Agent message is required.";
? "cron.errors.systemTextRequired"
: "cron.errors.agentMessageRequired";
}
if (form.payloadKind === "agentTurn") {
const timeoutRaw = form.timeoutSeconds.trim();
if (timeoutRaw) {
const timeout = toNumber(timeoutRaw, 0);
if (timeout <= 0) {
errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds.";
errors.timeoutSeconds = "cron.errors.timeoutInvalid";
}
}
}
if (form.deliveryMode === "webhook") {
const target = form.deliveryTo.trim();
if (!target) {
errors.deliveryTo = "Webhook URL is required.";
errors.deliveryTo = "cron.errors.webhookUrlRequired";
} else if (!/^https?:\/\//i.test(target)) {
errors.deliveryTo = "Webhook URL must start with http:// or https://.";
errors.deliveryTo = "cron.errors.webhookUrlInvalid";
}
}
return errors;
@@ -428,14 +429,14 @@ export function buildCronSchedule(form: CronFormState) {
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) {
throw new Error("Invalid run time.");
throw new Error(t("cron.errors.invalidRunTime"));
}
return { kind: "at" as const, at: new Date(ms).toISOString() };
}
if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) {
throw new Error("Invalid interval amount.");
throw new Error(t("cron.errors.invalidIntervalAmount"));
}
const unit = form.everyUnit;
const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
@@ -443,7 +444,7 @@ export function buildCronSchedule(form: CronFormState) {
}
const expr = form.cronExpr.trim();
if (!expr) {
throw new Error("Cron expression required.");
throw new Error(t("cron.errors.cronExprRequiredShort"));
}
if (form.scheduleExact) {
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 };
@@ -454,7 +455,7 @@ export function buildCronSchedule(form: CronFormState) {
}
const staggerValue = toNumber(staggerAmount, 0);
if (staggerValue <= 0) {
throw new Error("Invalid stagger amount.");
throw new Error(t("cron.errors.invalidStaggerAmount"));
}
const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000;
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs };
@@ -464,13 +465,13 @@ export function buildCronPayload(form: CronFormState) {
if (form.payloadKind === "systemEvent") {
const text = form.payloadText.trim();
if (!text) {
throw new Error("System event text required.");
throw new Error(t("cron.errors.systemEventTextRequired"));
}
return { kind: "systemEvent" as const, text };
}
const message = form.payloadText.trim();
if (!message) {
throw new Error("Agent message required.");
throw new Error(t("cron.errors.agentMessageRequiredShort"));
}
const payload: {
kind: "agentTurn";
@@ -540,7 +541,7 @@ export async function addCronJob(state: CronState) {
delivery,
};
if (!job.name) {
throw new Error("Name required.");
throw new Error(t("cron.errors.nameRequiredShort"));
}
if (state.cronEditingJobId) {
await state.client.request("cron.update", {

View File

@@ -491,9 +491,9 @@ describe("cron view", () => {
payloadText: "",
},
fieldErrors: {
name: "Name is required.",
cronExpr: "Cron expression is required.",
payloadText: "Agent message is required.",
name: "cron.errors.nameRequired",
cronExpr: "cron.errors.cronExprRequired",
payloadText: "cron.errors.agentMessageRequired",
},
canSubmit: false,
}),
@@ -527,9 +527,9 @@ describe("cron view", () => {
payloadText: "",
},
fieldErrors: {
name: "Name is required.",
everyAmount: "Interval must be greater than 0.",
payloadText: "Agent message is required.",
name: "cron.errors.nameRequired",
everyAmount: "cron.errors.everyAmountInvalid",
payloadText: "cron.errors.agentMessageRequired",
},
canSubmit: false,
}),

File diff suppressed because it is too large Load Diff