Compare commits

...

34 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
root
960657a251 feat: 增加 i18n 能力(独立 vue-i18n 实例 + useLocale + 中英 locale)
- 新增 src/i18n/(独立实例,中文同步、英文懒加载)+ src/composables/useLocale.js
- 抽取所有硬编码中文为 locale 文案(zh-CN/en)
- 组件改用 useLocale 直读自身 i18n 实例(嵌入时不走 useI18n 注入)
- App.vue 接 :locale prop,watch 同步父项目语言(嵌入 mainPage 时桥接)
- 42 项测试全通过,构建正常
2026-07-24 08:59:52 +00:00
2d7b8d0d07 docs: 移除 systemd 部署与 /ttf/ 独立入口说明
本项目仅作为 mainPage submodule 集成,无独立线上入口。
保留独立开发/测试说明(npm run dev/test/build)。
2026-07-23 04:46:23 +00:00
c68d1062ff style: 前端视觉与 mainPage 协调,浅色极简 + 靛蓝主色
- global.css 加设计令牌(与 mainPage variables.css 对齐:bg/surface/accent/
  border/text/success/danger/warn/radius/shadow/transition)
- App.vue 顶栏由深色(#2c3e50)改浅色(surface + border),视图切换器改分段样式
- 全部组件硬编码颜色令牌化:accent(主操作)/success(下载)/danger(删除)/
  warn(单次徽章)/surface/border/text-*
- 表单输入加 focus 描边、按钮统一 border+radius、阴影用令牌
- 月/周预览表头改浅灰、今日高亮改 accent-weak,网格边线用 border 令牌
- 事件配色 PALETTE 不变(数据可视化需高区分度)
- 独立运行 42 测试通过、build 通过
2026-07-23 04:24:04 +00:00
17aeea8359 refactor: App.vue 去掉 defineOptions name,保持子项目纯净
KeepAlive 匹配名改由 mainPage 侧包装器(Timetable.vue)持有,
子项目不再依赖宿主的缓存约定。独立运行不受影响(42 测试通过)。
2026-07-23 04:04:16 +00:00
a49a725950 refactor: App.vue 样式隔离,使其可作为组件被嵌入
- 全局重置(* / body)抽到 src/styles/global.css,仅独立 app 的 main.js 加载
- App.vue <style> 改 scoped,根及内部类名加 ttf- 前缀,避免与宿主冲突
- 根 class app -> ttf-app,min-height 改为填满容器以适配嵌入
- 加 defineOptions({ name: 'timetable' }) 供宿主 KeepAlive 匹配
- README 嵌入说明改为构建期组件集成 + git submodule
- 不影响独立运行:npm test 42 项通过,npm run build 通过
2026-07-23 02:48:58 +00:00
84bd2baeb6 docs: 说明如何作为页签嵌入 mainPage(/ttf/ 反代到 8888)
本项目独立运行;嵌入仅在 mainPage 站点的 Apache 加 /ttf/ 反代 + mainPage
配置 pageUrl,移除不影响本项目独立运行。
2026-07-23 02:26:32 +00:00
zikai
7edcf8c67a fix: 周预览折叠改为折叠浅灰非工作时段区域, 而非整个网格
- 折叠对象改为 8:00 前/22:00 后的浅灰区域, 工作时段始终可见
- 浅灰区域无事件 -> 默认折叠; 有事件 -> 默认展开
- 按钮切换「折叠夜间/展开夜间」, 切换周时按是否有夜间事件重置
- 折叠时网格从 8:00 起 (gridStartMin), 事件与 hour-line 按行索引定位
2026-07-22 07:29:45 +00:00
zikai
e64f9f4adf feat: 周预览非工作时段淡灰, 本周无日程自动折叠, 折叠/展开按钮
- 8:00 前与 22:00 后的时段/hour-line 置淡灰背景
- 本周无日程时自动折叠周网格, 显示占位提示
- 工具栏新增折叠/展开切换按钮, 可强制展开空周
- 切换周时按是否有日程自动重置折叠状态
2026-07-22 07:05:08 +00:00
zikai
037b70deec feat: 自动保存/移除撤销与红点, 间隔合并改进(DAILY/稀疏/交错拆分), 删死代码, 更新测试与文档
- organize: seriesKey 移除 weekday, 解锁每3天等 DAILY 序列合并
- organize: fitSeries 放宽阈值(稀疏序列如 BUS LAW Workshop 合并)
- organize: 新增 splitIntoChains, GCD=1 的交错多班次拆为多个系列
- useCalendar: 移除 dirty/history/undo/snapshot/markSaved 死代码
- HeaderBar: 移除撤销按钮与红点●, 下载兜底序列化当前状态
- 测试: 42 项(原32), 覆盖 DAILY/稀疏/交错拆分/两两和候选等场景
- 文档: README 精简, 新增 docs/organize-algorithm.md 与 auto-save-design.md
- 保留移动端 CSS 适配
2026-07-22 06:32:47 +00:00
zikai
b49dbd46eb feat: 月预览自定义悬停 tooltip, 月/周预览添加回到今日按钮
- MonthPreview: 用自定义 tooltip 替代原生 title 属性, 鼠标悬停事件
  即时显示详细信息 (标题/时间/地点/描述), 固定在右下角避免遮挡
- MonthPreview/WeekPreview: 工具栏新增回到今日按钮, 一键跳转回当天
- useCalendar: 新增 goToToday() action, 重置 previewDate 为今天
2026-07-22 04:29:46 +00:00
zikai
1c6ecf9d86 refactor: 移除重做/应用按钮, 自动应用更改, 删除死代码, 修复性能问题
UI 变更:
- 移除重做按钮及 redo/canRedo/redoStack 相关逻辑 (useCalendar, HeaderBar)
- 移除应用更改按钮, EventDetail 改为 watch form 自动应用 (deep watch)
- 用 syncing 标志防止 syncForm 回写时触发自动应用的循环

死代码清理:
- weekday.js: 移除未使用的 BYDAY_TO_INDEX 和 dowLabel
- date.js: 移除未使用的 toHHMM
- App.vue: 移除未使用的 .empty CSS
- DropZone.vue: 移除空的 .icon 容器及 CSS
- test/weekday.test.js: 移除对应死代码的 2 个测试用例

性能修复:
- WeekPreview: layoutEvents 从模板内联调用改为 computed (laidOutByDate),
  避免每次渲染重新计算布局
- MonthPreview: occByDate 过滤为仅网格内 42 天, 避免展开全部 occurrence

代码质量:
- useCalendar: prevMonth/nextMonth 合并为 shiftMonth(delta), 消除重复
- WeekPreview/MonthPreview: 移除未使用导入 (toISODate, toDate)
2026-07-22 04:18:05 +00:00
zikai
add471eb44 feat: 新增月预览和周预览组件及视图切换滑块
- App.vue: 新增三段式视图切换滑块 (编辑/月预览/周预览), 条件渲染对应组件
- MonthPreview.vue (新增): 月历网格, 每天显示事件标题色块, 悬停显示详情,
  支持上一月/下一月翻页, 今天高亮
- WeekPreview.vue (新增): 时间网格 (7列周一~周日, 24行每小时), 事件按真实
  时间定位 (top/height), 重叠事件并排布局, 支持上一周/下一周翻页
- 编辑模式显示左侧事件列表, 预览模式隐藏列表占满宽度
2026-07-22 04:07:16 +00:00
zikai
f4c11bdc3e feat: 新增视图切换状态管理及颜色/日期工具
- date.js: 新增 startOfWeek, startOfMonth, hhmmToMinutes 辅助函数
- colors.js (新增): 基于 uid 哈希的事件颜色分配, 12 色调色板
- useCalendar.js: 新增 viewMode (edit/month/week) 和 previewDate 状态,
  以及 setViewMode/prevMonth/nextMonth/prevWeek/nextWeek 操作
2026-07-22 04:04:29 +00:00
zikai
50fc8de9eb fix: 移除拖拽区中央的占位文字 2026-07-22 04:03:02 +00:00
zikai
a475315eb7 fix: 解析 ICS 时缺失 UID 导致全部日程被同时选中
ical-io.js 中 UID 缺失时回退为空字符串, 导致所有事件共享 uid='',
EventList 的 active 判断和 selectedEvent 的 find() 全部命中第一个事件。
改为缺失时调用 genUUID() 生成唯一 UID。
2026-07-22 04:02:43 +00:00
zikai
84dbaf9cd2 style: 移除 emoji, 注释改为中文 (保留专业名词)
- 移除所有 Vue 组件和 README 中的 emoji (按钮文字、标题、功能列表)
- 将 src 下所有英文注释改为中文, 保留专业名词 (RRULE, EXDATE, ICS 等)
- 涉及文件: date.js, event-factory.js, ical-io.js, organize.js,
  recur.js, split.js, weekday.js, useCalendar.js, EventDetail.vue,
  App.vue, DropZone.vue, EventList.vue, HeaderBar.vue, README.md
2026-07-22 02:55:45 +00:00
zikai
e9fc10c89d fix: WEEKLY 事件的 BYDAY 与 dtstartDate 不一致导致 ICS 输出错误
applyChanges 中 BYDAY 此前取自表单下拉框(默认 'MO'), 与 dtstartDate
的星期可能不一致。expandOccurrences 按 dtstartDate 展开 occurrence,
但序列化的 RRULE 写入表单选择的 BYDAY, 两者不匹配时日历应用会显示
错误的星期。

修复: BYDAY 始终由 dtstartDate 的星期自动推导(bydayFromDate), 下拉框
改为只读展示。确保 RRULE BYDAY 与实际 occurrence 日期始终一致。
2026-07-22 02:45:04 +00:00
zikai
4a2c53204f fix: crypto.randomUUID 在非安全上下文(非HTTPS)下崩溃
新增 src/lib/uuid.js 提供 genUUID(), 优先使用 crypto.randomUUID,
不可用时回退到 getRandomValues 手动拼装 v4 UUID, 最终回退到 Math.random。

原因: crypto.randomUUID() 仅在安全上下文(HTTPS 或 localhost)可用。
当前站点通过 http://f.zikai.wang:8888 访问, 属于非安全上下文,
导致 event-factory.js 的新增日程和 split.js 的拆分事件功能崩溃。

涉及文件:
- src/lib/uuid.js (新增): 带 fallback 的 UUID 生成
- src/lib/event-factory.js: crypto.randomUUID -> genUUID
- src/lib/split.js: crypto.randomUUID -> genUUID
2026-07-22 02:43:29 +00:00
zikai
6997f7a708 docs: 精简 README, 补充 Ubuntu 安装与 systemd 部署说明
- 重写 README 为精简版, 包含项目结构/Ubuntu 从零安装/启动关闭使用说明
- vite.config.js 添加 preview.allowedHosts 允许 f.zikai.wang 域名访问
- package-lock.json 随 npm 版本差异更新
2026-07-22 02:27:24 +00:00
timeTable2 dev
51461ff5f2 fix: use JSON clone instead of structuredClone for Vue reactive proxies
structuredClone cannot clone Vue reactive Proxy objects (DataCloneError).
EventModel contains only plain data, so JSON round-trip is safe and
lossless. Verified: organize, toggle, undo, download all work correctly.
2026-07-13 20:13:19 +08:00
timeTable2 dev
b352c4e116 refactor: high-cohesion low-coupling cleanup across store and components
ical-io.js:
- Import pad2/parseISODate from date.js (remove local duplicates)
- Rename ICAL.Time helpers to icalTimeTo* for clarity

useCalendar.js:
- Delegate addEvent to createEvent() factory
- Delegate splitEvent to splitRecurringEvent() pure function
- Use cloneEvent() instead of JSON.parse(JSON.stringify())
- Add selectEvent() action (components no longer mutate state.selectedUid directly)

Components:
- EventList: use selectEvent action + toDate() from date.js
- HeaderBar: destructure loadFile at top level (was re-calling useCalendar in handler)
- EventDetail: import DOW from weekday.js (was inlining the array)
- OccurrenceGrid: use toDate() from date.js
2026-07-13 20:06:00 +08:00
timeTable2 dev
02cfdda900 refactor: extract date.js, event-factory.js, split.js; use shared date helpers in recur/organize
- date.js: single source of truth for ISO date formatting/arithmetic
  (toISODate, parseISODate, addDays, daysBetween, todayISO, nowCompactTimestamp)
- event-factory.js: createEvent/cloneEvent (structuredClone with JSON fallback)
- split.js: splitRecurringEvent pure function using intervalDays from recur.js
- recur.js: replaced local toISO() and inline Date math with date.js helpers
- organize.js: replaced inline date formatting and gap math with date.js helpers,
  extracted buildGrid() helper and byDate comparator
2026-07-13 20:01:10 +08:00
timeTable2 dev
e4f549f848 docs: add README 2026-07-13 18:53:39 +08:00
timeTable2 dev
3af6b856e3 fix: store VTIMEZONEs as raw jCal arrays for Vue reactivity safety
ICAL.Component instances break when wrapped by Vue's reactive proxy
(the .jCal getter returns undefined through the proxy, causing
serializeICS to crash with 'Cannot read properties of undefined').
Storing vtz.jCal (plain arrays) instead makes meta fully serializable
for both reactivity and JSON snapshot cloning.
2026-07-13 18:53:01 +08:00
timeTable2 dev
15b6f30896 feat: add EventDetail with edit form, occurrence grid, split 2026-07-13 18:43:28 +08:00
timeTable2 dev
8a1d547001 feat: add OccurrenceGrid component 2026-07-13 18:41:51 +08:00
timeTable2 dev
f52f5315e6 feat: add EventList and HeaderBar with organize/undo/download 2026-07-13 18:40:44 +08:00
timeTable2 dev
906679fc11 feat: add DropZone and App layout skeleton 2026-07-13 18:39:15 +08:00
timeTable2 dev
361cc38384 feat: add central store with snapshot undo/redo 2026-07-13 18:37:58 +08:00
37 changed files with 3291 additions and 226 deletions

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
# timeTableFix - 纯前端 ICS 日历整理器
浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。独立可维护,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块集成展示。
## 项目结构
```
timeTableFix/
├── index.html Vite 入口
├── package.json
├── vite.config.js
├── vitest.config.js
├── .npmrc npmmirror 镜像加速
├── README.md
├── src/
│ ├── main.js 应用挂载i18n + 全局样式)
│ ├── App.vue 便携式根组件(嵌入与独立两用)
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale
│ ├── composables/
│ │ ├── 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
│ │ ├── recur.js RRULE 网格展开
│ │ ├── organize.js 间隔去重 + 链拆分算法
│ │ ├── event-factory.js 事件构造工厂
│ │ └── split.js 事件拆分逻辑
│ └── components/
│ ├── HeaderBar.vue 顶栏:整理/导入/下载
│ ├── DropZone.vue 拖拽/选择文件
│ ├── EventList.vue 侧栏事件列表
│ ├── EventDetail.vue 编辑表单 + 拆分
│ ├── OccurrenceGrid.vue 排除日期网格
│ ├── MonthPreview.vue 月预览
│ └── WeekPreview.vue 周预览
├── test/ Vitest 单元测试
└── docs/ 设计文档
```
## 外部依赖
| 项 | 说明 |
|----|------|
| 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 项)
```
顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。
## 技术栈
| 项 | 选择 |
|----|------|
| 框架 | 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)

74
docs/auto-save-design.md Normal file
View File

@@ -0,0 +1,74 @@
# 自动保存设计
本文档记录 `src/composables/useCalendar.js`
`src/components/HeaderBar.vue` 的保存模型重构。
## 背景
原设计有「dirty 标记 + 下载前确认弹窗 + 快照式 undo/redo最多 50 步)」。
EventDetail 的表单已改为自动应用(`watch(form)` 即时写回 store
commit `1c6ecf9`dirty 仅用于「下载前提示未保存」与红点显示。撤销是
独立的历史栈。
## 重构:移除 dirty / undo
纯前端工具,无持久化需求,所有编辑本就即时生效(自动应用 watch
dirty 标记与确认弹窗是冗余的仪式感undo 栈在自动应用场景下也意义
有限(每次 keystroke 都会 push 历史,栈迅速填满噪声)。移除以降低
复杂度、减少状态字段。
### 删除的死代码(`useCalendar.js`
- `state.history`(快照栈)、`state.dirty`
- `MAX_HISTORY` 常量
- `snapshot()``pushHistory()``restore()`
- `undo()` action、`canUndo` computed
- `markSaved()` action
- 各 action`organize`/`updateEvent`/`deleteEvent`/`addEvent`/
`splitEvent`/`toggleOccurrence`)内的 `pushHistory()`
`state.dirty = true` 调用
- `loadFile` 内的 `state.history = []``state.dirty = false`
- 不再使用的 `cloneEvent` 导入snapshot 曾用)
### `HeaderBar.vue` 改动
- 移除「撤销」按钮
- 移除下载按钮后的红点 `●``<span class="dirty-dot">`)与
`.dirty-dot` 样式
- `onDownload()`:移除 `if (state.dirty && !confirm(...))` 确认弹窗与
`markSaved()` 调用;直接 `serialize()` 下载
## 自动保存语义
- **所有编辑即时生效**`updateEvent`/`deleteEvent`/`addEvent`/
`splitEvent`/`toggleOccurrence` 直接 mutate 响应式 `state.events`
无中间缓冲。EventDetail 的 `watch(form, { deep: true })` 把表单变化
立即 patch 回 store。
- **下载兜底**`onDownload``serialize()` 序列化**当前最新状态**。
即便有任何未触发渲染的边界改动,下载时也会被序列化包含。文件名取
原文件名去 `.ics``.edited.ics`
- **occurrences 不持久化**EventModel 不存 occurrences`OccurrenceGrid`
渲染时实时调 `expandOccurrences(ev)` 计算,`toggleOccurrence` 直接改
`exdates`。无缓存,无不同步风险。
## 状态字段(重构后)
```js
const state = reactive({
meta: null,
events: [],
selectedUid: null,
fileName: null,
viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(),
timezone: detectTimezone(), // 日历级默认时区 (新建事件 tzid / X-WR-TIMEZONE)
})
```
`history`、无 `dirty`、无 `redoStack`。store 仅承载当前状态与视图锚点。
## 取消确认弹窗的理由
`onDownload``dirty` 时弹「有未保存的更改,仍要下载?」。但自动
应用下,所有更改已在 store 中,下载即序列化 store不存在「未保存」。
弹窗只会误导用户以为有数据丢失风险。移除后下载是一次性操作。

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、移除页签等流程

128
docs/organize-algorithm.md Normal file
View File

@@ -0,0 +1,128 @@
# 间隔合并与链拆分算法
本文档记录 `src/lib/organize.js` 的算法设计、关键决策与踩过的坑。
测试用例见 `test/organize.test.js`(含黄金用例 `golden-org.ics`)。
## 目标
把导出为 N 个扁平单次事件的重复日程,合并为带 `RRULE` + `EXDATE`
重复事件。需要覆盖:每周、每两周、每 N 天,以及「稀疏但有规律」(如
约每 3 周一次的 workshop和「交错的多班次」同名同时但分属周一/周二)。
## 算法分层
### 1. 分组(`seriesKey`
键 = `[summary, location, dtstartTime, durationMin].join('|')`
**关键决策:键不含星期。** 早期版本键含 `weekday = getDay()`,导致周期
为非 7 倍数(如每 3 天)的 DAILY 序列落在不同星期,被拆成单事件组,
永远到不了 `fitSeries`——这是「间隔 3 天无法合并」的根因。移除后,
WEEKLY 序列(间隔 7 倍数weekday 本就相同仍归一组DAILY 序列也
能归一组,交给 `fitSeries` 判断。星期改由 `fitSeries` 内的 `byday`
从首日期推导(仅 WEEKLY 有意义DAILY 设 `null`)。
时长(分钟)保留在键中,不同时长不算同系列。
### 2. 单一网格拟合(`fitSeries`
对组内事件:
1. 求所有相邻 gap天数的 GCD作为「最小公约间隔」`d`——使每个 gap
均为其整数倍的最小正整数。例如 gap `[7,7,14,7] -> d=7`(中间跳过
一周14=2×7`[3,3,3] -> d=3``[21,28] -> d=7`
2. 从首日期到末日期按 `d` 步进构建期望网格。
3. `missing` = 网格上但 absent 的日期;`offGrid` = present 但不在网格
上的日期。
4. `offGrid` 非空 -> 该组不是单一周期序列,返回 `null`(交由链拆分)。
5. 否则合并:`d` 是 7 的倍数 -> `WEEKLY``byday` 从首日期推导),
否则 -> `DAILY``byday=null``exdates = missing`
**阈值放宽**:合并条件仅为 `evs.length >= 2``offGrid` 为空。不再
因「太稀疏」(`missing >= present`)而拒绝——稀疏但有规律的序列(如
BUS LAW Workshop3 个事件跨 49 天gap 21/28d=7同样合并为
`WEEKLY` + 5 个 EXDATE。
### 3. 链拆分(`splitIntoChains`
仅在 `fitSeries` 返回 `null` 时调用,即该组无法落在单一 GCD 网格上。
典型场景:同名同时但分属周一/周二的两个班,排序后 gap 交替为
`1,6,1,6,1,6`GCD=1。
**触发条件:组级 GCD=1**(相邻 gap 含互质值,即交错)。`fitSeries`
`interval === 1` 时直接返回 `null`,交由拆分处理。为何 GCD=1 要拆:
`d=1` 的网格逐日命中所有日期,恒产生单链,把所有不在同子序列的日期
当 EXDATE是伪合并如把周一+周二两班误合成一个 `DAILY;INTERVAL=1`)。
**候选间隔 d**:相邻 gap 的**两两之和**(去重,降序)。仅用两两之和而
非单个 gap因为交错子序列的「偏移」如 1 或 2比真实周期短拿单个
gap 当周期会得到过密的伪网格。此函数仅在 GCD=1交错时调用故两两
之和总是能给出真实周期:
- gap `1,6` -> 和 `7`(每周)
- gap `2,5` -> 和 `7`(每周)
**网格抽取**:对每个候选 `d`,从最早未分配日期起按 `d` 步进到最末日期,
收集命中的 present 日期(**允许跳过周期**——缺席的网格点日后记为
EXDATE形成一条链剩余未分配日期重复抽取得若干链。
**择优**:选产生**链数最少**的 `d`(最大化分组);所有链长度须 ≥2
否则该 `d` 不合格(单点链说明 `d` 不适配)。链数相同取较小 `d`
**兜底**:无候选 `d` 能给出全 ≥2 链时,退化为每日期一链(全部保留为
独立事件)。例如仅 2 个相邻同名事件gap 1无两两之和候选
### 4. `organize` 总流程
1.`seriesKey` 分组。
2. 每组调 `organizeGroup`:先 `fitSeries`;成功则输出 1 个合并事件;
失败则 `splitIntoChains` 后对每条链独立 `fitSeries`,链长 <2 或无
规律的保留为独立事件
3. 合并后事件保留 base 的原始 UID不重新分配避免重新导入冲突
保证幂等
## 行为对照表
| 序列 | 相邻 gap | 结果 |
|------|----------|------|
| ACC INFO SYSgolden | 7×7,14,7×3 | WEEKLY;1 + 1 exdate跳过 9-21 |
| 3 | 3,3,3,3 | DAILY;3 |
| 每两周 | 14,14,14 | WEEKLY;2 |
| BUS LAW Workshopgolden | 21,28 | WEEKLY;1 + 5 exdates |
| 周一+周二两班同名同时 | 1,6,1,6,1,6 | 拆成 2 WEEKLY;1MO / TU |
| 两班各暂停一周gap 2,5,2,5,2,12,2,5,2,5 | 交错 | 拆成 2 WEEKLY;1各带 1 exdate |
| 2 个相邻同名事件 | 1 | 保留 2 个独立事件无候选 |
## 踩过的坑
### 坑 1weekday 分组拆碎 DAILY 序列
最初 `seriesKey` `weekday` 3 天的事件落在 5 个不同星期被拆成
5 个单事件组`fitSeries` 永远收不到 2 事件直接调 `fitSeries`
合并 `organize` 的分组步骤先把它拆碎了。**根因在分组键不在
`fitSeries`。** 修复键移除 weekday
### 坑 2GCD=1 的平凡网格伪合并
移除 weekday 周一+周二两班gap 1,6,1,6GCD=1`d=1` 网格逐日
命中所有日期offGrid 恒为空被误合并成一个 `DAILY;INTERVAL=1` +
大量 EXDATE。**这是present>0 即合并」与「拆分交错序列」的直接冲突。**
修复:`fitSeries``interval===1` 时拒绝(交由链拆分)。
### 坑 3候选 d 选错导致伪链
链拆分最初用「所有日期两两之差」作候选,并按链数最少择优。结果对
gap `2,5,2,5,...` 的用例,`d=2``d=7` 都产生 2 条链,平局取较小 `d`
选了 `d=2`,得到错误的 `DAILY;INTERVAL=2` + 大量 EXDATE 分组。`d=2`
是交错偏移而非周期。**修复:候选改为相邻 gap 两两之和**(偏移相加得
真实周期),排除单个 gap。
### 坑 4BUS LAW Workshop 从「3 flat」变「1 recurring」
原阈值 `missing >= present` 拒绝了 BUS LAW Workshop5 missing ≥
3 present保留为 3 个独立事件。放宽阈值后合并为 1 个 WEEKLY + 5
exdates。这是预期行为变化golden 测试相应更新61 事件 -> 6 系列,
原为 8 = 5 系列 + 3 flat
## 决策记录:为何选「最小公约间隔 + EXDATE」而非「严格相等 gap」
严格相等 gap所有相邻 gap 必须完全一致才合并)会拒绝 ACC INFO SYS
gap 含一个 14其余 7破坏现有 golden 合并。最小公约间隔 + EXDATE
允许「跳过周期」gap 是 `d` 的倍数),既保留 ACC INFO SYS 的合并,又
支持稀疏序列BUS LAW Workshop与 DAILY 序列(每 3 天)。交错的多个
序列则由链拆分处理。三者用同一算法兼得。

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 桥接(与时区无关)。

107
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"vue": "^3.5.0" "vue": "^3.5.0",
"vue-i18n": "^9.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
@@ -505,6 +506,47 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@intlify/core-base": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
"dependencies": {
"@intlify/message-compiler": "9.14.4",
"@intlify/shared": "9.14.4"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
"dependencies": {
"@intlify/shared": "9.14.4",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -603,9 +645,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -620,9 +659,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -637,9 +673,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -654,9 +687,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -671,9 +701,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -688,9 +715,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -705,9 +729,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -722,9 +743,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -739,9 +757,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -756,9 +771,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -773,9 +785,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -790,9 +799,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -807,9 +813,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1057,6 +1060,11 @@
"@vue/shared": "3.5.39" "@vue/shared": "3.5.39"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.39", "version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
@@ -2747,6 +2755,25 @@
} }
} }
}, },
"node_modules/vue-i18n": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
"dependencies": {
"@intlify/core-base": "9.14.4",
"@intlify/shared": "9.14.4",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -12,7 +12,8 @@
}, },
"dependencies": { "dependencies": {
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"vue": "^3.5.0" "vue": "^3.5.0",
"vue-i18n": "^9.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",

View File

@@ -1,9 +1,142 @@
<script setup> <script setup>
import { watch } from 'vue'
import { useCalendar } from './composables/useCalendar.js'
import { useLocale } from './composables/useLocale.js'
import { loadLocaleAsync } from './i18n/index.js'
import DropZone from './components/DropZone.vue'
import HeaderBar from './components/HeaderBar.vue'
import EventList from './components/EventList.vue'
import EventDetail from './components/EventDetail.vue'
import MonthPreview from './components/MonthPreview.vue'
import WeekPreview from './components/WeekPreview.vue'
const props = defineProps({
locale: { type: String, default: 'zh-CN' },
})
const { isLoaded, state, setViewMode } = useCalendar()
const { t } = useLocale()
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
watch(
() => props.locale,
(val) => {
if (val) loadLocaleAsync(val)
},
{ immediate: true }
)
</script> </script>
<template> <template>
<div style="font-family: sans-serif; padding: 40px;"> <div class="ttf-app">
<h1>📅 ICS 日程整理器</h1> <header class="ttf-header">
<p>脚手架就绪</p> <div class="ttf-header-left">
<h1>{{ t('app.title') }}</h1>
<div v-if="isLoaded" class="ttf-view-switcher">
<button
:class="{ active: state.viewMode === 'edit' }"
@click="setViewMode('edit')"
>{{ t('view.edit') }}</button>
<button
:class="{ active: state.viewMode === 'month' }"
@click="setViewMode('month')"
>{{ t('view.month') }}</button>
<button
:class="{ active: state.viewMode === 'week' }"
@click="setViewMode('week')"
>{{ t('view.week') }}</button>
</div>
</div>
<HeaderBar />
</header>
<main class="ttf-main">
<DropZone v-if="!isLoaded" />
<template v-else>
<EventList v-if="state.viewMode === 'edit'" />
<div class="ttf-detail-area">
<EventDetail v-if="state.viewMode === 'edit'" />
<MonthPreview v-else-if="state.viewMode === 'month'" />
<WeekPreview v-else-if="state.viewMode === 'week'" />
</div>
</template>
</main>
</div> </div>
</template> </template>
<style scoped>
/* scoped样式仅作用于本组件作为组件被嵌入时不污染宿主。
全局重置与设计令牌在 src/styles/global.css独立 app/ 宿主 :root嵌入时。 */
.ttf-app {
display: flex;
flex-direction: column;
/* 适配嵌入:填满宿主容器;独立 app 下 body 仍提供背景 */
min-height: 0;
height: 100%;
background: var(--bg);
color: var(--text-primary);
}
.ttf-header {
background: var(--surface);
color: var(--text-primary);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.ttf-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.ttf-header h1 {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
color: var(--text-primary);
}
.ttf-view-switcher {
display: flex;
gap: 2px;
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 2px;
}
.ttf-view-switcher button {
background: transparent;
color: var(--text-secondary);
border: none;
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: all var(--transition);
}
.ttf-view-switcher button:hover { color: var(--text-primary); }
.ttf-view-switcher button.active {
background: var(--bg);
color: var(--accent);
font-weight: 600;
box-shadow: var(--shadow);
}
.ttf-main { flex: 1; display: flex; overflow: hidden; min-height: 0; background: var(--bg); }
.ttf-detail-area { flex: 1; overflow-y: auto; }
/* 移动端适配 */
@media (max-width: 768px) {
.ttf-header {
flex-direction: column;
gap: 10px;
padding: 10px 12px;
}
.ttf-header-left {
width: 100%;
justify-content: space-between;
gap: 12px;
}
.ttf-header h1 { font-size: 14px; }
.ttf-view-switcher button { padding: 4px 10px; font-size: 12px; }
.ttf-main { flex-direction: column; }
}
</style>

104
src/components/DropZone.vue Normal file
View File

@@ -0,0 +1,104 @@
<script setup>
import { ref } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
const { loadFile } = useCalendar()
const { t } = useLocale()
const dragOver = ref(false)
const error = ref('')
function onDrop(e) {
dragOver.value = false
error.value = ''
const file = e.dataTransfer.files[0]
if (!file) return
if (!file.name.toLowerCase().endsWith('.ics')) {
error.value = t('dropzone.requireIcs')
return
}
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
})
}
function onPick(e) {
error.value = ''
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)
})
}
</script>
<template>
<div
class="dropzone"
:class="{ active: dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<div class="dropzone-content">
<p class="hint">{{ t('dropzone.hint') }}</p>
<label class="pick-btn">
{{ t('dropzone.pick') }}
<input type="file" accept=".ics" @change="onPick" hidden />
</label>
<p v-if="error" class="error">{{ error }}</p>
</div>
</div>
</template>
<style scoped>
.dropzone {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
margin: 40px;
transition: all var(--transition);
}
.dropzone.active {
border-color: var(--accent);
background: var(--accent-weak);
}
.dropzone-content {
text-align: center;
}
.hint {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 16px;
}
.pick-btn {
display: inline-block;
padding: 10px 24px;
background: var(--accent);
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background var(--transition);
}
.pick-btn:hover {
background: var(--accent-hover);
}
.error {
color: var(--danger);
font-size: 13px;
margin-top: 16px;
}
@media (max-width: 768px) {
.dropzone { margin: 16px; border-radius: 8px; }
.hint { font-size: 13px; }
.pick-btn { padding: 8px 20px; font-size: 13px; }
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup>
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'
import { toDate } from '../lib/date.js'
import OccurrenceGrid from './OccurrenceGrid.vue'
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
const { t } = useLocale()
// 本地表单状态, 选择切换时同步
const form = ref({})
const showSplit = ref(false)
const splitDate = ref('')
// 标记是否正在由选中事件同步表单, 避免 watch form 时回写
let syncing = false
function syncForm(ev) {
syncing = true
if (!ev) { form.value = {}; syncing = false; return }
form.value = {
summary: ev.summary || '',
location: ev.location || '',
description: ev.description || '',
dtstart_time: ev.dtstartTime || '',
dtend_time: ev.dtendTime || '',
is_recurring: ev.rrule !== null,
freq: ev.rrule?.freq || 'WEEKLY',
interval: ev.rrule?.interval || 1,
byday: ev.rrule?.byday || 'MO',
until_date: ev.rrule?.untilDate || '',
}
syncing = false
}
watch(selectedEvent, syncForm, { immediate: true })
// 自动应用: 表单变化时立即写回 store
watch(form, () => {
if (syncing) return
const ev = selectedEvent.value
if (!ev) return
const patch = {
summary: form.value.summary,
location: form.value.location,
description: form.value.description,
dtstartTime: form.value.dtstart_time,
dtendTime: form.value.dtend_time,
}
if (form.value.is_recurring) {
// BYDAY 必须与 dtstartDate 的星期一致, 否则 ICS 输出的 RRULE BYDAY
// 与 expandOccurrences 按 dtstartDate 展开的网格不匹配, 导致日历应用
// 显示的日期与应用内网格不一致。
const byday = form.value.freq === 'WEEKLY'
? bydayFromDate(toDate(ev.dtstartDate))
: null
patch.rrule = {
freq: form.value.freq,
interval: Number(form.value.interval) || 1,
byday,
untilDate: form.value.until_date || ev.rrule?.untilDate || '',
}
} else {
patch.rrule = null
patch.exdates = []
}
updateEvent(ev.uid, patch)
}, { deep: true })
function onDelete() {
const ev = selectedEvent.value
if (!ev) return
if (confirm(t('eventDetail.confirmDelete', { name: ev.summary }))) {
deleteEvent(ev.uid)
}
}
function onSplit() {
const ev = selectedEvent.value
if (!ev || !ev.rrule) return
if (!splitDate.value) {
alert(t('eventDetail.requireSplitDate'))
return
}
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
alert(t('eventDetail.splitDateInvalid'))
return
}
// 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 = ''
}
function onToggleOcc(date) {
const ev = selectedEvent.value
if (!ev) return
toggleOccurrence(ev.uid, date)
}
</script>
<template>
<div v-if="!selectedEvent" class="empty">{{ t('eventDetail.empty') }}</div>
<div v-else class="detail">
<!-- 基本信息 -->
<div class="section-title">{{ t('eventDetail.basicTitle') }}</div>
<div class="form-group">
<label>{{ t('eventDetail.summary') }}</label>
<input type="text" v-model="form.summary" />
</div>
<div class="form-row">
<div class="form-group">
<label>{{ t('eventDetail.startTime') }}</label>
<input type="time" v-model="form.dtstart_time" />
</div>
<div class="form-group">
<label>{{ t('eventDetail.endTime') }}</label>
<input type="time" v-model="form.dtend_time" />
</div>
</div>
<div class="form-group">
<label>{{ t('eventDetail.location') }}</label>
<input type="text" v-model="form.location" />
</div>
<div class="form-group">
<label>{{ t('eventDetail.description') }}</label>
<textarea v-model="form.description"></textarea>
</div>
<!-- 重复规则 (RRULE) -->
<div class="section-title">{{ t('eventDetail.rruleTitle') }}</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.is_recurring" /> {{ t('eventDetail.enableRecur') }}
</label>
</div>
<div v-if="form.is_recurring">
<div class="form-row">
<div class="form-group">
<label>{{ t('eventDetail.freq') }}</label>
<select v-model="form.freq">
<option value="WEEKLY">{{ t('eventDetail.freqWeekly') }}</option>
<option value="DAILY">{{ t('eventDetail.freqDaily') }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t('eventDetail.interval') }}</label>
<input type="number" v-model.number="form.interval" min="1" style="width: 80px" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ t('eventDetail.until') }}</label>
<input type="date" v-model="form.until_date" />
</div>
<div class="form-group" v-if="form.freq === 'WEEKLY'">
<label>{{ t('eventDetail.byday') }}</label>
<select v-model="form.byday" disabled>
<option v-for="(d, i) in DOW_EN" :key="d" :value="d">
{{ DOW[i] }}
</option>
</select>
</div>
</div>
</div>
<!-- occurrence / 排除日期网格 -->
<div v-if="form.is_recurring" class="section-title">{{ t('eventDetail.occTitle') }}</div>
<OccurrenceGrid
v-if="form.is_recurring && selectedEvent.rrule"
:event="selectedEvent"
@toggle="onToggleOcc"
/>
<!-- 工具栏 -->
<div class="toolbar">
<button
v-if="selectedEvent.rrule"
class="btn btn-secondary"
@click="showSplit = !showSplit"
>
{{ t('eventDetail.split') }}
</button>
<button class="btn btn-danger" @click="onDelete">{{ t('eventDetail.delete') }}</button>
</div>
<!-- 拆分面板 -->
<div v-if="showSplit && selectedEvent.rrule" class="split-panel">
<p>{{ t('eventDetail.splitDesc') }}</p>
<input type="date" v-model="splitDate" />
<button class="btn btn-primary" @click="onSplit">{{ t('eventDetail.splitConfirm') }}</button>
<button class="btn btn-secondary" @click="showSplit = false">{{ t('eventDetail.cancel') }}</button>
</div>
</div>
</template>
<style scoped>
.detail { padding: 24px 32px; }
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-tertiary);
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 5px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
background: var(--bg);
color: var(--text-primary);
transition: border-color var(--transition);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea { resize: vertical; min-height: 60px; }
.form-row { display: flex; gap: 16px; }
.form-row .form-group { flex: 1; }
.toolbar {
display: flex;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.btn {
padding: 7px 16px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
}
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-secondary { background: var(--bg); color: var(--text-secondary); }
.btn-secondary:hover { background: var(--surface); color: var(--text-primary); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.92); }
.split-panel {
margin-top: 16px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.split-panel p { width: 100%; font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; }
.split-panel input { padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
@media (max-width: 768px) {
.detail { padding: 16px; }
.form-row { flex-direction: column; gap: 0; }
.toolbar { flex-wrap: wrap; }
.btn { padding: 6px 12px; font-size: 12px; }
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup>
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js'
const { state, stats, addEvent, selectEvent } = useCalendar()
const { t } = useLocale()
function dowFor(ev) {
return DOW[toDate(ev.dtstartDate).getDay()]
}
</script>
<template>
<div class="event-list">
<div class="stat">
{{ t('eventList.stat', { total: stats.total, recurring: stats.recurring, single: stats.single }) }}
</div>
<button class="add-btn" @click="addEvent">{{ t('eventList.add') }}</button>
<div class="list">
<div
v-for="ev in state.events"
:key="ev.uid"
class="event-item"
:class="{ active: ev.uid === state.selectedUid }"
@click="selectEvent(ev.uid)"
>
<div class="title">
{{ ev.summary || t('eventList.untitled') }}
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
{{ ev.rrule ? t('eventList.recurring') : t('eventList.single') }}
</span>
</div>
<div class="meta">
{{ dowFor(ev) }} {{ ev.dtstartTime }}{{ ev.dtendTime }}
<span v-if="ev.location"> · {{ ev.location }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.event-list {
width: 340px;
background: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.stat { font-size: 12px; color: var(--text-tertiary); padding: 12px 16px 8px; }
.add-btn {
margin: 0 16px 8px;
padding: 8px;
border: 1px dashed var(--accent);
background: var(--accent-weak);
color: var(--accent);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
transition: background var(--transition);
}
.add-btn:hover { background: var(--surface-2); }
.list { flex: 1; overflow-y: auto; }
.event-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background var(--transition);
}
.event-item:hover { background: var(--surface); }
.event-item.active {
background: var(--accent-weak);
border-left: 3px solid var(--accent);
}
.title { font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 3px; }
.meta { font-size: 12px; color: var(--text-secondary); }
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
}
.badge-recur { background: var(--success-weak); color: var(--success); }
.badge-single { background: var(--warn-weak); color: var(--warn); }
@media (max-width: 768px) {
.event-list {
width: 100%;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid var(--border);
}
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup>
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, setTimezone } = useCalendar()
const { t } = useLocale()
const fileInput = ref(null)
const timezones = listTimezones()
function onOrganize() {
const stats = organize()
if (stats && stats.series === 0) {
alert(t('header.noPattern'))
}
}
function onImport() {
fileInput.value?.click()
}
function onFilePicked(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
alert(t('header.parseError') + (err.message || err))
})
e.target.value = ''
}
function onDownload() {
// 兜底自动保存: 始终序列化当前最新状态 (所有编辑已即时生效)
const text = serialize()
const blob = new Blob([text], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const base = state.fileName ? state.fileName.replace(/\.ics$/i, '') : 'calendar'
a.href = url
a.download = base + '.edited.ics'
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="header-actions">
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
{{ t('header.organize') }}
</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>
<style scoped>
.header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
.btn {
padding: 7px 16px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all var(--transition);
white-space: nowrap;
background: var(--bg);
color: var(--text-primary);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.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); }
/* 时区选择器:与按钮同行,紧凑 */
.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

@@ -0,0 +1,262 @@
<script setup>
import { ref, computed } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { expandOccurrences } from '../lib/recur.js'
import { eventColor } from '../lib/colors.js'
import {
startOfMonth, startOfWeek, addDays, parseISODate, todayISO,
} from '../lib/date.js'
import { DOW } from '../lib/weekday.js'
const { state, prevMonth, nextMonth, goToToday } = useCalendar()
const { t } = useLocale()
// 表头: 周一~周日
const headers = DOW.slice(1).concat(DOW[0])
// 自定义 tooltip 状态
const hoverEvent = ref(null)
// 当前预览月的年月标签
const monthLabel = computed(function () {
const parts = parseISODate(startOfMonth(state.previewDate))
return t('monthPreview.monthFmt', { y: parts.year, m: parts.month })
})
// 构建月历网格 (6 行 x 7 列, 从当月首日所在周的周一开始)
const gridDays = computed(function () {
const monthStart = startOfMonth(state.previewDate)
const gridStart = startOfWeek(monthStart)
const days = []
for (var i = 0; i < 42; i++) {
days.push(addDays(gridStart, i))
}
return days
})
// 按日期分组 occurrence, 仅保留网格内的日期, 过滤掉 skipped
// 返回 Map<dateStr, Array<{ event, date }>>
const occByDate = computed(function () {
const gridSet = new Set(gridDays.value)
const map = new Map()
for (const ev of state.events) {
const occs = expandOccurrences(ev)
for (const occ of occs) {
if (occ.skipped) continue
if (!gridSet.has(occ.date)) continue
if (!map.has(occ.date)) map.set(occ.date, [])
map.get(occ.date).push({ event: ev, date: occ.date })
}
}
return map
})
function isInMonth(dateStr) {
const target = parseISODate(state.previewDate)
const day = parseISODate(dateStr)
return target.month === day.month && target.year === day.year
}
function colorStyle(uid) {
const c = eventColor(uid)
return {
backgroundColor: c.bg,
borderLeft: '3px solid ' + c.border,
color: c.text,
}
}
function showTooltip(ev) {
hoverEvent.value = ev
}
function hideTooltip() {
hoverEvent.value = null
}
</script>
<template>
<div class="month-preview">
<div class="preview-toolbar">
<button class="nav-btn" @click="prevMonth">{{ t('monthPreview.prev') }}</button>
<span class="month-label">{{ monthLabel }}</span>
<button class="nav-btn" @click="nextMonth">{{ t('monthPreview.next') }}</button>
<button class="nav-btn today-btn" @click="goToToday">{{ t('monthPreview.today') }}</button>
</div>
<div class="month-grid">
<div v-for="h in headers" :key="h" class="grid-header">{{ h }}</div>
<div
v-for="dateStr in gridDays"
:key="dateStr"
class="grid-cell"
:class="{
'out-of-month': !isInMonth(dateStr),
'is-today': dateStr === todayISO(),
}"
>
<span class="day-num">{{ parseISODate(dateStr).day }}</span>
<div class="event-list">
<div
v-for="occ in (occByDate.get(dateStr) || [])"
:key="occ.event.uid + occ.date"
class="event-chip"
:style="colorStyle(occ.event.uid)"
@mouseenter="showTooltip(occ.event)"
@mouseleave="hideTooltip"
>
{{ occ.event.summary || t('monthPreview.untitled') }}
</div>
</div>
</div>
</div>
<!-- 自定义 tooltip -->
<div v-if="hoverEvent" class="event-tooltip" @mouseenter="showTooltip(hoverEvent)" @mouseleave="hideTooltip">
<div class="tooltip-title">{{ hoverEvent.summary || t('monthPreview.untitled') }}</div>
<div class="tooltip-row">{{ t('monthPreview.tooltipTime', { start: hoverEvent.dtstartTime, end: hoverEvent.dtendTime }) }}</div>
<div v-if="hoverEvent.location" class="tooltip-row">{{ t('monthPreview.tooltipLocation', { location: hoverEvent.location }) }}</div>
<div v-if="hoverEvent.description" class="tooltip-desc">{{ hoverEvent.description }}</div>
</div>
</div>
</template>
<style scoped>
.month-preview {
padding: 16px 24px;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.preview-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 12px;
}
.nav-btn {
padding: 5px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg);
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
transition: background var(--transition);
}
.nav-btn:hover { background: var(--surface); }
.today-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.today-btn:hover { background: var(--accent-weak); }
.month-label {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
min-width: 140px;
text-align: center;
}
.month-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 1fr;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.grid-header {
background: var(--surface-2);
color: var(--text-secondary);
text-align: center;
font-size: 13px;
font-weight: 600;
padding: 8px 0;
}
.grid-cell {
background: var(--bg);
padding: 4px 6px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.grid-cell.out-of-month { background: var(--surface); }
.grid-cell.out-of-month .day-num { color: var(--text-tertiary); }
.grid-cell.is-today { background: var(--accent-weak); }
.grid-cell.is-today .day-num {
color: #fff;
background: var(--accent);
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.day-num {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.event-list {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-chip {
font-size: 11px;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.event-tooltip {
position: fixed;
bottom: 16px;
right: 16px;
max-width: 320px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-hover);
padding: 12px 16px;
z-index: 100;
font-size: 13px;
}
.tooltip-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.tooltip-row {
color: var(--text-secondary);
margin-bottom: 3px;
}
.tooltip-desc {
color: var(--text-tertiary);
margin-top: 6px;
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 768px) {
.month-preview { padding: 10px 8px; }
.preview-toolbar { gap: 8px; flex-wrap: wrap; }
.nav-btn { padding: 4px 10px; font-size: 12px; }
.month-label { font-size: 14px; min-width: 100px; }
.grid-header { font-size: 11px; padding: 5px 0; }
.grid-cell { padding: 2px 3px; }
.day-num { font-size: 11px; }
.event-chip { font-size: 9px; padding: 0 3px; }
.event-tooltip { max-width: calc(100vw - 32px); bottom: 8px; right: 8px; left: 8px; }
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
import { computed } from 'vue'
import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js'
import { expandOccurrences } from '../lib/recur.js'
import { useLocale } from '../composables/useLocale.js'
const props = defineProps({
event: { type: Object, required: true },
})
const emit = defineEmits(['toggle'])
const { t } = useLocale()
const occurrences = computed(() => expandOccurrences(props.event))
function dowFor(dateStr) {
return DOW[toDate(dateStr).getDay()]
}
</script>
<template>
<div>
<p class="hint">
{{ t('eventDetail.occHint') }}
</p>
<div class="occ-grid">
<div
v-for="occ in occurrences"
:key="occ.date"
class="occ"
:class="occ.skipped ? 'skipped' : 'active'"
@click="emit('toggle', occ.date)"
>
{{ occ.date }}<br />
<span class="dow">{{ dowFor(occ.date) }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.occ-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding: 4px;
}
.occ {
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
text-align: center;
transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
}
.occ:hover { border-color: var(--accent); }
.occ.skipped {
background: var(--danger-weak);
border-color: var(--danger);
color: var(--danger);
text-decoration: line-through;
}
.occ.active {
background: var(--success-weak);
border-color: var(--success);
color: var(--success);
}
.dow { font-size: 10px; color: var(--text-tertiary); }
</style>

View File

@@ -0,0 +1,377 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { expandOccurrences } from '../lib/recur.js'
import { eventColor } from '../lib/colors.js'
import {
startOfWeek, addDays, parseISODate, todayISO, hhmmToMinutes,
} from '../lib/date.js'
import { DOW } from '../lib/weekday.js'
const { state, prevWeek, nextWeek, goToToday } = useCalendar()
const { t } = useLocale()
const HOUR_HEIGHT = 48 // 每小时像素高度
const headers = DOW.slice(1).concat(DOW[0]) // 周一..周日
const hours = Array.from({ length: 24 }, function (_, i) { return i })
// 非工作时段: 8:00 前 (0-7) 与 22:00 后 (22-23) 置淡灰
const OFF_HOUR_START = 8 // 8:00 起为工作时段
const OFF_HOUR_END = 22 // 22:00 起为非工作时段
function isOffHour(h) {
return h < OFF_HOUR_START || h >= OFF_HOUR_END
}
// 当前周的 7 天日期 (周一~周日)
const weekDays = computed(function () {
const ws = startOfWeek(state.previewDate)
return Array.from({ length: 7 }, function (_, i) { return addDays(ws, i) })
})
// 周范围标签
const weekLabel = computed(function () {
const days = weekDays.value
const f = parseISODate(days[0])
const l = parseISODate(days[6])
const fmt = (p) => `${p.year}/${p.month}/${p.day}`
return t('weekPreview.rangeFmt', { s: fmt(f), e: fmt(l) })
})
// 收集本周所有 occurrence 并做并排布局, 按日期分组
// 返回 Map<dateStr, Array<layoutItem>>
// layoutItem: { event, date, startMin, endMin, col, totalCols }
const laidOutByDate = computed(function () {
const weekSet = new Set(weekDays.value)
const rawByDate = new Map()
for (const ev of state.events) {
const occs = expandOccurrences(ev)
for (const occ of occs) {
if (occ.skipped) continue
if (!weekSet.has(occ.date)) continue
const startMin = hhmmToMinutes(ev.dtstartTime)
const endMin = hhmmToMinutes(ev.dtendTime)
if (!rawByDate.has(occ.date)) rawByDate.set(occ.date, [])
rawByDate.get(occ.date).push({ event: ev, date: occ.date, startMin: startMin, endMin: endMin })
}
}
// 对每天的事件做并排布局: 检测重叠并分配列
const result = new Map()
for (const entry of rawByDate) {
var date = entry[0]
var items = entry[1]
if (items.length === 0) { result.set(date, []); continue }
var sorted = items.slice().sort(function (a, b) { return a.startMin - b.startMin })
var placed = [] // { item, col }
for (var si = 0; si < sorted.length; si++) {
var item = sorted[si]
var col = 0
while (true) {
var conflict = null
for (var pi = 0; pi < placed.length; pi++) {
var p = placed[pi]
if (p.col === col && p.item.startMin < item.endMin && item.startMin < p.item.endMin) {
conflict = p
break
}
}
if (!conflict) break
col++
}
placed.push({ item: item, col: col })
}
var maxCol = 0
for (var mi = 0; mi < placed.length; mi++) { if (placed[mi].col > maxCol) maxCol = placed[mi].col }
var laid = placed.map(function (p) {
return Object.assign({}, p.item, { col: p.col, totalCols: maxCol + 1 })
})
result.set(date, laid)
}
return result
})
// 本周是否有任何日程
const hasEvents = computed(function () {
for (const items of laidOutByDate.value.values()) {
if (items.length > 0) return true
}
return false
})
// 非工作时段 (8:00 前 / 22:00 后) 是否有事件落入
// 事件开始 < 8:00 或 结束 > 22:00 即视为占用非工作时段
const offHoursHaveEvents = computed(function () {
const offStart = OFF_HOUR_START * 60
const offEnd = OFF_HOUR_END * 60
for (const items of laidOutByDate.value.values()) {
for (const it of items) {
if (it.startMin < offStart || it.endMin > offEnd) return true
}
}
return false
})
// 折叠状态: 折叠的是非工作时段 (浅灰区域), 不是整个网格。
// 默认: 非工作时段无事件 -> 折叠; 有事件 -> 展开。按钮可强制切换。
const offHoursCollapsed = ref(false)
watch(weekLabel, function () {
// 切换周时, 按非工作时段是否有事件重置折叠状态
offHoursCollapsed.value = !offHoursHaveEvents.value
}, { immediate: true })
function toggleCollapse() {
offHoursCollapsed.value = !offHoursCollapsed.value
}
// 渲染的小时列表: 折叠时仅显示工作时段 (8-21), 展开时显示全天 (0-23)
const displayHours = computed(function () {
if (offHoursCollapsed.value) {
return hours.filter(function (h) { return !isOffHour(h) })
}
return hours
})
// 网格起始分钟: 折叠时从 8:00 (480) 起, 展开时从 0:00 起。
// 事件与 hour-line 的 top 均相对于此基准。
const gridStartMin = computed(function () {
return offHoursCollapsed.value ? OFF_HOUR_START * 60 : 0
})
// 折叠时 day-body 的高度 (仅工作时段) 与展开时 (全天) 不同
const dayBodyHeight = computed(function () {
return displayHours.value.length * HOUR_HEIGHT
})
function eventStyle(occ) {
const c = eventColor(occ.event.uid)
const pxPerMin = HOUR_HEIGHT / 60
const top = (occ.startMin - gridStartMin.value) * pxPerMin
const height = Math.max((occ.endMin - occ.startMin) * pxPerMin, 18)
const widthPct = 100 / occ.totalCols
return {
top: top + 'px',
height: height + 'px',
left: (occ.col * widthPct) + '%',
width: widthPct + '%',
backgroundColor: c.bg,
borderLeft: '3px solid ' + c.border,
color: c.text,
}
}
function tooltip(ev) {
const parts = [ev.summary || t('weekPreview.untitled')]
parts.push(ev.dtstartTime + ' - ' + ev.dtendTime)
if (ev.location) parts.push(t('weekPreview.tooltipLocation', { location: ev.location }))
if (ev.description) parts.push(t('weekPreview.tooltipDesc', { description: ev.description }))
return parts.join('\n')
}
</script>
<template>
<div class="week-preview">
<div class="preview-toolbar">
<button class="nav-btn" @click="prevWeek">{{ t('weekPreview.prev') }}</button>
<span class="week-label">{{ weekLabel }}</span>
<button class="nav-btn" @click="nextWeek">{{ t('weekPreview.next') }}</button>
<button class="nav-btn today-btn" @click="goToToday">{{ t('weekPreview.today') }}</button>
<button class="nav-btn collapse-btn" @click="toggleCollapse">
{{ offHoursCollapsed ? t('weekPreview.expandNight') : t('weekPreview.collapseNight') }}
</button>
<span v-if="!hasEvents" class="empty-hint">{{ t('weekPreview.noEvents') }}</span>
</div>
<div class="week-grid-wrapper">
<div class="week-grid">
<!-- 左侧时间标签列 -->
<div class="time-col">
<div class="time-spacer"></div>
<div
v-for="h in displayHours"
:key="h"
class="time-slot"
:class="{ 'off-hour': isOffHour(h) }"
:style="{ height: HOUR_HEIGHT + 'px' }"
>
{{ String(h).padStart(2, '0') }}:00
</div>
</div>
<!-- 7 天列 -->
<div v-for="(dateStr, idx) in weekDays" :key="dateStr" class="day-col">
<div class="day-header" :class="{ 'is-today': dateStr === todayISO() }">
{{ headers[idx] }}
<span class="day-date">{{ parseISODate(dateStr).day }}</span>
</div>
<div class="day-body" :style="{ height: dayBodyHeight + 'px' }">
<div
v-for="(h, i) in displayHours"
:key="h"
class="hour-line"
:class="{ 'off-hour': isOffHour(h) }"
:style="{ top: i * HOUR_HEIGHT + 'px', height: HOUR_HEIGHT + 'px' }"
></div>
<div
v-for="occ in (laidOutByDate.get(dateStr) || [])"
:key="occ.event.uid + occ.date"
class="event-block"
:style="eventStyle(occ)"
:title="tooltip(occ.event)"
>
<div class="event-title">{{ occ.event.summary || t('weekPreview.untitled') }}</div>
<div class="event-time">{{ occ.event.dtstartTime }} - {{ occ.event.dtendTime }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.week-preview {
padding: 16px 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.preview-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 12px;
}
.nav-btn {
padding: 5px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg);
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
transition: background var(--transition);
}
.nav-btn:hover { background: var(--surface); }
.today-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.today-btn:hover { background: var(--accent-weak); }
.collapse-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.collapse-btn:hover { background: var(--accent-weak); }
.empty-hint {
font-size: 13px;
color: var(--text-tertiary);
font-style: italic;
}
.week-label {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
min-width: 200px;
text-align: center;
}
.week-grid-wrapper {
flex: 1;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.week-grid {
display: flex;
min-width: 700px;
}
.time-col {
width: 56px;
flex-shrink: 0;
border-right: 1px solid var(--border);
}
.time-spacer {
height: 40px;
border-bottom: 1px solid var(--border);
}
.time-slot {
font-size: 11px;
color: var(--text-tertiary);
text-align: right;
padding-right: 6px;
border-bottom: 1px solid var(--surface-2);
}
/* 非工作时段 (8:00 前 / 22:00 后) 淡灰 */
.time-slot.off-hour {
color: var(--text-tertiary);
background: var(--surface);
}
.day-col {
flex: 1;
min-width: 0;
border-right: 1px solid var(--border);
}
.day-col:last-child { border-right: none; }
.day-header {
height: 40px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.day-header.is-today {
background: var(--accent-weak);
color: var(--accent);
}
.day-date {
font-size: 14px;
}
.day-body {
position: relative;
overflow: hidden;
}
.hour-line {
position: absolute;
left: 0;
right: 0;
border-bottom: 1px solid var(--surface-2);
}
.hour-line.off-hour {
background: var(--surface);
}
.event-block {
position: absolute;
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
overflow: hidden;
box-sizing: border-box;
cursor: default;
z-index: 1;
}
.event-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time {
font-size: 10px;
opacity: 0.8;
}
@media (max-width: 768px) {
.week-preview { padding: 10px 4px; }
.preview-toolbar { gap: 8px; flex-wrap: wrap; }
.nav-btn { padding: 4px 10px; font-size: 12px; }
.week-label { font-size: 13px; min-width: 140px; }
.week-grid { min-width: 600px; }
.time-col { width: 40px; }
.time-slot { font-size: 10px; padding-right: 3px; }
.day-header { font-size: 10px; height: 34px; }
.day-date { font-size: 12px; }
.time-spacer { height: 34px; }
.event-block { font-size: 10px; padding: 1px 2px; }
.event-title { font-size: 10px; }
.event-time { font-size: 9px; }
}
</style>

View File

@@ -0,0 +1,187 @@
import { reactive, computed } from 'vue'
import { parseICS, serializeICS } from '../lib/ical-io.js'
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({
meta: null,
events: [],
selectedUid: null,
fileName: null,
viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
// 日历级默认时区: 加载时自动检测用户所在时区, 取不到回退墨尔本。
// 仅作新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE, 不做时间换算。
timezone: detectTimezone(),
})
// ------------------------------------------------------------------ //
// 文件加载
// ------------------------------------------------------------------ //
function loadFile(file) {
return file.text().then((text) => {
const parsed = parseICS(text)
state.meta = parsed.meta
state.events = parsed.events
state.selectedUid = null
state.fileName = file.name
})
}
// ------------------------------------------------------------------ //
// 事件操作 (所有改动即时生效 -- 自动保存)
// ------------------------------------------------------------------ //
function selectEvent(uid) {
state.selectedUid = uid
}
function organize() {
if (state.events.length === 0) return
const result = organizeEvents(state.events)
state.events = result.events
return result.stats
}
/**
* 更新指定事件的字段。
* 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。
* uid 不存在视为编程错误, 直接抛出 (调用方应先校验 selectedEvent)。
*/
function updateEvent(uid, patch) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev) throw new Error(`updateEvent: 事件不存在 uid=${uid}`)
Object.assign(ev, patch)
}
function deleteEvent(uid) {
state.events = state.events.filter((e) => e.uid !== uid)
if (state.selectedUid === uid) state.selectedUid = null
}
function addEvent() {
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) throw new Error(`splitEvent: 事件不存在 uid=${uid}`)
if (!ev.rrule) throw new Error('splitEvent: 非重复事件无法拆分')
const parts = splitRecurringEvent(ev, splitDate)
if (!parts) throw new Error('splitEvent: 拆分条件不满足 (日期越界或不在网格)')
const [partA, partB] = parts
const idx = state.events.findIndex((e) => e.uid === uid)
state.events.splice(idx, 1, partA, partB)
state.selectedUid = partB.uid
}
function toggleOccurrence(uid, date) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev) throw new Error(`toggleOccurrence: 事件不存在 uid=${uid}`)
const i = ev.exdates.indexOf(date)
if (i >= 0) {
ev.exdates.splice(i, 1)
} else {
ev.exdates.push(date)
ev.exdates.sort()
}
}
// ------------------------------------------------------------------ //
// 序列化 (下载时兜底: 始终序列化当前最新状态)
// ------------------------------------------------------------------ //
function serialize() {
// 把当前选定默认时区写入 X-WR-TIMEZONE, 使下载的 ICS 携带该标识。
// 保留 meta 原值用于回显, 仅在输出时覆盖 (不污染内存状态)。
const meta = state.meta
? { ...state.meta, xWrTimezone: state.timezone }
: state.meta
return serializeICS({ meta, events: state.events })
}
// ------------------------------------------------------------------ //
// 视图切换 / 预览导航
// ------------------------------------------------------------------ //
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)
state.previewDate = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-01`
}
function prevMonth() {
shiftMonth(-1)
}
function nextMonth() {
shiftMonth(1)
}
function prevWeek() {
state.previewDate = addDays(state.previewDate, -7)
}
function nextWeek() {
state.previewDate = addDays(state.previewDate, 7)
}
function goToToday() {
state.previewDate = todayISO()
}
// ------------------------------------------------------------------ //
// 计算属性
// ------------------------------------------------------------------ //
const selectedEvent = computed(() =>
state.events.find((e) => e.uid === state.selectedUid) || null,
)
const isLoaded = computed(() => state.events.length > 0)
const stats = computed(() => {
const nRecur = state.events.filter((e) => e.rrule !== null).length
return { total: state.events.length, recurring: nRecur, single: state.events.length - nRecur }
})
export function useCalendar() {
return {
state,
selectedEvent,
isLoaded,
stats,
loadFile,
selectEvent,
organize,
updateEvent,
deleteEvent,
addEvent,
splitEvent,
toggleOccurrence,
serialize,
setViewMode,
setTimezone,
prevMonth,
nextMonth,
prevWeek,
nextWeek,
goToToday,
}
}

