Files
timeTableFix/docs/superpowers/specs/2026-07-13-timetable2-design.md
2026-07-13 17:31:43 +08:00

557 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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