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('month'); const [anchor, setAnchor] = useState(() => new Date()); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(''); // 右栏三态:编辑既有事件 / 新建(带预填时间)/ 看某天的日程 const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(null); const [selectedDay, setSelectedDay] = useState(() => new Date()); // 窄屏下右栏是否已滑入。宽屏恒为 false(两栏并排,不需要覆盖) const [paneOpen, setPaneOpen] = useState(false); const [importing, setImporting] = useState(false); const fileRef = useRef(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]); /* * 翻页过渡(2026-09-14 用户:「日历滑动页面为什么没有切换动画?」)。 * * 用**声明式** key + 类,而不是"命令式加类":切月时 loading 会把网格整块换成 * 加载态再换回来,命令式加上的类会被 React 的重渲染与那次重挂载抹掉 * (实测:类根本没进 DOM)。key 变化 ⇒ 新元素带着动画类出现 ⇒ 一定会重放。 * * 首次进入不动画(`navigated` 为 false),否则一打开日历就滑一下很怪。 */ const bodyRef = useRef(null); const slideDir = useRef<1 | -1>(1); const navigated = useRef(false); const slideClass = navigated.current ? slideDir.current === 1 ? 'cal-slide-next' : 'cal-slide-prev' : ''; function shift(dir: 1 | -1) { // 记下方向:过渡动画要往正确的方向推(下一段从右边进、上一段从左边进)。 // 手势和"上一页/下一页"按钮都走这里 ⇒ 方向只有一个来源。 slideDir.current = dir; navigated.current = true; if (scale === 'month') setAnchor(a => addMonths(a, dir)); else if (scale === 'week') setAnchor(a => addDays(a, dir * 7)); else setAnchor(a => addDays(a, dir)); } /* * 左右滑动手势(2026-09-14 用户:「日历页面还不支持左右滑动手势」)。 * * 复用 shift() —— 手势与「上一月/下一月」按钮必须是同一套翻页逻辑, * 各写一遍的话阈值、边界、"周/日/月"三种刻度的行为迟早分叉。 * * 判据:水平位移 ≥ 40px 且明显大于垂直位移(否则会把纵向滚动列表误判成翻页, * 那是移动端最容易犯的手势错误);同时要求时间 < 600ms,避免"慢慢拖"也翻页。 */ const touch = useRef<{ x: number; y: number; t: number } | null>(null); const onTouchStart = (e: React.TouchEvent) => { const t = e.touches[0]; if (!t) return; touch.current = { x: t.clientX, y: t.clientY, t: Date.now() }; }; const onTouchEnd = (e: React.TouchEvent) => { const start = touch.current; touch.current = null; const t = e.changedTouches[0]; if (!start || !t) return; const dx = t.clientX - start.x; const dy = t.clientY - start.y; const fast = Date.now() - start.t < 600; if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5 || !fast) return; // 左滑 = 看下一段(与翻页按钮的"下一月"同向),右滑 = 上一段 shift(dx < 0 ? 1 : -1); }; 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 = (
{title}
{(['month', 'week', 'day'] as Scale[]).map(s => ( ))}
{ const f = e.target.files?.[0]; if (f) doImport(f); }} />
{err && (
{err}
)} {loading ? (
) : ( // 翻页动画的挂载点:三种刻度共用同一个容器,动画只有一处实现。 // key 里带 anchor/scale:一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
{scale === 'month' ? ( ) : scale === 'week' ? ( ) : ( )}
)}
); // ─── 右栏:编辑器 / 当日日程 ─── const sidePane = editing || creating ? ( ) : ( startCreate(atNineOClock(selectedDay))} onClose={narrow ? closePane : undefined} /> ); // 窄屏:右栏覆盖在网格上,滑入滑出(与收件箱详情同一套动画) if (narrow) { return ; } return (
{gridPane}
{sidePane}
); } /** 新建事件的默认时刻:那天上午 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 ( ); } function MonthGrid({ anchor, selectedDay, byDay, onPickDay, onPickEvent, onCreateAt }: { anchor: Date; selectedDay: Date; byDay: Map; 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 (
{WEEKDAY_LABELS.map(w => (
{w}
))}
{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 (
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' }`} >
{d.getDate()} {/* 农历日必须显示:农历重复规则的公历日期每次都在变, 不显示农历人无法确认「每月十五」到底落在哪一格 */} {cellLunarLabel(d)}
{list.slice(0, 3).map(e => ( onPickEvent(e)} /> ))} {list.length > 3 && ( 还有 {list.length - 3} 项 )}
); })}
); } function WeekGrid({ anchor, selectedDay, byDay, onPickDay, onPickEvent, onCreateAt }: { anchor: Date; selectedDay: Date; byDay: Map; onPickDay: (d: Date) => void; onPickEvent: (e: CalendarEvent) => void; onCreateAt: (d: Date) => void; }) { const days = useMemo(() => weekDays(anchor), [anchor]); const now = new Date(); return (
{days.map((d, i) => { const list = byDay.get(dayKey(d)) ?? []; const isToday = isSameDay(d, now); const isPicked = isSameDay(d, selectedDay); return (
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' : '' }`} >
{WEEKDAY_LABELS[i]}
{d.getMonth() + 1}.{d.getDate()}
{cellLunarLabel(d)}
{list.length === 0 ? (
) : ( list.map(e => onPickEvent(e)} />) )}
); })}
); } /** 日视图:按小时排。空的小时折叠成细条,否则 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(); 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 (
{formatSolarWithLunar(anchor)}
{HOURS.map(h => { const list = byHour.get(h) ?? []; const at = new Date(anchor); at.setHours(h, 0, 0, 0); return (
onCreateAt(at)} className={`flex gap-3 px-3 ${list.length ? 'py-2' : 'py-1 hover:bg-gray-50'} ${ h === nowHour ? 'bg-blue-50/40' : '' }`} > {String(h).padStart(2, '0')}:00 {list.length === 0 ? ( ) : (
{list.map(e => ( onPickEvent(e)} /> ))}
)}
); })}
); } /** * 右栏默认内容:选中那天的日程。 * * 这一栏的存在本身就是为了「宽屏右边不空着」—— 所以它必须在**没有** * 任何事件时也有话说(提示怎么新建),而不是渲染一片空白。 */ 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 (

{day.getMonth() + 1} 月 {day.getDate()} 日

周{WEEKDAY_LABELS[(day.getDay() + 6) % 7]} {isToday && ( 今天 )}

{formatSolarWithLunar(day)}

{onClose && ( )}
{events.length === 0 ? (

这一天没有日程。

在网格上双击任意格子也能直接新建。

) : (
{events.map(e => ( onPickEvent(e)} /> ))}
)}
); } /** 完整事件行:收件方 / 提醒时刻 / 重复规则都写出来。 */ 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 ( ); }