Files
kanban/stores/board.ts
xiaomai 485d75820b feat(ui): overhaul interface with Nuxt UI
Integrate the Nuxt UI component library and completely revamp the application's user interface to improve usability, aesthetics, and maintainability.

- Replace all custom components and native browser dialogs (`alert`, `prompt`, `confirm`) with Nuxt UI components like `UCard`, `UButton`, `UModal`, and `UNotifications`.
- Refactor the main page by extracting the sidebar into dedicated panel components: `CategoryPanel`, `BoardSummaryPanel`, and `HistoryPanel`.
- Redesign all major components (Toolbar, Board, Stage, Task) for a cleaner layout and improved information hierarchy.
- Implement user-friendly modals for all creation, editing, and deletion flows, providing a more consistent user experience.
- Add toast notifications to provide immediate feedback for user actions.
2025-10-22 17:52:17 +08:00

218 lines
8.7 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 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<Category>) {
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<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,
addCategory, updateCategory, removeCategory,
taskById, stageById, categoryById,
diffBoards
}
})