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,775 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
import type { CalendarEvent } from '../types';
import CalendarEventEditor from './CalendarEventEditor';
import NarrowStack from './NarrowStack';
import {
monthGrid,
weekDays,
bucketByDay,
dayKey,
isSameDay,
addDays,
addMonths,
startOfMonth,
startOfWeek,
endOfWeek,
startOfDay,
endOfDay,
remindAt,
describeRemindBefore
} from '../lib/calendar';
import {
cellLunarLabel,
formatSolarWithLunar,
describeRecurrenceRule,
isLunarRecurrence
} from '../lib/lunar';
import { useIsNarrow } from '../hooks/useIsNarrow';
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlusIcon,
SpinnerIcon,
DownloadIcon,
UploadIcon,
BellIcon,
RepeatIcon,
BotIcon,
PauseIcon,
UsersIcon,
CloseIcon
} from './icons';
type Scale = 'month' | 'week' | 'day';
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
/**
* 日历主视图。
*
* **布局与全站一致:内容区自己再分两栏。**
*
* 之前这里是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
* 而点「新建」时编辑器**顶掉**整个日历,人失去了正在看的那个月的上下文。
* 两个问题同源:日历没有「详情栏」这个位置。
*
* 现在左边是网格、右边是常驻面板:默认显示选中那天的日程(所以永远不空),
* 新建/编辑时同一个位置变成编辑器 —— 与「新建邮件是右侧整页」同一套语言。
*
* 窄屏放不下两栏,退回覆盖式(NarrowStack),与收件箱的行为一致。
*/
export default function CalendarView() {
const narrow = useIsNarrow();
const [scale, setScale] = useState<Scale>('month');
const [anchor, setAnchor] = useState(() => new Date());
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
// 右栏三态:编辑既有事件 / 新建(带预填时间)/ 看某天的日程
const [editing, setEditing] = useState<CalendarEvent | null>(null);
const [creating, setCreating] = useState<Date | null>(null);
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
// 窄屏下右栏是否已滑入。宽屏恒为 false(两栏并排,不需要覆盖)
const [paneOpen, setPaneOpen] = useState(false);
const [importing, setImporting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
/**
* 查询区间一律按月视图的 42 格算(含邻月首尾几天)。
*
* 三种粒度共用同一份数据:切 scale 不必重新请求,也不会出现
* 「周视图跨月时后半周空白」。
*/
const range = useMemo(() => {
const cells = monthGrid(anchor);
return {
from: startOfDay(cells[0]).toISOString(),
to: endOfDay(cells[cells.length - 1]).toISOString()
};
}, [anchor]);
async function load() {
setErr('');
try {
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
setEvents(r.events ?? []);
} catch (e: any) {
setErr(e?.message || '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
setLoading(true);
load();
}, [range.from, range.to]);
const byDay = useMemo(() => bucketByDay(events), [events]);
function shift(dir: 1 | -1) {
if (scale === 'month') setAnchor(a => addMonths(a, dir));
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
else setAnchor(a => addDays(a, dir));
}
function goToday() {
const now = new Date();
setAnchor(now);
setSelectedDay(now);
}
/** 点某天:宽屏只换右栏内容,窄屏滑入右栏。 */
function pickDay(d: Date) {
setSelectedDay(d);
setEditing(null);
setCreating(null);
if (narrow) setPaneOpen(true);
}
function pickEvent(e: CalendarEvent) {
setEditing(e);
setCreating(null);
if (narrow) setPaneOpen(true);
}
function startCreate(at: Date) {
setCreating(at);
setEditing(null);
setSelectedDay(at);
if (narrow) setPaneOpen(true);
}
function closePane() {
setEditing(null);
setCreating(null);
setPaneOpen(false);
}
const title = useMemo(() => {
if (scale === 'month') return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月`;
if (scale === 'week') {
const a = startOfWeek(anchor);
const b = endOfWeek(anchor);
// 跨月时两头都写月份,否则「9月28日 - 4日」看不出后者是十月
if (a.getMonth() === b.getMonth()) {
return `${a.getFullYear()} 年 ${a.getMonth() + 1} 月 ${a.getDate()}–${b.getDate()} 日`;
}
return `${a.getMonth() + 1}.${a.getDate()} – ${b.getMonth() + 1}.${b.getDate()}`;
}
return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月 ${anchor.getDate()} 日 周${
WEEKDAY_LABELS[(anchor.getDay() + 6) % 7]
}`;
}, [scale, anchor]);
async function doExport() {
try {
const ics = await api.exportCalendarICS(range.from, range.to);
// Blob 下载而不是导航:导航会丢掉 cookie 之外的认证头
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
const a = document.createElement('a');
a.href = url;
a.download = `agentmail-${dayKey(anchor)}.ics`;
a.click();
URL.revokeObjectURL(url);
} catch (e: any) {
setErr(e?.message || '导出失败');
}
}
async function doImport(f: File) {
setImporting(true);
setErr('');
try {
const r = await api.importCalendarICS(await f.text());
await load();
setErr(`已导入 ${r.imported} 个事件${r.skipped ? `,跳过 ${r.skipped} 个` : ''}`);
} catch (e: any) {
setErr(e?.message || '导入失败');
} finally {
setImporting(false);
if (fileRef.current) fileRef.current.value = '';
}
}
// ─── 左栏:工具条 + 网格 ───
const gridPane = (
<div className="flex-1 min-w-0 flex flex-col bg-white min-h-0">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 shrink-0 flex-wrap">
<div className="flex items-center gap-1">
<button
onClick={() => shift(-1)}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="上一页"
>
<ChevronLeftIcon />
</button>
<button
onClick={() => shift(1)}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="下一页"
>
<ChevronRightIcon />
</button>
<button
onClick={goToday}
className="px-2.5 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700"
>
今天
</button>
</div>
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-900 min-w-0">
<CalendarIcon className="w-4 h-4 text-gray-400 shrink-0" />
<span className="truncate">{title}</span>
</div>
<div className="ml-auto flex items-center gap-1.5">
<div className="flex rounded border border-gray-300 overflow-hidden">
{(['month', 'week', 'day'] as Scale[]).map(s => (
<button
key={s}
onClick={() => setScale(s)}
className={`px-2.5 py-1 text-xs ${
scale === s ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
{s === 'month' ? '月' : s === 'week' ? '周' : '日'}
</button>
))}
</div>
<button
onClick={doExport}
title="导出 .ics"
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
aria-label="导出"
>
<DownloadIcon />
</button>
<button
onClick={() => fileRef.current?.click()}
title="导入 .ics"
disabled={importing}
className="p-1.5 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-40"
aria-label="导入"
>
{importing ? <SpinnerIcon className="w-4 h-4 animate-spin" /> : <UploadIcon />}
</button>
<input
ref={fileRef}
type="file"
accept=".ics,text/calendar"
className="hidden"
onChange={e => {
const f = e.target.files?.[0];
if (f) doImport(f);
}}
/>
<button
onClick={() => startCreate(atNineOClock(selectedDay))}
className="px-2.5 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 flex items-center gap-1"
>
<PlusIcon className="w-3.5 h-3.5" />
新建
</button>
</div>
</div>
{err && (
<div className="px-3 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-800 shrink-0">
{err}
</div>
)}
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400">
<SpinnerIcon className="w-6 h-6 animate-spin" />
</div>
) : scale === 'month' ? (
<MonthGrid
anchor={anchor}
selectedDay={selectedDay}
byDay={byDay}
onPickDay={pickDay}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
) : scale === 'week' ? (
<WeekGrid
anchor={anchor}
selectedDay={selectedDay}
byDay={byDay}
onPickDay={pickDay}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
) : (
<DayGrid
anchor={anchor}
events={byDay.get(dayKey(anchor)) ?? []}
onPickEvent={pickEvent}
onCreateAt={startCreate}
/>
)}
</div>
);
// ─── 右栏:编辑器 / 当日日程 ───
const sidePane =
editing || creating ? (
<CalendarEventEditor
event={editing}
initialTime={creating ?? undefined}
onClose={closePane}
onSaved={load}
/>
) : (
<DayAgendaPane
day={selectedDay}
events={byDay.get(dayKey(selectedDay)) ?? []}
onPickEvent={pickEvent}
onCreate={() => startCreate(atNineOClock(selectedDay))}
onClose={narrow ? closePane : undefined}
/>
);
// 窄屏:右栏覆盖在网格上,滑入滑出(与收件箱详情同一套动画)
if (narrow) {
return <NarrowStack base={gridPane} overlay={sidePane} open={paneOpen} />;
}
return (
<div className="flex-1 min-w-0 flex min-h-0">
{gridPane}
<div className="w-full lg:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
{sidePane}
</div>
</div>
);
}
/** 新建事件的默认时刻:那天上午 9 点,比「现在」有用得多。 */
function atNineOClock(d: Date): Date {
const x = new Date(d);
x.setHours(9, 0, 0, 0);
return x;
}
function hhmm(iso: string): string {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return '';
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
}
/** 收件人数量标记。多收件人是常态,格子里得看得出来。 */
function recipientCount(e: CalendarEvent): number {
if (e.recipients?.length) return e.recipients.length;
return e.to_address || e.agent_name ? 1 : 0;
}
/** 事件在格子里的小色条。 */
function EventChip({
e,
onClick,
showTime = true
}: {
e: CalendarEvent;
onClick: () => void;
showTime?: boolean;
}) {
// 暂停/取消的事件不会触发提醒,视觉上必须与生效的区分开 ——
// 否则人以为设好了,实际到点什么都不会发生
const dead = e.status !== 'active';
const n = recipientCount(e);
return (
<button
onClick={ev => {
ev.stopPropagation();
onClick();
}}
title={`${e.title}${n > 1 ? `(${n} 个收件人)` : ''}`}
className={`w-full text-left px-1.5 py-0.5 rounded text-xs truncate flex items-center gap-1 ${
dead
? 'bg-gray-100 text-gray-400 line-through'
: 'bg-blue-50 text-blue-800 hover:bg-blue-100'
}`}
>
{dead && <PauseIcon className="w-3 h-3 shrink-0" />}
{showTime && <span className="tabular-nums shrink-0 opacity-70">{hhmm(e.event_time)}</span>}
<span className="truncate">{e.title}</span>
{n > 1 && <span className="shrink-0 opacity-70 tabular-nums">·{n}</span>}
</button>
);
}
function MonthGrid({
anchor,
selectedDay,
byDay,
onPickDay,
onPickEvent,
onCreateAt
}: {
anchor: Date;
selectedDay: Date;
byDay: Map<string, CalendarEvent[]>;
onPickDay: (d: Date) => void;
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const cells = useMemo(() => monthGrid(anchor), [anchor]);
const now = new Date();
const curMonth = startOfMonth(anchor).getMonth();
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
<div className="grid grid-cols-7 border-b border-gray-200 shrink-0 sticky top-0 bg-white z-10">
{WEEKDAY_LABELS.map(w => (
<div key={w} className="px-2 py-1.5 text-xs font-medium text-gray-500 text-center">
{w}
</div>
))}
</div>
<div className="grid grid-cols-7 grid-rows-6 flex-1 min-h-[30rem]">
{cells.map((d, i) => {
const list = byDay.get(dayKey(d)) ?? [];
const outside = d.getMonth() !== curMonth;
const isToday = isSameDay(d, now);
const isPicked = isSameDay(d, selectedDay);
return (
<div
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${
isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white'
}`}
>
<div className="flex items-baseline gap-1 shrink-0">
<span
className={`px-1.5 rounded text-xs tabular-nums ${
isToday
? 'bg-blue-600 text-white font-medium'
: outside
? 'text-gray-400'
: 'text-gray-700'
}`}
>
{d.getDate()}
</span>
{/* 农历日必须显示:农历重复规则的公历日期每次都在变,
不显示农历人无法确认「每月十五」到底落在哪一格 */}
<span
className={`text-[10px] leading-none truncate ${
outside ? 'text-gray-300' : 'text-gray-400'
}`}
>
{cellLunarLabel(d)}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-hidden">
{list.slice(0, 3).map(e => (
<EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
{list.length > 3 && (
<span className="px-1.5 text-xs text-gray-500">还有 {list.length - 3} 项</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function WeekGrid({
anchor,
selectedDay,
byDay,
onPickDay,
onPickEvent,
onCreateAt
}: {
anchor: Date;
selectedDay: Date;
byDay: Map<string, CalendarEvent[]>;
onPickDay: (d: Date) => void;
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const days = useMemo(() => weekDays(anchor), [anchor]);
const now = new Date();
return (
<div className="flex-1 min-h-0 overflow-auto">
<div className="grid grid-cols-7 min-w-[36rem] h-full">
{days.map((d, i) => {
const list = byDay.get(dayKey(d)) ?? [];
const isToday = isSameDay(d, now);
const isPicked = isSameDay(d, selectedDay);
return (
<div
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
isPicked ? 'bg-blue-50/50' : ''
}`}
>
<div
className={`px-2 py-1.5 border-b border-gray-200 sticky top-0 z-10 ${
isToday ? 'bg-blue-50' : isPicked ? 'bg-blue-50/70' : 'bg-white'
}`}
>
<div className="text-xs text-gray-500">{WEEKDAY_LABELS[i]}</div>
<div
className={`text-sm tabular-nums ${
isToday ? 'text-blue-700 font-medium' : 'text-gray-900'
}`}
>
{d.getMonth() + 1}.{d.getDate()}
</div>
<div className="text-[10px] text-gray-400 leading-none">{cellLunarLabel(d)}</div>
</div>
<div className="flex-1 p-1 flex flex-col gap-1">
{list.length === 0 ? (
<div className="text-xs text-gray-300 px-1 py-2">—</div>
) : (
list.map(e => <EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />)
)}
</div>
</div>
);
})}
</div>
</div>
);
}
/** 日视图:按小时排。空的小时折叠成细条,否则 24 行等高会把有内容的挤出屏幕。 */
function DayGrid({
anchor,
events,
onPickEvent,
onCreateAt
}: {
anchor: Date;
events: CalendarEvent[];
onPickEvent: (e: CalendarEvent) => void;
onCreateAt: (d: Date) => void;
}) {
const byHour = useMemo(() => {
const m = new Map<number, CalendarEvent[]>();
for (const e of events) {
const t = new Date(e.event_time);
if (Number.isNaN(t.getTime())) continue;
const h = t.getHours();
const b = m.get(h);
if (b) b.push(e);
else m.set(h, [e]);
}
return m;
}, [events]);
const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
{formatSolarWithLunar(anchor)}
</div>
<div className="divide-y divide-gray-100">
{HOURS.map(h => {
const list = byHour.get(h) ?? [];
const at = new Date(anchor);
at.setHours(h, 0, 0, 0);
return (
<div
key={h}
onDoubleClick={() => onCreateAt(at)}
className={`flex gap-3 px-3 ${list.length ? 'py-2' : 'py-1 hover:bg-gray-50'} ${
h === nowHour ? 'bg-blue-50/40' : ''
}`}
>
<span
className={`w-10 shrink-0 text-xs tabular-nums ${
list.length ? 'text-gray-500 pt-1' : 'text-gray-400'
}`}
>
{String(h).padStart(2, '0')}:00
</span>
{list.length === 0 ? (
<span className="text-xs text-gray-200">—</span>
) : (
<div className="flex-1 min-w-0 flex flex-col gap-2">
{list.map(e => (
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/**
* 右栏默认内容:选中那天的日程。
*
* 这一栏的存在本身就是为了「宽屏右边不空着」—— 所以它必须在**没有**
* 任何事件时也有话说(提示怎么新建),而不是渲染一片空白。
*/
function DayAgendaPane({
day,
events,
onPickEvent,
onCreate,
onClose
}: {
day: Date;
events: CalendarEvent[];
onPickEvent: (e: CalendarEvent) => void;
onCreate: () => void;
onClose?: () => void;
}) {
const isToday = isSameDay(day, new Date());
return (
<div className="flex flex-col h-full min-h-0 bg-white">
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-base font-medium text-gray-900">
{day.getMonth() + 1} 月 {day.getDate()} 日
</h2>
<span className="text-xs text-gray-500">
周{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
</span>
{isToday && (
<span className="px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded">今天</span>
)}
</div>
<p className="mt-0.5 text-xs text-gray-500">{formatSolarWithLunar(day)}</p>
</div>
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 shrink-0"
aria-label="返回日历"
>
<CloseIcon />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
{events.length === 0 ? (
<div className="text-sm text-gray-500">
<p>这一天没有日程。</p>
<p className="mt-1 text-xs text-gray-400">
在网格上双击任意格子也能直接新建。
</p>
</div>
) : (
<div className="flex flex-col gap-2">
{events.map(e => (
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
))}
</div>
)}
</div>
<div className="px-4 py-3 border-t border-gray-200 shrink-0">
<button
onClick={onCreate}
className="w-full px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 flex items-center justify-center gap-1.5"
>
<PlusIcon className="w-4 h-4" />
在这一天新建日程
</button>
</div>
</div>
);
}
/** 完整事件行:收件方 / 提醒时刻 / 重复规则都写出来。 */
function EventRow({ e, onClick }: { e: CalendarEvent; onClick: () => void }) {
const t = new Date(e.event_time);
const rt = remindAt(e);
const dead = e.status !== 'active';
const recips = e.recipients?.length ? e.recipients : [e.to_address || e.agent_name].filter(Boolean);
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2 rounded border ${
dead
? 'bg-gray-50 border-gray-200'
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-blue-50/30'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 tabular-nums shrink-0">{hhmm(e.event_time)}</span>
<span
className={`text-sm font-medium truncate ${
dead ? 'text-gray-400 line-through' : 'text-gray-900'
}`}
>
{e.title}
</span>
{dead && (
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded shrink-0">
{e.status === 'paused' ? '已暂停' : '已取消'}
</span>
)}
</div>
{e.description && (
<p className="text-xs text-gray-600 mb-1.5 line-clamp-2">{e.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
{recips.length > 0 && (
<span className="flex items-center gap-1 min-w-0">
{recips.length > 1 ? (
<UsersIcon className="w-3 h-3 shrink-0" />
) : (
<BotIcon className="w-3 h-3 shrink-0" />
)}
<span className="truncate">
{recips.length > 1
? `${recips.length} 人 · ${e.delivery_mode === 'together' ? '同一线索' : '各自独立'}`
: recips[0]}
</span>
</span>
)}
<span className="flex items-center gap-1">
<BellIcon className="w-3 h-3" />
{describeRemindBefore(e.remind_before)}
{e.remind_before > 0 && (
<span className="tabular-nums opacity-70">
({String(rt.getHours()).padStart(2, '0')}:{String(rt.getMinutes()).padStart(2, '0')})
</span>
)}
</span>
{e.recurrence !== 'none' && (
<span
className={`flex items-center gap-1 ${
isLunarRecurrence(e.recurrence) ? 'text-amber-700' : ''
}`}
>
<RepeatIcon className="w-3 h-3" />
{describeRecurrenceRule(e.recurrence, Number.isNaN(t.getTime()) ? undefined : t)}
</span>
)}
</div>
</button>
);
}