diff --git a/web/config.js b/web/config.js index 69477da..cb0a12b 100644 --- a/web/config.js +++ b/web/config.js @@ -87,6 +87,9 @@ "llm.modelLabel": "Model name", "llm.modelPh": "Leave blank for null", "llm.keyPh": "Required when LLM is enabled; never echoed", + "timezone.title": "Notification display", + "timezone.desc": "Received times in Telegram notifications are rendered in this time zone.", + "timezone.hint": "Only fixed-offset zones are supported; DST is not tracked.", "business.saveStateInitial": "You can fill in the full configuration after the admin session is verified.", "business.saveStateReady": "Fill in the complete BusinessConfigWire to submit. Secrets are never echoed.", "business.save": "Save business config and hot-reload", @@ -376,9 +379,44 @@ "login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。", "login.status": "无法创建管理会话(HTTP {status})。", "login.fail": "无法连接管理服务,请检查网络后重试。", + "timezone.title": "通知显示", + "timezone.desc": "Telegram 通知中的收件时间按此时区渲染。", + "timezone.hint": "仅支持固定偏移时区;不跟踪夏令时。", }, }); + // 后端 timezone 字段的白名单(全部固定偏移,按偏移升序;Asia/Shanghai 为默认值) + const DEFAULT_TIMEZONE = Object.freeze("Asia/Shanghai"); + const TIMEZONES = Object.freeze([ + Object.freeze({ iana: "Etc/UTC", offset: "+0", label: Object.freeze({ en: "UTC (UTC+0)", zh: "协调世界时 (UTC+0)" }) }), + Object.freeze({ iana: "Africa/Cairo", offset: "+2", label: Object.freeze({ en: "Cairo, Egypt (UTC+2)", zh: "埃及开罗 (UTC+2)" }) }), + Object.freeze({ iana: "Europe/Istanbul", offset: "+3", label: Object.freeze({ en: "Istanbul, Turkey (UTC+3)", zh: "土耳其伊斯坦布尔 (UTC+3)" }) }), + Object.freeze({ iana: "Africa/Nairobi", offset: "+3", label: Object.freeze({ en: "Nairobi, Kenya (UTC+3)", zh: "肯尼亚内罗毕 (UTC+3)" }) }), + Object.freeze({ iana: "Asia/Dubai", offset: "+4", label: Object.freeze({ en: "Dubai, UAE (UTC+4)", zh: "阿联酋迪拜 (UTC+4)" }) }), + Object.freeze({ iana: "Asia/Karachi", offset: "+5", label: Object.freeze({ en: "Karachi, Pakistan (UTC+5)", zh: "巴基斯坦卡拉奇 (UTC+5)" }) }), + Object.freeze({ iana: "Asia/Kolkata", offset: "+5:30", label: Object.freeze({ en: "Kolkata, India (UTC+5:30)", zh: "印度加尔各答 (UTC+5:30)" }) }), + Object.freeze({ iana: "Asia/Bangkok", offset: "+7", label: Object.freeze({ en: "Bangkok, Thailand (UTC+7)", zh: "泰国曼谷 (UTC+7)" }) }), + Object.freeze({ iana: "Asia/Ho_Chi_Minh", offset: "+7", label: Object.freeze({ en: "Ho Chi Minh City, Vietnam (UTC+7)", zh: "越南胡志明市 (UTC+7)" }) }), + Object.freeze({ iana: "Asia/Shanghai", offset: "+8", label: Object.freeze({ en: "Shanghai, China (UTC+8)", zh: "中国上海 (UTC+8)" }) }), + Object.freeze({ iana: "Asia/Hong_Kong", offset: "+8", label: Object.freeze({ en: "Hong Kong (UTC+8)", zh: "中国香港 (UTC+8)" }) }), + Object.freeze({ iana: "Asia/Taipei", offset: "+8", label: Object.freeze({ en: "Taipei, Taiwan (UTC+8)", zh: "中国台北 (UTC+8)" }) }), + Object.freeze({ iana: "Asia/Singapore", offset: "+8", label: Object.freeze({ en: "Singapore (UTC+8)", zh: "新加坡 (UTC+8)" }) }), + Object.freeze({ iana: "Asia/Manila", offset: "+8", label: Object.freeze({ en: "Manila, Philippines (UTC+8)", zh: "菲律宾马尼拉 (UTC+8)" }) }), + Object.freeze({ iana: "Asia/Tokyo", offset: "+9", label: Object.freeze({ en: "Tokyo, Japan (UTC+9)", zh: "日本东京 (UTC+9)" }) }), + Object.freeze({ iana: "Asia/Seoul", offset: "+9", label: Object.freeze({ en: "Seoul, South Korea (UTC+9)", zh: "韩国首尔 (UTC+9)" }) }), + ]); + + function populateTimezones() { + const select = document.querySelector("#timezone"); + for (const zone of TIMEZONES) { + const option = document.createElement("option"); + option.value = zone.iana; + option.textContent = zone.label[locale] || zone.label.en; + option.selected = zone.iana === DEFAULT_TIMEZONE; + select.appendChild(option); + } + } + function detectLocale() { let pref = ""; try { @@ -434,10 +472,20 @@ // Expose a tiny debug surface for runtime inspection and tests. if (typeof window !== "undefined") { - window.__mw = { t, detectLocale, getLocale: () => locale, messages: MESSAGES, joinList }; + window.__mw = { + t, + detectLocale, + getLocale: () => locale, + messages: MESSAGES, + joinList, + timezones: TIMEZONES, + defaultTimezone: DEFAULT_TIMEZONE, + readBusinessConfig, + }; } applyI18n(); + populateTimezones(); // ───────────────────────────────────────────────────────────────────────── const RUNTIME_DEFAULTS = Object.freeze({ @@ -896,6 +944,7 @@ llm_model: llmModelValue || null, reconcile_token: requireText("reconcile-token", "Reconcile Token", true), worker_token: requireText("worker-token", "Worker Token", true), + timezone: document.getElementById("timezone").value, }; return business; } catch (error) { diff --git a/web/config.test.mjs b/web/config.test.mjs index b658fff..6d67fac 100644 --- a/web/config.test.mjs +++ b/web/config.test.mjs @@ -23,6 +23,7 @@ class FakeElement { remove() {}, }; this.children = new Map(); + this.options = []; } addEventListener(type, callback) { @@ -39,6 +40,7 @@ class FakeElement { } querySelectorAll() { return []; } + appendChild(child) { this.options.push(child); } focus() {} reset() {} setCustomValidity() {} @@ -63,6 +65,7 @@ async function startPage(statusResponse) { return elements.get(id); }, querySelectorAll() { return []; }, + createElement() { return new FakeElement(); }, }; const window = { addEventListener() {}, @@ -82,7 +85,7 @@ async function startPage(statusResponse) { }; }; - vm.runInNewContext(source, { document, window, fetch, Headers }); + vm.runInNewContext(source, { document, window, fetch, Headers, URL }); await new Promise((resolve) => setImmediate(resolve)); await Promise.resolve(); return { elements, fetchCalls, window }; @@ -190,3 +193,87 @@ test("every data-i18n key in the markup resolves in both dictionaries", async () assert.match(html, /data-i18n-placeholder="auth\.placeholder"/); assert.match(html, /data-i18n-aria="brand\.aria"/); }); + +test("timezone catalogue is a duplicate-free, fixed-offset-only set with Asia/Shanghai as the default", async () => { + const { window } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const { timezones, defaultTimezone } = window.__mw; + + assert.equal(defaultTimezone, "Asia/Shanghai"); + assert.equal(timezones.length, 16); + assert.equal(new Set(timezones.map((zone) => zone.iana)).size, 16); + + const expected = [ + "Etc/UTC", + "Africa/Cairo", + "Europe/Istanbul", + "Africa/Nairobi", + "Asia/Dubai", + "Asia/Karachi", + "Asia/Kolkata", + "Asia/Bangkok", + "Asia/Ho_Chi_Minh", + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Taipei", + "Asia/Singapore", + "Asia/Manila", + "Asia/Tokyo", + "Asia/Seoul", + ]; + assert.deepEqual([...timezones.map((zone) => zone.iana)].sort(), [...expected].sort()); + assert.ok(timezones.some((zone) => zone.iana === defaultTimezone)); + + for (const zone of timezones) { + assert.match(zone.offset, /^\+\d{1,2}(:30)?$/); + assert.equal(zone.label.en.includes(zone.offset), true); + assert.equal(zone.label.zh.includes(zone.offset), true); + } +}); + +test("timezone select is filled with 16 options and pre-selects Asia/Shanghai", async () => { + const { elements, window } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const { timezones, defaultTimezone, getLocale } = window.__mw; + const timezoneSelect = elements.get("timezone"); + + assert.equal(timezoneSelect.options.length, 16); + assert.deepEqual(timezoneSelect.options.map((option) => option.value), Array.from(timezones, (zone) => zone.iana)); + + const selected = timezoneSelect.options.filter((option) => option.selected); + assert.equal(selected.length, 1); + assert.equal(selected[0].value, defaultTimezone); + + const defaultZone = timezones.find((zone) => zone.iana === defaultTimezone); + assert.equal(defaultZone.label.en, "Shanghai, China (UTC+8)"); + assert.equal(defaultZone.label.zh, "中国上海 (UTC+8)"); + assert.equal(selected[0].textContent, defaultZone.label[getLocale()]); + // Option labels live per zone and must exist in both languages (test 5 covers dictionary parity). + for (const zone of timezones) { + assert.equal(zone.label.en.length > 0, true); + assert.equal(zone.label.zh.length > 0, true); + } +}); + +test("readBusinessConfig carries the selected time zone", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + + elements.get("telegram-bot-token").value = "bot-token"; + elements.get("telegram-chat-id").value = "123456789"; + elements.get("chat-allowlist").value = "123456789"; + elements.get("telegram-webhook-secret").value = "webhook-secret"; + elements.get("jmap-session-url").value = "https://mail.example.com/jmap"; + elements.get("jmap-username").value = "bot@example.com"; + elements.get("jmap-password").value = "jmap-password"; + elements.get("reconcile-token").value = "reconcile-token"; + elements.get("worker-token").value = "worker-token"; + elements.get("timezone").value = "Asia/Tokyo"; + + const payload = pageWindow.__mw.readBusinessConfig(); + + assert.equal(payload.timezone, "Asia/Tokyo"); + assert.equal(payload.llm_enabled, false); + assert.equal(payload.llm_base_url, null); + assert.equal(payload.llm_model, null); + assert.equal(payload.llm_api_key, null); + assert.equal(typeof payload.bot_token, "string"); + assert.equal(typeof payload.reconcile_token, "string"); +}); diff --git a/web/index.html b/web/index.html index edb8078..b183b1a 100644 --- a/web/index.html +++ b/web/index.html @@ -122,6 +122,13 @@
+ +
+
04

Notification display

Received times in Telegram notifications are rendered in this time zone.

+
+
Only fixed-offset zones are supported; DST is not tracked.
+
+