View File

@@ -0,0 +1,14 @@
import { computed } from 'vue'
import i18n from '../i18n/index.js'
// ★ 子项目自有的语言辅助 composable。
// 关键:直接读模块级 i18n 实例的 global composer而非 useI18n() 注入。
// 原因useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
// 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。
export function useLocale() {
const { t, locale } = i18n.global
const isZh = computed(() => locale.value === 'zh-CN')
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
}

46
src/i18n/index.js Normal file
View File

@@ -0,0 +1,46 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.js'
// ★ timeTableFix 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vuewatch 同步到本实例,
// 从而跟随父项目语言开关切换(不共享父的 messages仅同步 locale 值,文案来自本子项目自有 locale
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN
}
})
const loadedLocales = new Set(['zh-CN'])
/**
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
* @param {string} locale
*/
export async function loadLocaleAsync(locale) {
if (i18n.global.locale.value === locale) return
if (loadedLocales.has(locale)) {
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
return
}
let messages
if (locale === 'en') {
messages = (await import('./locales/en.js')).default
} else {
i18n.global.locale.value = 'zh-CN'
document.documentElement.setAttribute('lang', 'zh-CN')
return
}
i18n.global.mergeLocaleMessage(locale, messages)
loadedLocales.add(locale)
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
}
export default i18n

