| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710 |
- 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();
|