24 KiB
timeTable2 - 纯前端 ICS 日历整理与编辑器
日期: 2026-07-13
状态: 设计已定稿,待用户复核
位置: D:\zcode\timeTable2(与 D:\zcode\timeTable 平级,独立 git 仓库)
1. 目标与范围
1.1 目标
构建一个纯前端(HTML + JS + CSS,无 Python、无后端服务)的 ICS 日历应用,实现:
- 解析:浏览器内读取任意
.ics文件(支持 VTIMEZONE、RRULE、EXDATE) - 去重整理:将"每周相同却被导出为 N 个独立事件"的扁平列表,自动合并为带
RRULE+EXDATE的重复事件 - 编辑:可视化编辑每个事件(标题/地点/描述/时间/重复规则/排除日期)
- 增删拆:手动新增事件、删除事件、把一个已合并的重复事件按日期拆成两段
- 导出:编辑完成后下载整理好的
.ics
用户全程通过拖拽/选择文件进入,编辑完成后下载,无需任何命令行或服务器。
1.2 范围边界
包含:
- Vite + Vue3(SFC,
<script setup>)工程化项目 - ical.js 仅用于 ICS 文本解析与序列化
- 移植
timeTable/ical_organizer.py的 GCD 去重算法到纯 JS - 移植
timeTable/ical_editor.py的 RRULE 网格展开逻辑到纯 JS - 单一中心 store + 快照式 undo/redo
- 与
timeTable/editor.html一致的深色顶栏 + 白色侧栏 + 绿/红网格视觉风格
不包含:
- Python 任何依赖(纯前端)
recurring-ical-events等服务端展开库(用自写网格生成器替代)- 多用户/多文件会话(单文件、单会话工具)
- 移动端原生适配(桌面优先,但布局响应式不崩)
1.3 与 timeTable 的关系
timeTable(Python 版)保持不动。timeTable2 是平级的全新项目,目标是"零安装、双击即用"的等价能力。算法逻辑从 Python 1:1 移植,确保行为一致(同一 org.ics 输入应产出与 ical_organizer.py 等价的合并结果)。
2. 技术栈与依赖
| 项 | 选择 | 说明 |
|---|---|---|
| 框架 | Vue 3.5+ | SFC,<script setup> 语法 |
| 构建 | Vite 6+ | npm create vite@latest |
| ICS 库 | ical.js | 唯一外部依赖,仅用 ICAL.parse / ICAL.Component / ICAL.Time 做文本↔对象转换 |
| UI 库 | 无 | 组件自带 scoped CSS,复用 editor.html 视觉风格 |
| 状态管理 | composable(无 Pinia) | useCalendar.js 单例 reactive store |
| 包源 | npmmirror.com | 中国镜像加速,配 .npmrc |
| Node | v24.14.0(本机) | 要求 Node 20+ |
| 版本控制 | git | D:\zcode\timeTable2 独立 git init |
.npmrc 内容:
registry=https://registry.npmmirror.com
package.json 依赖:
vue: ^3.5ical.js: ^2.1
devDependencies:
vite: ^6@vitejs/plugin-vue: ^5
无其他依赖。
3. 项目结构
timeTable2/
├── .gitignore
├── .npmrc # npmmirror 镜像
├── index.html # Vite 入口
├── package.json
├── vite.config.js
├── README.md
├── public/ # (可空,留作 favicon 等)
├── docs/
│ └── superpowers/specs/
│ └── 2026-07-13-timetable2-design.md # 本文件
└── src/
├── main.js # createApp 挂载
├── App.vue # 布局与装配
├── composables/
│ └── useCalendar.js # 中心 store + 快照 undo/redo
├── lib/
│ ├── ical-io.js # ICS 文本 <-> plain model(ical.js 封装)
│ ├── organize.js # GCD 去重算法(移植 ical_organizer.py)
│ ├── recur.js # RRULE 网格展开 / EXDATE 重算
│ └── weekday.js # DOW 数组、BYDAY 映射、weekday 工具
└── components/
├── HeaderBar.vue # 顶栏:整理/撤销/重做/导入/下载/dirty 标记
├── DropZone.vue # 拖拽/选择文件落地区
├── EventList.vue # 侧栏事件列表 + 新增按钮
├── EventDetail.vue # 右侧编辑表单 + 工具栏
└── OccurrenceGrid.vue # 排除日期网格(绿/红切换)
分层依赖(自下而上,单向)
lib/weekday.js 纯常量与工具,无依赖
lib/ical-io.js 依赖 ical.js + weekday.js
lib/recur.js 依赖 weekday.js
lib/organize.js 依赖 recur.js + weekday.js
composables/useCalendar.js 依赖 lib/* 全部
components/* 仅依赖 useCalendar,纯展示 + 调 store action
App.vue 组装 components
lib/ 全部是纯函数:入参为 plain object,返回 plain object,无副作用,可独立单元测试。
4. 数据模型
4.1 CalendarMeta
{
prodid: '-//Allocate//iCal4j 1.0//EN',
version: '2.0',
calscale: 'GREGORIAN',
method: 'PUBLISH' | null,
xWrCalname: string | null,
xWrTimezone: string | null,
vtimezones: [ /* 原始 VTIMEZONE 组件的 jCal 数组,原样透传 */ ],
}
4.2 EventModel(plain,Vue 响应式友好)
{
uid: 'uid0' | crypto.randomUUID(),
summary: 'ACC INFO SYS, Tutorial',
location: 'CA_B_B471',
description: 'ACF2400_CA_S2_ON-CAMPUS...',
dtstartDate: '2026-07-27', // YYYY-MM-DD
dtstartTime: '18:00', // HH:MM
dtendTime: '20:00', // HH:MM
tzid: 'Australia/Melbourne', // 时区标识,UTC 或 TZID
rrule: { // null 表示单次事件
freq: 'WEEKLY' | 'DAILY',
interval: 1,
byday: 'MO' | 'TU' | ... | 'SU', // 仅 WEEKLY 有意义
untilDate: '2026-10-19', // YYYY-MM-DD(墙钟语义)
} | null,
exdates: ['2026-09-21', ...], // YYYY-MM-DD 数组
_raw: { // 未建模属性原样透传,零数据丢失
dtstamp: '20260711T085008Z',
// 其他 X- 属性、ATTENDEE 等
},
}
设计要点:
- 墙钟语义:
dtstartDate/Time、untilDate、exdates全部是用户看到的本地墙钟值,不带时区计算。时区信息保存在tzid中,序列化时由 ical.js 处理。 - UNTIL 存储为墙钟日期,序列化到 ICS 时转 UTC(RFC 5545 要求 UNTIL 用 UTC,如
20261019T070000Z)。转换:用tzid把untilDate 23:59当作该时区时刻 -> UTC。 - EXDATE 存储为墙钟日期,序列化时用
dtstartTime+tzid还原为带 TZID 的EXDATE;TZID=...:20260921T180000。 _raw透传:除已建模字段外的所有属性(DTSTAMP、X-* 等)原样保留并回写,确保编辑后导出的 ICS 不丢字段。
4.3 StoreState
{
meta: CalendarMeta,
events: EventModel[],
selectedUid: string | null,
history: [ deepClone(state) ], // 快照栈,上限 50
redoStack: [ deepClone(state) ],
fileName: 'org.ics' | null,
dirty: boolean, // 有未保存变更
}
5. 模块设计
5.1 lib/weekday.js
export const DOW = ['周日','周一','周二','周三','周四','周五','周六'];
// JS getDay() 索引:0=周日 .. 6=周六
export const DOW_EN = ['SU','MO','TU','WE','TH','FR','SA'];
// 索引与 DOW 一致:BYDAY_MAP[getDay()] => 'SU'..'SA'
export const BYDAY_TO_INDEX = { SU:0, MO:1, TU:2, WE:3, TH:4, FR:5, SA:6 };
export function bydayFromDate(date) { return DOW_EN[date.getDay()]; }
export function dowLabel(date) { return DOW[date.getDay()]; }
关键决定:全项目统一使用 JS getDay()(0=周日),避免 Python weekday()(0=周一)的映射问题。BYDAY 数组直接按 getDay 索引,无需转换。这是与 timeTable/editor.html 里 pyWeekdayToJs hack 的根本区别——新项目从一开始就用对齐的索引。
5.2 lib/ical-io.js
export function parseICS(text) -> { meta: CalendarMeta, events: EventModel[] }
export function serializeICS({ meta, events }) -> string
parseICS 流程:
ICAL.parse(text)-> jCal 根数组new ICAL.Component(root)-> 遍历子组件- VTIMEZONE 组件:把 jCal 子数组原样 push 到
meta.vtimezones(不解析内部) - VEVENT 组件:
new ICAL.Event(component)提取字段summary/location/description:字符串dtstart:ICAL.Time->dtstartDate/Time(取year/month/day/hour/minute);tzid从component.getFirstProperty('dtstart').getParameter('tzid')取,无则'UTC'dtend:同上取时间rrule:event.component.getFirstPropertyValue('rrule')->ICAL.Recur-> 转{freq, interval, byday, untilDate}。until是ICAL.Time,按其zone转untilDate墙钟。exdates:遍历所有EXDATE属性,每个ICAL.Time->YYYY-MM-DD_raw:DTSTAMP 及其他未建模属性,存为{ propName: rawValue }
- meta:
prodid/version/calscale/method从component.getFirstPropertyValue(name)取;x-wr-calname/x-wr-timezone用全小写属性名取 - 返回
{meta, events}
serializeICS 流程:
new ICAL.Component(['vcalendar', [], []])- 添加 meta 标量属性(prodid/version/calscale/method/x-wr-calname/x-wr-timezone)
- 把
meta.vtimezones每个 jCal 数组addComponent回去 - 每个 EventModel -> 构造 VEVENT 子组件:
- SUMMARY/LOCATION/DESCRIPTION:
addPropertyWithValue - DTSTART:
new ICAL.Time({...dtstartDate/Time}, ICAL.TimezoneService.get(tzid)),set TZID 参数 - DTEND:同上
- RRULE(若 rrule 非空):
new ICAL.Recur({freq, interval, byday, until})。until用new ICAL.Time(untilDate 23:59, tz).convertToZone(ICAL.Timezone.utcTimezone) - EXDATE:每个
exdates[i]->new ICAL.Time({date + dtstartTime}, tz),addPropertyWithValue('exdate', time)并 set TZID - DTSTAMP:从
_raw取或用now UTC - 其他
_raw属性:原样回写
- SUMMARY/LOCATION/DESCRIPTION:
component.toString()-> ICS 文本
5.3 lib/recur.js
export function expandOccurrences(ev) -> [{ date:'YYYY-MM-DD', skipped:boolean }]
export function intervalDays(ev) -> number // DAILY->interval, WEEKLY->7*interval
expandOccurrences(移植 ical_editor.py 的网格生成):
- 单次事件(
rrule === null):返回[{date: ev.dtstartDate, skipped:false}] - 重复事件:
step = intervalDays(ev)cur = new Date(ev.dtstartDate),until = new Date(ev.untilDate)exdateSet = new Set(ev.exdates)- 循环
while cur <= until && count < 500:push {date: toISO(cur), skipped: exdateSet.has(date)},cur += step 天 - 返回数组
注:原 Python 版有
regenerateOccurrences(UNTIL 变化时保留 skip 状态)。新设计里 occurrences 完全按需从exdates推导,UNTIL 变化只需重新渲染网格,exdates 本身不变(超出新 UNTIL 的 exdate 仍在数组里但不被网格显示,序列化时无副作用)。因此无需regenerateOccurrences。
5.4 lib/organize.js(移植 ical_organizer.py)
export function seriesKey(ev) -> string
export function detectInterval(startDates) -> number | null
export function fitSeries(evs) -> { base, rrule, exdates } | null
export function organize(events) -> { events: EventModel[], stats: {series, flat} }
seriesKey(ev):[summary, location, dtstartTime, durationMin, weekday(getDay)] join。与 Python 版一致(除 weekday 用 getDay 而非 Python weekday,但分组效果相同——同一天的事件归一组)。
detectInterval(startDates):
<2个 ->null- 算连续日期间隔(天)的 GCD;GCD=0 ->
null - 返回 GCD
fitSeries(evs)(移植 ical_organizer.fit_series):
starts = evs.sort(by dtstartDate)interval = detectInterval(starts),null -> 拒绝first=starts[0], last=starts[-1]- 构建期望网格
first..last step interval:expected[] missing = expected 中不在 present 的;offGrid = present 中不在 expected 的- 有 offGrid -> 拒绝(事件不在规律网格上)
- "大多数规律":
evs.length < 2或missing.length >= evs.length-> 拒绝 - 构造 rrule:
interval % 7 == 0->freq='WEEKLY', interval=interval/7, byday=bydayFromDate(first)- 否则 ->
freq='DAILY', interval=interval, byday=null untilDate = last.dtstartDate
exdates = missing的日期- 返回
{base: evs[0], rrule, exdates}
organize(events)(移植 ical_organizer.organize):
- 按
seriesKey分组 - 每组排序后调
fitSeries:- 成功 -> 输出 1 个合并事件(保留 base 的原始 UID,而非 Python 的重新分配——避免重新导入时 UID 冲突)
- 失败 -> 原样输出每个事件
- 返回
{events, stats:{series:N, flat:M}}
与 Python 版的差异:
- UID 策略:保留原始 UID(Python 版重新分配
uid0..uidN)。原因:合并后的事件沿用其首次出现的 UID,日历客户端更稳定,且重新整理同一文件结果幂等。 - weekday 索引:用
getDay(0=周日),但仅用于分组键,不影响合并正确性。 - 其余算法 1:1 一致。
5.5 composables/useCalendar.js
单例 store(模块级 reactive + export function useCalendar() 返回同一实例)。
状态:见 4.3。
Actions(每个 mutation 前先 _pushHistory() 快照):
| Action | 行为 |
|---|---|
loadFile(file: File) |
读 text -> parseICS -> 设 meta/events/fileName,清 history/dirty,selectedUid=null |
organize() |
events = organize(events).events,设 dirty |
updateEvent(uid, patch) |
找到 event,浅合并 patch,设 dirty |
deleteEvent(uid) |
过滤掉,selectedUid 清空,设 dirty |
addEvent() |
新建单次事件(dtstart=今天,UID=crypto.randomUUID(),DTSTAMP=now UTC),push 并选中,设 dirty |
splitEvent(uid, splitDate) |
见下,设 dirty |
toggleOccurrence(uid, date) |
切换某 occurrence 的 skipped:若 date 在 exdates 中则移除,否则加入。设 dirty |
undo() |
redoStack.push(当前),state = history.pop() |
redo() |
history.push(当前),state = redoStack.pop() |
serialize() |
serializeICS({meta, events}) -> 字符串 |
markSaved() |
dirty=false |
occurrences 不是持久字段:EventModel(§4.2)不存 occurrences。OccurrenceGrid 渲染时实时调 expandOccurrences(ev) 计算,切换 skipped 走 toggleOccurrence 直接改 exdates。这样无需在 updateEvent 里维护缓存,避免缓存与 exdates 不同步。updateEvent 只做浅合并 patch,不碰 occurrences。
快照机制:_pushHistory() = history.push(structuredClone({meta, events, selectedUid})),超 50 截断尾部;清空 redoStack。structuredClone 在 Node/浏览器均可用,能深拷贝纯数据 plain object(store 里只有 plain object + 字符串,无函数/循环引用)。
splitEvent(uid, splitDate) 逻辑:
- 取原事件
ev,要求ev.rrule非空(单次事件不可拆) splitDate必须在dtstartDate < splitDate <= untilDate内- 若
splitDate不在网格上,snap到 ≥ splitDate 的下一个网格点 - Part A(前半段):
- 复制 ev
rrule.untilDate = (splitDate - intervalDays)的前一个网格点日期exdates = exdates.filter(d => d < splitDate)
- Part B(后半段):
- 复制 ev
dtstartDate = snapPoint(网格对齐后的 split 日期)rrule保持 freq/interval/byday,untilDate= 原 untilDateexdates = exdates.filter(d => d >= splitDate)uid = crypto.randomUUID()(新 UID)
- 替换原事件为
[A, B],选中 B - 用户随后可分别编辑两段(如后半段换教室)
6. UI 组件设计
视觉风格移植 timeTable/editor.html:深色顶栏 #2c3e50、白色侧栏、绿/红网格、圆角按钮。所有 CSS 用 SFC <style scoped>。
6.1 App.vue
布局:
┌─────────────────────────────────────────────┐
│ HeaderBar │
├──────────┬──────────────────────────────────┤
│ │ │
│ EventList│ EventDetail / DropZone / Empty │
│ │ │
└──────────┴──────────────────────────────────┘
- 未加载文件:主区显示
DropZone - 已加载、未选中:主区显示"从左侧选择"空状态
- 已选中:主区显示
EventDetail
6.2 HeaderBar.vue
- 左:标题「📅 ICS 日程整理器」
- 右按钮组:
- 「整理去重」(调用
organize(),按钮 disabled 当无事件或已全部为重复事件——简单判断:所有事件 rrule 非空时禁用,提示"已无可合并的重复") - 「↶ 撤销」「↷ 重做」(disabled 据 history/redoStack 长度)
- 「📥 导入文件」(触发隐藏
<input type=file>) - 「⬇ 下载 ICS」(dirty 时弹确认"有未保存更改,仍要下载?")
- dirty 红点标记
- 「整理去重」(调用
6.3 DropZone.vue
- 大虚线框,文字"拖入 .ics 文件 或 点击选择"
dragover高亮,drop读e.dataTransfer.files[0]- 点击触发隐藏
<input type=file accept=".ics"> - 解析失败:红字提示错误 + 保留 drop 区
6.4 EventList.vue
- 顶栏统计:「共 N 个日程(X 重复 / Y 单次)」
- 「➕ 新增日程」按钮(调用
addEvent()) - 列表项:标题 + 徽章(重复=绿/单次=橙)+
DOW[weekday] 时间–时间 · 地点 - 点击选中(
selectedUid),高亮左边框 - weekday 显示:
DOW[new Date(ev.dtstartDate).getDay()](直接用 getDay 索引 DOW,无转换)
6.5 EventDetail.vue
表单分三区:
基本信息:SUMMARY(text)、开始时间(time)、结束时间(time)、LOCATION(text)、DESCRIPTION(textarea)
重复规则(RRULE):
- 「启用重复」复选框
- 启用后显示:频率(WEEKLY/DAILY select)、间隔(number)、重复到 UNTIL(date)、星期 BYDAY(仅 WEEKLY,select DOW_EN)
- 改 UNTIL/BYDAY/INTERVAL 后,「应用更改」时 patch 进 store,网格因 exdates/rrule 响应式自动重算
工具栏:
- 「应用更改」(把表单值 patch 到 store)
- 「拆分此日程」(仅重复事件显示;点击展开日期选择器 + 确认)
- 「删除此日程」(确认对话框)
6.6 OccurrenceGrid.vue
- props:
occurrences: [{date, skipped}] - 网格
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)) - 每格:日期 + 小字星期(
DOW[new Date(date).getDay()]) - 绿色=active,红色删除线=skipped
- 点击切换 skipped(emit 事件让父组件调
store.toggleOccurrence(uid, date),该 action 直接增删exdates数组中的该日期) - 上限 500 格,超出截断 + 提示
7. 数据流
7.1 加载
用户拖入 file
-> DropZone.drop / input.change
-> file.text()
-> ical-io.parseICS(text)
-> store.loadFile({meta, events})
-> 渲染 EventList
7.2 整理去重
点击「整理去重」
-> store.organize()
-> lib/organize.organize(events)
-> 替换 events,push history
-> 渲染刷新
7.3 编辑
表单修改 -> 点击「应用更改」
-> store.updateEvent(uid, patch)
-> push history,dirty=true
-> EventDetail 刷新(网格实时调 expandOccurrences 重算)
7.4 拆分
点击「拆分」-> 选 splitDate -> 确认
-> store.splitEvent(uid, splitDate)
-> 原事件替换为 [A, B]
-> 选中 B
7.5 导出
点击「下载 ICS」
-> 若 dirty 弹确认
-> store.serialize()
-> Blob -> <a download> 触发下载
-> 文件名:原 fileName 去扩展 + '.edited.ics'(如 org.edited.ics)
8. 错误处理
| 场景 | 处理 |
|---|---|
| 拖入非 .ics / 解析失败 | DropZone 红字提示,保留落地区,不清空已有状态 |
| DTSTART 缺失的 VEVENT | parseICS 跳过该事件,控制台 warn,不阻断其余 |
| ical.js 不识别的属性 | 收进 _raw 原样回写,不丢 |
| 无时区信息的事件 | tzid 默认 'UTC' |
| organize 后无变化 | toast「未发现可合并的重复模式」 |
| split 日期不在范围内 | 表单校验红字,不执行 |
| undo 栈空 | 按钮已 disabled,理论不可触发 |
| 文件超大(>5MB) | 解析前提示「文件较大,解析可能较慢」,不阻断 |
错误展示统一用 toast(右下角,3 秒淡出),复用 editor.html 的 .toast 样式。
9. 测试策略
lib/ 是纯函数,重点单测:
- organize.js:用
timeTable/org.ics的期望结果作黄金用例(61 事件 -> 5 重复 + 3 单次,与 Python--verify一致)。但因 UID 策略不同,比对时忽略 UID,比对 (summary, dtstart, rrule, exdates)。 - recur.js:给定 rrule+exdates,展开网格与 Python 版
event_to_dict的occurrences字段逐项比对。 - ical-io.js round-trip:parse(serialize(parse(text))) == parse(text)(结构等价)。
测试框架:Vitest(devDependency)。若用户不想加测试依赖,至少写一个 npm run check 脚本跑黄金比对。
决定:加入 Vitest 作为 devDependency,确保移植正确性可自动验证。
10. Git 工作流
cd D:\zcode\timeTable2 && git init.gitignore:node_modules/、dist/、.vite/、*.log- 首个 commit:脚手架(package.json / vite.config / index.html / main.js / 空 App.vue)
- 后续按模块 commit:lib/weekday -> lib/ical-io -> lib/recur -> lib/organize -> useCalendar -> 各组件 -> README
- 每个 commit 信息:
feat: .../fix: .../docs: ... - spec 文档(本文件)单独 commit:
docs: add design spec
11. 开放决定(已自决)
| 问题 | 决定 | 理由 |
|---|---|---|
| UID 策略 | 合并后保留 base 的原始 UID | 避免重新导入冲突;幂等 |
| weekday 索引 | 全项目统一 getDay(0=周日) | 从根上消除 Python 版的映射 hack |
| undo 实现 | structuredClone 快照,上限 50 | 简单可靠,纯数据无循环引用 |
| 测试框架 | Vitest | 与 Vite 生态一致,纯函数易测 |
| 是否加 Pinia | 不加 | 单 store composable 足够,减依赖 |
| 时区处理 | 墙钟语义 + tzid 标签,序列化时由 ical.js 处理 | 避免手动时区计算错误 |
| 拆分粒度 | 按日期拆成两段 | 最常见需求(后半学期换教室) |
| CSS 方案 | SFC scoped,移植 editor.html 风格 | 视觉一致,无额外依赖 |
12. 验收标准
npm install用 npmmirror 成功,无错误npm run dev启动,浏览器打开无报错- 拖入
timeTable/org.ics,左侧显示 61 个事件 - 点击「整理去重」,变为 8 个事件(5 重复 + 3 单次),与 Python
ical_organizer.py输出结构等价(忽略 UID) - 选中一个重复事件,网格正确显示所有 occurrence,绿/红切换正常
- 编辑字段 -> 应用 -> 网格/列表刷新
- 拆分一个重复事件,得到两段,各自网格正确
- 新增一个事件,可编辑
- 撤销/重做正常
- 下载 ICS,用
timeTable/.venv的 Python 验证:parse + expand 后 occurrence 列表与编辑前等价 npm run build产出dist/,可直接用静态服务器或file://打开dist/index.html运行(注:ical.js 需确保无 CDN 依赖,全打包)
13. 实现顺序(供 writing-plans 细化)
- 脚手架:git init + Vite 创建 + .npmrc + .gitignore + 装依赖 + 首次 commit
lib/weekday.js(最简,先跑通)lib/ical-io.js+ 用 org.ics 手动验证 parse/serialize round-triplib/recur.js+ 单测lib/organize.js+ 黄金用例单测(对齐 Python 输出)composables/useCalendar.js(含 undo)DropZone.vue+App.vue骨架,跑通"拖入 -> 显示列表"EventList.vue+HeaderBar.vue(含整理按钮),跑通"整理去重"EventDetail.vue+OccurrenceGrid.vue,跑通编辑- splitEvent + addEvent + deleteEvent
- 下载导出 + dirty 提示
- README.md
npm run build验证 + 收尾 commit