chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
775
client/electron/src/components/CalendarView.tsx
Normal file
775
client/electron/src/components/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user