Files
MailUI4Agents/client/electron/src/components/CalendarView.tsx
JianFeeeee d11e6df9a8 feat(webui): 日历翻页过渡动画(方向跟随手势/按钮)
用户:「日历滑动页面为什么没有切换动画?」。

## 做法

`shift()` 里记下方向(手势与"上一页/下一页"按钮**都走这一个函数** ⇒ 方向只有一个来源),
容器用 **key + 声明的动画类**:

    <div key={`${anchor.getTime()}-${scale}`} className={`flex-1 min-h-0 flex ${slideClass}`}>

## 为什么不是"命令式加类"

第一版用 `el.classList.add()`(与视图切换动画同一套写法),**类根本没进 DOM**。
原因:切月时 `loading` 会把网格整块换成加载态、再换回来,命令式加上的类会被
React 的重渲染与那次重挂载抹掉。key 变化 ⇒ 新元素自带动画类出现 ⇒ 必然重放。

## 顺带修掉一个守卫 bug

`firstRender` 守卫原先只在 `el` 存在时才消费。挂载时 `loading=true` ⇒ 容器还没渲染
⇒ 守卫一直留着 ⇒ **用户第一次真正翻页的动画被吞掉**(实测:点第一下不动、第二下才动)。
现在无条件消费。

## 判据

`test/manual/calendar-swipe-verify.mjs` 增加三条(与滑动手势同一条线索):
反向对照"刚进页面不跑动画"(animationName=none)、翻页时 `cal-slide-next` +
`cal-in-next` 且时长 >0、反向翻页是 `cal-slide-prev`(方向正确性)。
**实测**:静止 none → 点下一页 `cal-slide-next`/`cal-in-next` 0.2s → 上一页 `cal-slide-prev`/`cal-in-prev`。
2026-09-14 11:27:37 +08:00

841 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]);
/*
* 翻页过渡2026-09-14 用户:「日历滑动页面为什么没有切换动画?」)。
*
* 用**声明式** key + 类,而不是"命令式加类":切月时 loading 会把网格整块换成
* 加载态再换回来,命令式加上的类会被 React 的重渲染与那次重挂载抹掉
* (实测:类根本没进 DOM。key 变化 ⇒ 新元素带着动画类出现 ⇒ 一定会重放。
*
* 首次进入不动画(`navigated` 为 false否则一打开日历就滑一下很怪。
*/
const bodyRef = useRef<HTMLDivElement | null>(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 = (
<div
className="flex-1 min-w-0 flex flex-col bg-white min-h-0"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<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>
) : (
// 翻页动画的挂载点:三种刻度共用同一个容器,动画只有一处实现。
// key 里带 anchor/scale一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
<div
ref={bodyRef}
key={`${anchor.getTime()}-${scale}`}
className={`flex-1 min-h-0 flex ${slideClass}`}
data-slide={slideClass || 'none'}
>
{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>
)}
</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-3xs 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-3xs 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>
);
}