From cf941c50bcb07c50cfd3c0b850034879f384f0fa Mon Sep 17 00:00:00 2001 From: zikai <1621362626@qq.com> Date: Mon, 27 Jul 2026 09:49:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E9=80=89=E6=8B=A9=EF=BC=88=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E9=80=80=E5=A2=A8=E5=B0=94=E6=9C=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶栏新增时区选择器:加载时自动检测用户所在时区(Intl.DateTimeFormat), 检测不到则默认澳大利亚墨尔本。选定时区作为新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE,不做时间换算(应用内部为 wall-clock 字符串)。 - 新增 src/lib/timezone.js(detect/list/validate/默认值) - useCalendar 持 state.timezone、setTimezone、addEvent 传 tzid、serialize 注入 X-WR-TIMEZONE - HeaderBar 顶栏下拉选择器 + i18n 文案 - 新增 test/timezone.test.js(15 项),全量 57 项通过 - README 简述功能,细节移至 docs/timezone.md --- README.md | 6 +- docs/timezone.md | 49 +++++++++++++ src/components/HeaderBar.vue | 29 +++++++- src/composables/useCalendar.js | 23 +++++- src/i18n/locales/en.js | 3 +- src/i18n/locales/zh-CN.js | 3 +- src/lib/timezone.js | 102 ++++++++++++++++++++++++++ test/timezone.test.js | 127 +++++++++++++++++++++++++++++++++ 8 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 docs/timezone.md create mode 100644 src/lib/timezone.js create mode 100644 test/timezone.test.js diff --git a/README.md b/README.md index cf58ca9..9325f0a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ timeTableFix/ │ ├── lib/ │ │ ├── weekday.js 星期常量与工具 │ │ ├── date.js 日期工具 +│ │ ├── timezone.js 时区检测 / 选择(回退墨尔本) │ │ ├── colors.js 事件配色 │ │ ├── uuid.js UUID 生成(含非安全上下文回退) │ │ ├── ical-io.js ICS 文本 <-> plain model @@ -60,9 +61,11 @@ npm install # 已配 npmmirror 镜像加速 npm run dev # 开发服务器 http://localhost:5173 npm run build # 构建到 dist/ -npm test # 单元测试(42 项) +npm test # 单元测试(57 项) ``` +顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。 + ## 技术栈 | 项 | 选择 | @@ -78,6 +81,7 @@ npm test # 单元测试(42 项) ## 了解更多 - [嵌入 mainPage(i18n prop 桥接 / 样式隔离)](./docs/integration.md) +- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md) - [间隔合并与链拆分算法](./docs/organize-algorithm.md) - [自动保存设计](./docs/auto-save-design.md) - [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md) diff --git a/docs/timezone.md b/docs/timezone.md new file mode 100644 index 0000000..986794e --- /dev/null +++ b/docs/timezone.md @@ -0,0 +1,49 @@ +# 时区选择 + +timeTableFix 顶栏提供时区选择器。加载时自动检测用户所在时区,检测不到则默认 **澳大利亚墨尔本(`Australia/Melbourne`)**。 + +## 检测逻辑 + +`src/lib/timezone.js` 的 `detectTimezone()`: + +1. 读取 `new Intl.DateTimeFormat().resolvedOptions().timeZone`(浏览器/Node 标准 API)。 +2. 若抛错、返回空串或格式不合法,回退 `DEFAULT_TIMEZONE`(`Australia/Melbourne`)。 + +非安全上下文(无 `https`)或极旧环境可能拿不到时区,此时一律回退墨尔本。 + +## 下拉列表来源 + +`listTimezones()` = 精选常用 IANA 时区(`COMMON_TIMEZONES`,含墨尔本/上海/UTC 等)∪ 运行时 `Intl.supportedValuesOf('timeZone')`(若可用),去重后排序,`UTC` 置顶。旧浏览器不支持 `supportedValuesOf` 时静默降级为仅精选列表,保证下拉始终可用、无外部依赖。 + +## 作用范围(设计要点) + +应用内部所有日期/时间均为 **wall-clock 字符串**(`YYYY-MM-DD` + `HH:MM`,见 `date.js`),**不做任何时区换算**。时区仅是一个挂在事件上的 IANA 标识(`tzid`),在 ICS 序列化/反序列化时作为 `DTSTART`/`DTEND` 的 `TZID` 参数读写。 + +因此选定时区的作用是**日历级默认值/标签**,而非时间换算层: + +| 行为 | 是否受选定时区影响 | +|---|---| +| 新建事件(「新增日程」)的 `tzid` | ✅ 使用选定时区 | +| 下载 ICS 的 `X-WR-TIMEZONE` | ✅ 写入选定时区 | +| 已导入事件的 `tzid` | ❌ 保留原始 TZID,不覆盖、不换算 | +| 日期/时间数值(编辑、预览、整理) | ❌ 始终 wall-clock,不换算 | +| 间隔去重(`organize.js`) | ❌ 不读 `tzid`,不受影响 | + +### 为何不换算 + +- 应用面向「整理/编辑日程文本」而非「跨时区会议调度」,wall-clock 直观且无歧义。 +- `organize.js` 按 `[summary, location, 开始时间, 时长]` 合并重复,引入换算会改变合并键、破坏既有 round-trip 测试。 +- 已导入事件保留原 TZID 保证 `parse(serialize(parse(x)))` 等价(见 `ical-io.test.js` round-trip 用例)。 + +## 实现位置 + +| 文件 | 改动 | +|---|---| +| `src/lib/timezone.js` | 新增:检测/列表/校验/默认值 | +| `src/composables/useCalendar.js` | `state.timezone`(初始 `detectTimezone()`)、`setTimezone()`、`addEvent` 传入 tzid、`serialize` 注入 `X-WR-TIMEZONE` | +| `src/components/HeaderBar.vue` | 顶栏 ` + + + @@ -74,8 +82,27 @@ function onDownload() { .btn-secondary { background: var(--bg); color: var(--text-secondary); } .btn-secondary:hover:not(:disabled) { background: var(--surface); color: var(--text-primary); } +/* 时区选择器:与按钮同行,紧凑 */ +.tz-picker { display: flex; align-items: center; gap: 4px; } +.tz-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; } +.tz-picker select { + padding: 6px 8px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: 12px; + font-family: inherit; + background: var(--bg); + color: var(--text-primary); + cursor: pointer; + max-width: 170px; + transition: border-color var(--transition); +} +.tz-picker select:focus { outline: none; border-color: var(--accent); } + @media (max-width: 768px) { .header-actions { gap: 6px; } .btn { padding: 5px 10px; font-size: 12px; } + .tz-label { display: none; } + .tz-picker select { padding: 5px 8px; font-size: 12px; max-width: 130px; } } diff --git a/src/composables/useCalendar.js b/src/composables/useCalendar.js index a1d96d7..c487f4b 100644 --- a/src/composables/useCalendar.js +++ b/src/composables/useCalendar.js @@ -4,6 +4,7 @@ import { organize as organizeEvents } from '../lib/organize.js' import { createEvent } from '../lib/event-factory.js' import { splitRecurringEvent } from '../lib/split.js' import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js' +import { detectTimezone, isValidTimezone, DEFAULT_TIMEZONE } from '../lib/timezone.js' // 单例响应式状态 const state = reactive({ @@ -13,6 +14,9 @@ const state = reactive({ fileName: null, viewMode: 'edit', // 'edit' | 'month' | 'week' previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周) + // 日历级默认时区: 加载时自动检测用户所在时区, 取不到回退墨尔本。 + // 仅作新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE, 不做时间换算。 + timezone: detectTimezone(), }) // ------------------------------------------------------------------ // @@ -59,7 +63,7 @@ function deleteEvent(uid) { } function addEvent() { - const ev = createEvent() + const ev = createEvent({ tzid: state.timezone }) state.events.push(ev) state.selectedUid = ev.uid } @@ -93,7 +97,12 @@ function toggleOccurrence(uid, date) { // 序列化 (下载时兜底: 始终序列化当前最新状态) // ------------------------------------------------------------------ // function serialize() { - return serializeICS({ meta: state.meta, events: state.events }) + // 把当前选定默认时区写入 X-WR-TIMEZONE, 使下载的 ICS 携带该标识。 + // 保留 meta 原值用于回显, 仅在输出时覆盖 (不污染内存状态)。 + const meta = state.meta + ? { ...state.meta, xWrTimezone: state.timezone } + : state.meta + return serializeICS({ meta, events: state.events }) } // ------------------------------------------------------------------ // @@ -103,6 +112,15 @@ function setViewMode(mode) { state.viewMode = mode } +/** + * 设置日历级默认时区(新建事件与 X-WR-TIMEZONE 用)。 + * 校验失败时回退到默认时区(墨尔本),保证 state 永远持有合法 IANA 标识。 + * @param {string} tz + */ +function setTimezone(tz) { + state.timezone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE +} + function shiftMonth(delta) { const { year, month } = parseISODate(startOfMonth(state.previewDate)) const d = new Date(year, month - 1 + delta, 1) @@ -158,6 +176,7 @@ export function useCalendar() { toggleOccurrence, serialize, setViewMode, + setTimezone, prevMonth, nextMonth, prevWeek, diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index d3348b4..d40daa1 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -13,7 +13,8 @@ export default { import: 'Import File', download: 'Download ICS', noPattern: 'No recurring patterns found to merge', - parseError: 'Parse failed: ' + parseError: 'Parse failed: ', + timezone: 'Time Zone' }, dropzone: { hint: 'Drop an .ics file, or', diff --git a/src/i18n/locales/zh-CN.js b/src/i18n/locales/zh-CN.js index 40b1872..9da8e7a 100644 --- a/src/i18n/locales/zh-CN.js +++ b/src/i18n/locales/zh-CN.js @@ -13,7 +13,8 @@ export default { import: '导入文件', download: '下载 ICS', noPattern: '未发现可合并的重复模式', - parseError: '解析失败:' + parseError: '解析失败:', + timezone: '时区' }, dropzone: { hint: '拖入 .ics 文件,或', diff --git a/src/lib/timezone.js b/src/lib/timezone.js new file mode 100644 index 0000000..6d3d99f --- /dev/null +++ b/src/lib/timezone.js @@ -0,0 +1,102 @@ +/** + * 时区选择辅助函数。 + * + * 应用内部所有日期/时间均为 wall-clock 字符串(见 date.js),不做时区换算。 + * 本模块仅负责「选择一个 IANA 时区标识」作为日历级默认值:检测浏览器所在 + * 时区,取不到则回退澳大利亚墨尔本(Australia/Melbourne);并提供下拉列表。 + * + * 选定时区用于:新建事件的默认 tzid、下载 ICS 的 X-WR-TIMEZONE。 + * 已导入事件保留其原始 TZID 不变(不做换算、不覆盖)。 + */ + +/** 检测失败时的回退时区(澳大利亚墨尔本)。 */ +export const DEFAULT_TIMEZONE = 'Australia/Melbourne' + +/** + * 精选常用 IANA 时区,保证下拉框在任意环境(含不支持 + * Intl.supportedValuesOf 的旧浏览器)下都有合理可选项。 + * 列表覆盖主要大洲 + UTC,墨尔本位列其中。 + */ +export const COMMON_TIMEZONES = [ + 'UTC', + 'Australia/Melbourne', + 'Australia/Sydney', + 'Australia/Perth', + 'Asia/Shanghai', + 'Asia/Hong_Kong', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Asia/Seoul', + 'Asia/Kolkata', + 'Asia/Dubai', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Moscow', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Toronto', + 'Pacific/Auckland', +] + +/** IANA 时区标识粗校验:至少含一个 '/',且仅含可打印 ASCII。 */ +const IANA_RE = /^[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)+$/ + +/** + * 校验字符串是否为合法的 IANA 时区标识。 + * UTC 与 Etc/GMT 等单段形式不被本应用使用,故要求至少一段 '/'。 + * @param {string} tz + * @returns {boolean} + */ +export function isValidTimezone(tz) { + if (typeof tz !== 'string' || tz.length === 0) return false + if (!IANA_RE.test(tz)) return false + return true +} + +/** + * 收集下拉框可选时区:精选列表 ∪ 浏览器支持的时区,去重后排序。 + * 运行时若 Intl.supportedValuesOf 不可用则仅返回精选列表。 + * UTC 始终置顶(用户可见的全局协调时区)。 + * @returns {string[]} + */ +export function listTimezones() { + const set = new Set(COMMON_TIMEZONES) + try { + if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') { + for (const tz of Intl.supportedValuesOf('timeZone')) { + if (isValidTimezone(tz)) set.add(tz) + } + } + } catch { + // 旧环境或不支持时静默降级到精选列表 + } + const list = [...set].sort((a, b) => { + if (a === 'UTC') return -1 + if (b === 'UTC') return 1 + return a.localeCompare(b) + }) + return list +} + +/** + * 检测用户当前所在时区。 + * 优先用 Intl.DateTimeFormat().resolvedOptions().timeZone;取不到或抛错 + * 时回退 DEFAULT_TIMEZONE(澳大利亚墨尔本)。 + * @returns {string} IANA 时区标识 + */ +export function detectTimezone() { + try { + const tz = + typeof Intl !== 'undefined' && + typeof Intl.DateTimeFormat === 'function' + ? new Intl.DateTimeFormat().resolvedOptions().timeZone + : null + if (tz && isValidTimezone(tz)) return tz + } catch { + // 非安全上下文或 Intl 缺失:回退 + } + return DEFAULT_TIMEZONE +} diff --git a/test/timezone.test.js b/test/timezone.test.js new file mode 100644 index 0000000..b8d49e9 --- /dev/null +++ b/test/timezone.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { + DEFAULT_TIMEZONE, + COMMON_TIMEZONES, + detectTimezone, + listTimezones, + isValidTimezone, +} from '../src/lib/timezone.js' +import { createEvent } from '../src/lib/event-factory.js' + +describe('DEFAULT_TIMEZONE', () => { + it('is Australia/Melbourne per requirement', () => { + expect(DEFAULT_TIMEZONE).toBe('Australia/Melbourne') + }) +}) + +describe('isValidTimezone', () => { + it('accepts valid IANA zone ids', () => { + expect(isValidTimezone('Australia/Melbourne')).toBe(true) + expect(isValidTimezone('Asia/Shanghai')).toBe(true) + expect(isValidTimezone('America/New_York')).toBe(true) + expect(isValidTimezone('Europe/London')).toBe(true) + }) + + it('rejects empty / non-string / malformed', () => { + expect(isValidTimezone('')).toBe(false) + expect(isValidTimezone(null)).toBe(false) + expect(isValidTimezone(undefined)).toBe(false) + expect(isValidTimezone(123)).toBe(false) + expect(isValidTimezone('UTC')).toBe(false) // 单段, 本应用要求至少一段 '/' + expect(isValidTimezone('Not A Zone')).toBe(false) + expect(isValidTimezone('Bogus/Zone/With Space')).toBe(false) + }) +}) + +describe('detectTimezone', () => { + // 保存原始 Intl 引用以便恢复 + const origDateTimeFormat = Intl.DateTimeFormat + afterEach(() => { + Intl.DateTimeFormat = origDateTimeFormat + }) + + it('returns a valid IANA zone in a normal environment', () => { + // Node 环境总能解析出系统时区 + const tz = detectTimezone() + expect(isValidTimezone(tz)).toBe(true) + }) + + it('falls back to Melbourne when Intl.DateTimeFormat throws', () => { + Intl.DateTimeFormat = function () { + throw new Error('unsupported') + } + expect(detectTimezone()).toBe(DEFAULT_TIMEZONE) + }) + + it('falls back to Melbourne when resolved timeZone is empty', () => { + Intl.DateTimeFormat = function () { + return { resolvedOptions: () => ({ timeZone: '' }) } + } + expect(detectTimezone()).toBe(DEFAULT_TIMEZONE) + }) + + it('falls back to Melbourne when resolved timeZone is malformed', () => { + Intl.DateTimeFormat = function () { + return { resolvedOptions: () => ({ timeZone: 'Not A Zone' }) } + } + expect(detectTimezone()).toBe(DEFAULT_TIMEZONE) + }) + + it('returns the detected zone when it is valid', () => { + Intl.DateTimeFormat = function () { + return { resolvedOptions: () => ({ timeZone: 'Asia/Shanghai' }) } + } + expect(detectTimezone()).toBe('Asia/Shanghai') + }) +}) + +describe('listTimezones', () => { + it('always includes Melbourne, Shanghai, UTC', () => { + const list = listTimezones() + expect(list).toContain('Australia/Melbourne') + expect(list).toContain('Asia/Shanghai') + expect(list).toContain('UTC') + }) + + it('has no duplicates', () => { + const list = listTimezones() + expect(new Set(list).size).toBe(list.length) + }) + + it('places UTC first, rest sorted', () => { + const list = listTimezones() + expect(list[0]).toBe('UTC') + const rest = list.slice(1) + const sorted = [...rest].sort((a, b) => a.localeCompare(b)) + expect(rest).toEqual(sorted) + }) + + it('every entry is a valid IANA zone (UTC excepted)', () => { + const list = listTimezones() + for (const tz of list) { + if (tz === 'UTC') continue + expect(isValidTimezone(tz)).toBe(true) + } + }) + + it('includes all COMMON_TIMEZONES entries', () => { + const list = listTimezones() + for (const tz of COMMON_TIMEZONES) { + expect(list).toContain(tz) + } + }) +}) + +describe('createEvent timezone override', () => { + // 确认 useCalendar.addEvent 依赖的覆盖路径:传入 { tzid } 后新建事件的 + // tzid 即为该值(而非 event-factory 的独立默认 'UTC')。 + it('honors the tzid passed via overrides', () => { + const ev = createEvent({ tzid: 'Asia/Shanghai' }) + expect(ev.tzid).toBe('Asia/Shanghai') + }) + + it('falls back to UTC when no tzid override is given (standalone default)', () => { + const ev = createEvent() + expect(ev.tzid).toBe('UTC') + }) +})