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(emptyBoard()) const lastLoadedSnapshot = ref(null) const selectedTaskId = ref(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 addCategory(title: string, color: string) { const c: Category = { uuid: uuid(), title, color } board.value.categories.push(c) log('category-add', { id: c.uuid, title, color }) return c } function updateCategory(id: string, patch: Partial) { const c = categoryById(id) if (!c) return false const before = JSON.parse(JSON.stringify(c)) Object.assign(c, patch) log('category-update', { id, before, after: c }) return true } function removeCategory(id: string) { const used = board.value.tasks.some(t => t.category === id) if (used) return false board.value.categories = board.value.categories.filter(c => c.uuid !== id) log('category-delete', { id }) return true } 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) { 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, addCategory, updateCategory, removeCategory, taskById, stageById, categoryById, diffBoards } })