"use client"; import { CalendarDays, ChevronDown, ChevronRight, Download, Link2, Minus, Plus, Printer, Search, UserRound, X, } from "lucide-react"; import type { PlaitElement } from "@plait/core"; import { useEffect, useMemo, useRef, useState } from "react"; import { addDays, dateIndex, getTaskRows, indexDate, todayDate, type TopicTask, } from "@/lib/flow-board/topic-tasks"; import { getTopicEntries } from "@/lib/flow-board/topic-details"; import { activeWorkingCalendar, getProjectCalendar, isWorkingDate, preserveNonWorkingDue, taskDueDate, workingDaysBetween, } from "@/lib/flow-board/project-calendar"; const ROW = 38, HEADER = 64; export function XmindGantt({ elements, title, onClose, onEdit, onCalendar, onChange, onUndo, onRedo, }: { elements: PlaitElement[]; title: string; onClose: () => void; onEdit: (id: string) => void; onCalendar: () => void; onChange: (id: string, task: TopicTask) => void; onUndo: () => void; onRedo: () => void; }) { const panelRef = useRef(null); useEffect(() => { const previous = document.activeElement as HTMLElement | null; panelRef.current?.querySelector("button")?.focus(); return () => { if (previous?.isConnected) previous.focus(); }; }, []); const [scale, setScale] = useState(34), [query, setQuery] = useState(""), [assignee, setAssignee] = useState(""), [status, setStatus] = useState("all"); const [collapsed, setCollapsed] = useState>(new Set()); const [selected, setSelected] = useState([]); const [drag, setDrag] = useState<{ id: string; x: number; kind: "move" | "start" | "due"; task: TopicTask; days: number; } | null>(null); const scrollRef = useRef(null); const rowScrollRef = useRef(null); const projectCalendar = getProjectCalendar(elements); const calendar = activeWorkingCalendar(projectCalendar); const rows = getTaskRows(elements); const visible = rows.filter( (row) => row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) && (!assignee || row.task.assignees.includes(assignee)) && (status === "all" || (status === "done" && row.task.progress === 100) || (status === "open" && row.task.progress < 100)) && !rows.some( (parent) => collapsed.has(parent.element.id) && parent.path.length < row.path.length && parent.path.every((n, i) => row.path[i] === n) ) ); const candidates = getTopicEntries(elements).filter( (e) => !rows.some((r) => r.element.id === e.element.id) ); const [candidate, setCandidate] = useState(""); const [notice, setNotice] = useState(""); const today = dateIndex(todayDate()); const dated = rows.filter((r) => r.task.start && r.task.due); const from = Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3; const last = Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4; // Very long plans use a virtual day grid; no thousands of day DOM nodes. const width = Math.max(800, (last - from + 1) * scale); const [scrollX, setScrollX] = useState(0), [viewportWidth, setViewportWidth] = useState(1000); const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30; const monthBands = useMemo(() => { const start = from + Math.max(0, Math.floor(scrollX / scale) - 31), end = Math.min( last, from + Math.ceil((scrollX + viewportWidth) / scale) + 31 ); const bands: { date: string; offset: number; days: number }[] = []; for (let day = start; day <= end; ) { const date = indexDate(day), next = dateIndex(`${date.slice(0, 7)}-01`); const d = new Date(next * 86_400_000); d.setUTCMonth(d.getUTCMonth() + 1); const monthEnd = d.getTime() / 86_400_000; const bandStart = Math.max(from, next), bandEnd = Math.min(last + 1, monthEnd); bands.push({ date: date.slice(0, 7), offset: bandStart - from, days: bandEnd - bandStart, }); day = monthEnd; } return bands; }, [from, last, scrollX, scale, viewportWidth]); const ticks = useMemo(() => { const result: number[] = []; const begin = Math.max( 0, Math.floor(scrollX / scale / tickStep) * tickStep - tickStep ); const end = Math.min( last - from, Math.ceil((scrollX + viewportWidth) / scale) + tickStep ); for (let i = begin; i <= end; i += tickStep) result.push(i); return result; }, [scrollX, scale, tickStep, viewportWidth, last, from]); const moveTask = (task: TopicTask, days: number) => { const start = addDays(task.start, days); return { ...task, start, due: preserveNonWorkingDue( addDays(task.due, days), taskDueDate(start, task.duration, calendar), calendar ), }; }; const effective = (row: (typeof rows)[number]) => { if (drag?.id !== row.element.id || !drag.days) return row.task; const task = row.task; if (drag.kind === "move") return moveTask(task, drag.days); const start = drag.kind === "start" ? addDays(task.start, drag.days) : task.start; const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due; return { ...task, start, due, duration: Math.max(1, workingDaysBetween(start, due, calendar)), }; }; const goToday = () => scrollRef.current?.scrollTo({ left: Math.max(0, (today - from) * scale - 200), behavior: "smooth", }); const downloadCsv = () => { const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`; const content = "\ufeff" + [ [ "任务名称", "开始日期", "截止日期", "时长(天)", "进度(%)", "优先级", "负责人", "前置任务", ], ...visible.map((r) => [ r.title, r.task.start, r.task.due, r.task.duration, r.task.progress, r.task.priority == null ? "" : `P${r.task.priority}`, r.task.assignees.join(", "), r.task.dependencies .map( (d) => `${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}` ) .join(", "), ]), ] .map((row) => row.map(quote).join(",")) .join("\r\n"); const url = URL.createObjectURL( new Blob([content], { type: "text/csv;charset=utf-8" }) ); const a = document.createElement("a"); a.href = url; a.download = `${title}-甘特图.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const linkTasks = () => { if (selected.length !== 2) return; const target = rows.find((r) => r.element.id === selected[1]); if (!target) return; onChange(target.element.id, { ...target.task, dependencies: [ ...target.task.dependencies.filter((d) => d.id !== selected[0]), { id: selected[0], type: "FS", lag: 0 }, ], }); }; return (
e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") { e.stopPropagation(); onClose(); } if (e.key === "Tab") { const controls = Array.from( panelRef.current?.querySelectorAll( 'button:not(:disabled),input,select,[tabindex="0"]' ) || [] ); const first = controls[0], last = controls.at(-1); if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); } if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); } } if ( (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLSelectElement) ) { e.preventDefault(); e.stopPropagation(); if (e.shiftKey) onRedo(); else onUndo(); } }} >

