initial commit
This commit is contained in:
990
app.js
Normal file
990
app.js
Normal file
@@ -0,0 +1,990 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user