Files
MailUI4Agents/client/electron/src/components/CalendarEventEditor.tsx

695 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 flex-wrap">
<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="w-full sm:w-auto sm:ml-auto">
{confirmDelete ? (
<div className="flex items-center gap-2 flex-wrap">
<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>
);
}