"use client"; import { useEffect, useRef, useState } from "react"; import { CalendarDays, ChevronLeft, ChevronRight, Plus, Trash2, X, } from "lucide-react"; import { CALENDAR_FREQUENCIES, defaultWorkingCalendar, isWorkingDate, readWorkingCalendar, validateWorkingCalendar, type CalendarRule, type ProjectCalendarConfig, type ProjectWorkingCalendar, } from "@/lib/flow-board/project-calendar"; import { addDays, dateIndex, DAY, indexDate, todayDate, } from "@/lib/flow-board/calendar-dates"; const STORAGE_KEY = "flow-board.personal-calendars.v1"; const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"]; const FREQUENCY_LABELS: Record = { NONE: "不重复", DAILY: "每天", WEEKLY: "每周", MONTHLY_BY_DATE: "每月(按日期)", MONTHLY_BY_WEEKDAY: "每月(按星期)", YEARLY: "每年", }; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); function useCalendarDialogFocus(ref: React.RefObject) { useEffect(() => { const previous = document.activeElement as HTMLElement | null; ref.current?.querySelector("input,button")?.focus(); return () => { if (previous?.isConnected) previous.focus(); }; }, [ref]); } function trapCalendarTab( event: React.KeyboardEvent, panel: HTMLElement | null ) { if (event.key !== "Tab") return; const controls = Array.from( panel?.querySelectorAll( "button:not(:disabled),input:not(:disabled),select:not(:disabled)" ) || [] ); const first = controls[0], last = controls.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } } function CalendarExceptionEditor({ rule, onSave, onClose, }: { rule: CalendarRule; onSave: (rule: CalendarRule) => void; onClose: () => void; }) { const [draft, setDraft] = useState(() => copy(rule)), [error, setError] = useState(""); const ref = useRef(null); useCalendarDialogFocus(ref); const patch = (changes: Partial) => setDraft((value) => ({ ...value, ...changes })); const save = () => { const problem = validateWorkingCalendar({ id: "validation", name: "", defaultWorkingDays: [1], rules: [draft], }); if (problem) { setError(problem); return; } const selected = draft.selectedDates[0]; if ( (draft.effectiveRange?.start && selected < draft.effectiveRange.start) || (draft.effectiveRange?.end && selected > draft.effectiveRange.end) ) { setError("例外日期必须在生效范围内。"); return; } onSave({ ...draft, name: draft.name.trim() || "例外" }); onClose(); }; return (
event.stopPropagation()} onKeyDown={(event) => { event.stopPropagation(); trapCalendarTab(event, ref.current); if (event.key === "Escape") onClose(); }} >

工作日例外

{draft.frequency !== "NONE" ? ( ) : null}

留空表示不限制生效日期。后添加的例外优先。

{error ? (

{error}

) : null}
); } export function XmindProjectCalendar({ config, onSave, onClose, }: { config: ProjectCalendarConfig; onSave: (config: ProjectCalendarConfig) => boolean; onClose: () => void; }) { const ref = useRef(null); useCalendarDialogFocus(ref); const [draft, setDraft] = useState(() => copy(config.settings || defaultWorkingCalendar()) ); const [skip, setSkip] = useState(config.settings ? config.skip : true), [error, setError] = useState(""); const [selectedDate, setSelectedDate] = useState(todayDate()), [month, setMonth] = useState(todayDate().slice(0, 7)); const [exception, setException] = useState(null), [manage, setManage] = useState(false); const [templates, setTemplates] = useState(() => { try { const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); return Array.isArray(stored) ? stored .map(readWorkingCalendar) .filter((item): item is ProjectWorkingCalendar => Boolean(item)) : []; } catch { return []; } }); const [templateName, setTemplateName] = useState(""); const [message, setMessage] = useState(""); const storeTemplates = (value: ProjectWorkingCalendar[]) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); setTemplates(value); return true; } catch { setError("个人日历保存失败,请检查浏览器存储空间。"); return false; } }; const save = () => { const problem = validateWorkingCalendar(draft); if (problem) { setError(problem); return; } if ( onSave({ settings: { ...draft, name: draft.name.trim() || "项目日历" }, skip, }) ) onClose(); }; const saveTemplate = () => { const problem = validateWorkingCalendar(draft); if (problem) { setError(problem); return; } if (!templateName.trim()) { setError("请输入个人日历名称。"); return; } const value = { ...copy(draft), id: crypto.randomUUID(), name: templateName.trim(), }; if (storeTemplates([...templates, value])) { setTemplateName(""); setError(""); setMessage(`已保存个人日历:${value.name}`); } }; const moveMonth = (step: number) => { const date = new Date(`${month}-01T00:00:00Z`); date.setUTCMonth(date.getUTCMonth() + step); setMonth(date.toISOString().slice(0, 7)); }; const first = dateIndex(`${month}-01`), offset = (new Date(first * DAY).getUTCDay() + 6) % 7; const days = Array.from({ length: 42 }, (_, index) => indexDate(first - offset + index) ); const addException = () => setException({ id: crypto.randomUUID(), name: "", type: isWorkingDate(selectedDate, draft) ? "NON_WORKING_DAY" : "WORKING_DAY", frequency: "NONE", selectedDates: [selectedDate], effectiveRange: { start: selectedDate, end: selectedDate }, }); return (
event.stopPropagation()} onKeyDown={(event) => { event.stopPropagation(); if (exception) return; trapCalendarTab(event, ref.current); if (event.key === "Escape") onClose(); }} >

项目工作日

只应用于当前工作表,不影响其他工作表。

{month.replace("-", "年")}月
{WEEKDAYS.map((day) => ( {day} ))} {days.map((date) => ( ))}

阴影为休息日。选择日期后可添加例外。

{manage ? (

个人日历

保存在当前浏览器,可用于其他工作表。

{templates.length ? ( templates.map((item) => (
{item.name}
)) ) : (

还没有个人日历。

)}
) : null}

每周工作日

{WEEKDAYS.map((day, index) => ( ))}

例外

{[...draft.rules].reverse().map((rule) => (
))} {!draft.rules.length ? (

可添加节假日、调休或重复例外。

) : null}
{error ? (

{error}

) : null} {message ?

{message}

: null}
{config.settings ? ( ) : null}
{exception ? ( setDraft((value) => ({ ...value, rules: value.rules.some((item) => item.id === rule.id) ? value.rules.map((item) => (item.id === rule.id ? rule : item)) : [...value.rules, rule], })) } onClose={() => setException(null)} /> ) : null}
); }