commit 00dc5685762999cc02535841a1596cae55313a7b Author: xiaomai Date: Wed Oct 22 15:57:46 2025 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffa72e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +Ramatex Tasks.json +*.private.json +*.local.json +node_modules/ +dist/ +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfec2cd --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +Open Kanban +=========== + +一款可本地使用、可开源的离线 Kanban(看板)。 + +- 纯前端(HTML/CSS/JS),无需安装服务端。 +- 数据保存在本机(Windows)JSON 文件中,支持导入/导出。 +- 支持拖拽移动/排序任务,编辑任务详情、步骤、类别、阶段和布局。 +- 增强协作:导入他人导出的文件时,提供差异预览与合并策略(偏向导入/偏向当前),可选同步删除缺失项。 +- 记录操作历史(meta.history),便于追踪改动来源与时间。 + +快速开始 +-------- + +1. 双击打开 `index.html`(推荐使用 Chrome/Edge)。 +2. 默认不会自动读取任何私有 JSON 文件,点击“打开…”手动选择,或配置 `config.json` 指定示例文件。 +3. 可直接使用仓库内的 `sample-board.json` 开始体验。 + +提示:如需本地 HTTP 访问(可避免部分浏览器对 file:// 读取限制),可在此目录临时起一个服务,例如: +- PowerShell: 以管理员打开 PowerShell,执行 `cd <此目录>; python -m http.server 8989`,浏览器访问 `http://localhost:8989/`。 + +主要功能 +-------- + +- 打开/保存 + - “打开…”读取 JSON 文件至内存。 + - “保存/导出”将当前看板下载为 JSON 文件(默认名取自 `config.json` 的 `defaultFilename`,默认为 `kanban.json`)。 +- 看板与拖拽 + - 根据 `layout.columns` 渲染多列,每列可包含多个阶段(stage)。 + - 支持跨阶段、阶段内排序拖放;自动更新 `stages[].tasks` 顺序。 +- 任务管理 + - 添加、编辑、删除任务;编辑标题、描述、类别与步骤(含勾选完成)。 + - 卡片上显示类别与步骤完成统计。 +- 阶段与类别 + - 添加、重命名、删除阶段(空阶段可删);选择列位置。 + - 添加、重命名类别与颜色;正被引用的类别不可删除。 +- 搜索/筛选 + - 关键词搜索(标题/描述),按类别筛选。 +- 协作导入/合并 + - “导入/合并…”选择 JSON 后显示差异预览:任务/阶段新增、删除、修改,布局变化。 + - 合并策略: + - 冲突优先“导入文件”:相同 ID 的任务/阶段在冲突字段上使用导入方;阶段任务顺序、布局亦以导入方为准。 + - 冲突优先“当前看板”:保持当前数据,仅补充导入方新增项。 + - 可选“同步删除在导入文件中不存在的任务/阶段”。 +- 历史记录 + - 记录操作人(右上角输入框),以及每次操作(新增、编辑、移动、合并等)。 + - 支持“清空历史(写入文件)”,以在文件内重置历史并记录一次清空事件。 + +数据结构说明(兼容扩展) +---------------------- + +原始结构: +- `categories`: `[{ uuid, title, color }]` +- `stages`: `[{ uuid, title, tasks: [taskUuid] }]` +- `tasks`: `[{ uuid, title, description, category, steps: [{ details, done }] }]` +- `layout`: `{ columns: [[stageUuid, ...], ...] }` + +本应用增加可选 `meta` 字段,用于协作追踪: +- `meta`: `{ id: 'open-kanban', version, createdAt, modifiedAt, actor, history: [{ id, ts, actor, type, payload }] }` + +未包含 `meta` 的旧文件可直接读取,保存时会补充。 + +协作建议 +-------- + +- 建议每位协作者在右上角填写自己的名字,导出前先“保存/导出”。 +- 通过“导入/合并…”接收他人文件时,先查看差异预览,再选择合并策略。 +- 需要严格控制主副本时,勾选“同步删除缺失项”,保持两侧严格一致。 +- 如需更细粒度的冲突解决(按字段逐项选择),欢迎提 Issue 我再扩展 UI 细节功能。 + +已知限制与备注 +-------------- + +- 纯前端页面无法直接写入磁盘文件,需要通过“保存/导出”下载保存。 +- 若浏览器禁止 `file://` 下的 `fetch`,自动读取示例或私有 JSON 可能失败,请手动“打开…”。 +- 阶段删除仅允许删除空阶段(避免误删任务)。如需支持“连同任务一起删除/移动到归档”,可以后续补充选项。 + +配置与目录结构 +-------------- + +- `index.html` 入口页面 +- `styles.css` 样式 +- `app.js` 主逻辑 +- `config.json` 应用配置(可选): + - `appTitle`: 页面标题(默认 `Kanban`) + - `defaultFilename`: 导出默认文件名(默认 `kanban.json`) + - `autoLoadFile`: 启动时自动读取的文件(默认空,不自动读取) +- `sample-board.json` 示例数据(通用,未包含任何公司/隐私信息) +- `.gitignore` 已默认忽略常见私有文件名(如 `*.private.json`、`*.local.json` 等),避免误提交 + +欢迎提出改进需求,我可以继续完善比如:按人分配、任务评论、每列 WIP 限制、标签、多选批量操作、导出增量补丁等。 + +隐私与开源建议 +-------------- + +- 默认不会自动读取任何私有数据。 +- 如需在本地使用私有数据,请将你的私有文件(例如 `tasks.private.json`)保留在本地且不提交到仓库;可将该文件名加入 `.gitignore`。 +- 如需自动加载示例或指定文件,请在 `config.json` 中设置 `autoLoadFile`(建议指向 `sample-board.json`)。 +- 若需更换品牌/标题,请在 `config.json` 中修改 `appTitle`;代码与 UI 默认不显示任何公司名称。 diff --git a/app.js b/app.js new file mode 100644 index 0000000..832eed2 --- /dev/null +++ b/app.js @@ -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(); diff --git a/config.json b/config.json new file mode 100644 index 0000000..ed56c8e --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "appTitle": "Kanban", + "defaultFilename": "kanban.json", + "autoLoadFile": "" +} + diff --git a/index.html b/index.html new file mode 100644 index 0000000..ccb1c96 --- /dev/null +++ b/index.html @@ -0,0 +1,127 @@ + + + + + + Kanban + + + +
+
+

Kanban

+ 未加载文件 +
+
+ + + + + + + + + +
+
+ +
+ + +
+
+ + + + + + + + + + diff --git a/sample-board.json b/sample-board.json new file mode 100644 index 0000000..2a5e5c9 --- /dev/null +++ b/sample-board.json @@ -0,0 +1,36 @@ +{ + "categories": [ + { "uuid": "c-urgent", "title": "Urgent", "color": "ff3b30" }, + { "uuid": "c-normal", "title": "Normal", "color": "34c759" } + ], + "stages": [ + { "uuid": "s-todo", "title": "Todo", "tasks": ["t-sample-1"] }, + { "uuid": "s-doing", "title": "Doing", "tasks": [] }, + { "uuid": "s-done", "title": "Done", "tasks": ["t-sample-2"] } + ], + "tasks": [ + { + "uuid": "t-sample-1", + "title": "Example: Set up project", + "description": "Try adding a stage, a task, and drag it.", + "category": "c-normal", + "steps": [ + { "details": "Create repository", "done": true }, + { "details": "Add README", "done": false } + ] + }, + { + "uuid": "t-sample-2", + "title": "Example: Celebrate", + "description": "Mark a task done to see step stats.", + "category": "c-urgent", + "steps": [ + { "details": "Share with team", "done": true } + ] + } + ], + "layout": { + "columns": [["s-todo"], ["s-doing"], ["s-done"]] + } +} + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c550d82 --- /dev/null +++ b/styles.css @@ -0,0 +1,129 @@ +:root { + --bg: #0f1115; + --panel: #161a22; + --panel-2: #1c2230; + --text: #e7e7ea; + --muted: #a5abb6; + --accent: #4cc2ff; + --danger: #ff5b5b; + --ok: #4caf50; + --card: #1b2230; + --border: #2a3242; + --shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial, + 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif; + background: var(--bg); + color: var(--text); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 10; + background: var(--panel); + border-bottom: 1px solid var(--border); + padding: 8px 12px; +} +.app-header h1 { font-size: 18px; margin: 0 10px 0 0; } +.file-status { color: var(--muted); font-size: 12px; } +.left { display: flex; align-items: center; gap: 8px; } +.toolbar { display: flex; align-items: center; gap: 8px; } +.sep { width: 1px; height: 24px; background: var(--border); margin: 0 6px; } + +.btn { + cursor: pointer; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: 6px; + box-shadow: var(--shadow); +} +.btn.small { padding: 4px 8px; font-size: 12px; } +.btn.primary { background: var(--accent); color: #001525; border-color: #2a93c4; } +.btn.danger { background: var(--danger); color: #1b0000; border-color: #a33939; } +.icon-btn { cursor: pointer; background: transparent; color: var(--text); border: none; font-size: 16px; } + +input, select, textarea { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; +} +.actor { width: 200px; } +.search { width: 200px; } +.category-filter { max-width: 220px; } + +.app-main { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 56px); } +.side-panel { overflow: auto; padding: 12px; border-right: 1px solid var(--border); background: var(--panel); } +.side-panel h3 { margin: 14px 0 8px; font-size: 14px; color: var(--muted); font-weight: 600; } +.categories { display: flex; flex-direction: column; gap: 6px; } +.category-item { display: flex; align-items: center; gap: 6px; background: var(--panel-2); padding: 6px; border: 1px solid var(--border); border-radius: 6px; } +.swatch { width: 14px; height: 14px; border-radius: 4px; border: 1px solid #00000033; display: inline-block; } +.category-item input[type="text"] { flex: 1; } +.layout-info { font-size: 12px; color: var(--muted); line-height: 1.6; } +.history { font-size: 12px; display: flex; flex-direction: column; gap: 8px; } +.history .event { padding: 6px 8px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; } + +.board { + overflow: auto; + padding: 12px; + display: grid; + grid-auto-rows: 1fr; + grid-auto-flow: column; + gap: 12px; +} +.board-column { display: flex; flex-direction: column; gap: 12px; min-width: 320px; } +.stage { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column; } +.stage-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.stage-title { font-weight: 600; } +.stage-actions { display: flex; align-items: center; gap: 6px; } +.task-list { padding: 8px; display: flex; flex-direction: column; gap: 8px; min-height: 60px; } +.task-list.drag-over { outline: 2px dashed var(--accent); outline-offset: -4px; border-radius: 6px; } + +.task { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 8px; box-shadow: var(--shadow); display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 8px; } +.task .grab { cursor: grab; user-select: none; opacity: 0.8; } +.task .title { font-weight: 600; } +.task .meta { font-size: 12px; color: var(--muted); } +.task .chip { font-size: 11px; padding: 2px 6px; border-radius: 10px; border: 1px solid #00000033; white-space: nowrap; } +.task .actions { display: flex; gap: 4px; } + +.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; padding: 16px; } +.modal.hidden { display: none; } +.modal-content { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; width: 720px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; } +.modal-content.large { width: 1000px; } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.modal-body { padding: 12px; overflow: auto; } +.modal-footer { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); } +.spacer { flex: 1; } + +.form-row { display: grid; grid-template-columns: 90px 1fr; align-items: start; gap: 10px; margin-bottom: 10px; } +.form-row label { font-size: 12px; color: var(--muted); padding-top: 6px; } +.steps { display: flex; flex-direction: column; gap: 6px; } +.step-item { display: grid; grid-template-columns: 24px 1fr 24px; align-items: center; gap: 8px; } +.step-item input[type="text"] { width: 100%; } +.checkbox { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; } + +.diff-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; } +.summary-card { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 8px; } +.summary-card h4 { margin: 2px 0 8px; font-size: 13px; color: var(--muted); } +.summary-card .num { font-size: 22px; font-weight: 700; } +.diff-details { display: flex; flex-direction: column; gap: 8px; } +.diff-item { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12px; white-space: pre-wrap; } + +@media (max-width: 1200px) { + .app-main { grid-template-columns: 1fr; } + .side-panel { display: none; } +} +