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

24
app.config.ts Normal file
View File

@@ -0,0 +1,24 @@
export default defineAppConfig({
ui: {
primary: 'sky',
gray: 'slate',
notifications: {
position: 'bottom-right'
},
button: {
default: {
color: 'neutral'
}
},
card: {
default: {
header: {
padding: 'px-4 py-3'
},
body: {
padding: 'px-4 py-4'
}
}
}
}
})

17
app.vue
View File

@@ -1,11 +1,18 @@
<template> <template>
<div class="min-h-full bg-slate-950 text-slate-100"> <UApp>
<NuxtLayout>
<NuxtPage /> <NuxtPage />
</div> </NuxtLayout>
<UNotifications />
</UApp>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const config = useRuntimeConfig().public const config = useRuntimeConfig().public;
useHead({ title: config.appTitle }) useHead({
titleTemplate: (title) => {
if (!title || title === config.appTitle) return config.appTitle;
return `${title} · ${config.appTitle}`;
},
});
</script> </script>

View File

@@ -1,44 +1,95 @@
<template> <template>
<div class="p-3 overflow-auto grid gap-3" :style="gridStyle"> <UCard class="flex h-full flex-col">
<div v-for="(col, colIndex) in columns" :key="colIndex" class="flex flex-col gap-3 min-w-[320px]"> <template #header>
<StageColumn v-for="sid in col" :key="sid" :stage-id="sid" :query="query" :category="category" /> <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>
<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> </div>
<div v-if="!columns.length || !columns[0]?.length" class="text-center text-slate-400 py-10"> </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> </div>
<div class="px-3 pb-3"> <StageColumn
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addColumn">添加列</button> v-for="sid in col"
:key="sid"
:stage-id="sid"
:query="query"
:category="category"
:column-index="colIndex"
/>
</div> </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> </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>
</UCard>
<StageModal v-model:open="stageModalOpen" :default-column="stageModalColumn" @submit="createStage" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBoardStore } from '~/stores/board' import { useBoardStore } from '~/stores/board'
import StageColumn from './StageColumn.vue' import StageColumn from './StageColumn.vue'
import StageModal from '~/components/dialogs/StageModal.vue'
const store = useBoardStore() const store = useBoardStore()
const props = defineProps<{ query: string; category: string | '' }>()
const columns = computed(() => store.board.layout?.columns || [])
const gridStyle = computed(() => ({ const props = defineProps<{ query: string; category: string | '' }>()
gridAutoFlow: 'column',
gridAutoRows: '1fr' 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() { function addColumn() {
if (!store.board.layout.columns) store.board.layout.columns = [] if (!store.board.layout.columns) store.board.layout.columns = []
store.board.layout.columns.push([]) store.board.layout.columns.push([])
store.log('column-add', { count: store.board.layout.columns.length }) store.log('column-add', { count: store.board.layout.columns.length })
} toast.add({ color: 'neutral', title: `新增列,当前共有 ${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)
} }
</script> </script>

View File

@@ -1,55 +1,84 @@
<template> <template>
<div v-if="open" class="fixed inset-0 bg-black/60 flex items-center justify-center p-4"> <UModal v-model="open" :ui="{ width: 'max-w-3xl' }">
<div class="w-[1000px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col"> <UCard>
<div class="flex items-center justify-between px-3 py-2 border-b border-border"> <template #header>
<h2 class="font-semibold">导入/合并 预览</h2> <div class="flex items-center justify-between">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="close"></button> <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> </div>
<div class="p-3 overflow-auto space-y-3"> <UBadge color="neutral" variant="soft">来源文件{{ name || '未命名' }}</UBadge>
<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>
</div> </div>
<label class="text-sm flex items-center gap-2"> </template>
<input type="checkbox" v-model="removeMissing" /> 同步删除在导入文件中不存在的任务/阶段
</label> <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>
<div class="grid grid-cols-3 gap-2">
<div class="rounded border border-border bg-slate-900 p-3"> <div class="grid gap-3 md:grid-cols-3">
<div class="text-sm text-slate-400">任务新增</div> <UCard variant="soft" class="border border-emerald-500/20 bg-emerald-500/5 text-emerald-200">
<div class="text-2xl font-bold">{{ diff?.tasks?.added?.length || 0 }}</div> <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> </div>
<div class="rounded border border-border bg-slate-900 p-3">
<div class="text-sm text-slate-400">任务删除</div> <UCard variant="soft" class="border border-slate-800 bg-slate-900/70 text-xs text-slate-300">
<div class="text-2xl font-bold">{{ diff?.tasks?.removed?.length || 0 }}</div> <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>
<div class="rounded border border-border bg-slate-900 p-3"> <div class="flex items-center gap-2">
<div class="text-sm text-slate-400">任务修改</div> <UIcon name="i-heroicons-queue-list-20-solid" class="h-4 w-4 text-slate-500" />
<div class="text-2xl font-bold">{{ diff?.tasks?.modified?.length || 0 }}</div> <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>
</div> </div>
<div class="rounded border border-border bg-slate-900 p-3 text-xs whitespace-pre-wrap"> </UCard>
<div>文件{{ name }}</div>
<div>Tasks: +{{ diff?.tasks?.added?.length || 0 }} -{{ diff?.tasks?.removed?.length || 0 }} ~{{ diff?.tasks?.modified?.length || 0 }}</div> <div class="flex justify-end gap-2">
<div>Stages: +{{ diff?.stages?.added?.length || 0 }} -{{ diff?.stages?.removed?.length || 0 }} ~{{ diff?.stages?.modified?.length || 0 }}</div> <UButton color="neutral" variant="ghost" @click="open = false">取消</UButton>
<div>Layout changed: {{ diff?.layout?.changed ? 'Yes' : 'No' }}</div> <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>
</div> </div>
</UCard>
</UModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBoardStore } from '~/stores/board' import { useBoardStore } from '~/stores/board'
const store = useBoardStore() const store = useBoardStore()
const open = defineModel<boolean>('open', { required: true }) const open = defineModel<boolean>('open', { required: true })
const imported = defineModel<any>('imported', { required: true }) const imported = defineModel<any>('imported', { required: true })
const diff = defineModel<any>('diff', { 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 policy = ref<'prefer-import' | 'prefer-current'>('prefer-import')
const removeMissing = ref(false) const removeMissing = ref(false)
function close(){ open.value = false }
const policies = [
{ label: '冲突优先:导入文件', value: 'prefer-import' },
{ label: '冲突优先:当前看板', value: 'prefer-current' }
]
function apply() { function apply() {
store.applyMerge(imported.value, policy.value, removeMissing.value) store.applyMerge(imported.value, policy.value, removeMissing.value)
close() open.value = false
} }
</script> </script>

View File

@@ -1,75 +1,261 @@
<template> <template>
<div class="rounded-lg border border-border bg-panel"> <UCard class="flex h-full flex-col border border-slate-800 bg-slate-950/60">
<div class="flex items-center justify-between px-3 py-2 border-b border-border"> <template #header>
<div class="font-semibold">{{ stage?.title || '未命名阶段' }}</div> <div class="flex items-start gap-2">
<div class="flex items-center gap-2 text-sm"> <div class="min-w-0 flex-1">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onAdd">添加任务</button> <div v-if="editingTitle" class="space-y-2">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onRename">重命名</button> <UInput
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onMoveCol">移列</button> v-model="titleDraft"
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="onDelete">删除</button> 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> </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 }"> <div v-else class="flex flex-wrap items-center gap-2">
<TaskCard v-for="tid in filteredTasks" :key="tid" :task-id="tid" @dragging="(v) => dragging.value = v" /> <h3 class="truncate font-semibold">{{ stage?.title || '未命名阶段' }}</h3>
<UBadge size="xs" color="neutral" variant="soft">{{ tasksCount }} 个任务</UBadge>
</div> </div>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBoardStore } from '~/stores/board' import { useBoardStore } from '~/stores/board'
import TaskCard from './TaskCard.vue' import TaskCard from './TaskCard.vue'
const props = defineProps<{ stageId: string; query: string; category: string | ''; columnIndex: number }>()
const store = useBoardStore() const store = useBoardStore()
const props = defineProps<{ stageId: string; query: string; category: string | '' }>() const toast = useToast()
const stage = computed(() => store.stageById(props.stageId)) const stage = computed(() => store.stageById(props.stageId))
const tasks = computed(() => stage.value?.tasks || []) const tasks = computed(() => stage.value?.tasks || [])
const tasksCount = computed(() => tasks.value.length)
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
const q = (props.query || '').toLowerCase() const q = (props.query || '').toLowerCase()
const c = props.category const c = props.category
return tasks.value.filter((tid) => { return tasks.value.filter((tid) => {
const t = store.taskById(tid) const t = store.taskById(tid)
if (!t) return false 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 const hitCat = !c || t.category === c
return hitText && hitCat return hitText && hitCat
}) })
}) })
const over = ref(false) const over = ref(false)
const dragging = ref(false)
function onDragOver(e: DragEvent) { function onDragOver(e: DragEvent) {
e.dataTransfer!.dropEffect = 'move' e.dataTransfer!.dropEffect = 'move'
over.value = true over.value = true
} }
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
over.value = false 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 list = (e.currentTarget as HTMLElement).querySelectorAll('[data-task]')
const y = e.clientY const y = e.clientY
let index = list.length let index = list.length
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const rect = (list[i] as HTMLElement).getBoundingClientRect() 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) store.moveTask(taskId, props.stageId, index)
} }
function onAdd() { store.addTask(props.stageId) }
function onRename() { function onAdd() {
const title = prompt('新的阶段名称?', stage.value?.title || '') const task = store.addTask(props.stageId)
if (!title) return toast.add({ color: 'primary', title: '已创建新任务', description: task.title })
store.renameStage(props.stageId, title.trim())
} }
function onDelete() {
if (!confirm('删除该阶段?仅空阶段可删除')) return const editingTitle = ref(false)
if (!store.deleteStage(props.stageId)) alert('阶段非空或不存在') 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 const cols = store.board.layout.columns
if (!cols.length) return alert('暂无列') const target = moveColumn.value
const ans = prompt(`移动到第几列1 - ${cols.length}?`, '1') if (!cols[target]) {
const target = Math.max(1, Math.min(Number(ans) || 1, cols.length)) - 1 toast.add({ color: 'rose', title: '目标列不存在' })
cols.forEach((col) => { const i = col.indexOf(props.stageId); if (i !== -1) col.splice(i, 1) }) return
}
cols.forEach((col) => {
const i = col.indexOf(props.stageId)
if (i !== -1) col.splice(i, 1)
})
cols[target].push(props.stageId) cols[target].push(props.stageId)
store.log('stage-move-column', { id: props.stageId, to: target }) 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> </script>

View File

@@ -1,16 +1,54 @@
<template> <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"> <UCard
<div class="opacity-70 select-none cursor-grab"></div> class="group cursor-grab select-none border border-slate-800 bg-slate-900/80 transition hover:border-sky-500/40"
<div> data-task
<div class="font-semibold">{{ task?.title || '(无标题)' }}</div> :data-id="taskId"
<div class="text-xs text-slate-400">步骤: {{ stat }}</div> 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>
<div class="flex items-center gap-2"> <div class="min-w-0 flex-1 space-y-2">
<span class="text-[11px] px-2 py-0.5 rounded-full border border-black/30 whitespace-nowrap" :style="chipStyle">{{ categoryTitle }}</span> <div class="flex flex-wrap items-center gap-2">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="openModal">编辑</button> <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> </div>
<TaskModal v-if="open" :task-id="taskId" @close="open=false" /> </UCard>
<TaskModal v-model:open="modalOpen" :task-id="taskId" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -19,21 +57,35 @@ import TaskModal from './TaskModal.vue'
const store = useBoardStore() const store = useBoardStore()
const props = defineProps<{ taskId: string }>() const props = defineProps<{ taskId: string }>()
const task = computed(() => store.taskById(props.taskId)) const task = computed(() => store.taskById(props.taskId))
const category = computed(() => store.categoryById(task.value?.category as string)) const category = computed(() => {
const categoryTitle = computed(() => category.value?.title || '未分类') const id = task.value?.category
const chipStyle = computed(() => ({ background: `#${(category.value?.color||'888888')}33`, borderColor: `#${(category.value?.color||'888888')}` })) return id ? store.categoryById(id) : null
const stat = computed(() => {
const steps = task.value?.steps || []
const done = steps.filter(s => s.done).length
return `${done}/${steps.length}`
}) })
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) { function onDragStart(e: DragEvent) {
e.dataTransfer?.setData('text/task', props.taskId) e.dataTransfer?.setData('text/task', props.taskId)
e.dataTransfer?.setDragImage(new Image(), 0, 0)
e.dataTransfer!.effectAllowed = 'move' e.dataTransfer!.effectAllowed = 'move'
emit('dragging', true)
} }
const emit = defineEmits<{ (e:'dragging', v:boolean): void }>()
</script> </script>

View File

@@ -1,68 +1,211 @@
<template> <template>
<div class="fixed inset-0 bg-black/60 flex items-center justify-center p-4"> <UModal v-model="open" :ui="{ width: 'max-w-2xl' }">
<div class="w-[720px] max-w-[95vw] max-h-[90vh] rounded-lg border border-border bg-panel flex flex-col"> <UCard>
<div class="flex items-center justify-between px-3 py-2 border-b border-border"> <template #header>
<h2 class="font-semibold">编辑任务</h2> <div class="flex items-center gap-2">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="$emit('close')"></button> <UIcon name="i-heroicons-pencil-square-20-solid" class="h-5 w-5 text-sky-400" />
<span class="font-semibold">编辑任务</span>
</div> </div>
<div class="p-3 overflow-auto space-y-3"> </template>
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
<label class="pt-1 text-sm text-slate-400">标题</label> <form class="space-y-5" @submit.prevent="save">
<input v-model="title" class="px-2 py-1 rounded bg-slate-900 border border-border" /> <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>
<div class="grid grid-cols-[90px_1fr] gap-3 items-start">
<label class="pt-1 text-sm text-slate-400">类别</label> <UFormGroup label="详细描述" name="description">
<select v-model="category" class="px-2 py-1 rounded bg-slate-900 border border-border"> <UTextarea
<option value="">未分类</option> v-model="form.description"
<option v-for="c in store.board.categories" :key="c.uuid" :value="c.uuid">{{ c.title }}</option> placeholder="补充任务背景、验收标准等……"
</select> :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>
<div class="grid grid-cols-[90px_1fr] gap-3 items-start"> <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">
<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>
<div class="grid grid-cols-[90px_1fr] gap-3 items-start"> <div v-else class="space-y-3">
<label class="pt-1 text-sm text-slate-400">步骤</label> <div
<div class="space-y-2"> v-for="(step, index) in form.steps"
<div v-for="(s, i) in steps" :key="i" class="grid grid-cols-[24px_1fr_24px] items-center gap-2"> :key="step.id"
<input type="checkbox" v-model="s.done" /> class="flex items-start gap-3 rounded border border-slate-800 bg-slate-900/80 p-3"
<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> <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> </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> <UTooltip text="删除步骤">
</div> <UButton
</div> size="xs"
</div> color="neutral"
<div class="flex items-center gap-2 px-3 py-2 border-t border-border"> variant="ghost"
<button class="px-3 py-1 rounded bg-red-500/90 text-white hover:brightness-110" @click="onDelete">删除</button> icon="i-heroicons-trash-20-solid"
<div class="flex-1" /> @click="removeStep(index)"
<button class="px-3 py-1 rounded bg-accent text-slate-900 font-medium hover:brightness-110" @click="onSave">保存</button> />
</UTooltip>
</div> </div>
</div> </div>
</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>
</UCard>
</UModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBoardStore } from '~/stores/board' 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 store = useBoardStore()
const props = defineProps<{ taskId: string }>() const toast = useToast()
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 || [])))
function onSave() { interface StepDraft {
store.editTask(props.taskId, { title: title.value.trim() || t.value?.title, description: desc.value, category: category.value || null, steps: steps.value }) id: string
emit('close') 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) store.removeTask(props.taskId)
emit('close') toast.add({ color: 'rose', title: '任务已删除' })
deleteOpen.value = false
open.value = false
} }
const emit = defineEmits(['close'])
</script> </script>

