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:
xiaomai
2025-10-22 17:08:31 +08:00
parent 00dc568576
commit 2384e42933
27 changed files with 8609 additions and 1331 deletions

106
pages/index.vue Normal file
View 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>