80
src/i18n/locales/en.js Normal file
View File

@@ -0,0 +1,80 @@
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk。
export default {
app: {
title: 'ICS Calendar Organizer'
},
view: {
edit: 'Edit',
month: 'Month',
week: 'Week'
},
header: {
organize: 'Organize & Dedupe',
import: 'Import File',
download: 'Download ICS',
noPattern: 'No recurring patterns found to merge',
parseError: 'Parse failed: ',
timezone: 'Time Zone'
},
dropzone: {
hint: 'Drop an .ics file, or',
pick: 'Click to select a file',
requireIcs: 'Please select an .ics file',
parseError: 'Parse failed: '
},
eventList: {
stat: '{total} events ({recurring} recurring / {single} single)',
add: 'Add Event',
untitled: '(Untitled)',
recurring: 'Recurring',
single: 'Single'
},
eventDetail: {
empty: 'Select an event from the left to edit',
basicTitle: 'Basic Info',
summary: 'Title (SUMMARY)',
startTime: 'Start Time',
endTime: 'End Time',
location: 'Location (LOCATION)',
description: 'Description (DESCRIPTION)',
rruleTitle: 'Recurrence Rule (RRULE)',
enableRecur: 'Enable recurrence',
freq: 'Frequency',
freqWeekly: 'Weekly (WEEKLY)',
freqDaily: 'Daily (DAILY)',
interval: 'Interval (INTERVAL)',
until: 'Until (UNTIL)',
byday: 'Weekday (BYDAY, auto from start date)',
occTitle: 'occurrence / excluded dates',
occHint: 'Click a date to toggle "exclude/include". Excluded dates (red strikethrough) are written to EXDATE.',
split: 'Split this event',
delete: 'Delete this event',
splitDesc: 'Split this recurring event into two segments from the specified date.',
splitConfirm: 'Confirm Split',
cancel: 'Cancel',
confirmDelete: 'Delete "{name}"?',
requireSplitDate: 'Please select a split date',
splitDateInvalid: 'Split date must be between the start and end dates'
},
monthPreview: {
prev: 'Prev Month',
next: 'Next Month',
today: 'Today',
untitled: '(Untitled)',
monthFmt: '{y}/{m}',
tooltipTime: 'Time: {start} - {end}',
tooltipLocation: 'Location: {location}'
},
weekPreview: {
prev: 'Prev Week',
next: 'Next Week',
today: 'Today',
expandNight: 'Show Night',
collapseNight: 'Hide Night',
noEvents: 'No events this week',
untitled: '(Untitled)',
rangeFmt: '{s} - {e}',
tooltipLocation: 'Location: {location}',
tooltipDesc: 'Description: {description}'
}
}

