Files
MailUI4Agents/client/electron/src/components/CalendarView.tsx
JianFeeeee d78f19fe9a fix(webui): 日历页面补上圆角(圆角要给到「有底色的那一层」)
用户(2026-09-14):「日历页面还没有添加圆角」。

根因不是「忘了写 border-radius」—— 外壳那条 `.app-shell > *` 是生效的,
它给日历**根节点**加了 14px 圆角。问题是根节点写着
`flex-1 min-w-0 flex min-h-0`、**自己完全没有底色**:
真正白底的是里面那两块面板(网格栏、右栏),它们的直角从透明外壳里戳出来。
所以 `getComputedStyle(根).borderRadius` 一直是 14px,看上去却全是方角。

两处不能照搬收件箱做法的地方:

① 网格栏与右栏**不是两张卡**(中间只有 1px 分隔线,没有外壳间距)
   ⇒ 只能给**外沿**:左边那块给左两角、右边那块给右两角。
   内侧也给会露出底色缺口。
② 右栏外面还套了一层容器(管 `lg:w-[400px]` 与 `border-l`,自己不上色),
   真正上色的是它里面的 DayAgendaPane / CalendarEventEditor
   ⇒ 圆角要**再往下给一层**,只加在容器上会被内层直角原地盖掉。
   (左栏本身就是白底面板,不能再往下给:它的第一个子元素是工具条。)

实测(1280×800,真浏览器读渲染像素):
  改前 左栏 radius=0px、右栏 radius=0px,四个外角都是白像素(方角)
  改后 左栏 `14px 0px 0px 14px`、右栏内层 `0px 14px 14px 0px`,
       四个外角像素都等于页面底色(被切掉);上边缘内缩 8px,
       与收件箱详情栏(对照)完全一致;内侧分隔线两侧保持直角。

判据:narrow-layout 新增 4 条 —— 钉的是「规则与 JSX 接没接上」「给的是哪几条边」
「右栏有没有再往下给一层」「左栏有没有多给一层」,不是那条 14px。
wide-regression 新增一条**渲染层**判据:从 `elementFromPoint` 沿祖先链找第一个
自带底色的元素,四个外角都必须正是那个圆角面板(光看 computed style 区分不了,
因为被盖掉的形态 computed 也照样是 14px)。

顺手修掉 wide-regression 里一条**假失败**:`#root > div` 现在指向壁纸幕布
(`app-backdrop` 后来挂成了 `#root` 的第一个子节点),于是「仍是三栏并排」
一直报「栏数=0」,而三栏好好的。假失败比没有判据更贵 —— 看的人会去查一个
本来没坏的东西。
2026-09-14 16:05:15 +08:00

881 lines
30 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);
/**
* 触点是否落在**可横向滚动**的区域里(周视图就是:`overflow-auto` + `min-w-[36rem]`)。
*
* 用户(2026-09-14):「横向滚动条会与切换视图的手势冲突,我觉得周视图需要卡严条件」。
*
* 冲突是真的:周视图在窄屏下必须横向滚,而我又给整页加了左右滑动翻页 ⇒
* 同一次横滑既想滚又想翻,结果两边都不好用。规则定死:**手势从可横向滚动的
* 区域里开始,就归滚动条,不翻页**。想要翻页就从别处(例如上方的标题栏)滑。
*/
const startsInHorizontalScroller = (target: EventTarget | null, root: EventTarget | null) => {
let el = target as HTMLElement | null;
while (el && el !== root) {
const cs = getComputedStyle(el);
if (
(cs.overflowX === 'auto' || cs.overflowX === 'scroll') &&
el.scrollWidth > el.clientWidth + 4
) {
return true;
}
el = el.parentElement;
}
return false;
};
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0];
if (!t) return;
/*
* 只在**周/日**刻度卡严(用户原话:「我觉得**周视图**需要卡严条件」)。
*
* 我第一版对所有刻度都卡,结果连月视图的翻页手势也一起没了 ——
* 因为"日历内存在可横滚容器"在月视图里也成立(外层容器本身就带 auto)。
* 判据必须贴在**真正有横向滚动需求的那个刻度**上,而不是"这页里有没有横滚容器"。
*/
const needsStrict = scale === 'week' || scale === 'day';
if (needsStrict && startsInHorizontalScroller(e.target, e.currentTarget)) {
// 卡严条件:这一次手势完全不参与翻页判断
touch.current = null;
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 (
/* `cal-panes`:这一层是"拼成一张卡的两块面板",圆角规则在 index.css 里
(根节点自己没有底色,见那里的说明)*/
<div className="cal-panes 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={`glass-card border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 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="glass-card 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={`glass-card 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="glass-card 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>
);
}