View File

@@ -1,35 +1,106 @@
<template> <template>
<div class="flex flex-wrap items-center gap-2 p-3 border-b border-border bg-panel"> <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"> <div class="flex items-center gap-2">
<h1 class="text-lg font-semibold">{{ appTitle }}</h1> <UIcon name="i-heroicons-squares-2x2-20-solid" class="h-5 w-5 text-sky-400" />
<span class="text-xs text-slate-400">{{ fileStatus }}</span> <span class="text-lg font-semibold">{{ appTitle }}</span>
</div> </div>
<div class="flex-1" /> <UBadge
:color="store.dirty ? 'primary' : 'neutral'"
variant="soft"
class="text-xs"
>
{{ fileStatus }}
</UBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton color="neutral" variant="soft" icon="i-heroicons-folder-open-20-solid" @click="triggerOpen">
打开文件
</UButton>
<UButton color="primary" icon="i-heroicons-arrow-down-tray-20-solid" @click="onSave">
导出 JSON
</UButton>
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-path-rounded-square-20-solid" @click="triggerImport">
导入/合并
</UButton>
<UButton color="neutral" variant="ghost" icon="i-heroicons-plus-circle-20-solid" @click="newOpen = true">
新建看板
</UButton>
<UDropdown :items="localMenu" mode="hover">
<UButton color="neutral" variant="ghost" icon="i-heroicons-device-phone-mobile-20-solid">
本地存储
</UButton>
</UDropdown>
</div>
</div>
</template>
<div class="grid gap-3 lg:grid-cols-4">
<UInput
v-model="q"
icon="i-heroicons-magnifying-glass-20-solid"
placeholder="搜索任务(标题或描述)"
color="neutral"
/>
<USelectMenu
v-model="cat"
:options="categoryOptions"
value-attribute="value"
option-attribute="label"
searchable
placeholder="筛选类别"
/>
<UInput
v-model="actor"
icon="i-heroicons-user-circle-20-solid"
placeholder="记录操作人名称(写入历史)"
/>
<div class="flex items-center justify-end text-xs text-slate-400">
最近保存于{{ lastSaved }}
</div>
</div>
</UCard>
<input ref="openInput" type="file" accept="application/json" class="hidden" @change="handleOpen" />
<input ref="importInput" type="file" accept="application/json" class="hidden" @change="handleImport" />
<UModal v-model="newOpen">
<UCard>
<template #header>
<div class="flex items-center gap-2"> <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"> <UIcon name="i-heroicons-plus-circle-20-solid" class="h-5 w-5 text-sky-400" />
<input type="file" class="hidden" accept="application/json" @change="onOpen" /> <span class="font-semibold">新建看板</span>
打开
</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>
</template>
<div class="space-y-4 text-sm text-slate-300">
<p>当前更改尚未导出继续新建将清空现有看板数据</p>
</div> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -37,6 +108,8 @@ import { useBoardStore } from '~/stores/board'
const store = useBoardStore() const store = useBoardStore()
const config = useRuntimeConfig().public const config = useRuntimeConfig().public
const toast = useToast()
const appTitle = computed(() => config.appTitle) const appTitle = computed(() => config.appTitle)
const actor = computed({ const actor = computed({
get: () => store.board.meta?.actor || '', get: () => store.board.meta?.actor || '',
@@ -45,15 +118,40 @@ const actor = computed({
const q = defineModel<string>('query', { required: true }) const q = defineModel<string>('query', { required: true })
const cat = defineModel<string | ''>('category', { 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 fileStatus = computed(() => {
const name = store.filename || '(未命名)' const name = store.filename || '未命名'
const a = store.board.meta?.actor ? ` | 操作人:${store.board.meta.actor}` : '' const indicator = store.dirty ? '(未保存修改)' : '(已同步)'
const star = store.dirty ? ' *' : '' return `${name} ${indicator}`
return `${name}${star}${a}`
}) })
function onOpen(e: Event) { const lastSaved = computed(() => {
const input = e.target as HTMLInputElement 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] const file = input.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader() const reader = new FileReader()
@@ -62,21 +160,19 @@ function onOpen(e: Event) {
const json = JSON.parse(String(reader.result)) const json = JSON.parse(String(reader.result))
store.setBoard(json, file.name) store.setBoard(json, file.name)
store.log('load-file', { name: file.name, size: file.size }) store.log('load-file', { name: file.name, size: file.size })
toast.add({ color: 'primary', title: `已载入 ${file.name}` })
} catch (err: any) { } catch (err: any) {
alert('解析 JSON 失败' + err.message) toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
} }
} }
reader.readAsText(file) reader.readAsText(file)
input.value = '' input.value = ''
} }
function onSave() { store.downloadCurrent() }
function onNew() { const emit = defineEmits<{ (e: 'open-merge', imported: any, diff: any, name: string): void }>()
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: [] } }, '') function handleImport(event: Event) {
store.log('new-board', {}) const input = event.target as HTMLInputElement
}
function onImport(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader() const reader = new FileReader()
@@ -84,23 +180,89 @@ function onImport(e: Event) {
try { try {
const data = JSON.parse(String(reader.result)) const data = JSON.parse(String(reader.result))
const diff = store.diffBoards(store.board as any, data) const diff = store.diffBoards(store.board as any, data)
// emit event for MergeModal
emit('open-merge', data, diff, file.name) emit('open-merge', data, diff, file.name)
toast.add({ color: 'neutral', title: `已分析 ${file.name}` })
} catch (err: any) { } catch (err: any) {
alert('解析 JSON 失败' + err.message) toast.add({ color: 'rose', title: '解析 JSON 失败', description: err?.message })
} }
} }
reader.readAsText(file) reader.readAsText(file)
input.value = '' input.value = ''
} }
function onLoadLocal() {
if (!store.loadFromLocal()) alert('本地不存在保存的数据') function onSave() {
} store.downloadCurrent()
function onSaveLocal() { store.saveToLocal() } toast.add({ color: 'primary', title: '已导出文件' })
function onClearLocal() {
if (confirm('清空本地保存?')) store.clearLocal()
} }
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> </script>

