Compare commits

...

5 Commits

Author SHA1 Message Date
d32f8be018 refactor: 清理死代码 + 提前失败 + 错误记录控制台
- 移除 EventDetail.vue 未用的 computed 导入
- 移除 HeaderBar.vue 未用的 .btn-secondary CSS
- store (updateEvent/splitEvent/toggleOccurrence): uid 缺失改抛错 (fail-fast), 不再静默 return
- EventDetail onSplit: 捕获 splitEvent 抛错并提示用户
- DropZone/HeaderBar 解析失败: catch 内补 console.error
- ical-io getZone: 时区未注册回退 UTC 时 console.warn
- WeekPreview 周标签改用 i18n (weekPreview.rangeFmt), 与 MonthPreview 一致
- docs: 新增 error-handling.md; 修正 auto-save-design state 快照补 timezone
- README: 补充部署说明 (经 zMainPage apache2 托管)
2026-07-28 11:08:06 +08:00
cf941c50bc feat: 增加时区选择(自动检测,回退墨尔本)
顶栏新增时区选择器:加载时自动检测用户所在时区(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
2026-07-27 09:49:13 +08:00
root
1b67e15071 feat: 导入文件按钮改为始终高亮(btn-primary)
原 btn-secondary 与页面背景同色、文字灰,视觉上偏暗;导入操作无前置依赖
(随时可点),应始终可用且醒目,故改为 btn-primary 与组织按钮一致。
2026-07-24 09:25:28 +00:00
root
0448ec8b72 docs: README 补初始设计文档链接 2026-07-24 09:16:23 +00:00
root
78d7afe699 docs: 精简 README(结构/依赖/使用),细节移至 docs/integration.md 2026-07-24 09:15:29 +00:00
15 changed files with 455 additions and 77 deletions

View File

@@ -1,16 +1,6 @@
# timeTableFix - 纯前端 ICS 日历整理器
浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。
## 功能
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
- 智能去重:检测事件间隔(每周 / 每两周 / 每 N 天),将扁平事件合并为 RRULE 重复事件 + EXDATE交错的多班次自动拆分为多个系列
- 自动保存:所有编辑即时生效,下载时兜底序列化最新状态,无需手动保存
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
- 拆分事件:把一个重复事件按日期拆成前后两段
- 增删事件:新增单次/重复事件,删除任意事件
- 导出 ICS下载整理后的 `.ics`,可直接导入日历应用
浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。独立可维护,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块集成展示。
## 项目结构
@@ -18,17 +8,21 @@
timeTableFix/
├── index.html Vite 入口
├── package.json
├── vite.config.js 构建配置(含 preview.allowedHosts
├── vite.config.js
├── vitest.config.js
├── .npmrc npmmirror 镜像加速
├── README.md
├── src/
│ ├── main.js 应用挂载
│ ├── App.vue 布局装配
│ ├── main.js 应用挂载i18n + 全局样式)
│ ├── App.vue 便携式根组件(嵌入与独立两用)
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale
│ ├── composables/
│ │ ── useCalendar.js 中心 store自动保存无 undo
│ │ ── useCalendar.js 中心 store自动保存无 undo
│ │ └── useLocale.js 语言辅助(直读自身 i18n 实例)
│ ├── lib/
│ │ ├── weekday.js 星期常量与工具
│ │ ├── date.js 日期工具
│ │ ├── timezone.js 时区检测 / 选择(回退墨尔本)
│ │ ├── colors.js 事件配色
│ │ ├── uuid.js UUID 生成(含非安全上下文回退)
│ │ ├── ical-io.js ICS 文本 <-> plain model
@@ -45,60 +39,32 @@ timeTableFix/
│ ├── MonthPreview.vue 月预览
│ └── WeekPreview.vue 周预览
├── test/ Vitest 单元测试
│ ├── golden-org.ics 测试用例61 事件的真实课表)
│ ├── weekday.test.js
│ ├── ical-io.test.js
│ ├── recur.test.js
│ └── organize.test.js
└── docs/ 设计文档
├── organize-algorithm.md 间隔合并与链拆分算法详解
└── auto-save-design.md 自动保存设计
```
## Ubuntu 从零安装
## 外部依赖
### 1. 安装 Node.js 18+
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version # 确认 v18.x
```
### 2. 克隆并安装依赖
| 项 | 说明 |
|----|------|
| Node.js | ≥ 18 |
| ical.js | ICS 解析库npm 依赖) |
> 纯前端应用,无运行期后端依赖。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管(无独立 apache2 配置);详见 zMainPage 的部署文档。
## 如何使用
```bash
# 克隆并安装
git clone https://git.zikai.wang/zikai/timeTableFix.git
cd timeTableFix
npm install # 已配 npmmirror 镜像加速
npm run dev # 开发服务器 http://localhost:5173
npm run build # 构建到 dist/
npm test # 单元测试57 项)
```
### 3. 构建生产版本
```bash
npm run build # 产物输出到 dist/
npm test # (可选)运行单元测试
```
### 4. 集成到主页
本项目作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的一个页签集成展示,采用**构建期组件集成 + git submodule**
- mainPage 通过 git submodule 把本项目作为子目录(`third_party/timeTableFix`)引入,并直接 `import` 本项目的 `src/App.vue` 作为懒加载路由组件。
- 集成时共享 mainPage 的构建、依赖(`ical.js`)与 KeepAlive 缓存,不再是 iframe无运行期割裂。
- 本项目 `App.vue` 已做样式隔离scoped + `ttf-` 前缀类名),设计令牌与 mainPage 对齐;全局重置仅在独立 app`main.js`)加载,作为组件嵌入时不污染宿主。
本项目无独立线上入口,仅通过主页 `https://zikai.wang/timetable` 访问。升级流程见 mainPage 的 [`docs/submodule-timeTableFix.md`](https://git.zikai.wang/zikai/zMainPage/src/branch/main/docs/submodule-timeTableFix.md)。
## 开发
```bash
cd timeTableFix
npm install
npm run dev # 开发服务器 http://localhost:5173
npm test # 单元测试42 项)
npm run build # 构建到 dist/(独立预览用,非线上入口)
```
顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。
## 技术栈
@@ -107,6 +73,16 @@ npm run build # 构建到 dist/(独立预览用,非线上入口)
| 框架 | Vue 3.5SFC`<script setup>` |
| 构建 | Vite 6 |
| ICS 库 | ical.js 2.x |
| 国际化 | vue-i18n 9中文同步英文懒加载 |
| 状态管理 | composable 单例 store |
| 测试 | Vitest |
| 包源 | npmmirror.com中国镜像加速 |
## 了解更多
- [嵌入 mainPagei18n prop 桥接 / 样式隔离)](./docs/integration.md)
- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md)
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
- [自动保存设计](./docs/auto-save-design.md)
- [错误处理与日志约定](./docs/error-handling.md)
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)