80
src/i18n/locales/zh-CN.js Normal file
View File

@@ -0,0 +1,80 @@
// 中文语言包(默认语言,同步注入主 chunk
export default {
app: {
title: 'ICS 日程整理器'
},
view: {
edit: '编辑',
month: '月预览',
week: '周预览'
},
header: {
organize: '整理去重',
import: '导入文件',
download: '下载 ICS',
noPattern: '未发现可合并的重复模式',
parseError: '解析失败:',
timezone: '时区'
},
dropzone: {
hint: '拖入 .ics 文件,或',
pick: '点击选择文件',
requireIcs: '请选择 .ics 文件',
parseError: '解析失败:'
},
eventList: {
stat: '共 {total} 个日程({recurring} 重复 / {single} 单次)',
add: '新增日程',
untitled: '(无标题)',
recurring: '重复',
single: '单次'
},
eventDetail: {
empty: '从左侧选择一个日程进行编辑',
basicTitle: '基本信息',
summary: '标题 (SUMMARY)',
startTime: '开始时间',
endTime: '结束时间',
location: '地点 (LOCATION)',
description: '描述 (DESCRIPTION)',
rruleTitle: '重复规则 (RRULE)',
enableRecur: '启用重复',
freq: '频率',
freqWeekly: '每周 (WEEKLY)',
freqDaily: '每天 (DAILY)',
interval: '间隔 (INTERVAL)',
until: '重复到 (UNTIL)',
byday: '星期 (BYDAY, 由开始日期自动确定)',
occTitle: 'occurrence / 排除日期',
occHint: '点击日期切换"排除/包含"状态。排除的日期(红色删除线)会写入 EXDATE。',
split: '拆分此日程',
delete: '删除此日程',
splitDesc: '从指定日期起,把此重复事件拆成前后两段。',
splitConfirm: '确认拆分',
cancel: '取消',
confirmDelete: '确定删除"{name}"',
requireSplitDate: '请选择拆分日期',
splitDateInvalid: '拆分日期必须在开始日期和结束日期之间'
},
monthPreview: {
prev: '上个月',
next: '下个月',
today: '回到今日',
untitled: '(无标题)',
monthFmt: '{y} 年 {m} 月',
tooltipTime: '时间: {start} - {end}',
tooltipLocation: '地点: {location}'
},
weekPreview: {
prev: '上一周',
next: '下一周',
today: '回到今日',
expandNight: '展开夜间',
collapseNight: '折叠夜间',
noEvents: '本周无日程',
untitled: '(无标题)',
rangeFmt: '{s} - {e}',
tooltipLocation: '地点: {location}',
tooltipDesc: '描述: {description}'
}
}

