Files
kanban/components/StageColumn.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

262 lines
7.5 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 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>
</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 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 hitCat = !c || t.category === c
return hitText && hitCat
})
})
const over = 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 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
}
}
store.moveTask(taskId, props.stageId, index)
}
function onAdd() {
const task = store.addTask(props.stageId)
toast.add({ color: 'primary', title: '已创建新任务', description: task.title })
}
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 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
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>