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