用户:「日历滑动页面为什么没有切换动画?」。
## 做法
`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`。
841 lines
28 KiB
TypeScript
841 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|