From 7f2855244028c94e20e914081b537428ff4f966a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:29:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=97=A5=E5=8E=86=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20=E2=80=94=E2=80=94=20=E6=9C=88/=E5=91=A8/=E6=97=A5?= =?UTF-8?q?=E4=B8=89=E7=B2=92=E5=BA=A6=20+=20=E4=BA=8B=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20+=20=E5=86=9C=E5=8E=86=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 布局与全站一致:**内容区自己再分两栏**。 第一版是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做, 而点「新建」时编辑器**顶掉**整个日历,人失去正在看的那个月的上下文。 两个问题同源:日历没有「详情栏」这个位置。 现在左边网格、右边常驻面板:默认显示选中那天的日程(所以永远不空), 新建/编辑时同一位置变编辑器 —— 与「新建邮件是右侧整页」同一套语言。 窄屏退回覆盖式(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 例。 --- web/src/api/client.ts | 113 ++- web/src/components/CalendarEventEditor.tsx | 694 ++++++++++++++++++ web/src/components/CalendarView.tsx | 775 +++++++++++++++++++++ web/src/lib/calendar.ts | 208 ++++++ web/src/types/index.ts | 106 +++ web/test/components/calendar.test.tsx | 281 ++++++++ 6 files changed, 2176 insertions(+), 1 deletion(-) create mode 100644 web/src/components/CalendarEventEditor.tsx create mode 100644 web/src/components/CalendarView.tsx create mode 100644 web/src/lib/calendar.ts create mode 100644 web/test/components/calendar.test.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ba30d3d..8bb8a36 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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('GET', `/calendar/events/${id}`); +} + +export async function createCalendarEvent(input: CalendarEventInput) { + return request('POST', '/calendar/events', input); +} + +export async function updateCalendarEvent(id: string, input: Partial) { + return request('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 { + 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}` + ); +} diff --git a/web/src/components/CalendarEventEditor.tsx b/web/src/components/CalendarEventEditor.tsx new file mode 100644 index 0000000..99158c0 --- /dev/null +++ b/web/src/components/CalendarEventEditor.tsx @@ -0,0 +1,694 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import * as api from '../api/client'; +import type { + CalendarEvent, + CalendarEventInput, + CalendarAttachment, + Recurrence, + DeliveryMode +} from '../types'; +import AddressInput from './AddressInput'; +import { + toLocalInput, + fromLocalInput, + renderReminder, + describeRemindBefore +} from '../lib/calendar'; +import { + upcomingOccurrences, + formatSolarWithLunar, + isLunarRecurrence, + describeRecurrenceRule +} from '../lib/lunar'; +import { + CloseIcon, + SpinnerIcon, + TrashIcon, + BellIcon, + RepeatIcon, + PaperclipIcon, + FileIcon, + PlusIcon, + UsersIcon, + BotIcon +} from './icons'; + +/** + * 事件编辑器(新建 / 编辑共用)。 + * + * 参照 Outlook 的编辑面板:时间与重复在上、收件方居中、提醒正文在下。 + * 提醒正文可编辑且带 `{title}` `{time}` `{description}` 变量 —— 预览实时渲染, + * 因为「模板里写了什么」和「Agent 收到什么」不是一个东西, + * 不给预览的话人只能发一次试试看。 + */ + +/** 提前提醒的预设档位。手打分钟数容易写出档位外的值,但也允许。 */ +const PRESETS = [0, 5, 15, 30, 60, 120, 1440]; + +const DEFAULT_TEMPLATE = '日程提醒:{title}\n时间:{time}\n{description}'; + +/** 重复规则选项。农历单独一组:它们的公历日期每次都在漂移。 */ +const RECURRENCE_GROUPS: { label: string; items: { value: Recurrence; label: string }[] }[] = [ + { + label: '公历', + items: [ + { value: 'none', label: '不重复' }, + { value: 'daily', label: '每天' }, + { value: 'weekly', label: '每周' }, + { value: 'monthly', label: '每月(同一日)' }, + { value: 'yearly', label: '每年(同月日)' } + ] + }, + { + label: '农历', + items: [ + { value: 'lunar_monthly', label: '每农历月(同一日,如每月十五)' }, + { value: 'lunar_yearly', label: '每农历年(同月日,如农历生日)' } + ] + } +]; + +export default function CalendarEventEditor({ + event, + initialTime, + onClose, + onSaved +}: { + /** 有值 = 编辑,无值 = 新建 */ + event?: CalendarEvent | null; + /** 新建时的预填时间(点空白格子建事件) */ + initialTime?: Date; + onClose: () => void; + onSaved: () => void; +}) { + const editing = !!event; + + const [title, setTitle] = useState(event?.title ?? ''); + const [description, setDescription] = useState(event?.description ?? ''); + const [reminderText, setReminderText] = useState(event?.reminder_text ?? ''); + + /** + * 收件人列表。 + * + * 初值兼容旧数据:recipients 为空时退回 to_address / agent_name —— + * 与后端 EffectiveRecipients() 同一条兜底链。不做这个归一化的话, + * 编辑一条老事件再保存会把它的收件人清空(列表是空的,保存就覆盖了)。 + */ + const [recipients, setRecipients] = useState(() => { + if (event?.recipients?.length) return event.recipients; + const legacy = (event?.to_address || event?.agent_name || '').trim(); + return legacy ? [legacy] : []; + }); + const [draftAddr, setDraftAddr] = useState(''); + const [deliveryMode, setDeliveryMode] = useState( + event?.delivery_mode === 'together' ? 'together' : 'separate' + ); + + const [eventTime, setEventTime] = useState(() => { + if (event?.event_time) return toLocalInput(new Date(event.event_time)); + if (initialTime) return toLocalInput(initialTime); + // 默认下一个整点:现在这一刻当默认值几乎总要改 + const d = new Date(); + d.setHours(d.getHours() + 1, 0, 0, 0); + return toLocalInput(d); + }); + const [remindBefore, setRemindBefore] = useState(event?.remind_before ?? 0); + const [recurrence, setRecurrence] = useState(event?.recurrence ?? 'none'); + const [recurrenceEnd, setRecurrenceEnd] = useState( + event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : '' + ); + const [status, setStatus] = useState(event?.status ?? 'active'); + + const [agents, setAgents] = useState([]); + const [atts, setAtts] = useState([]); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [err, setErr] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(false); + + // Agent 列表用于快捷添加。拉不到不算错 —— 地址仍可手输(三段式补全独立工作)。 + useEffect(() => { + api + .listAgents() + .then(r => setAgents((r.agents ?? []).map(a => a.agent_name))) + .catch(() => setAgents([])); + }, []); + + // 附件只在编辑既有事件时才有:新建时还没有 event_id 可挂 + useEffect(() => { + if (!event?.event_id) return; + api + .listCalendarAttachments(event.event_id) + .then(r => setAtts(r.attachments ?? [])) + .catch(() => setAtts([])); + }, [event?.event_id]); + + function addRecipient(addr: string) { + const v = addr.trim(); + if (!v) return; + // 去重:together 模式下同一个 Agent 既主收又抄送会收到两条 SSE, + // 插件可能因此起两轮 + if (recipients.includes(v)) { + setDraftAddr(''); + return; + } + setRecipients(prev => [...prev, v]); + setDraftAddr(''); + } + + function removeRecipient(addr: string) { + setRecipients(prev => prev.filter(x => x !== addr)); + } + + /** 上移一位。together 模式下第一个是主收件人,顺序有语义。 */ + function moveUp(i: number) { + if (i <= 0) return; + setRecipients(prev => { + const next = [...prev]; + [next[i - 1], next[i]] = [next[i], next[i - 1]]; + return next; + }); + } + + const preview = useMemo(() => { + const tpl = reminderText.trim() || DEFAULT_TEMPLATE; + return renderReminder(tpl, { + title: title || '(未填标题)', + description, + event_time: fromLocalInput(eventTime) || new Date().toISOString() + }); + }, [reminderText, title, description, eventTime]); + + /** + * 接下来三次触发。 + * + * 农历规则必须给这个预览:规则名(「每农历月廿二」)看不出公历日子, + * 而公历日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。 + */ + const upcoming = useMemo(() => { + if (recurrence === 'none') return []; + const iso = fromLocalInput(eventTime); + if (!iso) return []; + return upcomingOccurrences(recurrence, new Date(iso), 3); + }, [recurrence, eventTime]); + + async function addFiles(files: FileList | null) { + if (!files?.length || !event?.event_id) return; + setUploading(true); + setErr(''); + try { + // 逐个传而非并发:并发失败时分不清是哪个文件的问题 + for (const f of Array.from(files)) { + const a = await api.uploadCalendarAttachment(event.event_id, f); + setAtts(prev => [...prev, a]); + } + } catch (e: any) { + setErr(e?.message || '上传失败'); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ''; + } + } + + async function dropAttachment(id: string) { + try { + await api.deleteCalendarAttachment(id); + setAtts(prev => prev.filter(a => a.attachment_id !== id)); + } catch (e: any) { + setErr(e?.message || '删除附件失败'); + } + } + + // 收件人为空时不能保存:事件永远发不出去,后端也会 400。 + // 在这里就禁用按钮比让人点了再看报错好。 + const canSave = title.trim() !== '' && eventTime !== '' && recipients.length > 0 && !saving; + + async function save() { + if (!canSave) return; + setErr(''); + setSaving(true); + const iso = fromLocalInput(eventTime); + if (!iso) { + setErr('事件时间无效'); + setSaving(false); + return; + } + // recurrence 为 none 时清掉终止时间:留着它只会让后续编辑困惑 + const endIso = recurrence === 'none' || !recurrenceEnd ? null : fromLocalInput(recurrenceEnd); + const payload: CalendarEventInput = { + title: title.trim(), + description, + reminder_text: reminderText.trim(), + recipients, + delivery_mode: deliveryMode, + // 旧字段保持与列表首项一致:第三方客户端(与旧版前端)只读 to_address + to_address: recipients[0] ?? '', + event_time: iso, + remind_before: remindBefore, + recurrence, + recurrence_end: endIso, + status + }; + try { + if (editing && event) await api.updateCalendarEvent(event.event_id, payload); + else await api.createCalendarEvent(payload); + onSaved(); + onClose(); + } catch (e: any) { + setErr(e?.message || '保存失败'); + } finally { + setSaving(false); + } + } + + async function remove() { + if (!event) return; + setDeleting(true); + try { + await api.deleteCalendarEvent(event.event_id); + onSaved(); + onClose(); + } catch (e: any) { + setErr(e?.message || '删除失败'); + setDeleting(false); + } + } + + const unusedAgents = agents.filter(a => !recipients.includes(a)); + + return ( +
+
+

+ {editing ? '编辑日程' : '新建日程'} +

+ +
+ +
+
+ + setTitle(e.target.value)} + autoFocus + placeholder="例如:llmsproxy 发布评审" + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ +