feat(web): 日历前端 —— 月/周/日三粒度 + 事件编辑器 + 农历显示

布局与全站一致:**内容区自己再分两栏**。

第一版是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
而点「新建」时编辑器**顶掉**整个日历,人失去正在看的那个月的上下文。
两个问题同源:日历没有「详情栏」这个位置。

现在左边网格、右边常驻面板:默认显示选中那天的日程(所以永远不空),
新建/编辑时同一位置变编辑器 —— 与「新建邮件是右侧整页」同一套语言。
窄屏退回覆盖式(NarrowStack,与收件箱详情同一组件同一段动画)。

lib/calendar.ts 里三个易错点(都有测试钉住):

**周首必须是周一。** getDay() 把周日算作 0,直接减它会让周日归到上一周
末尾,月视图第一行整体错位。

**分桶用本地日期串而不是 toISOString().slice(0,10)。** 后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。

**月视图固定 42 格。** 按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。

另有一个 TS 陷阱:replaceAll 在 tsconfig 的 target: ES2020 下不存在
(TS2550)。改用 split/join —— **不能**退回 replace,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。

编辑器:
- 农历规则预览**接下来三次的公历日期**。规则名(「每农历月廿二」)看不出
  公历日子,而那日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
- 变量按钮 + 实时渲染预览。「模板里写了什么」和「Agent 收到什么」不是一个
  东西,不给预览人只能发一次试试看。
- 收件人列表可增删调序(together 模式下首个是主收件人,顺序有语义)。
  编辑旧事件时初值走与后端相同的兜底链 —— 不做归一化的话,编辑一条老事件
  再保存会把收件人清空(列表是空的,保存就覆盖了)。
- remindAt 单独显示:调度器比较的是「事件时间 − 提前分钟」那个点,
  不显示的话人设了「提前 30 分钟」却在事件时间才反应过来。

日历格子标农历日(初一显示月名,纸质日历惯例)。暂停/取消的事件加删除线
与图标 —— 否则人以为设好了,实际到点什么都不会发生。

exportCalendarICS 不走 request()(那个假定 JSON,这里是 text/calendar),
返回文本让调用方用 Blob 触发下载而不是让浏览器导航 —— 导航会丢掉 cookie
之外的认证头。

测试 35 例。
This commit is contained in:
2026-09-04 06:29:50 +08:00
parent e504eccf3a
commit 7f28552440
6 changed files with 2176 additions and 1 deletions

View File

@ -246,3 +246,109 @@ export interface AdminScopes {
agents: string[];
paths: string[];
}
/**
* 日历事件。
*
* 三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
* 提醒到点时调度器发一封 from_name="calendar" 的邮件给 to_address ——
* 对 Agent 来说就是一封普通邮件,它不知道也不需要知道信来自日历。
*/
export interface CalendarEvent {
event_id: string;
title: string;
description: string;
/**
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
* 支持 {title} {time} {description} 三个变量,触发时替换。
*/
reminder_text: string;
/**
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
* 保留作兼容与兜底:recipients 为空时才用它们。
* 新代码一律用 effectiveRecipients()。
*/
agent_name: string;
to_address: string;
/**
* 收件人列表,每项是完整三维地址串。
*
* 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
* 而重复事件每次触发都该重新决定落到哪条会话。
*/
recipients: string[];
/**
* 多收件人的投递方式。
* separate = 各发一封、落各自会话、互相看不到
* together = 首个为主收件人,其余进抄送、共享同一条线索
*
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
* 看到回复而趋同,那种污染事后无法分离。
*/
delivery_mode: DeliveryMode;
/** ISO 8601 */
event_time: string;
/** 提前多少分钟提醒;0 = 到点才提醒 */
remind_before: number;
recurrence: Recurrence;
/** 重复终止时间;越过它事件自动置为 cancelled */
recurrence_end?: string;
status: 'active' | 'paused' | 'cancelled';
/** 上次触发的墙上时钟 */
last_fired_at?: string;
/**
* 已触发的那个 occurrence(值 = 当时的 event_time)。
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
* 后端 DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
*/
fired_for?: string;
created_at: string;
updated_at: string;
created_by: string;
}
/**
* 重复规则。农历两种单独存在,因为它们的公历日期每年都在漂移 ——
* 用公历 yearly 会固定在同一天,与「过农历生日/祭日」的期望不符。
*
* 没有 lunar_daily(农历的「日」与公历同长,那就是 daily)
* 也没有 lunar_weekly(农历没有「周」这个单位)。
*/
export type Recurrence =
| 'none'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly'
| 'lunar_monthly'
| 'lunar_yearly';
export type DeliveryMode = 'separate' | 'together';
/** 事件附件(随提醒邮件一起发出) */
export interface CalendarAttachment {
attachment_id: string;
event_id: string;
filename: string;
sha256: string;
size_bytes: number;
created_at: string;
}
/** 新建/编辑事件的请求体。event_time 必填,其余可省。 */
export interface CalendarEventInput {
title: string;
description?: string;
reminder_text?: string;
agent_name?: string;
to_address?: string;
recipients?: string[];
delivery_mode?: DeliveryMode;
event_time: string;
remind_before?: number;
recurrence?: Recurrence;
recurrence_end?: string | null;
status?: 'active' | 'paused' | 'cancelled';
}