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 托管)
This commit is contained in:
@@ -49,7 +49,7 @@ timeTableFix/
|
|||||||
| Node.js | ≥ 18 |
|
| Node.js | ≥ 18 |
|
||||||
| ical.js | ICS 解析库(npm 依赖) |
|
| ical.js | ICS 解析库(npm 依赖) |
|
||||||
|
|
||||||
> 纯前端应用,无运行期后端依赖。
|
> 纯前端应用,无运行期后端依赖。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管(无独立 apache2 配置);详见 zMainPage 的部署文档。
|
||||||
|
|
||||||
## 如何使用
|
## 如何使用
|
||||||
|
|
||||||
@@ -84,4 +84,5 @@ npm test # 单元测试(57 项)
|
|||||||
- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md)
|
- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md)
|
||||||
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
|
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
|
||||||
- [自动保存设计](./docs/auto-save-design.md)
|
- [自动保存设计](./docs/auto-save-design.md)
|
||||||
|
- [错误处理与日志约定](./docs/error-handling.md)
|
||||||
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)
|
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)
|
||||||
|
|||||||
@@ -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
25
docs/error-handling.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 错误处理与日志约定
|
||||||
|
|
||||||
|
timeTableFix 的错误处理分两层:纯库函数 fail-fast,UI 层捕获后既写控制台又给用户反馈。
|
||||||
|
|
||||||
|
## 纯库层(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]` 前缀的日志均来自本子项目,便于在嵌入宿主时与宿主日志区分。
|
||||||
@@ -18,6 +18,7 @@ function onDrop(e) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadFile(file).catch((err) => {
|
loadFile(file).catch((err) => {
|
||||||
|
console.error('[timeTableFix] 解析 ICS 失败', err)
|
||||||
error.value = t('dropzone.parseError') + (err.message || err)
|
error.value = t('dropzone.parseError') + (err.message || err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,7 @@ 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) => {
|
||||||
|
console.error('[timeTableFix] 解析 ICS 失败', err)
|
||||||
error.value = t('dropzone.parseError') + (err.message || err)
|
error.value = t('dropzone.parseError') + (err.message || err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 { useLocale } from '../composables/useLocale.js'
|
||||||
import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js'
|
import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js'
|
||||||
@@ -88,7 +88,14 @@ function onSplit() {
|
|||||||
alert(t('eventDetail.splitDateInvalid'))
|
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 = ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,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(t('header.parseError') + (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 = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +82,6 @@ function onDownload() {
|
|||||||
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
|
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
|
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
|
||||||
.btn-success:hover:not(:disabled) { filter: brightness(0.92); }
|
.btn-success:hover:not(:disabled) { filter: brightness(0.92); }
|
||||||
.btn-secondary { background: var(--bg); color: var(--text-secondary); }
|
|
||||||
.btn-secondary:hover:not(:disabled) { background: var(--surface); color: var(--text-primary); }
|
|
||||||
|
|
||||||
/* 时区选择器:与按钮同行,紧凑 */
|
/* 时区选择器:与按钮同行,紧凑 */
|
||||||
.tz-picker { display: flex; align-items: center; gap: 4px; }
|
.tz-picker { display: flex; align-items: center; gap: 4px; }
|
||||||
|
|||||||
@@ -34,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 并做并排布局, 按日期分组
|
||||||
|
|||||||
@@ -49,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) {
|
||||||
@@ -70,10 +70,11 @@ function addEvent() {
|
|||||||
|
|
||||||
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)
|
||||||
@@ -83,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)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default {
|
|||||||
collapseNight: 'Hide Night',
|
collapseNight: 'Hide Night',
|
||||||
noEvents: 'No events this week',
|
noEvents: 'No events this week',
|
||||||
untitled: '(Untitled)',
|
untitled: '(Untitled)',
|
||||||
|
rangeFmt: '{s} - {e}',
|
||||||
tooltipLocation: 'Location: {location}',
|
tooltipLocation: 'Location: {location}',
|
||||||
tooltipDesc: 'Description: {description}'
|
tooltipDesc: 'Description: {description}'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default {
|
|||||||
collapseNight: '折叠夜间',
|
collapseNight: '折叠夜间',
|
||||||
noEvents: '本周无日程',
|
noEvents: '本周无日程',
|
||||||
untitled: '(无标题)',
|
untitled: '(无标题)',
|
||||||
|
rangeFmt: '{s} - {e}',
|
||||||
tooltipLocation: '地点: {location}',
|
tooltipLocation: '地点: {location}',
|
||||||
tooltipDesc: '描述: {description}'
|
tooltipDesc: '描述: {description}'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user