feat(spa): add a time zone selector to the business configuration page
Section 04 "Notification display" holds a single select populated from a frozen catalogue of the 16 zones the server accepts, with Asia/Shanghai selected by default. Labels are per-zone (`label.en` / `label.zh`) so they follow the existing browser-language switch. readBusinessConfig now submits the selection under the `timezone` key.
This commit is contained in:
parent
d32d645322
commit
61de5f985f
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -122,6 +122,13 @@
|
|||
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" data-i18n-placeholder="llm.keyPh" placeholder="Required when LLM is enabled; never echoed" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="timezone-title">
|
||||
<div class="form-section-heading"><span class="section-number">04</span><div><h3 id="timezone-title" data-i18n="timezone.title">Notification display</h3><p data-i18n="timezone.desc">Received times in Telegram notifications are rendered in this time zone.</p></div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label for="timezone">Time zone <span class="required-mark" data-i18n="common.required">Required</span></label><select id="timezone" name="timezone" required></select><small data-i18n="timezone.hint">Only fixed-offset zones are supported; DST is not tracked.</small></div>
|
||||
</div>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-footer">
|
||||
|
|
|
|||
Loading…
Reference in New Issue