const svg = document.getElementById('mindmapSvg'); const viewportGroup = document.getElementById('viewportGroup'); const linksLayer = document.getElementById('linksLayer'); const nodesLayer = document.getElementById('nodesLayer'); const canvasWrapper = document.getElementById('canvasWrapper'); const rootForm = document.getElementById('rootForm'); const rootNameInput = document.getElementById('rootNameInput'); const fitViewBtn = document.getElementById('fitViewBtn'); const resetViewBtn = document.getElementById('resetViewBtn'); const exportJsonBtn = document.getElementById('exportJsonBtn'); const exportSvgBtn = document.getElementById('exportSvgBtn'); const statRoots = document.getElementById('statRoots'); const statTotal = document.getElementById('statTotal'); const statRunning = document.getElementById('statRunning'); const statBug = document.getElementById('statBug'); const nodeModal = document.getElementById('nodeModal'); const closeModalBtn = document.getElementById('closeModalBtn'); const nodeForm = document.getElementById('nodeForm'); const nodeIdInput = document.getElementById('nodeIdInput'); const nameInput = document.getElementById('nameInput'); const statusInput = document.getElementById('statusInput'); const startTimeInput = document.getElementById('startTimeInput'); const endTimeInput = document.getElementById('endTimeInput'); const planInput = document.getElementById('planInput'); const bugInput = document.getElementById('bugInput'); const notesInput = document.getElementById('notesInput'); const createdAtInfo = document.getElementById('createdAtInfo'); const updatedAtInfo = document.getElementById('updatedAtInfo'); const childNameInput = document.getElementById('childNameInput'); const addChildBtn = document.getElementById('addChildBtn'); const deleteNodeBtn = document.getElementById('deleteNodeBtn'); const STATUS_LABELS = { pending: '待开始', 'in-progress': '执行中', completed: '已完成', bug: 'Bug 终止', }; const VIEW = { x: 120, y: 80, scale: 1, minScale: 0.32, maxScale: 2.5, }; const LAYOUT = { nodeWidth: 250, nodeHeight: 94, horizontalGap: 330, verticalGap: 122, rootGap: 60, }; let tasks = []; let selectedTaskId = null; let latestBounds = null; let panning = false; let panStart = { x: 0, y: 0 }; let panOrigin = { x: 0, y: 0 }; function svgEl(tagName, attrs = {}) { const node = document.createElementNS('http://www.w3.org/2000/svg', tagName); Object.entries(attrs).forEach(([key, value]) => { if (value !== undefined && value !== null) { node.setAttribute(key, String(value)); } }); return node; } async function api(path, options = {}) { const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || '请求失败'); } return payload; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function applyViewport() { viewportGroup.setAttribute('transform', `translate(${VIEW.x} ${VIEW.y}) scale(${VIEW.scale})`); } function toDateTimeLocal(isoValue) { if (!isoValue) { return ''; } const date = new Date(isoValue); if (Number.isNaN(date.getTime())) { return ''; } const pad = (num) => String(num).padStart(2, '0'); const y = date.getFullYear(); const m = pad(date.getMonth() + 1); const d = pad(date.getDate()); const h = pad(date.getHours()); const min = pad(date.getMinutes()); return `${y}-${m}-${d}T${h}:${min}`; } function fromDateTimeLocal(localValue) { if (!localValue) { return null; } const date = new Date(localValue); if (Number.isNaN(date.getTime())) { return null; } return date.toISOString(); } function toPrettyTime(isoValue) { if (!isoValue) { return '-'; } const date = new Date(isoValue); if (Number.isNaN(date.getTime())) { return '-'; } return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( date.getDate(), ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String( date.getMinutes(), ).padStart(2, '0')}`; } function truncate(text, max = 20) { if (!text) { return ''; } if (text.length <= max) { return text; } return `${text.slice(0, max - 1)}…`; } function buildTreeLayout(sourceTasks) { const byId = new Map(); sourceTasks.forEach((task) => { byId.set(task.id, { ...task, children: [] }); }); const roots = []; byId.forEach((task) => { if (task.parentId && byId.has(task.parentId)) { byId.get(task.parentId).children.push(task); } else { roots.push(task); } }); const sortByTime = (a, b) => { const timeA = Date.parse(a.createdAt || '') || 0; const timeB = Date.parse(b.createdAt || '') || 0; return timeA - timeB; }; roots.sort(sortByTime); byId.forEach((task) => { task.children.sort(sortByTime); }); const positions = new Map(); const links = []; let yCursor = 120; const walk = (node, depth) => { const x = depth * LAYOUT.horizontalGap + 180; if (!node.children.length) { positions.set(node.id, { x, y: yCursor }); yCursor += LAYOUT.verticalGap; return; } const startY = yCursor; node.children.forEach((child) => { walk(child, depth + 1); }); const endY = yCursor - LAYOUT.verticalGap; const centerY = (startY + endY) / 2; positions.set(node.id, { x, y: centerY }); }; roots.forEach((root) => { walk(root, 0); yCursor += LAYOUT.rootGap; }); byId.forEach((node) => { if (!node.parentId || !positions.has(node.parentId) || !positions.has(node.id)) { return; } links.push({ from: positions.get(node.parentId), to: positions.get(node.id), status: node.status, }); }); let bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0, }; if (positions.size) { const xs = []; const ys = []; positions.forEach(({ x, y }) => { xs.push(x); ys.push(y); }); bounds = { minX: Math.min(...xs) - (LAYOUT.nodeWidth / 2) - 40, maxX: Math.max(...xs) + (LAYOUT.nodeWidth / 2) + 40, minY: Math.min(...ys) - (LAYOUT.nodeHeight / 2) - 40, maxY: Math.max(...ys) + (LAYOUT.nodeHeight / 2) + 40, width: 0, height: 0, }; bounds.width = bounds.maxX - bounds.minX; bounds.height = bounds.maxY - bounds.minY; } return { positions, links, roots, bounds }; } function renderEmptyState() { linksLayer.innerHTML = ''; nodesLayer.innerHTML = ''; const textNode = svgEl('text', { x: 220, y: 180, class: 'empty-state', }); textNode.textContent = '还没有节点,先在上方新建一个主对话。'; nodesLayer.appendChild(textNode); } function renderGraph() { if (!tasks.length) { latestBounds = null; renderEmptyState(); return; } const { positions, links, bounds } = buildTreeLayout(tasks); latestBounds = bounds; linksLayer.innerHTML = ''; nodesLayer.innerHTML = ''; links.forEach((link) => { const c1x = link.from.x + 90; const c2x = link.to.x - 90; const path = svgEl('path', { class: `link-path status-${link.status}`, d: `M ${link.from.x} ${link.from.y} C ${c1x} ${link.from.y}, ${c2x} ${link.to.y}, ${link.to.x} ${link.to.y}`, }); linksLayer.appendChild(path); }); tasks.forEach((task) => { const pos = positions.get(task.id); if (!pos) { return; } const nodeGroup = svgEl('g', { class: `node-group${selectedTaskId === task.id ? ' selected' : ''}`, transform: `translate(${pos.x} ${pos.y})`, 'data-id': task.id, }); if (task.status === 'in-progress') { const ring = svgEl('rect', { x: -133, y: -52, width: 266, height: 104, rx: 24, class: 'progress-ring', }); nodeGroup.appendChild(ring); } const card = svgEl('rect', { class: 'node-card', x: -(LAYOUT.nodeWidth / 2), y: -(LAYOUT.nodeHeight / 2), rx: 18, width: LAYOUT.nodeWidth, height: LAYOUT.nodeHeight, }); nodeGroup.appendChild(card); const statusDot = svgEl('circle', { class: `node-status-dot status-${task.status}`, cx: -(LAYOUT.nodeWidth / 2) + 16, cy: -(LAYOUT.nodeHeight / 2) + 18, r: 7, }); nodeGroup.appendChild(statusDot); const title = svgEl('text', { class: 'node-title', x: -(LAYOUT.nodeWidth / 2) + 32, y: -(LAYOUT.nodeHeight / 2) + 22, }); title.textContent = truncate(task.name, 17); nodeGroup.appendChild(title); const subtitle = svgEl('text', { class: 'node-subtitle', x: -(LAYOUT.nodeWidth / 2) + 32, y: -(LAYOUT.nodeHeight / 2) + 43, }); subtitle.textContent = `${STATUS_LABELS[task.status] || task.status} · ${toPrettyTime( task.updatedAt, )}`; nodeGroup.appendChild(subtitle); const line2 = svgEl('text', { class: 'node-subtitle', x: -(LAYOUT.nodeWidth / 2) + 32, y: -(LAYOUT.nodeHeight / 2) + 64, }); const isRoot = !task.parentId; line2.textContent = isRoot ? '主对话节点' : `父节点:${truncate(getParentName(task.parentId), 9)}`; nodeGroup.appendChild(line2); const badge = svgEl('rect', { class: 'node-badge', x: LAYOUT.nodeWidth / 2 - 62, y: -(LAYOUT.nodeHeight / 2) + 8, width: 52, height: 18, rx: 9, }); nodeGroup.appendChild(badge); const badgeText = svgEl('text', { class: 'node-badge-text', x: LAYOUT.nodeWidth / 2 - 55, y: -(LAYOUT.nodeHeight / 2) + 21, }); badgeText.textContent = isRoot ? '主节点' : '子节点'; nodeGroup.appendChild(badgeText); nodeGroup.addEventListener('click', (event) => { event.stopPropagation(); openNodeModal(task.id); }); nodesLayer.appendChild(nodeGroup); }); } function getParentName(parentId) { const parent = tasks.find((item) => item.id === parentId); return parent ? parent.name : '未知'; } function updateStats() { const roots = tasks.filter((task) => !task.parentId).length; const running = tasks.filter((task) => task.status === 'in-progress').length; const bugCount = tasks.filter((task) => task.status === 'bug').length; statRoots.textContent = String(roots); statTotal.textContent = String(tasks.length); statRunning.textContent = String(running); statBug.textContent = String(bugCount); } async function refresh() { tasks = await api('/api/tasks'); renderGraph(); updateStats(); applyViewport(); } function fitView() { if (!latestBounds || !tasks.length) { VIEW.x = 120; VIEW.y = 80; VIEW.scale = 1; applyViewport(); return; } const margin = 44; const width = canvasWrapper.clientWidth || 1; const height = canvasWrapper.clientHeight || 1; const scaleX = (width - margin * 2) / latestBounds.width; const scaleY = (height - margin * 2) / latestBounds.height; const nextScale = clamp(Math.min(scaleX, scaleY), VIEW.minScale, 1.25); VIEW.scale = nextScale; VIEW.x = (width - latestBounds.width * nextScale) / 2 - latestBounds.minX * nextScale; VIEW.y = (height - latestBounds.height * nextScale) / 2 - latestBounds.minY * nextScale; applyViewport(); } function openNodeModal(taskId) { const task = tasks.find((item) => item.id === taskId); if (!task) { return; } selectedTaskId = task.id; renderGraph(); nodeIdInput.value = task.id; nameInput.value = task.name || ''; statusInput.value = task.status || 'pending'; startTimeInput.value = toDateTimeLocal(task.startTime); endTimeInput.value = toDateTimeLocal(task.endTime); planInput.value = task.plannedApproach || ''; bugInput.value = task.bugDetails || ''; notesInput.value = task.notes || ''; childNameInput.value = ''; createdAtInfo.textContent = `创建时间:${toPrettyTime(task.createdAt)}`; updatedAtInfo.textContent = `更新时间:${toPrettyTime(task.updatedAt)}`; nodeModal.classList.remove('hidden'); nodeModal.setAttribute('aria-hidden', 'false'); } function closeNodeModal() { nodeModal.classList.add('hidden'); nodeModal.setAttribute('aria-hidden', 'true'); selectedTaskId = null; renderGraph(); } async function createRootTask(name) { const text = name.trim(); if (!text) { return; } await api('/api/tasks', { method: 'POST', body: JSON.stringify({ name: text, status: 'pending', }), }); } async function createChildTask(parentId, name) { const text = name.trim(); if (!text || !parentId) { return; } await api('/api/tasks', { method: 'POST', body: JSON.stringify({ name: text, parentId, status: 'pending', }), }); } async function updateCurrentNode() { const nodeId = nodeIdInput.value; if (!nodeId) { return; } const payload = { name: nameInput.value.trim(), status: statusInput.value, startTime: fromDateTimeLocal(startTimeInput.value), endTime: fromDateTimeLocal(endTimeInput.value), plannedApproach: planInput.value.trim(), bugDetails: bugInput.value.trim(), notes: notesInput.value.trim(), }; await api(`/api/tasks/${encodeURIComponent(nodeId)}`, { method: 'PUT', body: JSON.stringify(payload), }); } async function deleteCurrentNode() { const nodeId = nodeIdInput.value; if (!nodeId) { return; } const target = tasks.find((item) => item.id === nodeId); if (!target) { return; } const sure = window.confirm(`确认删除节点「${target.name}」及其全部子节点吗?`); if (!sure) { return; } await api(`/api/tasks/${encodeURIComponent(nodeId)}`, { method: 'DELETE' }); closeNodeModal(); await refresh(); } function exportAsJson() { const filename = `openclaw-board-${new Date().toISOString().slice(0, 10)}.json`; const blob = new Blob([`${JSON.stringify(tasks, null, 2)}\n`], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function exportAsSvg() { const clonedSvg = svg.cloneNode(true); clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clonedSvg.setAttribute('width', String(svg.clientWidth || 1400)); clonedSvg.setAttribute('height', String(svg.clientHeight || 900)); const styles = ` .link-path{fill:none;stroke:#cfb8c3;stroke-width:2;opacity:.9} .link-path.status-in-progress{stroke:#c98ea5} .link-path.status-completed{stroke:#98bca7} .link-path.status-bug{stroke:#c88f98} .node-card{fill:#fff8fb;stroke:#dfc2cf;stroke-width:1.4} .status-pending{fill:#b9aab2} .status-in-progress{fill:#d58ca8} .status-completed{fill:#90bca2} .status-bug{fill:#c98289} .node-status-dot{stroke:rgba(255,255,255,.9);stroke-width:1} .node-title{font-size:15px;font-weight:600;fill:#4e3844} .node-subtitle{font-size:12px;fill:#806673} .node-badge{fill:#f2e2ea;stroke:#dfc2cf;stroke-width:1} .node-badge-text{font-size:10px;fill:#7a5d6a} .progress-ring{fill:none;stroke:#cd8ea6;stroke-width:2;stroke-dasharray:6 5} `; const styleTag = svgEl('style'); styleTag.textContent = styles; clonedSvg.prepend(styleTag); const source = new XMLSerializer().serializeToString(clonedSvg); const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `openclaw-board-${new Date().toISOString().slice(0, 10)}.svg`; link.click(); URL.revokeObjectURL(url); } rootForm.addEventListener('submit', async (event) => { event.preventDefault(); const name = rootNameInput.value; if (!name.trim()) { return; } try { await createRootTask(name); rootNameInput.value = ''; await refresh(); fitView(); } catch (error) { window.alert(error.message); } }); nodeForm.addEventListener('submit', async (event) => { event.preventDefault(); try { await updateCurrentNode(); await refresh(); openNodeModal(nodeIdInput.value); } catch (error) { window.alert(error.message); } }); addChildBtn.addEventListener('click', async () => { try { const parentId = nodeIdInput.value; const childName = childNameInput.value; if (!childName.trim()) { window.alert('请输入子节点标题'); return; } await createChildTask(parentId, childName); childNameInput.value = ''; await refresh(); openNodeModal(parentId); fitView(); } catch (error) { window.alert(error.message); } }); deleteNodeBtn.addEventListener('click', async () => { try { await deleteCurrentNode(); } catch (error) { window.alert(error.message); } }); fitViewBtn.addEventListener('click', () => fitView()); resetViewBtn.addEventListener('click', () => { VIEW.x = 120; VIEW.y = 80; VIEW.scale = 1; applyViewport(); }); exportJsonBtn.addEventListener('click', () => exportAsJson()); exportSvgBtn.addEventListener('click', () => exportAsSvg()); closeModalBtn.addEventListener('click', () => closeNodeModal()); nodeModal.addEventListener('click', (event) => { if (event.target && event.target.dataset && event.target.dataset.close === 'true') { closeNodeModal(); } }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !nodeModal.classList.contains('hidden')) { closeNodeModal(); } }); canvasWrapper.addEventListener('wheel', (event) => { event.preventDefault(); const zoomFactor = event.deltaY < 0 ? 1.08 : 0.92; const nextScale = clamp(VIEW.scale * zoomFactor, VIEW.minScale, VIEW.maxScale); VIEW.scale = nextScale; applyViewport(); }); canvasWrapper.addEventListener('pointerdown', (event) => { if (event.button !== 0) { return; } const target = event.target; if (target && target.closest && target.closest('.node-group')) { return; } panning = true; panStart = { x: event.clientX, y: event.clientY }; panOrigin = { x: VIEW.x, y: VIEW.y }; canvasWrapper.setPointerCapture(event.pointerId); }); canvasWrapper.addEventListener('pointermove', (event) => { if (!panning) { return; } const dx = event.clientX - panStart.x; const dy = event.clientY - panStart.y; VIEW.x = panOrigin.x + dx; VIEW.y = panOrigin.y + dy; applyViewport(); }); canvasWrapper.addEventListener('pointerup', (event) => { panning = false; canvasWrapper.releasePointerCapture(event.pointerId); }); canvasWrapper.addEventListener('pointercancel', (event) => { panning = false; canvasWrapper.releasePointerCapture(event.pointerId); }); window.addEventListener('resize', () => { if (tasks.length) { fitView(); } }); async function bootstrap() { try { await refresh(); if (tasks.length) { fitView(); } else { applyViewport(); } } catch (error) { window.alert(`加载失败:${error.message}`); } } bootstrap();