ai-chatbot/lib/flow-board/topic-tasks.ts
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

269 lines
8.3 KiB
TypeScript

import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export const TASK_PROVIDER = "org.xmind.ui.task";
import {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
export {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
taskStartDate,
preserveNonWorkingDue,
workingDaysBetween,
type ProjectCalendarConfig,
} from "./project-calendar";
export type TaskDependency = {
id: string;
type: "FS" | "SS" | "FF" | "SF";
lag: number;
};
export type TopicTask = {
start: string;
due: string;
duration: number;
progress: number;
priority: number | null;
assignees: string[];
dependencies: TaskDependency[];
creator?: string;
};
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
export function getTaskContent(element: PlaitElement) {
const extensions = record(element.xmind).extensions;
if (!Array.isArray(extensions)) return null;
const ext = extensions.map(record).find((e) => e.provider === TASK_PROVIDER);
const content = record(ext?.content);
return typeof content.progress === "number" ? content : null;
}
export function getTopicTask(element: PlaitElement): TopicTask | null {
const c = getTaskContent(element);
if (!c) return null;
return {
start: timestampDate(c.start),
due: timestampDate(c.due),
duration: Math.max(1, Math.round(Number(c.duration || DAY) / DAY)),
progress: Math.max(0, Math.min(100, Math.round(Number(c.progress) * 100))),
priority: typeof c.priority === "number" ? c.priority : null,
assignees: Array.isArray(c.assignees)
? c.assignees.filter((s): s is string => typeof s === "string")
: [],
dependencies: Array.isArray(c.dependencies)
? c.dependencies.flatMap((d) => {
const item = record(d);
return typeof item.id === "string" &&
["FS", "SS", "FF", "SF"].includes(String(item.type))
? [
{
id: item.id,
type: item.type as TaskDependency["type"],
lag: Number(item.lag) || 0,
},
]
: [];
})
: [],
...(typeof c.creator === "string" ? { creator: c.creator } : {}),
};
}
export function newTopicTask(): TopicTask {
return {
start: todayDate(),
due: todayDate(),
duration: 1,
progress: 0,
priority: null,
assignees: [],
dependencies: [],
};
}
export function patchTopicTask(element: PlaitElement, task: TopicTask | null) {
const metadata = { ...record(element.xmind) };
const extensions = Array.isArray(metadata.extensions)
? metadata.extensions.map(record)
: [];
const existing = extensions.find((e) => e.provider === TASK_PROVIDER);
const others = extensions.filter((e) => e.provider !== TASK_PROVIDER);
if (task)
others.push({
...existing,
provider: TASK_PROVIDER,
content: {
...record(existing?.content),
...task,
start: dateTimestamp(task.start),
due: dateTimestamp(task.due, true),
duration: task.duration * DAY,
progress: task.progress / 100,
},
});
metadata.extensions = others;
return metadata;
}
export function validateTopicTask(
id: string,
task: TopicTask,
elements: PlaitElement[]
): string | null {
if (
(task.start && !Number.isFinite(dateIndex(task.start))) ||
(task.due && !Number.isFinite(dateIndex(task.due)))
)
return "请输入有效日期。";
if (task.start && task.due && task.due < task.start)
return "截止日期不能早于开始日期。";
if (
!Number.isInteger(task.duration) ||
task.duration < 1 ||
task.duration > 36500
)
return "工期必须是 1 至 36500 天。";
if (
!Number.isFinite(task.progress) ||
task.progress < 0 ||
task.progress > 100
)
return "进度必须在 0% 至 100% 之间。";
const entries = getTopicEntries(elements),
byId = new Map(entries.map((e) => [e.element.id, e]));
const me = byId.get(id);
for (const dep of task.dependencies) {
const target = byId.get(dep.id);
if (!target || !getTopicTask(target.element)) return "前置任务已不存在。";
if (!Number.isFinite(dep.lag)) return "间隔天数无效。";
if (
dep.id === id ||
(me &&
(target.path.every((n, i) => me.path[i] === n) ||
me.path.every((n, i) => target.path[i] === n)))
)
return "任务不能依赖自身、父任务或子任务。";
const visit = (current: string, seen = new Set<string>()): boolean => {
if (current === id) return true;
if (seen.has(current)) return false;
seen.add(current);
const item = byId.get(current);
return item
? (getTopicTask(item.element)?.dependencies || []).some((d) =>
visit(d.id, seen)
)
: false;
};
if (visit(dep.id)) return "前置任务形成循环,请调整依赖关系。";
}
return null;
}
export function getTaskRows(elements: PlaitElement[]) {
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
return getTopicEntries(elements).flatMap((entry) => {
const task = getTopicTask(entry.element);
if (!task) return [];
const descendants = getTopicEntries(entry.element.children || [])
.map((e) => getTopicTask(e.element))
.filter((t): t is TopicTask => Boolean(t?.start && t.due));
if (!descendants.length) return [{ ...entry, task, summary: false }];
const start = descendants.map((t) => t.start).sort()[0],
due = descendants
.map((t) => t.due)
.sort()
.at(-1)!;
const total = descendants.reduce((s, t) => s + t.duration, 0);
return [
{
...entry,
summary: true,
task: {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
progress: Math.round(
descendants.reduce((s, t) => s + t.progress * t.duration, 0) / total
),
},
},
];
});
}
/** Propagate finish/start constraints in topological order; the caller commits one undo batch. */
export function scheduleTaskChanges(
elements: PlaitElement[],
id: string,
task: TopicTask,
config: ProjectCalendarConfig = getProjectCalendar(elements)
): Map<string, TopicTask> {
const error = validateTopicTask(id, task, elements);
if (error) throw new Error(error);
const calendar = activeWorkingCalendar(config);
const tasks = new Map(
getTopicEntries(elements).flatMap((e) => {
const t = getTopicTask(e.element);
return t ? [[e.element.id, t] as const] : [];
})
);
tasks.set(id, task);
const changed = new Map<string, TopicTask>([[id, task]]),
visiting = new Set<string>(),
done = new Set<string>();
const visit = (key: string) => {
if (done.has(key)) return;
if (visiting.has(key)) return;
visiting.add(key);
const t = tasks.get(key)!;
for (const d of t.dependencies) if (tasks.has(d.id)) visit(d.id);
if (t.start && t.due) {
let earliest = dateIndex(t.start);
for (const d of t.dependencies) {
const before = tasks.get(d.id);
if (!before?.start || !before.due) continue;
const lag = d.lag / DAY;
const anchor = d.type[0] === "F" ? before.due : before.start;
const endpoint = addDays(
anchor,
d.type[0] === "F" ? Math.ceil(lag) : Math.floor(lag)
);
const constraint =
d.type === "FS"
? addDays(endpoint, 1)
: d.type === "SS"
? endpoint
: taskStartDate(endpoint, t.duration, calendar);
earliest = Math.max(earliest, dateIndex(constraint));
}
const start = indexDate(earliest);
const due = preserveNonWorkingDue(
t.due,
taskDueDate(start, t.duration, calendar),
calendar
);
if (start !== t.start || (calendar && due !== t.due)) {
const next = { ...t, start, due };
tasks.set(key, next);
changed.set(key, next);
}
}
visiting.delete(key);
done.add(key);
};
tasks.forEach((_, key) => visit(key));
return changed;
}