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.
196 lines
7.9 KiB
TypeScript
196 lines
7.9 KiB
TypeScript
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
|
|
}
|
|
})
|
|
|