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}`
);
}

View File

@ -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<string[]>(() => {
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<DeliveryMode>(
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<Recurrence>(event?.recurrence ?? 'none');
const [recurrenceEnd, setRecurrenceEnd] = useState(
event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : ''
);
const [status, setStatus] = useState<CalendarEvent['status']>(event?.status ?? 'active');
const [agents, setAgents] = useState<string[]>([]);
const [atts, setAtts] = useState<CalendarAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col h-full min-h-0 bg-white">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
<h2 className="text-base font-medium text-gray-900">
{editing ? '编辑日程' : '新建日程'}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
aria-label="关闭"
>
<CloseIcon />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
value={title}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="可留空;会作为 {description} 变量填入提醒正文"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
type="datetime-local"
value={eventTime}
onChange={e => setEventTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{fromLocalInput(eventTime) && (
<p className="mt-1 text-xs text-gray-500">
{formatSolarWithLunar(new Date(fromLocalInput(eventTime)))}
</p>
)}
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<BellIcon className="w-3.5 h-3.5" />
</label>
<select
value={PRESETS.includes(remindBefore) ? String(remindBefore) : 'custom'}
onChange={e => {
if (e.target.value !== 'custom') setRemindBefore(Number(e.target.value));
}}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{PRESETS.map(m => (
<option key={m} value={m}>
{describeRemindBefore(m)}
</option>
))}
{!PRESETS.includes(remindBefore) && (
<option value="custom">{describeRemindBefore(remindBefore)}</option>
)}
</select>
</div>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<RepeatIcon className="w-3.5 h-3.5" />
</label>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value as Recurrence)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{RECURRENCE_GROUPS.map(g => (
<optgroup key={g.label} label={g.label}>
{g.items.map(it => (
<option key={it.value} value={it.value}>
{it.label}
</option>
))}
</optgroup>
))}
</select>
{recurrence !== 'none' && (
<div className="mt-2 space-y-2">
<div className="px-2.5 py-2 bg-gray-50 border border-gray-200 rounded">
<div className="text-xs text-gray-600 mb-1">
{describeRecurrenceRule(
recurrence,
fromLocalInput(eventTime) ? new Date(fromLocalInput(eventTime)) : undefined
)}
{isLunarRecurrence(recurrence) && (
<span className="ml-1 text-amber-700">· </span>
)}
</div>
{/* 接下来三次必须显示:农历规则的公历日期每次都在变,
光看规则名分辨不出对不对,而错了要等一个月才发现 */}
{upcoming.length > 0 ? (
<ul className="space-y-0.5">
{upcoming.map((d, i) => (
<li key={i} className="text-xs text-gray-700 tabular-nums">
{formatSolarWithLunar(d)}{' '}
<span className="text-gray-400">
{String(d.getHours()).padStart(2, '0')}:
{String(d.getMinutes()).padStart(2, '0')}
</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-amber-700">
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
type="datetime-local"
value={recurrenceEnd}
onChange={e => setRecurrenceEnd(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500"> = </p>
</div>
</div>
)}
</div>
{/* ── 收件人 ── */}
<div className="pt-1 border-t border-gray-100">
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1 mt-3">
<UsersIcon className="w-3.5 h-3.5" />
{recipients.length}
</label>
{recipients.length > 0 && (
<ul className="mb-2 space-y-1">
{recipients.map((addr, i) => (
<li
key={addr}
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
>
<BotIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 min-w-0 truncate text-gray-800 font-mono">{addr}</span>
{/* together 模式下首个是主收件人,顺序有语义,因此要能调 */}
{deliveryMode === 'together' && i === 0 && (
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded shrink-0">
</span>
)}
{deliveryMode === 'together' && i > 0 && (
<button
onClick={() => moveUp(i)}
title="设为主收件人方向移动"
className="px-1 rounded hover:bg-gray-200 text-gray-500 shrink-0"
>
</button>
)}
<button
onClick={() => removeRecipient(addr)}
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
aria-label={`移除 ${addr}`}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<AddressInput
value={draftAddr}
onChange={setDraftAddr}
placeholder="name@path.session省略 .session = 默认会话)"
/>
</div>
<button
onClick={() => addRecipient(draftAddr)}
disabled={!draftAddr.trim()}
className="px-2.5 py-2 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 shrink-0 flex items-center gap-1"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
</div>
{unusedAgents.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{unusedAgents.map(a => (
<button
key={a}
onClick={() => addRecipient(a)}
className="px-2 py-0.5 text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
>
+ {a}
</button>
))}
</div>
)}
{recipients.length > 1 && (
<div className="mt-3">
<div className="text-xs font-medium text-gray-600 mb-1"></div>
<div className="space-y-1.5">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
checked={deliveryMode === 'separate'}
onChange={() => setDeliveryMode('separate')}
className="mt-0.5"
/>
<span className="text-xs">
<span className="text-gray-800"></span>
<span className="block text-gray-500">
Agent
</span>
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
checked={deliveryMode === 'together'}
onChange={() => setDeliveryMode('together')}
className="mt-0.5"
/>
<span className="text-xs">
<span className="text-gray-800"></span>
<span className="block text-gray-500">
线
</span>
</span>
</label>
</div>
</div>
)}
</div>
{/* ── 提醒正文 ── */}
<div className="pt-1 border-t border-gray-100">
<label className="block text-xs font-medium text-gray-600 mb-1 mt-3"></label>
<textarea
value={reminderText}
onChange={e => setReminderText(e.target.value)}
rows={4}
placeholder={DEFAULT_TEMPLATE}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{['{title}', '{time}', '{description}'].map(v => (
<button
key={v}
type="button"
onClick={() => setReminderText(t => t + v)}
className="px-2 py-0.5 text-xs font-mono bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
>
{v}
</button>
))}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Agent </div>
<pre className="px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-800 whitespace-pre-wrap break-words">
{preview}
</pre>
</div>
{editing && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<PaperclipIcon className="w-3.5 h-3.5" />
</label>
{atts.length > 0 && (
<ul className="mb-2 space-y-1">
{atts.map(a => (
<li
key={a.attachment_id}
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 min-w-0 truncate text-gray-800">{a.filename}</span>
<span className="text-gray-400 tabular-nums shrink-0">
{(a.size_bytes / 1024).toFixed(1)} KB
</span>
<button
onClick={() => dropAttachment(a.attachment_id)}
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
aria-label={`移除 ${a.filename}`}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-2.5 py-1 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 flex items-center gap-1.5"
>
{uploading ? (
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
) : (
<PaperclipIcon className="w-3.5 h-3.5" />
)}
</button>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={e => addFiles(e.target.files)}
/>
</div>
)}
{editing && (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<select
value={status}
onChange={e => setStatus(e.target.value as CalendarEvent['status'])}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="active"></option>
<option value="paused"></option>
<option value="cancelled"></option>
</select>
</div>
)}
{err && (
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{err}
</div>
)}
</div>
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0">
<button
onClick={save}
disabled={!canSave}
title={recipients.length === 0 ? '至少要有一个收件人' : undefined}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{editing ? '保存' : '创建'}
</button>
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50"
>
</button>
{editing && (
<div className="ml-auto">
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600"></span>
<button
onClick={remove}
disabled={deleting}
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-40 flex items-center gap-1"
>
{deleting && <SpinnerIcon className="w-3 h-3 animate-spin" />}
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-3 py-1.5 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="px-3 py-1.5 text-red-600 text-sm rounded hover:bg-red-50 flex items-center gap-1.5"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,775 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
import type { CalendarEvent } from '../types';
import CalendarEventEditor from './CalendarEventEditor';
import NarrowStack from './NarrowStack';
import {
monthGrid,
weekDays,
bucketByDay,
dayKey,
isSameDay,
addDays,
addMonths,
startOfMonth,
startOfWeek,
endOfWeek,
startOfDay,
endOfDay,
remindAt,
describeRemindBefore
} from '../lib/calendar';
import {
cellLunarLabel,
formatSolarWithLunar,
describeRecurrenceRule,
isLunarRecurrence
} from '../lib/lunar';
import { useIsNarrow } from '../hooks/useIsNarrow';
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlusIcon,
SpinnerIcon,
DownloadIcon,
UploadIcon,
BellIcon,
RepeatIcon,
BotIcon,
PauseIcon,
UsersIcon,
CloseIcon
} from './icons';
type Scale = 'month' | 'week' | 'day';
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
/**
* 日历主视图。
*
* **布局与全站一致:内容区自己再分两栏。**
*
* 之前这里是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
* 而点「新建」时编辑器**顶掉**整个日历,人失去了正在看的那个月的上下文。
* 两个问题同源:日历没有「详情栏」这个位置。
*
* 现在左边是网格、右边是常驻面板:默认显示选中那天的日程(所以永远不空),
* 新建/编辑时同一个位置变成编辑器 —— 与「新建邮件是右侧整页」同一套语言。
*
* 窄屏放不下两栏退回覆盖式NarrowStack与收件箱的行为一致。
*/
export default function CalendarView() {
const narrow = useIsNarrow();
const [scale, setScale] = useState<Scale>('month');
const [anchor, setAnchor] = useState(() => new Date());
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
// 右栏三态:编辑既有事件 / 新建(带预填时间)/ 看某天的日程
const [editing, setEditing] = useState<CalendarEvent | null>(null);
const [creating, setCreating] = useState<Date | null>(null);
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
// 窄屏下右栏是否已滑入。宽屏恒为 false两栏并排不需要覆盖
const [paneOpen, setPaneOpen] = useState(false);
const [importing, setImporting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
/**
* 查询区间一律按月视图的 42 格算(含邻月首尾几天)。
*
* 三种粒度共用同一份数据:切 scale 不必重新请求,也不会出现
* 「周视图跨月时后半周空白」。
*/
const range = useMemo(() => {
const cells = monthGrid(anchor);
return {
from: startOfDay(cells[0]).toISOString(),
to: endOfDay(cells[cells.length - 1]).toISOString()
};
}, [anchor]);
async function load() {
setErr('');
try {
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
setEvents(r.events ?? []);
} catch (e: any) {
setErr(e?.message || '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
setLoading(true);
load();
}, [range.from, range.to]);
const byDay = useMemo(() => bucketByDay(events), [events]);
function shift(dir: 1 | -1) {
if (scale === 'month') setAnchor(a => addMonths(a, dir));
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
else setAnchor(a => addDays(a, dir));
}
function goToday() {
const now = new Date();
setAnchor(now);
setSelectedDay(now);
}
/** 点某天:宽屏只换右栏内容,窄屏滑入右栏。 */
function pickDay(d: Date) {
setSelectedDay(d);
setEditing(null);
setCreating(null);
if (narrow) setPaneOpen(true);
}
function pickEvent(e: CalendarEvent) {
setEditing(e);
setCreating(null);
if (narrow) setPaneOpen(true);
}
function startCreate(at: Date) {
setCreating(at);
setEditing(null);
setSelectedDay(at);
if (narrow) setPaneOpen(true);
}
function closePane() {
setEditing(null);
setCreating(null);
setPaneOpen(false);
}
const title = useMemo(() => {
if (scale === 'month') return `${anchor.getFullYear()}${anchor.getMonth() + 1}`;
if (scale === 'week') {
const a = startOfWeek(anchor);
const b = endOfWeek(anchor);
// 跨月时两头都写月份否则「9月28日 - 4日」看不出后者是十月
if (a.getMonth() === b.getMonth()) {
return `${a.getFullYear()}${a.getMonth() + 1}${a.getDate()}${b.getDate()}`;
}
return `${a.getMonth() + 1}.${a.getDate()} ${b.getMonth() + 1}.${b.getDate()}`;
}
return `${anchor.getFullYear()}${anchor.getMonth() + 1}${anchor.getDate()} 日 周${
WEEKDAY_LABELS[(anchor.getDay() + 6) % 7]
}`;
}, [scale, anchor]);
async function doExport() {
try {
const ics = await api.exportCalendarICS(range.from, range.to);
// Blob 下载而不是导航:导航会丢掉 cookie 之外的认证头
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
const a = document.createElement('a');
a.href = url;
a.download = `agentmail-${dayKey(anchor)}.ics`;
a.click();
URL.revokeObjectURL(url);
} catch (e: any) {
setErr(e?.message || '导出失败');
}
}
async function doImport(f: File) {
setImporting(true);
setErr('');
try {
const r = await api.importCalendarICS(await f.text());
await load();
setErr(`已导入 ${r.imported} 个事件${r.skipped ? `,跳过 ${r.skipped}` : ''}`);
} catch (e: any) {
setErr(e?.message || '导入失败');
} finally {
setImporting(false);
if (fileRef.current) fileRef.current.value = '';
}
}
// ─── 左栏:工具条 + 网格 ───
const gridPane = (
<div className="flex-1 min-w-0 flex flex-col bg-white min-h-0">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 shrink-0 flex-wrap">
<div className="flex items-center gap-1">
<button
onClick={() => shift(-1)}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="上一页"
>
<ChevronLeftIcon />
</button>
<button
onClick={() => shift(1)}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="下一页"
>
<ChevronRightIcon />
</button>
<button
onClick={goToday}
className="px-2.5 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700"
>
</button>
</div>
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-900 min-w-0">
<CalendarIcon className="w-4 h-4 text-gray-400 shrink-0" />
<span className="truncate">{title}</span>
</div>
<div className="ml-auto flex items-center gap-1.5">
<div className="flex rounded border border-gray-300 overflow-hidden">
{(['month', 'week', 'day'] as Scale[]).map(s => (
<button
key={s}
onClick={() => setScale(s)}
className={`px-2.5 py-1 text-xs ${
scale === s ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
{s === 'month' ? '月' : s === 'week' ? '周' : '日'}
</button>
))}
</div>
<button
onClick={doExport}
title="导出 .ics"
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="导出"
>
<DownloadIcon />
</button>
<button
onClick={() => fileRef.current?.click()}
title="导入 .ics"
disabled={importing}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-40"
aria-label="导入"
>
{importing ? <SpinnerIcon className="w-4 h-4 animate-spin" /> : <UploadIcon />}
</button>
<input
ref={fileRef}
type="file"
accept=".ics,text/calendar"
className="hidden"
onChange={e => {
const f = e.target.files?.[0];
if (f) doImport(f);
}}
/>
<button
onClick={() => startCreate(atNineOClock(selectedDay))}
className="px-2.5 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 flex items-center gap-1"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
{err && (
<div className="px-3 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-800 shrink-0">
{err}
</div>
)}
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400">
<SpinnerIcon className="w-6 h-6 animate-spin" />
</div>
) : scale === 'month' ? (
<MonthGrid
anchor={anchor}
selectedDay={selectedDay}
byDay={byDay}
onPickDay={pickDay}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
) : scale === 'week' ? (
<WeekGrid
anchor={anchor}
selectedDay={selectedDay}
byDay={byDay}
onPickDay={pickDay}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
) : (
<DayGrid
anchor={anchor}
events={byDay.get(dayKey(anchor)) ?? []}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
)}
</div>
);
// ─── 右栏:编辑器 / 当日日程 ───
const sidePane =
editing || creating ? (
<CalendarEventEditor
event={editing}
initialTime={creating ?? undefined}
onClose={closePane}
onSaved={load}
/>
) : (
<DayAgendaPane
day={selectedDay}
events={byDay.get(dayKey(selectedDay)) ?? []}
onPickEvent={pickEvent}
onCreate={() => startCreate(atNineOClock(selectedDay))}
onClose={narrow ? closePane : undefined}
/>
);
// 窄屏:右栏覆盖在网格上,滑入滑出(与收件箱详情同一套动画)
if (narrow) {
return <NarrowStack base={gridPane} overlay={sidePane} open={paneOpen} />;
}
return (
<div className="flex-1 min-w-0 flex min-h-0">
{gridPane}
<div className="w-full md:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
{sidePane}
</div>
</div>
);
}
/** 新建事件的默认时刻:那天上午 9 点,比「现在」有用得多。 */
function atNineOClock(d: Date): Date {
const x = new Date(d);
x.setHours(9, 0, 0, 0);
return x;
}
function hhmm(iso: string): string {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return '';
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
}
/** 收件人数量标记。多收件人是常态,格子里得看得出来。 */
function recipientCount(e: CalendarEvent): number {
if (e.recipients?.length) return e.recipients.length;
return e.to_address || e.agent_name ? 1 : 0;
}
/** 事件在格子里的小色条。 */
function EventChip({
e,
onClick,
showTime = true
}: {
e: CalendarEvent;
onClick: () => void;
showTime?: boolean;
}) {
// 暂停/取消的事件不会触发提醒,视觉上必须与生效的区分开 ——
// 否则人以为设好了,实际到点什么都不会发生
const dead = e.status !== 'active';
const n = recipientCount(e);
return (
<button
onClick={ev => {
ev.stopPropagation();
onClick();
}}
title={`${e.title}${n > 1 ? `${n} 个收件人)` : ''}`}
className={`w-full text-left px-1.5 py-0.5 rounded text-xs truncate flex items-center gap-1 ${
dead
? 'bg-gray-100 text-gray-400 line-through'
: 'bg-blue-50 text-blue-800 hover:bg-blue-100'
}`}
>
{dead && <PauseIcon className="w-3 h-3 shrink-0" />}
{showTime && <span className="tabular-nums shrink-0 opacity-70">{hhmm(e.event_time)}</span>}
<span className="truncate">{e.title}</span>
{n > 1 && <span className="shrink-0 opacity-70 tabular-nums">·{n}</span>}
</button>
);
}
function MonthGrid({
anchor,
selectedDay,
byDay,
onPickDay,
onPickEvent,
onCreateAt
}: {
anchor: Date;
selectedDay: Date;
byDay: Map<string, CalendarEvent[]>;
onPickDay: (d: Date) => void;
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const cells = useMemo(() => monthGrid(anchor), [anchor]);
const now = new Date();
const curMonth = startOfMonth(anchor).getMonth();
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
<div className="grid grid-cols-7 border-b border-gray-200 shrink-0 sticky top-0 bg-white z-10">
{WEEKDAY_LABELS.map(w => (
<div key={w} className="px-2 py-1.5 text-xs font-medium text-gray-500 text-center">
{w}
</div>
))}
</div>
<div className="grid grid-cols-7 grid-rows-6 flex-1 min-h-[30rem]">
{cells.map((d, i) => {
const list = byDay.get(dayKey(d)) ?? [];
const outside = d.getMonth() !== curMonth;
const isToday = isSameDay(d, now);
const isPicked = isSameDay(d, selectedDay);
return (
<div
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${
isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white'
}`}
>
<div className="flex items-baseline gap-1 shrink-0">
<span
className={`px-1.5 rounded text-xs tabular-nums ${
isToday
? 'bg-blue-600 text-white font-medium'
: outside
? 'text-gray-400'
: 'text-gray-700'
}`}
>
{d.getDate()}
</span>
{/* 农历日必须显示:农历重复规则的公历日期每次都在变,
不显示农历人无法确认「每月十五」到底落在哪一格 */}
<span
className={`text-[10px] leading-none truncate ${
outside ? 'text-gray-300' : 'text-gray-400'
}`}
>
{cellLunarLabel(d)}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-hidden">
{list.slice(0, 3).map(e => (
<EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
{list.length > 3 && (
<span className="px-1.5 text-xs text-gray-500"> {list.length - 3} </span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function WeekGrid({
anchor,
selectedDay,
byDay,
onPickDay,
onPickEvent,
onCreateAt
}: {
anchor: Date;
selectedDay: Date;
byDay: Map<string, CalendarEvent[]>;
onPickDay: (d: Date) => void;
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const days = useMemo(() => weekDays(anchor), [anchor]);
const now = new Date();
return (
<div className="flex-1 min-h-0 overflow-auto">
<div className="grid grid-cols-7 min-w-[36rem] h-full">
{days.map((d, i) => {
const list = byDay.get(dayKey(d)) ?? [];
const isToday = isSameDay(d, now);
const isPicked = isSameDay(d, selectedDay);
return (
<div
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
isPicked ? 'bg-blue-50/50' : ''
}`}
>
<div
className={`px-2 py-1.5 border-b border-gray-200 sticky top-0 z-10 ${
isToday ? 'bg-blue-50' : isPicked ? 'bg-blue-50/70' : 'bg-white'
}`}
>
<div className="text-xs text-gray-500">{WEEKDAY_LABELS[i]}</div>
<div
className={`text-sm tabular-nums ${
isToday ? 'text-blue-700 font-medium' : 'text-gray-900'
}`}
>
{d.getMonth() + 1}.{d.getDate()}
</div>
<div className="text-[10px] text-gray-400 leading-none">{cellLunarLabel(d)}</div>
</div>
<div className="flex-1 p-1 flex flex-col gap-1">
{list.length === 0 ? (
<div className="text-xs text-gray-300 px-1 py-2"></div>
) : (
list.map(e => <EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />)
)}
</div>
</div>
);
})}
</div>
</div>
);
}
/** 日视图:按小时排。空的小时折叠成细条,否则 24 行等高会把有内容的挤出屏幕。 */
function DayGrid({
anchor,
events,
onPickEvent,
onCreateAt
}: {
anchor: Date;
events: CalendarEvent[];
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const byHour = useMemo(() => {
const m = new Map<number, CalendarEvent[]>();
for (const e of events) {
const t = new Date(e.event_time);
if (Number.isNaN(t.getTime())) continue;
const h = t.getHours();
const b = m.get(h);
if (b) b.push(e);
else m.set(h, [e]);
}
return m;
}, [events]);
const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
{formatSolarWithLunar(anchor)}
</div>
<div className="divide-y divide-gray-100">
{HOURS.map(h => {
const list = byHour.get(h) ?? [];
const at = new Date(anchor);
at.setHours(h, 0, 0, 0);
return (
<div
key={h}
onDoubleClick={() => onCreateAt(at)}
className={`flex gap-3 px-3 ${list.length ? 'py-2' : 'py-1 hover:bg-gray-50'} ${
h === nowHour ? 'bg-blue-50/40' : ''
}`}
>
<span
className={`w-10 shrink-0 text-xs tabular-nums ${
list.length ? 'text-gray-500 pt-1' : 'text-gray-400'
}`}
>
{String(h).padStart(2, '0')}:00
</span>
{list.length === 0 ? (
<span className="text-xs text-gray-200"></span>
) : (
<div className="flex-1 min-w-0 flex flex-col gap-2">
{list.map(e => (
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/**
* 右栏默认内容:选中那天的日程。
*
* 这一栏的存在本身就是为了「宽屏右边不空着」—— 所以它必须在**没有**
* 任何事件时也有话说(提示怎么新建),而不是渲染一片空白。
*/
function DayAgendaPane({
day,
events,
onPickEvent,
onCreate,
onClose
}: {
day: Date;
events: CalendarEvent[];
onPickEvent: (e: CalendarEvent) => void;
onCreate: () => void;
onClose?: () => void;
}) {
const isToday = isSameDay(day, new Date());
return (
<div className="flex flex-col h-full min-h-0 bg-white">
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-base font-medium text-gray-900">
{day.getMonth() + 1} {day.getDate()}
</h2>
<span className="text-xs text-gray-500">
{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
</span>
{isToday && (
<span className="px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded"></span>
)}
</div>
<p className="mt-0.5 text-xs text-gray-500">{formatSolarWithLunar(day)}</p>
</div>
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 shrink-0"
aria-label="返回日历"
>
<CloseIcon />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
{events.length === 0 ? (
<div className="text-sm text-gray-500">
<p></p>
<p className="mt-1 text-xs text-gray-400">
</p>
</div>
) : (
<div className="flex flex-col gap-2">
{events.map(e => (
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
</div>
)}
</div>
<div className="px-4 py-3 border-t border-gray-200 shrink-0">
<button
onClick={onCreate}
className="w-full px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 flex items-center justify-center gap-1.5"
>
<PlusIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}
/** 完整事件行:收件方 / 提醒时刻 / 重复规则都写出来。 */
function EventRow({ e, onClick }: { e: CalendarEvent; onClick: () => void }) {
const t = new Date(e.event_time);
const rt = remindAt(e);
const dead = e.status !== 'active';
const recips = e.recipients?.length ? e.recipients : [e.to_address || e.agent_name].filter(Boolean);
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2 rounded border ${
dead
? 'bg-gray-50 border-gray-200'
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-blue-50/30'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 tabular-nums shrink-0">{hhmm(e.event_time)}</span>
<span
className={`text-sm font-medium truncate ${
dead ? 'text-gray-400 line-through' : 'text-gray-900'
}`}
>
{e.title}
</span>
{dead && (
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded shrink-0">
{e.status === 'paused' ? '已暂停' : '已取消'}
</span>
)}
</div>
{e.description && (
<p className="text-xs text-gray-600 mb-1.5 line-clamp-2">{e.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
{recips.length > 0 && (
<span className="flex items-center gap-1 min-w-0">
{recips.length > 1 ? (
<UsersIcon className="w-3 h-3 shrink-0" />
) : (
<BotIcon className="w-3 h-3 shrink-0" />
)}
<span className="truncate">
{recips.length > 1
? `${recips.length} 人 · ${e.delivery_mode === 'together' ? '同一线索' : '各自独立'}`
: recips[0]}
</span>
</span>
)}
<span className="flex items-center gap-1">
<BellIcon className="w-3 h-3" />
{describeRemindBefore(e.remind_before)}
{e.remind_before > 0 && (
<span className="tabular-nums opacity-70">
{String(rt.getHours()).padStart(2, '0')}:{String(rt.getMinutes()).padStart(2, '0')}
</span>
)}
</span>
{e.recurrence !== 'none' && (
<span
className={`flex items-center gap-1 ${
isLunarRecurrence(e.recurrence) ? 'text-amber-700' : ''
}`}
>
<RepeatIcon className="w-3 h-3" />
{describeRecurrenceRule(e.recurrence, Number.isNaN(t.getTime()) ? undefined : t)}
</span>
)}
</div>
</button>
);
}

208
web/src/lib/calendar.ts Normal file
View File

@ -0,0 +1,208 @@
import type { CalendarEvent } from '../types';
/**
* 日历的日期计算与事件分桶。
*
* 全部是纯函数,跟 React 无关,因此能单独测。月视图的格子、事件落到哪一天、
* 提醒的实际触发时刻 —— 这些算错了没有任何报错,只是提醒发在错误的时间。
*
* 时区一律用**本地时区**日历是给人看的人说「9 月 3 日」指的是自己那天。
* 与后端交互时才转 ISO`toISOString()` 给 UTC后端存 UTC
*/
/** 一天的开始(本地时区 00:00:00.000)。 */
export function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
/** 一天的结束(本地时区 23:59:59.999)。 */
export function endOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
/** 月首(本地时区)。 */
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
/** 月末最后一刻。 */
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
/**
* 周首。
*
* 中文语境下一周从**周一**开始,而 `getDay()` 把周日算作 0 ——
* 直接减 `getDay()` 会让周日被归到上一周的末尾,月视图第一行就错位。
*/
export function startOfWeek(d: Date): Date {
const x = startOfDay(d);
const dow = x.getDay(); // 0=周日
const diff = dow === 0 ? 6 : dow - 1;
x.setDate(x.getDate() - diff);
return x;
}
export function endOfWeek(d: Date): Date {
const x = startOfWeek(d);
x.setDate(x.getDate() + 6);
return endOfDay(x);
}
/** 同一天?(本地时区,只比年月日) */
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** 加天数,返回新对象(不修改入参)。 */
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function addMonths(d: Date, n: number): Date {
// 先归到 1 号再加月:从 1 月 31 日加一个月setMonth 会溢出到 3 月 2/3 日
return new Date(d.getFullYear(), d.getMonth() + n, 1);
}
/**
* 月视图的 42 个格子6 行 × 7 列)。
*
* 固定 6 行而不是按需 4~6 行:行数变化会让整个网格高度跳动,
* 翻月时页面内容上下弹。多出来的格子显示邻月日期并置灰。
*/
export function monthGrid(anchor: Date): Date[] {
const first = startOfWeek(startOfMonth(anchor));
const cells: Date[] = [];
for (let i = 0; i < 42; i++) cells.push(addDays(first, i));
return cells;
}
/** 周视图的 7 天。 */
export function weekDays(anchor: Date): Date[] {
const first = startOfWeek(anchor);
return Array.from({ length: 7 }, (_, i) => addDays(first, i));
}
/**
* 事件的实际提醒时刻 = 事件时间 remind_before 分钟。
*
* 这是调度器真正比较的那个时间点。UI 上要显示它,否则人设了「提前 30 分钟」
* 却在事件时间那一刻才反应过来 —— 提醒早就发出去了。
*/
export function remindAt(e: CalendarEvent): Date {
const t = new Date(e.event_time).getTime();
return new Date(t - (e.remind_before || 0) * 60_000);
}
/** 事件时间解析失败时给 epoch 0 而不是 NaNNaN 参与排序会让顺序不确定)。 */
function eventTime(e: CalendarEvent): number {
const t = new Date(e.event_time).getTime();
return Number.isNaN(t) ? 0 : t;
}
/**
* 把事件按天分桶,键是 `YYYY-MM-DD`(本地时区)。
*
* 用本地日期串而不是 ISO 前缀切片:`toISOString().slice(0,10)` 给的是 UTC 日期,
* 东八区晚上 8 点之后的事件会被归到**第二天**的格子里。
*/
export function bucketByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.event_time);
if (Number.isNaN(d.getTime())) continue; // 脏数据不该让整个视图空白
const key = dayKey(d);
const bucket = map.get(key);
if (bucket) bucket.push(e);
else map.set(key, [e]);
}
// 同一天内按时间正序:日历格子里人从上往下读就是时间顺序
for (const list of map.values()) {
list.sort((a, b) => eventTime(a) - eventTime(b) || a.event_id.localeCompare(b.event_id));
}
return map;
}
/** 本地时区的 `YYYY-MM-DD`。 */
export function dayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
/**
* 渲染提醒正文:把 `{title}` `{time}` `{description}` 替换成实际值。
*
* 必须与后端 `scheduler.RenderReminder` 的行为一致 —— 前端预览显示的
* 与实际发出的不是一个东西,那比不预览更糟。变量名两处都是硬编码的字面量,
* 改动时要同步。
*/
export function renderReminder(tpl: string, e: { title: string; description?: string; event_time: string }): string {
const t = new Date(e.event_time);
const time = Number.isNaN(t.getTime())
? e.event_time
: `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(
t.getDate()
).padStart(2, '0')} ${String(t.getHours()).padStart(2, '0')}:${String(
t.getMinutes()
).padStart(2, '0')}`;
// 用 split/join 而不是 replaceAlltsconfig 的 target 是 ES2020
// replaceAll 在那个 lib 里不存在TS2550。也不能用 replace ——
// 它只换第一个,同一变量写两次时第二个会原样漏到邮件里。
const sub = (s: string, from: string, to: string) => s.split(from).join(to);
return sub(sub(sub(tpl, '{title}', e.title || ''), '{time}', time), '{description}', e.description || '');
}
/** `<input type="datetime-local">` 要的格式:本地时区的 `YYYY-MM-DDTHH:mm`。 */
export function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
/**
* `datetime-local` 的值转 ISO给后端
*
* `new Date('2026-09-03T14:30')` 按**本地时区**解析(无 Z 后缀),
* 这正是想要的:人在输入框里写的就是本地时间。
*/
export function fromLocalInput(v: string): string {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? '' : d.toISOString();
}
/** 人类可读的重复规则。 */
export function describeRecurrence(e: CalendarEvent): string {
const map: Record<string, string> = {
none: '不重复',
daily: '每天',
weekly: '每周',
monthly: '每月'
};
const base = map[e.recurrence] || '不重复';
if (e.recurrence === 'none' || !e.recurrence_end) return base;
const end = new Date(e.recurrence_end);
if (Number.isNaN(end.getTime())) return base;
return `${base},至 ${dayKey(end)}`;
}
/** 提前提醒的人类描述。 */
export function describeRemindBefore(min: number): string {
if (!min) return '到点提醒';
if (min < 60) return `提前 ${min} 分钟`;
if (min % 60 === 0) return `提前 ${min / 60} 小时`;
return `提前 ${Math.floor(min / 60)} 小时 ${min % 60} 分钟`;
}

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';
}

View File

@ -0,0 +1,281 @@
import { describe, it, expect } from 'vitest';
import {
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
describeRecurrence, describeRemindBefore
} from '../../src/lib/calendar';
import type { CalendarEvent } from '../../src/types';
let seq = 0;
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
seq += 1;
return {
event_id: `e${String(seq).padStart(3, '0')}`,
title: `事件 ${seq}`,
description: '',
reminder_text: '',
agent_name: 'pi',
to_address: '',
recipients: [],
delivery_mode: 'separate',
event_time: '2026-09-03T14:30:00+08:00',
remind_before: 0,
recurrence: 'none',
status: 'active',
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
created_by: 'jianf',
...over
};
}
describe('日期边界', () => {
it('startOfDay / endOfDay 用本地时区', () => {
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
expect(startOfDay(d).getHours()).toBe(0);
expect(startOfDay(d).getMilliseconds()).toBe(0);
expect(endOfDay(d).getHours()).toBe(23);
expect(endOfDay(d).getMilliseconds()).toBe(999);
// 不修改入参
expect(d.getHours()).toBe(14);
});
it('startOfMonth / endOfMonth', () => {
const d = new Date(2026, 8, 15);
expect(startOfMonth(d).getDate()).toBe(1);
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
});
it('周从周一开始,周日归到上一周末尾', () => {
// 2026-09-06 是周日
const sunday = new Date(2026, 8, 6);
expect(sunday.getDay()).toBe(0);
// 它所在周的周一应是 08-31不是 09-07
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
});
it('周一自己就是周首', () => {
const monday = new Date(2026, 8, 7);
expect(monday.getDay()).toBe(1);
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
});
});
describe('日期运算', () => {
it('addDays 不修改入参', () => {
const d = new Date(2026, 8, 3);
const r = addDays(d, 5);
expect(dayKey(r)).toBe('2026-09-08');
expect(dayKey(d)).toBe('2026-09-03');
});
it('addDays 跨月', () => {
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
});
it('addMonths 从月末加一个月不会溢出', () => {
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
const jan31 = new Date(2026, 0, 31);
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
});
it('isSameDay 只比年月日', () => {
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
});
});
describe('monthGrid', () => {
it('固定 42 格6 行 × 7 列)', () => {
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
});
it('首格是月首所在周的周一', () => {
// 2026-09-01 是周二 → 首格应是 08-31周一
const cells = monthGrid(new Date(2026, 8, 15));
expect(dayKey(cells[0])).toBe('2026-08-31');
});
it('格子连续无空洞', () => {
const cells = monthGrid(new Date(2026, 8, 15));
for (let i = 1; i < cells.length; i++) {
const diff = cells[i].getTime() - cells[i - 1].getTime();
// 允许夏令时造成的 ±1 小时偏差
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
}
});
it('包含整个当月', () => {
const anchor = new Date(2026, 8, 15);
const keys = monthGrid(anchor).map(dayKey);
expect(keys).toContain('2026-09-01');
expect(keys).toContain('2026-09-30');
});
});
describe('weekDays', () => {
it('给 7 天,周一起头', () => {
const days = weekDays(new Date(2026, 8, 3)); // 周四
expect(days).toHaveLength(7);
expect(dayKey(days[0])).toBe('2026-08-31');
expect(dayKey(days[6])).toBe('2026-09-06');
});
});
describe('remindAt', () => {
it('提前 30 分钟', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
});
it('remind_before 为 0 时就是事件时间', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
});
it('提前一整天', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
});
});
describe('bucketByDay', () => {
it('按本地日期分桶,不用 UTC 前缀', () => {
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
const m = bucketByDay([e]);
expect([...m.keys()]).toEqual(['2026-09-03']);
});
it('同一天内按时间正序', () => {
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
const m = bucketByDay([late, early]);
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
});
it('同刻事件用 event_id 兜底定序', () => {
const ts = '2026-09-03T09:00:00+08:00';
const a = ev({ event_id: 'aaa', event_time: ts });
const z = ev({ event_id: 'zzz', event_time: ts });
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
expect(m1).toEqual(m2);
expect(m1).toEqual(['aaa', 'zzz']);
});
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
const bad = ev({ event_time: '不是时间' });
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
const m = bucketByDay([bad, good]);
expect([...m.keys()]).toEqual(['2026-09-03']);
});
it('空输入给空 Map', () => {
expect(bucketByDay([]).size).toBe(0);
});
});
describe('renderReminder', () => {
it('替换三个变量', () => {
const out = renderReminder('【{title}】{time} — {description}', {
title: '发布评审',
description: '看 llmsproxy 的部署脚本',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
});
it('同一变量出现多次全部替换', () => {
// replaceAll 而非 replace后者只换第一个
const out = renderReminder('{title} / {title}', {
title: 'X',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('X / X');
});
it('description 缺失时替换成空串而不是 undefined', () => {
const out = renderReminder('{description}|', {
title: 'T',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('|');
});
it('没有变量的模板原样返回', () => {
const out = renderReminder('纯文本提醒', {
title: 'T',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('纯文本提醒');
});
it('时间解析失败时回退到原始串', () => {
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
expect(out).toBe('坏时间');
});
});
describe('datetime-local 往返', () => {
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
const d = new Date(2026, 8, 3, 14, 30);
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
});
it('补零', () => {
const d = new Date(2026, 0, 5, 9, 5);
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
});
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
const iso = fromLocalInput('2026-09-03T14:30');
// 结果应与本地构造的 Date 一致
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
});
it('往返不丢分钟', () => {
const original = new Date(2026, 8, 3, 14, 30);
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
});
it('非法输入给空串', () => {
expect(fromLocalInput('')).toBe('');
expect(fromLocalInput('坏值')).toBe('');
});
});
describe('人类可读描述', () => {
it('重复规则', () => {
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
});
it('带终止时间', () => {
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
});
it('none 时忽略 recurrence_end', () => {
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
expect(describeRecurrence(e)).toBe('不重复');
});
it('提前提醒', () => {
expect(describeRemindBefore(0)).toBe('到点提醒');
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
expect(describeRemindBefore(60)).toBe('提前 1 小时');
expect(describeRemindBefore(120)).toBe('提前 2 小时');
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
});
});