chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,694 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
import type {
CalendarEvent,
CalendarEventInput,
CalendarAttachment,
Recurrence,
DeliveryMode
} from '../types';
import AddressInput from './AddressInput';
import {
toLocalInput,
fromLocalInput,
renderReminder,
describeRemindBefore
} from '../lib/calendar';
import {
upcomingOccurrences,
formatSolarWithLunar,
isLunarRecurrence,
describeRecurrenceRule
} from '../lib/lunar';
import {
CloseIcon,
SpinnerIcon,
TrashIcon,
BellIcon,
RepeatIcon,
PaperclipIcon,
FileIcon,
PlusIcon,
UsersIcon,
BotIcon
} from './icons';
/**
* 事件编辑器(新建 / 编辑共用)。
*
* 参照 Outlook 的编辑面板:时间与重复在上、收件方居中、提醒正文在下。
* 提醒正文可编辑且带 `{title}` `{time}` `{description}` 变量 —— 预览实时渲染,
* 因为「模板里写了什么」和「Agent 收到什么」不是一个东西,
* 不给预览的话人只能发一次试试看。
*/
/** 提前提醒的预设档位。手打分钟数容易写出档位外的值,但也允许。 */
const PRESETS = [0, 5, 15, 30, 60, 120, 1440];
const DEFAULT_TEMPLATE = '日程提醒:{title}\n时间:{time}\n{description}';
/** 重复规则选项。农历单独一组:它们的公历日期每次都在漂移。 */
const RECURRENCE_GROUPS: { label: string; items: { value: Recurrence; label: string }[] }[] = [
{
label: '公历',
items: [
{ value: 'none', label: '不重复' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月(同一日)' },
{ value: 'yearly', label: '每年(同月日)' }
]
},
{
label: '农历',
items: [
{ value: 'lunar_monthly', label: '每农历月(同一日,如每月十五)' },
{ value: 'lunar_yearly', label: '每农历年(同月日,如农历生日)' }
]
}
];
export default function CalendarEventEditor({
event,
initialTime,
onClose,
onSaved
}: {
/** 有值 = 编辑,无值 = 新建 */
event?: CalendarEvent | null;
/** 新建时的预填时间(点空白格子建事件) */
initialTime?: Date;
onClose: () => void;
onSaved: () => void;
}) {
const editing = !!event;
const [title, setTitle] = useState(event?.title ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const [reminderText, setReminderText] = useState(event?.reminder_text ?? '');
/**
* 收件人列表。
*
* 初值兼容旧数据:recipients 为空时退回 to_address / agent_name ——
* 与后端 EffectiveRecipients() 同一条兜底链。不做这个归一化的话,
* 编辑一条老事件再保存会把它的收件人清空(列表是空的,保存就覆盖了)。
*/
const [recipients, setRecipients] = useState<string[]>(() => {
if (event?.recipients?.length) return event.recipients;
const legacy = (event?.to_address || event?.agent_name || '').trim();
return legacy ? [legacy] : [];
});
const [draftAddr, setDraftAddr] = useState('');
const [deliveryMode, setDeliveryMode] = useState<DeliveryMode>(
event?.delivery_mode === 'together' ? 'together' : 'separate'
);
const [eventTime, setEventTime] = useState(() => {
if (event?.event_time) return toLocalInput(new Date(event.event_time));
if (initialTime) return toLocalInput(initialTime);
// 默认下一个整点:现在这一刻当默认值几乎总要改
const d = new Date();
d.setHours(d.getHours() + 1, 0, 0, 0);
return toLocalInput(d);
});
const [remindBefore, setRemindBefore] = useState(event?.remind_before ?? 0);
const [recurrence, setRecurrence] = useState<Recurrence>(event?.recurrence ?? 'none');
const [recurrenceEnd, setRecurrenceEnd] = useState(
event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : ''
);
const [status, setStatus] = useState<CalendarEvent['status']>(event?.status ?? 'active');
const [agents, setAgents] = useState<string[]>([]);
const [atts, setAtts] = useState<CalendarAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [err, setErr] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
// Agent 列表用于快捷添加。拉不到不算错 —— 地址仍可手输(三段式补全独立工作)。
useEffect(() => {
api
.listAgents()
.then(r => setAgents((r.agents ?? []).map(a => a.agent_name)))
.catch(() => setAgents([]));
}, []);
// 附件只在编辑既有事件时才有:新建时还没有 event_id 可挂
useEffect(() => {
if (!event?.event_id) return;
api
.listCalendarAttachments(event.event_id)
.then(r => setAtts(r.attachments ?? []))
.catch(() => setAtts([]));
}, [event?.event_id]);
function addRecipient(addr: string) {
const v = addr.trim();
if (!v) return;
// 去重:together 模式下同一个 Agent 既主收又抄送会收到两条 SSE,
// 插件可能因此起两轮
if (recipients.includes(v)) {
setDraftAddr('');
return;
}
setRecipients(prev => [...prev, v]);
setDraftAddr('');
}
function removeRecipient(addr: string) {
setRecipients(prev => prev.filter(x => x !== addr));
}
/** 上移一位。together 模式下第一个是主收件人,顺序有语义。 */
function moveUp(i: number) {
if (i <= 0) return;
setRecipients(prev => {
const next = [...prev];
[next[i - 1], next[i]] = [next[i], next[i - 1]];
return next;
});
}
const preview = useMemo(() => {
const tpl = reminderText.trim() || DEFAULT_TEMPLATE;
return renderReminder(tpl, {
title: title || '(未填标题)',
description,
event_time: fromLocalInput(eventTime) || new Date().toISOString()
});
}, [reminderText, title, description, eventTime]);
/**
* 接下来三次触发。
*
* 农历规则必须给这个预览:规则名(「每农历月廿二」)看不出公历日子,
* 而公历日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
*/
const upcoming = useMemo(() => {
if (recurrence === 'none') return [];
const iso = fromLocalInput(eventTime);
if (!iso) return [];
return upcomingOccurrences(recurrence, new Date(iso), 3);
}, [recurrence, eventTime]);
async function addFiles(files: FileList | null) {
if (!files?.length || !event?.event_id) return;
setUploading(true);
setErr('');
try {
// 逐个传而非并发:并发失败时分不清是哪个文件的问题
for (const f of Array.from(files)) {
const a = await api.uploadCalendarAttachment(event.event_id, f);
setAtts(prev => [...prev, a]);
}
} catch (e: any) {
setErr(e?.message || '上传失败');
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
async function dropAttachment(id: string) {
try {
await api.deleteCalendarAttachment(id);
setAtts(prev => prev.filter(a => a.attachment_id !== id));
} catch (e: any) {
setErr(e?.message || '删除附件失败');
}
}
// 收件人为空时不能保存:事件永远发不出去,后端也会 400。
// 在这里就禁用按钮比让人点了再看报错好。
const canSave = title.trim() !== '' && eventTime !== '' && recipients.length > 0 && !saving;
async function save() {
if (!canSave) return;
setErr('');
setSaving(true);
const iso = fromLocalInput(eventTime);
if (!iso) {
setErr('事件时间无效');
setSaving(false);
return;
}
// recurrence 为 none 时清掉终止时间:留着它只会让后续编辑困惑
const endIso = recurrence === 'none' || !recurrenceEnd ? null : fromLocalInput(recurrenceEnd);
const payload: CalendarEventInput = {
title: title.trim(),
description,
reminder_text: reminderText.trim(),
recipients,
delivery_mode: deliveryMode,
// 旧字段保持与列表首项一致:第三方客户端(与旧版前端)只读 to_address
to_address: recipients[0] ?? '',
event_time: iso,
remind_before: remindBefore,
recurrence,
recurrence_end: endIso,
status
};
try {
if (editing && event) await api.updateCalendarEvent(event.event_id, payload);
else await api.createCalendarEvent(payload);
onSaved();
onClose();
} catch (e: any) {
setErr(e?.message || '保存失败');
} finally {
setSaving(false);
}
}
async function remove() {
if (!event) return;
setDeleting(true);
try {
await api.deleteCalendarEvent(event.event_id);
onSaved();
onClose();
} catch (e: any) {
setErr(e?.message || '删除失败');
setDeleting(false);
}
}
const unusedAgents = agents.filter(a => !recipients.includes(a));
return (
<div className="flex flex-col h-full min-h-0 bg-white">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
<h2 className="text-base font-medium text-gray-900">
{editing ? '编辑日程' : '新建日程'}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
aria-label="关闭"
>
<CloseIcon />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">标题</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
autoFocus
placeholder="例如:llmsproxy 发布评审"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">说明</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="可留空;会作为 {description} 变量填入提醒正文"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">时间</label>
<input
type="datetime-local"
value={eventTime}
onChange={e => setEventTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{fromLocalInput(eventTime) && (
<p className="mt-1 text-xs text-gray-500">
{formatSolarWithLunar(new Date(fromLocalInput(eventTime)))}
</p>
)}
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<BellIcon className="w-3.5 h-3.5" />
提醒
</label>
<select
value={PRESETS.includes(remindBefore) ? String(remindBefore) : 'custom'}
onChange={e => {
if (e.target.value !== 'custom') setRemindBefore(Number(e.target.value));
}}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{PRESETS.map(m => (
<option key={m} value={m}>
{describeRemindBefore(m)}
</option>
))}
{!PRESETS.includes(remindBefore) && (
<option value="custom">{describeRemindBefore(remindBefore)}</option>
)}
</select>
</div>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<RepeatIcon className="w-3.5 h-3.5" />
重复
</label>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value as Recurrence)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{RECURRENCE_GROUPS.map(g => (
<optgroup key={g.label} label={g.label}>
{g.items.map(it => (
<option key={it.value} value={it.value}>
{it.label}
</option>
))}
</optgroup>
))}
</select>
{recurrence !== 'none' && (
<div className="mt-2 space-y-2">
<div className="px-2.5 py-2 bg-gray-50 border border-gray-200 rounded">
<div className="text-xs text-gray-600 mb-1">
{describeRecurrenceRule(
recurrence,
fromLocalInput(eventTime) ? new Date(fromLocalInput(eventTime)) : undefined
)}
{isLunarRecurrence(recurrence) && (
<span className="ml-1 text-amber-700">· 按农历推进</span>
)}
</div>
{/* 接下来三次必须显示:农历规则的公历日期每次都在变,
光看规则名分辨不出对不对,而错了要等一个月才发现 */}
{upcoming.length > 0 ? (
<ul className="space-y-0.5">
{upcoming.map((d, i) => (
<li key={i} className="text-xs text-gray-700 tabular-nums">
{formatSolarWithLunar(d)}{' '}
<span className="text-gray-400">
{String(d.getHours()).padStart(2, '0')}:
{String(d.getMinutes()).padStart(2, '0')}
</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-amber-700">
算不出下一次 —— 这条规则在后续年份可能不存在(例如闰月)。
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">重复至</label>
<input
type="datetime-local"
value={recurrenceEnd}
onChange={e => setRecurrenceEnd(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">留空 = 一直重复</p>
</div>
</div>
)}
</div>
{/* ── 收件人 ── */}
<div className="pt-1 border-t border-gray-100">
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1 mt-3">
<UsersIcon className="w-3.5 h-3.5" />
提醒发给谁({recipients.length})
</label>
{recipients.length > 0 && (
<ul className="mb-2 space-y-1">
{recipients.map((addr, i) => (
<li
key={addr}
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
>
<BotIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 min-w-0 truncate text-gray-800 font-mono">{addr}</span>
{/* together 模式下首个是主收件人,顺序有语义,因此要能调 */}
{deliveryMode === 'together' && i === 0 && (
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded shrink-0">
主收件人
</span>
)}
{deliveryMode === 'together' && i > 0 && (
<button
onClick={() => moveUp(i)}
title="设为主收件人方向移动"
className="px-1 rounded hover:bg-gray-200 text-gray-500 shrink-0"
>
↑
</button>
)}
<button
onClick={() => removeRecipient(addr)}
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
aria-label={`移除 ${addr}`}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<AddressInput
value={draftAddr}
onChange={setDraftAddr}
placeholder="name@path.session(省略 .session = 默认会话)"
/>
</div>
<button
onClick={() => addRecipient(draftAddr)}
disabled={!draftAddr.trim()}
className="px-2.5 py-2 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 shrink-0 flex items-center gap-1"
>
<PlusIcon className="w-3.5 h-3.5" />
添加
</button>
</div>
{unusedAgents.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{unusedAgents.map(a => (
<button
key={a}
onClick={() => addRecipient(a)}
className="px-2 py-0.5 text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
>
+ {a}
</button>
))}
</div>
)}
{recipients.length > 1 && (
<div className="mt-3">
<div className="text-xs font-medium text-gray-600 mb-1">怎么投递</div>
<div className="space-y-1.5">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
checked={deliveryMode === 'separate'}
onChange={() => setDeliveryMode('separate')}
className="mt-0.5"
/>
<span className="text-xs">
<span className="text-gray-800">分别发送</span>
<span className="block text-gray-500">
每人一封、落各自的会话,互相看不到 —— 适合让几个 Agent
各自独立判断。
</span>
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
checked={deliveryMode === 'together'}
onChange={() => setDeliveryMode('together')}
className="mt-0.5"
/>
<span className="text-xs">
<span className="text-gray-800">一起发送</span>
<span className="block text-gray-500">
首个是主收件人、其余抄送,共享同一条线索,能看到彼此的回复 ——
适合有主次的协作。
</span>
</span>
</label>
</div>
</div>
)}
</div>
{/* ── 提醒正文 ── */}
<div className="pt-1 border-t border-gray-100">
<label className="block text-xs font-medium text-gray-600 mb-1 mt-3">提醒正文</label>
<textarea
value={reminderText}
onChange={e => setReminderText(e.target.value)}
rows={4}
placeholder={DEFAULT_TEMPLATE}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{['{title}', '{time}', '{description}'].map(v => (
<button
key={v}
type="button"
onClick={() => setReminderText(t => t + v)}
className="px-2 py-0.5 text-xs font-mono bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
>
{v}
</button>
))}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Agent 会收到</div>
<pre className="px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-800 whitespace-pre-wrap break-words">
{preview}
</pre>
</div>
{editing && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
<PaperclipIcon className="w-3.5 h-3.5" />
附件(随提醒邮件一起发出)
</label>
{atts.length > 0 && (
<ul className="mb-2 space-y-1">
{atts.map(a => (
<li
key={a.attachment_id}
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 min-w-0 truncate text-gray-800">{a.filename}</span>
<span className="text-gray-400 tabular-nums shrink-0">
{(a.size_bytes / 1024).toFixed(1)} KB
</span>
<button
onClick={() => dropAttachment(a.attachment_id)}
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
aria-label={`移除 ${a.filename}`}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-2.5 py-1 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 flex items-center gap-1.5"
>
{uploading ? (
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
) : (
<PaperclipIcon className="w-3.5 h-3.5" />
)}
添加附件
</button>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={e => addFiles(e.target.files)}
/>
</div>
)}
{editing && (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">状态</label>
<select
value={status}
onChange={e => setStatus(e.target.value as CalendarEvent['status'])}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="active">启用</option>
<option value="paused">暂停(不再触发提醒)</option>
<option value="cancelled">已取消</option>
</select>
</div>
)}
{err && (
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{err}
</div>
)}
</div>
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0 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>
);
}