import { useEffect, useMemo, useRef, useState } from 'react'; import * as api from '../api/client'; import type { CalendarEvent, CalendarEventInput, CalendarAttachment, Recurrence, DeliveryMode } from '../types'; import AddressInput from './AddressInput'; import { toLocalInput, fromLocalInput, renderReminder, describeRemindBefore } from '../lib/calendar'; import { upcomingOccurrences, formatSolarWithLunar, isLunarRecurrence, describeRecurrenceRule } from '../lib/lunar'; import { CloseIcon, SpinnerIcon, TrashIcon, BellIcon, RepeatIcon, PaperclipIcon, FileIcon, PlusIcon, UsersIcon, BotIcon } from './icons'; /** * 事件编辑器(新建 / 编辑共用)。 * * 参照 Outlook 的编辑面板:时间与重复在上、收件方居中、提醒正文在下。 * 提醒正文可编辑且带 `{title}` `{time}` `{description}` 变量 —— 预览实时渲染, * 因为「模板里写了什么」和「Agent 收到什么」不是一个东西, * 不给预览的话人只能发一次试试看。 */ /** 提前提醒的预设档位。手打分钟数容易写出档位外的值,但也允许。 */ const PRESETS = [0, 5, 15, 30, 60, 120, 1440]; const DEFAULT_TEMPLATE = '日程提醒:{title}\n时间:{time}\n{description}'; /** 重复规则选项。农历单独一组:它们的公历日期每次都在漂移。 */ const RECURRENCE_GROUPS: { label: string; items: { value: Recurrence; label: string }[] }[] = [ { label: '公历', items: [ { value: 'none', label: '不重复' }, { value: 'daily', label: '每天' }, { value: 'weekly', label: '每周' }, { value: 'monthly', label: '每月(同一日)' }, { value: 'yearly', label: '每年(同月日)' } ] }, { label: '农历', items: [ { value: 'lunar_monthly', label: '每农历月(同一日,如每月十五)' }, { value: 'lunar_yearly', label: '每农历年(同月日,如农历生日)' } ] } ]; export default function CalendarEventEditor({ event, initialTime, onClose, onSaved }: { /** 有值 = 编辑,无值 = 新建 */ event?: CalendarEvent | null; /** 新建时的预填时间(点空白格子建事件) */ initialTime?: Date; onClose: () => void; onSaved: () => void; }) { const editing = !!event; const [title, setTitle] = useState(event?.title ?? ''); const [description, setDescription] = useState(event?.description ?? ''); const [reminderText, setReminderText] = useState(event?.reminder_text ?? ''); /** * 收件人列表。 * * 初值兼容旧数据:recipients 为空时退回 to_address / agent_name —— * 与后端 EffectiveRecipients() 同一条兜底链。不做这个归一化的话, * 编辑一条老事件再保存会把它的收件人清空(列表是空的,保存就覆盖了)。 */ const [recipients, setRecipients] = useState(() => { if (event?.recipients?.length) return event.recipients; const legacy = (event?.to_address || event?.agent_name || '').trim(); return legacy ? [legacy] : []; }); const [draftAddr, setDraftAddr] = useState(''); const [deliveryMode, setDeliveryMode] = useState( event?.delivery_mode === 'together' ? 'together' : 'separate' ); const [eventTime, setEventTime] = useState(() => { if (event?.event_time) return toLocalInput(new Date(event.event_time)); if (initialTime) return toLocalInput(initialTime); // 默认下一个整点:现在这一刻当默认值几乎总要改 const d = new Date(); d.setHours(d.getHours() + 1, 0, 0, 0); return toLocalInput(d); }); const [remindBefore, setRemindBefore] = useState(event?.remind_before ?? 0); const [recurrence, setRecurrence] = useState(event?.recurrence ?? 'none'); const [recurrenceEnd, setRecurrenceEnd] = useState( event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : '' ); const [status, setStatus] = useState(event?.status ?? 'active'); const [agents, setAgents] = useState([]); const [atts, setAtts] = useState([]); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [err, setErr] = useState(''); const [confirmDelete, setConfirmDelete] = useState(false); // Agent 列表用于快捷添加。拉不到不算错 —— 地址仍可手输(三段式补全独立工作)。 useEffect(() => { api .listAgents() .then(r => setAgents((r.agents ?? []).map(a => a.agent_name))) .catch(() => setAgents([])); }, []); // 附件只在编辑既有事件时才有:新建时还没有 event_id 可挂 useEffect(() => { if (!event?.event_id) return; api .listCalendarAttachments(event.event_id) .then(r => setAtts(r.attachments ?? [])) .catch(() => setAtts([])); }, [event?.event_id]); function addRecipient(addr: string) { const v = addr.trim(); if (!v) return; // 去重:together 模式下同一个 Agent 既主收又抄送会收到两条 SSE, // 插件可能因此起两轮 if (recipients.includes(v)) { setDraftAddr(''); return; } setRecipients(prev => [...prev, v]); setDraftAddr(''); } function removeRecipient(addr: string) { setRecipients(prev => prev.filter(x => x !== addr)); } /** 上移一位。together 模式下第一个是主收件人,顺序有语义。 */ function moveUp(i: number) { if (i <= 0) return; setRecipients(prev => { const next = [...prev]; [next[i - 1], next[i]] = [next[i], next[i - 1]]; return next; }); } const preview = useMemo(() => { const tpl = reminderText.trim() || DEFAULT_TEMPLATE; return renderReminder(tpl, { title: title || '(未填标题)', description, event_time: fromLocalInput(eventTime) || new Date().toISOString() }); }, [reminderText, title, description, eventTime]); /** * 接下来三次触发。 * * 农历规则必须给这个预览:规则名(「每农历月廿二」)看不出公历日子, * 而公历日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。 */ const upcoming = useMemo(() => { if (recurrence === 'none') return []; const iso = fromLocalInput(eventTime); if (!iso) return []; return upcomingOccurrences(recurrence, new Date(iso), 3); }, [recurrence, eventTime]); async function addFiles(files: FileList | null) { if (!files?.length || !event?.event_id) return; setUploading(true); setErr(''); try { // 逐个传而非并发:并发失败时分不清是哪个文件的问题 for (const f of Array.from(files)) { const a = await api.uploadCalendarAttachment(event.event_id, f); setAtts(prev => [...prev, a]); } } catch (e: any) { setErr(e?.message || '上传失败'); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ''; } } async function dropAttachment(id: string) { try { await api.deleteCalendarAttachment(id); setAtts(prev => prev.filter(a => a.attachment_id !== id)); } catch (e: any) { setErr(e?.message || '删除附件失败'); } } // 收件人为空时不能保存:事件永远发不出去,后端也会 400。 // 在这里就禁用按钮比让人点了再看报错好。 const canSave = title.trim() !== '' && eventTime !== '' && recipients.length > 0 && !saving; async function save() { if (!canSave) return; setErr(''); setSaving(true); const iso = fromLocalInput(eventTime); if (!iso) { setErr('事件时间无效'); setSaving(false); return; } // recurrence 为 none 时清掉终止时间:留着它只会让后续编辑困惑 const endIso = recurrence === 'none' || !recurrenceEnd ? null : fromLocalInput(recurrenceEnd); const payload: CalendarEventInput = { title: title.trim(), description, reminder_text: reminderText.trim(), recipients, delivery_mode: deliveryMode, // 旧字段保持与列表首项一致:第三方客户端(与旧版前端)只读 to_address to_address: recipients[0] ?? '', event_time: iso, remind_before: remindBefore, recurrence, recurrence_end: endIso, status }; try { if (editing && event) await api.updateCalendarEvent(event.event_id, payload); else await api.createCalendarEvent(payload); onSaved(); onClose(); } catch (e: any) { setErr(e?.message || '保存失败'); } finally { setSaving(false); } } async function remove() { if (!event) return; setDeleting(true); try { await api.deleteCalendarEvent(event.event_id); onSaved(); onClose(); } catch (e: any) { setErr(e?.message || '删除失败'); setDeleting(false); } } const unusedAgents = agents.filter(a => !recipients.includes(a)); return (

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

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