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.
This commit is contained in:
xiaomai
2025-10-22 17:52:17 +08:00
parent 2384e42933
commit 485d75820b
19 changed files with 1823 additions and 318 deletions

View File

@@ -1,35 +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>
<UCard>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-squares-2x2-20-solid" class="h-5 w-5 text-sky-400" />
<span class="text-lg font-semibold">{{ appTitle }}</span>
</div>
<UBadge
:color="store.dirty ? 'primary' : 'neutral'"
variant="soft"
class="text-xs"
>
{{ fileStatus }}
</UBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton color="neutral" variant="soft" icon="i-heroicons-folder-open-20-solid" @click="triggerOpen">
打开文件
</UButton>
<UButton color="primary" icon="i-heroicons-arrow-down-tray-20-solid" @click="onSave">
导出 JSON
</UButton>
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-path-rounded-square-20-solid" @click="triggerImport">
导入/合并
</UButton>
<UButton color="neutral" variant="ghost" icon="i-heroicons-plus-circle-20-solid" @click="newOpen = true">
新建看板
</UButton>
<UDropdown :items="localMenu" mode="hover">
<UButton color="neutral" variant="ghost" icon="i-heroicons-device-phone-mobile-20-solid">
本地存储
</UButton>
</UDropdown>
</div>
</div>
</template>
<div class="grid gap-3 lg:grid-cols-4">
<UInput
v-model="q"
icon="i-heroicons-magnifying-glass-20-solid"
placeholder="搜索任务(标题或描述)"
color="neutral"
/>
<USelectMenu
v-model="cat"
:options="categoryOptions"
value-attribute="value"
option-attribute="label"
searchable
placeholder="筛选类别"
/>
<UInput
v-model="actor"
icon="i-heroicons-user-circle-20-solid"
placeholder="记录操作人名称(写入历史)"
/>
<div class="flex items-center justify-end text-xs text-slate-400">
最近保存于{{ lastSaved }}
</div>
</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>
</UCard>
<input ref="openInput" type="file" accept="application/json" class="hidden" @change="handleOpen" />
<input ref="importInput" type="file" accept="application/json" class="hidden" @change="handleImport" />
<UModal v-model="newOpen">
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-plus-circle-20-solid" class="h-5 w-5 text-sky-400" />
<span class="font-semibold">新建看板</span>
</div>
</template>
<div class="space-y-4 text-sm text-slate-300">
<p>当前更改尚未导出继续新建将清空现有看板数据</p>
</div>
<div class="mt-6 flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="newOpen = false">取消</UButton>
<UButton color="primary" @click="createNewBoard">确认新建</UButton>
</div>
</UCard>
</UModal>
<UModal v-model="clearLocalOpen">
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-exclamation-triangle-20-solid" class="h-5 w-5 text-amber-400" />
<span class="font-semibold">清空本地缓存</span>
</div>
</template>
<p class="text-sm text-slate-300">
清空后将无法恢复确认继续吗
</p>
<div class="mt-6 flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="clearLocalOpen = false">取消</UButton>
<UButton color="rose" @click="clearLocalData">清空数据</UButton>
</div>
</UCard>
</UModal>
</template>
<script setup lang="ts">
@@ -37,6 +108,8 @@ import { useBoardStore } from '~/stores/board'
const store = useBoardStore()
const config = useRuntimeConfig().public
const toast = useToast()
const appTitle = computed(() => config.appTitle)
const actor = computed({
get: () => store.board.meta?.actor || '',
@@ -45,15 +118,40 @@ const actor = computed({
const q = defineModel<string>('query', { required: true })
const cat = defineModel<string | ''>('category', { required: true })
const openInput = ref<HTMLInputElement | null>(null)
const importInput = ref<HTMLInputElement | null>(null)
const categoryOptions = computed(() => [
{ label: '全部类别', value: '' },
...store.board.categories.map((c) => ({ label: c.title, value: c.uuid }))
])
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}`
const name = store.filename || '未命名'
const indicator = store.dirty ? '(未保存修改)' : '(已同步)'
return `${name} ${indicator}`
})
function onOpen(e: Event) {
const input = e.target as HTMLInputElement
const lastSaved = computed(() => {
const ts = store.board.meta?.modifiedAt
if (!ts) return '尚未记录'
try {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(ts))
} catch {
return ts
}
})
function triggerOpen() {
openInput.value?.click()
}
function triggerImport() {
importInput.value?.click()
}
function handleOpen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
@@ -62,21 +160,19 @@ function onOpen(e: Event) {
const json = JSON.parse(String(reader.result))
store.setBoard(json, file.name)
store.log('load-file', { name: file.name, size: file.size })
toast.add({ color: 'primary', title: `已载入 ${file.name}` })
} catch (err: any) {
alert('解析 JSON 失败' + err.message)
toast.add({ color: 'rose', title: '解析 JSON 失败', description: 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 emit = defineEmits<{ (e: 'open-merge', imported: any, diff: any, name: string): void }>()
function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
@@ -84,23 +180,89 @@ function onImport(e: Event) {
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)
toast.add({ color: 'neutral', title: `已分析 ${file.name}` })
} catch (err: any) {
alert('解析 JSON 失败' + err.message)
toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
}
}
reader.readAsText(file)
input.value = ''
}
function onLoadLocal() {
if (!store.loadFromLocal()) alert('本地不存在保存的数据')
}
function onSaveLocal() { store.saveToLocal() }
function onClearLocal() {
if (confirm('清空本地保存?')) store.clearLocal()
function onSave() {
store.downloadCurrent()
toast.add({ color: 'primary', title: '已导出文件' })
}
const emit = defineEmits<{ (e: 'open-merge', imported: any, diff: any, name: string): void }>()
const newOpen = ref(false)
function createNewBoard() {
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', {})
toast.add({ color: 'neutral', title: '已创建新的空白看板' })
newOpen.value = false
}
function loadLocal() {
if (!store.loadFromLocal()) {
toast.add({ color: 'rose', title: '本地没有保存的数据' })
return
}
toast.add({ color: 'neutral', title: '已从本地加载看板' })
}
function saveLocal() {
store.saveToLocal()
toast.add({ color: 'primary', title: '已同步至本地存储' })
}
const clearLocalOpen = ref(false)
function clearLocalData() {
store.clearLocal()
toast.add({ color: 'neutral', title: '本地数据已清空' })
clearLocalOpen.value = false
}
const localMenu = computed(() => [
[
{
label: '保存至本地',
icon: 'i-heroicons-arrow-down-on-square-20-solid',
click: () => saveLocal()
},
{
label: '从本地恢复',
icon: 'i-heroicons-arrow-up-on-square-20-solid',
click: () => loadLocal()
}
],
[
{
label: '清空本地缓存',
icon: 'i-heroicons-trash-20-solid',
click: () => {
clearLocalOpen.value = true
},
color: 'rose'
}
]
])
</script>