View 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>

View 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>

View 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>

View 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>

5
layouts/default.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100">
<slot />
</div>
</template>

View File

@@ -1,6 +1,6 @@
// Nuxt 4 configuration // Nuxt 4 configuration
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'], modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/tailwindcss'],
css: ['~/assets/css/tailwind.css'], css: ['~/assets/css/tailwind.css'],
typescript: { typescript: {
strict: true strict: true
@@ -10,6 +10,11 @@ export default defineNuxtConfig({
title: 'Kanban' title: 'Kanban'
} }
}, },
ui: {
global: true,
primary: 'sky',
gray: 'slate'
},
tailwindcss: { tailwindcss: {
viewer: false viewer: false
}, },
@@ -22,4 +27,3 @@ export default defineNuxtConfig({
} }
} }
}); });

View File

@@ -22,7 +22,8 @@
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"@types/node": "^20.11.30" "@types/node": "^20.11.30",
"@nuxt/ui": "^2.15.1",
"@nuxt/icon": "^1.5.0"
} }
} }

View File

@@ -1,106 +1,95 @@
<template> <template>
<div class="grid" style="grid-template-rows: auto 1fr; height: calc(100vh);"> <UContainer class="mx-auto max-w-7xl space-y-6 py-8">
<Toolbar v-model:query="query" v-model:category="category" @open-merge="openMerge" /> <Toolbar v-model:query="query" v-model:category="category" @open-merge="openMerge" />
<div class="grid" style="grid-template-columns: 280px 1fr;">
<aside class="overflow-auto p-3 border-r border-border bg-panel"> <div class="grid gap-6 xl:grid-cols-[320px_1fr]">
<section class="mb-4"> <div class="space-y-6">
<h3 class="text-sm font-semibold text-slate-400 mb-2">类别</h3> <CategoryPanel />
<div class="flex flex-col gap-2"> <BoardSummaryPanel />
<div v-for="c in store.board.categories" :key="c.uuid" class="flex items-center gap-2 p-2 rounded border border-border bg-slate-900"> <HistoryPanel />
<span class="w-4 h-4 rounded" :style="{ background: '#'+(c.color||'888888') }" />
<input v-model="c.title" class="flex-1 px-2 py-1 rounded bg-slate-950 border border-border" @change="onCatChange(c)" />
<input v-model="c.color" placeholder="hex" class="w-24 px-2 py-1 rounded bg-slate-950 border border-border" @change="onCatChange(c)" />
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="delCat(c.uuid)">删除</button>
</div> </div>
<button class="px-3 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="addCat">添加类别</button> <div class="space-y-6">
</div>
</section>
<section class="mb-4 text-sm text-slate-400">
<h3 class="text-sm font-semibold text-slate-400 mb-2">布局</h3>
<div>列数{{ store.board.layout.columns.length }}</div>
<div>阶段总数{{ store.board.stages.length }}</div>
</section>
<section>
<h3 class="text-sm font-semibold text-slate-400 mb-2">历史</h3>
<div class="flex flex-col gap-2 text-xs max-h-[40vh] overflow-auto">
<div v-for="h in (store.board.meta?.history||[]).slice().reverse().slice(0,200)" :key="h.id" class="p-2 rounded border border-border bg-slate-900">{{ h.ts }} {{ h.actor?('@'+h.actor):'' }} {{ h.type }}</div>
</div>
<div class="mt-2">
<button class="px-2 py-1 rounded bg-slate-800/60 border border-border hover:bg-slate-800" @click="clearHistory">清空历史写入本地</button>
</div>
</section>
</aside>
<main>
<Board :query="query" :category="category" /> <Board :query="query" :category="category" />
</main> </div>
</div> </div>
<!-- Merge modal --> <MergeModal
<MergeModal v-model:open="mergeOpen" v-model:imported="imported" v-model:diff="diff" v-model:name="name" /> v-model:open="mergeOpen"
</div> v-model:imported="imported"
v-model:diff="diff"
v-model:name="importName"
/>
</UContainer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Toolbar from '~/components/Toolbar.vue' import Toolbar from '~/components/Toolbar.vue'
import Board from '~/components/Board.vue' import Board from '~/components/Board.vue'
import MergeModal from '~/components/MergeModal.vue' import MergeModal from '~/components/MergeModal.vue'
import CategoryPanel from '~/components/panels/CategoryPanel.vue'
import BoardSummaryPanel from '~/components/panels/BoardSummaryPanel.vue'
import HistoryPanel from '~/components/panels/HistoryPanel.vue'
import { useBoardStore } from '~/stores/board' import { useBoardStore } from '~/stores/board'
const store = useBoardStore() const store = useBoardStore()
const config = useRuntimeConfig().public const config = useRuntimeConfig().public
const toast = useToast()
// Try load from query or config const query = ref('')
onMounted(async () => { const category = ref('')
const qp = new URL(window.location.href).searchParams.get('file')
const mergeOpen = ref(false)
const imported = ref<any>({})
const diff = ref<any>({})
const importName = ref('导入文件')
function openMerge(data: any, d: any, name: string) {
imported.value = data
diff.value = d
importName.value = name
mergeOpen.value = true
}
async function bootstrap() {
const currentUrl = typeof window !== 'undefined' ? window.location.href : ''
const qp = currentUrl ? new URL(currentUrl).searchParams.get('file') : null
const fileToLoad = qp || config.autoLoadFile || '' const fileToLoad = qp || config.autoLoadFile || ''
if (fileToLoad) { if (fileToLoad) {
try { try {
const res = await fetch(fileToLoad, { cache: 'no-store' }) const res = await fetch(fileToLoad, { cache: 'no-store' })
if (res.ok) { if (res.ok) {
store.setBoard(await res.json(), fileToLoad) const data = await res.json()
store.setBoard(data, fileToLoad)
store.log('load-file', { name: fileToLoad, via: qp ? 'query' : 'config' }) store.log('load-file', { name: fileToLoad, via: qp ? 'query' : 'config' })
toast.add({ color: 'primary', title: `已自动加载 ${fileToLoad}` })
return return
} }
} catch (e) {} } catch (error: any) {
toast.add({ color: 'rose', title: '自动加载失败', description: error?.message })
}
} }
// else try local storage
if (!store.loadFromLocal()) { if (!store.loadFromLocal()) {
// else set empty store.setBoard(
store.setBoard({ categories: [], stages: [], tasks: [], layout: { columns: [] }, meta: { id: 'open-kanban', version: '1.0.0', createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), actor: '', history: [] } }, '') {
categories: [],
stages: [],
tasks: [],
layout: { columns: [] },
meta: {
id: 'open-kanban',
version: '1.0.0',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
actor: '',
history: []
} }
},
''
)
}
}
onMounted(() => {
bootstrap()
}) })
const query = ref('')
const category = ref('')
function addCat(){
const title = prompt('类别名称?'); if (!title) return
const color = prompt('颜色 hex不含 #', '70bafa') || '70bafa'
store.board.categories.push({ uuid: crypto.randomUUID?.() || Math.random().toString(36).slice(2), title: title.trim(), color: color.replace(/#/g,'').slice(0,6) })
store.log('category-add', {})
}
function delCat(id: string){
// prevent delete if used
const used = store.board.tasks.some(t => t.category === id)
if (used) return alert('该类别已被任务引用,无法删除')
store.board.categories = store.board.categories.filter(c => c.uuid !== id)
store.log('category-delete', { id })
}
function onCatChange(c: any){ store.log('category-update', { id: c.uuid }) }
function clearHistory(){
if (!store.board.meta) return
if (confirm('清空历史记录?将写入本地。')){
store.board.meta.history = []
store.board.meta.modifiedAt = new Date().toISOString()
store.log('history-clear', {})
}
}
// Merge modal state
const mergeOpen = ref(false)
const imported = ref<any>({})
const diff = ref<any>({})
const name = ref('导入文件')
function openMerge(data: any, d: any, n: string){ imported.value = data; diff.value = d; name.value = n; mergeOpen.value = true }
</script> </script>

373
pnpm-lock.yaml generated
View File

@@ -24,6 +24,12 @@ importers:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@nuxt/icon':
specifier: ^1.5.0
version: 1.15.0(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@nuxt/ui':
specifier: ^2.15.1
version: 2.22.3(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)(zod@3.25.76)
'@nuxtjs/tailwindcss': '@nuxtjs/tailwindcss':
specifier: ^6.11.4 specifier: ^6.11.4
version: 6.14.0(magicast@0.3.5)(yaml@2.8.1) version: 6.14.0(magicast@0.3.5)(yaml@2.8.1)
@@ -49,6 +55,12 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -347,6 +359,35 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@headlessui/tailwindcss@0.2.2':
resolution: {integrity: sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==}
engines: {node: '>=10'}
peerDependencies:
tailwindcss: ^3.0 || ^4.0
'@headlessui/vue@1.7.23':
resolution: {integrity: sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==}
engines: {node: '>=10'}
peerDependencies:
vue: ^3.2.0
'@iconify-json/heroicons@1.2.3':
resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
'@iconify/collections@1.0.608':
resolution: {integrity: sha512-uMbaErE6TzDb04peWVFYjc9cweBD+j1nFBHi5EEcA1u1mXJAyePF01VzH6dimurrhivvU+nRmuYfiC8GPDyG6g==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/vue@5.0.0':
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
peerDependencies:
vue: '>=3'
'@ioredis/commands@1.4.0': '@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
@@ -430,6 +471,9 @@ packages:
peerDependencies: peerDependencies:
vite: '>=6.0' vite: '>=6.0'
'@nuxt/icon@1.15.0':
resolution: {integrity: sha512-kA0rxqr1B601zNJNcOXera8CyYcxUCEcT7dXEC7rwAz71PRCN5emf7G656eKEQgtqrD4JSj6NQqWDgrmFcf/GQ==}
'@nuxt/kit@3.19.3': '@nuxt/kit@3.19.3':
resolution: {integrity: sha512-ze46EW5xW+UxDvinvPkYt2MzR355Az1lA3bpX8KDialgnCwr+IbkBij/udbUEC6ZFbidPkfK1eKl4ESN7gMY+w==} resolution: {integrity: sha512-ze46EW5xW+UxDvinvPkYt2MzR355Az1lA3bpX8KDialgnCwr+IbkBij/udbUEC6ZFbidPkfK1eKl4ESN7gMY+w==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
@@ -447,6 +491,26 @@ packages:
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
hasBin: true hasBin: true
'@nuxt/ui@2.22.3':
resolution: {integrity: sha512-895SAzqCCT5JAc1JQ8nAmmpwdKCJqArY8ifL/PNtD681FKSdXiSPxODGnpqpovM/ws6bvoRwglA7BtwAJ5ySBg==}
peerDependencies:
joi: ^17.13.0
superstruct: ^2.0.0
valibot: ^1.0.0
yup: ^1.6.0
zod: ^3.24.0
peerDependenciesMeta:
joi:
optional: true
superstruct:
optional: true
valibot:
optional: true
yup:
optional: true
zod:
optional: true
'@nuxt/vite-builder@4.1.3': '@nuxt/vite-builder@4.1.3':
resolution: {integrity: sha512-yrblLSpGW6h9k+sDZa+vtevQz/6JLrPAj3n97HrEmVa6qB+4sE4HWtkMNUtWsOPe60sAm9usRsjDUkkiHZ0DpA==} resolution: {integrity: sha512-yrblLSpGW6h9k+sDZa+vtevQz/6JLrPAj3n97HrEmVa6qB+4sE4HWtkMNUtWsOPe60sAm9usRsjDUkkiHZ0DpA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -457,6 +521,9 @@ packages:
rolldown: rolldown:
optional: true optional: true
'@nuxtjs/color-mode@3.5.2':
resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==}
'@nuxtjs/tailwindcss@6.14.0': '@nuxtjs/tailwindcss@6.14.0':
resolution: {integrity: sha512-30RyDK++LrUVRgc2A85MktGWIZoRQgeQKjE4CjjD64OXNozyl+4ScHnnYgqVToMM6Ch2ZG2W4wV2J0EN6F0zkQ==} resolution: {integrity: sha512-30RyDK++LrUVRgc2A85MktGWIZoRQgeQKjE4CjjD64OXNozyl+4ScHnnYgqVToMM6Ch2ZG2W4wV2J0EN6F0zkQ==}
@@ -828,6 +895,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@poppinss/colors@4.1.5': '@poppinss/colors@4.1.5':
resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==}
@@ -1039,6 +1109,37 @@ packages:
'@speed-highlight/core@1.2.7': '@speed-highlight/core@1.2.7':
resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@tailwindcss/aspect-ratio@0.4.2':
resolution: {integrity: sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==}
peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
'@tailwindcss/container-queries@0.1.1':
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
'@tailwindcss/forms@0.5.10':
resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==}
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@tanstack/vue-virtual@3.13.12':
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1058,6 +1159,9 @@ packages:
'@types/web-bluetooth@0.0.20': '@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@unhead/vue@2.0.19': '@unhead/vue@2.0.19':
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==} resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==}
peerDependencies: peerDependencies:
@@ -1167,12 +1271,72 @@ packages:
'@vueuse/core@11.3.0': '@vueuse/core@11.3.0':
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==} resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.9.0':
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
peerDependencies:
async-validator: ^4
axios: ^1
change-case: ^5
drauu: ^0.4
focus-trap: ^7
fuse.js: ^7
idb-keyval: ^6
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
universal-cookie: ^7 || ^8
vue: ^3.5.0
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
'@vueuse/math@13.9.0':
resolution: {integrity: sha512-Qk2jqlaEGKwwe2/MBGtUd8nPpzoQPSQTfm2d30NPywjpYdpbI+WqOAE99MuSq9kIRoU7Xq3IYBtxMaLTy6lpsA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@11.3.0': '@vueuse/metadata@11.3.0':
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==} resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/shared@11.3.0': '@vueuse/shared@11.3.0':
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==} resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
abbrev@3.0.1: abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@@ -1923,6 +2087,10 @@ packages:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
globby@15.0.0: globby@15.0.0:
resolution: {integrity: sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==} resolution: {integrity: sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2211,6 +2379,9 @@ packages:
resolution: {integrity: sha512-zPPuIt+ku1iCpFBRwseMcPYQ1cJL8l60rSmKeOuGfOXyE6YnTBmf2aEFNL2HQGrD0cPcLO/t+v9RTgC+fwEh/g==} resolution: {integrity: sha512-zPPuIt+ku1iCpFBRwseMcPYQ1cJL8l60rSmKeOuGfOXyE6YnTBmf2aEFNL2HQGrD0cPcLO/t+v9RTgC+fwEh/g==}
engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
launch-editor@2.11.1: launch-editor@2.11.1:
resolution: {integrity: sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==} resolution: {integrity: sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==}
@@ -2326,6 +2497,10 @@ packages:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'} engines: {node: '>=12'}
mini-svg-data-uri@1.4.4:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -2818,6 +2993,10 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.4.32 postcss: ^8.4.32
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3179,6 +3358,9 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: 1 || 2 || 2.0.1-compat || 3 tailwindcss: 1 || 2 || 2.0.1-compat || 3
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tailwindcss@3.4.18: tailwindcss@3.4.18:
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==} resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -3640,6 +3822,13 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.5.0
tinyexec: 1.0.1
'@antfu/utils@8.1.1': {}
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
@@ -3913,6 +4102,43 @@ snapshots:
'@esbuild/win32-x64@0.25.11': '@esbuild/win32-x64@0.25.11':
optional: true optional: true
'@headlessui/tailwindcss@0.2.2(tailwindcss@3.4.18(yaml@2.8.1))':
dependencies:
tailwindcss: 3.4.18(yaml@2.8.1)
'@headlessui/vue@1.7.23(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/vue-virtual': 3.13.12(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@iconify-json/heroicons@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify/collections@1.0.608':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.2
mlly: 1.8.0
transitivePeerDependencies:
- supports-color
'@iconify/vue@5.0.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.22(typescript@5.9.3)
'@ioredis/commands@1.4.0': {} '@ioredis/commands@1.4.0': {}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
@@ -4096,6 +4322,28 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - vue
'@nuxt/icon@1.15.0(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@iconify/collections': 1.0.608
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
'@iconify/vue': 5.0.0(vue@3.5.22(typescript@5.9.3))
'@nuxt/devtools-kit': 2.6.5(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))
'@nuxt/kit': 3.19.3(magicast@0.3.5)
consola: 3.4.2
local-pkg: 1.1.2
mlly: 1.8.0
ohash: 2.0.11
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinyglobby: 0.2.15
transitivePeerDependencies:
- magicast
- supports-color
- vite
- vue
'@nuxt/kit@3.19.3(magicast@0.3.5)': '@nuxt/kit@3.19.3(magicast@0.3.5)':
dependencies: dependencies:
c12: 3.3.1(magicast@0.3.5) c12: 3.3.1(magicast@0.3.5)
@@ -4178,6 +4426,52 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
'@nuxt/ui@2.22.3(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)(zod@3.25.76)':
dependencies:
'@headlessui/tailwindcss': 0.2.2(tailwindcss@3.4.18(yaml@2.8.1))
'@headlessui/vue': 1.7.23(vue@3.5.22(typescript@5.9.3))
'@iconify-json/heroicons': 1.2.3
'@nuxt/icon': 1.15.0(magicast@0.3.5)(vite@7.1.11(@types/node@20.19.23)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@nuxt/kit': 4.1.3(magicast@0.3.5)
'@nuxtjs/color-mode': 3.5.2(magicast@0.3.5)
'@nuxtjs/tailwindcss': 6.14.0(magicast@0.3.5)(yaml@2.8.1)
'@popperjs/core': 2.11.8
'@standard-schema/spec': 1.0.0
'@tailwindcss/aspect-ratio': 0.4.2(tailwindcss@3.4.18(yaml@2.8.1))
'@tailwindcss/container-queries': 0.1.1(tailwindcss@3.4.18(yaml@2.8.1))
'@tailwindcss/forms': 0.5.10(tailwindcss@3.4.18(yaml@2.8.1))
'@tailwindcss/typography': 0.5.19(tailwindcss@3.4.18(yaml@2.8.1))
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/integrations': 13.9.0(fuse.js@7.1.0)(vue@3.5.22(typescript@5.9.3))
'@vueuse/math': 13.9.0(vue@3.5.22(typescript@5.9.3))
defu: 6.1.4
fuse.js: 7.1.0
ohash: 2.0.11
pathe: 2.0.3
scule: 1.3.0
tailwind-merge: 2.6.0
tailwindcss: 3.4.18(yaml@2.8.1)
optionalDependencies:
zod: 3.25.76
transitivePeerDependencies:
- async-validator
- axios
- change-case
- drauu
- focus-trap
- idb-keyval
- jwt-decode
- magicast
- nprogress
- qrcode
- sortablejs
- supports-color
- tsx
- universal-cookie
- vite
- vue
- yaml
'@nuxt/vite-builder@4.1.3(@types/node@20.19.23)(magicast@0.3.5)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)': '@nuxt/vite-builder@4.1.3(@types/node@20.19.23)(magicast@0.3.5)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)':
dependencies: dependencies:
'@nuxt/kit': 4.1.3(magicast@0.3.5) '@nuxt/kit': 4.1.3(magicast@0.3.5)
@@ -4235,6 +4529,15 @@ snapshots:
- vue-tsc - vue-tsc
- yaml - yaml
'@nuxtjs/color-mode@3.5.2(magicast@0.3.5)':
dependencies:
'@nuxt/kit': 3.19.3(magicast@0.3.5)
pathe: 1.1.2
pkg-types: 1.3.1
semver: 7.7.3
transitivePeerDependencies:
- magicast
'@nuxtjs/tailwindcss@6.14.0(magicast@0.3.5)(yaml@2.8.1)': '@nuxtjs/tailwindcss@6.14.0(magicast@0.3.5)(yaml@2.8.1)':
dependencies: dependencies:
'@nuxt/kit': 3.19.3(magicast@0.3.5) '@nuxt/kit': 3.19.3(magicast@0.3.5)
@@ -4482,6 +4785,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@popperjs/core@2.11.8': {}
'@poppinss/colors@4.1.5': '@poppinss/colors@4.1.5':
dependencies: dependencies:
kleur: 4.1.5 kleur: 4.1.5
@@ -4635,6 +4940,33 @@ snapshots:
'@speed-highlight/core@1.2.7': {} '@speed-highlight/core@1.2.7': {}
'@standard-schema/spec@1.0.0': {}
'@tailwindcss/aspect-ratio@0.4.2(tailwindcss@3.4.18(yaml@2.8.1))':
dependencies:
tailwindcss: 3.4.18(yaml@2.8.1)
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.18(yaml@2.8.1))':
dependencies:
tailwindcss: 3.4.18(yaml@2.8.1)
'@tailwindcss/forms@0.5.10(tailwindcss@3.4.18(yaml@2.8.1))':
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.18(yaml@2.8.1)
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.18(yaml@2.8.1))':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.18(yaml@2.8.1)
'@tanstack/virtual-core@3.13.12': {}
'@tanstack/vue-virtual@3.13.12(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/virtual-core': 3.13.12
vue: 3.5.22(typescript@5.9.3)
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -4654,6 +4986,8 @@ snapshots:
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@unhead/vue@2.0.19(vue@3.5.22(typescript@5.9.3))': '@unhead/vue@2.0.19(vue@3.5.22(typescript@5.9.3))':
dependencies: dependencies:
hookable: 5.5.3 hookable: 5.5.3
@@ -4846,8 +5180,30 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
'@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/integrations@13.9.0(fuse.js@7.1.0)(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
fuse.js: 7.1.0
'@vueuse/math@13.9.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/metadata@11.3.0': {} '@vueuse/metadata@11.3.0': {}
'@vueuse/metadata@13.9.0': {}
'@vueuse/shared@11.3.0(vue@3.5.22(typescript@5.9.3))': '@vueuse/shared@11.3.0(vue@3.5.22(typescript@5.9.3))':
dependencies: dependencies:
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3)) vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
@@ -4855,6 +5211,10 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
'@vueuse/shared@13.9.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
vue: 3.5.22(typescript@5.9.3)
abbrev@3.0.1: {} abbrev@3.0.1: {}
abort-controller@3.0.0: abort-controller@3.0.0:
@@ -5586,6 +5946,8 @@ snapshots:
dependencies: dependencies:
ini: 4.1.1 ini: 4.1.1
globals@15.15.0: {}
globby@15.0.0: globby@15.0.0:
dependencies: dependencies:
'@sindresorhus/merge-streams': 4.0.0 '@sindresorhus/merge-streams': 4.0.0
@@ -5889,6 +6251,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
kolorist@1.8.0: {}
launch-editor@2.11.1: launch-editor@2.11.1:
dependencies: dependencies:
picocolors: 1.1.1 picocolors: 1.1.1
@@ -6006,6 +6370,8 @@ snapshots:
mimic-fn@4.0.0: {} mimic-fn@4.0.0: {}
mini-svg-data-uri@1.4.4: {}
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
@@ -6688,6 +7054,11 @@ snapshots:
postcss: 8.5.6 postcss: 8.5.6
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -7072,6 +7443,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
tailwind-merge@2.6.0: {}
tailwindcss@3.4.18(yaml@2.8.1): tailwindcss@3.4.18(yaml@2.8.1):
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0

