// Open Kanban - Pure JS implementation // Uses the schema compatible with your prior board JSON and adds optional `meta` for history. // ---- Utilities ---- function uuidv4() { // RFC4122 v4 - simplified return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function deepClone(obj) { return structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj)); } function nowIso() { return new Date().toISOString(); } function download(filename, text) { const element = document.createElement('a'); element.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } // ---- State ---- const state = { filename: '', dirty: false, board: null, // full schema selectedTaskId: null, lastLoadedSnapshot: null, // baseline for delta/diff exports (optional) }; // App-level config (overridable by config.json) const appConfig = { appTitle: 'Kanban', defaultFilename: 'kanban.json', autoLoadFile: '', // do not auto-load by default; can be set to e.g. 'sample-board.json' }; // Default empty board function makeEmptyBoard() { return { categories: [], stages: [], tasks: [], layout: { columns: [] }, meta: { id: 'open-kanban', version: '1.0.0', createdAt: nowIso(), modifiedAt: nowIso(), actor: '', history: [], }, }; } // ---- DOM Refs ---- const el = { fileStatus: document.getElementById('fileStatus'), openFile: document.getElementById('openFile'), importFile: document.getElementById('importFile'), saveBtn: document.getElementById('saveBtn'), newBoardBtn: document.getElementById('newBoardBtn'), actorInput: document.getElementById('actorInput'), searchInput: document.getElementById('searchInput'), categoryFilter: document.getElementById('categoryFilter'), board: document.getElementById('board'), categoriesList: document.getElementById('categoriesList'), addCategoryBtn: document.getElementById('addCategoryBtn'), layoutInfo: document.getElementById('layoutInfo'), historyList: document.getElementById('historyList'), clearHistoryBtn: document.getElementById('clearHistoryBtn'), addStageBtn: document.getElementById('addStageBtn'), addColumnBtn: document.getElementById('addColumnBtn'), // Task modal taskModal: document.getElementById('taskModal'), taskModalClose: document.getElementById('taskModalClose'), taskTitleInput: document.getElementById('taskTitleInput'), taskCategorySelect: document.getElementById('taskCategorySelect'), taskDescInput: document.getElementById('taskDescInput'), stepsContainer: document.getElementById('stepsContainer'), addStepBtn: document.getElementById('addStepBtn'), deleteTaskBtn: document.getElementById('deleteTaskBtn'), taskSaveBtn: document.getElementById('taskSaveBtn'), // Merge modal mergeModal: document.getElementById('mergeModal'), mergeModalClose: document.getElementById('mergeModalClose'), mergePolicy: document.getElementById('mergePolicy'), mergeRemoveMissing: document.getElementById('mergeRemoveMissing'), diffSummary: document.getElementById('diffSummary'), diffDetails: document.getElementById('diffDetails'), applyMergeBtn: document.getElementById('applyMergeBtn'), }; // ---- Helpers to access entities ---- function idxById(arr, id) { return arr.findIndex((x) => x.uuid === id); } function getCategory(id) { return state.board.categories.find((x) => x.uuid === id) || null; } function getStage(id) { return state.board.stages.find((x) => x.uuid === id) || null; } function getTask(id) { return state.board.tasks.find((x) => x.uuid === id) || null; } // ---- History ---- function logEvent(type, payload) { const actor = state.board?.meta?.actor || ''; const entry = { id: uuidv4(), ts: nowIso(), actor, type, payload }; state.board.meta.history.push(entry); state.board.meta.modifiedAt = nowIso(); state.dirty = true; renderHistory(); updateFileStatus(); } // ---- Load / Save ---- function updateFileStatus() { const n = state.filename || '(未命名)'; const a = state.board?.meta?.actor ? ` | 操作人:${state.board.meta.actor}` : ''; el.fileStatus.textContent = `${n}${state.dirty ? ' *' : ''}${a}`; } function setBoard(newBoard, filename = '') { // normalize optional meta if (!newBoard.meta) { newBoard.meta = { id: 'open-kanban', version: '1.0.0', createdAt: nowIso(), modifiedAt: nowIso(), actor: state.actor || '', history: [], }; } if (!newBoard.layout) newBoard.layout = { columns: [] }; if (!newBoard.layout.columns) newBoard.layout.columns = []; state.board = newBoard; state.filename = filename; state.dirty = false; state.lastLoadedSnapshot = deepClone(newBoard); el.actorInput.value = state.board.meta.actor || ''; populateCategoryFilter(); renderAll(); } async function loadConfig() { try { const res = await fetch('config.json', { cache: 'no-store' }); if (res.ok) { const cfg = await res.json(); if (cfg && typeof cfg === 'object') { if (cfg.appTitle) appConfig.appTitle = cfg.appTitle; if (cfg.defaultFilename) appConfig.defaultFilename = cfg.defaultFilename; if (typeof cfg.autoLoadFile === 'string') appConfig.autoLoadFile = cfg.autoLoadFile; } } } catch (_) { // ignore } // Apply title const titleEl = document.getElementById('appTitle'); const pageTitle = document.getElementById('pageTitle'); if (titleEl) titleEl.textContent = appConfig.appTitle; if (pageTitle) pageTitle.textContent = appConfig.appTitle; document.title = appConfig.appTitle; } function getQueryParam(name) { const url = new URL(window.location.href); return url.searchParams.get(name); } async function bootstrapLoad() { await loadConfig(); const qp = getQueryParam('file'); const fileToLoad = qp || appConfig.autoLoadFile || ''; if (!fileToLoad) { setBoard(makeEmptyBoard()); return; } try { const res = await fetch(fileToLoad, { cache: 'no-store' }); if (res.ok) { const json = await res.json(); setBoard(json, fileToLoad); logEvent('load-file', { name: fileToLoad, via: qp ? 'query' : 'config' }); return; } } catch (_) {} setBoard(makeEmptyBoard()); } function handleOpenFile(ev) { const file = ev.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const json = JSON.parse(String(reader.result)); setBoard(json, file.name); logEvent('load-file', { name: file.name, size: file.size }); } catch (e) { alert('解析 JSON 失败:' + e.message); } }; reader.readAsText(file); // reset value to allow opening same file again ev.target.value = ''; } function handleImportFile(ev) { const file = ev.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const imported = JSON.parse(String(reader.result)); showMergePreview(imported, file.name); } catch (e) { alert('解析 JSON 失败:' + e.message); } }; reader.readAsText(file); ev.target.value = ''; } function handleSave() { if (!state.board) return; state.board.meta.actor = el.actorInput.value.trim(); state.board.meta.modifiedAt = nowIso(); const filename = state.filename || appConfig.defaultFilename || 'kanban.json'; const text = JSON.stringify(state.board, null, 2); download(filename, text); state.dirty = false; updateFileStatus(); } function handleNewBoard() { if (state.dirty && !confirm('当前更改尚未保存,确定新建吗?')) return; const board = makeEmptyBoard(); setBoard(board); logEvent('new-board', {}); } // ---- Renderers ---- function renderAll() { renderCategories(); renderBoard(); renderHistory(); renderLayoutInfo(); updateFileStatus(); } function populateCategoryFilter() { const sel = el.categoryFilter; while (sel.firstChild) sel.removeChild(sel.firstChild); const optAll = document.createElement('option'); optAll.value = ''; optAll.textContent = '筛选:全部类别'; sel.appendChild(optAll); state.board.categories.forEach((c) => { const op = document.createElement('option'); op.value = c.uuid; op.textContent = c.title; sel.appendChild(op); }); } function renderCategories() { const root = el.categoriesList; root.innerHTML = ''; state.board.categories.forEach((c) => { const row = document.createElement('div'); row.className = 'category-item'; const sw = document.createElement('span'); sw.className = 'swatch'; sw.style.background = '#' + (c.color || '888888'); row.appendChild(sw); const title = document.createElement('input'); title.type = 'text'; title.value = c.title; title.addEventListener('change', () => { c.title = title.value.trim() || c.title; state.dirty = true; populateCategoryFilter(); renderBoard(); logEvent('category-rename', { id: c.uuid, title: c.title }); }); row.appendChild(title); const color = document.createElement('input'); color.type = 'text'; color.value = c.color || ''; color.placeholder = '颜色 hex (不含#)'; color.style.width = '110px'; color.addEventListener('change', () => { const v = color.value.replace(/#/g, '').slice(0, 6); c.color = v; sw.style.background = '#' + v; state.dirty = true; renderBoard(); logEvent('category-color', { id: c.uuid, color: v }); }); row.appendChild(color); const del = document.createElement('button'); del.className = 'btn small'; del.textContent = '删除'; del.addEventListener('click', () => { // only allow delete if unused const used = state.board.tasks.some((t) => t.category === c.uuid); if (used) return alert('该类别已被任务引用,无法删除'); state.board.categories = state.board.categories.filter((x) => x.uuid !== c.uuid); state.dirty = true; populateCategoryFilter(); renderCategories(); logEvent('category-delete', { id: c.uuid }); }); row.appendChild(del); root.appendChild(row); }); } function renderLayoutInfo() { const li = el.layoutInfo; const cols = state.board.layout?.columns || []; let txt = `列数:${cols.length}`; txt += '\n阶段总数:' + state.board.stages.length; li.textContent = txt; } function renderHistory() { const root = el.historyList; root.innerHTML = ''; const hist = (state.board.meta && state.board.meta.history) || []; hist.slice().reverse().slice(0, 200).forEach((h) => { const div = document.createElement('div'); div.className = 'event'; const actor = h.actor ? `@${h.actor}` : ''; div.textContent = `${h.ts} ${actor} ${h.type}`; root.appendChild(div); }); } function createEl(tag, className, text) { const e = document.createElement(tag); if (className) e.className = className; if (text != null) e.textContent = String(text); return e; } function renderBoard() { const board = el.board; board.innerHTML = ''; const filterText = el.searchInput.value.trim().toLowerCase(); const filterCat = el.categoryFilter.value; const columns = state.board.layout?.columns || []; if (!columns.length) { // fallback: one column containing all stages by order const allStageIds = state.board.stages.map((s) => s.uuid); columns.push(allStageIds); } columns.forEach((stageIdList, colIdx) => { const col = createEl('div', 'board-column'); stageIdList.forEach((sid) => { const s = getStage(sid); if (!s) return; const stageEl = createEl('div', 'stage'); stageEl.dataset.stageId = s.uuid; const header = createEl('div', 'stage-header'); const title = createEl('div', 'stage-title', s.title || '未命名阶段'); header.appendChild(title); const actions = createEl('div', 'stage-actions'); const addBtn = createEl('button', 'btn small', '添加任务'); addBtn.addEventListener('click', () => addTaskIntoStage(s.uuid)); const renameBtn = createEl('button', 'btn small', '重命名'); renameBtn.addEventListener('click', () => renameStage(s.uuid)); const moveBtn = createEl('button', 'btn small', '移列'); moveBtn.addEventListener('click', () => moveStageToColumn(s.uuid)); const delBtn = createEl('button', 'btn small', '删除'); delBtn.addEventListener('click', () => deleteStage(s.uuid)); actions.appendChild(addBtn); actions.appendChild(renameBtn); actions.appendChild(moveBtn); actions.appendChild(delBtn); header.appendChild(actions); stageEl.appendChild(header); const list = createEl('div', 'task-list'); list.dataset.stageId = s.uuid; enableListDnd(list); // tasks in order (s.tasks || []).forEach((tid) => { const t = getTask(tid); if (!t) return; // filters const hitText = !filterText || t.title?.toLowerCase().includes(filterText) || t.description?.toLowerCase().includes(filterText); const hitCat = !filterCat || t.category === filterCat; if (!(hitText && hitCat)) return; const card = renderTaskCard(t); list.appendChild(card); }); stageEl.appendChild(list); col.appendChild(stageEl); }); board.appendChild(col); }); } function categoryChip(catId) { const c = getCategory(catId); const span = createEl('span', 'chip'); if (!c) { span.textContent = '未分类'; return span; } span.textContent = c.title; span.style.background = '#' + (c.color || '888888') + '33'; span.style.borderColor = '#' + (c.color || '888888'); return span; } function stepStats(steps) { if (!Array.isArray(steps) || steps.length === 0) return '0/0'; const done = steps.filter((s) => s.done).length; return `${done}/${steps.length}`; } function renderTaskCard(t) { const div = createEl('div', 'task'); div.draggable = true; div.dataset.taskId = t.uuid; div.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/task', t.uuid); e.dataTransfer.effectAllowed = 'move'; }); const grab = createEl('div', 'grab', '⋮⋮'); div.appendChild(grab); const mid = createEl('div'); const title = createEl('div', 'title', t.title || '(无标题)'); mid.appendChild(title); const meta = createEl('div', 'meta', `步骤: ${stepStats(t.steps || [])}`); mid.appendChild(meta); div.appendChild(mid); const right = createEl('div', 'actions'); right.appendChild(categoryChip(t.category)); const editBtn = createEl('button', 'btn small', '编辑'); editBtn.addEventListener('click', () => openTaskModal(t.uuid)); right.appendChild(editBtn); div.appendChild(right); return div; } // ---- DnD ---- function enableListDnd(listEl) { listEl.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; listEl.classList.add('drag-over'); }); listEl.addEventListener('dragleave', () => listEl.classList.remove('drag-over')); listEl.addEventListener('drop', (e) => { e.preventDefault(); listEl.classList.remove('drag-over'); const taskId = e.dataTransfer.getData('text/task'); if (!taskId) return; const stageId = listEl.dataset.stageId; // compute index const children = Array.from(listEl.querySelectorAll('.task')); const y = e.clientY; let index = children.length; for (let i = 0; i < children.length; i++) { const rect = children[i].getBoundingClientRect(); if (y < rect.top + rect.height / 2) { index = i; break; } } moveTaskToStage(taskId, stageId, index); }); } function moveTaskToStage(taskId, stageId, index) { const fromStage = state.board.stages.find((s) => (s.tasks || []).includes(taskId)); const toStage = getStage(stageId); if (!toStage) return; if (fromStage && fromStage.uuid === toStage.uuid) { // reorder within same stage const arr = fromStage.tasks; const oldIdx = arr.indexOf(taskId); if (oldIdx === -1) return; arr.splice(oldIdx, 1); arr.splice(index > oldIdx ? index - 1 : index, 0, taskId); state.dirty = true; renderBoard(); logEvent('task-reorder', { stage: toStage.uuid, task: taskId, toIndex: index }); return; } // move across stages if (fromStage) { fromStage.tasks = (fromStage.tasks || []).filter((id) => id !== taskId); } if (!Array.isArray(toStage.tasks)) toStage.tasks = []; const clampedIndex = Math.max(0, Math.min(index, toStage.tasks.length)); toStage.tasks.splice(clampedIndex, 0, taskId); state.dirty = true; renderBoard(); logEvent('task-move', { task: taskId, from: fromStage?.uuid || null, to: toStage.uuid, toIndex: clampedIndex }); } // ---- Task CRUD ---- function addTaskIntoStage(stageId) { const t = { uuid: uuidv4(), title: '新任务', description: '', category: state.board.categories[0]?.uuid || null, steps: [], }; state.board.tasks.push(t); const s = getStage(stageId); if (s) { if (!Array.isArray(s.tasks)) s.tasks = []; s.tasks.push(t.uuid); } state.dirty = true; logEvent('task-add', { task: t.uuid, stage: stageId }); renderBoard(); openTaskModal(t.uuid); } function openTaskModal(taskId) { state.selectedTaskId = taskId; const t = getTask(taskId); if (!t) return; el.taskTitleInput.value = t.title || ''; el.taskDescInput.value = t.description || ''; // categories while (el.taskCategorySelect.firstChild) el.taskCategorySelect.removeChild(el.taskCategorySelect.firstChild); const none = document.createElement('option'); none.value = ''; none.textContent = '未分类'; el.taskCategorySelect.appendChild(none); state.board.categories.forEach((c) => { const op = document.createElement('option'); op.value = c.uuid; op.textContent = c.title; el.taskCategorySelect.appendChild(op); }); el.taskCategorySelect.value = t.category || ''; // steps renderStepList(t); el.taskModal.classList.remove('hidden'); } function renderStepList(t) { el.stepsContainer.innerHTML = ''; const steps = Array.isArray(t.steps) ? t.steps : []; steps.forEach((step, idx) => { const row = createEl('div', 'step-item'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!step.done; cb.addEventListener('change', () => { step.done = cb.checked; state.dirty = true; }); row.appendChild(cb); const txt = document.createElement('input'); txt.type = 'text'; txt.value = step.details || ''; txt.addEventListener('change', () => { step.details = txt.value; state.dirty = true; }); row.appendChild(txt); const del = document.createElement('button'); del.className = 'icon-btn'; del.textContent = '🗑'; del.title = '删除步骤'; del.addEventListener('click', () => { t.steps.splice(idx, 1); renderStepList(t); state.dirty = true; }); row.appendChild(del); el.stepsContainer.appendChild(row); }); } function closeTaskModal() { el.taskModal.classList.add('hidden'); state.selectedTaskId = null; } function saveTaskFromModal() { const id = state.selectedTaskId; const t = getTask(id); if (!t) return; const prev = deepClone(t); t.title = el.taskTitleInput.value.trim() || t.title; t.description = el.taskDescInput.value; t.category = el.taskCategorySelect.value || null; // steps already bound state.dirty = true; renderBoard(); logEvent('task-edit', { id, before: prev, after: t }); closeTaskModal(); } function deleteTask() { const id = state.selectedTaskId; if (!id) return; if (!confirm('删除该任务?此操作不可撤销')) return; // remove from stages state.board.stages.forEach((s) => { s.tasks = (s.tasks || []).filter((tid) => tid !== id); }); // remove task const idx = idxById(state.board.tasks, id); const prev = state.board.tasks[idx]; state.board.tasks.splice(idx, 1); state.dirty = true; logEvent('task-delete', { id, task: prev }); renderBoard(); closeTaskModal(); } // ---- Stage CRUD ---- function addStage() { const title = prompt('阶段名称?'); if (!title) return; let colIndex = 0; if (state.board.layout.columns.length > 1) { const ans = prompt(`放入第几列(1 - ${state.board.layout.columns.length})?`, '1'); const n = Math.max(1, Math.min(Number(ans) || 1, state.board.layout.columns.length)); colIndex = n - 1; } const s = { uuid: uuidv4(), title: title.trim(), tasks: [] }; state.board.stages.push(s); if (!state.board.layout.columns.length) state.board.layout.columns.push([]); state.board.layout.columns[colIndex].push(s.uuid); state.dirty = true; renderBoard(); renderLayoutInfo(); logEvent('stage-add', { id: s.uuid, title: s.title, col: colIndex }); } function renameStage(stageId) { const s = getStage(stageId); if (!s) return; const title = prompt('新的阶段名称?', s.title || ''); if (!title) return; const prev = s.title; s.title = title.trim(); state.dirty = true; renderBoard(); logEvent('stage-rename', { id: s.uuid, from: prev, to: s.title }); } function deleteStage(stageId) { const s = getStage(stageId); if (!s) return; if ((s.tasks || []).length) return alert('阶段非空,暂不支持删除非空阶段'); if (!confirm(`删除阶段“${s.title}”?`)) return; state.board.stages = state.board.stages.filter((x) => x.uuid !== stageId); // remove from layout columns state.board.layout.columns = state.board.layout.columns.map((col) => col.filter((id) => id !== stageId)); state.dirty = true; renderBoard(); renderLayoutInfo(); logEvent('stage-delete', { id: stageId }); } function addColumn() { if (!state.board.layout.columns) state.board.layout.columns = []; state.board.layout.columns.push([]); state.dirty = true; renderBoard(); renderLayoutInfo(); logEvent('column-add', { count: state.board.layout.columns.length }); } function moveStageToColumn(stageId) { const cols = state.board.layout.columns; if (!cols || !cols.length) return alert('暂无列'); const ans = prompt(`移动到第几列(1 - ${cols.length})?`, '1'); let target = Math.max(1, Math.min(Number(ans) || 1, cols.length)) - 1; // remove from any column first cols.forEach((col, i) => { const idx = col.indexOf(stageId); if (idx !== -1) col.splice(idx, 1); }); cols[target].push(stageId); state.dirty = true; renderBoard(); renderLayoutInfo(); logEvent('stage-move-column', { id: stageId, to: target }); } // ---- Categories CRUD ---- function addCategory() { const title = prompt('类别名称?'); if (!title) return; const color = prompt('颜色 hex(不含 #),例如 ff0000?', '70bafa') || '70bafa'; const c = { uuid: uuidv4(), title: title.trim(), color: color.replace(/#/g, '').slice(0, 6) }; state.board.categories.push(c); state.dirty = true; populateCategoryFilter(); renderCategories(); renderBoard(); logEvent('category-add', { id: c.uuid, title: c.title }); } // ---- Import / Merge ---- function idSet(arr) { const s = new Set(); arr.forEach((x) => s.add(x.uuid)); return s; } function diffArrays(a, b) { // arrays of ids const sa = new Set(a); const sb = new Set(b); const added = b.filter((x) => !sa.has(x)); const removed = a.filter((x) => !sb.has(x)); const changedOrder = JSON.stringify(a) !== JSON.stringify(b); return { added, removed, changedOrder }; } function diffBoards(cur, imp) { const diff = { tasks: {}, stages: {}, categories: {}, layout: { changed: false } }; // tasks const curTasks = idSet(cur.tasks); const impTasks = idSet(imp.tasks); diff.tasks.added = imp.tasks.filter((t) => !curTasks.has(t.uuid)).map((t) => t.uuid); diff.tasks.removed = cur.tasks.filter((t) => !impTasks.has(t.uuid)).map((t) => t.uuid); diff.tasks.modified = []; cur.tasks.forEach((t) => { if (!impTasks.has(t.uuid)) return; const it = imp.tasks.find((x) => x.uuid === t.uuid); const fields = []; if ((t.title || '') !== (it.title || '')) fields.push('title'); if ((t.description || '') !== (it.description || '')) fields.push('description'); if ((t.category || '') !== (it.category || '')) fields.push('category'); if (JSON.stringify(t.steps || []) !== JSON.stringify(it.steps || [])) fields.push('steps'); if (fields.length) diff.tasks.modified.push({ id: t.uuid, fields }); }); // categories const curCats = idSet(cur.categories); const impCats = idSet(imp.categories); diff.categories.added = imp.categories.filter((c) => !curCats.has(c.uuid)).map((c) => c.uuid); diff.categories.removed = cur.categories.filter((c) => !impCats.has(c.uuid)).map((c) => c.uuid); diff.categories.modified = []; cur.categories.forEach((c) => { if (!impCats.has(c.uuid)) return; const ic = imp.categories.find((x) => x.uuid === c.uuid); const fields = []; if ((c.title || '') !== (ic.title || '')) fields.push('title'); if ((c.color || '') !== (ic.color || '')) fields.push('color'); if (fields.length) diff.categories.modified.push({ id: c.uuid, fields }); }); // stages const curStages = idSet(cur.stages); const impStages = idSet(imp.stages); diff.stages.added = imp.stages.filter((s) => !curStages.has(s.uuid)).map((s) => s.uuid); diff.stages.removed = cur.stages.filter((s) => !impStages.has(s.uuid)).map((s) => s.uuid); diff.stages.modified = []; cur.stages.forEach((s) => { if (!impStages.has(s.uuid)) return; const is = imp.stages.find((x) => x.uuid === s.uuid); const fields = []; if ((s.title || '') !== (is.title || '')) fields.push('title'); const da = diffArrays(s.tasks || [], is.tasks || []); if (da.added.length || da.removed.length || da.changedOrder) fields.push('tasks'); if (fields.length) diff.stages.modified.push({ id: s.uuid, fields }); }); // layout diff.layout.changed = JSON.stringify(cur.layout || {}) !== JSON.stringify(imp.layout || {}); return diff; } function showMergePreview(imported, importedName = '导入文件') { const cur = state.board || makeEmptyBoard(); const diff = diffBoards(cur, imported); // Summary el.diffSummary.innerHTML = ''; const items = [ { title: '任务新增', num: diff.tasks.added.length }, { title: '任务删除', num: diff.tasks.removed.length }, { title: '任务修改', num: diff.tasks.modified.length }, { title: '阶段新增', num: diff.stages.added.length }, { title: '阶段删除', num: diff.stages.removed.length }, { title: '阶段修改', num: diff.stages.modified.length }, ]; items.forEach((it) => { const card = createEl('div', 'summary-card'); card.appendChild(createEl('h4', null, it.title)); card.appendChild(createEl('div', 'num', it.num)); el.diffSummary.appendChild(card); }); // Details const det = []; det.push(`[文件] ${importedName}`); det.push(`Tasks: +${diff.tasks.added.length} -${diff.tasks.removed.length} ~${diff.tasks.modified.length}`); if (diff.tasks.added.length) det.push(' 新增: ' + diff.tasks.added.join(', ')); if (diff.tasks.removed.length) det.push(' 删除: ' + diff.tasks.removed.join(', ')); diff.tasks.modified.forEach((m) => det.push(` 修改: ${m.id} -> ${m.fields.join(', ')}`)); det.push(`Stages: +${diff.stages.added.length} -${diff.stages.removed.length} ~${diff.stages.modified.length}`); if (diff.stages.added.length) det.push(' 新增: ' + diff.stages.added.join(', ')); if (diff.stages.removed.length) det.push(' 删除: ' + diff.stages.removed.join(', ')); diff.stages.modified.forEach((m) => det.push(` 修改: ${m.id} -> ${m.fields.join(', ')}`)); det.push(`Layout changed: ${diff.layout.changed}`); el.diffDetails.textContent = det.join('\n'); // Save imported data holder on modal element el.mergeModal.dataset.payload = JSON.stringify(imported); el.mergeModal.classList.remove('hidden'); } function closeMergeModal() { el.mergeModal.classList.add('hidden'); el.mergeModal.dataset.payload = ''; } function applyMerge() { const imported = JSON.parse(el.mergeModal.dataset.payload || '{}'); if (!imported || !imported.tasks) return; const policy = el.mergePolicy.value; // 'prefer-import' | 'prefer-current' const removeMissing = el.mergeRemoveMissing.checked; const cur = state.board; const imp = imported; // Merge categories const curCatMap = Object.fromEntries(cur.categories.map((c) => [c.uuid, c])); const impCatMap = Object.fromEntries(imp.categories.map((c) => [c.uuid, c])); // add new imp.categories.forEach((c) => { if (!curCatMap[c.uuid]) cur.categories.push(deepClone(c)); }); // update existing cur.categories.forEach((c) => { const ic = impCatMap[c.uuid]; if (!ic) return; if (policy === 'prefer-import') { c.title = ic.title; c.color = ic.color; } }); if (removeMissing) { cur.categories = cur.categories.filter((c) => !!impCatMap[c.uuid]); } // Merge tasks const curTaskMap = Object.fromEntries(cur.tasks.map((t) => [t.uuid, t])); const impTaskMap = Object.fromEntries(imp.tasks.map((t) => [t.uuid, t])); // add new imp.tasks.forEach((t) => { if (!curTaskMap[t.uuid]) cur.tasks.push(deepClone(t)); }); // update existing cur.tasks.forEach((t) => { const it = impTaskMap[t.uuid]; if (!it) return; if (policy === 'prefer-import') { t.title = it.title; t.description = it.description; t.category = it.category; t.steps = deepClone(it.steps || []); } }); if (removeMissing) { const keep = new Set(imp.tasks.map((t) => t.uuid)); cur.tasks = cur.tasks.filter((t) => keep.has(t.uuid)); } // Merge stages const curStageMap = Object.fromEntries(cur.stages.map((s) => [s.uuid, s])); const impStageMap = Object.fromEntries(imp.stages.map((s) => [s.uuid, s])); // add new imp.stages.forEach((s) => { if (!curStageMap[s.uuid]) cur.stages.push(deepClone(s)); }); // update existing cur.stages.forEach((s) => { const is = impStageMap[s.uuid]; if (!is) return; if (policy === 'prefer-import') { s.title = is.title; s.tasks = deepClone(is.tasks || []); } }); if (removeMissing) { const keep = new Set(imp.stages.map((s) => s.uuid)); cur.stages = cur.stages.filter((s) => keep.has(s.uuid)); } // layout if (policy === 'prefer-import') { cur.layout = deepClone(imp.layout || { columns: [] }); } state.dirty = true; renderAll(); logEvent('merge-apply', { policy, removeMissing }); closeMergeModal(); } // ---- Event wiring ---- el.openFile.addEventListener('change', handleOpenFile); el.importFile.addEventListener('change', handleImportFile); el.saveBtn.addEventListener('click', handleSave); el.newBoardBtn.addEventListener('click', handleNewBoard); el.actorInput.addEventListener('change', () => { if (!state.board?.meta) return; state.board.meta.actor = el.actorInput.value.trim(); state.dirty = true; updateFileStatus(); }); el.searchInput.addEventListener('input', renderBoard); el.categoryFilter.addEventListener('change', renderBoard); el.addCategoryBtn.addEventListener('click', addCategory); el.clearHistoryBtn.addEventListener('click', () => { if (confirm('清空历史记录?将写入文件。')) { state.board.meta.history = []; state.board.meta.modifiedAt = nowIso(); state.dirty = true; renderHistory(); logEvent('history-clear', {}); } }); el.addStageBtn.addEventListener('click', addStage); el.addColumnBtn.addEventListener('click', addColumn); // Task modal events el.taskModalClose.addEventListener('click', closeTaskModal); el.addStepBtn.addEventListener('click', () => { const t = getTask(state.selectedTaskId); if (!t) return; if (!Array.isArray(t.steps)) t.steps = []; t.steps.push({ details: '新步骤', done: false }); state.dirty = true; renderStepList(t); }); el.taskSaveBtn.addEventListener('click', saveTaskFromModal); el.deleteTaskBtn.addEventListener('click', deleteTask); // Merge modal events el.mergeModalClose?.addEventListener('click', closeMergeModal); el.applyMergeBtn?.addEventListener('click', applyMerge); // Bootstrap bootstrapLoad();