44
src/lib/colors.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* 事件颜色分配工具。
*
* 基于事件 uid 的哈希值从预定义调色板中选取颜色, 确保相同事件始终
* 同色, 不同事件大概率不同色。预定义调色板保证颜色区分度高且可读。
*/
// 12 种区分度高的颜色, 每种包含背景色、边框色、文字色
const PALETTE = [
{ bg: '#e3f2fd', border: '#1976d2', text: '#0d47a1' }, // 蓝
{ bg: '#f3e5f5', border: '#7b1fa2', text: '#4a148c' }, // 紫
{ bg: '#e8f5e9', border: '#388e3c', text: '#1b5e20' }, // 绿
{ bg: '#fff3e0', border: '#e65100', text: '#bf360c' }, // 橙
{ bg: '#fce4ec', border: '#c2185b', text: '#880e4f' }, // 粉
{ bg: '#e0f7fa', border: '#00838f', text: '#006064' }, // 青
{ bg: '#f1f8e9', border: '#827717', text: '#33691e' }, // 黄绿
{ bg: '#ede7f6', border: '#512da8', text: '#311b92' }, // 靛
{ bg: '#fbe9e7', border: '#d84315', text: '#bf360c' }, // 红橙
{ bg: '#e0f2f1', border: '#00695c', text: '#004d40' }, // 蓝绿
{ bg: '#fff8e1', border: '#f9a825', text: '#f57f17' }, // 金
{ bg: '#f9fbe7', border: '#afb42b', text: '#827717' }, // 橄榄
]
/**
* 对字符串进行简单哈希, 返回非负整数。
* @param {string} str
* @returns {number}
*/
function hashStr(str) {
let h = 0
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) | 0
}
return Math.abs(h)
}
/**
* 根据 uid 返回调色板中的颜色对象。
* @param {string} uid
* @returns {{ bg: string, border: string, text: string }}
*/
export function eventColor(uid) {
return PALETTE[hashStr(uid) % PALETTE.length]
}

86
src/lib/date.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* 集中式的日期字符串工具集。
*
* 应用内所有日期值统一使用 'YYYY-MM-DD' (本地墙钟时间) 格式。
* 本模块是格式化与日期运算的唯一来源, 替代此前散落在 organize.js、
* recur.js、useCalendar.js、ical-io.js 中的重复实现。
*/
/** 将数字补零为 2 位字符串。 */
export function pad2(n) {
return String(n).padStart(2, '0')
}
/** 将 JS Date 转换为 'YYYY-MM-DD' (本地墙钟时间)。 */
export function toISODate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
}
/** 将 'YYYY-MM-DD' 解析为 { year, month, day } (month 为 1 基)。 */
export function parseISODate(s) {
const [y, m, d] = s.split('-').map(Number)
return { year: y, month: m, day: d }
}
/** 由 'YYYY-MM-DD' 构造本地午夜 JS Date。 */
export function toDate(isoDate) {
return new Date(isoDate + 'T00:00:00')
}
/** 在 ISO 日期字符串上增加 `days` 天, 返回新的 ISO 字符串。 */
export function addDays(isoDate, days) {
const d = toDate(isoDate)
d.setDate(d.getDate() + days)
return toISODate(d)
}
/** 两个 ISO 日期字符串之间的整天差 (b - a)。 */
export function daysBetween(isoA, isoB) {
return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000))
}
/** 今天的日期, 格式为 'YYYY-MM-DD' (本地)。 */
export function todayISO() {
return toISODate(new Date())
}
/**
* 当前 UTC 时间戳, 紧凑 RFC 5545 格式 (如 '20260711T085008Z')。
* 用于新事件的 DTSTAMP。
*/
export function nowCompactTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
}
/**
* 返回 isoDate 所在周的周一 (ISO 周, 周一为首日)。
* @param {string} isoDate - 'YYYY-MM-DD'
* @returns {string} 周一的 'YYYY-MM-DD'
*/
export function startOfWeek(isoDate) {
const d = toDate(isoDate)
const dow = d.getDay() // 0=周日 .. 6=周六
const diff = dow === 0 ? -6 : 1 - dow // 回退到周一
d.setDate(d.getDate() + diff)
return toISODate(d)
}
/**
* 返回 isoDate 所在月的第一天。
* @param {string} isoDate - 'YYYY-MM-DD'
* @returns {string} 当月 1 号的 'YYYY-MM-DD'
*/
export function startOfMonth(isoDate) {
const { year, month } = parseISODate(isoDate)
return `${year}-${pad2(month)}-01`
}
/**
* 将 'HH:MM' 转换为当天分钟数 (0~1439)。
* @param {string} hhmm - 'HH:MM'
* @returns {number}
*/
export function hhmmToMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number)
return h * 60 + m
}

