app.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. const svg = document.getElementById('mindmapSvg');
  2. const viewportGroup = document.getElementById('viewportGroup');
  3. const linksLayer = document.getElementById('linksLayer');
  4. const nodesLayer = document.getElementById('nodesLayer');
  5. const canvasWrapper = document.getElementById('canvasWrapper');
  6. const rootForm = document.getElementById('rootForm');
  7. const rootNameInput = document.getElementById('rootNameInput');
  8. const fitViewBtn = document.getElementById('fitViewBtn');
  9. const resetViewBtn = document.getElementById('resetViewBtn');
  10. const exportJsonBtn = document.getElementById('exportJsonBtn');
  11. const exportSvgBtn = document.getElementById('exportSvgBtn');
  12. const statRoots = document.getElementById('statRoots');
  13. const statTotal = document.getElementById('statTotal');
  14. const statRunning = document.getElementById('statRunning');
  15. const statBug = document.getElementById('statBug');
  16. const nodeModal = document.getElementById('nodeModal');
  17. const closeModalBtn = document.getElementById('closeModalBtn');
  18. const nodeForm = document.getElementById('nodeForm');
  19. const nodeIdInput = document.getElementById('nodeIdInput');
  20. const nameInput = document.getElementById('nameInput');
  21. const statusInput = document.getElementById('statusInput');
  22. const startTimeInput = document.getElementById('startTimeInput');
  23. const endTimeInput = document.getElementById('endTimeInput');
  24. const planInput = document.getElementById('planInput');
  25. const bugInput = document.getElementById('bugInput');
  26. const notesInput = document.getElementById('notesInput');
  27. const createdAtInfo = document.getElementById('createdAtInfo');
  28. const updatedAtInfo = document.getElementById('updatedAtInfo');
  29. const childNameInput = document.getElementById('childNameInput');
  30. const addChildBtn = document.getElementById('addChildBtn');
  31. const deleteNodeBtn = document.getElementById('deleteNodeBtn');
  32. const STATUS_LABELS = {
  33. pending: '待开始',
  34. 'in-progress': '执行中',
  35. completed: '已完成',
  36. bug: 'Bug 终止',
  37. };
  38. const VIEW = {
  39. x: 120,
  40. y: 80,
  41. scale: 1,
  42. minScale: 0.32,
  43. maxScale: 2.5,
  44. };
  45. const LAYOUT = {
  46. nodeWidth: 250,
  47. nodeHeight: 94,
  48. horizontalGap: 330,
  49. verticalGap: 122,
  50. rootGap: 60,
  51. };
  52. let tasks = [];
  53. let selectedTaskId = null;
  54. let latestBounds = null;
  55. let panning = false;
  56. let panStart = { x: 0, y: 0 };
  57. let panOrigin = { x: 0, y: 0 };
  58. function svgEl(tagName, attrs = {}) {
  59. const node = document.createElementNS('http://www.w3.org/2000/svg', tagName);
  60. Object.entries(attrs).forEach(([key, value]) => {
  61. if (value !== undefined && value !== null) {
  62. node.setAttribute(key, String(value));
  63. }
  64. });
  65. return node;
  66. }
  67. async function api(path, options = {}) {
  68. const response = await fetch(path, {
  69. headers: { 'Content-Type': 'application/json' },
  70. ...options,
  71. });
  72. const payload = await response.json().catch(() => ({}));
  73. if (!response.ok) {
  74. throw new Error(payload.error || '请求失败');
  75. }
  76. return payload;
  77. }
  78. function clamp(value, min, max) {
  79. return Math.min(max, Math.max(min, value));
  80. }
  81. function applyViewport() {
  82. viewportGroup.setAttribute('transform', `translate(${VIEW.x} ${VIEW.y}) scale(${VIEW.scale})`);
  83. }
  84. function toDateTimeLocal(isoValue) {
  85. if (!isoValue) {
  86. return '';
  87. }
  88. const date = new Date(isoValue);
  89. if (Number.isNaN(date.getTime())) {
  90. return '';
  91. }
  92. const pad = (num) => String(num).padStart(2, '0');
  93. const y = date.getFullYear();
  94. const m = pad(date.getMonth() + 1);
  95. const d = pad(date.getDate());
  96. const h = pad(date.getHours());
  97. const min = pad(date.getMinutes());
  98. return `${y}-${m}-${d}T${h}:${min}`;
  99. }
  100. function fromDateTimeLocal(localValue) {
  101. if (!localValue) {
  102. return null;
  103. }
  104. const date = new Date(localValue);
  105. if (Number.isNaN(date.getTime())) {
  106. return null;
  107. }
  108. return date.toISOString();
  109. }
  110. function toPrettyTime(isoValue) {
  111. if (!isoValue) {
  112. return '-';
  113. }
  114. const date = new Date(isoValue);
  115. if (Number.isNaN(date.getTime())) {
  116. return '-';
  117. }
  118. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
  119. date.getDate(),
  120. ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(
  121. date.getMinutes(),
  122. ).padStart(2, '0')}`;
  123. }
  124. function truncate(text, max = 20) {
  125. if (!text) {
  126. return '';
  127. }
  128. if (text.length <= max) {
  129. return text;
  130. }
  131. return `${text.slice(0, max - 1)}…`;
  132. }
  133. function buildTreeLayout(sourceTasks) {
  134. const byId = new Map();
  135. sourceTasks.forEach((task) => {
  136. byId.set(task.id, { ...task, children: [] });
  137. });
  138. const roots = [];
  139. byId.forEach((task) => {
  140. if (task.parentId && byId.has(task.parentId)) {
  141. byId.get(task.parentId).children.push(task);
  142. } else {
  143. roots.push(task);
  144. }
  145. });
  146. const sortByTime = (a, b) => {
  147. const timeA = Date.parse(a.createdAt || '') || 0;
  148. const timeB = Date.parse(b.createdAt || '') || 0;
  149. return timeA - timeB;
  150. };
  151. roots.sort(sortByTime);
  152. byId.forEach((task) => {
  153. task.children.sort(sortByTime);
  154. });
  155. const positions = new Map();
  156. const links = [];
  157. let yCursor = 120;
  158. const walk = (node, depth) => {
  159. const x = depth * LAYOUT.horizontalGap + 180;
  160. if (!node.children.length) {
  161. positions.set(node.id, { x, y: yCursor });
  162. yCursor += LAYOUT.verticalGap;
  163. return;
  164. }
  165. const startY = yCursor;
  166. node.children.forEach((child) => {
  167. walk(child, depth + 1);
  168. });
  169. const endY = yCursor - LAYOUT.verticalGap;
  170. const centerY = (startY + endY) / 2;
  171. positions.set(node.id, { x, y: centerY });
  172. };
  173. roots.forEach((root) => {
  174. walk(root, 0);
  175. yCursor += LAYOUT.rootGap;
  176. });
  177. byId.forEach((node) => {
  178. if (!node.parentId || !positions.has(node.parentId) || !positions.has(node.id)) {
  179. return;
  180. }
  181. links.push({
  182. from: positions.get(node.parentId),
  183. to: positions.get(node.id),
  184. status: node.status,
  185. });
  186. });
  187. let bounds = {
  188. minX: 0,
  189. minY: 0,
  190. maxX: 0,
  191. maxY: 0,
  192. width: 0,
  193. height: 0,
  194. };
  195. if (positions.size) {
  196. const xs = [];
  197. const ys = [];
  198. positions.forEach(({ x, y }) => {
  199. xs.push(x);
  200. ys.push(y);
  201. });
  202. bounds = {
  203. minX: Math.min(...xs) - (LAYOUT.nodeWidth / 2) - 40,
  204. maxX: Math.max(...xs) + (LAYOUT.nodeWidth / 2) + 40,
  205. minY: Math.min(...ys) - (LAYOUT.nodeHeight / 2) - 40,
  206. maxY: Math.max(...ys) + (LAYOUT.nodeHeight / 2) + 40,
  207. width: 0,
  208. height: 0,
  209. };
  210. bounds.width = bounds.maxX - bounds.minX;
  211. bounds.height = bounds.maxY - bounds.minY;
  212. }
  213. return { positions, links, roots, bounds };
  214. }
  215. function renderEmptyState() {
  216. linksLayer.innerHTML = '';
  217. nodesLayer.innerHTML = '';
  218. const textNode = svgEl('text', {
  219. x: 220,
  220. y: 180,
  221. class: 'empty-state',
  222. });
  223. textNode.textContent = '还没有节点,先在上方新建一个主对话。';
  224. nodesLayer.appendChild(textNode);
  225. }
  226. function renderGraph() {
  227. if (!tasks.length) {
  228. latestBounds = null;
  229. renderEmptyState();
  230. return;
  231. }
  232. const { positions, links, bounds } = buildTreeLayout(tasks);
  233. latestBounds = bounds;
  234. linksLayer.innerHTML = '';
  235. nodesLayer.innerHTML = '';
  236. links.forEach((link) => {
  237. const c1x = link.from.x + 90;
  238. const c2x = link.to.x - 90;
  239. const path = svgEl('path', {
  240. class: `link-path status-${link.status}`,
  241. d: `M ${link.from.x} ${link.from.y} C ${c1x} ${link.from.y}, ${c2x} ${link.to.y}, ${link.to.x} ${link.to.y}`,
  242. });
  243. linksLayer.appendChild(path);
  244. });
  245. tasks.forEach((task) => {
  246. const pos = positions.get(task.id);
  247. if (!pos) {
  248. return;
  249. }
  250. const nodeGroup = svgEl('g', {
  251. class: `node-group${selectedTaskId === task.id ? ' selected' : ''}`,
  252. transform: `translate(${pos.x} ${pos.y})`,
  253. 'data-id': task.id,
  254. });
  255. if (task.status === 'in-progress') {
  256. const ring = svgEl('rect', {
  257. x: -133,
  258. y: -52,
  259. width: 266,
  260. height: 104,
  261. rx: 24,
  262. class: 'progress-ring',
  263. });
  264. nodeGroup.appendChild(ring);
  265. }
  266. const card = svgEl('rect', {
  267. class: 'node-card',
  268. x: -(LAYOUT.nodeWidth / 2),
  269. y: -(LAYOUT.nodeHeight / 2),
  270. rx: 18,
  271. width: LAYOUT.nodeWidth,
  272. height: LAYOUT.nodeHeight,
  273. });
  274. nodeGroup.appendChild(card);
  275. const statusDot = svgEl('circle', {
  276. class: `node-status-dot status-${task.status}`,
  277. cx: -(LAYOUT.nodeWidth / 2) + 16,
  278. cy: -(LAYOUT.nodeHeight / 2) + 18,
  279. r: 7,
  280. });
  281. nodeGroup.appendChild(statusDot);
  282. const title = svgEl('text', {
  283. class: 'node-title',
  284. x: -(LAYOUT.nodeWidth / 2) + 32,
  285. y: -(LAYOUT.nodeHeight / 2) + 22,
  286. });
  287. title.textContent = truncate(task.name, 17);
  288. nodeGroup.appendChild(title);
  289. const subtitle = svgEl('text', {
  290. class: 'node-subtitle',
  291. x: -(LAYOUT.nodeWidth / 2) + 32,
  292. y: -(LAYOUT.nodeHeight / 2) + 43,
  293. });
  294. subtitle.textContent = `${STATUS_LABELS[task.status] || task.status} · ${toPrettyTime(
  295. task.updatedAt,
  296. )}`;
  297. nodeGroup.appendChild(subtitle);
  298. const line2 = svgEl('text', {
  299. class: 'node-subtitle',
  300. x: -(LAYOUT.nodeWidth / 2) + 32,
  301. y: -(LAYOUT.nodeHeight / 2) + 64,
  302. });
  303. const isRoot = !task.parentId;
  304. line2.textContent = isRoot ? '主对话节点' : `父节点:${truncate(getParentName(task.parentId), 9)}`;
  305. nodeGroup.appendChild(line2);
  306. const badge = svgEl('rect', {
  307. class: 'node-badge',
  308. x: LAYOUT.nodeWidth / 2 - 62,
  309. y: -(LAYOUT.nodeHeight / 2) + 8,
  310. width: 52,
  311. height: 18,
  312. rx: 9,
  313. });
  314. nodeGroup.appendChild(badge);
  315. const badgeText = svgEl('text', {
  316. class: 'node-badge-text',
  317. x: LAYOUT.nodeWidth / 2 - 55,
  318. y: -(LAYOUT.nodeHeight / 2) + 21,
  319. });
  320. badgeText.textContent = isRoot ? '主节点' : '子节点';
  321. nodeGroup.appendChild(badgeText);
  322. nodeGroup.addEventListener('click', (event) => {
  323. event.stopPropagation();
  324. openNodeModal(task.id);
  325. });
  326. nodesLayer.appendChild(nodeGroup);
  327. });
  328. }
  329. function getParentName(parentId) {
  330. const parent = tasks.find((item) => item.id === parentId);
  331. return parent ? parent.name : '未知';
  332. }
  333. function updateStats() {
  334. const roots = tasks.filter((task) => !task.parentId).length;
  335. const running = tasks.filter((task) => task.status === 'in-progress').length;
  336. const bugCount = tasks.filter((task) => task.status === 'bug').length;
  337. statRoots.textContent = String(roots);
  338. statTotal.textContent = String(tasks.length);
  339. statRunning.textContent = String(running);
  340. statBug.textContent = String(bugCount);
  341. }
  342. async function refresh() {
  343. tasks = await api('/api/tasks');
  344. renderGraph();
  345. updateStats();
  346. applyViewport();
  347. }
  348. function fitView() {
  349. if (!latestBounds || !tasks.length) {
  350. VIEW.x = 120;
  351. VIEW.y = 80;
  352. VIEW.scale = 1;
  353. applyViewport();
  354. return;
  355. }
  356. const margin = 44;
  357. const width = canvasWrapper.clientWidth || 1;
  358. const height = canvasWrapper.clientHeight || 1;
  359. const scaleX = (width - margin * 2) / latestBounds.width;
  360. const scaleY = (height - margin * 2) / latestBounds.height;
  361. const nextScale = clamp(Math.min(scaleX, scaleY), VIEW.minScale, 1.25);
  362. VIEW.scale = nextScale;
  363. VIEW.x = (width - latestBounds.width * nextScale) / 2 - latestBounds.minX * nextScale;
  364. VIEW.y = (height - latestBounds.height * nextScale) / 2 - latestBounds.minY * nextScale;
  365. applyViewport();
  366. }
  367. function openNodeModal(taskId) {
  368. const task = tasks.find((item) => item.id === taskId);
  369. if (!task) {
  370. return;
  371. }
  372. selectedTaskId = task.id;
  373. renderGraph();
  374. nodeIdInput.value = task.id;
  375. nameInput.value = task.name || '';
  376. statusInput.value = task.status || 'pending';
  377. startTimeInput.value = toDateTimeLocal(task.startTime);
  378. endTimeInput.value = toDateTimeLocal(task.endTime);
  379. planInput.value = task.plannedApproach || '';
  380. bugInput.value = task.bugDetails || '';
  381. notesInput.value = task.notes || '';
  382. childNameInput.value = '';
  383. createdAtInfo.textContent = `创建时间:${toPrettyTime(task.createdAt)}`;
  384. updatedAtInfo.textContent = `更新时间:${toPrettyTime(task.updatedAt)}`;
  385. nodeModal.classList.remove('hidden');
  386. nodeModal.setAttribute('aria-hidden', 'false');
  387. }
  388. function closeNodeModal() {
  389. nodeModal.classList.add('hidden');
  390. nodeModal.setAttribute('aria-hidden', 'true');
  391. selectedTaskId = null;
  392. renderGraph();
  393. }
  394. async function createRootTask(name) {
  395. const text = name.trim();
  396. if (!text) {
  397. return;
  398. }
  399. await api('/api/tasks', {
  400. method: 'POST',
  401. body: JSON.stringify({
  402. name: text,
  403. status: 'pending',
  404. }),
  405. });
  406. }
  407. async function createChildTask(parentId, name) {
  408. const text = name.trim();
  409. if (!text || !parentId) {
  410. return;
  411. }
  412. await api('/api/tasks', {
  413. method: 'POST',
  414. body: JSON.stringify({
  415. name: text,
  416. parentId,
  417. status: 'pending',
  418. }),
  419. });
  420. }
  421. async function updateCurrentNode() {
  422. const nodeId = nodeIdInput.value;
  423. if (!nodeId) {
  424. return;
  425. }
  426. const payload = {
  427. name: nameInput.value.trim(),
  428. status: statusInput.value,
  429. startTime: fromDateTimeLocal(startTimeInput.value),
  430. endTime: fromDateTimeLocal(endTimeInput.value),
  431. plannedApproach: planInput.value.trim(),
  432. bugDetails: bugInput.value.trim(),
  433. notes: notesInput.value.trim(),
  434. };
  435. await api(`/api/tasks/${encodeURIComponent(nodeId)}`, {
  436. method: 'PUT',
  437. body: JSON.stringify(payload),
  438. });
  439. }
  440. async function deleteCurrentNode() {
  441. const nodeId = nodeIdInput.value;
  442. if (!nodeId) {
  443. return;
  444. }
  445. const target = tasks.find((item) => item.id === nodeId);
  446. if (!target) {
  447. return;
  448. }
  449. const sure = window.confirm(`确认删除节点「${target.name}」及其全部子节点吗?`);
  450. if (!sure) {
  451. return;
  452. }
  453. await api(`/api/tasks/${encodeURIComponent(nodeId)}`, { method: 'DELETE' });
  454. closeNodeModal();
  455. await refresh();
  456. }
  457. function exportAsJson() {
  458. const filename = `openclaw-board-${new Date().toISOString().slice(0, 10)}.json`;
  459. const blob = new Blob([`${JSON.stringify(tasks, null, 2)}\n`], { type: 'application/json' });
  460. const url = URL.createObjectURL(blob);
  461. const link = document.createElement('a');
  462. link.href = url;
  463. link.download = filename;
  464. link.click();
  465. URL.revokeObjectURL(url);
  466. }
  467. function exportAsSvg() {
  468. const clonedSvg = svg.cloneNode(true);
  469. clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  470. clonedSvg.setAttribute('width', String(svg.clientWidth || 1400));
  471. clonedSvg.setAttribute('height', String(svg.clientHeight || 900));
  472. const styles = `
  473. .link-path{fill:none;stroke:#cfb8c3;stroke-width:2;opacity:.9}
  474. .link-path.status-in-progress{stroke:#c98ea5}
  475. .link-path.status-completed{stroke:#98bca7}
  476. .link-path.status-bug{stroke:#c88f98}
  477. .node-card{fill:#fff8fb;stroke:#dfc2cf;stroke-width:1.4}
  478. .status-pending{fill:#b9aab2}
  479. .status-in-progress{fill:#d58ca8}
  480. .status-completed{fill:#90bca2}
  481. .status-bug{fill:#c98289}
  482. .node-status-dot{stroke:rgba(255,255,255,.9);stroke-width:1}
  483. .node-title{font-size:15px;font-weight:600;fill:#4e3844}
  484. .node-subtitle{font-size:12px;fill:#806673}
  485. .node-badge{fill:#f2e2ea;stroke:#dfc2cf;stroke-width:1}
  486. .node-badge-text{font-size:10px;fill:#7a5d6a}
  487. .progress-ring{fill:none;stroke:#cd8ea6;stroke-width:2;stroke-dasharray:6 5}
  488. `;
  489. const styleTag = svgEl('style');
  490. styleTag.textContent = styles;
  491. clonedSvg.prepend(styleTag);
  492. const source = new XMLSerializer().serializeToString(clonedSvg);
  493. const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' });
  494. const url = URL.createObjectURL(blob);
  495. const link = document.createElement('a');
  496. link.href = url;
  497. link.download = `openclaw-board-${new Date().toISOString().slice(0, 10)}.svg`;
  498. link.click();
  499. URL.revokeObjectURL(url);
  500. }
  501. rootForm.addEventListener('submit', async (event) => {
  502. event.preventDefault();
  503. const name = rootNameInput.value;
  504. if (!name.trim()) {
  505. return;
  506. }
  507. try {
  508. await createRootTask(name);
  509. rootNameInput.value = '';
  510. await refresh();
  511. fitView();
  512. } catch (error) {
  513. window.alert(error.message);
  514. }
  515. });
  516. nodeForm.addEventListener('submit', async (event) => {
  517. event.preventDefault();
  518. try {
  519. await updateCurrentNode();
  520. await refresh();
  521. openNodeModal(nodeIdInput.value);
  522. } catch (error) {
  523. window.alert(error.message);
  524. }
  525. });
  526. addChildBtn.addEventListener('click', async () => {
  527. try {
  528. const parentId = nodeIdInput.value;
  529. const childName = childNameInput.value;
  530. if (!childName.trim()) {
  531. window.alert('请输入子节点标题');
  532. return;
  533. }
  534. await createChildTask(parentId, childName);
  535. childNameInput.value = '';
  536. await refresh();
  537. openNodeModal(parentId);
  538. fitView();
  539. } catch (error) {
  540. window.alert(error.message);
  541. }
  542. });
  543. deleteNodeBtn.addEventListener('click', async () => {
  544. try {
  545. await deleteCurrentNode();
  546. } catch (error) {
  547. window.alert(error.message);
  548. }
  549. });
  550. fitViewBtn.addEventListener('click', () => fitView());
  551. resetViewBtn.addEventListener('click', () => {
  552. VIEW.x = 120;
  553. VIEW.y = 80;
  554. VIEW.scale = 1;
  555. applyViewport();
  556. });
  557. exportJsonBtn.addEventListener('click', () => exportAsJson());
  558. exportSvgBtn.addEventListener('click', () => exportAsSvg());
  559. closeModalBtn.addEventListener('click', () => closeNodeModal());
  560. nodeModal.addEventListener('click', (event) => {
  561. if (event.target && event.target.dataset && event.target.dataset.close === 'true') {
  562. closeNodeModal();
  563. }
  564. });
  565. document.addEventListener('keydown', (event) => {
  566. if (event.key === 'Escape' && !nodeModal.classList.contains('hidden')) {
  567. closeNodeModal();
  568. }
  569. });
  570. canvasWrapper.addEventListener('wheel', (event) => {
  571. event.preventDefault();
  572. const zoomFactor = event.deltaY < 0 ? 1.08 : 0.92;
  573. const nextScale = clamp(VIEW.scale * zoomFactor, VIEW.minScale, VIEW.maxScale);
  574. VIEW.scale = nextScale;
  575. applyViewport();
  576. });
  577. canvasWrapper.addEventListener('pointerdown', (event) => {
  578. if (event.button !== 0) {
  579. return;
  580. }
  581. const target = event.target;
  582. if (target && target.closest && target.closest('.node-group')) {
  583. return;
  584. }
  585. panning = true;
  586. panStart = { x: event.clientX, y: event.clientY };
  587. panOrigin = { x: VIEW.x, y: VIEW.y };
  588. canvasWrapper.setPointerCapture(event.pointerId);
  589. });
  590. canvasWrapper.addEventListener('pointermove', (event) => {
  591. if (!panning) {
  592. return;
  593. }
  594. const dx = event.clientX - panStart.x;
  595. const dy = event.clientY - panStart.y;
  596. VIEW.x = panOrigin.x + dx;
  597. VIEW.y = panOrigin.y + dy;
  598. applyViewport();
  599. });
  600. canvasWrapper.addEventListener('pointerup', (event) => {
  601. panning = false;
  602. canvasWrapper.releasePointerCapture(event.pointerId);
  603. });
  604. canvasWrapper.addEventListener('pointercancel', (event) => {
  605. panning = false;
  606. canvasWrapper.releasePointerCapture(event.pointerId);
  607. });
  608. window.addEventListener('resize', () => {
  609. if (tasks.length) {
  610. fitView();
  611. }
  612. });
  613. async function bootstrap() {
  614. try {
  615. await refresh();
  616. if (tasks.length) {
  617. fitView();
  618. } else {
  619. applyViewport();
  620. }
  621. } catch (error) {
  622. window.alert(`加载失败:${error.message}`);
  623. }
  624. }
  625. bootstrap();