Files
kanban/app.js
2025-10-22 15:57:46 +08:00

991 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();