Add zh/en i18n and follow DSH locale.bind for UI.

Wire API/delivery/tool strings through lib/i18n.js, and make the sidebar use ctx.locale.bind plus locale/change so English host preference no longer stays frozen on Chinese.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-09 22:10:07 +08:00
parent 51deaf037b
commit b35bfde557
12 changed files with 373 additions and 37 deletions

View file

@ -186,6 +186,43 @@ window.__ModuleLoader__.load({
const LOCALE_NS = 'settings.dshCronTasks'
const TITLE_PREFIX = '定时任务 · '
const TITLE_PREFIX_EN = 'Scheduled tasks · '
/** Live translator from ctx.locale.bind; falls back until apply() wires it. */
let translate = (key) => en[key] || zh[key] || key
function uiT(key) {
try { return translate(key) } catch { return en[key] || zh[key] || key }
}
function resolveUiLang(ctx) {
const snap = typeof ctx?.locale?.getSnapshot === 'function' ? ctx.locale.getSnapshot() : null
const raw = snap?.active
|| snap?.locale
|| snap?.preference
|| ctx?.locale?.active
|| (typeof document !== 'undefined' ? document.documentElement?.lang : '')
|| 'en'
return String(raw).toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
function makeTranslator(ctx) {
if (typeof ctx?.locale?.bind === 'function') {
try {
const bound = ctx.locale.bind(LOCALE_NS)
if (typeof bound === 'function') {
return (key, params) => {
try {
const out = bound(key, params)
if (out != null && out !== key) return out
} catch { /* fall through */ }
const dict = resolveUiLang(ctx) === 'zh' ? zh : en
return dict[key] || en[key] || zh[key] || key
}
}
} catch { /* fall through */ }
}
return (key) => {
const dict = resolveUiLang(ctx) === 'zh' ? zh : en
return dict[key] || en[key] || zh[key] || key
}
}
const listSnapshot = {
jobs: [], runs: [], workspaces: [],
catalog: { groups: [], current: null },
@ -365,6 +402,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。',
mirrorToSession: '镜像回原会话',
mirrorToSessionHint: '开启后把每次运行摘要写入创建时的 WhatsApp/Web 会话(不新开模型轮次)。默认关闭。',
loginRequired: '登录后才能使用定时任务',
runNoSession: '这次运行没有可打开的会话',
runOpenFailed: '打不开这次对话,会话可能已被删除',
}
const en = {
title: 'Scheduled tasks',
@ -408,6 +448,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.',
mirrorToSession: 'Mirror into origin session',
mirrorToSessionHint: 'When enabled, append each run summary into the creating WhatsApp/Web session (no new model turn). Off by default.',
loginRequired: 'Sign in to use scheduled tasks',
runNoSession: 'This run has no session to open',
runOpenFailed: 'Could not open this chat; the session may have been deleted',
}
function normalizePrefs(value) {
@ -487,7 +530,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
function SettingsCard(props) {
const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
const t = typeof props.t === 'function'
? props.t
: (key) => (resolveUiLang() === 'zh' ? zh : en)[key] || en[key] || zh[key] || key
const state = props.useCard ? props.useCard((s) => s) : {
writable: false, dirty: false, saving: false, failed: false, value: DEFAULTS,
}
@ -551,14 +596,16 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
async function api(path, options = {}) {
if (!hasUdsLoginCookie() && path !== '/health') {
throw new Error('登录后才能使用定时任务')
throw new Error(uiT('loginRequired'))
}
const response = await fetch(`${API}${path}`, {
...options,
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
})
const body = await response.json().catch(() => ({}))
if (!response.ok || body.ok === false) throw new Error(body.error || `HTTP ${response.status}`)
if (!response.ok || body.ok === false) {
throw new Error(body.message || body.error || `HTTP ${response.status}`)
}
return body
}
@ -822,7 +869,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const ordered = [...groups.values()].sort((a, b) => {
if (a.key === '__unassigned__') return 1
if (b.key === '__unassigned__') return -1
return String(a.label).localeCompare(String(b.label), 'zh')
return String(a.label).localeCompare(String(b.label), resolveUiLang() === 'zh' ? 'zh' : 'en')
})
return ordered.map((group) => {
const folderId = `owner:${group.key}`
@ -1181,7 +1228,17 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null })
const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false })
const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body))
const [localeTick, setLocaleTick] = useState(0)
const skipAutoSelect = useRef(false)
void localeTick
useEffect(() => {
const bump = () => setLocaleTick((n) => n + 1)
const offs = []
window.addEventListener('dsh-ct-locale', bump)
offs.push(() => window.removeEventListener('dsh-ct-locale', bump))
return () => { for (const off of offs) off() }
}, [])
useEffect(() => {
const bind = () => {
@ -1366,7 +1423,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
expandJobPath(jobId)
setError('')
if (!run.sessionId) {
setError('这次运行没有可打开的会话')
setError(uiT('runNoSession'))
return
}
try {
@ -1375,7 +1432,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
setError(err instanceof Error ? err.message : String(err))
}
const opened = await faces.openSession?.(run.sessionId)
if (opened === false) setError('打不开这次对话,会话可能已被删除')
if (opened === false) setError(uiT('runOpenFailed'))
}
function currentSessionId(faces) {
@ -1522,11 +1579,15 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|| value.startsWith('运维定时')
|| value.startsWith(TITLE_PREFIX.trim())
|| value.includes(TITLE_PREFIX)
|| value.startsWith(TITLE_PREFIX_EN.trim())
|| value.includes(TITLE_PREFIX_EN)
|| /^scheduled tasks/i.test(value)
}
function workspaceTitleFromCronFork(title) {
const value = String(title || '').trim()
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
if (value.startsWith(TITLE_PREFIX_EN)) return value.slice(TITLE_PREFIX_EN.length).trim() || value
if (value.startsWith('运维定时')) {
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
@ -1535,6 +1596,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
if (/^scheduled tasks(?:\s*·\s*|\s+)/i.test(value)) {
const stripped = value.replace(/^scheduled tasks(?:\s*·\s*|\s+)/i, '').trim()
return stripped || value
}
return value
}
@ -1547,7 +1612,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务') || title.startsWith('运维定时')
const looksCron = title.startsWith(TITLE_PREFIX)
|| title.startsWith(TITLE_PREFIX_EN)
|| title.startsWith('定时任务')
|| title.startsWith('运维定时')
|| /^scheduled tasks/i.test(title)
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}
@ -1653,7 +1722,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}
function installChrome(t, faces) {
function installChrome(t, faces, ctx) {
let entry = null
let listRoot = null
let appRoot = null
@ -1672,6 +1741,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
})
}
function syncEntryCopy() {
if (!entry) return
const label = entry.querySelector('span')
if (label) label.textContent = cronOn ? t('backToWorkspace') : t('entry')
entry.setAttribute('aria-label', cronOn ? t('backLabel') : t('entryLabel'))
}
function onLocaleChange() {
syncEntryCopy()
window.dispatchEvent(new CustomEvent('dsh-ct-locale'))
if (appRoot) appRoot.render(h(CronApp, { t, faces }))
}
const place = () => {
const found = findNewSessionButton()
if (!found) return
@ -1722,9 +1804,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
entry.append(icon, label)
}
syncEntryLook(entry, found)
const label = entry.querySelector('span')
if (label) label.textContent = cronOn ? t('backToWorkspace') : t('entry')
entry.setAttribute('aria-label', cronOn ? t('backLabel') : t('entryLabel'))
syncEntryCopy()
if (anchor.parentNode && anchor.nextSibling !== entry) {
if (anchor.nextSibling) anchor.parentNode.insertBefore(entry, anchor.nextSibling)
else anchor.parentNode.appendChild(entry)
@ -1781,6 +1861,24 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const onAuth = () => { place() }
window.addEventListener('focus', onAuth)
window.addEventListener('uds-auth-changed', onAuth)
const localeOffs = []
try {
const off = ctx?.on?.('locale/change', onLocaleChange)
if (typeof off === 'function') localeOffs.push(off)
} catch { /* optional */ }
try {
const off = ctx?.locale?.subscribe?.(onLocaleChange)
if (typeof off === 'function') localeOffs.push(off)
} catch { /* optional */ }
// document.lang flips with DSH syncDocumentLanguage — catch hosts without locale events.
let lastLang = typeof document !== 'undefined' ? document.documentElement?.lang : ''
const langTimer = setInterval(() => {
const next = document.documentElement?.lang || ''
if (next !== lastLang) {
lastLang = next
onLocaleChange()
}
}, 1000)
let boots = 0
const boot = () => {
boots += 1
@ -1792,6 +1890,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return () => {
window.removeEventListener('focus', onAuth)
window.removeEventListener('uds-auth-changed', onAuth)
for (const off of localeOffs) off()
clearInterval(langTimer)
observer.disconnect()
clearInterval(timer)
if (placeRaf) cancelAnimationFrame(placeRaf)
@ -1975,10 +2075,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}, 'dsh-ops-cron: fork promote')
ctx.effect(() => {
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
const t = (key) => dict[key] || zh[key] || key
return installChrome(t, faces)
translate = makeTranslator(ctx)
return installChrome(translate, faces, ctx)
}, 'dsh-ops-cron: chrome')
}

View file

@ -3,6 +3,8 @@
* Group jobs may also pin mentionJid so delivery @s the creator.
*/
import { DEFAULT_LOCALE, t } from './i18n.js'
export function normalizeDelivery(input = {}) {
const src = input && typeof input === 'object' ? input : {}
const kind = String(src.kind || 'dsh').trim().toLowerCase()
@ -181,7 +183,7 @@ export function deliveryDraftFromPeer(peer) {
// Non-WhatsApp peers are not auto-materialized yet.
return null
}
const name = String(peer.label || (isGroup ? 'WhatsApp 群聊' : 'WhatsApp 私聊')).trim().slice(0, 80)
const name = String(peer.label || (isGroup ? t('delivery.wa_group') : t('delivery.wa_dm'))).trim().slice(0, 80)
const targetId = isGroup
? `auto-group-${slugId(routeJid, 'chat')}`
: `auto-dm-${slugId(peer.phone || routeJid, 'user')}`
@ -427,11 +429,13 @@ const IM_MAX_CHARS = 3500
* Shared body for IM delivery and session mirror (without @mention prefix).
* @param {object} job
* @param {string} [summary]
* @param {'zh' | 'en' | string} [locale]
*/
export function formatRunResultBody(job, summary) {
const text = String(summary || '').trim() || `(定时任务「${job?.name || ''}」已完成,无文本摘要)`
export function formatRunResultBody(job, summary, locale = DEFAULT_LOCALE) {
const name = job?.name || ''
const text = String(summary || '').trim() || t('delivery.empty_summary', locale, { name })
const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text
return `【定时任务 · ${job?.name || ''}】\n${clipped}`
return `${t('delivery.header', locale, { name })}\n${clipped}`
}
/**

View file

@ -9,6 +9,7 @@ import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
import { deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeOrigin } from './delivery.js'
import { apiError, resolveLocale } from './i18n.js'
import { workspaceVisibleIds } from './isolation.js'
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
import {
@ -148,8 +149,9 @@ async function resolveBrowserIdentity(request, getUdsAuth) {
* @returns {Promise<object|null>} identity or null after writing error response
*/
async function requireIdentity(request, write, getUdsAuth) {
const locale = resolveLocale(request)
if (!isTrustedApiRequest(request)) {
write(403, { ok: false, error: 'forbidden' })
write(403, apiError('forbidden', locale))
return null
}
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
@ -159,10 +161,10 @@ async function requireIdentity(request, write, getUdsAuth) {
}
const identity = await resolveBrowserIdentity(request, getUdsAuth)
if (!identity?.empNo) {
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
write(401, apiError('login_required', locale))
return null
}
return { ...identity, mode: 'multi' }
return { ...identity, mode: 'multi', lang: identity.lang || locale }
}
function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
@ -740,7 +742,7 @@ export function createHostService(options = {}) {
const runId = decodeURIComponent(openMatch[1])
const state = await snapshot()
const run = (state.runs || []).find((row) => row.id === runId)
if (!run?.sessionId) return write(404, { ok: false, error: 'run not found' })
if (!run?.sessionId) return write(404, apiError('run_not_found', resolveLocale(req, identity)))
assertCanAccessJob(getJob(state, run.jobId), identity)
let unarchived = false
if (typeof sessionPort?.revealSession === 'function') {
@ -755,7 +757,7 @@ export function createHostService(options = {}) {
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const sessionId = decodeURIComponent(adoptMatch[1])
if (!sessionId) return write(400, { ok: false, error: 'sessionId required' })
if (!sessionId) return write(400, apiError('session_id_required', resolveLocale(req, identity)))
if (typeof sessionPort?.adoptSession !== 'function') {
return write(200, { ok: false, attached: false, sessionId })
}
@ -810,16 +812,19 @@ export function createHostService(options = {}) {
return
}
write(404, { ok: false, error: 'not found' })
write(404, apiError('not_found', resolveLocale(req)))
} catch (error) {
const locale = resolveLocale(req)
const code = error && error.code
if (code === 'NOT_FOUND') return write(404, { ok: false, error: error.message })
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
if (code === 'NOT_FOUND') return write(404, { ...apiError('not_found', locale), error: error.message || 'not_found' })
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD') {
return write(400, { ok: false, error: error.message, code })
return write(400, { ok: false, error: error.message, code, message: error.message })
}
if (code === 'PAYLOAD_TOO_LARGE') return write(413, { ok: false, error: 'payload too large' })
write(500, { ok: false, error: error instanceof Error ? error.message : 'internal error' })
if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale))
write(500, {
...apiError('internal', locale),
error: error instanceof Error ? error.message : 'internal',
})
}
}

126
lib/i18n.js Normal file
View file

@ -0,0 +1,126 @@
/**
* dsh-ops-cron i18n — API / delivery / tool card strings.
* UI sidebar+settings strings live in lib/client.js (DSH locale.register shape).
* Locales: zh (default), en.
*/
export const DEFAULT_LOCALE = 'zh'
/** Canonical session title prefix (stored on run sessions). */
export const TITLE_PREFIX = '定时任务 · '
/** Alternate English prefix — recognized when stripping / matching. */
export const TITLE_PREFIX_EN = 'Scheduled tasks · '
/** @type {Record<string, Record<string, string>>} */
export const MESSAGES = {
zh: {
'err.login_required': '登录后才能使用定时任务',
'err.forbidden': '禁止访问',
'err.not_found': '未找到',
'err.run_not_found': '运行记录不存在',
'err.session_id_required': '需要 sessionId',
'err.payload_too_large': '请求体过大',
'err.internal': '内部错误',
'delivery.empty_summary': '(定时任务「{name}」已完成,无文本摘要)',
'delivery.header': '【定时任务 · {name}】',
'delivery.wa_group': 'WhatsApp 群聊',
'delivery.wa_dm': 'WhatsApp 私聊',
'tool.create': '创建定时任务',
'tool.list': '列出定时任务',
'tool.pause': '暂停定时任务',
'tool.resume': '恢复定时任务',
'tool.delete': '删除定时任务',
},
en: {
'err.login_required': 'Sign in to use scheduled tasks',
'err.forbidden': 'Forbidden',
'err.not_found': 'Not found',
'err.run_not_found': 'Run not found',
'err.session_id_required': 'sessionId required',
'err.payload_too_large': 'Payload too large',
'err.internal': 'Internal error',
'delivery.empty_summary': '(Scheduled task "{name}" finished with no text summary)',
'delivery.header': '[Scheduled tasks · {name}]',
'delivery.wa_group': 'WhatsApp group',
'delivery.wa_dm': 'WhatsApp DM',
'tool.create': 'Create scheduled task',
'tool.list': 'List scheduled tasks',
'tool.pause': 'Pause scheduled task',
'tool.resume': 'Resume scheduled task',
'tool.delete': 'Delete scheduled task',
},
}
/**
* @param {unknown} lang
* @returns {'zh' | 'en'}
*/
export function normalizeLocale(lang) {
const raw = String(lang || '').trim().toLowerCase()
if (!raw) return DEFAULT_LOCALE
if (raw.startsWith('en')) return 'en'
if (raw.startsWith('zh')) return DEFAULT_LOCALE
return DEFAULT_LOCALE
}
/**
* @param {string} key
* @param {'zh' | 'en' | string} [locale]
* @param {Record<string, string | number>} [vars]
*/
export function t(key, locale = DEFAULT_LOCALE, vars = {}) {
const loc = normalizeLocale(locale)
const table = MESSAGES[loc] || MESSAGES[DEFAULT_LOCALE]
let text = table[key] || MESSAGES[DEFAULT_LOCALE][key] || key
for (const [k, v] of Object.entries(vars || {})) {
text = text.split('{' + k + '}').join(String(v))
}
return text
}
function parseCookie(header, name) {
if (!header) return null
const prefix = name + '='
for (const part of String(header).split(';')) {
const v = part.trim()
if (v.startsWith(prefix)) {
try { return decodeURIComponent(v.slice(prefix.length)) } catch { return v.slice(prefix.length) }
}
}
return null
}
/**
* Resolve locale from HTTP request + optional identity.lang.
* @param {import('node:http').IncomingMessage | null | undefined} req
* @param {{ lang?: string } | null | undefined} [identity]
*/
export function resolveLocale(req, identity) {
const headers = req?.headers || {}
const cookie = headers.cookie || ''
const fromIdentity = identity?.lang
const fromHeader = headers['x-lang-id'] || headers['X-Lang-Id']
const fromCookie = parseCookie(cookie, 'PORTALSSOLanguage')
|| parseCookie(cookie, 'ZTEDPGSSOLanguage')
const accept = String(headers['accept-language'] || '').split(',')[0]
return normalizeLocale(fromIdentity || fromHeader || fromCookie || accept || DEFAULT_LOCALE)
}
/**
* API error payload: stable `error` code + localized `message`.
* @param {string} code
* @param {'zh' | 'en' | string} locale
* @param {Record<string, string | number>} [vars]
*/
export function apiError(code, locale, vars) {
const key = code.startsWith('err.') ? code : 'err.' + code
return {
ok: false,
error: code.startsWith('err.') ? code.slice(4) : code,
message: t(key, locale, vars),
}
}

9
lib/index.d.ts vendored
View file

@ -59,6 +59,13 @@ export function resolveCreateOrigin(args?: object, exec?: object, deps?: object)
export function resolveMirrorSession(job: object, deps?: object): Promise<{ sessionId: string, via: string } | null>
export function resolveCallerPeer(exec?: object, dshIm?: object): Promise<object | null>
export function jobVisibleToPeer(job: object, peer: object): boolean
export function formatRunResultBody(job: object, summary?: string): string
export function formatRunResultBody(job: object, summary?: string, locale?: string): string
export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise<object>
export function mirrorRunToSession(job: object, summary?: string, deps?: object): Promise<object>
export const DEFAULT_LOCALE: 'zh'
export const TITLE_PREFIX_EN: string
export const MESSAGES: Record<string, Record<string, string>>
export function normalizeLocale(lang?: unknown): 'zh' | 'en'
export function resolveLocale(req?: object | null, identity?: { lang?: string } | null): 'zh' | 'en'
export function t(key: string, locale?: string, vars?: Record<string, string | number>): string
export function apiError(code: string, locale?: string, vars?: Record<string, string | number>): { ok: false, error: string, message: string }

View file

@ -74,6 +74,15 @@ export {
resolveCreateOrigin,
resolveMirrorSession,
} from './delivery.js'
export {
DEFAULT_LOCALE,
MESSAGES,
TITLE_PREFIX_EN,
apiError,
normalizeLocale,
resolveLocale,
t,
} from './i18n.js'
function loadPkg(id) {
const homes = [

View file

@ -6,6 +6,7 @@
export const HIDDEN_REASON = 'cron-run'
export const TITLE_PREFIX = '定时任务 · '
export const TITLE_PREFIX_EN = 'Scheduled tasks · '
/**
* Strip the scheduler title prefix so a forked child can sit in the
@ -17,6 +18,9 @@ export function workspaceTitleFromCronFork(title) {
if (value.startsWith(TITLE_PREFIX)) {
return value.slice(TITLE_PREFIX.length).trim() || value
}
if (value.startsWith(TITLE_PREFIX_EN)) {
return value.slice(TITLE_PREFIX_EN.length).trim() || value
}
if (value.startsWith('运维定时')) {
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
@ -26,6 +30,10 @@ export function workspaceTitleFromCronFork(title) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
if (/^scheduled tasks(?:\s*·\s*|\s+)/i.test(value)) {
const stripped = value.replace(/^scheduled tasks(?:\s*·\s*|\s+)/i, '').trim()
return stripped || value
}
return value
}
@ -60,7 +68,11 @@ export function shouldPromoteCronFork(row, cronSessionIds, previousId) {
const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时') || title.startsWith('定时任务')
const looksCron = title.startsWith(TITLE_PREFIX)
|| title.startsWith(TITLE_PREFIX_EN)
|| title.startsWith('运维定时')
|| title.startsWith('定时任务')
|| /^scheduled tasks/i.test(title)
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}

View file

@ -11,6 +11,7 @@ import {
resolveCreateDelivery,
resolveCreateOrigin,
} from './delivery.js'
import { t } from './i18n.js'
import { resolveCreateAgentPreset } from './preset.js'
import {
assertCanAccessJob,
@ -287,7 +288,7 @@ export function cronToolDefinitions(service, deps = {}) {
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
},
},
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
presentCall: (args) => ({ card: 'generic', title: t('tool.create'), content: String(args?.name || '') }),
async execute(args, exec) {
aborted(exec)
const timeout = Number(args.timeout_minutes)
@ -352,7 +353,7 @@ export function cronToolDefinitions(service, deps = {}) {
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
},
},
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
presentCall: () => ({ card: 'generic', title: t('tool.list') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
@ -376,7 +377,7 @@ export function cronToolDefinitions(service, deps = {}) {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: t('tool.pause'), content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
@ -400,7 +401,7 @@ export function cronToolDefinitions(service, deps = {}) {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: t('tool.resume'), content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
@ -432,7 +433,7 @@ export function cronToolDefinitions(service, deps = {}) {
},
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
},
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: t('tool.delete'), content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())

View file

@ -29,6 +29,7 @@
"default": "./lib/index.js"
},
"./client": "./lib/client.js",
"./i18n": "./lib/i18n.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},

View file

@ -436,4 +436,6 @@ test('formatRunResultBody matches IM delivery header', () => {
const body = formatRunResultBody({ name: '晨报' }, 'ok')
assert.match(body, /^【定时任务 · 晨报】\n/)
assert.match(body, /ok$/)
const en = formatRunResultBody({ name: 'Daily' }, 'ok', 'en')
assert.match(en, /^\[Scheduled tasks · Daily\]\n/)
})

62
test/i18n.test.js Normal file
View file

@ -0,0 +1,62 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
MESSAGES,
apiError,
formatRunResultBody,
normalizeLocale,
resolveLocale,
t,
workspaceTitleFromCronFork,
} from '../lib/index.js'
describe('dsh-ops-cron i18n', () => {
it('normalizes locales', () => {
assert.equal(normalizeLocale('en-US'), 'en')
assert.equal(normalizeLocale('zh-CN'), 'zh')
assert.equal(normalizeLocale(''), 'zh')
})
it('translates API and delivery keys', () => {
assert.equal(t('err.login_required', 'zh'), '登录后才能使用定时任务')
assert.equal(t('err.login_required', 'en'), 'Sign in to use scheduled tasks')
assert.equal(t('tool.create', 'en'), 'Create scheduled task')
})
it('interpolates delivery vars', () => {
assert.match(t('delivery.header', 'zh', { name: '晨报' }), /晨报/)
assert.match(t('delivery.header', 'en', { name: 'Daily' }), /Daily/)
})
it('returns stable API error codes with localized message', () => {
const zh = apiError('login_required', 'zh')
const en = apiError('login_required', 'en')
assert.equal(zh.error, 'login_required')
assert.equal(en.error, 'login_required')
assert.equal(zh.ok, false)
assert.equal(zh.message, '登录后才能使用定时任务')
assert.equal(en.message, 'Sign in to use scheduled tasks')
})
it('resolves locale from Accept-Language', () => {
assert.equal(resolveLocale({ headers: { 'accept-language': 'en-US,en;q=0.9' } }), 'en')
assert.equal(resolveLocale({ headers: { 'accept-language': 'zh-CN' } }), 'zh')
})
it('formatRunResultBody respects locale', () => {
const zh = formatRunResultBody({ name: '晨报' }, 'ok', 'zh')
const en = formatRunResultBody({ name: 'Daily' }, 'ok', 'en')
assert.match(zh, /^【定时任务 · 晨报】\n/)
assert.match(en, /^\[Scheduled tasks · Daily\]\n/)
})
it('strips English title prefix on fork promote', () => {
assert.equal(workspaceTitleFromCronFork('Scheduled tasks · Daily (1)'), 'Daily (1)')
})
it('zh and en tables share the same keys', () => {
const zhKeys = Object.keys(MESSAGES.zh).sort()
const enKeys = Object.keys(MESSAGES.en).sort()
assert.deepEqual(zhKeys, enKeys)
})
})

View file

@ -68,6 +68,15 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(client, /agentPreset/)
assert.match(client, /mirrorToSession/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
assert.match(client, /locale\.bind/)
assert.match(client, /locale\/change/)
assert.match(client, /loginRequired/)
assert.match(client, /runNoSession/)
assert.match(client, /body\.message/)
const i18n = await readFile(join(root, 'lib/i18n.js'), 'utf8')
assert.match(i18n, /err\.login_required/)
assert.match(i18n, /export function apiError/)
assert.ok(pkg.exports['./i18n'])
const preset = await readFile(join(root, 'lib/preset.js'), 'utf8')
assert.match(preset, /resolveCreateAgentPreset/)
const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')