695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|