mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-10 12:23:17 +08:00
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:
parent
51deaf037b
commit
b35bfde557
12 changed files with 373 additions and 37 deletions
128
lib/client.js
128
lib/client.js
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
27
lib/host.js
27
lib/host.js
|
|
@ -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
126
lib/i18n.js
Normal 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
9
lib/index.d.ts
vendored
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
11
lib/tools.js
11
lib/tools.js
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
62
test/i18n.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue