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.
269 lines
8.0 KiB
Vue
269 lines
8.0 KiB
Vue
<template>
|
||
<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>
|
||
</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">
|
||
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 || '',
|
||
set: (v: string) => store.setActor(v)
|
||
})
|
||
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 indicator = store.dirty ? '(未保存修改)' : '(已同步)'
|
||
return `${name} ${indicator}`
|
||
})
|
||
|
||
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()
|
||
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 })
|
||
toast.add({ color: 'primary', title: `已载入 ${file.name}` })
|
||
} catch (err: any) {
|
||
toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
|
||
}
|
||
}
|
||
reader.readAsText(file)
|
||
input.value = ''
|
||
}
|
||
|
||
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()
|
||
reader.onload = () => {
|
||
try {
|
||
const data = JSON.parse(String(reader.result))
|
||
const diff = store.diffBoards(store.board as any, data)
|
||
emit('open-merge', data, diff, file.name)
|
||
toast.add({ color: 'neutral', title: `已分析 ${file.name}` })
|
||
} catch (err: any) {
|
||
toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
|
||
}
|
||
}
|
||
reader.readAsText(file)
|
||
input.value = ''
|
||
}
|
||
|
||
function onSave() {
|
||
store.downloadCurrent()
|
||
toast.add({ color: 'primary', title: '已导出文件' })
|
||
}
|
||
|
||
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>
|