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,75 +1,261 @@
<template>
<div class="rounded-lg border border-border bg-panel">
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
<div class="font-semibold">{{ stage?.title || '未命名阶段' }}</div>
<div class="flex items-center gap-2 text-sm">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onAdd">添加任务</button>
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onRename">重命名</button>
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onMoveCol">移列</button>
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onDelete">删除</button>
<UCard class="flex h-full flex-col border border-slate-800 bg-slate-950/60">
<template #header>
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<div v-if="editingTitle" class="space-y-2">
<UInput
v-model="titleDraft"
size="sm"
placeholder="阶段名称"
autofocus
@keyup.enter="saveTitle"
/>
<div class="flex items-center gap-2">
<UButton size="2xs" color="primary" icon="i-heroicons-check-20-solid" @click="saveTitle">
保存
</UButton>
<UButton size="2xs" color="neutral" variant="ghost" @click="cancelEdit">
取消
</UButton>
</div>
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<h3 class="truncate font-semibold">{{ stage?.title || '未命名阶段' }}</h3>
<UBadge size="xs" color="neutral" variant="soft">{{ tasksCount }} 个任务</UBadge>
</div>
</div>
<div class="flex items-center gap-1">
<UTooltip text="添加任务">
<UButton size="xs" color="primary" variant="soft" icon="i-heroicons-plus-20-solid" @click="onAdd" />
</UTooltip>
<UDropdown :items="dropdownItems">
<UButton size="xs" color="neutral" variant="ghost" icon="i-heroicons-ellipsis-horizontal-20-solid" />
</UDropdown>
</div>
</div>
</template>
<div
class="flex flex-1 flex-col gap-3 overflow-auto rounded-lg border border-dashed border-slate-800 bg-slate-950/50 p-2 transition"
:data-stage-id="stageId"
@dragover.prevent="onDragOver"
@dragleave="over = false"
@drop.prevent="onDrop"
:class="{ 'border-sky-400/60 bg-sky-500/10': over }"
>
<TaskCard
v-for="tid in filteredTasks"
:key="tid"
:task-id="tid"
/>
<div v-if="!filteredTasks.length" class="py-6 text-center text-xs text-slate-500">
暂无匹配的任务
</div>
</div>
<div class="p-2 flex flex-col gap-2 min-h-[60px]" :data-stage-id="stageId" @dragover.prevent="onDragOver" @dragleave="over=false" @drop.prevent="onDrop" :class="{ 'outline outline-2 outline-accent/60 outline-offset-[-4px] rounded-md': over }">
<TaskCard v-for="tid in filteredTasks" :key="tid" :task-id="tid" @dragging="(v) => dragging.value = v" />
</div>
</div>
</UCard>
<UModal v-model="moveOpen">
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-view-columns-20-solid" class="h-5 w-5 text-sky-400" />
<span class="font-semibold">移动阶段</span>
</div>
</template>
<div class="space-y-4">
<UFormGroup label="目标列" name="column">
<USelectMenu
v-model="moveColumn"
:options="columnOptions"
value-attribute="value"
option-attribute="label"
/>
</UFormGroup>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="moveOpen = false">取消</UButton>
<UButton color="primary" @click="moveStage">移动</UButton>
</div>
</div>
</UCard>
</UModal>
<UModal v-model="deleteOpen">
<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="deleteOpen = false">取消</UButton>
<UButton color="rose" @click="removeStage">删除</UButton>
</div>
</UCard>
</UModal>
</template>
<script setup lang="ts">
import { useBoardStore } from '~/stores/board'
import TaskCard from './TaskCard.vue'
const props = defineProps<{ stageId: string; query: string; category: string | ''; columnIndex: number }>()
const store = useBoardStore()
const props = defineProps<{ stageId: string; query: string; category: string | '' }>()
const toast = useToast()
const stage = computed(() => store.stageById(props.stageId))
const tasks = computed(() => stage.value?.tasks || [])
const tasksCount = computed(() => tasks.value.length)
const filteredTasks = computed(() => {
const q = (props.query || '').toLowerCase()
const c = props.category
return tasks.value.filter((tid) => {
const t = store.taskById(tid)
if (!t) return false
const hitText = !q || t.title.toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q)
const hitText =
!q ||
t.title.toLowerCase().includes(q) ||
(t.description || '').toLowerCase().includes(q)
const hitCat = !c || t.category === c
return hitText && hitCat
})
})
const over = ref(false)
const dragging = ref(false)
function onDragOver(e: DragEvent) {
e.dataTransfer!.dropEffect = 'move'
over.value = true
}
function onDrop(e: DragEvent) {
over.value = false
const taskId = e.dataTransfer?.getData('text/task'); if (!taskId) return
const taskId = e.dataTransfer?.getData('text/task')
if (!taskId) return
const list = (e.currentTarget as HTMLElement).querySelectorAll('[data-task]')
const y = e.clientY
let index = list.length
for (let i = 0; i < list.length; i++) {
const rect = (list[i] as HTMLElement).getBoundingClientRect()
if (y < rect.top + rect.height / 2) { index = i; break }
if (y < rect.top + rect.height / 2) {
index = i
break
}
}
store.moveTask(taskId, props.stageId, index)
}
function onAdd() { store.addTask(props.stageId) }
function onRename() {
const title = prompt('新的阶段名称?', stage.value?.title || '')
if (!title) return
store.renameStage(props.stageId, title.trim())
function onAdd() {
const task = store.addTask(props.stageId)
toast.add({ color: 'primary', title: '已创建新任务', description: task.title })
}
function onDelete() {
if (!confirm('删除该阶段?仅空阶段可删除')) return
if (!store.deleteStage(props.stageId)) alert('阶段非空或不存在')
const editingTitle = ref(false)
const titleDraft = ref('')
watch(
stage,
(value) => {
if (!value) return
titleDraft.value = value.title
},
{ immediate: true }
)
function startEdit() {
editingTitle.value = true
titleDraft.value = stage.value?.title || ''
}
function onMoveCol() {
function saveTitle() {
if (!titleDraft.value.trim()) {
toast.add({ color: 'rose', title: '阶段名称不能为空' })
return
}
store.renameStage(props.stageId, titleDraft.value.trim())
editingTitle.value = false
toast.add({ color: 'neutral', title: '阶段名称已更新' })
}
function cancelEdit() {
editingTitle.value = false
titleDraft.value = stage.value?.title || ''
}
const moveOpen = ref(false)
const moveColumn = ref(props.columnIndex)
watch(
() => props.columnIndex,
(value) => {
moveColumn.value = value
}
)
const columnOptions = computed(() =>
(store.board.layout?.columns || []).map((_, index) => ({
label: `${index + 1}`,
value: index
}))
)
function moveStage() {
const cols = store.board.layout.columns
if (!cols.length) return alert('暂无列')
const ans = prompt(`移动到第几列1 - ${cols.length}?`, '1')
const target = Math.max(1, Math.min(Number(ans) || 1, cols.length)) - 1
cols.forEach((col) => { const i = col.indexOf(props.stageId); if (i !== -1) col.splice(i, 1) })
const target = moveColumn.value
if (!cols[target]) {
toast.add({ color: 'rose', title: '目标列不存在' })
return
}
cols.forEach((col) => {
const i = col.indexOf(props.stageId)
if (i !== -1) col.splice(i, 1)
})
cols[target].push(props.stageId)
store.log('stage-move-column', { id: props.stageId, to: target })
toast.add({ color: 'neutral', title: `阶段已移动到第 ${target + 1}` })
moveOpen.value = false
}
const deleteOpen = ref(false)
function removeStage() {
const success = store.deleteStage(props.stageId)
if (!success) {
toast.add({ color: 'rose', title: '阶段中仍有任务,无法删除' })
return
}
deleteOpen.value = false
toast.add({ color: 'neutral', title: '阶段已删除' })
}
const dropdownItems = computed(() => [
[
{
label: '重命名',
icon: 'i-heroicons-pencil-square-20-solid',
click: () => startEdit()
},
{
label: '移动到列',
icon: 'i-heroicons-view-columns-20-solid',
click: () => {
moveColumn.value = props.columnIndex
moveOpen.value = true
}
}
],
[
{
label: '删除阶段',
icon: 'i-heroicons-trash-20-solid',
click: () => {
deleteOpen.value = true
},
color: 'rose'
}
]
])
</script>