Files
kanban/components/Toolbar.vue
xiaomai 485d75820b 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.
2025-10-22 17:52:17 +08:00

269 lines
8.0 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>