initial commit

This commit is contained in:
xiaomai
2025-10-22 15:57:46 +08:00
commit 00dc568576
7 changed files with 1394 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
Ramatex Tasks.json
*.private.json
*.local.json
node_modules/
dist/
.DS_Store

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
Open Kanban
===========
一款可本地使用、可开源的离线 Kanban看板
- 纯前端HTML/CSS/JS无需安装服务端。
- 数据保存在本机WindowsJSON 文件中,支持导入/导出。
- 支持拖拽移动/排序任务,编辑任务详情、步骤、类别、阶段和布局。
- 增强协作:导入他人导出的文件时,提供差异预览与合并策略(偏向导入/偏向当前),可选同步删除缺失项。
- 记录操作历史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 默认不显示任何公司名称。

990
app.js Normal file
View 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();

6
config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"appTitle": "Kanban",
"defaultFilename": "kanban.json",
"autoLoadFile": ""
}

127
index.html Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title id="pageTitle">Kanban</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div class="left">
<h1 id="appTitle">Kanban</h1>
<span class="file-status" id="fileStatus">未加载文件</span>
</div>
<div class="right toolbar">
<label class="btn">
<input type="file" id="openFile" accept="application/json" hidden />
打开…
</label>
<button id="saveBtn" class="btn" title="导出当前板为 JSON">保存/导出</button>
<label class="btn">
<input type="file" id="importFile" accept="application/json" hidden />
导入/合并…
</label>
<button id="newBoardBtn" class="btn" title="新建看板">新建</button>
<span class="sep"></span>
<input id="actorInput" class="actor" placeholder="你的名字(用于记录历史)" />
<span class="sep"></span>
<input id="searchInput" class="search" placeholder="搜索任务…" />
<select id="categoryFilter" class="category-filter">
<option value="">筛选:全部类别</option>
</select>
</div>
</header>
<main class="app-main">
<aside class="side-panel">
<section>
<h3>类别</h3>
<div id="categoriesList" class="categories"></div>
<button id="addCategoryBtn" class="btn small">添加类别</button>
</section>
<section>
<h3>布局</h3>
<div id="layoutInfo" class="layout-info"></div>
<div style="display:flex; gap:6px; margin:6px 0;">
<button id="addStageBtn" class="btn small">添加阶段</button>
<button id="addColumnBtn" class="btn small">添加列</button>
</div>
</section>
<section>
<h3>历史</h3>
<div id="historyList" class="history"></div>
<button id="clearHistoryBtn" class="btn small">清空历史(写入文件)</button>
</section>
</aside>
<section class="board" id="board"></section>
</main>
<!-- 模态框:编辑任务 -->
<div class="modal hidden" id="taskModal" role="dialog" aria-modal="true">
<div class="modal-content">
<div class="modal-header">
<h2>编辑任务</h2>
<button class="icon-btn" id="taskModalClose" aria-label="关闭"></button>
</div>
<div class="modal-body">
<div class="form-row">
<label>标题</label>
<input id="taskTitleInput" />
</div>
<div class="form-row">
<label>类别</label>
<select id="taskCategorySelect"></select>
</div>
<div class="form-row">
<label>描述</label>
<textarea id="taskDescInput" rows="6"></textarea>
</div>
<div class="form-row">
<label>步骤</label>
<div id="stepsContainer" class="steps"></div>
<button id="addStepBtn" class="btn small">添加步骤</button>
</div>
</div>
<div class="modal-footer">
<button id="deleteTaskBtn" class="btn danger">删除</button>
<div class="spacer"></div>
<button id="taskSaveBtn" class="btn primary">保存</button>
</div>
</div>
</div>
<!-- 模态框:导入/合并 -->
<div class="modal hidden" id="mergeModal" role="dialog" aria-modal="true">
<div class="modal-content large">
<div class="modal-header">
<h2>导入/合并 预览</h2>
<button class="icon-btn" id="mergeModalClose" aria-label="关闭"></button>
</div>
<div class="modal-body">
<div class="form-row">
<label>策略</label>
<select id="mergePolicy">
<option value="prefer-import">冲突优先:导入文件</option>
<option value="prefer-current">冲突优先:当前看板</option>
</select>
<label class="checkbox">
<input type="checkbox" id="mergeRemoveMissing" /> 同步删除在导入文件中不存在的任务/阶段
</label>
</div>
<div class="diff-summary" id="diffSummary"></div>
<details>
<summary>详细变更</summary>
<div class="diff-details" id="diffDetails"></div>
</details>
</div>
<div class="modal-footer">
<button id="applyMergeBtn" class="btn primary">应用合并</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

36
sample-board.json Normal file
View File

@@ -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"]]
}
}

129
styles.css Normal file
View File

@@ -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; }
}