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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user