Compare commits

..

8 Commits

Author SHA1 Message Date
d32f8be018 refactor: 清理死代码 + 提前失败 + 错误记录控制台
- 移除 EventDetail.vue 未用的 computed 导入
- 移除 HeaderBar.vue 未用的 .btn-secondary CSS
- store (updateEvent/splitEvent/toggleOccurrence): uid 缺失改抛错 (fail-fast), 不再静默 return
- EventDetail onSplit: 捕获 splitEvent 抛错并提示用户
- DropZone/HeaderBar 解析失败: catch 内补 console.error
- ical-io getZone: 时区未注册回退 UTC 时 console.warn
- WeekPreview 周标签改用 i18n (weekPreview.rangeFmt), 与 MonthPreview 一致
- docs: 新增 error-handling.md; 修正 auto-save-design state 快照补 timezone
- README: 补充部署说明 (经 zMainPage apache2 托管)
2026-07-28 11:08:06 +08:00
cf941c50bc feat: 增加时区选择(自动检测,回退墨尔本)
顶栏新增时区选择器:加载时自动检测用户所在时区(Intl.DateTimeFormat),
检测不到则默认澳大利亚墨尔本。选定时区作为新建事件的默认 tzid 与下载
ICS 的 X-WR-TIMEZONE,不做时间换算(应用内部为 wall-clock 字符串)。

- 新增 src/lib/timezone.js(detect/list/validate/默认值)
- useCalendar 持 state.timezone、setTimezone、addEvent 传 tzid、serialize 注入 X-WR-TIMEZONE
- HeaderBar 顶栏下拉选择器 + i18n 文案
- 新增 test/timezone.test.js(15 项),全量 57 项通过
- README 简述功能,细节移至 docs/timezone.md
2026-07-27 09:49:13 +08:00
root
1b67e15071 feat: 导入文件按钮改为始终高亮(btn-primary)
原 btn-secondary 与页面背景同色、文字灰,视觉上偏暗;导入操作无前置依赖
(随时可点),应始终可用且醒目,故改为 btn-primary 与组织按钮一致。
2026-07-24 09:25:28 +00:00
root
0448ec8b72 docs: README 补初始设计文档链接 2026-07-24 09:16:23 +00:00
root
78d7afe699 docs: 精简 README(结构/依赖/使用),细节移至 docs/integration.md 2026-07-24 09:15:29 +00:00
root
960657a251 feat: 增加 i18n 能力(独立 vue-i18n 实例 + useLocale + 中英 locale)
- 新增 src/i18n/(独立实例,中文同步、英文懒加载)+ src/composables/useLocale.js
- 抽取所有硬编码中文为 locale 文案(zh-CN/en)
- 组件改用 useLocale 直读自身 i18n 实例(嵌入时不走 useI18n 注入)
- App.vue 接 :locale prop,watch 同步父项目语言(嵌入 mainPage 时桥接)
- 42 项测试全通过,构建正常
2026-07-24 08:59:52 +00:00
2d7b8d0d07 docs: 移除 systemd 部署与 /ttf/ 独立入口说明
本项目仅作为 mainPage submodule 集成,无独立线上入口。
保留独立开发/测试说明(npm run dev/test/build)。
2026-07-23 04:46:23 +00:00
c68d1062ff style: 前端视觉与 mainPage 协调,浅色极简 + 靛蓝主色
- global.css 加设计令牌(与 mainPage variables.css 对齐:bg/surface/accent/
  border/text/success/danger/warn/radius/shadow/transition)
- App.vue 顶栏由深色(#2c3e50)改浅色(surface + border),视图切换器改分段样式
- 全部组件硬编码颜色令牌化:accent(主操作)/success(下载)/danger(删除)/
  warn(单次徽章)/surface/border/text-*
- 表单输入加 focus 描边、按钮统一 border+radius、阴影用令牌
- 月/周预览表头改浅灰、今日高亮改 accent-weak,网格边线用 border 令牌
- 事件配色 PALETTE 不变(数据可视化需高区分度)
- 独立运行 42 测试通过、build 通过
2026-07-23 04:24:04 +00:00
25 changed files with 1049 additions and 354 deletions

164
README.md
View File

@@ -1,16 +1,6 @@
# timeTableFix - 纯前端 ICS 日历整理器 # timeTableFix - 纯前端 ICS 日历整理器
浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。 浏览器内解析、去重、编辑 ICS 日历文件,全程无需后端服务。拖入 `.ics` -> 整理去重 -> 编辑 -> 下载。独立可维护,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块集成展示。
## 功能
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
- 智能去重:检测事件间隔(每周 / 每两周 / 每 N 天),将扁平事件合并为 RRULE 重复事件 + EXDATE交错的多班次自动拆分为多个系列
- 自动保存:所有编辑即时生效,下载时兜底序列化最新状态,无需手动保存
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
- 拆分事件:把一个重复事件按日期拆成前后两段
- 增删事件:新增单次/重复事件,删除任意事件
- 导出 ICS下载整理后的 `.ics`,可直接导入日历应用
## 项目结构 ## 项目结构
@@ -18,17 +8,21 @@
timeTableFix/ timeTableFix/
├── index.html Vite 入口 ├── index.html Vite 入口
├── package.json ├── package.json
├── vite.config.js 构建配置(含 preview.allowedHosts ├── vite.config.js
├── vitest.config.js ├── vitest.config.js
├── .npmrc npmmirror 镜像加速 ├── .npmrc npmmirror 镜像加速
├── README.md
├── src/ ├── src/
│ ├── main.js 应用挂载 │ ├── main.js 应用挂载i18n + 全局样式)
│ ├── App.vue 布局装配 │ ├── App.vue 便携式根组件(嵌入与独立两用)
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale
│ ├── composables/ │ ├── composables/
│ │ ── useCalendar.js 中心 store自动保存无 undo │ │ ── useCalendar.js 中心 store自动保存无 undo
│ │ └── useLocale.js 语言辅助(直读自身 i18n 实例)
│ ├── lib/ │ ├── lib/
│ │ ├── weekday.js 星期常量与工具 │ │ ├── weekday.js 星期常量与工具
│ │ ├── date.js 日期工具 │ │ ├── date.js 日期工具
│ │ ├── timezone.js 时区检测 / 选择(回退墨尔本)
│ │ ├── colors.js 事件配色 │ │ ├── colors.js 事件配色
│ │ ├── uuid.js UUID 生成(含非安全上下文回退) │ │ ├── uuid.js UUID 生成(含非安全上下文回退)
│ │ ├── ical-io.js ICS 文本 <-> plain model │ │ ├── ical-io.js ICS 文本 <-> plain model
@@ -45,132 +39,32 @@ timeTableFix/
│ ├── MonthPreview.vue 月预览 │ ├── MonthPreview.vue 月预览
│ └── WeekPreview.vue 周预览 │ └── WeekPreview.vue 周预览
├── test/ Vitest 单元测试 ├── test/ Vitest 单元测试
│ ├── golden-org.ics 测试用例61 事件的真实课表)
│ ├── weekday.test.js
│ ├── ical-io.test.js
│ ├── recur.test.js
│ └── organize.test.js
└── docs/ 设计文档 └── docs/ 设计文档
├── organize-algorithm.md 间隔合并与链拆分算法详解
└── auto-save-design.md 自动保存设计
``` ```
## Ubuntu 从零安装 ## 外部依赖
### 1. 安装 Node.js 18+ | 项 | 说明 |
|----|------|
```bash | Node.js | ≥ 18 |
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - | ical.js | ICS 解析库npm 依赖) |
sudo apt-get install -y nodejs
node --version # 确认 v18.x > 纯前端应用,无运行期后端依赖。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管(无独立 apache2 配置);详见 zMainPage 的部署文档。
```
## 如何使用
### 2. 克隆并安装依赖
```bash ```bash
# 克隆并安装
git clone https://git.zikai.wang/zikai/timeTableFix.git git clone https://git.zikai.wang/zikai/timeTableFix.git
cd timeTableFix cd timeTableFix
npm install # 已配 npmmirror 镜像加速 npm install # 已配 npmmirror 镜像加速
npm run dev # 开发服务器 http://localhost:5173
npm run build # 构建到 dist/
npm test # 单元测试57 项)
``` ```
### 3. 构建生产版本 顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。
```bash
npm run build # 产物输出到 dist/
npm test # (可选)运行单元测试
```
### 4. 部署为系统服务systemd
创建服务文件 `/etc/systemd/system/timetable-fix.service`
```ini
[Unit]
Description=timeTableFix ICS Calendar Organizer
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/timeTableFix
ExecStart=/path/to/timeTableFix/node_modules/.bin/vite preview --host 0.0.0.0 --port 8888
Restart=on-failure
RestartSec=3
Environment=NODE_ENV=production
StandardOutput=append:/var/log/timetable-preview.log
StandardError=append:/var/log/timetable-preview.log
[Install]
WantedBy=multi-user.target
```
> 将上述 `WorkingDirectory` 和 `ExecStart` 中的路径替换为实际的 `timeTableFix` 绝对路径。
加载并启动:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now timetable-fix
```
### 5. 放行防火墙端口
```bash
sudo ufw allow 8888/tcp
```
浏览器访问 `http://<服务器IP>:8888` 即可。若通过域名访问(如 `f.zikai.wang`),需在 `vite.config.js``preview.allowedHosts` 中添加该域名。
### 6. 嵌入主页(可选)
本项目可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的一个页签集成展示。采用**构建期组件集成 + git submodule**
- mainPage 通过 git submodule 把本项目作为子目录(`third_party/timeTableFix`)引入,并直接 `import` 本项目的 `src/App.vue` 作为懒加载路由组件。
- 集成时共享 mainPage 的构建、依赖(`ical.js`)与 KeepAlive 缓存,不再是 iframe无运行期割裂。
- 本项目 `App.vue` 已做样式隔离scoped + `ttf-` 前缀类名),全局重置仅在独立 app`main.js`)加载,作为组件嵌入时不污染宿主。
**升级流程**mainPage 侧更新本项目到新版本):
```bash
cd mainPage
git submodule update --remote third_party/timeTableFix # 拉取最新
cd third_party/timeTableFix && git checkout master # 固定到目标分支
cd ../..
git add third_party/timeTableFix # 更新 gitlink
git commit -m "chore: 升级 timeTableFix 子模块"
npm run build # 重新构建 mainPage
```
本项目本身完全独立:脱离 mainPage 仍可独立 `npm run build` / `npm test` / systemd 部署。移除 mainPage 中的页签仅需删其 `src/modules/timetable/``git submodule deinit`,不影响本项目。
> 旧的 iframe 嵌入方式Apache `/ttf/` 反代到 8888仍可保留用于经 `https://zikai.wang/ttf/` 单独访问独立 app与组件集成互不冲突。
## 使用(启动 / 关闭)
服务由 systemd 管理,开机自启、崩溃自动恢复。
```bash
# 启动
sudo systemctl start timetable-fix
# 关闭
sudo systemctl stop timetable-fix
# 重启
sudo systemctl restart timetable-fix
# 查看状态
sudo systemctl status timetable-fix
# 查看日志
tail -f /var/log/timetable-preview.log
```
开发模式(无需 systemd前台运行
```bash
cd timeTableFix
npm run dev # http://localhost:5173
```
## 技术栈 ## 技术栈
@@ -179,6 +73,16 @@ npm run dev # http://localhost:5173
| 框架 | Vue 3.5SFC`<script setup>` | | 框架 | Vue 3.5SFC`<script setup>` |
| 构建 | Vite 6 | | 构建 | Vite 6 |
| ICS 库 | ical.js 2.x | | ICS 库 | ical.js 2.x |
| 国际化 | vue-i18n 9中文同步英文懒加载 |
| 状态管理 | composable 单例 store | | 状态管理 | composable 单例 store |
| 测试 | Vitest | | 测试 | Vitest |
| 包源 | npmmirror.com中国镜像加速 | | 包源 | npmmirror.com中国镜像加速 |
## 了解更多
- [嵌入 mainPagei18n prop 桥接 / 样式隔离)](./docs/integration.md)
- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md)
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
- [自动保存设计](./docs/auto-save-design.md)
- [错误处理与日志约定](./docs/error-handling.md)
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)

