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

24 KiB
Raw Blame History

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 的关系

timeTablePython 版)保持不动。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

{
  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 响应式友好)

{
  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/TimeuntilDateexdates 全部是用户看到的本地墙钟值,不带时区计算。时区信息保存在 tzid 中,序列化时由 ical.js 处理。
  • UNTIL 存储为墙钟日期,序列化到 ICS 时转 UTCRFC 5545 要求 UNTIL 用 UTC20261019T070000Z)。转换:用 tziduntilDate 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.htmlpyWeekdayToJs hack 的根本区别——新项目从一开始就用对齐的索引。

5.2 lib/ical-io.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:字符串
    • dtstartICAL.Time -> dtstartDate/Time(取 year/month/day/hour/minutetzidcomponent.getFirstProperty('dtstart').getParameter('tzid') 取,无则 'UTC'
    • dtend:同上取时间
    • rruleevent.component.getFirstPropertyValue('rrule') -> ICAL.Recur -> 转 {freq, interval, byday, untilDate}untilICAL.Time,按其 zoneuntilDate 墙钟。
    • exdates:遍历所有 EXDATE 属性,每个 ICAL.Time -> YYYY-MM-DD
    • _rawDTSTAMP 及其他未建模属性,存为 { propName: rawValue }
  5. metaprodid/version/calscale/methodcomponent.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/DESCRIPTIONaddPropertyWithValue
    • DTSTARTnew ICAL.Time({...dtstartDate/Time}, ICAL.TimezoneService.get(tzid))set TZID 参数
    • DTEND同上
    • RRULE若 rrule 非空):new ICAL.Recur({freq, interval, byday, until})untilnew 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

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 < 500push {date: toISO(cur), skipped: exdateSet.has(date)}cur += step 天
    • 返回数组

注:原 Python 版有 regenerateOccurrencesUNTIL 变化时保留 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
  • 算连续日期间隔(天)的 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 intervalexpected[]
  5. missing = expected 中不在 present 的offGrid = present 中不在 expected 的
  6. 有 offGrid -> 拒绝(事件不在规律网格上)
  7. "大多数规律"evs.length < 2missing.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 索引:用 getDay0=周日),但仅用于分组键,不影响合并正确性。
  • 其余算法 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/bydayuntilDate = 原 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 高亮,drope.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_dictoccurrences 字段逐项比对。
  • ical-io.js round-tripparse(serialize(parse(text))) == parse(text)(结构等价)。

测试框架VitestdevDependency。若用户不想加测试依赖至少写一个 npm run check 脚本跑黄金比对。

决定:加入 Vitest 作为 devDependency确保移植正确性可自动验证。


10. Git 工作流

  1. cd D:\zcode\timeTable2 && git init
  2. .gitignorenode_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 文档(本文件)单独 commitdocs: 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. 下载 ICStimeTable/.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