41
src/lib/event-factory.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* 事件模型工厂与辅助函数。
*
* EventModel 是应用内通用的纯 JS 数据结构 (见 docs/superpowers/specs/...
* §4.2)。本模块集中管理事件的创建与克隆, 使 useCalendar.js 专注于状态管理。
*/
import { todayISO, nowCompactTimestamp } from './date.js'
import { genUUID } from './uuid.js'
/**
* 创建一个具有合理默认值的单次事件。
* @param {object} [overrides] - 需覆盖的部分字段
* @returns {object} 一个全新的 EventModel
*/
export function createEvent(overrides = {}) {
return {
uid: genUUID(),
summary: '新日程',
location: '',
description: '',
dtstartDate: todayISO(),
dtstartTime: '09:00',
dtendTime: '10:00',
tzid: 'UTC',
rrule: null,
exdates: [],
_raw: { dtstamp: nowCompactTimestamp() },
...overrides,
}
}
/**
* 深拷贝一个事件 (或任意纯数据对象)。
*
* 使用 JSON 往返而非 structuredClone, 因为事件可能被 Vue 响应式代理包裹,
* structuredClone 无法处理。EventModel 仅含纯数据 (字符串、数组、嵌套对象),
* 因此 JSON 拷贝在此是安全且无损的。
*/
export function cloneEvent(ev) {
return JSON.parse(JSON.stringify(ev))
}

View File