View File

@@ -61,6 +61,7 @@ const state = reactive({
fileName: null, fileName: null,
viewMode: 'edit', // 'edit' | 'month' | 'week' viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(), previewDate: todayISO(),
timezone: detectTimezone(), // 日历级默认时区 (新建事件 tzid / X-WR-TIMEZONE)
}) })
``` ```

25
docs/error-handling.md Normal file
View File

@@ -0,0 +1,25 @@
# 错误处理与日志约定
timeTableFix 的错误处理分两层:纯库函数 fail-fastUI 层捕获后既写控制台又给用户反馈。
## 纯库层src/lib/
- 解析/算法错误直接抛出或返回 `null`,由调用方决定如何反馈。
- `parseICS``ICAL.parse` 对非法文本抛错,向上传播。
- `splitRecurringEvent` / `fitSeries`:不可处理时返回 `null`(合法的「拒绝」语义)。
- `getZone`时区未注册VTIMEZONE 缺失)时回退 UTC 并 `console.warn`,避免静默时间偏移。
## store 层src/composables/useCalendar.js
- `loadFile`:不吞错,`parseICS` 的异常直接 reject 给调用方。
- `updateEvent` / `splitEvent` / `toggleOccurrence`uid 不存在视为编程错误,**抛错**而非静默 return。调用方EventDetail在调前已校验 `selectedEvent`,正常流程不会触发;触发即暴露 bug。
## UI 层src/components/
- 文件解析失败DropZone / HeaderBar`.catch``console.error` 记录原始错误,再用 `alert` 告知用户。
- 拆分失败EventDetail `onSplit``splitEvent` 抛错被 `try/catch` 捕获,`console.error` + 提示日期无效。
- 时区检测/列表降级timezone.js旧环境 `Intl` 不可用时静默回退墨尔本(有测试覆盖),属可接受的降级。
## 控制台前缀
所有 `[timeTableFix]` 前缀的日志均来自本子项目,便于在嵌入宿主时与宿主日志区分。

30
docs/integration.md Normal file
View File

