chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
694
client/electron/src/components/CalendarEventEditor.tsx
Normal file
694
client/electron/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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user