@@ -1,7 +1,9 @@
import ICAL from 'ical.js' import ICAL from 'ical.js'
import { pad2, parseISODate } from './date.js'
import { genUUID } from './uuid.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Timezone registration (ical.js does not bundle IANA zones) // 时区注册 (ical.js 不内置 IANA 时区)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function registerTimezones(vcalendar) { function registerTimezones(vcalendar) {
for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) { for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) {
@@ -13,17 +15,14 @@ function registerTimezones(vcalendar) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Helpers // ICAL.Time <-> 普通字符串辅助函数
// (ICAL.Time 直接暴露 .year/.month/.day/.hour/.minute)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function pad2(n) { function icalTimeToISODate(t) {
return String(n).padStart(2, '0')
}
function timeToISODate(t) {
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}` return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
} }
function timeToHHMM(t) { function icalTimeToHHMM(t) {
return `${pad2(t.hour)}:${pad2(t.minute)}` return `${pad2(t.hour)}:${pad2(t.minute)}`
} }
@@ -35,14 +34,14 @@ function getTzid(component, propName) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Parse a single VEVENT component -> plain EventModel // 解析单个 VEVENT 组件 -> 普通 EventModel
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function parseEvent(veventComp) { function parseEvent(veventComp) {
const event = new ICAL.Event(veventComp) const event = new ICAL.Event(veventComp)
const start = event.startDate const start = event.startDate
const end = event.endDate const end = event.endDate
// Determine tzid // 确定 tzid
const tzid = getTzid(veventComp, 'dtstart') const tzid = getTzid(veventComp, 'dtstart')
// RRULE // RRULE
@@ -63,29 +62,29 @@ function parseEvent(veventComp) {
} }
} }
// EXDATEs // EXDATE
const exdates = [] const exdates = []
for (const prop of veventComp.getAllProperties('exdate')) { for (const prop of veventComp.getAllProperties('exdate')) {
for (const t of prop.getValues()) { for (const t of prop.getValues()) {
exdates.push(timeToISODate(t)) exdates.push(icalTimeToISODate(t))
} }
} }
// _raw: capture DTSTAMP and any other unmapped properties // _raw: 保存 DTSTAMP 及其他未映射的属性
// Use toICALString() to preserve the compact RFC5545 form ("20260711T085008Z") // 使用 toICALString() 保留紧凑 RFC5545 形式 ("20260711T085008Z"),
// rather than toString()'s ISO 8601 form ("2026-07-11T08:50:08Z"). // 而非 toString() ISO 8601 形式 ("2026-07-11T08:50:08Z")
const _raw = {} const _raw = {}
const dtstamp = veventComp.getFirstPropertyValue('dtstamp') const dtstamp = veventComp.getFirstPropertyValue('dtstamp')
if (dtstamp) _raw.dtstamp = dtstamp.toICALString() if (dtstamp) _raw.dtstamp = dtstamp.toICALString()
return { return {
uid: veventComp.getFirstPropertyValue('uid') || '', uid: veventComp.getFirstPropertyValue('uid') || genUUID(),
summary: veventComp.getFirstPropertyValue('summary') || '', summary: veventComp.getFirstPropertyValue('summary') || '',
location: veventComp.getFirstPropertyValue('location') || '', location: veventComp.getFirstPropertyValue('location') || '',
description: veventComp.getFirstPropertyValue('description') || '', description: veventComp.getFirstPropertyValue('description') || '',
dtstartDate: timeToISODate(start), dtstartDate: icalTimeToISODate(start),
dtstartTime: timeToHHMM(start), dtstartTime: icalTimeToHHMM(start),
dtendTime: timeToHHMM(end), dtendTime: icalTimeToHHMM(end),
tzid, tzid,
rrule, rrule,
exdates, exdates,
@@ -94,16 +93,16 @@ function parseEvent(veventComp) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Parse full ICS text -> { meta, events } // 解析完整 ICS 文本 -> { meta, events }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
export function parseICS(text) { export function parseICS(text) {
const jcal = ICAL.parse(text) const jcal = ICAL.parse(text)
const vcalendar = new ICAL.Component(jcal) const vcalendar = new ICAL.Component(jcal)
// Register timezones from VTIMEZONEs before constructing zoned Times // 在构造带时区的 Time 之前, 先从 VTIMEZONE 注册时区
registerTimezones(vcalendar) registerTimezones(vcalendar)
// Meta // 日历元信息
const meta = { const meta = {
prodid: vcalendar.getFirstPropertyValue('prodid') || '', prodid: vcalendar.getFirstPropertyValue('prodid') || '',
version: vcalendar.getFirstPropertyValue('version') || '2.0', version: vcalendar.getFirstPropertyValue('version') || '2.0',
@@ -111,23 +110,20 @@ export function parseICS(text) {
method: vcalendar.getFirstPropertyValue('method') || null, method: vcalendar.getFirstPropertyValue('method') || null,
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null, xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null,
xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null, xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null,
vtimezones: vcalendar.getAllSubcomponents('vtimezone'), // 存储原始 jCal 数组 (而非 ICAL.Component 实例), 使 meta 保持纯可序列化,
// 便于 Vue 响应式与 JSON 快照克隆。
vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal),
} }
// Events // 事件
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent) const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
return { meta, events } return { meta, events }
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Helpers for serialization // 序列化辅助函数
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function parseISODate(s) {
const [y, m, d] = s.split('-').map(Number)
return { year: y, month: m, day: d }
}
function parseHHMM(s) { function parseHHMM(s) {
const [h, m] = s.split(':').map(Number) const [h, m] = s.split(':').map(Number)
return { hour: h, minute: m } return { hour: h, minute: m }
@@ -136,7 +132,13 @@ function parseHHMM(s) {
function getZone(tzid) { function getZone(tzid) {
if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone
const z = ICAL.TimezoneService.get(tzid) 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) { function makeTime(dateStr, timeStr, tzid) {
@@ -148,10 +150,10 @@ function makeTime(dateStr, timeStr, tzid) {
) )
} }
// Parse a compact RFC5545 date-time string (e.g. "20260711T085008Z" or // 解析紧凑 RFC5545 日期时间字符串 (如 "20260711T085008Z"
// "20260711T085008") back into an ICAL.Time. ical.js's public Time.fromString // "20260711T085008") ICAL.Timeical.js Time.fromString 不接受紧凑
// rejects the compact form, so we parse it manually and hand the Time object // 格式, 因此手动解析后构造 Time 对象传给 updatePropertyWithValue
// to updatePropertyWithValue (which writes it back in compact form). // (该方法会以紧凑格式写回)。
function parseCompactDateTime(s) { function parseCompactDateTime(s) {
const isUtc = s.endsWith('Z') const isUtc = s.endsWith('Z')
const core = isUtc ? s.slice(0, -1) : s const core = isUtc ? s.slice(0, -1) : s
@@ -173,7 +175,7 @@ function parseCompactDateTime(s) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Serialize { meta, events } -> ICS text // 序列化 { meta, events } -> ICS 文本
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
export function serializeICS({ meta, events }) { export function serializeICS({ meta, events }) {
const vcalendar = new ICAL.Component('vcalendar') const vcalendar = new ICAL.Component('vcalendar')
@@ -185,9 +187,10 @@ export function serializeICS({ meta, events }) {
if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname) if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname)
if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone) if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
// Re-add VTIMEZONEs (clone jCal to avoid moving from original parent) // 重新添加 VTIMEZONE。meta.vtimezones 存储的是原始 jCal 数组 (见 parseICS),
for (const vtz of meta.vtimezones || []) { // 因此直接用新 Component 包裹 (避免移动原始组件)。
vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal)) for (const vtzJCal of meta.vtimezones || []) {
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
} }
for (const ev of events) { for (const ev of events) {
@@ -208,10 +211,9 @@ export function serializeICS({ meta, events }) {
const deProp = vevent.addPropertyWithValue('dtend', dtend) const deProp = vevent.addPropertyWithValue('dtend', dtend)
if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid) if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid)
// DTSTAMP - _raw.dtstamp is stored in compact RFC5545 form // DTSTAMP - _raw.dtstamp 以紧凑 RFC5545 格式存储 ("20260711T085008Z");
// ("20260711T085008Z"); pass an ICAL.Time object so ical.js decorates it // 传入 ICAL.Time 对象使 ical.js 正确装饰并以相同紧凑格式写回。
// correctly and writes it back in the same compact form. Passing the raw // 直接传原始字符串会失败 (ical.js 2.x 的 fromDateTimeString 不接受紧凑格式)。
// string fails (ical.js 2.x rejects compact form in fromDateTimeString).
const dtstampRaw = ev._raw?.dtstamp const dtstampRaw = ev._raw?.dtstamp
const dtstampTime = dtstampRaw const dtstampTime = dtstampRaw
? parseCompactDateTime(dtstampRaw) ? parseCompactDateTime(dtstampRaw)
@@ -224,7 +226,7 @@ export function serializeICS({ meta, events }) {
const recurData = { freq: r.freq, interval: r.interval || 1 } const recurData = { freq: r.freq, interval: r.interval || 1 }
if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday
if (r.untilDate) { if (r.untilDate) {
// UNTIL as UTC (RFC 5545): build zoned time at 23:59 then convert // UNTIL 使用 UTC (RFC 5545): 先构造 23:59 的带时区时间再转换
const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid) const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid)
recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone) recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone)
} }
@@ -232,7 +234,7 @@ export function serializeICS({ meta, events }) {
vevent.addPropertyWithValue('rrule', recur) vevent.addPropertyWithValue('rrule', recur)
} }
// EXDATEs (in DTSTART's timezone) // EXDATE (使用 DTSTART 的时区)
for (const exDate of ev.exdates || []) { for (const exDate of ev.exdates || []) {
const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid) const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid)
const exProp = vevent.addPropertyWithValue('exdate', exTime) const exProp = vevent.addPropertyWithValue('exdate', exTime)

View File

@@ -1,27 +1,48 @@
/** /**
* GCD-based recurrence detection - port of ical_organizer.py. * 重复事件合并 - 将扁平的单次事件列表合并为 RRULE + EXDATE。
* Collapses a flat list of per-occurrence events into RRULE + EXDATE. *
* 算法分两层:
*
* 1. 分组 (seriesKey): 标题/地点/开始墙钟时间/时长相同的事件归一组。
* 键中不含星期 -- 周期为非 7 倍数 (如每 3 天) 的 DAILY 序列会落在
* 不同星期, 含星期会把它们拆成单事件组, 永远到不了 fitSeries。
* 星期改由 fitSeries 内的 byday 从首日期推导, 仅 WEEKLY 有意义。
*
* 2. 系列拟合 (fitSeries): 求所有相邻 gap 的 GCD 作为「最小公约间隔」d
* (使每个 gap 均为其整数倍的最小正整数), 据此构建期望网格。所有
* present 日期必须在网格上 (offGrid 为空); 缺席的网格日期记为
* EXDATE。不再因「太稀疏」拒绝 -- 稀疏但有规律的序列 (如约每 3 周
* 一次的 workshop) 同样合并。
*
* 3. 链拆分 (splitIntoChains): 当一组日期无法落在单一 GCD 网格上
* (offGrid 非空, 典型如同名同时但分属周一/周二的两个班, gap 交替
* 为 1,6,1,6), 把日期拆成若干「等间隔链」: 候选间隔取所有日期对的
* 差 (降序), 从最早未分配日期起贪心抽取最长链。每条链独立 fitSeries。
* 这样交错的两个每周班会正确拆成 2 个 WEEKLY 事件, 而非误合并成
* 一个 DAILY;INTERVAL=1。
*
* 详见 docs/organize-algorithm.md。
*/ */
import { bydayFromDate } from './weekday.js' import { bydayFromDate } from './weekday.js'
import { toDate, addDays, daysBetween } from './date.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Grouping // 分组
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
/** /**
* Identity of a recurring series. * 重复系列的标识。
* Two events belong together iff they share summary, location, start * 两个事件属于同一系列当且仅当: 标题、地点、开始墙钟时间、
* wall-clock time, duration (minutes), and weekday. * 时长 (分钟) 均相同。星期不参与分组 (见模块头注释)。
*/ */
export function seriesKey(ev) { export function seriesKey(ev) {
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
const [sh, sm] = ev.dtstartTime.split(':').map(Number) const [sh, sm] = ev.dtstartTime.split(':').map(Number)
const [eh, em] = ev.dtendTime.split(':').map(Number) const [eh, em] = ev.dtendTime.split(':').map(Number)
const durationMin = (eh * 60 + em) - (sh * 60 + sm) const durationMin = (eh * 60 + em) - (sh * 60 + sm)
return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|') return [ev.summary, ev.location, ev.dtstartTime, durationMin].join('|')
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Interval detection // 间隔检测
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function gcd(a, b) { function gcd(a, b) {
while (b) { [a, b] = [b, a % b] } while (b) { [a, b] = [b, a % b] }
@@ -29,17 +50,18 @@ function gcd(a, b) {
} }
/** /**
* Return the recurrence interval in days, or null if irregular. * 返回「最小公约间隔」(天数), 不规则时返回 null。
* Computes GCD of all consecutive date gaps. *
* 计算 d = 所有相邻日期间隔的 GCD -- 这是使每个 gap 都为其整数倍的
* 最小正整数。例如 gap [7,7,14,7] -> d=7 (中间跳过一周, 14=2*7);
* gap [3,3,3] -> d=3 (每 3 天); gap [21,28] -> d=7。
*/ */
export function detectInterval(dateStrings) { export function detectInterval(dateStrings) {
if (dateStrings.length < 2) return null if (dateStrings.length < 2) return null
const dates = [...dateStrings].sort() const dates = [...dateStrings].sort()
const gaps = [] const gaps = []
for (let i = 0; i < dates.length - 1; i++) { for (let i = 0; i < dates.length - 1; i++) {
const a = new Date(dates[i] + 'T00:00:00') gaps.push(daysBetween(dates[i], dates[i + 1]))
const b = new Date(dates[i + 1] + 'T00:00:00')
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
} }
let g = gaps[0] let g = gaps[0]
for (let i = 1; i < gaps.length; i++) { for (let i = 1; i < gaps.length; i++) {
@@ -49,48 +71,32 @@ export function detectInterval(dateStrings) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Series fitting // 系列拟合
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
const byDate = (a, b) => (a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0)
/** /**
* Try to collapse events into one recurring event. * 从首日期到末日期按给定间隔构建期望的 occurrence 网格。
* Returns { base, rrule, exdates } or null to decline. * 返回 { expected, missing, offGrid } (相对于实际存在的日期)。
*/ */
export function fitSeries(evs) { function buildGrid(firstDate, lastDate, interval, present) {
if (evs.length < 2) return null
const sorted = [...evs].sort((a, b) =>
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
)
const starts = sorted.map(e => e.dtstartDate)
const interval = detectInterval(starts)
if (!interval) return null
const first = sorted[0]
const last = sorted[sorted.length - 1]
const present = new Set(starts)
// Build expected grid first..last step interval days
const expected = [] const expected = []
const cur = new Date(first.dtstartDate + 'T00:00:00') let cur = firstDate
const lastDate = new Date(last.dtstartDate + 'T00:00:00')
while (cur <= lastDate) { while (cur <= lastDate) {
const y = cur.getFullYear() expected.push(cur)
const m = String(cur.getMonth() + 1).padStart(2, '0') cur = addDays(cur, interval)
const d = String(cur.getDate()).padStart(2, '0')
expected.push(`${y}-${m}-${d}`)
cur.setDate(cur.getDate() + interval)
} }
const expectedSet = new Set(expected) const expectedSet = new Set(expected)
const missing = expected.filter(d => !present.has(d)) const missing = expected.filter((d) => !present.has(d))
const offGrid = starts.filter(d => !expectedSet.has(d)) const offGrid = [...present].filter((d) => !expectedSet.has(d))
if (offGrid.length > 0) return null return { expected, missing, offGrid }
}
// "Mostly regular": at least 2 occurrences and occurrences >= skips /**
if (evs.length < 2 || missing.length >= evs.length) return null * 由天数间隔构造 RRULE。7 的倍数 -> WEEKLY (byday 从首日期推导),
* 否则 -> DAILY (byday 为 null)。
// Build rrule */
const firstDate = new Date(first.dtstartDate + 'T00:00:00') function buildRRule(interval, firstDate, lastDateStr) {
let freq, step, byday let freq, step, byday
if (interval % 7 === 0) { if (interval % 7 === 0) {
freq = 'WEEKLY' freq = 'WEEKLY'
@@ -101,20 +107,169 @@ export function fitSeries(evs) {
step = interval step = interval
byday = null byday = null
} }
return { freq, interval: step, byday, untilDate: lastDateStr }
}
const rrule = { freq, interval: step, byday, untilDate: last.dtstartDate } /**
* 尝试将事件合并为一个重复事件 (单一 GCD 网格)。
* 返回 { base, rrule, exdates }, 无法合并时返回 null。
*
* 拒绝条件:
* - < 2 个事件, 或无 GCD 间隔;
* - GCD = 1: 相邻 gap 含互质值 (如 1,6 或 2,5), 说明是交错的多个
* 序列, 单一 DAILY;INTERVAL=1 网格会把所有不在同子序列的日期当
* EXDATE, 是伪合并。交由 splitIntoChains 拆分。
* - offGrid 非空: present 日期不全在网格上。
*/
export function fitSeries(evs) {
if (evs.length < 2) return null
const sorted = [...evs].sort(byDate)
const starts = sorted.map((e) => e.dtstartDate)
const interval = detectInterval(starts)
if (!interval) return null
if (interval === 1) return null
const first = sorted[0]
const last = sorted[sorted.length - 1]
const present = new Set(starts)
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
// present 日期必须全部落在规律网格上, 否则该组不是单一周期序列
// (需交由 splitIntoChains 拆分)。
if (offGrid.length > 0) return null
const rrule = buildRRule(interval, toDate(first.dtstartDate), last.dtstartDate)
return { base: first, rrule, exdates: missing } return { base: first, rrule, exdates: missing }
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Organize // 链拆分
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
/** /**
* Collapse flat events into recurring series. * 把一组日期拆成若干「等间隔链」, 每条链允许中间跳过若干周期
* Returns { events, stats: { series, flat } }. * (跳过的日期将作为 EXDATE 由 fitSeries 记录)。
*
* 仅在单一 GCD 网格失败 (组级 GCD=1, 即存在互质相邻 gap, 典型为
* 交错的多个周序列, 如同名同时的周一+周二两班) 时调用。
*
* 候选间隔 d: 相邻 gap 两两之和 (覆盖「配对相加得真实周期」的情况,
* 如 gap 1,6 -> 周期 7; gap 2,5 -> 周期 7)。仅用两两之和而非单个
* gap, 因为交错子序列的「偏移」(如 1 或 2) 比真实周期短, 拿单个 gap
* 当周期会得到过密的伪网格。此函数仅在组级 GCD=1 (互质 gap, 即交错)
* 时调用, 故两两之和总是能给出真实周期。
* 对每个 d 用网格抽取 (从最早未分配日期起按 d 步进, 收集命中的存在
* 日期, 跳过缺席周期) 得到若干链; 选产生链数最少的 d (最大化分组)。
*
* 例: 周一+周二交错 (gap 1,6,1,6,1,6) -> d=7 抽出链 [5,12,19] 与
* [6,13,20], 得 2 个 WEEKLY 事件。
* 例: gap 2,5,2,5,2,12,2,5,2,5 -> d=7 抽出链 [0,7,14,28,35,42]
* (中间跳过 21) 与 [2,9,16,30,37] (中间跳过 23), 得 2 个 WEEKLY
* 事件, 各带 1 个 EXDATE。
*/
export function splitIntoChains(dateStrings) {
const dates = [...new Set(dateStrings)].sort()
if (dates.length < 2) return dates.length === 1 ? [dates] : []
// 候选间隔: 相邻 gap 两两之和 (见上方注释)。仅 >1 者。
const gaps = []
for (let i = 0; i < dates.length - 1; i++) {
gaps.push(daysBetween(dates[i], dates[i + 1]))
}
const candSet = new Set()
for (let i = 0; i < gaps.length - 1; i++) {
const s = gaps[i] + gaps[i + 1]
if (s > 1) candSet.add(s)
}
const candidates = [...candSet].sort((a, b) => b - a)
// 对给定 d 用网格抽取链 (允许跳过周期)。返回链数组。
function extractChains(d) {
const remaining = new Set(dates)
const chains = []
while (remaining.size > 0) {
const start = [...remaining].sort()[0]
// 从 start 按 d 步进到最末日期, 收集命中的存在日期
const chain = []
let cur = start
const lastDate = dates[dates.length - 1]
while (cur <= lastDate) {
if (remaining.has(cur)) chain.push(cur)
cur = addDays(cur, d)
}
if (chain.length === 0) {
// start 不在任何网格点 (不应发生, 因 cur=start 命中), 兜底
chains.push([start])
remaining.delete(start)
} else {
chains.push(chain)
for (const dd of chain) remaining.delete(dd)
}
}
return chains
}
// 选产生链数最少的 d (链数相同取较小 d, 周期更细)
let best = null
for (const d of candidates) {
const chains = extractChains(d)
// 只接受所有链长度 >= 2 的方案 (单点链说明 d 不适配)
const valid = chains.every((c) => c.length >= 2)
const score = chains.length
if (valid && (!best || score < best.score || (score === best.score && d < best.d))) {
best = { d, chains, score }
}
}
if (best) return best.chains
// 兜底: 无候选 d 能给出全 >=2 链, 退化为每日期一链 (全部 flat)
return dates.map((d) => [d])
}
/**
* 对一组同系列事件做合并: 先尝试单一 fitSeries, 失败 (offGrid) 则
* splitIntoChains 后对每条链独立 fitSeries。返回 EventModel[]。
*/
function organizeGroup(evs) {
evs.sort(byDate)
const fit = fitSeries(evs)
if (fit) {
// 合并后的事件: 保留 base 的 UID (不重新分配)
return [{ ...fit.base, rrule: fit.rrule, exdates: fit.exdates }]
}
// offGrid 非空: 拆链。按日期建立 date->event 映射 (同日期取首个)。
const byDateMap = new Map()
for (const ev of evs) {
if (!byDateMap.has(ev.dtstartDate)) byDateMap.set(ev.dtstartDate, ev)
}
const chains = splitIntoChains(evs.map((e) => e.dtstartDate))
const result = []
for (const chain of chains) {
const chainEvs = chain.map((d) => byDateMap.get(d)).filter(Boolean)
const chainFit = fitSeries(chainEvs)
if (chainFit) {
result.push({ ...chainFit.base, rrule: chainFit.rrule, exdates: chainFit.exdates })
} else {
// 链长 < 2 或无规律: 保留为独立事件
for (const ev of chainEvs) result.push(ev)
}
}
return result
}
// ------------------------------------------------------------------ //
// 整理
// ------------------------------------------------------------------ //
/**
* 将扁平事件合并为重复系列。
* 返回 { events, stats: { series, flat } }。
*/ */
export function organize(events) { export function organize(events) {
// Group by series key // 按系列标识分组
const groups = new Map() const groups = new Map()
for (const ev of events) { for (const ev of events) {
const key = seriesKey(ev) const key = seriesKey(ev)
@@ -127,26 +282,11 @@ export function organize(events) {
let nFlat = 0 let nFlat = 0
for (const evs of groups.values()) { for (const evs of groups.values()) {
// Sort by date const merged = organizeGroup(evs)
evs.sort((a, b) => for (const ev of merged) {
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, result.push(ev)
) if (ev.rrule) nSeries++
else nFlat++
const fit = fitSeries(evs)
if (fit) {
// Collapsed event: preserve base's UID (not reassign)
result.push({
...fit.base,
rrule: fit.rrule,
exdates: fit.exdates,
})
nSeries++
} else {
// Keep flat
for (const ev of evs) {
result.push(ev)
nFlat++
}
} }
} }

View File

@@ -1,9 +1,10 @@
/** /**
* Recurrence grid expansion - port of ical_editor.py's occurrence generation. * 重复网格展开 - 移植自 ical_editor.py occurrence 生成逻辑。
* occurrences are computed on-demand from exdates, never stored on the model. * occurrence 按需从 exdates 计算, 不存储在模型上。
*/ */
import { toISODate, toDate, addDays } from './date.js'
/** Interval in days for a recurring event. */ /** 重复事件的间隔天数。 */
export function intervalDays(ev) { export function intervalDays(ev) {
if (!ev.rrule) return 0 if (!ev.rrule) return 0
const { freq, interval } = ev.rrule const { freq, interval } = ev.rrule
@@ -11,19 +12,14 @@ export function intervalDays(ev) {
return freq === 'WEEKLY' ? 7 * step : step return freq === 'WEEKLY' ? 7 * step : step
} }
function toISO(date) { const MAX_OCCURRENCES = 500
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** /**
* Expand a recurring event into an occurrence grid. * 将重复事件展开为 occurrence 网格。
* Returns [{ date: 'YYYY-MM-DD', skipped: boolean }]. * 返回 [{ date: 'YYYY-MM-DD', skipped: boolean }]
*/ */
export function expandOccurrences(ev) { export function expandOccurrences(ev) {
// Non-recurring: single occurrence // 非重复事件: 单个 occurrence
if (!ev.rrule) { if (!ev.rrule) {
return [{ date: ev.dtstartDate, skipped: false }] return [{ date: ev.dtstartDate, skipped: false }]
} }
@@ -33,20 +29,18 @@ export function expandOccurrences(ev) {
return [{ date: ev.dtstartDate, skipped: false }] return [{ date: ev.dtstartDate, skipped: false }]
} }
const start = new Date(ev.dtstartDate + 'T00:00:00')
const until = ev.rrule.untilDate const until = ev.rrule.untilDate
? new Date(ev.rrule.untilDate + 'T00:00:00') ? toDate(ev.rrule.untilDate)
: new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year : new Date(toDate(ev.dtstartDate).getTime() + 365 * 24 * 3600 * 1000)
const exdateSet = new Set(ev.exdates || []) const exdateSet = new Set(ev.exdates || [])
const occs = [] const occs = []
const cur = new Date(start) let cur = ev.dtstartDate
let count = 0 let count = 0
while (cur <= until && count < 500) { while (cur <= toISODate(until) && count < MAX_OCCURRENCES) {
const iso = toISO(cur) occs.push({ date: cur, skipped: exdateSet.has(cur) })
occs.push({ date: iso, skipped: exdateSet.has(iso) }) cur = addDays(cur, step)
cur.setDate(cur.getDate() + step)
count++ count++
} }

50
src/lib/split.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* 将一个重复事件在指定日期拆分为两段。
*
* 从 useCalendar.js 提取为纯函数, 使 store 专注于状态管理。
* 网格对齐逻辑复用 recur.js 的 intervalDays (步长的唯一来源)。
*
* @param {object} ev - 待拆分的事件 (必须有非空 rrule)
* @param {string} splitDate - 'YYYY-MM-DD', 必须在事件范围内
* @returns {[object, object]|null} [partA, partB], 日期超出范围或
* 事件非重复时返回 null。
*/
import { intervalDays } from './recur.js'
import { cloneEvent } from './event-factory.js'
import { genUUID } from './uuid.js'
import { toDate, toISODate, addDays, daysBetween } from './date.js'
/** 将日期向前对齐到不早于它的网格点。 */
function snapToGrid(startDate, targetDate, stepDays, untilDate) {
const start = toDate(startDate)
let snap = toDate(targetDate)
if (snap < start) snap = new Date(start)
while (daysBetween(startDate, toISODate(snap)) % stepDays !== 0) {
snap.setDate(snap.getDate() + 1)
if (snap > toDate(untilDate)) return null
}
return toISODate(snap)
}
export function splitRecurringEvent(ev, splitDate) {
if (!ev.rrule) return null
const { untilDate } = ev.rrule
if (splitDate <= ev.dtstartDate || splitDate > untilDate) return null
const step = intervalDays(ev)
const snapStr = snapToGrid(ev.dtstartDate, splitDate, step, untilDate)
if (!snapStr) return null
const prevStr = addDays(snapStr, -step)
const partA = cloneEvent(ev)
partA.rrule = { ...ev.rrule, untilDate: prevStr }
partA.exdates = ev.exdates.filter((d) => d < splitDate)
const partB = cloneEvent(ev)
partB.uid = genUUID()
partB.dtstartDate = snapStr
partB.exdates = ev.exdates.filter((d) => d >= splitDate)
return [partA, partB]
}

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
}

37
src/lib/uuid.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* UUID 生成工具。
*
* 优先使用 crypto.randomUUID(), 但该方法仅在安全上下文 (HTTPS 或 localhost)
* 中可用。当前站点通过 http://f.zikai.wang:8888 访问, 属于非安全上下文,
* crypto.randomUUID 会是 undefined, 因此需要回退方案。
*
* 回退一: 用 crypto.getRandomValues (不受安全上下文限制) 手动拼装
* RFC 4122 version 4 UUID。
* 回退二: 若 crypto 对象完全不存在 (如旧版 Node 直接运行 .mjs), 用
* Math.random 拼装, 牺牲少量随机性但保证可用。
*/
/**
* 生成一个 RFC 4122 version 4 UUID 字符串。
* @returns {string} 形如 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
*/
export function genUUID() {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
if (c && typeof c.randomUUID === 'function') {
return c.randomUUID()
}
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(16)
c.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant
const h = [...bytes].map((b) => b.toString(16).padStart(2, '0'))
return `${h.slice(0, 4).join('')}-${h.slice(4, 6).join('')}-${h.slice(6, 8).join('')}-${h.slice(8, 10).join('')}-${h.slice(10, 16).join('')}`
}
// 最终回退: Math.random
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0
const v = ch === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -1,17 +1,10 @@
// JS getDay() index: 0=Sunday .. 6=Saturday // JS getDay() 索引: 0=周日 .. 6=周六
export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
// BYDAY codes indexed by JS getDay(): DOW_EN[getDay()] => 'SU'..'SA' // BYDAY 编码, 按 JS getDay() 索引: DOW_EN[getDay()] => 'SU'..'SA'
export const DOW_EN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] export const DOW_EN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
export const BYDAY_TO_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 } /** 返回 JS Date 对应的 BYDAY 编码 ('SU'..'SA')。 */
/** Return the BYDAY code ('SU'..'SA') for a JS Date. */
export function bydayFromDate(date) { export function bydayFromDate(date) {
return DOW_EN[date.getDay()] return DOW_EN[date.getDay()]
} }
/** Return the Chinese weekday label for a JS Date. */
export function dowLabel(date) {
return DOW[date.getDay()]
}

View File

@@ -1,4 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import i18n from './i18n/index.js'
import './styles/global.css'
createApp(App).mount('#app') createApp(App).use(i18n).mount('#app')

52
src/styles/global.css Normal file
View File

@@ -0,0 +1,52 @@
/* 全局重置 + 设计令牌 -- 仅独立 appmain.js加载。
作为组件被嵌入时由宿主(mainPage)提供 :root 令牌,本文件的重置仍安全(与宿主一致)。
令牌值与 mainPage src/styles/variables.css 对齐,保证独立与嵌入视觉一致。 */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", sans-serif;
background: var(--bg, #ffffff);
color: var(--text-primary, #1f2329);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:root {
/* 背景/表面 */
--bg: #ffffff;
--surface: #f7f8fa;
--surface-2: #f0f2f5;
/* 文字 */
--text-primary: #1f2329;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* 强调色(靛蓝,与 mainPage 一致) */
--accent: #4f46e5;
--accent-weak: #eef2ff;
--accent-hover: #4338ca;
/* 线/边 */
--border: #eceef1;
--border-strong: #e0e2e6;
/* 语义色 */
--success: #2e7d32;
--success-weak: #e8f5e9;
--danger: #c62828;
--danger-weak: #fbe9e7;
--warn: #e65100;
--warn-weak: #fff3e0;
/* 圆角 */
--radius-sm: 8px;
--radius: 12px;
/* 阴影 */
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
--shadow-hover: 0 6px 16px rgba(17, 24, 39, 0.08), 0 2px 6px rgba(17, 24, 39, 0.05);
/* 过渡 */
--transition: 0.18s ease;
}

View File

@@ -3,14 +3,30 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { parseICS } from '../src/lib/ical-io.js' import { parseICS } from '../src/lib/ical-io.js'
import { organize, seriesKey, detectInterval, fitSeries } from '../src/lib/organize.js' import { organize, seriesKey, detectInterval, fitSeries, splitIntoChains } from '../src/lib/organize.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8') const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
const { events } = parseICS(GOLDEN) const { events } = parseICS(GOLDEN)
// 构造最小 EventModel 用于合成用例
function mk(dtstart, summary, loc = 'L', st = '09:00', et = '10:00') {
return {
uid: 'uid-' + dtstart + '-' + summary,
summary, location: loc, description: '',
dtstartDate: dtstart, dtstartTime: st, dtendTime: et,
tzid: 'UTC', rrule: null, exdates: [], _raw: { dtstamp: '20260711T085008Z' },
}
}
// 由相对天偏移生成 ISO 日期 (基准 2026-01-05 = 周一)
function iso(off) {
const d = new Date(2026, 0, 5)
d.setDate(d.getDate() + off)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
describe('seriesKey', () => { describe('seriesKey', () => {
it('groups events with same summary/location/time/duration/weekday', () => { it('groups events with same summary/location/time/duration', () => {
const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00 const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00
const e2 = events[1] // same series, next week const e2 = events[1] // same series, next week
expect(seriesKey(e1)).toBe(seriesKey(e2)) expect(seriesKey(e1)).toBe(seriesKey(e2))
@@ -18,10 +34,17 @@ describe('seriesKey', () => {
it('separates events with different summary', () => { it('separates events with different summary', () => {
const e1 = events[0] // ACC INFO SYS const e1 = events[0] // ACC INFO SYS
// Find a BUS LAW event
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial')) const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
expect(seriesKey(e1)).not.toBe(seriesKey(eBus)) expect(seriesKey(e1)).not.toBe(seriesKey(eBus))
}) })
it('groups DAILY-series events that fall on different weekdays', () => {
// 每 3 天 -> 落在 周一/周四/周日/周三/周六, 星期各不同
const every3 = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
const keys = every3.map(seriesKey)
// 键不再含星期, 故全部相同
expect(new Set(keys).size).toBe(1)
})
}) })
describe('detectInterval', () => { describe('detectInterval', () => {
@@ -38,9 +61,14 @@ describe('detectInterval', () => {
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31'] const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
expect(detectInterval(dates)).toBe(7) expect(detectInterval(dates)).toBe(7)
}) })
it('returns 3 for every-3-day dates', () => {
const dates = ['2026-01-05', '2026-01-08', '2026-01-11', '2026-01-14']
expect(detectInterval(dates)).toBe(3)
})
}) })
describe('fitSeries', () => { describe('fitSeries (single GCD grid)', () => {
it('fits ACC INFO SYS series (12 events, 1 skip)', () => { it('fits ACC INFO SYS series (12 events, 1 skip)', () => {
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial') const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
const result = fitSeries(accEvents) const result = fitSeries(accEvents)
@@ -51,32 +79,120 @@ describe('fitSeries', () => {
expect(result.exdates).toContain('2026-09-21') expect(result.exdates).toContain('2026-09-21')
}) })
it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => { it('merges sparse BUS LAW Workshop (gaps 21,28 -> gcd 7) into WEEKLY + exdates', () => {
const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop') const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop')
expect(wsEvents).toHaveLength(3) expect(wsEvents).toHaveLength(3)
expect(fitSeries(wsEvents)).toBeNull() const result = fitSeries(wsEvents)
expect(result).not.toBeNull()
expect(result.rrule.freq).toBe('WEEKLY')
expect(result.rrule.interval).toBe(1)
expect(result.rrule.byday).toBe('WE')
expect(result.rrule.untilDate).toBe('2026-09-30')
// 8 周跨度内 3 次出现 -> 5 次缺席
expect(result.exdates).toHaveLength(5)
expect(result.exdates).toContain('2026-08-19')
expect(result.exdates).toContain('2026-09-23')
})
it('merges every-3-day series into DAILY;INTERVAL=3', () => {
const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
const result = fitSeries(evs)
expect(result).not.toBeNull()
expect(result.rrule.freq).toBe('DAILY')
expect(result.rrule.interval).toBe(3)
expect(result.rrule.byday).toBeNull()
expect(result.exdates).toEqual([])
})
it('merges biweekly (14-day) series into WEEKLY;INTERVAL=2', () => {
const evs = [0, 14, 28, 42, 56].map(off => mk(iso(off), 'Bi'))
const result = fitSeries(evs)
expect(result).not.toBeNull()
expect(result.rrule.freq).toBe('WEEKLY')
expect(result.rrule.interval).toBe(2)
expect(result.rrule.byday).toBe('MO')
})
it('rejects interleaved weekday classes (gcd=1)', () => {
// 周一 + 周二, 同名同时同地, 排序后 gap 1,6,1,6,1,6 -> gcd 1
const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1'))
const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1'))
expect(fitSeries([...mon, ...tue])).toBeNull()
})
})
describe('splitIntoChains', () => {
it('splits interleaved Mon+Tue classes into 2 weekly chains', () => {
const dates = [0, 1, 7, 8, 14, 15].map(iso)
const chains = splitIntoChains(dates)
expect(chains).toHaveLength(2)
expect(chains[0]).toEqual([iso(0), iso(7), iso(14)])
expect(chains[1]).toEqual([iso(1), iso(8), iso(15)])
})
it('splits user example (gaps 2,5,2,5,2,12,2,5,2,5) into 2 weekly chains', () => {
// 两个交错周序列, 中间各暂停一周
const dates = [0, 2, 7, 9, 14, 16, 28, 30, 35, 37, 42].map(iso)
const chains = splitIntoChains(dates)
expect(chains).toHaveLength(2)
// 链 A: 0,7,14,28,35,42 (跳过 21)
expect(chains[0]).toEqual([iso(0), iso(7), iso(14), iso(28), iso(35), iso(42)])
// 链 B: 2,9,16,30,37 (跳过 23)
expect(chains[1]).toEqual([iso(2), iso(9), iso(16), iso(30), iso(37)])
})
})
describe('organize', () => {
it('merges every-3-day DAILY series (end-to-end via organize)', () => {
const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
const { events: out, stats } = organize(evs)
expect(out).toHaveLength(1)
expect(stats.series).toBe(1)
expect(out[0].rrule.freq).toBe('DAILY')
expect(out[0].rrule.interval).toBe(3)
})
it('splits interleaved Mon+Tue into 2 separate WEEKLY events', () => {
const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1'))
const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1'))
const { events: out, stats } = organize([...mon, ...tue])
expect(out).toHaveLength(2)
expect(stats.series).toBe(2)
expect(stats.flat).toBe(0)
const bydays = out.map(e => e.rrule.byday).sort()
expect(bydays).toEqual(['MO', 'TU'])
})
it('does not merge 2 consecutive same-name events (no pairwise-sum candidate)', () => {
const evs = [0, 1].map(off => mk(iso(off), 'OneOff'))
const { events: out, stats } = organize(evs)
expect(stats.series).toBe(0)
expect(stats.flat).toBe(2)
}) })
}) })
describe('organize (golden test against org.ics)', () => { describe('organize (golden test against org.ics)', () => {
const { events: organized, stats } = organize(events) const { events: organized, stats } = organize(events)
it('collapses 61 events into 8', () => { it('collapses 61 events into 6 (6 series, 0 flat)', () => {
expect(organized).toHaveLength(8) expect(organized).toHaveLength(6)
expect(stats.series).toBe(5) expect(stats.series).toBe(6)
expect(stats.flat).toBe(3) expect(stats.flat).toBe(0)
}) })
it('produces 5 recurring events with RRULE', () => { it('produces 6 recurring events with RRULE', () => {
const recurring = organized.filter(e => e.rrule !== null) const recurring = organized.filter(e => e.rrule !== null)
expect(recurring).toHaveLength(5) expect(recurring).toHaveLength(6)
}) })
it('produces 3 flat BUS LAW Workshop events', () => { it('merges BUS LAW Workshop into 1 recurring event with 5 exdates', () => {
const flatWs = organized.filter( const ws = organized.filter(e => e.summary === 'BUS LAW, Workshop')
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null, expect(ws).toHaveLength(1)
) expect(ws[0].rrule).not.toBeNull()
expect(flatWs).toHaveLength(3) expect(ws[0].rrule.freq).toBe('WEEKLY')
expect(ws[0].exdates).toHaveLength(5)
expect(ws[0].exdates).toContain('2026-08-19')
expect(ws[0].exdates).toContain('2026-09-23')
}) })
it('ACC INFO SYS series has 1 exdate (Sep 21)', () => { it('ACC INFO SYS series has 1 exdate (Sep 21)', () => {

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

View File

@@ -1,31 +1,21 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { DOW, DOW_EN, BYDAY_TO_INDEX, bydayFromDate, dowLabel } from '../src/lib/weekday.js' import { DOW, DOW_EN, bydayFromDate } from '../src/lib/weekday.js'
describe('weekday', () => { describe('weekday', () => {
it('DOW has 7 entries starting Sunday', () => { it('DOW 有 7 项, 首项为周日', () => {
expect(DOW).toHaveLength(7) expect(DOW).toHaveLength(7)
expect(DOW[0]).toBe('周日') expect(DOW[0]).toBe('周日')
expect(DOW[6]).toBe('周六') expect(DOW[6]).toBe('周六')
}) })
it('DOW_EN indexed by JS getDay (0=Sunday)', () => { it('DOW_EN JS getDay 索引 (0=周日)', () => {
expect(DOW_EN).toEqual(['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']) expect(DOW_EN).toEqual(['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'])
}) })
it('BYDAY_TO_INDEX maps SU..SA to 0..6', () => { it('bydayFromDate 返回 Date 对应的 BYDAY 编码', () => {
expect(BYDAY_TO_INDEX.SU).toBe(0) // 2026-07-27 是周一 -> getDay()=1 -> 'MO'
expect(BYDAY_TO_INDEX.SA).toBe(6)
})
it('bydayFromDate returns BYDAY code for a Date', () => {
// 2026-07-27 is a Monday -> getDay()=1 -> 'MO'
expect(bydayFromDate(new Date(2026, 6, 27))).toBe('MO') expect(bydayFromDate(new Date(2026, 6, 27))).toBe('MO')
// 2026-08-01 is a Saturday -> getDay()=6 -> 'SA' // 2026-08-01 是周六 -> getDay()=6 -> 'SA'
expect(bydayFromDate(new Date(2026, 7, 1))).toBe('SA') expect(bydayFromDate(new Date(2026, 7, 1))).toBe('SA')
}) })
it('dowLabel returns Chinese label for a Date', () => {
expect(dowLabel(new Date(2026, 6, 27))).toBe('周一')
expect(dowLabel(new Date(2026, 6, 26))).toBe('周日')
})
}) })

View File

@@ -4,4 +4,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
base: './', base: './',
preview: {
allowedHosts: ['f.zikai.wang'],
},
}) })