Compare commits

..

24 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
37 changed files with 2543 additions and 559 deletions

143
README.md
View File

@@ -1,103 +1,70 @@
# timeTable2 - 纯前端 ICS 日历整理器
# timeTableFix - 纯前端 ICS 日历整理器
浏览器内解析、去重、编辑 ICS 日历文件,全程无需 Python 或后端服务。拖入 `.ics` 整理去重 编辑 下载。
## 功能
- 📥 **拖入即用**:拖拽或选择 `.ics` 文件,浏览器内直接解析
- 🔁 **智能去重**:自动检测重复模式,将 61 个扁平事件合并为 5 个 RRULE 重复事件 + 3 个独立事件
- ✏️ **可视化编辑**:编辑标题/地点/时间/重复规则,点击网格切换排除日期
- ✂️ **拆分事件**:把一个重复事件按日期拆成前后两段(如后半学期换教室)
- **增删事件**:新增单次/重复事件,删除任意事件
-**撤销重做**:快照式 undo/redo最多 50 步
-**导出 ICS**:编辑完成下载整理后的 `.ics`,可直接导入日历应用
## 快速开始
```bash
# 安装依赖(使用 npmmirror 镜像加速,已配在 .npmrc
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 运行单元测试
npm test
```
浏览器打开 `http://localhost:5173`,拖入 `.ics` 文件即可。
## 使用流程
1. **导入**:拖入 `.ics` 文件(或点击选择),左侧显示所有原始事件
2. **去重**:点击顶栏「🔁 整理去重」,自动合并重复事件为 RRULE + EXDATE
3. **编辑**:点击左侧事件,右侧展开编辑表单
- 修改标题/地点/描述/时间
- 调整重复规则(频率/间隔/星期/重复到)
- 点击 occurrence 网格切换排除日期(绿色=包含,红色删除线=排除)
4. **拆分**(可选):点击「✂ 拆分此日程」,选日期,把重复事件拆成前后两段
5. **新增/删除**:点「➕ 新增日程」或「删除此日程」
6. **撤销**:「↶ 撤销」/「↷ 重做」最多 50 步
7. **下载**:点「⬇ 下载 ICS」导出整理后的 `.ics`(有未保存更改时会提示确认)
浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。独立可维护,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块集成展示。
## 项目结构
```
timeTable2/
timeTableFix/
├── index.html Vite 入口
├── package.json
├── .npmrc npmmirror 镜像加速
├── vite.config.js
├── vitest.config.js
├── .npmrc npmmirror 镜像加速
├── README.md
├── src/
│ ├── main.js 应用挂载
│ ├── App.vue 布局装配
│ ├── main.js 应用挂载i18n + 全局样式)
│ ├── App.vue 便携式根组件(嵌入与独立两用)
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale
│ ├── composables/
│ │ ── useCalendar.js 中心 store + 快照 undo/redo
│ │ ── useCalendar.js 中心 store(自动保存,无 undo
│ │ └── useLocale.js 语言辅助(直读自身 i18n 实例)
│ ├── lib/
│ │ ├── weekday.js 星期常量与工具(统一 getDay 索引)
│ │ ├── ical-io.js ICS 文本 <-> plain modelical.js 封装)
│ │ ├── weekday.js 星期常量与工具
│ │ ├── date.js 日期工具
│ │ ├── timezone.js 时区检测 / 选择(回退墨尔本)
│ │ ├── colors.js 事件配色
│ │ ├── uuid.js UUID 生成(含非安全上下文回退)
│ │ ├── ical-io.js ICS 文本 <-> plain model
│ │ ├── recur.js RRULE 网格展开
│ │ ── organize.js GCD 去重算法(移植自 Python 版)
│ │ ── organize.js 间隔去重 + 链拆分算法
│ │ ├── event-factory.js 事件构造工厂
│ │ └── split.js 事件拆分逻辑
│ └── components/
│ ├── HeaderBar.vue 顶栏:整理/撤销/重做/导入/下载
│ ├── HeaderBar.vue 顶栏:整理/导入/下载
│ ├── DropZone.vue 拖拽/选择文件
│ ├── EventList.vue 侧栏事件列表
│ ├── EventDetail.vue 编辑表单 + 拆分
── OccurrenceGrid.vue 排除日期网格
└── test/ Vitest 单元测试34 个)
── golden-org.ics 测试用例61 事件的真实课表)
├── weekday.test.js
├── ical-io.test.js
├── recur.test.js
└── organize.test.js
── OccurrenceGrid.vue 排除日期网格
│ ├── MonthPreview.vue 月预览
── WeekPreview.vue 周预览
├── test/ Vitest 单元测试
└── docs/ 设计文档
```
### 三个核心 lib 模块
## 外部依赖
| 文件 | 作用 |
|------|------|
| `ical-io.js` | ICS 文本解析为普通对象、序列化回 ICS 文本ical.js 封装) |
| `organize.js` | GCD 间隔检测 + 系列分组 + EXDATE 生成(移植自 `ical_organizer.py` |
| `recur.js` | 从 RRULE + EXDATE 按需展开 occurrence 网格 |
| | 说明 |
|----|------|
| Node.js | ≥ 18 |
| ical.js | ICS 解析库npm 依赖 |
## 算法
> 纯前端应用,无运行期后端依赖。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管(无独立 apache2 配置);详见 zMainPage 的部署文档。
去重算法从 `timeTable/ical_organizer.py` 1:1 移植:
## 如何使用
1. 按 (标题 + 地点 + 开始时间 + 时长 + 星期) 将事件分组
2. 计算组内相邻日期间隔的 **GCD**,识别:
- GCD = 7 → `FREQ=WEEKLY`(每周,含 `BYDAY`
- GCD = 1 → `FREQ=DAILY`(每天)
- GCD = 14 → `FREQ=WEEKLY;INTERVAL=2`(每两周)
3. 构建期望网格,网格上缺失的日期写入 `EXDATE`(如假期)
4. 不符合规律的组(跳过次数 ≥ 事件数)保留为独立事件
5. 合并后保留首个事件的原始 UID避免重新导入冲突
```bash
# 克隆并安装
git clone https://git.zikai.wang/zikai/timeTableFix.git
cd timeTableFix
npm install # 已配 npmmirror 镜像加速
**验证**:对 `org.ics`61 事件)整理后输出 8 个事件5 重复 + 3 独立),与 Python 版 `ical_organizer.py --verify` 结果一致。下载的 ICS 用 Python `recurring-ical-events` 展开61 个 occurrence 与原始文件完全匹配。
npm run dev # 开发服务器 http://localhost:5173
npm run build # 构建到 dist/
npm test # 单元测试57 项)
```
顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。
## 技术栈
@@ -105,21 +72,17 @@ timeTable2/
|----|------|
| 框架 | Vue 3.5SFC`<script setup>` |
| 构建 | Vite 6 |
| ICS 库 | ical.js 2.x(仅用于文本↔对象转换) |
| 状态管理 | composable 单例 store无 Pinia |
| ICS 库 | ical.js 2.x |
| 国际化 | vue-i18n 9中文同步英文懒加载 |
| 状态管理 | composable 单例 store |
| 测试 | Vitest |
| 包源 | npmmirror.com中国镜像加速 |
## 与 timeTable 的关系
## 了解更多
`timeTable`Python 版)使用 Flask + icalendar 库,需创建 venv 并启动本地服务器。
`timeTable2` 是纯前端等价实现:零安装、双击即用、算法行为一致。
| 对比 | timeTable (Python) | timeTable2 (前端) |
|------|--------------------|--------------------|
| 运行方式 | venv + Flask 服务器 | 浏览器直接打开 |
| 依赖 | icalendar, flask, recurring-ical-events | ical.js |
| 整理器 | `ical_organizer.py` 命令行 | 网页内「整理去重」按钮 |
| 编辑器 | `ical_editor.py` + `editor.html` | Vue3 组件 |
| 验证 | `recurring_ical_events` 展开 | 网页内 occurrence 网格 |
| 导出 | 保存到磁盘 / 下载 | 下载 `.ics` |
- [嵌入 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",
"dependencies": {
"ical.js": "^2.1.0",
"vue": "^3.5.0"
"vue": "^3.5.0",
"vue-i18n": "^9.13.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
@@ -505,6 +506,47 @@
"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": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -603,9 +645,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -620,9 +659,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -637,9 +673,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -654,9 +687,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -671,9 +701,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -688,9 +715,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -705,9 +729,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -722,9 +743,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -739,9 +757,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -756,9 +771,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -773,9 +785,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -790,9 +799,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -807,9 +813,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1057,6 +1060,11 @@
"@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": {
"version": "3.5.39",
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

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

View File

@@ -1,54 +1,142 @@
<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 { isLoaded } = useCalendar()
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>
<template>
<div class="app">
<header class="app-header">
<h1>📅 ICS 日程整理器</h1>
<div class="ttf-app">
<header class="ttf-header">
<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="app-main">
<main class="ttf-main">
<DropZone v-if="!isLoaded" />
<template v-else>
<EventList />
<div class="detail-area">
<EventDetail />
<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>
</template>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
background: #f5f6f8;
color: #222;
line-height: 1.5;
<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);
}
.app { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
background: #2c3e50;
color: #fff;
padding: 14px 24px;
.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;
}
.app-header h1 { font-size: 18px; font-weight: 600; }
.app-main { flex: 1; display: flex; overflow: hidden; }
.detail-area { flex: 1; overflow-y: auto; }
.empty {
text-align: center;
padding: 60px 20px;
color: #95a5a6;
.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>

View File

@@ -1,8 +1,10 @@
<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('')
@@ -12,11 +14,12 @@ function onDrop(e) {
const file = e.dataTransfer.files[0]
if (!file) return
if (!file.name.toLowerCase().endsWith('.ics')) {
error.value = '请选择 .ics 文件'
error.value = t('dropzone.requireIcs')
return
}
loadFile(file).catch((err) => {
error.value = '解析失败' + (err.message || err)
console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
})
}
@@ -25,7 +28,8 @@ function onPick(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => {
error.value = '解析失败' + (err.message || err)
console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
})
}
</script>
@@ -39,10 +43,9 @@ function onPick(e) {
@drop.prevent="onDrop"
>
<div class="dropzone-content">
<div class="icon">📅</div>
<p class="hint">拖入 .ics 文件</p>
<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>
@@ -56,44 +59,46 @@ function onPick(e) {
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #bdc3c7;
border-radius: 12px;
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
margin: 40px;
transition: all 0.2s;
transition: all var(--transition);
}
.dropzone.active {
border-color: #3498db;
background: #eaf4fc;
border-color: var(--accent);
background: var(--accent-weak);
}
.dropzone-content {
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
.hint {
color: #7f8c8d;
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 16px;
}
.pick-btn {
display: inline-block;
padding: 10px 24px;
background: #3498db;
background: var(--accent);
color: #fff;
border-radius: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.15s;
transition: background var(--transition);
}
.pick-btn:hover {
background: #2980b9;
background: var(--accent-hover);
}
.error {
color: #e74c3c;
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

@@ -1,18 +1,25 @@
<script setup>
import { ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { DOW, DOW_EN } from '../lib/weekday.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()
// Local form state, synced when selection changes
// 本地表单状态, 选择切换时同步
const form = ref({})
const showSplit = ref(false)
const splitDate = ref('')
// 标记是否正在由选中事件同步表单, 避免 watch form 时回写
let syncing = false
function syncForm(ev) {
if (!ev) { form.value = {}; return }
syncing = true
if (!ev) { form.value = {}; syncing = false; return }
form.value = {
summary: ev.summary || '',
location: ev.location || '',
@@ -25,11 +32,14 @@ function syncForm(ev) {
byday: ev.rrule?.byday || 'MO',
until_date: ev.rrule?.untilDate || '',
}
syncing = false
}
watch(selectedEvent, syncForm, { immediate: true })
function applyChanges() {
// 自动应用: 表单变化时立即写回 store
watch(form, () => {
if (syncing) return
const ev = selectedEvent.value
if (!ev) return
const patch = {
@@ -40,10 +50,16 @@ function applyChanges() {
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: form.value.freq === 'WEEKLY' ? form.value.byday : null,
byday,
untilDate: form.value.until_date || ev.rrule?.untilDate || '',
}
} else {
@@ -51,12 +67,12 @@ function applyChanges() {
patch.exdates = []
}
updateEvent(ev.uid, patch)
}
}, { deep: true })
function onDelete() {
const ev = selectedEvent.value
if (!ev) return
if (confirm(`确定删除"${ev.summary}"`)) {
if (confirm(t('eventDetail.confirmDelete', { name: ev.summary }))) {
deleteEvent(ev.uid)
}
}
@@ -65,14 +81,21 @@ function onSplit() {
const ev = selectedEvent.value
if (!ev || !ev.rrule) return
if (!splitDate.value) {
alert('请选择拆分日期')
alert(t('eventDetail.requireSplitDate'))
return
}
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
alert('拆分日期必须在开始日期和结束日期之间')
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 = ''
}
@@ -85,62 +108,62 @@ function onToggleOcc(date) {
</script>
<template>
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div>
<div v-if="!selectedEvent" class="empty">{{ t('eventDetail.empty') }}</div>
<div v-else class="detail">
<!-- Basic info -->
<div class="section-title">基本信息</div>
<!-- 基本信息 -->
<div class="section-title">{{ t('eventDetail.basicTitle') }}</div>
<div class="form-group">
<label>标题 (SUMMARY)</label>
<label>{{ t('eventDetail.summary') }}</label>
<input type="text" v-model="form.summary" />
</div>
<div class="form-row">
<div class="form-group">
<label>开始时间</label>
<label>{{ t('eventDetail.startTime') }}</label>
<input type="time" v-model="form.dtstart_time" />
</div>
<div class="form-group">
<label>结束时间</label>
<label>{{ t('eventDetail.endTime') }}</label>
<input type="time" v-model="form.dtend_time" />
</div>
</div>
<div class="form-group">
<label>地点 (LOCATION)</label>
<label>{{ t('eventDetail.location') }}</label>
<input type="text" v-model="form.location" />
</div>
<div class="form-group">
<label>描述 (DESCRIPTION)</label>
<label>{{ t('eventDetail.description') }}</label>
<textarea v-model="form.description"></textarea>
</div>
<!-- RRULE -->
<div class="section-title">重复规则 (RRULE)</div>
<!-- 重复规则 (RRULE) -->
<div class="section-title">{{ t('eventDetail.rruleTitle') }}</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.is_recurring" /> 启用重复
<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>频率</label>
<label>{{ t('eventDetail.freq') }}</label>
<select v-model="form.freq">
<option value="WEEKLY">每周 (WEEKLY)</option>
<option value="DAILY">每天 (DAILY)</option>
<option value="WEEKLY">{{ t('eventDetail.freqWeekly') }}</option>
<option value="DAILY">{{ t('eventDetail.freqDaily') }}</option>
</select>
</div>
<div class="form-group">
<label>间隔 (INTERVAL)</label>
<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>重复到 (UNTIL)</label>
<label>{{ t('eventDetail.until') }}</label>
<input type="date" v-model="form.until_date" />
</div>
<div class="form-group" v-if="form.freq === 'WEEKLY'">
<label>星期 (BYDAY)</label>
<select v-model="form.byday">
<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>
@@ -149,33 +172,32 @@ function onToggleOcc(date) {
</div>
</div>
<!-- Occurrence grid -->
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</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"
/>
<!-- Toolbar -->
<!-- 工具栏 -->
<div class="toolbar">
<button class="btn btn-primary" @click="applyChanges">应用更改</button>
<button
v-if="selectedEvent.rrule"
class="btn btn-secondary"
@click="showSplit = !showSplit"
>
拆分此日程
{{ t('eventDetail.split') }}
</button>
<button class="btn btn-danger" @click="onDelete">删除此日程</button>
<button class="btn btn-danger" @click="onDelete">{{ t('eventDetail.delete') }}</button>
</div>
<!-- Split panel -->
<!-- 拆分面板 -->
<div v-if="showSplit && selectedEvent.rrule" class="split-panel">
<p>从指定日期起把此重复事件拆成前后两段</p>
<p>{{ t('eventDetail.splitDesc') }}</p>
<input type="date" v-model="splitDate" />
<button class="btn btn-primary" @click="onSplit">确认拆分</button>
<button class="btn btn-secondary" @click="showSplit = false">取消</button>
<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>
@@ -185,22 +207,22 @@ function onToggleOcc(date) {
.empty {
text-align: center;
padding: 60px 20px;
color: #95a5a6;
color: var(--text-tertiary);
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #2c3e50;
color: var(--text-primary);
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 2px solid #ecf0f1;
border-bottom: 2px solid var(--border);
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #34495e;
color: var(--text-secondary);
margin-bottom: 5px;
}
.form-group input,
@@ -208,10 +230,19 @@ function onToggleOcc(date) {
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d5dbdb;
border-radius: 6px;
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; }
@@ -221,33 +252,43 @@ function onToggleOcc(date) {
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #ecf0f1;
border-top: 1px solid var(--border);
}
.btn {
padding: 7px 16px;
border: none;
border-radius: 6px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
}
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
.btn-secondary:hover { background: #d5dbdb; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.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: #f8f9fa;
border-radius: 8px;
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: #7f8c8d; margin-bottom: 8px; }
.split-panel input { padding: 6px 10px; border: 1px solid #d5dbdb; border-radius: 6px; }
.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

@@ -1,9 +1,11 @@
<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()]
@@ -13,9 +15,9 @@ function dowFor(ev) {
<template>
<div class="event-list">
<div class="stat">
{{ stats.total }} 个日程{{ stats.recurring }} 重复 / {{ stats.single }} 单次
{{ t('eventList.stat', { total: stats.total, recurring: stats.recurring, single: stats.single }) }}
</div>
<button class="add-btn" @click="addEvent"> 新增日程</button>
<button class="add-btn" @click="addEvent">{{ t('eventList.add') }}</button>
<div class="list">
<div
v-for="ev in state.events"
@@ -25,9 +27,9 @@ function dowFor(ev) {
@click="selectEvent(ev.uid)"
>
<div class="title">
{{ ev.summary || '(无标题)' }}
{{ ev.summary || t('eventList.untitled') }}
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
{{ ev.rrule ? '重复' : '单次' }}
{{ ev.rrule ? t('eventList.recurring') : t('eventList.single') }}
</span>
</div>
<div class="meta">
@@ -42,38 +44,39 @@ function dowFor(ev) {
<style scoped>
.event-list {
width: 340px;
background: #fff;
border-right: 1px solid #e0e3e6;
background: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.stat { font-size: 12px; color: #95a5a6; padding: 12px 16px 8px; }
.stat { font-size: 12px; color: var(--text-tertiary); padding: 12px 16px 8px; }
.add-btn {
margin: 0 16px 8px;
padding: 8px;
border: 1px dashed #3498db;
background: #eaf4fc;
color: #2980b9;
border-radius: 6px;
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: #d4eaf9; }
.add-btn:hover { background: var(--surface-2); }
.list { flex: 1; overflow-y: auto; }
.event-item {
padding: 12px 16px;
border-bottom: 1px solid #eef0f2;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s;
transition: background var(--transition);
}
.event-item:hover { background: #f8f9fa; }
.event-item:hover { background: var(--surface); }
.event-item.active {
background: #eaf4fc;
border-left: 3px solid #3498db;
background: var(--accent-weak);
border-left: 3px solid var(--accent);
}
.title { font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 3px; }
.meta { font-size: 12px; color: #7f8c8d; }
.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;
@@ -82,6 +85,15 @@ function dowFor(ev) {
font-weight: 600;
margin-left: 6px;
}
.badge-recur { background: #e8f5e9; color: #2e7d32; }
.badge-single { background: #fff3e0; color: #e65100; }
.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

@@ -1,14 +1,18 @@
<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, canUndo, canRedo, organize, undo, redo, serialize, markSaved, loadFile } = useCalendar()
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('未发现可合并的重复模式')
alert(t('header.noPattern'))
}
}
@@ -19,12 +23,15 @@ function onImport() {
function onFilePicked(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err)))
loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
alert(t('header.parseError') + (err.message || err))
})
e.target.value = ''
}
function onDownload() {
if (state.dirty && !confirm('有未保存的更改,仍要下载?')) return
// 兜底自动保存: 始终序列化当前最新状态 (所有编辑已即时生效)
const text = serialize()
const blob = new Blob([text], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
@@ -34,43 +41,69 @@ function onDownload() {
a.download = base + '.edited.ics'
a.click()
URL.revokeObjectURL(url)
markSaved()
}
</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-secondary" @click="undo" :disabled="!canUndo"> 撤销</button>
<button class="btn btn-secondary" @click="redo" :disabled="!canRedo"> 重做</button>
<button class="btn btn-secondary" @click="onImport">📥 导入文件</button>
<button class="btn btn-primary" @click="onImport">{{ t('header.import') }}</button>
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
下载 ICS
{{ t('header.download') }}
</button>
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改"></span>
<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: 10px; align-items: center; }
.header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
.btn {
padding: 7px 16px;
border: none;
border-radius: 6px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
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: #3498db; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #2980b9; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover:not(:disabled) { background: #229954; }
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
.dirty-dot { color: #e74c3c; font-size: 16px; }
.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

@@ -3,11 +3,13 @@ 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))
@@ -19,7 +21,7 @@ function dowFor(dateStr) {
<template>
<div>
<p class="hint">
点击日期切换"排除/包含"状态排除的日期红色删除线会写入 EXDATE
{{ t('eventDetail.occHint') }}
</p>
<div class="occ-grid">
<div
@@ -39,7 +41,7 @@ function dowFor(dateStr) {
<style scoped>
.hint {
font-size: 12px;
color: #7f8c8d;
color: var(--text-secondary);
margin-bottom: 8px;
}
.occ-grid {
@@ -52,24 +54,26 @@ function dowFor(dateStr) {
}
.occ {
padding: 8px 10px;
border: 1px solid #d5dbdb;
border-radius: 6px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
text-align: center;
transition: all 0.12s;
transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
}
.occ:hover { border-color: #3498db; }
.occ:hover { border-color: var(--accent); }
.occ.skipped {
background: #fce4ec;
border-color: #e74c3c;
color: #c0392b;
background: var(--danger-weak);
border-color: var(--danger);
color: var(--danger);
text-decoration: line-through;
}
.occ.active {
background: #e8f5e9;
border-color: #27ae60;
color: #1e7d32;
background: var(--success-weak);
border-color: var(--success);
color: var(--success);
}
.dow { font-size: 10px; color: #95a5a6; }
.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

@@ -1,47 +1,26 @@
import { reactive, computed } from 'vue'
import { parseICS, serializeICS } from '../lib/ical-io.js'
import { organize as organizeEvents } from '../lib/organize.js'
import { createEvent, cloneEvent } from '../lib/event-factory.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 MAX_HISTORY = 50
// Singleton reactive state
// 单例响应式状态
const state = reactive({
meta: null,
events: [],
selectedUid: null,
history: [],
redoStack: [],
fileName: null,
dirty: false,
viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
// 日历级默认时区: 加载时自动检测用户所在时区, 取不到回退墨尔本。
// 仅作新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE, 不做时间换算。
timezone: detectTimezone(),
})
// ------------------------------------------------------------------ //
// Snapshot / undo
// ------------------------------------------------------------------ //
function snapshot() {
return {
meta: state.meta ? cloneEvent(state.meta) : null,
events: state.events.map(cloneEvent),
selectedUid: state.selectedUid,
}
}
function pushHistory() {
state.history.push(snapshot())
if (state.history.length > MAX_HISTORY) state.history.shift()
state.redoStack = []
}
function restore(snap) {
state.meta = snap.meta ? cloneEvent(snap.meta) : null
state.events = snap.events.map(cloneEvent)
state.selectedUid = snap.selectedUid
}
// ------------------------------------------------------------------ //
// Actions
// 文件加载
// ------------------------------------------------------------------ //
function loadFile(file) {
return file.text().then((text) => {
@@ -49,69 +28,63 @@ function loadFile(file) {
state.meta = parsed.meta
state.events = parsed.events
state.selectedUid = null
state.history = []
state.redoStack = []
state.fileName = file.name
state.dirty = false
})
}
// ------------------------------------------------------------------ //
// 事件操作 (所有改动即时生效 -- 自动保存)
// ------------------------------------------------------------------ //
function selectEvent(uid) {
state.selectedUid = uid
}
function organize() {
if (state.events.length === 0) return
pushHistory()
const result = organizeEvents(state.events)
state.events = result.events
state.dirty = true
return result.stats
}
/**
* 更新指定事件的字段。
* 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。
* uid 不存在视为编程错误, 直接抛出 (调用方应先校验 selectedEvent)。
*/
function updateEvent(uid, patch) {
pushHistory()
const ev = state.events.find((e) => e.uid === uid)
if (ev) {
if (!ev) throw new Error(`updateEvent: 事件不存在 uid=${uid}`)
Object.assign(ev, patch)
state.dirty = true
}
}
function deleteEvent(uid) {
pushHistory()
state.events = state.events.filter((e) => e.uid !== uid)
if (state.selectedUid === uid) state.selectedUid = null
state.dirty = true
}
function addEvent() {
pushHistory()
const ev = createEvent()
const ev = createEvent({ tzid: state.timezone })
state.events.push(ev)
state.selectedUid = ev.uid
state.dirty = true
}
function splitEvent(uid, splitDate) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev || !ev.rrule) return
if (!ev) throw new Error(`splitEvent: 事件不存在 uid=${uid}`)
if (!ev.rrule) throw new Error('splitEvent: 非重复事件无法拆分')
const parts = splitRecurringEvent(ev, splitDate)
if (!parts) return
if (!parts) throw new Error('splitEvent: 拆分条件不满足 (日期越界或不在网格)')
pushHistory()
const [partA, partB] = parts
const idx = state.events.findIndex((e) => e.uid === uid)
state.events.splice(idx, 1, partA, partB)
state.selectedUid = partB.uid
state.dirty = true
}
function toggleOccurrence(uid, date) {
pushHistory()
const ev = state.events.find((e) => e.uid === uid)
if (!ev) return
if (!ev) throw new Error(`toggleOccurrence: 事件不存在 uid=${uid}`)
const i = ev.exdates.indexOf(date)
if (i >= 0) {
ev.exdates.splice(i, 1)
@@ -119,38 +92,69 @@ function toggleOccurrence(uid, date) {
ev.exdates.push(date)
ev.exdates.sort()
}
state.dirty = true
}
function undo() {
if (state.history.length === 0) return
state.redoStack.push(snapshot())
restore(state.history.pop())
}
function redo() {
if (state.redoStack.length === 0) return
state.history.push(snapshot())
restore(state.redoStack.pop())
}
function serialize() {
return serializeICS({ meta: state.meta, events: state.events })
}
function markSaved() {
state.dirty = false
}
// ------------------------------------------------------------------ //
// Computed
// 序列化 (下载时兜底: 始终序列化当前最新状态)
// ------------------------------------------------------------------ //
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 canUndo = computed(() => state.history.length > 0)
const canRedo = computed(() => state.redoStack.length > 0)
const isLoaded = computed(() => state.events.length > 0)
const stats = computed(() => {
const nRecur = state.events.filter((e) => e.rrule !== null).length
@@ -161,8 +165,6 @@ export function useCalendar() {
return {
state,
selectedEvent,
canUndo,
canRedo,
isLoaded,
stats,
loadFile,
@@ -173,9 +175,13 @@ export function useCalendar() {
addEvent,
splitEvent,
toggleOccurrence,
undo,
redo,
serialize,
markSaved,
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]
}

View File

@@ -1,59 +1,86 @@
/**
* Centralized date-string utilities.
* 集中式的日期字符串工具集。
*
* All date values in the app use the 'YYYY-MM-DD' (wall-clock) format.
* These helpers are the single source of truth for formatting and
* arithmetic on that format, replacing ad-hoc inline code that was
* duplicated across organize.js, recur.js, useCalendar.js, and ical-io.js.
* 应用内所有日期值统一使用 'YYYY-MM-DD' (本地墙钟时间) 格式。
* 本模块是格式化与日期运算的唯一来源, 替代此前散落在 organize.js、
* recur.js、useCalendar.js、ical-io.js 中的重复实现。
*/
/** Pad a number to 2 digits with leading zero. */
/** 将数字补零为 2 位字符串。 */
export function pad2(n) {
return String(n).padStart(2, '0')
}
/** Convert a JS Date to 'YYYY-MM-DD' (local wall-clock). */
/** JS Date 转换为 'YYYY-MM-DD' (本地墙钟时间)。 */
export function toISODate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
}
/** Parse 'YYYY-MM-DD' into { year, month, day } (month is 1-based). */
/** '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 }
}
/** Build a local JS Date at midnight from 'YYYY-MM-DD'. */
/** 由 'YYYY-MM-DD' 构造本地午夜 JS Date。 */
export function toDate(isoDate) {
return new Date(isoDate + 'T00:00:00')
}
/** Add `days` days to an ISO date string, return new ISO string. */
/** 在 ISO 日期字符串上增加 `days` 天, 返回新的 ISO 字符串。 */
export function addDays(isoDate, days) {
const d = toDate(isoDate)
d.setDate(d.getDate() + days)
return toISODate(d)
}
/** Whole-day difference between two ISO date strings (b - a). */
/** 两个 ISO 日期字符串之间的整天差 (b - a) */
export function daysBetween(isoA, isoB) {
return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000))
}
/** Today's date as 'YYYY-MM-DD' (local). */
/** 今天的日期, 格式为 'YYYY-MM-DD' (本地)。 */
export function todayISO() {
return toISODate(new Date())
}
/**
* Current UTC timestamp in compact RFC 5545 form (e.g. '20260711T085008Z').
* Used for DTSTAMP on new events.
* 当前 UTC 时间戳, 紧凑 RFC 5545 格式 (如 '20260711T085008Z')
* 用于新事件的 DTSTAMP。
*/
export function nowCompactTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
}
/** Format a JS Date's time as 'HH:MM'. */
export function toHHMM(date) {
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
/**
* 返回 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
}

View File

@@ -1,20 +1,20 @@
/**
* Event model factory and helpers.
* 事件模型工厂与辅助函数。
*
* The EventModel is the plain-JS shape used throughout the app (see
* docs/superpowers/specs/... §4.2). This module centralizes creation
* and cloning so that useCalendar.js stays focused on state management.
* EventModel 是应用内通用的纯 JS 数据结构 (见 docs/superpowers/specs/...
* §4.2)。本模块集中管理事件的创建与克隆, 使 useCalendar.js 专注于状态管理。
*/
import { todayISO, nowCompactTimestamp } from './date.js'
import { genUUID } from './uuid.js'
/**
* Create a new single-occurrence event with sensible defaults.
* @param {object} [overrides] - partial fields to override
* @returns {object} a fresh EventModel
* 创建一个具有合理默认值的单次事件。
* @param {object} [overrides] - 需覆盖的部分字段
* @returns {object} 一个全新的 EventModel
*/
export function createEvent(overrides = {}) {
return {
uid: crypto.randomUUID(),
uid: genUUID(),
summary: '新日程',
location: '',
description: '',
@@ -30,12 +30,11 @@ export function createEvent(overrides = {}) {
}
/**
* Deep-clone an event (or any plain-data object).
* 深拷贝一个事件 (或任意纯数据对象)。
*
* Uses JSON round-trip rather than structuredClone because events may be
* wrapped in Vue reactive proxies, which structuredClone cannot handle.
* EventModel contains only plain data (strings, arrays, nested objects),
* so JSON cloning is safe and lossless here.
* 使用 JSON 往返而非 structuredClone, 因为事件可能被 Vue 响应式代理包裹,
* structuredClone 无法处理。EventModel 仅含纯数据 (字符串、数组、嵌套对象),
* 因此 JSON 拷贝在此是安全且无损的。
*/
export function cloneEvent(ev) {
return JSON.parse(JSON.stringify(ev))

View File

@@ -1,8 +1,9 @@
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) {
for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) {
@@ -14,8 +15,8 @@ function registerTimezones(vcalendar) {
}
// ------------------------------------------------------------------ //
// ICAL.Time <-> plain string helpers
// (ICAL.Time exposes .year/.month/.day/.hour/.minute directly)
// ICAL.Time <-> 普通字符串辅助函数
// (ICAL.Time 直接暴露 .year/.month/.day/.hour/.minute)
// ------------------------------------------------------------------ //
function icalTimeToISODate(t) {
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
@@ -33,14 +34,14 @@ function getTzid(component, propName) {
}
// ------------------------------------------------------------------ //
// Parse a single VEVENT component -> plain EventModel
// 解析单个 VEVENT 组件 -> 普通 EventModel
// ------------------------------------------------------------------ //
function parseEvent(veventComp) {
const event = new ICAL.Event(veventComp)
const start = event.startDate
const end = event.endDate
// Determine tzid
// 确定 tzid
const tzid = getTzid(veventComp, 'dtstart')
// RRULE
@@ -61,7 +62,7 @@ function parseEvent(veventComp) {
}
}
// EXDATEs
// EXDATE
const exdates = []
for (const prop of veventComp.getAllProperties('exdate')) {
for (const t of prop.getValues()) {
@@ -69,15 +70,15 @@ function parseEvent(veventComp) {
}
}
// _raw: capture DTSTAMP and any other unmapped properties
// Use toICALString() to preserve the compact RFC5545 form ("20260711T085008Z")
// rather than toString()'s ISO 8601 form ("2026-07-11T08:50:08Z").
// _raw: 保存 DTSTAMP 及其他未映射的属性
// 使用 toICALString() 保留紧凑 RFC5545 形式 ("20260711T085008Z"),
// 而非 toString() ISO 8601 形式 ("2026-07-11T08:50:08Z")
const _raw = {}
const dtstamp = veventComp.getFirstPropertyValue('dtstamp')
if (dtstamp) _raw.dtstamp = dtstamp.toICALString()
return {
uid: veventComp.getFirstPropertyValue('uid') || '',
uid: veventComp.getFirstPropertyValue('uid') || genUUID(),
summary: veventComp.getFirstPropertyValue('summary') || '',
location: veventComp.getFirstPropertyValue('location') || '',
description: veventComp.getFirstPropertyValue('description') || '',
@@ -92,16 +93,16 @@ function parseEvent(veventComp) {
}
// ------------------------------------------------------------------ //
// Parse full ICS text -> { meta, events }
// 解析完整 ICS 文本 -> { meta, events }
// ------------------------------------------------------------------ //
export function parseICS(text) {
const jcal = ICAL.parse(text)
const vcalendar = new ICAL.Component(jcal)
// Register timezones from VTIMEZONEs before constructing zoned Times
// 在构造带时区的 Time 之前, 先从 VTIMEZONE 注册时区
registerTimezones(vcalendar)
// Meta
// 日历元信息
const meta = {
prodid: vcalendar.getFirstPropertyValue('prodid') || '',
version: vcalendar.getFirstPropertyValue('version') || '2.0',
@@ -109,19 +110,19 @@ export function parseICS(text) {
method: vcalendar.getFirstPropertyValue('method') || null,
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null,
xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null,
// Store raw jCal arrays (not ICAL.Component instances) so the meta stays
// plain-serializable for Vue reactivity and JSON snapshot cloning.
// 存储原始 jCal 数组 (而非 ICAL.Component 实例), 使 meta 保持纯可序列化,
// 便于 Vue 响应式与 JSON 快照克隆。
vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal),
}
// Events
// 事件
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
return { meta, events }
}
// ------------------------------------------------------------------ //
// Helpers for serialization
// 序列化辅助函数
// ------------------------------------------------------------------ //
function parseHHMM(s) {
const [h, m] = s.split(':').map(Number)
@@ -131,7 +132,13 @@ function parseHHMM(s) {
function getZone(tzid) {
if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone
const z = ICAL.TimezoneService.get(tzid)
return z || ICAL.Timezone.utcTimezone
if (!z) {
// 时区未注册 (VTIMEZONE 缺失): ical.js 回退 UTC 会导致时间偏移。
// 记录到控制台便于定位 (典型成因: 手建事件 tzid 未在源 ICS 中定义)。
console.warn(`[timeTableFix] 时区 ${tzid} 未注册, 回退 UTC`)
return ICAL.Timezone.utcTimezone
}
return z
}
function makeTime(dateStr, timeStr, tzid) {
@@ -143,10 +150,10 @@ function makeTime(dateStr, timeStr, tzid) {
)
}
// Parse a compact RFC5545 date-time string (e.g. "20260711T085008Z" or
// "20260711T085008") back into an ICAL.Time. ical.js's public Time.fromString
// rejects the compact form, so we parse it manually and hand the Time object
// to updatePropertyWithValue (which writes it back in compact form).
// 解析紧凑 RFC5545 日期时间字符串 (如 "20260711T085008Z"
// "20260711T085008") ICAL.Timeical.js Time.fromString 不接受紧凑
// 格式, 因此手动解析后构造 Time 对象传给 updatePropertyWithValue
// (该方法会以紧凑格式写回)。
function parseCompactDateTime(s) {
const isUtc = s.endsWith('Z')
const core = isUtc ? s.slice(0, -1) : s
@@ -168,7 +175,7 @@ function parseCompactDateTime(s) {
}
// ------------------------------------------------------------------ //
// Serialize { meta, events } -> ICS text
// 序列化 { meta, events } -> ICS 文本
// ------------------------------------------------------------------ //
export function serializeICS({ meta, events }) {
const vcalendar = new ICAL.Component('vcalendar')
@@ -180,8 +187,8 @@ export function serializeICS({ meta, events }) {
if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname)
if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
// Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS),
// so wrap each directly in a new Component (avoids moving the original).
// 重新添加 VTIMEZONEmeta.vtimezones 存储的是原始 jCal 数组 (见 parseICS),
// 因此直接用新 Component 包裹 (避免移动原始组件)。
for (const vtzJCal of meta.vtimezones || []) {
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
}
@@ -204,10 +211,9 @@ export function serializeICS({ meta, events }) {
const deProp = vevent.addPropertyWithValue('dtend', dtend)
if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid)
// DTSTAMP - _raw.dtstamp is stored in compact RFC5545 form
// ("20260711T085008Z"); pass an ICAL.Time object so ical.js decorates it
// correctly and writes it back in the same compact form. Passing the raw
// string fails (ical.js 2.x rejects compact form in fromDateTimeString).
// DTSTAMP - _raw.dtstamp 以紧凑 RFC5545 格式存储 ("20260711T085008Z");
// 传入 ICAL.Time 对象使 ical.js 正确装饰并以相同紧凑格式写回。
// 直接传原始字符串会失败 (ical.js 2.x 的 fromDateTimeString 不接受紧凑格式)。
const dtstampRaw = ev._raw?.dtstamp
const dtstampTime = dtstampRaw
? parseCompactDateTime(dtstampRaw)
@@ -220,7 +226,7 @@ export function serializeICS({ meta, events }) {
const recurData = { freq: r.freq, interval: r.interval || 1 }
if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday
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)
recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone)
}
@@ -228,7 +234,7 @@ export function serializeICS({ meta, events }) {
vevent.addPropertyWithValue('rrule', recur)
}
// EXDATEs (in DTSTART's timezone)
// EXDATE (使用 DTSTART 的时区)
for (const exDate of ev.exdates || []) {
const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid)
const exProp = vevent.addPropertyWithValue('exdate', exTime)

View File

@@ -1,28 +1,48 @@
/**
* GCD-based recurrence detection - port of ical_organizer.py.
* Collapses a flat list of per-occurrence events into RRULE + EXDATE.
* 重复事件合并 - 将扁平的单次事件列表合并为 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 { toDate, toISODate, addDays, daysBetween } from './date.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) {
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
const [eh, em] = ev.dtendTime.split(':').map(Number)
const durationMin = (eh * 60 + em) - (sh * 60 + sm)
const weekday = toDate(ev.dtstartDate).getDay()
return [ev.summary, ev.location, ev.dtstartTime, durationMin, weekday].join('|')
return [ev.summary, ev.location, ev.dtstartTime, durationMin].join('|')
}
// ------------------------------------------------------------------ //
// Interval detection
// 间隔检测
// ------------------------------------------------------------------ //
function gcd(a, b) {
while (b) { [a, b] = [b, a % b] }
@@ -30,8 +50,11 @@ function gcd(a, b) {
}
/**
* Return the recurrence interval in days, or null if irregular.
* Computes GCD of all consecutive date gaps.
* 返回「最小公约间隔」(天数), 不规则时返回 null。
*
* 计算 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) {
if (dateStrings.length < 2) return null
@@ -48,13 +71,13 @@ export function detectInterval(dateStrings) {
}
// ------------------------------------------------------------------ //
// Series fitting
// 系列拟合
// ------------------------------------------------------------------ //
const byDate = (a, b) => (a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0)
/**
* Build the expected occurrence grid from first to last at the given interval.
* Returns { expected: string[], missing: string[] } relative to present dates.
* 从首日期到末日期按给定间隔构建期望的 occurrence 网格。
* 返回 { expected, missing, offGrid } (相对于实际存在的日期)。
*/
function buildGrid(firstDate, lastDate, interval, present) {
const expected = []
@@ -70,29 +93,10 @@ function buildGrid(firstDate, lastDate, interval, present) {
}
/**
* Try to collapse events into one recurring event.
* Returns { base, rrule, exdates } or null to decline.
* 由天数间隔构造 RRULE。7 的倍数 -> WEEKLY (byday 从首日期推导),
* 否则 -> DAILY (byday 为 null)。
*/
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
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)
if (offGrid.length > 0) return null
// "Mostly regular": at least 2 occurrences and occurrences >= skips
if (evs.length < 2 || missing.length >= evs.length) return null
// Build rrule
const firstDate = toDate(first.dtstartDate)
function buildRRule(interval, firstDate, lastDateStr) {
let freq, step, byday
if (interval % 7 === 0) {
freq = 'WEEKLY'
@@ -103,20 +107,169 @@ export function fitSeries(evs) {
step = interval
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 }
}
// ------------------------------------------------------------------ //
// 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) {
// Group by series key
// 按系列标识分组
const groups = new Map()
for (const ev of events) {
const key = seriesKey(ev)
@@ -129,23 +282,11 @@ export function organize(events) {
let nFlat = 0
for (const evs of groups.values()) {
evs.sort(byDate)
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) {
const merged = organizeGroup(evs)
for (const ev of merged) {
result.push(ev)
nFlat++
}
if (ev.rrule) nSeries++
else nFlat++
}
}

View File

@@ -1,10 +1,10 @@
/**
* Recurrence grid expansion - port of ical_editor.py's occurrence generation.
* occurrences are computed on-demand from exdates, never stored on the model.
* 重复网格展开 - 移植自 ical_editor.py occurrence 生成逻辑。
* occurrence 按需从 exdates 计算, 不存储在模型上。
*/
import { toISODate, toDate, addDays } from './date.js'
/** Interval in days for a recurring event. */
/** 重复事件的间隔天数。 */
export function intervalDays(ev) {
if (!ev.rrule) return 0
const { freq, interval } = ev.rrule
@@ -15,11 +15,11 @@ export function intervalDays(ev) {
const MAX_OCCURRENCES = 500
/**
* Expand a recurring event into an occurrence grid.
* Returns [{ date: 'YYYY-MM-DD', skipped: boolean }].
* 将重复事件展开为 occurrence 网格。
* 返回 [{ date: 'YYYY-MM-DD', skipped: boolean }]
*/
export function expandOccurrences(ev) {
// Non-recurring: single occurrence
// 非重复事件: 单个 occurrence
if (!ev.rrule) {
return [{ date: ev.dtstartDate, skipped: false }]
}

View File

@@ -1,20 +1,20 @@
/**
* Split a recurring event into two at a given date.
* 将一个重复事件在指定日期拆分为两段。
*
* Extracted from useCalendar.js as a pure function so the store stays
* focused on state management. The grid-snapping logic reuses
* intervalDays from recur.js (single source of truth for step size).
* useCalendar.js 提取为纯函数, 使 store 专注于状态管理。
* 网格对齐逻辑复用 recur.js 的 intervalDays (步长的唯一来源)。
*
* @param {object} ev - the event to split (must have a non-null rrule)
* @param {string} splitDate - 'YYYY-MM-DD', must be within the event's range
* @returns {[object, object]|null} [partA, partB] or null if the date is
* out of range or the event is not recurring.
* @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'
/** Snap a date forward to the next grid point on or after it. */
/** 将日期向前对齐到不早于它的网格点。 */
function snapToGrid(startDate, targetDate, stepDays, untilDate) {
const start = toDate(startDate)
let snap = toDate(targetDate)
@@ -42,7 +42,7 @@ export function splitRecurringEvent(ev, splitDate) {
partA.exdates = ev.exdates.filter((d) => d < splitDate)
const partB = cloneEvent(ev)
partB.uid = crypto.randomUUID()
partB.uid = genUUID()
partB.dtstartDate = snapStr
partB.exdates = ev.exdates.filter((d) => d >= splitDate)

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 = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
// 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 BYDAY_TO_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }
/** Return the BYDAY code ('SU'..'SA') for a JS Date. */
/** 返回 JS Date 对应的 BYDAY 编码 ('SU'..'SA')。 */
export function bydayFromDate(date) {
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 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 { dirname, join } from 'node:path'
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 GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
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', () => {
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 e2 = events[1] // same series, next week
expect(seriesKey(e1)).toBe(seriesKey(e2))
@@ -18,10 +34,17 @@ describe('seriesKey', () => {
it('separates events with different summary', () => {
const e1 = events[0] // ACC INFO SYS
// Find a BUS LAW event
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
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', () => {
@@ -38,9 +61,14 @@ describe('detectInterval', () => {
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
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)', () => {
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
const result = fitSeries(accEvents)
@@ -51,32 +79,120 @@ describe('fitSeries', () => {
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')
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)', () => {
const { events: organized, stats } = organize(events)
it('collapses 61 events into 8', () => {
expect(organized).toHaveLength(8)
expect(stats.series).toBe(5)
expect(stats.flat).toBe(3)
it('collapses 61 events into 6 (6 series, 0 flat)', () => {
expect(organized).toHaveLength(6)
expect(stats.series).toBe(6)
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)
expect(recurring).toHaveLength(5)
expect(recurring).toHaveLength(6)
})
it('produces 3 flat BUS LAW Workshop events', () => {
const flatWs = organized.filter(
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null,
)
expect(flatWs).toHaveLength(3)
it('merges BUS LAW Workshop into 1 recurring event with 5 exdates', () => {
const ws = organized.filter(e => e.summary === 'BUS LAW, Workshop')
expect(ws).toHaveLength(1)
expect(ws[0].rrule).not.toBeNull()
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)', () => {

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 { 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', () => {
it('DOW has 7 entries starting Sunday', () => {
it('DOW 有 7 项, 首项为周日', () => {
expect(DOW).toHaveLength(7)
expect(DOW[0]).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'])
})
it('BYDAY_TO_INDEX maps SU..SA to 0..6', () => {
expect(BYDAY_TO_INDEX.SU).toBe(0)
expect(BYDAY_TO_INDEX.SA).toBe(6)
})
it('bydayFromDate returns BYDAY code for a Date', () => {
// 2026-07-27 is a Monday -> getDay()=1 -> 'MO'
it('bydayFromDate 返回 Date 对应的 BYDAY 编码', () => {
// 2026-07-27 是周一 -> getDay()=1 -> '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')
})
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({
plugins: [vue()],
base: './',
preview: {
allowedHosts: ['f.zikai.wang'],
},
})