View File

@@ -113,6 +113,28 @@ export const useBoardStore = defineStore('board', () => {
const stageById = (id: string) => board.value.stages.find(s => s.uuid === id) const stageById = (id: string) => board.value.stages.find(s => s.uuid === id)
const categoryById = (id: string) => board.value.categories.find(c => c.uuid === id) const categoryById = (id: string) => board.value.categories.find(c => c.uuid === id)
function addCategory(title: string, color: string) {
const c: Category = { uuid: uuid(), title, color }
board.value.categories.push(c)
log('category-add', { id: c.uuid, title, color })
return c
}
function updateCategory(id: string, patch: Partial<Category>) {
const c = categoryById(id)
if (!c) return false
const before = JSON.parse(JSON.stringify(c))
Object.assign(c, patch)
log('category-update', { id, before, after: c })
return true
}
function removeCategory(id: string) {
const used = board.value.tasks.some(t => t.category === id)
if (used) return false
board.value.categories = board.value.categories.filter(c => c.uuid !== id)
log('category-delete', { id })
return true
}
function addStage(title: string, colIndex = 0) { function addStage(title: string, colIndex = 0) {
const s: Stage = { uuid: uuid(), title, tasks: [] } const s: Stage = { uuid: uuid(), title, tasks: [] }
board.value.stages.push(s) board.value.stages.push(s)
@@ -188,8 +210,8 @@ export const useBoardStore = defineStore('board', () => {
setBoard, setActor, log, saveToLocal, loadFromLocal, clearLocal, setBoard, setActor, log, saveToLocal, loadFromLocal, clearLocal,
toJSON, downloadCurrent, applyMerge, toJSON, downloadCurrent, applyMerge,
addStage, renameStage, deleteStage, addTask, removeTask, editTask, moveTask, addStage, renameStage, deleteStage, addTask, removeTask, editTask, moveTask,
addCategory, updateCategory, removeCategory,
taskById, stageById, categoryById, taskById, stageById, categoryById,
diffBoards diffBoards
} }
}) })

View File

@@ -19,6 +19,5 @@ export default {
} }
} }
}, },
plugins: [] plugins: [],
} satisfies Config } satisfies Config