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:
73
components/panels/BoardSummaryPanel.vue
Normal file
73
components/panels/BoardSummaryPanel.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-chart-bar-20-solid" class="h-5 w-5 text-emerald-400" />
|
||||
<span class="font-semibold">看板概览</span>
|
||||
</div>
|
||||
<UBadge
|
||||
v-if="lastModified"
|
||||
:label="`更新于:${lastModified}`"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<UCard variant="soft" class="border border-emerald-500/10 bg-emerald-500/5 text-emerald-200">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide opacity-80">任务</p>
|
||||
<p class="text-2xl font-semibold">{{ tasksCount }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard variant="soft" class="border border-sky-500/10 bg-sky-500/5 text-sky-200">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide opacity-80">阶段</p>
|
||||
<p class="text-2xl font-semibold">{{ stagesCount }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard variant="soft" class="border border-violet-500/10 bg-violet-500/5 text-violet-200">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide opacity-80">列</p>
|
||||
<p class="text-2xl font-semibold">{{ columnsCount }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2 text-xs text-slate-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-circle-20-solid" class="h-4 w-4 text-slate-500" />
|
||||
<span>当前操作人:{{ actor || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-duplicate-20-solid" class="h-4 w-4 text-slate-500" />
|
||||
<span>文件来源:{{ filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
|
||||
const columnsCount = computed(() => store.board.layout?.columns?.length || 0)
|
||||
const stagesCount = computed(() => store.board.stages.length)
|
||||
const tasksCount = computed(() => store.board.tasks.length)
|
||||
const actor = computed(() => store.board.meta?.actor || '')
|
||||
const filename = computed(() => store.filename || '未命名')
|
||||
|
||||
const lastModified = 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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
213
components/panels/CategoryPanel.vue
Normal file
213
components/panels/CategoryPanel.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-tag-20-solid" class="h-5 w-5 text-sky-400" />
|
||||
<span class="font-semibold">类别管理</span>
|
||||
</div>
|
||||
<UBadge :label="categories.length" color="neutral" variant="subtle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p v-if="!categories.length" class="text-sm text-slate-400">
|
||||
当前看板还没有类别。新建类别以便在任务中进行筛选和标识。
|
||||
</p>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<UCard v-for="cat in categories" :key="cat.uuid" variant="soft" class="border-slate-800">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="h-3 w-3 rounded-full ring-2 ring-slate-900"
|
||||
:style="{ backgroundColor: drafts[cat.uuid]?.color || '#64748b' }"
|
||||
/>
|
||||
<UInput
|
||||
v-model="drafts[cat.uuid].title"
|
||||
size="sm"
|
||||
placeholder="类别名称"
|
||||
@blur="saveCategory(cat.uuid)"
|
||||
@keyup.enter="saveCategory(cat.uuid)"
|
||||
/>
|
||||
<UTooltip text="更新类别">
|
||||
<UButton icon="i-heroicons-check-20-solid" size="sm" color="primary" variant="soft" @click="saveCategory(cat.uuid)" />
|
||||
</UTooltip>
|
||||
<UTooltip text="删除类别">
|
||||
<UButton icon="i-heroicons-trash-20-solid" size="sm" color="rose" variant="ghost" @click="askRemove(cat)" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>颜色</span>
|
||||
<input
|
||||
:value="drafts[cat.uuid].color"
|
||||
type="color"
|
||||
class="h-8 w-14 cursor-pointer rounded border border-slate-800 bg-transparent"
|
||||
@input="(e) => updateDraftColor(cat.uuid, e)"
|
||||
@change="saveCategory(cat.uuid)"
|
||||
/>
|
||||
<code class="rounded bg-slate-900/60 px-2 py-1">{{ drafts[cat.uuid].color.toUpperCase() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UButton icon="i-heroicons-plus-circle-20-solid" block color="primary" @click="openCreate">
|
||||
新建类别
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal v-model="createOpen">
|
||||
<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>
|
||||
<form class="space-y-4" @submit.prevent="createCategory">
|
||||
<UFormGroup label="类别名称" name="title">
|
||||
<UInput v-model="createForm.title" placeholder="例如:优先级、类型…" autofocus />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="颜色" name="color">
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="createForm.color" type="color" class="h-10 w-16 rounded border border-slate-800 bg-transparent" />
|
||||
<code class="rounded bg-slate-900/60 px-2 py-1">{{ createForm.color.toUpperCase() }}</code>
|
||||
</div>
|
||||
</UFormGroup>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="createOpen = false">取消</UButton>
|
||||
<UButton type="submit" color="primary">保存</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model="removeOpen">
|
||||
<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>
|
||||
<div class="space-y-4 text-sm">
|
||||
<p>
|
||||
确定要删除类别
|
||||
<span class="font-medium text-slate-100">“{{ removeTarget?.title }}”</span>
|
||||
吗?
|
||||
</p>
|
||||
<p class="text-slate-400">
|
||||
若该类别被任务引用,将无法删除。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="removeOpen = false">取消</UButton>
|
||||
<UButton color="rose" @click="confirmRemove">立即删除</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import type { Category } from '~/types/schema'
|
||||
|
||||
const store = useBoardStore()
|
||||
const toast = useToast()
|
||||
|
||||
const categories = computed(() => store.board.categories)
|
||||
|
||||
const drafts = reactive<Record<string, { title: string; color: string }>>({})
|
||||
|
||||
watch(
|
||||
categories,
|
||||
(cats) => {
|
||||
cats.forEach((cat) => {
|
||||
if (!drafts[cat.uuid]) {
|
||||
drafts[cat.uuid] = {
|
||||
title: cat.title,
|
||||
color: `#${(cat.color || '888888').padStart(6, '0')}`
|
||||
}
|
||||
} else {
|
||||
drafts[cat.uuid].title = cat.title
|
||||
drafts[cat.uuid].color = `#${(cat.color || '888888').padStart(6, '0')}`
|
||||
}
|
||||
})
|
||||
Object.keys(drafts).forEach((id) => {
|
||||
if (!cats.find((c) => c.uuid === id)) delete drafts[id]
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const createOpen = ref(false)
|
||||
const createForm = reactive({
|
||||
title: '',
|
||||
color: '#38bdf8'
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
createForm.title = ''
|
||||
createForm.color = '#38bdf8'
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
function createCategory() {
|
||||
if (!createForm.title.trim()) {
|
||||
toast.add({ color: 'rose', title: '类别名称不能为空' })
|
||||
return
|
||||
}
|
||||
const color = createForm.color.replace('#', '').slice(0, 6) || '888888'
|
||||
const created = store.addCategory(createForm.title.trim(), color)
|
||||
drafts[created.uuid] = {
|
||||
title: created.title,
|
||||
color: `#${color.toUpperCase()}`
|
||||
}
|
||||
toast.add({ color: 'primary', title: `已创建类别「${created.title}」` })
|
||||
createOpen.value = false
|
||||
}
|
||||
|
||||
function sanitizeDraft(id: string) {
|
||||
const draft = drafts[id]
|
||||
if (!draft) return
|
||||
draft.title = draft.title.trim() || '未命名类别'
|
||||
draft.color = `#${draft.color.replace('#', '').slice(0, 6) || '888888'}`
|
||||
}
|
||||
|
||||
function saveCategory(id: string) {
|
||||
const draft = drafts[id]
|
||||
if (!draft) return
|
||||
sanitizeDraft(id)
|
||||
store.updateCategory(id, {
|
||||
title: draft.title,
|
||||
color: draft.color.replace('#', '').slice(0, 6)
|
||||
})
|
||||
}
|
||||
|
||||
function updateDraftColor(id: string, event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
drafts[id].color = value
|
||||
}
|
||||
|
||||
const removeOpen = ref(false)
|
||||
const removeTarget = ref<Category | null>(null)
|
||||
|
||||
function askRemove(cat: Category) {
|
||||
removeTarget.value = cat
|
||||
removeOpen.value = true
|
||||
}
|
||||
|
||||
function confirmRemove() {
|
||||
if (!removeTarget.value) return
|
||||
const ok = store.removeCategory(removeTarget.value.uuid)
|
||||
if (!ok) {
|
||||
toast.add({ color: 'rose', title: '该类别正在被任务引用,无法删除' })
|
||||
return
|
||||
}
|
||||
toast.add({ color: 'neutral', title: `已删除类别「${removeTarget.value.title}」` })
|
||||
removeOpen.value = false
|
||||
removeTarget.value = null
|
||||
}
|
||||
</script>
|
||||
92
components/panels/HistoryPanel.vue
Normal file
92
components/panels/HistoryPanel.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-clock-20-solid" class="h-5 w-5 text-amber-400" />
|
||||
<span class="font-semibold">历史记录</span>
|
||||
</div>
|
||||
<UButton
|
||||
size="2xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-trash-20-solid"
|
||||
@click="clearOpen = true"
|
||||
>
|
||||
清空
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="!entries.length" class="rounded border border-slate-800 bg-slate-900/70 px-3 py-4 text-sm text-slate-400">
|
||||
暂无历史记录。
|
||||
</div>
|
||||
<div v-else class="max-h-72 space-y-2 overflow-auto pr-1 text-xs">
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="rounded border border-slate-800 bg-slate-900/70 px-3 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-slate-200">{{ entry.type }}</span>
|
||||
<span class="text-[10px] text-slate-500">{{ formatTime(entry.ts) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<UIcon name="i-heroicons-user-20-solid" class="h-3.5 w-3.5" />
|
||||
<span>{{ entry.actor || '系统' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal v-model="clearOpen">
|
||||
<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="clearOpen = false">取消</UButton>
|
||||
<UButton color="rose" @click="clearHistory">确认清空</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const toast = useToast()
|
||||
|
||||
const entries = computed(() => {
|
||||
const list = store.board.meta?.history || []
|
||||
return list.slice(-200).reverse()
|
||||
})
|
||||
|
||||
function formatTime(ts: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(ts))
|
||||
} catch {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
||||
const clearOpen = ref(false)
|
||||
|
||||
function clearHistory() {
|
||||
clearOpen.value = false
|
||||
if (!store.board.meta) return
|
||||
store.board.meta.history = []
|
||||
store.board.meta.modifiedAt = new Date().toISOString()
|
||||
store.log('history-clear', {})
|
||||
toast.add({ color: 'neutral', title: '历史记录已清空' })
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user