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}`
|
||||
);
|
||||
}
|
||||
|
||||
694
web/src/components/CalendarEventEditor.tsx
Normal file
694
web/src/components/CalendarEventEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
775
web/src/components/CalendarView.tsx
Normal file
775
web/src/components/CalendarView.tsx
Normal 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
208
web/src/lib/calendar.ts
Normal 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 而不是 NaN(NaN 参与排序会让顺序不确定)。 */
|
||||
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 而不是 replaceAll:tsconfig 的 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} 分钟`;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
|
||||
281
web/test/components/calendar.test.tsx
Normal file
281
web/test/components/calendar.test.tsx
Normal 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 小时');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user