@@ -0,0 +1,30 @@
# 嵌入 mainPage
timeTableFix 可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块git submodule经构建期组件集成展示位于 mainPage 的 `third_party/timeTableFix`
## 集成方式
- mainPage 通过 git submodule 把本项目作为子目录引入,并直接 `import` 本项目的 `src/App.vue` 作为懒加载路由组件。
- 构建期集成(非 iframe共享 mainPage 的构建与 KeepAlive 缓存,无运行期割裂。
- 本项目无独立线上入口,仅通过主页 `https://zikai.wang/timetable` 访问。
## 便携式 App.vue
`src/App.vue` 是嵌入与独立两用的根组件:
- **嵌入时**mainPage 侧包装器 `import` 本项目 `src/App.vue`,把父项目当前语言经 `:locale` prop 传入。`App.vue``watch` 该 prop同步到本项目独立的 `vue-i18n` 实例 `locale`,从而跟随父项目语言开关响应式切换。文案来自本项目自有 locale 文件,不共享父项目的 messages。
- **独立运行时**:不接收 prop默认中文。
## i18n 桥接关键点
组件内用 `useLocale` composable **直接读自身模块级 i18n 实例**`i18n.global`),而非 `useI18n({ useScope: 'global' })`
原因:`useI18n({ useScope: 'global' })` 解析的是「当前运行 app 已安装的全局 i18n」。嵌入 mainPage 时那是父项目的实例(不含本项目文案),会导致文案回退成 i18n key显示 ID 而非文字)。改为直接引用本项目实例的 `.global`,无论是否 `app.use()` 都拿到本项目的 `t`
## 样式隔离
`App.vue` 已做样式隔离scoped + `ttf-` 前缀类名),设计令牌与 mainPage 对齐;全局重置仅在独立 app`main.js`)加载,作为组件嵌入时不污染宿主。
## 集成 / 升级 / 移除
详见 mainPage 仓库的 [`docs/submodule-timeTableFix.md`](https://git.zikai.wang/zikai/zMainPage/src/branch/main/docs/submodule-timeTableFix.md)(克隆含子模块、升级 gitlink、移除页签等流程

49
docs/timezone.md Normal file
View File

@@ -0,0 +1,49 @@
# 时区选择
timeTableFix 顶栏提供时区选择器。加载时自动检测用户所在时区,检测不到则默认 **澳大利亚墨尔本(`Australia/Melbourne`**
## 检测逻辑
`src/lib/timezone.js``detectTimezone()`
1. 读取 `new Intl.DateTimeFormat().resolvedOptions().timeZone`(浏览器/Node 标准 API
2. 若抛错、返回空串或格式不合法,回退 `DEFAULT_TIMEZONE``Australia/Melbourne`)。
非安全上下文(无 `https`)或极旧环境可能拿不到时区,此时一律回退墨尔本。
## 下拉列表来源
`listTimezones()` = 精选常用 IANA 时区(`COMMON_TIMEZONES`,含墨尔本/上海/UTC 等)∪ 运行时 `Intl.supportedValuesOf('timeZone')`(若可用),去重后排序,`UTC` 置顶。旧浏览器不支持 `supportedValuesOf` 时静默降级为仅精选列表,保证下拉始终可用、无外部依赖。
## 作用范围(设计要点)
应用内部所有日期/时间均为 **wall-clock 字符串**`YYYY-MM-DD` + `HH:MM`,见 `date.js`**不做任何时区换算**。时区仅是一个挂在事件上的 IANA 标识(`tzid`),在 ICS 序列化/反序列化时作为 `DTSTART`/`DTEND``TZID` 参数读写。
因此选定时区的作用是**日历级默认值/标签**,而非时间换算层:
| 行为 | 是否受选定时区影响 |
|---|---|
| 新建事件(「新增日程」)的 `tzid` | ✅ 使用选定时区 |
| 下载 ICS 的 `X-WR-TIMEZONE` | ✅ 写入选定时区 |
| 已导入事件的 `tzid` | ❌ 保留原始 TZID不覆盖、不换算 |
| 日期/时间数值(编辑、预览、整理) | ❌ 始终 wall-clock不换算 |
| 间隔去重(`organize.js` | ❌ 不读 `tzid`,不受影响 |
### 为何不换算
- 应用面向「整理/编辑日程文本」而非「跨时区会议调度」wall-clock 直观且无歧义。
- `organize.js``[summary, location, 开始时间, 时长]` 合并重复,引入换算会改变合并键、破坏既有 round-trip 测试。
- 已导入事件保留原 TZID 保证 `parse(serialize(parse(x)))` 等价(见 `ical-io.test.js` round-trip 用例)。
## 实现位置
| 文件 | 改动 |
|---|---|
| `src/lib/timezone.js` | 新增:检测/列表/校验/默认值 |
| `src/composables/useCalendar.js` | `state.timezone`(初始 `detectTimezone()`)、`setTimezone()``addEvent` 传入 tzid、`serialize` 注入 `X-WR-TIMEZONE` |
| `src/components/HeaderBar.vue` | 顶栏 `<select>` 选择器 |
| `src/i18n/locales/{zh-CN,en}.js` | `header.timezone` 文案 |
## 嵌入 mainPage
时区状态属于 timeTableFix 自有单例 store嵌入 mainPage 时随子模块独立运作,不与父项目共享。语言切换仍经 `:locale` prop 桥接(与时区无关)。

68
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"vue": "^3.5.0" "vue": "^3.5.0",
"vue-i18n": "^9.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
@@ -505,6 +506,47 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@intlify/core-base": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
"dependencies": {
"@intlify/message-compiler": "9.14.4",
"@intlify/shared": "9.14.4"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
"dependencies": {
"@intlify/shared": "9.14.4",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1018,6 +1060,11 @@
"@vue/shared": "3.5.39" "@vue/shared": "3.5.39"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.39", "version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
@@ -2708,6 +2755,25 @@
} }
} }
}, },
"node_modules/vue-i18n": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
"dependencies": {
"@intlify/core-base": "9.14.4",
"@intlify/shared": "9.14.4",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -12,7 +12,8 @@
}, },
"dependencies": { "dependencies": {
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"vue": "^3.5.0" "vue": "^3.5.0",
"vue-i18n": "^9.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",

View File

@@ -1,5 +1,8 @@
<script setup> <script setup>
import { watch } from 'vue'
import { useCalendar } from './composables/useCalendar.js' import { useCalendar } from './composables/useCalendar.js'
import { useLocale } from './composables/useLocale.js'
import { loadLocaleAsync } from './i18n/index.js'
import DropZone from './components/DropZone.vue' import DropZone from './components/DropZone.vue'
import HeaderBar from './components/HeaderBar.vue' import HeaderBar from './components/HeaderBar.vue'
import EventList from './components/EventList.vue' import EventList from './components/EventList.vue'
@@ -7,27 +10,41 @@ import EventDetail from './components/EventDetail.vue'
import MonthPreview from './components/MonthPreview.vue' import MonthPreview from './components/MonthPreview.vue'
import WeekPreview from './components/WeekPreview.vue' import WeekPreview from './components/WeekPreview.vue'
const props = defineProps({
locale: { type: String, default: 'zh-CN' },
})
const { isLoaded, state, setViewMode } = useCalendar() const { isLoaded, state, setViewMode } = useCalendar()
const { t } = useLocale()
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
watch(
() => props.locale,
(val) => {
if (val) loadLocaleAsync(val)
},
{ immediate: true }
)
</script> </script>
<template> <template>
<div class="ttf-app"> <div class="ttf-app">
<header class="ttf-header"> <header class="ttf-header">
<div class="ttf-header-left"> <div class="ttf-header-left">
<h1>ICS 日程整理器</h1> <h1>{{ t('app.title') }}</h1>
<div v-if="isLoaded" class="ttf-view-switcher"> <div v-if="isLoaded" class="ttf-view-switcher">
<button <button
:class="{ active: state.viewMode === 'edit' }" :class="{ active: state.viewMode === 'edit' }"
@click="setViewMode('edit')" @click="setViewMode('edit')"
>编辑</button> >{{ t('view.edit') }}</button>
<button <button
:class="{ active: state.viewMode === 'month' }" :class="{ active: state.viewMode === 'month' }"
@click="setViewMode('month')" @click="setViewMode('month')"
>月预览</button> >{{ t('view.month') }}</button>
<button <button
:class="{ active: state.viewMode === 'week' }" :class="{ active: state.viewMode === 'week' }"
@click="setViewMode('week')" @click="setViewMode('week')"
>周预览</button> >{{ t('view.week') }}</button>
</div> </div>
</div> </div>
<HeaderBar /> <HeaderBar />
@@ -48,18 +65,21 @@ const { isLoaded, state, setViewMode } = useCalendar()
<style scoped> <style scoped>
/* scoped样式仅作用于本组件作为组件被嵌入时不污染宿主。 /* scoped样式仅作用于本组件作为组件被嵌入时不污染宿主。
全局重置* / body已移至 src/styles/global.css,仅独立 app 加载。 */ 全局重置与设计令牌在 src/styles/global.css独立 app/ 宿主 :root嵌入时。 */
.ttf-app { .ttf-app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 适配嵌入:填满宿主 <main> 区域;独立 app 下 body 仍提供背景 */ /* 适配嵌入:填满宿主容器;独立 app 下 body 仍提供背景 */
min-height: 0; min-height: 0;
height: 100%; height: 100%;
background: var(--bg);
color: var(--text-primary);
} }
.ttf-header { .ttf-header {
background: #2c3e50; background: var(--surface);
color: #fff; color: var(--text-primary);
padding: 14px 24px; border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -68,30 +88,39 @@ const { isLoaded, state, setViewMode } = useCalendar()
.ttf-header-left { .ttf-header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 20px;
}
.ttf-header h1 {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
color: var(--text-primary);
} }
.ttf-header h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
.ttf-view-switcher { .ttf-view-switcher {
display: flex; display: flex;
background: #1a252f; gap: 2px;
border-radius: 6px; background: var(--surface-2);
overflow: hidden; border-radius: var(--radius-sm);
padding: 2px;
} }
.ttf-view-switcher button { .ttf-view-switcher button {
background: transparent; background: transparent;
color: #bdc3c7; color: var(--text-secondary);
border: none; border: none;
padding: 6px 16px; padding: 5px 14px;
font-size: 13px; font-size: 13px;
border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all var(--transition);
} }
.ttf-view-switcher button:hover { color: #fff; } .ttf-view-switcher button:hover { color: var(--text-primary); }
.ttf-view-switcher button.active { .ttf-view-switcher button.active {
background: #3498db; background: var(--bg);
color: #fff; color: var(--accent);
font-weight: 600;
box-shadow: var(--shadow);
} }
.ttf-main { flex: 1; display: flex; overflow: hidden; min-height: 0; } .ttf-main { flex: 1; display: flex; overflow: hidden; min-height: 0; background: var(--bg); }
.ttf-detail-area { flex: 1; overflow-y: auto; } .ttf-detail-area { flex: 1; overflow-y: auto; }
/* 移动端适配 */ /* 移动端适配 */
@@ -106,8 +135,8 @@ const { isLoaded, state, setViewMode } = useCalendar()
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.ttf-header h1 { font-size: 15px; } .ttf-header h1 { font-size: 14px; }
.ttf-view-switcher button { padding: 5px 10px; font-size: 12px; } .ttf-view-switcher button { padding: 4px 10px; font-size: 12px; }
.ttf-main { flex-direction: column; } .ttf-main { flex-direction: column; }
} }
</style> </style>

View File

@@ -1,8 +1,10 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
const { loadFile } = useCalendar() const { loadFile } = useCalendar()
const { t } = useLocale()
const dragOver = ref(false) const dragOver = ref(false)
const error = ref('') const error = ref('')
@@ -12,11 +14,12 @@ function onDrop(e) {
const file = e.dataTransfer.files[0] const file = e.dataTransfer.files[0]
if (!file) return if (!file) return
if (!file.name.toLowerCase().endsWith('.ics')) { if (!file.name.toLowerCase().endsWith('.ics')) {
error.value = '请选择 .ics 文件' error.value = t('dropzone.requireIcs')
return return
} }
loadFile(file).catch((err) => { loadFile(file).catch((err) => {
error.value = '解析失败' + (err.message || err) console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
}) })
} }
@@ -25,7 +28,8 @@ function onPick(e) {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) return if (!file) return
loadFile(file).catch((err) => { loadFile(file).catch((err) => {
error.value = '解析失败' + (err.message || err) console.error('[timeTableFix] 解析 ICS 失败', err)
error.value = t('dropzone.parseError') + (err.message || err)
}) })
} }
</script> </script>
@@ -39,9 +43,9 @@ function onPick(e) {
@drop.prevent="onDrop" @drop.prevent="onDrop"
> >
<div class="dropzone-content"> <div class="dropzone-content">
<p class="hint">拖入 .ics 文件</p> <p class="hint">{{ t('dropzone.hint') }}</p>
<label class="pick-btn"> <label class="pick-btn">
点击选择文件 {{ t('dropzone.pick') }}
<input type="file" accept=".ics" @change="onPick" hidden /> <input type="file" accept=".ics" @change="onPick" hidden />
</label> </label>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -55,39 +59,39 @@ function onPick(e) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px dashed #bdc3c7; border: 2px dashed var(--border-strong);
border-radius: 12px; border-radius: var(--radius);
margin: 40px; margin: 40px;
transition: all 0.2s; transition: all var(--transition);
} }
.dropzone.active { .dropzone.active {
border-color: #3498db; border-color: var(--accent);
background: #eaf4fc; background: var(--accent-weak);
} }
.dropzone-content { .dropzone-content {
text-align: center; text-align: center;
} }
.hint { .hint {
color: #7f8c8d; color: var(--text-secondary);
font-size: 14px; font-size: 14px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.pick-btn { .pick-btn {
display: inline-block; display: inline-block;
padding: 10px 24px; padding: 10px 24px;
background: #3498db; background: var(--accent);
color: #fff; color: #fff;
border-radius: 8px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: background 0.15s; transition: background var(--transition);
} }
.pick-btn:hover { .pick-btn:hover {
background: #2980b9; background: var(--accent-hover);
} }
.error { .error {
color: #e74c3c; color: var(--danger);
font-size: 13px; font-size: 13px;
margin-top: 16px; margin-top: 16px;
} }

View File

@@ -1,11 +1,13 @@
<script setup> <script setup>
import { ref, watch, computed } from 'vue' import { ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js' import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js'
import { toDate } from '../lib/date.js' import { toDate } from '../lib/date.js'
import OccurrenceGrid from './OccurrenceGrid.vue' import OccurrenceGrid from './OccurrenceGrid.vue'
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar() const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
const { t } = useLocale()
// 本地表单状态, 选择切换时同步 // 本地表单状态, 选择切换时同步
const form = ref({}) const form = ref({})
@@ -70,7 +72,7 @@ watch(form, () => {
function onDelete() { function onDelete() {
const ev = selectedEvent.value const ev = selectedEvent.value
if (!ev) return if (!ev) return
if (confirm(`确定删除"${ev.summary}"`)) { if (confirm(t('eventDetail.confirmDelete', { name: ev.summary }))) {
deleteEvent(ev.uid) deleteEvent(ev.uid)
} }
} }
@@ -79,14 +81,21 @@ function onSplit() {
const ev = selectedEvent.value const ev = selectedEvent.value
if (!ev || !ev.rrule) return if (!ev || !ev.rrule) return
if (!splitDate.value) { if (!splitDate.value) {
alert('请选择拆分日期') alert(t('eventDetail.requireSplitDate'))
return return
} }
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) { if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
alert('拆分日期必须在开始日期和结束日期之间') alert(t('eventDetail.splitDateInvalid'))
return return
} }
// splitEvent 在日期无法对齐到网格时抛错 (fail-fast); 捕获后提示用户。
try {
splitEvent(ev.uid, splitDate.value) splitEvent(ev.uid, splitDate.value)
} catch (err) {
console.error('[timeTableFix] 拆分失败', err)
alert(t('eventDetail.splitDateInvalid'))
return
}
showSplit.value = false showSplit.value = false
splitDate.value = '' splitDate.value = ''
} }
@@ -99,61 +108,61 @@ function onToggleOcc(date) {
</script> </script>
<template> <template>
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div> <div v-if="!selectedEvent" class="empty">{{ t('eventDetail.empty') }}</div>
<div v-else class="detail"> <div v-else class="detail">
<!-- 基本信息 --> <!-- 基本信息 -->
<div class="section-title">基本信息</div> <div class="section-title">{{ t('eventDetail.basicTitle') }}</div>
<div class="form-group"> <div class="form-group">
<label>标题 (SUMMARY)</label> <label>{{ t('eventDetail.summary') }}</label>
<input type="text" v-model="form.summary" /> <input type="text" v-model="form.summary" />
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>开始时间</label> <label>{{ t('eventDetail.startTime') }}</label>
<input type="time" v-model="form.dtstart_time" /> <input type="time" v-model="form.dtstart_time" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>结束时间</label> <label>{{ t('eventDetail.endTime') }}</label>
<input type="time" v-model="form.dtend_time" /> <input type="time" v-model="form.dtend_time" />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>地点 (LOCATION)</label> <label>{{ t('eventDetail.location') }}</label>
<input type="text" v-model="form.location" /> <input type="text" v-model="form.location" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>描述 (DESCRIPTION)</label> <label>{{ t('eventDetail.description') }}</label>
<textarea v-model="form.description"></textarea> <textarea v-model="form.description"></textarea>
</div> </div>
<!-- 重复规则 (RRULE) --> <!-- 重复规则 (RRULE) -->
<div class="section-title">重复规则 (RRULE)</div> <div class="section-title">{{ t('eventDetail.rruleTitle') }}</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" v-model="form.is_recurring" /> 启用重复 <input type="checkbox" v-model="form.is_recurring" /> {{ t('eventDetail.enableRecur') }}
</label> </label>
</div> </div>
<div v-if="form.is_recurring"> <div v-if="form.is_recurring">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>频率</label> <label>{{ t('eventDetail.freq') }}</label>
<select v-model="form.freq"> <select v-model="form.freq">
<option value="WEEKLY">每周 (WEEKLY)</option> <option value="WEEKLY">{{ t('eventDetail.freqWeekly') }}</option>
<option value="DAILY">每天 (DAILY)</option> <option value="DAILY">{{ t('eventDetail.freqDaily') }}</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>间隔 (INTERVAL)</label> <label>{{ t('eventDetail.interval') }}</label>
<input type="number" v-model.number="form.interval" min="1" style="width: 80px" /> <input type="number" v-model.number="form.interval" min="1" style="width: 80px" />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>重复到 (UNTIL)</label> <label>{{ t('eventDetail.until') }}</label>
<input type="date" v-model="form.until_date" /> <input type="date" v-model="form.until_date" />
</div> </div>
<div class="form-group" v-if="form.freq === 'WEEKLY'"> <div class="form-group" v-if="form.freq === 'WEEKLY'">
<label>星期 (BYDAY, 由开始日期自动确定)</label> <label>{{ t('eventDetail.byday') }}</label>
<select v-model="form.byday" disabled> <select v-model="form.byday" disabled>
<option v-for="(d, i) in DOW_EN" :key="d" :value="d"> <option v-for="(d, i) in DOW_EN" :key="d" :value="d">
{{ DOW[i] }} {{ DOW[i] }}
@@ -164,7 +173,7 @@ function onToggleOcc(date) {
</div> </div>
<!-- occurrence / 排除日期网格 --> <!-- occurrence / 排除日期网格 -->
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div> <div v-if="form.is_recurring" class="section-title">{{ t('eventDetail.occTitle') }}</div>
<OccurrenceGrid <OccurrenceGrid
v-if="form.is_recurring && selectedEvent.rrule" v-if="form.is_recurring && selectedEvent.rrule"
:event="selectedEvent" :event="selectedEvent"
@@ -178,17 +187,17 @@ function onToggleOcc(date) {
class="btn btn-secondary" class="btn btn-secondary"
@click="showSplit = !showSplit" @click="showSplit = !showSplit"
> >
拆分此日程 {{ t('eventDetail.split') }}
</button> </button>
<button class="btn btn-danger" @click="onDelete">删除此日程</button> <button class="btn btn-danger" @click="onDelete">{{ t('eventDetail.delete') }}</button>
</div> </div>
<!-- 拆分面板 --> <!-- 拆分面板 -->
<div v-if="showSplit && selectedEvent.rrule" class="split-panel"> <div v-if="showSplit && selectedEvent.rrule" class="split-panel">
<p>从指定日期起把此重复事件拆成前后两段</p> <p>{{ t('eventDetail.splitDesc') }}</p>
<input type="date" v-model="splitDate" /> <input type="date" v-model="splitDate" />
<button class="btn btn-primary" @click="onSplit">确认拆分</button> <button class="btn btn-primary" @click="onSplit">{{ t('eventDetail.splitConfirm') }}</button>
<button class="btn btn-secondary" @click="showSplit = false">取消</button> <button class="btn btn-secondary" @click="showSplit = false">{{ t('eventDetail.cancel') }}</button>
</div> </div>
</div> </div>
</template> </template>
@@ -198,22 +207,22 @@ function onToggleOcc(date) {
.empty { .empty {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: #95a5a6; color: var(--text-tertiary);
} }
.section-title { .section-title {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #2c3e50; color: var(--text-primary);
margin: 20px 0 12px; margin: 20px 0 12px;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 2px solid #ecf0f1; border-bottom: 2px solid var(--border);
} }
.form-group { margin-bottom: 16px; } .form-group { margin-bottom: 16px; }
.form-group label { .form-group label {
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #34495e; color: var(--text-secondary);
margin-bottom: 5px; margin-bottom: 5px;
} }
.form-group input, .form-group input,
@@ -221,10 +230,19 @@ function onToggleOcc(date) {
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d5dbdb; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
font-family: inherit; font-family: inherit;
background: var(--bg);
color: var(--text-primary);
transition: border-color var(--transition);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
} }
.form-group textarea { resize: vertical; min-height: 60px; } .form-group textarea { resize: vertical; min-height: 60px; }
.form-row { display: flex; gap: 16px; } .form-row { display: flex; gap: 16px; }
@@ -234,35 +252,38 @@ function onToggleOcc(date) {
gap: 8px; gap: 8px;
margin-top: 20px; margin-top: 20px;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid #ecf0f1; border-top: 1px solid var(--border);
} }
.btn { .btn {
padding: 7px 16px; padding: 7px 16px;
border: none; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
transition: background 0.15s; transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
} }
.btn-primary { background: #3498db; color: #fff; } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #2980b9; } .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-secondary { background: #ecf0f1; color: #2c3e50; } .btn-secondary { background: var(--bg); color: var(--text-secondary); }
.btn-secondary:hover { background: #d5dbdb; } .btn-secondary:hover { background: var(--surface); color: var(--text-primary); }
.btn-danger { background: #e74c3c; color: #fff; } .btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-danger:hover { background: #c0392b; } .btn-danger:hover { filter: brightness(0.92); }
.split-panel { .split-panel {
margin-top: 16px; margin-top: 16px;
padding: 16px; padding: 16px;
background: #f8f9fa; background: var(--surface);
border-radius: 8px; border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.split-panel p { width: 100%; font-size: 13px; color: #7f8c8d; margin-bottom: 8px; } .split-panel p { width: 100%; font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; }
.split-panel input { padding: 6px 10px; border: 1px solid #d5dbdb; border-radius: 6px; } .split-panel input { padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
@media (max-width: 768px) { @media (max-width: 768px) {
.detail { padding: 16px; } .detail { padding: 16px; }

View File

@@ -1,9 +1,11 @@
<script setup> <script setup>
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { DOW } from '../lib/weekday.js' import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js' import { toDate } from '../lib/date.js'
const { state, stats, addEvent, selectEvent } = useCalendar() const { state, stats, addEvent, selectEvent } = useCalendar()
const { t } = useLocale()
function dowFor(ev) { function dowFor(ev) {
return DOW[toDate(ev.dtstartDate).getDay()] return DOW[toDate(ev.dtstartDate).getDay()]
@@ -13,9 +15,9 @@ function dowFor(ev) {
<template> <template>
<div class="event-list"> <div class="event-list">
<div class="stat"> <div class="stat">
{{ stats.total }} 个日程{{ stats.recurring }} 重复 / {{ stats.single }} 单次 {{ t('eventList.stat', { total: stats.total, recurring: stats.recurring, single: stats.single }) }}
</div> </div>
<button class="add-btn" @click="addEvent">新增日程</button> <button class="add-btn" @click="addEvent">{{ t('eventList.add') }}</button>
<div class="list"> <div class="list">
<div <div
v-for="ev in state.events" v-for="ev in state.events"
@@ -25,9 +27,9 @@ function dowFor(ev) {
@click="selectEvent(ev.uid)" @click="selectEvent(ev.uid)"
> >
<div class="title"> <div class="title">
{{ ev.summary || '(无标题)' }} {{ ev.summary || t('eventList.untitled') }}
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'"> <span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
{{ ev.rrule ? '重复' : '单次' }} {{ ev.rrule ? t('eventList.recurring') : t('eventList.single') }}
</span> </span>
</div> </div>
<div class="meta"> <div class="meta">
@@ -42,38 +44,39 @@ function dowFor(ev) {
<style scoped> <style scoped>
.event-list { .event-list {
width: 340px; width: 340px;
background: #fff; background: var(--bg);
border-right: 1px solid #e0e3e6; border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.stat { font-size: 12px; color: #95a5a6; padding: 12px 16px 8px; } .stat { font-size: 12px; color: var(--text-tertiary); padding: 12px 16px 8px; }
.add-btn { .add-btn {
margin: 0 16px 8px; margin: 0 16px 8px;
padding: 8px; padding: 8px;
border: 1px dashed #3498db; border: 1px dashed var(--accent);
background: #eaf4fc; background: var(--accent-weak);
color: #2980b9; color: var(--accent);
border-radius: 6px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
transition: background var(--transition);
} }
.add-btn:hover { background: #d4eaf9; } .add-btn:hover { background: var(--surface-2); }
.list { flex: 1; overflow-y: auto; } .list { flex: 1; overflow-y: auto; }
.event-item { .event-item {
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid #eef0f2; border-bottom: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background var(--transition);
} }
.event-item:hover { background: #f8f9fa; } .event-item:hover { background: var(--surface); }
.event-item.active { .event-item.active {
background: #eaf4fc; background: var(--accent-weak);
border-left: 3px solid #3498db; border-left: 3px solid var(--accent);
} }
.title { font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 3px; } .title { font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 3px; }
.meta { font-size: 12px; color: #7f8c8d; } .meta { font-size: 12px; color: var(--text-secondary); }
.badge { .badge {
display: inline-block; display: inline-block;
padding: 1px 7px; padding: 1px 7px;
@@ -82,15 +85,15 @@ function dowFor(ev) {
font-weight: 600; font-weight: 600;
margin-left: 6px; margin-left: 6px;
} }
.badge-recur { background: #e8f5e9; color: #2e7d32; } .badge-recur { background: var(--success-weak); color: var(--success); }
.badge-single { background: #fff3e0; color: #e65100; } .badge-single { background: var(--warn-weak); color: var(--warn); }
@media (max-width: 768px) { @media (max-width: 768px) {
.event-list { .event-list {
width: 100%; width: 100%;
max-height: 40vh; max-height: 40vh;
border-right: none; border-right: none;
border-bottom: 1px solid #e0e3e6; border-bottom: 1px solid var(--border);
} }
} }
</style> </style>

View File

@@ -1,14 +1,18 @@
<script setup> <script setup>
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { ref } from 'vue' import { ref } from 'vue'
import { listTimezones } from '../lib/timezone.js'
const { state, organize, serialize, loadFile } = useCalendar() const { state, organize, serialize, loadFile, setTimezone } = useCalendar()
const { t } = useLocale()
const fileInput = ref(null) const fileInput = ref(null)
const timezones = listTimezones()
function onOrganize() { function onOrganize() {
const stats = organize() const stats = organize()
if (stats && stats.series === 0) { if (stats && stats.series === 0) {
alert('未发现可合并的重复模式') alert(t('header.noPattern'))
} }
} }
@@ -19,7 +23,10 @@ function onImport() {
function onFilePicked(e) { function onFilePicked(e) {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) return if (!file) return
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err))) loadFile(file).catch((err) => {
console.error('[timeTableFix] 解析 ICS 失败', err)
alert(t('header.parseError') + (err.message || err))
})
e.target.value = '' e.target.value = ''
} }
@@ -40,38 +47,63 @@ function onDownload() {
<template> <template>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0"> <button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
整理去重 {{ t('header.organize') }}
</button> </button>
<button class="btn btn-secondary" @click="onImport">导入文件</button> <button class="btn btn-primary" @click="onImport">{{ t('header.import') }}</button>
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0"> <button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
下载 ICS {{ t('header.download') }}
</button> </button>
<label class="tz-picker">
<span class="tz-label">{{ t('header.timezone') }}</span>
<select :value="state.timezone" @change="setTimezone($event.target.value)" :title="t('header.timezone')">
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</label>
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden /> <input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.header-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: center; } .header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
.btn { .btn {
padding: 7px 16px; padding: 7px 16px;
border: none; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
transition: background 0.15s; transition: all var(--transition);
white-space: nowrap; white-space: nowrap;
background: var(--bg);
color: var(--text-primary);
} }
.btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #3498db; color: #fff; } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { background: #2980b9; } .btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-success { background: #27ae60; color: #fff; } .btn-success { background: var(--success); border-color: var(--success); color: #fff; }
.btn-success:hover:not(:disabled) { background: #229954; } .btn-success:hover:not(:disabled) { filter: brightness(0.92); }
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; } /* 时区选择器:与按钮同行,紧凑 */
.tz-picker { display: flex; align-items: center; gap: 4px; }
.tz-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.tz-picker select {
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 12px;
font-family: inherit;
background: var(--bg);
color: var(--text-primary);
cursor: pointer;
max-width: 170px;
transition: border-color var(--transition);
}
.tz-picker select:focus { outline: none; border-color: var(--accent); }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-actions { gap: 6px; } .header-actions { gap: 6px; }
.btn { padding: 5px 10px; font-size: 12px; } .btn { padding: 5px 10px; font-size: 12px; }
.tz-label { display: none; }
.tz-picker select { padding: 5px 8px; font-size: 12px; max-width: 130px; }
} }
</style> </style>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { expandOccurrences } from '../lib/recur.js' import { expandOccurrences } from '../lib/recur.js'
import { eventColor } from '../lib/colors.js' import { eventColor } from '../lib/colors.js'
import { import {
@@ -9,6 +10,7 @@ import {
import { DOW } from '../lib/weekday.js' import { DOW } from '../lib/weekday.js'
const { state, prevMonth, nextMonth, goToToday } = useCalendar() const { state, prevMonth, nextMonth, goToToday } = useCalendar()
const { t } = useLocale()
// 表头: 周一~周日 // 表头: 周一~周日
const headers = DOW.slice(1).concat(DOW[0]) const headers = DOW.slice(1).concat(DOW[0])
@@ -19,7 +21,7 @@ const hoverEvent = ref(null)
// 当前预览月的年月标签 // 当前预览月的年月标签
const monthLabel = computed(function () { const monthLabel = computed(function () {
const parts = parseISODate(startOfMonth(state.previewDate)) const parts = parseISODate(startOfMonth(state.previewDate))
return parts.year + ' 年 ' + parts.month + ' 月' return t('monthPreview.monthFmt', { y: parts.year, m: parts.month })
}) })
// 构建月历网格 (6 行 x 7 列, 从当月首日所在周的周一开始) // 构建月历网格 (6 行 x 7 列, 从当月首日所在周的周一开始)
@@ -77,10 +79,10 @@ function hideTooltip() {
<template> <template>
<div class="month-preview"> <div class="month-preview">
<div class="preview-toolbar"> <div class="preview-toolbar">
<button class="nav-btn" @click="prevMonth">上个月</button> <button class="nav-btn" @click="prevMonth">{{ t('monthPreview.prev') }}</button>
<span class="month-label">{{ monthLabel }}</span> <span class="month-label">{{ monthLabel }}</span>
<button class="nav-btn" @click="nextMonth">下个月</button> <button class="nav-btn" @click="nextMonth">{{ t('monthPreview.next') }}</button>
<button class="nav-btn today-btn" @click="goToToday">回到今日</button> <button class="nav-btn today-btn" @click="goToToday">{{ t('monthPreview.today') }}</button>
</div> </div>
<div class="month-grid"> <div class="month-grid">
<div v-for="h in headers" :key="h" class="grid-header">{{ h }}</div> <div v-for="h in headers" :key="h" class="grid-header">{{ h }}</div>
@@ -103,16 +105,16 @@ function hideTooltip() {
@mouseenter="showTooltip(occ.event)" @mouseenter="showTooltip(occ.event)"
@mouseleave="hideTooltip" @mouseleave="hideTooltip"
> >
{{ occ.event.summary || '(无标题)' }} {{ occ.event.summary || t('monthPreview.untitled') }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 自定义 tooltip --> <!-- 自定义 tooltip -->
<div v-if="hoverEvent" class="event-tooltip" @mouseenter="showTooltip(hoverEvent)" @mouseleave="hideTooltip"> <div v-if="hoverEvent" class="event-tooltip" @mouseenter="showTooltip(hoverEvent)" @mouseleave="hideTooltip">
<div class="tooltip-title">{{ hoverEvent.summary || '(无标题)' }}</div> <div class="tooltip-title">{{ hoverEvent.summary || t('monthPreview.untitled') }}</div>
<div class="tooltip-row">时间: {{ hoverEvent.dtstartTime }} - {{ hoverEvent.dtendTime }}</div> <div class="tooltip-row">{{ t('monthPreview.tooltipTime', { start: hoverEvent.dtstartTime, end: hoverEvent.dtendTime }) }}</div>
<div v-if="hoverEvent.location" class="tooltip-row">地点: {{ hoverEvent.location }}</div> <div v-if="hoverEvent.location" class="tooltip-row">{{ t('monthPreview.tooltipLocation', { location: hoverEvent.location }) }}</div>
<div v-if="hoverEvent.description" class="tooltip-desc">{{ hoverEvent.description }}</div> <div v-if="hoverEvent.description" class="tooltip-desc">{{ hoverEvent.description }}</div>
</div> </div>
</div> </div>
@@ -135,20 +137,21 @@ function hideTooltip() {
} }
.nav-btn { .nav-btn {
padding: 5px 14px; padding: 5px 14px;
border: 1px solid #d5dbdb; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
background: #fff; background: var(--bg);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #2c3e50; color: var(--text-primary);
transition: background var(--transition);
} }
.nav-btn:hover { background: #ecf0f1; } .nav-btn:hover { background: var(--surface); }
.today-btn { border-color: #3498db; color: #3498db; font-weight: 600; } .today-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.today-btn:hover { background: #eaf4fc; } .today-btn:hover { background: var(--accent-weak); }
.month-label { .month-label {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
min-width: 140px; min-width: 140px;
text-align: center; text-align: center;
} }
@@ -158,33 +161,33 @@ function hideTooltip() {
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: 1px; gap: 1px;
background: #e0e3e6; background: var(--border);
border: 1px solid #e0e3e6; border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
} }
.grid-header { .grid-header {
background: #2c3e50; background: var(--surface-2);
color: #fff; color: var(--text-secondary);
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
padding: 8px 0; padding: 8px 0;
} }
.grid-cell { .grid-cell {
background: #fff; background: var(--bg);
padding: 4px 6px; padding: 4px 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
.grid-cell.out-of-month { background: #f8f9fa; } .grid-cell.out-of-month { background: var(--surface); }
.grid-cell.out-of-month .day-num { color: #bdc3c7; } .grid-cell.out-of-month .day-num { color: var(--text-tertiary); }
.grid-cell.is-today { background: #eaf4fc; } .grid-cell.is-today { background: var(--accent-weak); }
.grid-cell.is-today .day-num { .grid-cell.is-today .day-num {
color: #fff; color: #fff;
background: #3498db; background: var(--accent);
border-radius: 50%; border-radius: 50%;
width: 22px; width: 22px;
height: 22px; height: 22px;
@@ -196,7 +199,7 @@ function hideTooltip() {
.day-num { .day-num {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
} }
.event-list { .event-list {
@@ -220,10 +223,10 @@ function hideTooltip() {
bottom: 16px; bottom: 16px;
right: 16px; right: 16px;
max-width: 320px; max-width: 320px;
background: #fff; background: var(--bg);
border: 1px solid #d5dbdb; border: 1px solid var(--border-strong);
border-radius: 8px; border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-hover);
padding: 12px 16px; padding: 12px 16px;
z-index: 100; z-index: 100;
font-size: 13px; font-size: 13px;
@@ -231,15 +234,15 @@ function hideTooltip() {
.tooltip-title { .tooltip-title {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #2c3e50; color: var(--text-primary);
margin-bottom: 6px; margin-bottom: 6px;
} }
.tooltip-row { .tooltip-row {
color: #34495e; color: var(--text-secondary);
margin-bottom: 3px; margin-bottom: 3px;
} }
.tooltip-desc { .tooltip-desc {
color: #7f8c8d; color: var(--text-tertiary);
margin-top: 6px; margin-top: 6px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;

View File

@@ -3,11 +3,13 @@ import { computed } from 'vue'
import { DOW } from '../lib/weekday.js' import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js' import { toDate } from '../lib/date.js'
import { expandOccurrences } from '../lib/recur.js' import { expandOccurrences } from '../lib/recur.js'
import { useLocale } from '../composables/useLocale.js'
const props = defineProps({ const props = defineProps({
event: { type: Object, required: true }, event: { type: Object, required: true },
}) })
const emit = defineEmits(['toggle']) const emit = defineEmits(['toggle'])
const { t } = useLocale()
const occurrences = computed(() => expandOccurrences(props.event)) const occurrences = computed(() => expandOccurrences(props.event))
@@ -19,7 +21,7 @@ function dowFor(dateStr) {
<template> <template>
<div> <div>
<p class="hint"> <p class="hint">
点击日期切换"排除/包含"状态排除的日期红色删除线会写入 EXDATE {{ t('eventDetail.occHint') }}
</p> </p>
<div class="occ-grid"> <div class="occ-grid">
<div <div
@@ -39,7 +41,7 @@ function dowFor(dateStr) {
<style scoped> <style scoped>
.hint { .hint {
font-size: 12px; font-size: 12px;
color: #7f8c8d; color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.occ-grid { .occ-grid {
@@ -52,24 +54,26 @@ function dowFor(dateStr) {
} }
.occ { .occ {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid #d5dbdb; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
transition: all 0.12s; transition: all var(--transition);
background: var(--bg);
color: var(--text-primary);
} }
.occ:hover { border-color: #3498db; } .occ:hover { border-color: var(--accent); }
.occ.skipped { .occ.skipped {
background: #fce4ec; background: var(--danger-weak);
border-color: #e74c3c; border-color: var(--danger);
color: #c0392b; color: var(--danger);
text-decoration: line-through; text-decoration: line-through;
} }
.occ.active { .occ.active {
background: #e8f5e9; background: var(--success-weak);
border-color: #27ae60; border-color: var(--success);
color: #1e7d32; color: var(--success);
} }
.dow { font-size: 10px; color: #95a5a6; } .dow { font-size: 10px; color: var(--text-tertiary); }
</style> </style>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js' import { useCalendar } from '../composables/useCalendar.js'
import { useLocale } from '../composables/useLocale.js'
import { expandOccurrences } from '../lib/recur.js' import { expandOccurrences } from '../lib/recur.js'
import { eventColor } from '../lib/colors.js' import { eventColor } from '../lib/colors.js'
import { import {
@@ -9,6 +10,7 @@ import {
import { DOW } from '../lib/weekday.js' import { DOW } from '../lib/weekday.js'
const { state, prevWeek, nextWeek, goToToday } = useCalendar() const { state, prevWeek, nextWeek, goToToday } = useCalendar()
const { t } = useLocale()
const HOUR_HEIGHT = 48 // 每小时像素高度 const HOUR_HEIGHT = 48 // 每小时像素高度
const headers = DOW.slice(1).concat(DOW[0]) // 周一..周日 const headers = DOW.slice(1).concat(DOW[0]) // 周一..周日
@@ -32,7 +34,8 @@ const weekLabel = computed(function () {
const days = weekDays.value const days = weekDays.value
const f = parseISODate(days[0]) const f = parseISODate(days[0])
const l = parseISODate(days[6]) const l = parseISODate(days[6])
return f.year + '/' + f.month + '/' + f.day + ' - ' + l.year + '/' + l.month + '/' + l.day const fmt = (p) => `${p.year}/${p.month}/${p.day}`
return t('weekPreview.rangeFmt', { s: fmt(f), e: fmt(l) })
}) })
// 收集本周所有 occurrence 并做并排布局, 按日期分组 // 收集本周所有 occurrence 并做并排布局, 按日期分组
@@ -159,10 +162,10 @@ function eventStyle(occ) {
} }
function tooltip(ev) { function tooltip(ev) {
const parts = [ev.summary || '(无标题)'] const parts = [ev.summary || t('weekPreview.untitled')]
parts.push(ev.dtstartTime + ' - ' + ev.dtendTime) parts.push(ev.dtstartTime + ' - ' + ev.dtendTime)
if (ev.location) parts.push('地点: ' + ev.location) if (ev.location) parts.push(t('weekPreview.tooltipLocation', { location: ev.location }))
if (ev.description) parts.push('描述: ' + ev.description) if (ev.description) parts.push(t('weekPreview.tooltipDesc', { description: ev.description }))
return parts.join('\n') return parts.join('\n')
} }
</script> </script>
@@ -170,14 +173,14 @@ function tooltip(ev) {
<template> <template>
<div class="week-preview"> <div class="week-preview">
<div class="preview-toolbar"> <div class="preview-toolbar">
<button class="nav-btn" @click="prevWeek">上一周</button> <button class="nav-btn" @click="prevWeek">{{ t('weekPreview.prev') }}</button>
<span class="week-label">{{ weekLabel }}</span> <span class="week-label">{{ weekLabel }}</span>
<button class="nav-btn" @click="nextWeek">下一周</button> <button class="nav-btn" @click="nextWeek">{{ t('weekPreview.next') }}</button>
<button class="nav-btn today-btn" @click="goToToday">回到今日</button> <button class="nav-btn today-btn" @click="goToToday">{{ t('weekPreview.today') }}</button>
<button class="nav-btn collapse-btn" @click="toggleCollapse"> <button class="nav-btn collapse-btn" @click="toggleCollapse">
{{ offHoursCollapsed ? '展开夜间' : '折叠夜间' }} {{ offHoursCollapsed ? t('weekPreview.expandNight') : t('weekPreview.collapseNight') }}
</button> </button>
<span v-if="!hasEvents" class="empty-hint">本周无日程</span> <span v-if="!hasEvents" class="empty-hint">{{ t('weekPreview.noEvents') }}</span>
</div> </div>
<div class="week-grid-wrapper"> <div class="week-grid-wrapper">
<div class="week-grid"> <div class="week-grid">
@@ -215,7 +218,7 @@ function tooltip(ev) {
:style="eventStyle(occ)" :style="eventStyle(occ)"
:title="tooltip(occ.event)" :title="tooltip(occ.event)"
> >
<div class="event-title">{{ occ.event.summary || '(无标题)' }}</div> <div class="event-title">{{ occ.event.summary || t('weekPreview.untitled') }}</div>
<div class="event-time">{{ occ.event.dtstartTime }} - {{ occ.event.dtendTime }}</div> <div class="event-time">{{ occ.event.dtstartTime }} - {{ occ.event.dtendTime }}</div>
</div> </div>
</div> </div>
@@ -241,35 +244,36 @@ function tooltip(ev) {
} }
.nav-btn { .nav-btn {
padding: 5px 14px; padding: 5px 14px;
border: 1px solid #d5dbdb; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: var(--radius-sm);
background: #fff; background: var(--bg);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #2c3e50; color: var(--text-primary);
transition: background var(--transition);
} }
.nav-btn:hover { background: #ecf0f1; } .nav-btn:hover { background: var(--surface); }
.today-btn { border-color: #3498db; color: #3498db; font-weight: 600; } .today-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.today-btn:hover { background: #eaf4fc; } .today-btn:hover { background: var(--accent-weak); }
.collapse-btn { border-color: #9b59b6; color: #9b59b6; font-weight: 600; } .collapse-btn { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.collapse-btn:hover { background: #f4ecf7; } .collapse-btn:hover { background: var(--accent-weak); }
.empty-hint { .empty-hint {
font-size: 13px; font-size: 13px;
color: #95a5a6; color: var(--text-tertiary);
font-style: italic; font-style: italic;
} }
.week-label { .week-label {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
min-width: 200px; min-width: 200px;
text-align: center; text-align: center;
} }
.week-grid-wrapper { .week-grid-wrapper {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
border: 1px solid #e0e3e6; border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius-sm);
} }
.week-grid { .week-grid {
display: flex; display: flex;
@@ -278,28 +282,28 @@ function tooltip(ev) {
.time-col { .time-col {
width: 56px; width: 56px;
flex-shrink: 0; flex-shrink: 0;
border-right: 1px solid #e0e3e6; border-right: 1px solid var(--border);
} }
.time-spacer { .time-spacer {
height: 40px; height: 40px;
border-bottom: 1px solid #e0e3e6; border-bottom: 1px solid var(--border);
} }
.time-slot { .time-slot {
font-size: 11px; font-size: 11px;
color: #95a5a6; color: var(--text-tertiary);
text-align: right; text-align: right;
padding-right: 6px; padding-right: 6px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--surface-2);
} }
/* 非工作时段 (8:00 前 / 22:00 后) 淡灰 */ /* 非工作时段 (8:00 前 / 22:00 后) 淡灰 */
.time-slot.off-hour { .time-slot.off-hour {
color: #c4ccd0; color: var(--text-tertiary);
background: #f7f8f9; background: var(--surface);
} }
.day-col { .day-col {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
border-right: 1px solid #e0e3e6; border-right: 1px solid var(--border);
} }
.day-col:last-child { border-right: none; } .day-col:last-child { border-right: none; }
.day-header { .day-header {
@@ -310,13 +314,13 @@ function tooltip(ev) {
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
border-bottom: 1px solid #e0e3e6; border-bottom: 1px solid var(--border);
background: #f8f9fa; background: var(--surface);
} }
.day-header.is-today { .day-header.is-today {
background: #eaf4fc; background: var(--accent-weak);
color: #3498db; color: var(--accent);
} }
.day-date { .day-date {
font-size: 14px; font-size: 14px;
@@ -329,10 +333,10 @@ function tooltip(ev) {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--surface-2);
} }
.hour-line.off-hour { .hour-line.off-hour {
background: #f7f8f9; background: var(--surface);
} }
.event-block { .event-block {
position: absolute; position: absolute;

View File

@@ -4,6 +4,7 @@ import { organize as organizeEvents } from '../lib/organize.js'
import { createEvent } from '../lib/event-factory.js' import { createEvent } from '../lib/event-factory.js'
import { splitRecurringEvent } from '../lib/split.js' import { splitRecurringEvent } from '../lib/split.js'
import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js' import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js'
import { detectTimezone, isValidTimezone, DEFAULT_TIMEZONE } from '../lib/timezone.js'
// 单例响应式状态 // 单例响应式状态
const state = reactive({ const state = reactive({
@@ -13,6 +14,9 @@ const state = reactive({
fileName: null, fileName: null,
viewMode: 'edit', // 'edit' | 'month' | 'week' viewMode: 'edit', // 'edit' | 'month' | 'week'
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周) previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
// 日历级默认时区: 加载时自动检测用户所在时区, 取不到回退墨尔本。
// 仅作新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE, 不做时间换算。
timezone: detectTimezone(),
}) })
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -45,12 +49,12 @@ function organize() {
/** /**
* 更新指定事件的字段。 * 更新指定事件的字段。
* 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。 * 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。
* uid 不存在视为编程错误, 直接抛出 (调用方应先校验 selectedEvent)。
*/ */
function updateEvent(uid, patch) { function updateEvent(uid, patch) {
const ev = state.events.find((e) => e.uid === uid) const ev = state.events.find((e) => e.uid === uid)
if (ev) { if (!ev) throw new Error(`updateEvent: 事件不存在 uid=${uid}`)
Object.assign(ev, patch) Object.assign(ev, patch)
}
} }
function deleteEvent(uid) { function deleteEvent(uid) {
@@ -59,17 +63,18 @@ function deleteEvent(uid) {
} }
function addEvent() { function addEvent() {
const ev = createEvent() const ev = createEvent({ tzid: state.timezone })
state.events.push(ev) state.events.push(ev)
state.selectedUid = ev.uid state.selectedUid = ev.uid
} }
function splitEvent(uid, splitDate) { function splitEvent(uid, splitDate) {
const ev = state.events.find((e) => e.uid === uid) const ev = state.events.find((e) => e.uid === uid)
if (!ev || !ev.rrule) return if (!ev) throw new Error(`splitEvent: 事件不存在 uid=${uid}`)
if (!ev.rrule) throw new Error('splitEvent: 非重复事件无法拆分')
const parts = splitRecurringEvent(ev, splitDate) const parts = splitRecurringEvent(ev, splitDate)
if (!parts) return if (!parts) throw new Error('splitEvent: 拆分条件不满足 (日期越界或不在网格)')
const [partA, partB] = parts const [partA, partB] = parts
const idx = state.events.findIndex((e) => e.uid === uid) const idx = state.events.findIndex((e) => e.uid === uid)
@@ -79,7 +84,7 @@ function splitEvent(uid, splitDate) {
function toggleOccurrence(uid, date) { function toggleOccurrence(uid, date) {
const ev = state.events.find((e) => e.uid === uid) const ev = state.events.find((e) => e.uid === uid)
if (!ev) return if (!ev) throw new Error(`toggleOccurrence: 事件不存在 uid=${uid}`)
const i = ev.exdates.indexOf(date) const i = ev.exdates.indexOf(date)
if (i >= 0) { if (i >= 0) {
ev.exdates.splice(i, 1) ev.exdates.splice(i, 1)
@@ -93,7 +98,12 @@ function toggleOccurrence(uid, date) {
// 序列化 (下载时兜底: 始终序列化当前最新状态) // 序列化 (下载时兜底: 始终序列化当前最新状态)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function serialize() { function serialize() {
return serializeICS({ meta: state.meta, events: state.events }) // 把当前选定默认时区写入 X-WR-TIMEZONE, 使下载的 ICS 携带该标识。
// 保留 meta 原值用于回显, 仅在输出时覆盖 (不污染内存状态)。
const meta = state.meta
? { ...state.meta, xWrTimezone: state.timezone }
: state.meta
return serializeICS({ meta, events: state.events })
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -103,6 +113,15 @@ function setViewMode(mode) {
state.viewMode = mode state.viewMode = mode
} }
/**
* 设置日历级默认时区(新建事件与 X-WR-TIMEZONE 用)。
* 校验失败时回退到默认时区(墨尔本),保证 state 永远持有合法 IANA 标识。
* @param {string} tz
*/
function setTimezone(tz) {
state.timezone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE
}
function shiftMonth(delta) { function shiftMonth(delta) {
const { year, month } = parseISODate(startOfMonth(state.previewDate)) const { year, month } = parseISODate(startOfMonth(state.previewDate))
const d = new Date(year, month - 1 + delta, 1) const d = new Date(year, month - 1 + delta, 1)
@@ -158,6 +177,7 @@ export function useCalendar() {
toggleOccurrence, toggleOccurrence,
serialize, serialize,
setViewMode, setViewMode,
setTimezone,
prevMonth, prevMonth,
nextMonth, nextMonth,
prevWeek, prevWeek,

View File

@@ -0,0 +1,14 @@
import { computed } from 'vue'
import i18n from '../i18n/index.js'
// ★ 子项目自有的语言辅助 composable。
// 关键:直接读模块级 i18n 实例的 global composer而非 useI18n() 注入。
// 原因useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
// 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。
export function useLocale() {
const { t, locale } = i18n.global
const isZh = computed(() => locale.value === 'zh-CN')
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
}

46
src/i18n/index.js Normal file
View File

@@ -0,0 +1,46 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.js'
// ★ timeTableFix 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vuewatch 同步到本实例,
// 从而跟随父项目语言开关切换(不共享父的 messages仅同步 locale 值,文案来自本子项目自有 locale
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN
}
})
const loadedLocales = new Set(['zh-CN'])
/**
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
* @param {string} locale
*/
export async function loadLocaleAsync(locale) {
if (i18n.global.locale.value === locale) return
if (loadedLocales.has(locale)) {
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
return
}
let messages
if (locale === 'en') {
messages = (await import('./locales/en.js')).default
} else {
i18n.global.locale.value = 'zh-CN'
document.documentElement.setAttribute('lang', 'zh-CN')
return
}
i18n.global.mergeLocaleMessage(locale, messages)
loadedLocales.add(locale)
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
}
export default i18n

80
src/i18n/locales/en.js Normal file
View File

@@ -0,0 +1,80 @@
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk。
export default {
app: {
title: 'ICS Calendar Organizer'
},
view: {
edit: 'Edit',
month: 'Month',
week: 'Week'
},
header: {
organize: 'Organize & Dedupe',
import: 'Import File',
download: 'Download ICS',
noPattern: 'No recurring patterns found to merge',
parseError: 'Parse failed: ',
timezone: 'Time Zone'
},
dropzone: {
hint: 'Drop an .ics file, or',
pick: 'Click to select a file',
requireIcs: 'Please select an .ics file',
parseError: 'Parse failed: '
},
eventList: {
stat: '{total} events ({recurring} recurring / {single} single)',
add: 'Add Event',
untitled: '(Untitled)',
recurring: 'Recurring',
single: 'Single'
},
eventDetail: {
empty: 'Select an event from the left to edit',
basicTitle: 'Basic Info',
summary: 'Title (SUMMARY)',
startTime: 'Start Time',
endTime: 'End Time',
location: 'Location (LOCATION)',
description: 'Description (DESCRIPTION)',
rruleTitle: 'Recurrence Rule (RRULE)',
enableRecur: 'Enable recurrence',
freq: 'Frequency',
freqWeekly: 'Weekly (WEEKLY)',
freqDaily: 'Daily (DAILY)',
interval: 'Interval (INTERVAL)',
until: 'Until (UNTIL)',
byday: 'Weekday (BYDAY, auto from start date)',
occTitle: 'occurrence / excluded dates',
occHint: 'Click a date to toggle "exclude/include". Excluded dates (red strikethrough) are written to EXDATE.',
split: 'Split this event',
delete: 'Delete this event',
splitDesc: 'Split this recurring event into two segments from the specified date.',
splitConfirm: 'Confirm Split',
cancel: 'Cancel',
confirmDelete: 'Delete "{name}"?',
requireSplitDate: 'Please select a split date',
splitDateInvalid: 'Split date must be between the start and end dates'
},
monthPreview: {
prev: 'Prev Month',
next: 'Next Month',
today: 'Today',
untitled: '(Untitled)',
monthFmt: '{y}/{m}',
tooltipTime: 'Time: {start} - {end}',
tooltipLocation: 'Location: {location}'
},
weekPreview: {
prev: 'Prev Week',
next: 'Next Week',
today: 'Today',
expandNight: 'Show Night',
collapseNight: 'Hide Night',
noEvents: 'No events this week',
untitled: '(Untitled)',
rangeFmt: '{s} - {e}',
tooltipLocation: 'Location: {location}',
tooltipDesc: 'Description: {description}'
}
}

80
src/i18n/locales/zh-CN.js Normal file
View File

@@ -0,0 +1,80 @@
// 中文语言包(默认语言,同步注入主 chunk
export default {
app: {
title: 'ICS 日程整理器'
},
view: {
edit: '编辑',
month: '月预览',
week: '周预览'
},
header: {
organize: '整理去重',
import: '导入文件',
download: '下载 ICS',
noPattern: '未发现可合并的重复模式',
parseError: '解析失败:',
timezone: '时区'
},
dropzone: {
hint: '拖入 .ics 文件,或',
pick: '点击选择文件',
requireIcs: '请选择 .ics 文件',
parseError: '解析失败:'
},
eventList: {
stat: '共 {total} 个日程({recurring} 重复 / {single} 单次)',
add: '新增日程',
untitled: '(无标题)',
recurring: '重复',
single: '单次'
},
eventDetail: {
empty: '从左侧选择一个日程进行编辑',
basicTitle: '基本信息',
summary: '标题 (SUMMARY)',
startTime: '开始时间',
endTime: '结束时间',
location: '地点 (LOCATION)',
description: '描述 (DESCRIPTION)',
rruleTitle: '重复规则 (RRULE)',
enableRecur: '启用重复',
freq: '频率',
freqWeekly: '每周 (WEEKLY)',
freqDaily: '每天 (DAILY)',
interval: '间隔 (INTERVAL)',
until: '重复到 (UNTIL)',
byday: '星期 (BYDAY, 由开始日期自动确定)',
occTitle: 'occurrence / 排除日期',
occHint: '点击日期切换"排除/包含"状态。排除的日期(红色删除线)会写入 EXDATE。',
split: '拆分此日程',
delete: '删除此日程',
splitDesc: '从指定日期起,把此重复事件拆成前后两段。',
splitConfirm: '确认拆分',
cancel: '取消',
confirmDelete: '确定删除"{name}"',
requireSplitDate: '请选择拆分日期',
splitDateInvalid: '拆分日期必须在开始日期和结束日期之间'
},
monthPreview: {
prev: '上个月',
next: '下个月',
today: '回到今日',
untitled: '(无标题)',
monthFmt: '{y} 年 {m} 月',
tooltipTime: '时间: {start} - {end}',
tooltipLocation: '地点: {location}'
},
weekPreview: {
prev: '上一周',
next: '下一周',
today: '回到今日',
expandNight: '展开夜间',
collapseNight: '折叠夜间',
noEvents: '本周无日程',
untitled: '(无标题)',
rangeFmt: '{s} - {e}',
tooltipLocation: '地点: {location}',
tooltipDesc: '描述: {description}'
}
}

View File

@@ -132,7 +132,13 @@ function parseHHMM(s) {
function getZone(tzid) { function getZone(tzid) {
if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone
const z = ICAL.TimezoneService.get(tzid) const z = ICAL.TimezoneService.get(tzid)
return z || ICAL.Timezone.utcTimezone if (!z) {
// 时区未注册 (VTIMEZONE 缺失): ical.js 回退 UTC 会导致时间偏移。
// 记录到控制台便于定位 (典型成因: 手建事件 tzid 未在源 ICS 中定义)。
console.warn(`[timeTableFix] 时区 ${tzid} 未注册, 回退 UTC`)
return ICAL.Timezone.utcTimezone
}
return z
} }
function makeTime(dateStr, timeStr, tzid) { function makeTime(dateStr, timeStr, tzid) {

102
src/lib/timezone.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* 时区选择辅助函数。
*
* 应用内部所有日期/时间均为 wall-clock 字符串(见 date.js不做时区换算。
* 本模块仅负责「选择一个 IANA 时区标识」作为日历级默认值:检测浏览器所在
* 时区取不到则回退澳大利亚墨尔本Australia/Melbourne并提供下拉列表。
*
* 选定时区用于:新建事件的默认 tzid、下载 ICS 的 X-WR-TIMEZONE。
* 已导入事件保留其原始 TZID 不变(不做换算、不覆盖)。
*/
/** 检测失败时的回退时区(澳大利亚墨尔本)。 */
export const DEFAULT_TIMEZONE = 'Australia/Melbourne'
/**
* 精选常用 IANA 时区,保证下拉框在任意环境(含不支持
* Intl.supportedValuesOf 的旧浏览器)下都有合理可选项。
* 列表覆盖主要大洲 + UTC墨尔本位列其中。
*/
export const COMMON_TIMEZONES = [
'UTC',
'Australia/Melbourne',
'Australia/Sydney',
'Australia/Perth',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Tokyo',
'Asia/Singapore',
'Asia/Seoul',
'Asia/Kolkata',
'Asia/Dubai',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'Pacific/Auckland',
]
/** IANA 时区标识粗校验:至少含一个 '/',且仅含可打印 ASCII。 */
const IANA_RE = /^[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)+$/
/**
* 校验字符串是否为合法的 IANA 时区标识。
* UTC 与 Etc/GMT 等单段形式不被本应用使用,故要求至少一段 '/'。
* @param {string} tz
* @returns {boolean}
*/
export function isValidTimezone(tz) {
if (typeof tz !== 'string' || tz.length === 0) return false
if (!IANA_RE.test(tz)) return false
return true
}
/**
* 收集下拉框可选时区:精选列表 浏览器支持的时区,去重后排序。
* 运行时若 Intl.supportedValuesOf 不可用则仅返回精选列表。
* UTC 始终置顶(用户可见的全局协调时区)。
* @returns {string[]}
*/
export function listTimezones() {
const set = new Set(COMMON_TIMEZONES)
try {
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
for (const tz of Intl.supportedValuesOf('timeZone')) {
if (isValidTimezone(tz)) set.add(tz)
}
}
} catch {
// 旧环境或不支持时静默降级到精选列表
}
const list = [...set].sort((a, b) => {
if (a === 'UTC') return -1
if (b === 'UTC') return 1
return a.localeCompare(b)
})
return list
}
/**
* 检测用户当前所在时区。
* 优先用 Intl.DateTimeFormat().resolvedOptions().timeZone取不到或抛错
* 时回退 DEFAULT_TIMEZONE澳大利亚墨尔本
* @returns {string} IANA 时区标识
*/
export function detectTimezone() {
try {
const tz =
typeof Intl !== 'undefined' &&
typeof Intl.DateTimeFormat === 'function'
? new Intl.DateTimeFormat().resolvedOptions().timeZone
: null
if (tz && isValidTimezone(tz)) return tz
} catch {
// 非安全上下文或 Intl 缺失:回退
}
return DEFAULT_TIMEZONE
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import i18n from './i18n/index.js'
import './styles/global.css' import './styles/global.css'
createApp(App).mount('#app') createApp(App).use(i18n).mount('#app')

View File

@@ -1,9 +1,52 @@
/* 全局重置 -- 仅独立 appmain.js加载。 /* 全局重置 + 设计令牌 -- 仅独立 appmain.js加载。
作为组件被嵌入时由宿主提供重置,本文件不参与,避免污染宿主。 */ 作为组件被嵌入时由宿主(mainPage)提供 :root 令牌,本文件的重置仍安全(与宿主一致)。
令牌值与 mainPage src/styles/variables.css 对齐,保证独立与嵌入视觉一致。 */
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
background: #f5f6f8; "Microsoft YaHei", sans-serif;
color: #222; background: var(--bg, #ffffff);
color: var(--text-primary, #1f2329);
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:root {
/* 背景/表面 */
--bg: #ffffff;
--surface: #f7f8fa;
--surface-2: #f0f2f5;
/* 文字 */
--text-primary: #1f2329;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* 强调色(靛蓝,与 mainPage 一致) */
--accent: #4f46e5;
--accent-weak: #eef2ff;
--accent-hover: #4338ca;
/* 线/边 */
--border: #eceef1;
--border-strong: #e0e2e6;
/* 语义色 */
--success: #2e7d32;
--success-weak: #e8f5e9;
--danger: #c62828;
--danger-weak: #fbe9e7;
--warn: #e65100;
--warn-weak: #fff3e0;
/* 圆角 */
--radius-sm: 8px;
--radius: 12px;
/* 阴影 */
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
--shadow-hover: 0 6px 16px rgba(17, 24, 39, 0.08), 0 2px 6px rgba(17, 24, 39, 0.05);
/* 过渡 */
--transition: 0.18s ease;
} }

127
test/timezone.test.js Normal file
View File

@@ -0,0 +1,127 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
DEFAULT_TIMEZONE,
COMMON_TIMEZONES,
detectTimezone,
listTimezones,
isValidTimezone,
} from '../src/lib/timezone.js'
import { createEvent } from '../src/lib/event-factory.js'
describe('DEFAULT_TIMEZONE', () => {
it('is Australia/Melbourne per requirement', () => {
expect(DEFAULT_TIMEZONE).toBe('Australia/Melbourne')
})
})
describe('isValidTimezone', () => {
it('accepts valid IANA zone ids', () => {
expect(isValidTimezone('Australia/Melbourne')).toBe(true)
expect(isValidTimezone('Asia/Shanghai')).toBe(true)
expect(isValidTimezone('America/New_York')).toBe(true)
expect(isValidTimezone('Europe/London')).toBe(true)
})
it('rejects empty / non-string / malformed', () => {
expect(isValidTimezone('')).toBe(false)
expect(isValidTimezone(null)).toBe(false)
expect(isValidTimezone(undefined)).toBe(false)
expect(isValidTimezone(123)).toBe(false)
expect(isValidTimezone('UTC')).toBe(false) // 单段, 本应用要求至少一段 '/'
expect(isValidTimezone('Not A Zone')).toBe(false)
expect(isValidTimezone('Bogus/Zone/With Space')).toBe(false)
})
})
describe('detectTimezone', () => {
// 保存原始 Intl 引用以便恢复
const origDateTimeFormat = Intl.DateTimeFormat
afterEach(() => {
Intl.DateTimeFormat = origDateTimeFormat
})
it('returns a valid IANA zone in a normal environment', () => {
// Node 环境总能解析出系统时区
const tz = detectTimezone()
expect(isValidTimezone(tz)).toBe(true)
})
it('falls back to Melbourne when Intl.DateTimeFormat throws', () => {
Intl.DateTimeFormat = function () {
throw new Error('unsupported')
}
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
})
it('falls back to Melbourne when resolved timeZone is empty', () => {
Intl.DateTimeFormat = function () {
return { resolvedOptions: () => ({ timeZone: '' }) }
}
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
})
it('falls back to Melbourne when resolved timeZone is malformed', () => {
Intl.DateTimeFormat = function () {
return { resolvedOptions: () => ({ timeZone: 'Not A Zone' }) }
}
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
})
it('returns the detected zone when it is valid', () => {
Intl.DateTimeFormat = function () {
return { resolvedOptions: () => ({ timeZone: 'Asia/Shanghai' }) }
}
expect(detectTimezone()).toBe('Asia/Shanghai')
})
})
describe('listTimezones', () => {
it('always includes Melbourne, Shanghai, UTC', () => {
const list = listTimezones()
expect(list).toContain('Australia/Melbourne')
expect(list).toContain('Asia/Shanghai')
expect(list).toContain('UTC')
})
it('has no duplicates', () => {
const list = listTimezones()
expect(new Set(list).size).toBe(list.length)
})
it('places UTC first, rest sorted', () => {
const list = listTimezones()
expect(list[0]).toBe('UTC')
const rest = list.slice(1)
const sorted = [...rest].sort((a, b) => a.localeCompare(b))
expect(rest).toEqual(sorted)
})
it('every entry is a valid IANA zone (UTC excepted)', () => {
const list = listTimezones()
for (const tz of list) {
if (tz === 'UTC') continue
expect(isValidTimezone(tz)).toBe(true)
}
})
it('includes all COMMON_TIMEZONES entries', () => {
const list = listTimezones()
for (const tz of COMMON_TIMEZONES) {
expect(list).toContain(tz)
}
})
})
describe('createEvent timezone override', () => {
// 确认 useCalendar.addEvent 依赖的覆盖路径:传入 { tzid } 后新建事件的
// tzid 即为该值(而非 event-factory 的独立默认 'UTC')。
it('honors the tzid passed via overrides', () => {
const ev = createEvent({ tzid: 'Asia/Shanghai' })
expect(ev.tzid).toBe('Asia/Shanghai')
})
it('falls back to UTC when no tzid override is given (standalone default)', () => {
const ev = createEvent()
expect(ev.tzid).toBe('UTC')
})
})