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:
@@ -1,44 +1,95 @@
|
||||
<template>
|
||||
<div class="p-3 overflow-auto grid gap-3" :style="gridStyle">
|
||||
<div v-for="(col, colIndex) in columns" :key="colIndex" class="flex flex-col gap-3 min-w-[320px]">
|
||||
<StageColumn v-for="sid in col" :key="sid" :stage-id="sid" :query="query" :category="category" />
|
||||
<UCard class="flex h-full flex-col">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-slate-200">看板阶段</span>
|
||||
<span class="text-xs text-slate-400">
|
||||
共 {{ totalStages }} 个阶段,分布在 {{ columns.length }} 列
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<UTooltip text="新增阶段">
|
||||
<UButton color="primary" icon="i-heroicons-plus-circle-20-solid" @click="openStageModal()">
|
||||
添加阶段
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
<UTooltip text="新增列(用于分组阶段)">
|
||||
<UButton color="neutral" variant="soft" icon="i-heroicons-view-columns-20-solid" @click="addColumn">
|
||||
添加列
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<div class="flex h-full gap-4 overflow-x-auto pb-6 pr-2">
|
||||
<div
|
||||
v-for="(col, colIndex) in columns"
|
||||
:key="`column-${colIndex}`"
|
||||
class="flex min-w-[320px] flex-col gap-4"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||||
<span>列 {{ colIndex + 1 }}</span>
|
||||
<UBadge color="neutral" variant="soft" :label="`${col.length} 个阶段`" />
|
||||
</div>
|
||||
<StageColumn
|
||||
v-for="sid in col"
|
||||
:key="sid"
|
||||
:stage-id="sid"
|
||||
:query="query"
|
||||
:category="category"
|
||||
:column-index="colIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!columns.length || !totalStages"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 text-slate-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-queue-list-20-solid" class="h-10 w-10 text-slate-600" />
|
||||
<div class="text-sm">暂无阶段。点击“添加阶段”开始构建看板。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!columns.length || !columns[0]?.length" class="text-center text-slate-400 py-10">
|
||||
暂无阶段。点击左侧“添加阶段”创建。
|
||||
</div>
|
||||
<div class="px-3 pb-3">
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addColumn">添加列</button>
|
||||
</div>
|
||||
<div class="px-3 pb-3">
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addStage">添加阶段</button>
|
||||
</div>
|
||||
|
||||
</UCard>
|
||||
|
||||
<StageModal v-model:open="stageModalOpen" :default-column="stageModalColumn" @submit="createStage" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import StageColumn from './StageColumn.vue'
|
||||
import StageModal from '~/components/dialogs/StageModal.vue'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ query: string; category: string | '' }>()
|
||||
const columns = computed(() => store.board.layout?.columns || [])
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridAutoFlow: 'column',
|
||||
gridAutoRows: '1fr'
|
||||
}))
|
||||
const props = defineProps<{ query: string; category: string | '' }>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const columns = computed(() => store.board.layout?.columns || [])
|
||||
const totalStages = computed(() => store.board.stages.length)
|
||||
|
||||
const stageModalOpen = ref(false)
|
||||
const stageModalColumn = ref(0)
|
||||
|
||||
function openStageModal(column = 0) {
|
||||
stageModalColumn.value = column
|
||||
stageModalOpen.value = true
|
||||
}
|
||||
|
||||
function createStage(payload: { title: string; column: number }) {
|
||||
stageModalOpen.value = false
|
||||
const column = Math.max(0, Math.min(payload.column, columns.value.length ? columns.value.length - 1 : 0))
|
||||
store.addStage(payload.title.trim(), column)
|
||||
toast.add({ color: 'primary', title: `已创建阶段「${payload.title}」` })
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (!store.board.layout.columns) store.board.layout.columns = []
|
||||
store.board.layout.columns.push([])
|
||||
store.log('column-add', { count: store.board.layout.columns.length })
|
||||
}
|
||||
function addStage() {
|
||||
const title = prompt('阶段名称?')
|
||||
if (!title) return
|
||||
const colIndex = store.board.layout.columns.length ? 0 : 0
|
||||
store.addStage(title.trim(), colIndex)
|
||||
toast.add({ color: 'neutral', title: `新增列,当前共有 ${store.board.layout.columns.length} 列` })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
<template>
|
||||
<div v-if="open" class="fixed inset-0 bg-black/60 flex items-center justify-center p-4">
|
||||
<div class="w-[1000px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h2 class="font-semibold">导入/合并 预览</h2>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="close">✕</button>
|
||||
</div>
|
||||
<div class="p-3 overflow-auto space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<label class="text-sm text-slate-400 mr-2">策略</label>
|
||||
<select v-model="policy" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="prefer-import">冲突优先:导入文件</option>
|
||||
<option value="prefer-current">冲突优先:当前看板</option>
|
||||
</select>
|
||||
<UModal v-model="open" :ui="{ width: 'max-w-3xl' }">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-arrow-down-tray-20-solid" class="h-5 w-5 text-sky-400" />
|
||||
<span class="font-semibold">导入/合并预览</span>
|
||||
</div>
|
||||
<label class="text-sm flex items-center gap-2">
|
||||
<input type="checkbox" v-model="removeMissing" /> 同步删除在导入文件中不存在的任务/阶段
|
||||
</label>
|
||||
<UBadge color="neutral" variant="soft">来源文件:{{ name || '未命名' }}</UBadge>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务新增</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.added?.length || 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务删除</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.removed?.length || 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3">
|
||||
<div class="text-sm text-slate-400">任务修改</div>
|
||||
<div class="text-2xl font-bold">{{ diff?.tasks?.modified?.length || 0 }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UAlert
|
||||
color="primary"
|
||||
variant="soft"
|
||||
title="合并前请确认策略"
|
||||
description="导入文件将与当前看板数据对比。请选择冲突策略及是否删除缺失项目。"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<UFormGroup label="冲突策略" name="policy" class="w-64">
|
||||
<USelectMenu
|
||||
v-model="policy"
|
||||
:options="policies"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<UCheckbox v-model="removeMissing">
|
||||
同步删除在导入文件中不存在的任务/阶段
|
||||
</UCheckbox>
|
||||
</div>
|
||||
<div class="rounded border border-border bg-slate-900 p-3 text-xs whitespace-pre-wrap">
|
||||
<div>文件:{{ name }}</div>
|
||||
<div>Tasks: +{{ diff?.tasks?.added?.length || 0 }} -{{ diff?.tasks?.removed?.length || 0 }} ~{{ diff?.tasks?.modified?.length || 0 }}</div>
|
||||
<div>Stages: +{{ diff?.stages?.added?.length || 0 }} -{{ diff?.stages?.removed?.length || 0 }} ~{{ diff?.stages?.modified?.length || 0 }}</div>
|
||||
<div>Layout changed: {{ diff?.layout?.changed ? 'Yes' : 'No' }}</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<UCard variant="soft" class="border border-emerald-500/20 bg-emerald-500/5 text-emerald-200">
|
||||
<p class="text-xs uppercase tracking-wide opacity-70">任务新增</p>
|
||||
<p class="text-3xl font-semibold">{{ diff?.tasks?.added?.length || 0 }}</p>
|
||||
</UCard>
|
||||
<UCard variant="soft" class="border border-rose-500/20 bg-rose-500/5 text-rose-200">
|
||||
<p class="text-xs uppercase tracking-wide opacity-70">任务删除</p>
|
||||
<p class="text-3xl font-semibold">{{ diff?.tasks?.removed?.length || 0 }}</p>
|
||||
</UCard>
|
||||
<UCard variant="soft" class="border border-amber-500/20 bg-amber-500/5 text-amber-200">
|
||||
<p class="text-xs uppercase tracking-wide opacity-70">任务修改</p>
|
||||
<p class="text-3xl font-semibold">{{ diff?.tasks?.modified?.length || 0 }}</p>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard variant="soft" class="border border-slate-800 bg-slate-900/70 text-xs text-slate-300">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-duplicate-20-solid" class="h-4 w-4 text-slate-500" />
|
||||
<span>Tasks: +{{ diff?.tasks?.added?.length || 0 }} / -{{ diff?.tasks?.removed?.length || 0 }} / ~{{ diff?.tasks?.modified?.length || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-queue-list-20-solid" class="h-4 w-4 text-slate-500" />
|
||||
<span>Stages: +{{ diff?.stages?.added?.length || 0 }} / -{{ diff?.stages?.removed?.length || 0 }} / ~{{ diff?.stages?.modified?.length || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-view-columns-20-solid" class="h-4 w-4 text-slate-500" />
|
||||
<span>布局是否变化:{{ diff?.layout?.changed ? '是' : '否' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="open = false">取消</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-arrow-up-tray-20-solid" @click="apply">应用合并</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-t border-border">
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="apply">应用合并</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const imported = defineModel<any>('imported', { required: true })
|
||||
const diff = defineModel<any>('diff', { required: true })
|
||||
@@ -57,10 +86,14 @@ const name = defineModel<string>('name', { required: true })
|
||||
|
||||
const policy = ref<'prefer-import' | 'prefer-current'>('prefer-import')
|
||||
const removeMissing = ref(false)
|
||||
function close(){ open.value = false }
|
||||
function apply(){
|
||||
|
||||
const policies = [
|
||||
{ label: '冲突优先:导入文件', value: 'prefer-import' },
|
||||
{ label: '冲突优先:当前看板', value: 'prefer-current' }
|
||||
]
|
||||
|
||||
function apply() {
|
||||
store.applyMerge(imported.value, policy.value, removeMissing.value)
|
||||
close()
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[20px_1fr_auto] items-center gap-2 p-2 rounded-lg bg-card border border-border shadow" draggable="true" data-task :data-id="taskId" @dragstart="onDragStart">
|
||||
<div class="opacity-70 select-none cursor-grab">⋮⋮</div>
|
||||
<div>
|
||||
<div class="font-semibold">{{ task?.title || '(无标题)' }}</div>
|
||||
<div class="text-xs text-slate-400">步骤: {{ stat }}</div>
|
||||
<UCard
|
||||
class="group cursor-grab select-none border border-slate-800 bg-slate-900/80 transition hover:border-sky-500/40"
|
||||
data-task
|
||||
:data-id="taskId"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-slate-800/80 text-xs text-slate-400">
|
||||
<UIcon name="i-heroicons-arrows-up-down-20-solid" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="truncate font-semibold text-slate-100">{{ task?.title || '未命名任务' }}</h4>
|
||||
<UBadge size="xs" color="neutral" variant="subtle">{{ stat }}</UBadge>
|
||||
</div>
|
||||
<div v-if="hasSteps" class="space-y-1">
|
||||
<UProgress :value="progress" color="primary" class="h-1.5" />
|
||||
<p class="text-[11px] text-slate-400">步骤完成度 {{ progress }}%</p>
|
||||
</div>
|
||||
<p v-if="task?.description" class="line-clamp-2 text-xs text-slate-400">
|
||||
{{ task.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<UBadge
|
||||
size="xs"
|
||||
variant="soft"
|
||||
:style="{
|
||||
backgroundColor: `${categoryColor}1A`,
|
||||
color: categoryColor,
|
||||
borderColor: `${categoryColor}33`
|
||||
}"
|
||||
>
|
||||
{{ categoryTitle }}
|
||||
</UBadge>
|
||||
<UButton
|
||||
size="2xs"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-pencil-square-20-solid"
|
||||
@click.stop="modalOpen = true"
|
||||
>
|
||||
详情
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-full border border-black/30 whitespace-nowrap" :style="chipStyle">{{ categoryTitle }}</span>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="openModal">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskModal v-if="open" :task-id="taskId" @close="open=false" />
|
||||
</UCard>
|
||||
|
||||
<TaskModal v-model:open="modalOpen" :task-id="taskId" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -19,21 +57,35 @@ import TaskModal from './TaskModal.vue'
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
|
||||
const task = computed(() => store.taskById(props.taskId))
|
||||
const category = computed(() => store.categoryById(task.value?.category as string))
|
||||
const categoryTitle = computed(() => category.value?.title || '未分类')
|
||||
const chipStyle = computed(() => ({ background: `#${(category.value?.color||'888888')}33`, borderColor: `#${(category.value?.color||'888888')}` }))
|
||||
const stat = computed(() => {
|
||||
const steps = task.value?.steps || []
|
||||
const done = steps.filter(s => s.done).length
|
||||
return `${done}/${steps.length}`
|
||||
const category = computed(() => {
|
||||
const id = task.value?.category
|
||||
return id ? store.categoryById(id) : null
|
||||
})
|
||||
const open = ref(false)
|
||||
function openModal(){ open.value = true }
|
||||
|
||||
const categoryTitle = computed(() => category.value?.title || '未分类')
|
||||
const categoryColor = computed(() => `#${(category.value?.color || '64748b').padStart(6, '0')}`)
|
||||
|
||||
const steps = computed(() => task.value?.steps || [])
|
||||
const hasSteps = computed(() => steps.value.length > 0)
|
||||
|
||||
const stat = computed(() => {
|
||||
const done = steps.value.filter((s) => s.done).length
|
||||
return `${done}/${steps.value.length}`
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!steps.value.length) return 0
|
||||
const done = steps.value.filter((s) => s.done).length
|
||||
return Math.round((done / steps.value.length) * 100)
|
||||
})
|
||||
|
||||
const modalOpen = ref(false)
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
e.dataTransfer?.setData('text/task', props.taskId)
|
||||
e.dataTransfer?.setDragImage(new Image(), 0, 0)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
emit('dragging', true)
|
||||
}
|
||||
const emit = defineEmits<{ (e:'dragging', v:boolean): void }>()
|
||||
</script>
|
||||
|
||||
@@ -1,68 +1,211 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center p-4">
|
||||
<div class="w-[720px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h2 class="font-semibold">编辑任务</h2>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="p-3 overflow-auto space-y-3">
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">标题</label>
|
||||
<input v-model="title" class="px-2 py-1 rounded bg-slate-900 border border-border" />
|
||||
<UModal v-model="open" :ui="{ width: 'max-w-2xl' }">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-pencil-square-20-solid" class="h-5 w-5 text-sky-400" />
|
||||
<span class="font-semibold">编辑任务</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">类别</label>
|
||||
<select v-model="category" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="">未分类</option>
|
||||
<option v-for="c in store.board.categories" :key="c.uuid" :value="c.uuid">{{ c.title }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="save">
|
||||
<UFormGroup label="标题" name="title">
|
||||
<UInput v-model="form.title" placeholder="任务标题" autofocus />
|
||||
</UFormGroup>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<UFormGroup label="类别" name="category">
|
||||
<USelectMenu
|
||||
v-model="form.category"
|
||||
:options="categoryOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
searchable
|
||||
placeholder="选择类别"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="摘要" name="summary">
|
||||
<UInput v-model="summary" placeholder="用于步骤的快速概览(可选)" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">描述</label>
|
||||
<textarea v-model="desc" rows="6" class="px-2 py-1 rounded bg-slate-900 border border-border"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
|
||||
<label class="pt-1 text-sm text-slate-400">步骤</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(s, i) in steps" :key="i" class="grid grid-cols-[24px_1fr_24px] items-center gap-2">
|
||||
<input type="checkbox" v-model="s.done" />
|
||||
<input v-model="s.details" class="px-2 py-1 rounded bg-slate-900 border border-border" />
|
||||
<button class="text-sm" @click="steps.splice(i,1)">🗑</button>
|
||||
|
||||
<UFormGroup label="详细描述" name="description">
|
||||
<UTextarea
|
||||
v-model="form.description"
|
||||
placeholder="补充任务背景、验收标准等……"
|
||||
:rows="6"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-200">步骤清单</span>
|
||||
<UButton size="xs" color="primary" variant="soft" icon="i-heroicons-plus-20-solid" @click="addStep">
|
||||
添加步骤
|
||||
</UButton>
|
||||
</div>
|
||||
<div v-if="!form.steps.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="space-y-3">
|
||||
<div
|
||||
v-for="(step, index) in form.steps"
|
||||
:key="step.id"
|
||||
class="flex items-start gap-3 rounded border border-slate-800 bg-slate-900/80 p-3"
|
||||
>
|
||||
<UCheckbox v-model="step.done" class="mt-1.5" />
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<UInput
|
||||
v-model="step.details"
|
||||
placeholder="步骤内容"
|
||||
/>
|
||||
<p class="text-[11px] text-slate-500">步骤 {{ index + 1 }}</p>
|
||||
</div>
|
||||
<UTooltip text="删除步骤">
|
||||
<UButton
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-trash-20-solid"
|
||||
@click="removeStep(index)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="steps.push({details:'新步骤', done:false})">添加步骤</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<UButton
|
||||
color="rose"
|
||||
variant="soft"
|
||||
icon="i-heroicons-trash-20-solid"
|
||||
@click.prevent="deleteOpen = true"
|
||||
>
|
||||
删除任务
|
||||
</UButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="open = false">取消</UButton>
|
||||
<UButton type="submit" color="primary" icon="i-heroicons-check-20-solid">保存</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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="removeTask">永久删除</UButton>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-t border-border">
|
||||
<button class="px-3 py-1 rounded bg-red-500/90 text-white hover:brightness-110" @click="onDelete">删除</button>
|
||||
<div class="flex-1" />
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="onSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
import { uuid } from '~/utils/uuid'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const store = useBoardStore()
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const t = computed(() => store.taskById(props.taskId))
|
||||
const title = ref(t.value?.title || '')
|
||||
const desc = ref(t.value?.description || '')
|
||||
const category = ref<string | ''>((t.value?.category as string) || '')
|
||||
const steps = ref(JSON.parse(JSON.stringify(t.value?.steps || [])))
|
||||
const toast = useToast()
|
||||
|
||||
function onSave() {
|
||||
store.editTask(props.taskId, { title: title.value.trim() || t.value?.title, description: desc.value, category: category.value || null, steps: steps.value })
|
||||
emit('close')
|
||||
interface StepDraft {
|
||||
id: string
|
||||
details: string
|
||||
done: boolean
|
||||
}
|
||||
function onDelete() {
|
||||
if (!confirm('删除该任务?此操作不可撤销')) return
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '' as string | '',
|
||||
description: '',
|
||||
steps: [] as StepDraft[]
|
||||
})
|
||||
|
||||
const summary = ref('')
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ label: '未分类', value: '' },
|
||||
...store.board.categories.map((c) => ({ label: c.title, value: c.uuid }))
|
||||
])
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(value) => {
|
||||
if (value) hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
() => {
|
||||
if (open.value) hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
function hydrate() {
|
||||
const task = store.taskById(props.taskId)
|
||||
if (!task) return
|
||||
form.title = task.title
|
||||
form.category = (task.category as string) || ''
|
||||
form.description = task.description || ''
|
||||
summary.value = ''
|
||||
form.steps = (task.steps || []).map((step) => ({
|
||||
id: uuid(),
|
||||
details: step.details,
|
||||
done: step.done
|
||||
}))
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
form.steps.push({
|
||||
id: uuid(),
|
||||
details: summary.value ? summary.value : `步骤 ${form.steps.length + 1}`,
|
||||
done: false
|
||||
})
|
||||
summary.value = ''
|
||||
}
|
||||
|
||||
function removeStep(index: number) {
|
||||
form.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!form.title.trim()) {
|
||||
toast.add({ color: 'rose', title: '任务标题不能为空' })
|
||||
return
|
||||
}
|
||||
store.editTask(props.taskId, {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
category: form.category || null,
|
||||
steps: form.steps.map((step) => ({
|
||||
details: step.details.trim(),
|
||||
done: step.done
|
||||
}))
|
||||
})
|
||||
toast.add({ color: 'primary', title: '任务已保存' })
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
|
||||
function removeTask() {
|
||||
store.removeTask(props.taskId)
|
||||
emit('close')
|
||||
toast.add({ color: 'rose', title: '任务已删除' })
|
||||
deleteOpen.value = false
|
||||
open.value = false
|
||||
}
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,35 +1,106 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 p-3 border-b border-border bg-panel">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-semibold">{{ appTitle }}</h1>
|
||||
<span class="text-xs text-slate-400">{{ fileStatus }}</span>
|
||||
<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>
|
||||
<div class="flex-1" />
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="px-3 py-1 rounded bg-slate-800/60 border border-border cursor-pointer hover:bg-slate-800">
|
||||
<input type="file" class="hidden" accept="application/json" @change="onOpen" />
|
||||
打开…
|
||||
</label>
|
||||
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="onSave">保存/导出</button>
|
||||
<label class="px-3 py-1 rounded bg-slate-800/60 border border-border cursor-pointer hover:bg-slate-800">
|
||||
<input type="file" class="hidden" accept="application/json" @change="onImport" />
|
||||
导入/合并…
|
||||
</label>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onNew">新建</button>
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<input v-model="actor" placeholder="你的名字(记录历史)" class="px-2 py-1 rounded bg-slate-900 border border-border w-48" />
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<input v-model="q" placeholder="搜索任务…" class="px-2 py-1 rounded bg-slate-900 border border-border w-56" />
|
||||
<select v-model="cat" class="px-2 py-1 rounded bg-slate-900 border border-border">
|
||||
<option value="">筛选:全部类别</option>
|
||||
<option v-for="c in store.board.categories" :key="c.uuid" :value="c.uuid">{{ c.title }}</option>
|
||||
</select>
|
||||
<span class="w-px h-6 bg-border mx-1" />
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onLoadLocal">从本地读取</button>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onSaveLocal">保存到本地</button>
|
||||
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onClearLocal">清空本地</button>
|
||||
</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">
|
||||
@@ -37,6 +108,8 @@ 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 || '',
|
||||
@@ -45,15 +118,40 @@ const actor = computed({
|
||||
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 a = store.board.meta?.actor ? ` | 操作人:${store.board.meta.actor}` : ''
|
||||
const star = store.dirty ? ' *' : ''
|
||||
return `${name}${star}${a}`
|
||||
const name = store.filename || '未命名'
|
||||
const indicator = store.dirty ? '(未保存修改)' : '(已同步)'
|
||||
return `${name} ${indicator}`
|
||||
})
|
||||
|
||||
function onOpen(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
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()
|
||||
@@ -62,21 +160,19 @@ function onOpen(e: Event) {
|
||||
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) {
|
||||
alert('解析 JSON 失败:' + err.message)
|
||||
toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
input.value = ''
|
||||
}
|
||||
function onSave() { store.downloadCurrent() }
|
||||
function onNew() {
|
||||
if (store.dirty && !confirm('当前更改尚未保存,确定新建吗?')) return
|
||||
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', {})
|
||||
}
|
||||
function onImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
|
||||
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()
|
||||
@@ -84,23 +180,89 @@ function onImport(e: Event) {
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result))
|
||||
const diff = store.diffBoards(store.board as any, data)
|
||||
// emit event for MergeModal
|
||||
emit('open-merge', data, diff, file.name)
|
||||
toast.add({ color: 'neutral', title: `已分析 ${file.name}` })
|
||||
} catch (err: any) {
|
||||
alert('解析 JSON 失败:' + err.message)
|
||||
toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
input.value = ''
|
||||
}
|
||||
function onLoadLocal() {
|
||||
if (!store.loadFromLocal()) alert('本地不存在保存的数据')
|
||||
}
|
||||
function onSaveLocal() { store.saveToLocal() }
|
||||
function onClearLocal() {
|
||||
if (confirm('清空本地保存?')) store.clearLocal()
|
||||
|
||||
function onSave() {
|
||||
store.downloadCurrent()
|
||||
toast.add({ color: 'primary', title: '已导出文件' })
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'open-merge', imported: any, diff: any, name: string): void }>()
|
||||
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>
|
||||
|
||||
|
||||
76
components/dialogs/StageModal.vue
Normal file
76
components/dialogs/StageModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<UModal v-model="open">
|
||||
<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="handleSubmit">
|
||||
<UFormGroup label="阶段名称" name="title">
|
||||
<UInput v-model="form.title" placeholder="例如:待办、进行中、已完成…" autofocus />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup v-if="columnOptions.length" label="放置到列" name="column">
|
||||
<USelectMenu
|
||||
v-model="form.column"
|
||||
:options="columnOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<p v-else class="text-xs text-slate-400">
|
||||
尚未创建列,新增阶段时系统会自动建立第一列。
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="open = false">取消</UButton>
|
||||
<UButton type="submit" color="primary">创建阶段</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoardStore } from '~/stores/board'
|
||||
|
||||
const store = useBoardStore()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{ defaultColumn?: number }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const emit = defineEmits<{ (e: 'submit', payload: { title: string; column: number }): void }>()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
column: 0
|
||||
})
|
||||
|
||||
const columnOptions = computed(() =>
|
||||
(store.board.layout?.columns || []).map((_, index) => ({
|
||||
label: `列 ${index + 1}`,
|
||||
value: index
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
form.title = ''
|
||||
const defaultColumn = props.defaultColumn ?? 0
|
||||
form.column = columnOptions.value[defaultColumn] ? defaultColumn : 0
|
||||
}
|
||||
)
|
||||
|
||||
function handleSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.add({ color: 'rose', title: '请输入阶段名称' })
|
||||
return
|
||||
}
|
||||
emit('submit', { title: form.title.trim(), column: form.column })
|
||||
}
|
||||
</script>
|
||||
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