甘特图

{title}
setScale(Number(e.target.value))} />
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""} {visible.length} 个任务
任务名称 开始日期 时长
{ const node = scrollRef.current; if (node && node.scrollTop !== e.currentTarget.scrollTop) node.scrollTop = e.currentTarget.scrollTop; }} data-gantt-row-scroll="true" > {visible.map((row) => (
setSelected((s) => e.target.checked ? [...s, row.element.id] : s.filter((id) => id !== row.element.id) ) } /> {row.summary ? ( ) : null}
{effective(row).duration} 天
))} {!visible.length ? (
{rows.length ? "没有匹配的任务" : "没有任务"}

{rows.length ? "调整搜索或筛选条件。" : "请选择一个主题以添加任务。"}

) : null}
{ setScrollX(e.currentTarget.scrollLeft); setViewportWidth(e.currentTarget.clientWidth); const rowScroll = rowScrollRef.current; if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop) rowScroll.scrollTop = e.currentTarget.scrollTop; }} >
{calendar ? Array.from( { length: Math.ceil(viewportWidth / scale) + 3 }, (_, index) => Math.max(0, Math.floor(scrollX / scale) - 1) + index ) .filter( (offset) => offset <= last - from && !isWorkingDate(indexDate(from + offset), calendar) ) .map((offset) => (
)) : null}
{ticks.map((offset) => { const date = indexDate(from + offset); return (
{tickStep >= 30 ? date.slice(5, 7) + "月" : tickStep >= 7 ? date.slice(5) : String(Number(date.slice(8)))}
); })} {monthBands.map((band) => (
{band.date.replace("-", "年")}月
))}
{visible.flatMap((row, i) => row.task.dependencies.map((d) => { const fromIndex = visible.findIndex( (r) => r.element.id === d.id ); if (fromIndex < 0) return null; const before = effective(visible[fromIndex]), after = effective(row); if (!before.start || !after.start) return null; const x1 = (dateIndex( d.type[0] === "F" ? before.due : before.start ) - from + (d.type[0] === "F" ? 1 : 0)) * scale, x2 = (dateIndex(d.type[1] === "F" ? after.due : after.start) - from + (d.type[1] === "F" ? 1 : 0)) * scale, y1 = HEADER + fromIndex * ROW + ROW / 2, y2 = HEADER + i * ROW + ROW / 2; return ( ); }) )} {visible.map((row, i) => selected.includes(row.element.id) ? (
) : null )} {visible.map((row, i) => { const t = effective(row); if (!t.start || !t.due) return null; const left = (dateIndex(t.start) - from) * scale, barWidth = Math.max( 6, (dateIndex(t.due) - dateIndex(t.start) + 1) * scale ); return (
onEdit(row.element.id)} onKeyDown={(e) => { if (e.key === "Enter") onEdit(row.element.id); if ( !row.summary && ["ArrowLeft", "ArrowRight"].includes(e.key) ) { e.preventDefault(); const days = e.key === "ArrowLeft" ? -1 : 1; onChange(row.element.id, moveTask(t, days)); } }} onPointerDown={(e) => { if (row.summary || e.button !== 0) return; e.preventDefault(); setSelected((ids) => e.metaKey || e.ctrlKey ? ids.includes(row.element.id) ? ids.filter((id) => id !== row.element.id) : [...ids, row.element.id] : [row.element.id] ); e.currentTarget.setPointerCapture(e.pointerId); const kind = (e.target as HTMLElement).dataset.resize === "start" ? "start" : (e.target as HTMLElement).dataset.resize === "due" ? "due" : "move"; setDrag({ id: row.element.id, x: e.clientX, kind, task: row.task, days: 0, }); }} onPointerMove={(e) => { if (drag?.id !== row.element.id) return; let days = Math.round((e.clientX - drag.x) / scale); if (drag.kind === "start") days = Math.min( dateIndex(drag.task.due) - dateIndex(drag.task.start), days ); if (drag.kind === "due") days = Math.max( dateIndex(drag.task.start) - dateIndex(drag.task.due), days ); setDrag({ ...drag, days }); }} onPointerUp={() => { if (drag?.id !== row.element.id) return; if (drag.days) onChange(row.element.id, t); setDrag(null); }} onPointerCancel={() => setDrag(null)} >
{!row.summary ? ( <> ) : null} {row.title}
); })}
); }