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:
@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user