refactor(app): rewrite application with Nuxt 4, Pinia, and TailwindCSS
This commit replaces the original vanilla JavaScript implementation with a modern frontend stack. The entire application has been rewritten to improve maintainability, developer experience, and leverage modern web technologies. Key changes include: - Replaced plain HTML, CSS, and JS with a Nuxt 4 project structure. - Migrated state management from a global state object to a Pinia store for better organization and reactivity. - Rebuilt the UI with Vue 3 components and styled with TailwindCSS. - Changed the primary persistence mechanism from manual file I/O to automatic LocalStorage saving, while retaining import/export functionality. - All core features, including drag-and-drop, task management, and the import/merge diffing logic, have been ported to the new architecture.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
|
||||
.nuxt/
|
||||
237
README.md
237
README.md
@@ -1,99 +1,178 @@
|
||||
Open Kanban
|
||||
===========
|
||||
# ⚡ Open Kanban (Nuxt 4 版)
|
||||
|
||||
一款可本地使用、可开源的离线 Kanban(看板)。
|
||||
一个完全本地可用、隐私安全、支持导入/导出的 **现代离线看板应用**。
|
||||
基于 **Nuxt 4 + TailwindCSS + Pinia** 构建,默认使用浏览器 **LocalStorage** 持久化数据,
|
||||
无需服务端、无需账号,直接运行即可使用。💥
|
||||
|
||||
- 纯前端(HTML/CSS/JS),无需安装服务端。
|
||||
- 数据保存在本机(Windows)JSON 文件中,支持导入/导出。
|
||||
- 支持拖拽移动/排序任务,编辑任务详情、步骤、类别、阶段和布局。
|
||||
- 增强协作:导入他人导出的文件时,提供差异预览与合并策略(偏向导入/偏向当前),可选同步删除缺失项。
|
||||
- 记录操作历史(meta.history),便于追踪改动来源与时间。
|
||||
---
|
||||
|
||||
快速开始
|
||||
--------
|
||||
## ✨ 功能特性
|
||||
|
||||
1. 双击打开 `index.html`(推荐使用 Chrome/Edge)。
|
||||
2. 默认不会自动读取任何私有 JSON 文件,点击“打开…”手动选择,或配置 `config.json` 指定示例文件。
|
||||
3. 可直接使用仓库内的 `sample-board.json` 开始体验。
|
||||
- 🧠 **纯前端离线运行**
|
||||
无需安装后端服务,开箱即用。
|
||||
所有数据均保存在浏览器 LocalStorage,可导出 JSON 备份。
|
||||
|
||||
提示:如需本地 HTTP 访问(可避免部分浏览器对 file:// 读取限制),可在此目录临时起一个服务,例如:
|
||||
- PowerShell: 以管理员打开 PowerShell,执行 `cd <此目录>; python -m http.server 8989`,浏览器访问 `http://localhost:8989/`。
|
||||
- 💾 **本地永久保存**
|
||||
自动将看板写入 LocalStorage(`public.localStorageKey`),
|
||||
支持手动保存、导出、导入与清空。
|
||||
|
||||
主要功能
|
||||
--------
|
||||
- 🔄 **导入 / 导出 / 合并预览**
|
||||
支持将 JSON 文件导入并进行差异对比:
|
||||
|
||||
- 打开/保存
|
||||
- “打开…”读取 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 可能失败,请手动“打开…”。
|
||||
- 阶段删除仅允许删除空阶段(避免误删任务)。如需支持“连同任务一起删除/移动到归档”,可以后续补充选项。
|
||||
### 1️⃣ 开发环境运行
|
||||
|
||||
配置与目录结构
|
||||
--------------
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm i
|
||||
|
||||
- `index.html` 入口页面
|
||||
- `styles.css` 样式
|
||||
- `app.js` 主逻辑
|
||||
- `config.json` 应用配置(可选):
|
||||
- `appTitle`: 页面标题(默认 `Kanban`)
|
||||
- `defaultFilename`: 导出默认文件名(默认 `kanban.json`)
|
||||
- `autoLoadFile`: 启动时自动读取的文件(默认空,不自动读取)
|
||||
- `sample-board.json` 示例数据(通用,未包含任何公司/隐私信息)
|
||||
- `.gitignore` 已默认忽略常见私有文件名(如 `*.private.json`、`*.local.json` 等),避免误提交
|
||||
# 启动开发模式
|
||||
npm run dev
|
||||
```
|
||||
|
||||
欢迎提出改进需求,我可以继续完善比如:按人分配、任务评论、每列 WIP 限制、标签、多选批量操作、导出增量补丁等。
|
||||
浏览器打开输出地址(通常为):
|
||||
|
||||
隐私与开源建议
|
||||
--------------
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
- 默认不会自动读取任何私有数据。
|
||||
- 如需在本地使用私有数据,请将你的私有文件(例如 `tasks.private.json`)保留在本地且不提交到仓库;可将该文件名加入 `.gitignore`。
|
||||
- 如需自动加载示例或指定文件,请在 `config.json` 中设置 `autoLoadFile`(建议指向 `sample-board.json`)。
|
||||
- 若需更换品牌/标题,请在 `config.json` 中修改 `appTitle`;代码与 UI 默认不显示任何公司名称。
|
||||
### 2️⃣ 构建与预览
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 运行配置(`nuxt.config.ts`)
|
||||
|
||||
在 `runtimeConfig.public` 中可自定义以下配置项:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| ----------------- | --------------------- | ---------------------------------------------------------- |
|
||||
| `appTitle` | `"Kanban"` | 页面标题 |
|
||||
| `localStorageKey` | `"open-kanban-board"` | LocalStorage 键名 |
|
||||
| `defaultFilename` | `"kanban.json"` | 导出文件名 |
|
||||
| `autoLoadFile` | `""` | 启动时自动加载的 JSON 文件路径(例:`/sample-board.json`) |
|
||||
|
||||
---
|
||||
|
||||
## 📂 项目结构
|
||||
|
||||
```
|
||||
/ # 项目根目录
|
||||
├─ app.vue # 主应用入口
|
||||
├─ pages/ # 页面目录
|
||||
├─ components/ # UI 组件
|
||||
├─ stores/ # Pinia 状态管理
|
||||
├─ public/
|
||||
│ ├─ sample-board.json # 示例数据
|
||||
│ └─ favicon.ico
|
||||
├─ nuxt.config.ts # 配置文件
|
||||
├─ package.json
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧾 数据结构
|
||||
|
||||
应用内部数据结构遵循以下规范:
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [{ "uuid": "", "title": "", "color": "" }],
|
||||
"stages": [{ "uuid": "", "title": "", "tasks": ["taskUuid"] }],
|
||||
"tasks": [
|
||||
{
|
||||
"uuid": "",
|
||||
"title": "",
|
||||
"description": "",
|
||||
"category": "",
|
||||
"steps": [{ "details": "", "done": true }]
|
||||
}
|
||||
],
|
||||
"layout": { "columns": [["stageUuid", "stageUuid"]] },
|
||||
"meta": {
|
||||
"id": "open-kanban",
|
||||
"version": "1.0",
|
||||
"createdAt": "",
|
||||
"modifiedAt": "",
|
||||
"actor": "",
|
||||
"history": [{ "id": "", "ts": "", "actor": "", "type": "", "payload": {} }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 隐私与数据安全
|
||||
|
||||
- 默认不读取任何私有文件或远程数据。
|
||||
- 所有数据仅保存在浏览器本地(LocalStorage)。
|
||||
- 导入的 JSON 文件仅在浏览器内解析,不会上传到任何服务器。
|
||||
- 建议用户自行导出 JSON 文件进行备份或团队共享。
|
||||
- 若需自动加载非私有数据,可在配置中设置 `autoLoadFile`。
|
||||
|
||||
---
|
||||
|
||||
## 🧑🤝🧑 协作建议
|
||||
|
||||
- 各协作者可在“操作人”输入框填写自己的名字。
|
||||
- 导出前请先保存当前版本。
|
||||
- 导入他人文件时使用合并预览选择合并策略。
|
||||
- 若需严格同步主副本,可启用“同步删除缺失项”。
|
||||
- 更复杂的冲突解决(字段级)将在未来版本支持。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 已知限制
|
||||
|
||||
- 浏览器 LocalStorage 有体积上限(约 5~10MB),如需存放大型项目请使用 JSON 导入导出。
|
||||
- 不支持直接写入磁盘文件(需通过“保存/导出”下载)。
|
||||
- 当前版本未包含多用户同步功能(未来可能支持 WebRTC / CRDT 同步模式)。
|
||||
|
||||
---
|
||||
|
||||
## 🧭 后续计划
|
||||
|
||||
- 👤 成员分配与头像标识
|
||||
- 💬 任务评论与活动时间线
|
||||
- 🏷️ 标签系统与多标签筛选
|
||||
- 🔢 每列 WIP 限制
|
||||
- 🧰 批量操作与快捷键支持
|
||||
- 📦 导出增量补丁文件
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
Open Kanban 是一个完全掌控在你手中的工具:
|
||||
无账号、无追踪、无外部依赖。
|
||||
在你的浏览器里,它就是一个本地应用。
|
||||
|
||||
**离线即自由,协作也本地。**
|
||||
**Open Kanban — 自主掌控你的工作流。** ⚡🔥
|
||||
|
||||
990
app.js
990
app.js
@@ -1,990 +0,0 @@
|
||||
// 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();
|
||||
11
app.vue
Normal file
11
app.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="min-h-full bg-slate-950 text-slate-100">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig().public
|
||||
useHead({ title: config.appTitle })
|
||||
</script>
|
||||
|
||||
8
assets/css/tailwind.css
Normal file
8
assets/css/tailwind.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
44
components/Board.vue
Normal file
44
components/Board.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="p-3 overflow-auto grid gap-3" :style="gridStyle">
|
||||
<div v-for="(col, colIndex) in columns" :key="colIndex" class="flex flex-col gap-3 min-w-[320px]">
|
||||
<StageColumn v-for="sid in col" :key="sid" :stage-id="sid" :query="query" :category="category" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!columns.length || !columns[0]?.length" class="text-center text-slate-400 py-10">
|
||||
暂无阶段。点击左侧“添加阶段”创建。
|
||||
</div>
|
||||
<div class="px-3 pb-3">
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addColumn">添加列</button>
|
||||
</div>
|
||||
<div class="px-3 pb-3">
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addStage">添加阶段</button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import StageColumn from './StageColumn.vue'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ query: string; category: string | '' }>()
|
||||
const columns = computed(() => store.board.layout?.columns || [])
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridAutoFlow: 'column',
|
||||
gridAutoRows: '1fr'
|
||||
}))
|
||||
|
||||
function addColumn() {
|
||||
if (!store.board.layout.columns) store.board.layout.columns = []
|
||||
store.board.layout.columns.push([])
|
||||
store.log('column-add', { count: store.board.layout.columns.length })
|
||||
}
|
||||
function addStage() {
|
||||
const title = prompt('阶段名称?')
|
||||
if (!title) return
|
||||
const colIndex = store.board.layout.columns.length ? 0 : 0
|
||||
store.addStage(title.trim(), colIndex)
|
||||
}
|
||||
</script>
|
||||
|
||||
66
components/MergeModal.vue
Normal file
66
components/MergeModal.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div v-if="open" class="fixed inset-0 bg-black/60 flex items-center justify-center p-4">
|
||||
<div class="w-[1000px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h2 class="font-semibold">导入/合并 预览</h2>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="close">✕</button>
|
||||
</div>
|
||||
<div class="p-3 overflow-auto space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<label class="text-sm text-slate-400 mr-2">策略</label>
|
||||
<select v-model="policy" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="prefer-import">冲突优先:导入文件</option>
|
||||
<option value="prefer-current">冲突优先:当前看板</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="text-sm flex items-center gap-2">
|
||||
<input type="checkbox" v-model="removeMissing" /> 同步删除在导入文件中不存在的任务/阶段
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务新增</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.added?.length || 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务删除</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.removed?.length || 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务修改</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.modified?.length || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3 text-xs whitespace-pre-wrap">
|
||||
<div>文件:{{ name }}</div>
|
||||
<div>Tasks: +{{ diff?.tasks?.added?.length || 0 }} -{{ diff?.tasks?.removed?.length || 0 }} ~{{ diff?.tasks?.modified?.length || 0 }}</div>
|
||||
<div>Stages: +{{ diff?.stages?.added?.length || 0 }} -{{ diff?.stages?.removed?.length || 0 }} ~{{ diff?.stages?.modified?.length || 0 }}</div>
|
||||
<div>Layout changed: {{ diff?.layout?.changed ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-t border-border">
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="apply">应用合并</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const imported = defineModel<any>('imported', { required: true })
|
||||
const diff = defineModel<any>('diff', { required: true })
|
||||
const name = defineModel<string>('name', { required: true })
|
||||
|
||||
const policy = ref<'prefer-import' | 'prefer-current'>('prefer-import')
|
||||
const removeMissing = ref(false)
|
||||
function close(){ open.value = false }
|
||||
function apply(){
|
||||
store.applyMerge(imported.value, policy.value, removeMissing.value)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
75
components/StageColumn.vue
Normal file
75
components/StageColumn.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-border bg-panel">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<div class="font-semibold">{{ stage?.title || '未命名阶段' }}</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onAdd">添加任务</button>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onRename">重命名</button>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onMoveCol">移列</button>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-2 min-h-[60px]" :data-stage-id="stageId" @dragover.prevent="onDragOver" @dragleave="over=false" @drop.prevent="onDrop" :class="{ 'outline outline-2 outline-accent/60 outline-offset-[-4px] rounded-md': over }">
|
||||
<TaskCard v-for="tid in filteredTasks" :key="tid" :task-id="tid" @dragging="(v) => dragging.value = v" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import TaskCard from './TaskCard.vue'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ stageId: string; query: string; category: string | '' }>()
|
||||
const stage = computed(() => store.stageById(props.stageId))
|
||||
const tasks = computed(() => stage.value?.tasks || [])
|
||||
const filteredTasks = computed(() => {
|
||||
const q = (props.query || '').toLowerCase()
|
||||
const c = props.category
|
||||
return tasks.value.filter((tid) => {
|
||||
const t = store.taskById(tid)
|
||||
if (!t) return false
|
||||
const hitText = !q || t.title.toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q)
|
||||
const hitCat = !c || t.category === c
|
||||
return hitText && hitCat
|
||||
})
|
||||
})
|
||||
|
||||
const over = ref(false)
|
||||
const dragging = ref(false)
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.dataTransfer!.dropEffect = 'move'
|
||||
over.value = true
|
||||
}
|
||||
function onDrop(e: DragEvent) {
|
||||
over.value = false
|
||||
const taskId = e.dataTransfer?.getData('text/task'); if (!taskId) return
|
||||
const list = (e.currentTarget as HTMLElement).querySelectorAll('[data-task]')
|
||||
const y = e.clientY
|
||||
let index = list.length
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const rect = (list[i] as HTMLElement).getBoundingClientRect()
|
||||
if (y < rect.top + rect.height / 2) { index = i; break }
|
||||
}
|
||||
store.moveTask(taskId, props.stageId, index)
|
||||
}
|
||||
function onAdd() { store.addTask(props.stageId) }
|
||||
function onRename() {
|
||||
const title = prompt('新的阶段名称?', stage.value?.title || '')
|
||||
if (!title) return
|
||||
store.renameStage(props.stageId, title.trim())
|
||||
}
|
||||
function onDelete() {
|
||||
if (!confirm('删除该阶段?仅空阶段可删除')) return
|
||||
if (!store.deleteStage(props.stageId)) alert('阶段非空或不存在')
|
||||
}
|
||||
function onMoveCol() {
|
||||
const cols = store.board.layout.columns
|
||||
if (!cols.length) return alert('暂无列')
|
||||
const ans = prompt(`移动到第几列(1 - ${cols.length})?`, '1')
|
||||
const target = Math.max(1, Math.min(Number(ans) || 1, cols.length)) - 1
|
||||
cols.forEach((col) => { const i = col.indexOf(props.stageId); if (i !== -1) col.splice(i, 1) })
|
||||
cols[target].push(props.stageId)
|
||||
store.log('stage-move-column', { id: props.stageId, to: target })
|
||||
}
|
||||
</script>
|
||||
39
components/TaskCard.vue
Normal file
39
components/TaskCard.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[20px_1fr_auto] items-center gap-2 p-2 rounded-lg bg-card border border-border shadow" draggable="true" data-task :data-id="taskId" @dragstart="onDragStart">
|
||||
<div class="opacity-70 select-none cursor-grab">⋮⋮</div>
|
||||
<div>
|
||||
<div class="font-semibold">{{ task?.title || '(无标题)' }}</div>
|
||||
<div class="text-xs text-slate-400">步骤: {{ stat }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-full border border-black/30 whitespace-nowrap" :style="chipStyle">{{ categoryTitle }}</span>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="openModal">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskModal v-if="open" :task-id="taskId" @close="open=false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import TaskModal from './TaskModal.vue'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const task = computed(() => store.taskById(props.taskId))
|
||||
const category = computed(() => store.categoryById(task.value?.category as string))
|
||||
const categoryTitle = computed(() => category.value?.title || '未分类')
|
||||
const chipStyle = computed(() => ({ background: `#${(category.value?.color||'888888')}33`, borderColor: `#${(category.value?.color||'888888')}` }))
|
||||
const stat = computed(() => {
|
||||
const steps = task.value?.steps || []
|
||||
const done = steps.filter(s => s.done).length
|
||||
return `${done}/${steps.length}`
|
||||
})
|
||||
const open = ref(false)
|
||||
function openModal(){ open.value = true }
|
||||
function onDragStart(e: DragEvent) {
|
||||
e.dataTransfer?.setData('text/task', props.taskId)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
emit('dragging', true)
|
||||
}
|
||||
const emit = defineEmits<{ (e:'dragging', v:boolean): void }>()
|
||||
</script>
|
||||
68
components/TaskModal.vue
Normal file
68
components/TaskModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center p-4">
|
||||
<div class="w-[720px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h2 class="font-semibold">编辑任务</h2>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="p-3 overflow-auto space-y-3">
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">标题</label>
|
||||
<input v-model="title" class="px-2 py-1 rounded bg-slate-900 border border-border" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">类别</label>
|
||||
<select v-model="category" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="">未分类</option>
|
||||
<option v-for="c in store.board.categories" :key="c.uuid" :value="c.uuid">{{ c.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">描述</label>
|
||||
<textarea v-model="desc" rows="6" class="px-2 py-1 rounded bg-slate-900 border border-border"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">步骤</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(s, i) in steps" :key="i" class="grid grid-cols-[24px_1fr_24px] items-center gap-2">
|
||||
<input type="checkbox" v-model="s.done" />
|
||||
<input v-model="s.details" class="px-2 py-1 rounded bg-slate-900 border border-border" />
|
||||
<button class="text-sm" @click="steps.splice(i,1)">🗑</button>
|
||||
</div>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="steps.push({details:'新步骤', done:false})">添加步骤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-t border-border">
|
||||
<button class="px-3 py-1 rounded bg-red-500/90 text-white hover:brightness-110" @click="onDelete">删除</button>
|
||||
<div class="flex-1" />
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="onSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const t = computed(() => store.taskById(props.taskId))
|
||||
const title = ref(t.value?.title || '')
|
||||
const desc = ref(t.value?.description || '')
|
||||
const category = ref<string | ''>((t.value?.category as string) || '')
|
||||
const steps = ref(JSON.parse(JSON.stringify(t.value?.steps || [])))
|
||||
|
||||
function onSave() {
|
||||
store.editTask(props.taskId, { title: title.value.trim() || t.value?.title, description: desc.value, category: category.value || null, steps: steps.value })
|
||||
emit('close')
|
||||
}
|
||||
function onDelete() {
|
||||
if (!confirm('删除该任务?此操作不可撤销')) return
|
||||
store.removeTask(props.taskId)
|
||||
emit('close')
|
||||
}
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
106
components/Toolbar.vue
Normal file
106
components/Toolbar.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 p-3 border-b border-border bg-panel">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-semibold">{{ appTitle }}</h1>
|
||||
<span class="text-xs text-slate-400">{{ fileStatus }}</span>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="px-3 py-1 rounded bg-slate-800/60 border border-border cursor-pointer hover:bg-slate-800">
|
||||
<input type="file" class="hidden" accept="application/json" @change="onOpen" />
|
||||
打开…
|
||||
</label>
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="onSave">保存/导出</button>
|
||||
<label class="px-3 py-1 rounded bg-slate-800/60 border border-border cursor-pointer hover:bg-slate-800">
|
||||
<input type="file" class="hidden" accept="application/json" @change="onImport" />
|
||||
导入/合并…
|
||||
</label>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onNew">新建</button>
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<input v-model="actor" placeholder="你的名字(记录历史)" class="px-2 py-1 rounded bg-slate-900 border border-border w-48" />
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<input v-model="q" placeholder="搜索任务…" class="px-2 py-1 rounded bg-slate-900 border border-border w-56" />
|
||||
<select v-model="cat" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="">筛选:全部类别</option>
|
||||
<option v-for="c in store.board.categories" :key="c.uuid" :value="c.uuid">{{ c.title }}</option>
|
||||
</select>
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onLoadLocal">从本地读取</button>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onSaveLocal">保存到本地</button>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onClearLocal">清空本地</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const config = useRuntimeConfig().public
|
||||
const appTitle = computed(() => config.appTitle)
|
||||
const actor = computed({
|
||||
get: () => store.board.meta?.actor || '',
|
||||
set: (v: string) => store.setActor(v)
|
||||
})
|
||||
const q = defineModel<string>('query', { required: true })
|
||||
const cat = defineModel<string | ''>('category', { required: true })
|
||||
|
||||
const fileStatus = computed(() => {
|
||||
const name = store.filename || '(未命名)'
|
||||
const a = store.board.meta?.actor ? ` | 操作人:${store.board.meta.actor}` : ''
|
||||
const star = store.dirty ? ' *' : ''
|
||||
return `${name}${star}${a}`
|
||||
})
|
||||
|
||||
function onOpen(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(String(reader.result))
|
||||
store.setBoard(json, file.name)
|
||||
store.log('load-file', { name: file.name, size: file.size })
|
||||
} catch (err: any) {
|
||||
alert('解析 JSON 失败:' + err.message)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
input.value = ''
|
||||
}
|
||||
function onSave() { store.downloadCurrent() }
|
||||
function onNew() {
|
||||
if (store.dirty && !confirm('当前更改尚未保存,确定新建吗?')) return
|
||||
store.setBoard({ categories: [], stages: [], tasks: [], layout: { columns: [] }, meta: { id: 'open-kanban', version: '1.0.0', createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), actor: store.board.meta?.actor, history: [] } }, '')
|
||||
store.log('new-board', {})
|
||||
}
|
||||
function onImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result))
|
||||
const diff = store.diffBoards(store.board as any, data)
|
||||
// emit event for MergeModal
|
||||
emit('open-merge', data, diff, file.name)
|
||||
} catch (err: any) {
|
||||
alert('解析 JSON 失败:' + err.message)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
input.value = ''
|
||||
}
|
||||
function onLoadLocal() {
|
||||
if (!store.loadFromLocal()) alert('本地不存在保存的数据')
|
||||
}
|
||||
function onSaveLocal() { store.saveToLocal() }
|
||||
function onClearLocal() {
|
||||
if (confirm('清空本地保存?')) store.clearLocal()
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'open-merge', imported: any, diff: any, name: string): void }>()
|
||||
</script>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"appTitle": "Kanban",
|
||||
"defaultFilename": "kanban.json",
|
||||
"autoLoadFile": ""
|
||||
}
|
||||
|
||||
127
index.html
127
index.html
@@ -1,127 +0,0 @@
|
||||
<!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>
|
||||
25
nuxt.config.ts
Normal file
25
nuxt.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Nuxt 4 configuration
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],
|
||||
css: ['~/assets/css/tailwind.css'],
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
title: 'Kanban'
|
||||
}
|
||||
},
|
||||
tailwindcss: {
|
||||
viewer: false
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
appTitle: 'Kanban',
|
||||
localStorageKey: 'open-kanban-board',
|
||||
defaultFilename: 'kanban.json',
|
||||
autoLoadFile: '' // e.g. '/sample-board.json'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "open-kanban-nuxt",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview",
|
||||
"lint": "echo 'no lint configured'"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"zod": "^3.23.8",
|
||||
"@vueuse/core": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"@types/node": "^20.11.30"
|
||||
}
|
||||
}
|
||||
|
||||
106
pages/index.vue
Normal file
106
pages/index.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="grid" style="grid-template-rows: auto 1fr; height: calc(100vh);">
|
||||
<Toolbar v-model:query="query" v-model:category="category" @open-merge="openMerge" />
|
||||
<div class="grid" style="grid-template-columns: 280px 1fr;">
|
||||
<aside class="overflow-auto p-3 border-r border-border bg-panel">
|
||||
<section class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-slate-400 mb-2">类别</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="c in store.board.categories" :key="c.uuid" class="flex items-center gap-2 p-2 rounded border border-border bg-slate-900">
|
||||
<span class="w-4 h-4 rounded" :style="{ background: '#'+(c.color||'888888') }" />
|
||||
<input v-model="c.title" class="flex-1 px-2 py-1 rounded bg-slate-950 border border-border" @change="onCatChange(c)" />
|
||||
<input v-model="c.color" placeholder="hex" class="w-24 px-2 py-1 rounded bg-slate-950 border border-border" @change="onCatChange(c)" />
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="delCat(c.uuid)">删除</button>
|
||||
</div>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addCat">添加类别</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4 text-sm text-slate-400">
|
||||
<h3 class="text-sm font-semibold text-slate-400 mb-2">布局</h3>
|
||||
<div>列数:{{ store.board.layout.columns.length }}</div>
|
||||
<div>阶段总数:{{ store.board.stages.length }}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-slate-400 mb-2">历史</h3>
|
||||
<div class="flex flex-col gap-2 text-xs max-h-[40vh] overflow-auto">
|
||||
<div v-for="h in (store.board.meta?.history||[]).slice().reverse().slice(0,200)" :key="h.id" class="p-2 rounded border border-border bg-slate-900">{{ h.ts }} {{ h.actor?('@'+h.actor):'' }} {{ h.type }}</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="clearHistory">清空历史(写入本地)</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
<main>
|
||||
<Board :query="query" :category="category" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Merge modal -->
|
||||
<MergeModal v-model:open="mergeOpen" v-model:imported="imported" v-model:diff="diff" v-model:name="name" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toolbar from '~/components/Toolbar.vue'
|
||||
import Board from '~/components/Board.vue'
|
||||
import MergeModal from '~/components/MergeModal.vue'
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const config = useRuntimeConfig().public
|
||||
|
||||
// Try load from query or config
|
||||
onMounted(async () => {
|
||||
const qp = new URL(window.location.href).searchParams.get('file')
|
||||
const fileToLoad = qp || config.autoLoadFile || ''
|
||||
if (fileToLoad) {
|
||||
try {
|
||||
const res = await fetch(fileToLoad, { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
store.setBoard(await res.json(), fileToLoad)
|
||||
store.log('load-file', { name: fileToLoad, via: qp ? 'query' : 'config' })
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
// else try local storage
|
||||
if (!store.loadFromLocal()) {
|
||||
// else set empty
|
||||
store.setBoard({ categories: [], stages: [], tasks: [], layout: { columns: [] }, meta: { id: 'open-kanban', version: '1.0.0', createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), actor: '', history: [] } }, '')
|
||||
}
|
||||
})
|
||||
|
||||
const query = ref('')
|
||||
const category = ref('')
|
||||
|
||||
function addCat(){
|
||||
const title = prompt('类别名称?'); if (!title) return
|
||||
const color = prompt('颜色 hex(不含 #)?', '70bafa') || '70bafa'
|
||||
store.board.categories.push({ uuid: crypto.randomUUID?.() || Math.random().toString(36).slice(2), title: title.trim(), color: color.replace(/#/g,'').slice(0,6) })
|
||||
store.log('category-add', {})
|
||||
}
|
||||
function delCat(id: string){
|
||||
// prevent delete if used
|
||||
const used = store.board.tasks.some(t => t.category === id)
|
||||
if (used) return alert('该类别已被任务引用,无法删除')
|
||||
store.board.categories = store.board.categories.filter(c => c.uuid !== id)
|
||||
store.log('category-delete', { id })
|
||||
}
|
||||
function onCatChange(c: any){ store.log('category-update', { id: c.uuid }) }
|
||||
function clearHistory(){
|
||||
if (!store.board.meta) return
|
||||
if (confirm('清空历史记录?将写入本地。')){
|
||||
store.board.meta.history = []
|
||||
store.board.meta.modifiedAt = new Date().toISOString()
|
||||
store.log('history-clear', {})
|
||||
}
|
||||
}
|
||||
|
||||
// Merge modal state
|
||||
const mergeOpen = ref(false)
|
||||
const imported = ref<any>({})
|
||||
const diff = ref<any>({})
|
||||
const name = ref('导入文件')
|
||||
function openMerge(data: any, d: any, n: string){ imported.value = data; diff.value = d; name.value = n; mergeOpen.value = true }
|
||||
</script>
|
||||
|
||||
7515
pnpm-lock.yaml
generated
Normal file
7515
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- esbuild
|
||||
- vue-demi
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
195
stores/board.ts
Normal file
195
stores/board.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Board, Stage, Task, Category, Meta } from '~/types/schema'
|
||||
import { uuid } from '~/utils/uuid'
|
||||
import { diffBoards } from '~/utils/diff'
|
||||
|
||||
function nowIso() { return new Date().toISOString() }
|
||||
|
||||
function emptyBoard(): Board {
|
||||
return { categories: [], stages: [], tasks: [], layout: { columns: [] }, meta: makeMeta('') }
|
||||
}
|
||||
function makeMeta(actor: string): Meta {
|
||||
return { id: 'open-kanban', version: '1.0.0', createdAt: nowIso(), modifiedAt: nowIso(), actor, history: [] }
|
||||
}
|
||||
|
||||
export const useBoardStore = defineStore('board', () => {
|
||||
const runtime = useRuntimeConfig().public
|
||||
const localKey = runtime.localStorageKey || 'open-kanban-board'
|
||||
|
||||
const filename = ref('')
|
||||
const dirty = ref(false)
|
||||
const board = ref<Board>(emptyBoard())
|
||||
const lastLoadedSnapshot = ref<Board | null>(null)
|
||||
const selectedTaskId = ref<string | null>(null)
|
||||
|
||||
function log(type: string, payload?: any) {
|
||||
if (!board.value.meta) board.value.meta = makeMeta('')
|
||||
const actor = board.value.meta.actor || ''
|
||||
board.value.meta.history.push({ id: uuid(), ts: nowIso(), actor, type, payload })
|
||||
board.value.meta.modifiedAt = nowIso()
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function setBoard(data: Board, name = '') {
|
||||
if (!data.layout) data.layout = { columns: [] }
|
||||
if (!data.meta) data.meta = makeMeta(board.value.meta?.actor || '')
|
||||
filename.value = name
|
||||
board.value = data
|
||||
dirty.value = false
|
||||
lastLoadedSnapshot.value = JSON.parse(JSON.stringify(data))
|
||||
}
|
||||
|
||||
function setActor(name: string) {
|
||||
if (!board.value.meta) board.value.meta = makeMeta('')
|
||||
board.value.meta.actor = name
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
// LocalStorage persistence
|
||||
function saveToLocal() {
|
||||
localStorage.setItem(localKey, JSON.stringify(board.value))
|
||||
dirty.value = false
|
||||
}
|
||||
function loadFromLocal(): boolean {
|
||||
const raw = localStorage.getItem(localKey)
|
||||
if (!raw) return false
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
setBoard(data, '(localStorage)')
|
||||
log('load-local', {})
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
function clearLocal() {
|
||||
localStorage.removeItem(localKey)
|
||||
}
|
||||
|
||||
// Import/Export
|
||||
function toJSON(pretty = true) {
|
||||
return JSON.stringify(board.value, null, pretty ? 2 : 0)
|
||||
}
|
||||
function downloadCurrent() {
|
||||
const name = filename.value || runtime.defaultFilename || 'kanban.json'
|
||||
const blob = new Blob([toJSON(true)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = name
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
dirty.value = false
|
||||
}
|
||||
function applyMerge(imported: Board, policy: 'prefer-import' | 'prefer-current', removeMissing: boolean) {
|
||||
const cur = board.value
|
||||
// cats
|
||||
const cMap = Object.fromEntries(cur.categories.map(c => [c.uuid, c]))
|
||||
const iMap = Object.fromEntries(imported.categories.map(c => [c.uuid, c]))
|
||||
imported.categories.forEach(c => { if (!cMap[c.uuid]) cur.categories.push(structuredClone(c)) })
|
||||
cur.categories.forEach(c => { const ic = iMap[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 => !!iMap[c.uuid])
|
||||
// tasks
|
||||
const tMap = Object.fromEntries(cur.tasks.map(t => [t.uuid, t]))
|
||||
const itMap = Object.fromEntries(imported.tasks.map(t => [t.uuid, t]))
|
||||
imported.tasks.forEach(t => { if (!tMap[t.uuid]) cur.tasks.push(structuredClone(t)) })
|
||||
cur.tasks.forEach(t => { const it = itMap[t.uuid]; if (!it) return; if (policy === 'prefer-import') { t.title = it.title; t.description = it.description; t.category = it.category; t.steps = structuredClone(it.steps || []) } })
|
||||
if (removeMissing) cur.tasks = cur.tasks.filter(t => !!itMap[t.uuid])
|
||||
// stages
|
||||
const sMap = Object.fromEntries(cur.stages.map(s => [s.uuid, s]))
|
||||
const isMap = Object.fromEntries(imported.stages.map(s => [s.uuid, s]))
|
||||
imported.stages.forEach(s => { if (!sMap[s.uuid]) cur.stages.push(structuredClone(s)) })
|
||||
cur.stages.forEach(s => { const is = isMap[s.uuid]; if (!is) return; if (policy === 'prefer-import') { s.title = is.title; s.tasks = structuredClone(is.tasks || []) } })
|
||||
if (removeMissing) cur.stages = cur.stages.filter(s => !!isMap[s.uuid])
|
||||
// layout
|
||||
if (policy === 'prefer-import') cur.layout = structuredClone(imported.layout || { columns: [] })
|
||||
dirty.value = true
|
||||
log('merge-apply', { policy, removeMissing })
|
||||
}
|
||||
|
||||
// Operations
|
||||
const taskById = (id: string) => board.value.tasks.find(t => t.uuid === id)
|
||||
const stageById = (id: string) => board.value.stages.find(s => s.uuid === id)
|
||||
const categoryById = (id: string) => board.value.categories.find(c => c.uuid === id)
|
||||
|
||||
function addStage(title: string, colIndex = 0) {
|
||||
const s: Stage = { uuid: uuid(), title, tasks: [] }
|
||||
board.value.stages.push(s)
|
||||
if (!board.value.layout.columns.length) board.value.layout.columns.push([])
|
||||
board.value.layout.columns[Math.max(0, Math.min(colIndex, board.value.layout.columns.length - 1))].push(s.uuid)
|
||||
dirty.value = true
|
||||
log('stage-add', { id: s.uuid, title, colIndex })
|
||||
return s
|
||||
}
|
||||
function renameStage(id: string, title: string) {
|
||||
const s = stageById(id); if (!s) return
|
||||
const from = s.title; s.title = title; dirty.value = true; log('stage-rename', { id, from, to: title })
|
||||
}
|
||||
function deleteStage(id: string) {
|
||||
const s = stageById(id); if (!s) return false
|
||||
if (s.tasks?.length) return false
|
||||
board.value.stages = board.value.stages.filter(x => x.uuid !== id)
|
||||
board.value.layout.columns = board.value.layout.columns.map(col => col.filter(x => x !== id))
|
||||
dirty.value = true
|
||||
log('stage-delete', { id })
|
||||
return true
|
||||
}
|
||||
function addTask(stageId: string) {
|
||||
const t: Task = { uuid: uuid(), title: '新任务', description: '', category: null, steps: [] }
|
||||
board.value.tasks.push(t)
|
||||
const s = stageById(stageId)
|
||||
if (s) s.tasks.push(t.uuid)
|
||||
dirty.value = true
|
||||
log('task-add', { task: t.uuid, stage: stageId })
|
||||
return t
|
||||
}
|
||||
function removeTask(id: string) {
|
||||
board.value.stages.forEach(s => s.tasks = s.tasks.filter(tid => tid !== id))
|
||||
const prev = taskById(id)
|
||||
board.value.tasks = board.value.tasks.filter(t => t.uuid !== id)
|
||||
dirty.value = true
|
||||
log('task-delete', { id, task: prev })
|
||||
}
|
||||
function editTask(id: string, patch: Partial<Task>) {
|
||||
const t = taskById(id); if (!t) return
|
||||
const before = JSON.parse(JSON.stringify(t))
|
||||
Object.assign(t, patch)
|
||||
dirty.value = true
|
||||
log('task-edit', { id, before, after: t })
|
||||
}
|
||||
function moveTask(taskId: string, toStageId: string, toIndex: number) {
|
||||
const fromStage = board.value.stages.find(s => s.tasks.includes(taskId))
|
||||
const toStage = stageById(toStageId); if (!toStage) return
|
||||
if (fromStage && fromStage.uuid === toStage.uuid) {
|
||||
const arr = fromStage.tasks
|
||||
const old = arr.indexOf(taskId)
|
||||
arr.splice(old, 1)
|
||||
arr.splice(toIndex > old ? toIndex - 1 : toIndex, 0, taskId)
|
||||
dirty.value = true
|
||||
log('task-reorder', { stage: toStageId, task: taskId, toIndex })
|
||||
return
|
||||
}
|
||||
if (fromStage) fromStage.tasks = fromStage.tasks.filter(x => x !== taskId)
|
||||
toStage.tasks.splice(Math.max(0, Math.min(toIndex, toStage.tasks.length)), 0, taskId)
|
||||
dirty.value = true
|
||||
log('task-move', { task: taskId, from: fromStage?.uuid || null, to: toStageId, toIndex })
|
||||
}
|
||||
|
||||
// Autosave to LocalStorage (throttled)
|
||||
let saveTimer: any
|
||||
watch(board, () => {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saveToLocal() }, 500)
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
filename, dirty, board, lastLoadedSnapshot, selectedTaskId,
|
||||
setBoard, setActor, log, saveToLocal, loadFromLocal, clearLocal,
|
||||
toJSON, downloadCurrent, applyMerge,
|
||||
addStage, renameStage, deleteStage, addTask, removeTask, editTask, moveTask,
|
||||
taskById, stageById, categoryById,
|
||||
diffBoards
|
||||
}
|
||||
})
|
||||
|
||||
129
styles.css
129
styles.css
@@ -1,129 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
|
||||
24
tailwind.config.ts
Normal file
24
tailwind.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./components/**/*.{vue,js,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'./app.vue'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
panel: '#111827',
|
||||
panel2: '#0b1220',
|
||||
border: '#1f2937',
|
||||
card: '#0f172a',
|
||||
accent: '#38bdf8'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config
|
||||
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
54
types/schema.ts
Normal file
54
types/schema.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface Category {
|
||||
uuid: string
|
||||
title: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
details: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
uuid: string
|
||||
title: string
|
||||
description?: string
|
||||
category?: string | null
|
||||
steps?: Step[]
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
uuid: string
|
||||
title: string
|
||||
tasks: string[]
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
columns: string[][]
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
ts: string
|
||||
actor?: string
|
||||
type: string
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
id: 'open-kanban'
|
||||
version: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
actor?: string
|
||||
history: HistoryEntry[]
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
categories: Category[]
|
||||
stages: Stage[]
|
||||
tasks: Task[]
|
||||
layout: Layout
|
||||
meta?: Meta
|
||||
}
|
||||
|
||||
60
utils/diff.ts
Normal file
60
utils/diff.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Board } from '~/types/schema'
|
||||
|
||||
export function diffBoards(cur: Board, imp: Board) {
|
||||
const res: any = { tasks: {}, stages: {}, categories: {}, layout: { changed: false } }
|
||||
|
||||
const idSet = (arr: any[]) => new Set(arr.map((x) => x.uuid))
|
||||
const curTasks = idSet(cur.tasks)
|
||||
const impTasks = idSet(imp.tasks)
|
||||
res.tasks.added = imp.tasks.filter((t) => !curTasks.has(t.uuid)).map((t) => t.uuid)
|
||||
res.tasks.removed = cur.tasks.filter((t) => !impTasks.has(t.uuid)).map((t) => t.uuid)
|
||||
res.tasks.modified = [] as any[]
|
||||
cur.tasks.forEach((t) => {
|
||||
if (!impTasks.has(t.uuid)) return
|
||||
const it = imp.tasks.find((x) => x.uuid === t.uuid)!
|
||||
const fields: string[] = []
|
||||
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) res.tasks.modified.push({ id: t.uuid, fields })
|
||||
})
|
||||
|
||||
const curCats = idSet(cur.categories)
|
||||
const impCats = idSet(imp.categories)
|
||||
res.categories.added = imp.categories.filter((c) => !curCats.has(c.uuid)).map((c) => c.uuid)
|
||||
res.categories.removed = cur.categories.filter((c) => !impCats.has(c.uuid)).map((c) => c.uuid)
|
||||
res.categories.modified = [] as any[]
|
||||
cur.categories.forEach((c) => {
|
||||
if (!impCats.has(c.uuid)) return
|
||||
const ic = imp.categories.find((x) => x.uuid === c.uuid)!
|
||||
const fields: string[] = []
|
||||
if ((c.title || '') !== (ic.title || '')) fields.push('title')
|
||||
if ((c.color || '') !== (ic.color || '')) fields.push('color')
|
||||
if (fields.length) res.categories.modified.push({ id: c.uuid, fields })
|
||||
})
|
||||
|
||||
const curStages = idSet(cur.stages)
|
||||
const impStages = idSet(imp.stages)
|
||||
res.stages.added = imp.stages.filter((s) => !curStages.has(s.uuid)).map((s) => s.uuid)
|
||||
res.stages.removed = cur.stages.filter((s) => !impStages.has(s.uuid)).map((s) => s.uuid)
|
||||
res.stages.modified = [] as any[]
|
||||
const arrDiff = (a: string[], b: string[]) => ({
|
||||
added: b.filter((x) => !new Set(a).has(x)),
|
||||
removed: a.filter((x) => !new Set(b).has(x)),
|
||||
changedOrder: JSON.stringify(a) !== JSON.stringify(b)
|
||||
})
|
||||
cur.stages.forEach((s) => {
|
||||
if (!impStages.has(s.uuid)) return
|
||||
const is = imp.stages.find((x) => x.uuid === s.uuid)!
|
||||
const fields: string[] = []
|
||||
if ((s.title || '') !== (is.title || '')) fields.push('title')
|
||||
const d = arrDiff(s.tasks || [], is.tasks || [])
|
||||
if (d.added.length || d.removed.length || d.changedOrder) fields.push('tasks')
|
||||
if (fields.length) res.stages.modified.push({ id: s.uuid, fields })
|
||||
})
|
||||
|
||||
res.layout.changed = JSON.stringify(cur.layout || {}) !== JSON.stringify(imp.layout || {})
|
||||
return res
|
||||
}
|
||||
|
||||
8
utils/uuid.ts
Normal file
8
utils/uuid.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user