ai-chatbot/components/flow-board/xmind-project-calendar.tsx
134 23ced0b760 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 17:38:14 +08:00

651 lines
21 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.

"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<CalendarRule["frequency"], string> = {
NONE: "不重复",
DAILY: "每天",
WEEKLY: "每周",
MONTHLY_BY_DATE: "每月(按日期)",
MONTHLY_BY_WEEKDAY: "每月(按星期)",
YEARLY: "每年",
};
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
ref.current?.querySelector<HTMLElement>("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<HTMLElement>(
"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<HTMLElement>(null);
useCalendarDialogFocus(ref);
const patch = (changes: Partial<CalendarRule>) =>
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 (
<div
className="xmind-calendar-exception-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-calendar-exception"
role="dialog"
aria-modal="true"
aria-label="工作日例外"
>
<header>
<h2>工作日例外</h2>
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
<X size={17} />
</button>
</header>
<div className="xmind-calendar-exception-fields">
<label>
名称
<input
aria-label="例外名称"
value={draft.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
日期
<input
type="date"
aria-label="例外日期"
value={draft.selectedDates[0] || ""}
onChange={(event) =>
patch({ selectedDates: [event.target.value] })
}
/>
</label>
<label>
类型
<select
aria-label="例外类型"
value={draft.type}
onChange={(event) =>
patch({ type: event.target.value as CalendarRule["type"] })
}
>
<option value="NON_WORKING_DAY">休息日</option>
<option value="WORKING_DAY">工作日(补班)</option>
</select>
</label>
<label>
重复
<select
aria-label="例外重复规则"
value={draft.frequency}
onChange={(event) =>
patch({
frequency: event.target.value as CalendarRule["frequency"],
})
}
>
{CALENDAR_FREQUENCIES.map((frequency) => (
<option key={frequency} value={frequency}>
{FREQUENCY_LABELS[frequency]}
</option>
))}
</select>
</label>
{draft.frequency !== "NONE" ? (
<label>
每隔
<input
type="number"
min={1}
max={100}
aria-label="例外重复间隔"
value={draft.interval || 1}
onChange={(event) =>
patch({ interval: Number(event.target.value) })
}
/>
</label>
) : null}
<div className="xmind-calendar-range">
<label>
生效开始
<input
type="date"
aria-label="例外生效开始"
value={draft.effectiveRange?.start || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
start: event.target.value,
},
})
}
/>
</label>
<label>
生效结束
<input
type="date"
aria-label="例外生效结束"
value={draft.effectiveRange?.end || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
end: event.target.value,
},
})
}
/>
</label>
</div>
<p className="xmind-calendar-hint">
留空表示不限制生效日期。后添加的例外优先。
</p>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
</div>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存例外
</button>
</footer>
</section>
</div>
);
}
export function XmindProjectCalendar({
config,
onSave,
onClose,
}: {
config: ProjectCalendarConfig;
onSave: (config: ProjectCalendarConfig) => boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(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<CalendarRule | null>(null),
[manage, setManage] = useState(false);
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
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 (
<div
className="xmind-project-calendar-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (exception) return;
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-project-calendar"
role="dialog"
aria-modal="true"
aria-label="项目工作日"
>
<header>
<div>
<h2>
<CalendarDays size={19} />
项目工作日
</h2>
<p>只应用于当前工作表,不影响其他工作表。</p>
</div>
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="xmind-project-calendar-content">
<div className="xmind-calendar-preview">
<div className="xmind-calendar-import">
<label>
导入日历
<select
aria-label="导入个人日历"
value=""
onChange={(event) => {
const value =
event.target.value === "basic"
? { ...defaultWorkingCalendar(), name: "基本日历" }
: templates.find(
(item) => item.id === event.target.value
);
if (value) {
setDraft(copy(value));
setError("");
}
}}
>
<option value="">当前项目:{draft.name}</option>
<option value="basic">基本日历(周一至周五)</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setManage((value) => !value)}
>
管理
</button>
</div>
<div className="xmind-calendar-month-header">
<button
type="button"
aria-label="上个月"
onClick={() => moveMonth(-1)}
>
<ChevronLeft size={16} />
</button>
<strong>{month.replace("-", "年")}月</strong>
<button
type="button"
aria-label="下个月"
onClick={() => moveMonth(1)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
{WEEKDAYS.map((day) => (
<span className="xmind-calendar-weekday" key={day}>
{day}
</span>
))}
{days.map((date) => (
<button
type="button"
key={date}
data-calendar-date={date}
data-working={isWorkingDate(date, draft)}
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
aria-pressed={selectedDate === date}
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(8))}
</button>
))}
</div>
<p className="xmind-calendar-hint">
阴影为休息日。选择日期后可添加例外。
</p>
{manage ? (
<div className="xmind-calendar-personal">
<h3>个人日历</h3>
<p>保存在当前浏览器,可用于其他工作表。</p>
{templates.length ? (
templates.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
type="button"
aria-label={`删除个人日历 ${item.name}`}
onClick={() =>
storeTemplates(
templates.filter((value) => value.id !== item.id)
)
}
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p>还没有个人日历。</p>
)}
<label>
名称
<input
aria-label="个人日历名称"
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
/>
</label>
<button type="button" onClick={saveTemplate}>
保存为个人日历
</button>
</div>
) : null}
</div>
<div className="xmind-calendar-settings">
<label>
日历名称
<input
aria-label="项目日历名称"
value={draft.name}
onChange={(event) =>
setDraft((value) => ({ ...value, name: event.target.value }))
}
/>
</label>
<label className="xmind-calendar-skip">
<input
type="checkbox"
aria-label="跳过非工作日"
checked={skip}
onChange={(event) => setSkip(event.target.checked)}
/>
跳过非工作日
</label>
<h3>每周工作日</h3>
<div className="xmind-calendar-working-week">
{WEEKDAYS.map((day, index) => (
<label key={day}>
<input
type="checkbox"
aria-label={`周${day}为工作日`}
checked={draft.defaultWorkingDays.includes(index + 1)}
onChange={(event) => {
const value = event.target.checked
? [...draft.defaultWorkingDays, index + 1].sort()
: draft.defaultWorkingDays.filter(
(value) => value !== index + 1
);
if (!value.length) {
setError("请至少保留一个每周工作日。");
return;
}
setDraft((settings) => ({
...settings,
defaultWorkingDays: value,
}));
setError("");
}}
/>
{day}
</label>
))}
</div>
<div className="xmind-calendar-rule-heading">
<h3>例外</h3>
<button
type="button"
aria-label="添加工作日例外"
onClick={addException}
>
<Plus size={16} />
</button>
</div>
<div className="xmind-calendar-rules">
{[...draft.rules].reverse().map((rule) => (
<div key={rule.id}>
<button
type="button"
aria-label={`编辑工作日例外 ${rule.name}`}
onClick={() => setException(copy(rule))}
>
<strong>{rule.name}</strong>
<span>
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
{rule.selectedDates.join("、")} ·{" "}
{FREQUENCY_LABELS[rule.frequency]}
{rule.interval && rule.interval > 1
? `(间隔 ${rule.interval})`
: ""}
</span>
</button>
<button
type="button"
aria-label={`删除工作日例外 ${rule.name}`}
onClick={() =>
setDraft((value) => ({
...value,
rules: value.rules.filter(
(item) => item.id !== rule.id
),
}))
}
>
<Trash2 size={14} />
</button>
</div>
))}
{!draft.rules.length ? (
<p>可添加节假日、调休或重复例外。</p>
) : null}
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
{message ? <p role="status">{message}</p> : null}
</div>
</div>
<footer>
<div>
{config.settings ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
if (onSave({ settings: null, skip: false })) onClose();
}}
>
移除项目日历
</button>
) : null}
<button type="button" onClick={() => setManage(true)}>
保存为日历
</button>
</div>
<div>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
onClick={save}
>
保存工作日
</button>
</div>
</footer>
</section>
{exception ? (
<CalendarExceptionEditor
rule={exception}
onSave={(rule) =>
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}
</div>
);
}