557 lines
24 KiB
Markdown
557 lines
24 KiB
Markdown
# timeTable2 - 纯前端 ICS 日历整理与编辑器
|
||
|
||
**日期**: 2026-07-13
|
||
**状态**: 设计已定稿,待用户复核
|
||
**位置**: `D:\zcode\timeTable2`(与 `D:\zcode\timeTable` 平级,独立 git 仓库)
|
||
|
||
---
|
||
|
||
## 1. 目标与范围
|
||
|
||
### 1.1 目标
|
||
|
||
构建一个**纯前端**(HTML + JS + CSS,无 Python、无后端服务)的 ICS 日历应用,实现:
|
||
|
||
1. **解析**:浏览器内读取任意 `.ics` 文件(支持 VTIMEZONE、RRULE、EXDATE)
|
||
2. **去重整理**:将"每周相同却被导出为 N 个独立事件"的扁平列表,自动合并为带 `RRULE` + `EXDATE` 的重复事件
|
||
3. **编辑**:可视化编辑每个事件(标题/地点/描述/时间/重复规则/排除日期)
|
||
4. **增删拆**:手动新增事件、删除事件、把一个已合并的重复事件按日期拆成两段
|
||
5. **导出**:编辑完成后下载整理好的 `.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.5
|
||
- `ical.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
|
||
|
||
```js
|
||
{
|
||
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 响应式友好)
|
||
|
||
```js
|
||
{
|
||
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
|
||
|
||
```js
|
||
{
|
||
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`
|
||
|
||
```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`
|
||
|
||
```js
|
||
export function parseICS(text) -> { meta: CalendarMeta, events: EventModel[] }
|
||
export function serializeICS({ meta, events }) -> string
|
||
```
|
||
|
||
**parseICS 流程**:
|
||
1. `ICAL.parse(text)` -> jCal 根数组
|
||
2. `new ICAL.Component(root)` -> 遍历子组件
|
||
3. VTIMEZONE 组件:把 jCal 子数组原样 push 到 `meta.vtimezones`(不解析内部)
|
||
4. 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 }`
|
||
5. meta:`prodid/version/calscale/method` 从 `component.getFirstPropertyValue(name)` 取;`x-wr-calname/x-wr-timezone` 用全小写属性名取
|
||
6. 返回 `{meta, events}`
|
||
|
||
**serializeICS 流程**:
|
||
1. `new ICAL.Component(['vcalendar', [], []])`
|
||
2. 添加 meta 标量属性(prodid/version/calscale/method/x-wr-calname/x-wr-timezone)
|
||
3. 把 `meta.vtimezones` 每个 jCal 数组 `addComponent` 回去
|
||
4. 每个 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` 属性:原样回写
|
||
5. `component.toString()` -> ICS 文本
|
||
|
||
### 5.3 `lib/recur.js`
|
||
|
||
```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`)
|
||
|
||
```js
|
||
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`):
|
||
1. `starts = evs.sort(by dtstartDate)`
|
||
2. `interval = detectInterval(starts)`,null -> 拒绝
|
||
3. `first=starts[0], last=starts[-1]`
|
||
4. 构建期望网格 `first..last step interval`:`expected[]`
|
||
5. `missing = expected 中不在 present 的`;`offGrid = present 中不在 expected 的`
|
||
6. 有 offGrid -> 拒绝(事件不在规律网格上)
|
||
7. "大多数规律":`evs.length < 2` 或 `missing.length >= evs.length` -> 拒绝
|
||
8. 构造 rrule:
|
||
- `interval % 7 == 0` -> `freq='WEEKLY', interval=interval/7, byday=bydayFromDate(first)`
|
||
- 否则 -> `freq='DAILY', interval=interval, byday=null`
|
||
- `untilDate = last.dtstartDate`
|
||
9. `exdates = missing` 的日期
|
||
10. 返回 `{base: evs[0], rrule, exdates}`
|
||
|
||
**organize(events)**(移植 `ical_organizer.organize`):
|
||
1. 按 `seriesKey` 分组
|
||
2. 每组排序后调 `fitSeries`:
|
||
- 成功 -> 输出 1 个合并事件(**保留 base 的原始 UID**,而非 Python 的重新分配——避免重新导入时 UID 冲突)
|
||
- 失败 -> 原样输出每个事件
|
||
3. 返回 `{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)** 逻辑:
|
||
1. 取原事件 `ev`,要求 `ev.rrule` 非空(单次事件不可拆)
|
||
2. `splitDate` 必须在 `dtstartDate < splitDate <= untilDate` 内
|
||
3. 若 `splitDate` 不在网格上,`snap` 到 ≥ splitDate 的下一个网格点
|
||
4. **Part A**(前半段):
|
||
- 复制 ev
|
||
- `rrule.untilDate = (splitDate - intervalDays)` 的前一个网格点日期
|
||
- `exdates = exdates.filter(d => d < splitDate)`
|
||
5. **Part B**(后半段):
|
||
- 复制 ev
|
||
- `dtstartDate = snapPoint`(网格对齐后的 split 日期)
|
||
- `rrule` 保持 freq/interval/byday,`untilDate` = 原 untilDate
|
||
- `exdates = exdates.filter(d => d >= splitDate)`
|
||
- `uid = crypto.randomUUID()`(新 UID)
|
||
6. 替换原事件为 `[A, B]`,选中 B
|
||
7. 用户随后可分别编辑两段(如后半段换教室)
|
||
|
||
---
|
||
|
||
## 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 工作流
|
||
|
||
1. `cd D:\zcode\timeTable2 && git init`
|
||
2. `.gitignore`:`node_modules/`、`dist/`、`.vite/`、`*.log`
|
||
3. 首个 commit:脚手架(package.json / vite.config / index.html / main.js / 空 App.vue)
|
||
4. 后续按模块 commit:lib/weekday -> lib/ical-io -> lib/recur -> lib/organize -> useCalendar -> 各组件 -> README
|
||
5. 每个 commit 信息:`feat: ...` / `fix: ...` / `docs: ...`
|
||
6. 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. 验收标准
|
||
|
||
1. `npm install` 用 npmmirror 成功,无错误
|
||
2. `npm run dev` 启动,浏览器打开无报错
|
||
3. 拖入 `timeTable/org.ics`,左侧显示 61 个事件
|
||
4. 点击「整理去重」,变为 8 个事件(5 重复 + 3 单次),与 Python `ical_organizer.py` 输出结构等价(忽略 UID)
|
||
5. 选中一个重复事件,网格正确显示所有 occurrence,绿/红切换正常
|
||
6. 编辑字段 -> 应用 -> 网格/列表刷新
|
||
7. 拆分一个重复事件,得到两段,各自网格正确
|
||
8. 新增一个事件,可编辑
|
||
9. 撤销/重做正常
|
||
10. 下载 ICS,用 `timeTable/.venv` 的 Python 验证:parse + expand 后 occurrence 列表与编辑前等价
|
||
11. `npm run build` 产出 `dist/`,可直接用静态服务器或 `file://` 打开 `dist/index.html` 运行(注:ical.js 需确保无 CDN 依赖,全打包)
|
||
|
||
---
|
||
|
||
## 13. 实现顺序(供 writing-plans 细化)
|
||
|
||
1. 脚手架:git init + Vite 创建 + .npmrc + .gitignore + 装依赖 + 首次 commit
|
||
2. `lib/weekday.js`(最简,先跑通)
|
||
3. `lib/ical-io.js` + 用 org.ics 手动验证 parse/serialize round-trip
|
||
4. `lib/recur.js` + 单测
|
||
5. `lib/organize.js` + 黄金用例单测(对齐 Python 输出)
|
||
6. `composables/useCalendar.js`(含 undo)
|
||
7. `DropZone.vue` + `App.vue` 骨架,跑通"拖入 -> 显示列表"
|
||
8. `EventList.vue` + `HeaderBar.vue`(含整理按钮),跑通"整理去重"
|
||
9. `EventDetail.vue` + `OccurrenceGrid.vue`,跑通编辑
|
||
10. splitEvent + addEvent + deleteEvent
|
||
11. 下载导出 + dirty 提示
|
||
12. README.md
|
||
13. `npm run build` 验证 + 收尾 commit
|