View File

@@ -61,6 +61,7 @@ const state = reactive({
fileName: null,
viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(),
timezone: detectTimezone(), // 日历级默认时区 (新建事件 tzid / X-WR-TIMEZONE)
})
```

25
docs/error-handling.md Normal file
View File

@@ -0,0 +1,25 @@
# 错误处理与日志约定
timeTableFix 的错误处理分两层:纯库函数 fail-fastUI 层捕获后既写控制台又给用户反馈。
## 纯库层src/lib/
- 解析/算法错误直接抛出或返回 `null`,由调用方决定如何反馈。
- `parseICS``ICAL.parse` 对非法文本抛错,向上传播。
- `splitRecurringEvent` / `fitSeries`:不可处理时返回 `null`(合法的「拒绝」语义)。
- `getZone`时区未注册VTIMEZONE 缺失)时回退 UTC 并 `console.warn`,避免静默时间偏移。
## store 层src/composables/useCalendar.js
- `loadFile`:不吞错,`parseICS` 的异常直接 reject 给调用方。
- `updateEvent` / `splitEvent` / `toggleOccurrence`uid 不存在视为编程错误,**抛错**而非静默 return。调用方EventDetail在调前已校验 `selectedEvent`,正常流程不会触发;触发即暴露 bug。
## UI 层src/components/
- 文件解析失败DropZone / HeaderBar`.catch``console.error` 记录原始错误,再用 `alert` 告知用户。
- 拆分失败EventDetail `onSplit``splitEvent` 抛错被 `try/catch` 捕获,`console.error` + 提示日期无效。
- 时区检测/列表降级timezone.js旧环境 `Intl` 不可用时静默回退墨尔本(有测试覆盖),属可接受的降级。
## 控制台前缀
所有 `[timeTableFix]` 前缀的日志均来自本子项目,便于在嵌入宿主时与宿主日志区分。

30
docs/integration.md Normal file
View File

@@ -0,0 +1,30 @@
# 嵌入 mainPage
timeTableFix 可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块git submodule经构建期组件集成展示位于 mainPage 的 `third_party/timeTableFix`
## 集成方式
- mainPage 通过 git submodule 把本项目作为子目录引入,并直接 `import` 本项目的 `src/App.vue` 作为懒加载路由组件。
- 构建期集成(非 iframe共享 mainPage 的构建与 KeepAlive 缓存,无运行期割裂。
- 本项目无独立线上入口,仅通过主页 `https://zikai.wang/timetable` 访问。
## 便携式 App.vue
`src/App.vue` 是嵌入与独立两用的根组件:
- **嵌入时**mainPage 侧包装器 `import` 本项目 `src/App.vue`,把父项目当前语言经 `:locale` prop 传入。`App.vue``watch` 该 prop同步到本项目独立的 `vue-i18n` 实例 `locale`,从而跟随父项目语言开关响应式切换。文案来自本项目自有 locale 文件,不共享父项目的 messages。
- **独立运行时**:不接收 prop默认中文。
## i18n 桥接关键点
组件内用 `useLocale` composable **直接读自身模块级 i18n 实例**`i18n.global`),而非 `useI18n({ useScope: 'global' })`
原因:`useI18n({ useScope: 'global' })` 解析的是「当前运行 app 已安装的全局 i18n」。嵌入 mainPage 时那是父项目的实例(不含本项目文案),会导致文案回退成 i18n key显示 ID 而非文字)。改为直接引用本项目实例的 `.global`,无论是否 `app.use()` 都拿到本项目的 `t`
## 样式隔离
`App.vue` 已做样式隔离scoped + `ttf-` 前缀类名),设计令牌与 mainPage 对齐;全局重置仅在独立 app`main.js`)加载,作为组件嵌入时不污染宿主。
## 集成 / 升级 / 移除
详见 mainPage 仓库的 [`docs/submodule-timeTableFix.md`](https://git.zikai.wang/zikai/zMainPage/src/branch/main/docs/submodule-timeTableFix.md)(克隆含子模块、升级 gitlink、移除页签等流程

49
docs/timezone.md Normal file
View File

@@ -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` | 顶栏 `<select>` 选择器 |
| `src/i18n/locales/{zh-CN,en}.js` | `header.timezone` 文案 |
## 嵌入 mainPage
时区状态属于 timeTableFix 自有单例 store嵌入 mainPage 时随子模块独立运作,不与父项目共享。语言切换仍经 `:locale` prop 桥接(与时区无关)。

View File

@@ -18,6 +18,7 @@ function onDrop(e) {
return
}
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
})
}
@@ -27,6 +28,7 @@ function onPick(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
})
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js'
@@ -88,7 +88,14 @@ function onSplit() {
alert(t('eventDetail.splitDateInvalid'))
return
}
splitEvent(ev.uid, splitDate.value)
// splitEvent 在日期无法对齐到网格时抛错 (fail-fast); 捕获后提示用户。
try {
splitEvent(ev.uid, splitDate.value)
} catch (err) {
console.error('[timeTableFix] 拆分失败', err)
alert(t('eventDetail.splitDateInvalid'))
return
}
showSplit.value = false
splitDate.value = ''
}

View File

@@ -2,10 +2,12 @@
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { ref } from 'vue'
import { listTimezones } from '../lib/timezone.js'
const { state, organize, serialize, loadFile } = useCalendar()
const { state, organize, serialize, loadFile, setTimezone } = useCalendar()
const { t } = useLocale()
const fileInput = ref(null)
const timezones = listTimezones()
function onOrganize() {
const stats = organize()
@@ -21,7 +23,10 @@ function onImport() {
function onFilePicked(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => alert(t('header.parseError') + (err.message || err)))
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
alert(t('header.parseError') + (err.message || err))
})
e.target.value = ''
}
@@ -44,10 +49,16 @@ function onDownload() {
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
{{ t('header.organize') }}
</button>
<button class="btn btn-secondary" @click="onImport">{{ t('header.import') }}</button>
<button class="btn btn-primary" @click="onImport">{{ t('header.import') }}</button>
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
{{ t('header.download') }}
</button>
<label class="tz-picker">
<span class="tz-label">{{ t('header.timezone') }}</span>
<select :value="state.timezone" @change="setTimezone($event.target.value)" :title="t('header.timezone')">
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</label>
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
</div>
</template>
@@ -71,11 +82,28 @@ function onDownload() {
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
.btn-success:hover:not(:disabled) { filter: brightness(0.92); }
.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; }
}
</style>

View File

@@ -34,7 +34,8 @@ const weekLabel = computed(function () {
const days = weekDays.value
const f = parseISODate(days[0])
const l = parseISODate(days[6])
return f.year + '/' + f.month + '/' + f.day + ' - ' + l.year + '/' + l.month + '/' + l.day
const fmt = (p) => `${p.year}/${p.month}/${p.day}`
return t('weekPreview.rangeFmt', { s: fmt(f), e: fmt(l) })
})
// 收集本周所有 occurrence 并做并排布局, 按日期分组

View File

@@ -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(),
})
// ------------------------------------------------------------------ //
@@ -45,12 +49,12 @@ function organize() {
/**
* 更新指定事件的字段。
* 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。
* uid 不存在视为编程错误, 直接抛出 (调用方应先校验 selectedEvent)。
*/
function updateEvent(uid, patch) {
const ev = state.events.find((e) => e.uid === uid)
if (ev) {
Object.assign(ev, patch)
}
if (!ev) throw new Error(`updateEvent: 事件不存在 uid=${uid}`)
Object.assign(ev, patch)
}
function deleteEvent(uid) {
@@ -59,17 +63,18 @@ function deleteEvent(uid) {
}
function addEvent() {
const ev = createEvent()
const ev = createEvent({ tzid: state.timezone })
state.events.push(ev)
state.selectedUid = ev.uid
}
function splitEvent(uid, splitDate) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev || !ev.rrule) return
if (!ev) throw new Error(`splitEvent: 事件不存在 uid=${uid}`)
if (!ev.rrule) throw new Error('splitEvent: 非重复事件无法拆分')
const parts = splitRecurringEvent(ev, splitDate)
if (!parts) return
if (!parts) throw new Error('splitEvent: 拆分条件不满足 (日期越界或不在网格)')
const [partA, partB] = parts
const idx = state.events.findIndex((e) => e.uid === uid)
@@ -79,7 +84,7 @@ function splitEvent(uid, splitDate) {
function toggleOccurrence(uid, date) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev) return
if (!ev) throw new Error(`toggleOccurrence: 事件不存在 uid=${uid}`)
const i = ev.exdates.indexOf(date)
if (i >= 0) {
ev.exdates.splice(i, 1)
@@ -93,7 +98,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 +113,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 +177,7 @@ export function useCalendar() {
toggleOccurrence,
serialize,
setViewMode,
setTimezone,
prevMonth,
nextMonth,
prevWeek,

View File

@@ -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',
@@ -72,6 +73,7 @@ export default {
collapseNight: 'Hide Night',
noEvents: 'No events this week',
untitled: '(Untitled)',
rangeFmt: '{s} - {e}',
tooltipLocation: 'Location: {location}',
tooltipDesc: 'Description: {description}'
}

View File

@@ -13,7 +13,8 @@ export default {
import: '导入文件',
download: '下载 ICS',
noPattern: '未发现可合并的重复模式',
parseError: '解析失败:'
parseError: '解析失败:',
timezone: '时区'
},
dropzone: {
hint: '拖入 .ics 文件,或',
@@ -72,6 +73,7 @@ export default {
collapseNight: '折叠夜间',
noEvents: '本周无日程',
untitled: '(无标题)',
rangeFmt: '{s} - {e}',
tooltipLocation: '地点: {location}',
tooltipDesc: '描述: {description}'
}

View File

@@ -132,7 +132,13 @@ function parseHHMM(s) {
function getZone(tzid) {
if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone
const z = ICAL.TimezoneService.get(tzid)
return z || ICAL.Timezone.utcTimezone
if (!z) {
// 时区未注册 (VTIMEZONE 缺失): ical.js 回退 UTC 会导致时间偏移。
// 记录到控制台便于定位 (典型成因: 手建事件 tzid 未在源 ICS 中定义)。
console.warn(`[timeTableFix] 时区 ${tzid} 未注册, 回退 UTC`)
return ICAL.Timezone.utcTimezone
}
return z
}
function makeTime(dateStr, timeStr, tzid) {

102
src/lib/timezone.js Normal file
View File

@@ -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
}

127
test/timezone.test.js Normal file
View File

@@ -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')
})
})