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

44
components/Board.vue Normal file
View 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
View 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>

View 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
View 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
View 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
View 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>