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.modelLabel": "Model name",
|
||||||
"llm.modelPh": "Leave blank for null",
|
"llm.modelPh": "Leave blank for null",
|
||||||
"llm.keyPh": "Required when LLM is enabled; never echoed",
|
"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.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.saveStateReady": "Fill in the complete BusinessConfigWire to submit. Secrets are never echoed.",
|
||||||
"business.save": "Save business config and hot-reload",
|
"business.save": "Save business config and hot-reload",
|
||||||
|
|
@ -376,9 +379,44 @@
|
||||||
"login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。",
|
"login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。",
|
||||||
"login.status": "无法创建管理会话(HTTP {status})。",
|
"login.status": "无法创建管理会话(HTTP {status})。",
|
||||||
"login.fail": "无法连接管理服务,请检查网络后重试。",
|
"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() {
|
function detectLocale() {
|
||||||
let pref = "";
|
let pref = "";
|
||||||
try {
|
try {
|
||||||
|
|
@ -434,10 +472,20 @@
|
||||||
|
|
||||||
// Expose a tiny debug surface for runtime inspection and tests.
|
// Expose a tiny debug surface for runtime inspection and tests.
|
||||||
if (typeof window !== "undefined") {
|
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();
|
applyI18n();
|
||||||
|
populateTimezones();
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RUNTIME_DEFAULTS = Object.freeze({
|
const RUNTIME_DEFAULTS = Object.freeze({
|
||||||
|
|
@ -896,6 +944,7 @@
|
||||||
llm_model: llmModelValue || null,
|
llm_model: llmModelValue || null,
|
||||||
reconcile_token: requireText("reconcile-token", "Reconcile Token", true),
|
reconcile_token: requireText("reconcile-token", "Reconcile Token", true),
|
||||||
worker_token: requireText("worker-token", "Worker Token", true),
|
worker_token: requireText("worker-token", "Worker Token", true),
|
||||||
|
timezone: document.getElementById("timezone").value,
|
||||||
};
|
};
|
||||||
return business;
|
return business;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class FakeElement {
|
||||||
remove() {},
|
remove() {},
|
||||||
};
|
};
|
||||||
this.children = new Map();
|
this.children = new Map();
|
||||||
|
this.options = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(type, callback) {
|
addEventListener(type, callback) {
|
||||||
|
|
@ -39,6 +40,7 @@ class FakeElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
querySelectorAll() { return []; }
|
querySelectorAll() { return []; }
|
||||||
|
appendChild(child) { this.options.push(child); }
|
||||||
focus() {}
|
focus() {}
|
||||||
reset() {}
|
reset() {}
|
||||||
setCustomValidity() {}
|
setCustomValidity() {}
|
||||||
|
|
@ -63,6 +65,7 @@ async function startPage(statusResponse) {
|
||||||
return elements.get(id);
|
return elements.get(id);
|
||||||
},
|
},
|
||||||
querySelectorAll() { return []; },
|
querySelectorAll() { return []; },
|
||||||
|
createElement() { return new FakeElement(); },
|
||||||
};
|
};
|
||||||
const window = {
|
const window = {
|
||||||
addEventListener() {},
|
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 new Promise((resolve) => setImmediate(resolve));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
return { elements, fetchCalls, window };
|
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-placeholder="auth\.placeholder"/);
|
||||||
assert.match(html, /data-i18n-aria="brand\.aria"/);
|
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 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>
|
</div>
|
||||||
</section>
|
</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>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue