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.
651 lines
21 KiB
TypeScript
651 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|