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

@ -1,5 +1,5 @@
import type { User } from '../types';
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
import { API_BASE, authHeaders, withToken } from './config';
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
@ -549,3 +549,114 @@ export async function decidePermission(mailId: string, decision: string, note?:
export async function listPendingPermissions() {
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
}
// ---------- Calendar ----------
/**
* 列事件。
*
* from/to 是 ISO 8601 区间;省略时后端给「前一个月到后一个月」。
* 月视图要显示跨月的首尾几天,所以查询区间必须按**格子**算而不是按月首月末算。
*/
export async function listCalendarEvents(opts: {
from?: string;
to?: string;
status?: string;
} = {}) {
const p = new URLSearchParams();
if (opts.from) p.set('from', opts.from);
if (opts.to) p.set('to', opts.to);
if (opts.status) p.set('status', opts.status);
const qs = p.toString();
return request<{ events: CalendarEvent[] }>('GET', `/calendar/events${qs ? '?' + qs : ''}`);
}
export async function getCalendarEvent(id: string) {
return request<CalendarEvent>('GET', `/calendar/events/${id}`);
}
export async function createCalendarEvent(input: CalendarEventInput) {
return request<CalendarEvent>('POST', '/calendar/events', input);
}
export async function updateCalendarEvent(id: string, input: Partial<CalendarEventInput>) {
return request<CalendarEvent>('PUT', `/calendar/events/${id}`, input);
}
export async function deleteCalendarEvent(id: string) {
return request<{ status: string }>('DELETE', `/calendar/events/${id}`);
}
export async function listCalendarAttachments(id: string) {
return request<{ attachments: CalendarAttachment[] }>(
'GET',
`/calendar/events/${id}/attachments`
);
}
/**
* 导出 .ics。
*
* 不走 request():那个函数假定响应是 JSON,而这里是 text/calendar。
* 返回文本由调用方自己触发下载 —— 用 Blob 而不是让浏览器直接导航,
* 因为导航会丢掉 cookie 之外的认证头(附件下载同理,见 withToken)。
*/
export async function exportCalendarICS(from?: string, to?: string): Promise<string> {
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
const qs = p.toString();
const res = await fetch(`${API_BASE}/calendar/export.ics${qs ? '?' + qs : ''}`, {
headers: authHeaders(),
credentials: 'include'
});
if (!res.ok) {
throw new ApiError(res.status, `导出失败:HTTP ${res.status}`);
}
return res.text();
}
/** 导入 .ics。整份文本原样 POST,后端解析 VEVENT。 */
export async function importCalendarICS(ics: string) {
const res = await fetch(`${API_BASE}/calendar/import.ics`, {
method: 'POST',
headers: { 'Content-Type': 'text/calendar', ...authHeaders() },
credentials: 'include',
body: ics
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, payload.error || `导入失败:HTTP ${res.status}`);
}
return payload as { imported: number; skipped: number };
}
/**
* 日历事件附件:上传。
*
* 与邮件附件走不同的存储关联(calendar_attachments 表),因此不能复用
* uploadAttachment —— 那个挂到 mail_id 上,日历要挂到 event_id 上。
* 事件必须先存在,所以新建流程是「先创建事件拿到 event_id,再传附件」。
*/
export async function uploadCalendarAttachment(eventId: string, file: File) {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${API_BASE}/calendar/events/${eventId}/attachments`, {
method: 'POST',
headers: authHeaders(),
credentials: 'include',
body: form
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, payload.error || `上传失败:HTTP ${res.status}`);
}
return payload as CalendarAttachment;
}
export async function deleteCalendarAttachment(attachmentId: string) {
return request<{ status: string; attachment_id: string }>(
'DELETE',
`/calendar/attachments/${attachmentId}`
);
}