docs: add timeTable2 design spec

This commit is contained in:
timeTable2 dev
2026-07-13 17:31:43 +08:00
commit 64dd8c5cd1
2 changed files with 561 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
# 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 + Vue3SFC`<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 modelical.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 EventModelplainVue 响应式友好)
```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 时转 UTCRFC 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`
- 算连续日期间隔(天)的 GCDGCD=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 策略:保留原始 UIDPython 版重新分配 `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/dirtyselectedUid=null |
| `organize()` | `events = organize(events).events`,设 dirty |
| `updateEvent(uid, patch)` | 找到 event浅合并 patch设 dirty |
| `deleteEvent(uid)` | 过滤掉selectedUid 清空,设 dirty |
| `addEvent()` | 新建单次事件dtstart=今天UID=`crypto.randomUUID()`DTSTAMP=now UTCpush 并选中,设 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 objectstore 里只有 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`
表单分三区:
**基本信息**SUMMARYtext、开始时间time、结束时间time、LOCATIONtext、DESCRIPTIONtextarea
**重复规则RRULE**
- 「启用重复」复选框
- 启用后显示频率WEEKLY/DAILY select、间隔number、重复到 UNTILdate、星期 BYDAY仅 WEEKLYselect 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
- 点击切换 skippedemit 事件让父组件调 `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)
-> 替换 eventspush history
-> 渲染刷新
```
### 7.3 编辑
```
表单修改 -> 点击「应用更改」
-> store.updateEvent(uid, patch)
-> push historydirty=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)(结构等价)。
测试框架VitestdevDependency。若用户不想加测试依赖至少写一个 `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. 后续按模块 commitlib/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 索引 | 全项目统一 getDay0=周日) | 从根上消除 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