feat(ui): 详情抽屉支持状态切换、归档恢复与未保存确认
- 新增三列状态切换,今日视图也能把任务移到进行中 - 归档任务显示归档提示与「恢复」入口 - 关闭抽屉前检查未保存修改,二次确认后才丢弃草稿
This commit is contained in:
@@ -46,9 +46,9 @@ describe('TaskDetailDrawer 回填', () => {
|
|||||||
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
||||||
'原标题',
|
'原标题',
|
||||||
)
|
)
|
||||||
expect(
|
expect((wrapper.find('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value).toBe(
|
||||||
(wrapper.find('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value,
|
'原描述',
|
||||||
).toBe('原描述')
|
)
|
||||||
expect(wrapper.text()).toContain('#设计')
|
expect(wrapper.text()).toContain('#设计')
|
||||||
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
|
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
|
||||||
'2026-12-01',
|
'2026-12-01',
|
||||||
@@ -125,12 +125,31 @@ describe('TaskDetailDrawer 保存', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('TaskDetailDrawer 取消', () => {
|
describe('TaskDetailDrawer 取消', () => {
|
||||||
it('取消不产生脏数据', async () => {
|
it('没有修改时取消直接关闭', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('有未保存的修改')
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有未保存修改时先确认,放弃后才关闭且不产生脏数据', async () => {
|
||||||
const { store, wrapper, taskId } = setup()
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
||||||
await buttonByText(wrapper, '取消')?.trigger('click')
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
// 还没有关闭,先给出确认
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
expect(wrapper.text()).toContain('有未保存的修改')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '继续编辑')?.trigger('click')
|
||||||
|
expect(wrapper.text()).not.toContain('有未保存的修改')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '放弃修改')?.trigger('click')
|
||||||
|
|
||||||
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
|
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
|
||||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -140,6 +159,7 @@ describe('TaskDetailDrawer 取消', () => {
|
|||||||
|
|
||||||
await wrapper.find('input[aria-label="标题"]').setValue('临时草稿')
|
await wrapper.find('input[aria-label="标题"]').setValue('临时草稿')
|
||||||
await buttonByText(wrapper, '取消')?.trigger('click')
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '放弃修改')?.trigger('click')
|
||||||
|
|
||||||
// 模拟真实的「关闭再打开」:BoardView 会把 editingId 置空再设回,
|
// 模拟真实的「关闭再打开」:BoardView 会把 editingId 置空再设回,
|
||||||
// 因此 prop 会经历 null -> task 的变化并触发 watcher
|
// 因此 prop 会经历 null -> task 的变化并触发 watcher
|
||||||
@@ -153,6 +173,58 @@ describe('TaskDetailDrawer 取消', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 状态', () => {
|
||||||
|
it('可以把任务从待办改为进行中并保存', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '进行中')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.status).toBe('doing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切到已完成会写入 completedAt', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '已完成')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
|
||||||
|
const task = store.tasks.find((item) => item.id === taskId)
|
||||||
|
expect(task?.status).toBe('done')
|
||||||
|
expect(task?.completedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('修改后显示未保存标记,保存后消失', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('未保存')
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('改过的标题')
|
||||||
|
expect(wrapper.text()).toContain('未保存')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
|
||||||
|
// 保存后 store 里的任务已更新,抽屉被关闭,草稿不再算脏
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('改过的标题')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 归档', () => {
|
||||||
|
it('归档任务显示恢复入口,点击后取消归档', async () => {
|
||||||
|
const { store, toast, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
store.archiveTasks([taskId])
|
||||||
|
await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已归档')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '恢复')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.archivedAt).toBeNull()
|
||||||
|
expect(toast.toasts[toast.toasts.length - 1]?.message).toContain('已恢复')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('TaskDetailDrawer 删除与撤销', () => {
|
describe('TaskDetailDrawer 删除与撤销', () => {
|
||||||
it('删除后任务消失并弹出可撤销提示', async () => {
|
it('删除后任务消失并弹出可撤销提示', async () => {
|
||||||
const { store, toast, wrapper } = setup()
|
const { store, toast, wrapper } = setup()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { PRIORITY_LABELS, type Priority, type Task } from '@/types/task'
|
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task'
|
||||||
|
|
||||||
import DatePicker from './DatePicker.vue'
|
import DatePicker from './DatePicker.vue'
|
||||||
import SideDrawer from './SideDrawer.vue'
|
import SideDrawer from './SideDrawer.vue'
|
||||||
@@ -20,11 +20,18 @@ const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
|||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
|
const status = ref<ColumnId>('todo')
|
||||||
const priority = ref<Priority>(2)
|
const priority = ref<Priority>(2)
|
||||||
const dueDate = ref<string | null>(null)
|
const dueDate = ref<string | null>(null)
|
||||||
const tags = ref<string[]>([])
|
const tags = ref<string[]>([])
|
||||||
const titleError = ref<string | null>(null)
|
const titleError = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭确认:有未保存的修改时先问一句,而不是让遮罩点击把草稿直接吞掉。
|
||||||
|
* 只有用户明确选择「放弃修改」才真正关闭。
|
||||||
|
*/
|
||||||
|
const confirmingClose = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开时把任务字段复制到本地草稿。
|
* 打开时把任务字段复制到本地草稿。
|
||||||
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
|
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
|
||||||
@@ -34,14 +41,51 @@ watch(
|
|||||||
(task) => {
|
(task) => {
|
||||||
title.value = task?.title ?? ''
|
title.value = task?.title ?? ''
|
||||||
description.value = task?.description ?? ''
|
description.value = task?.description ?? ''
|
||||||
|
status.value = task?.status ?? 'todo'
|
||||||
priority.value = task?.priority ?? 2
|
priority.value = task?.priority ?? 2
|
||||||
dueDate.value = task?.dueDate ?? null
|
dueDate.value = task?.dueDate ?? null
|
||||||
tags.value = task ? [...task.tags] : []
|
tags.value = task ? [...task.tags] : []
|
||||||
titleError.value = null
|
titleError.value = null
|
||||||
|
confirmingClose.value = false
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const archived = computed(() => props.task !== null && props.task.archivedAt !== null)
|
||||||
|
|
||||||
|
function sameTags(left: readonly string[], right: readonly string[]): boolean {
|
||||||
|
return left.length === right.length && left.every((tag, index) => tag === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 草稿是否与已保存的任务不同 */
|
||||||
|
const dirty = computed(() => {
|
||||||
|
const task = props.task
|
||||||
|
if (!task) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
title.value.trim() !== task.title ||
|
||||||
|
description.value.trim() !== task.description ||
|
||||||
|
status.value !== task.status ||
|
||||||
|
priority.value !== task.priority ||
|
||||||
|
dueDate.value !== task.dueDate ||
|
||||||
|
!sameTags(tags.value, task.tags)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 关闭入口:有未保存修改时先进入确认态 */
|
||||||
|
function requestClose(): void {
|
||||||
|
if (dirty.value) {
|
||||||
|
confirmingClose.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardAndClose(): void {
|
||||||
|
confirmingClose.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
function save(): void {
|
function save(): void {
|
||||||
const task = props.task
|
const task = props.task
|
||||||
if (!task) return
|
if (!task) return
|
||||||
@@ -55,6 +99,7 @@ function save(): void {
|
|||||||
store.updateTask(task.id, {
|
store.updateTask(task.id, {
|
||||||
title: trimmed,
|
title: trimmed,
|
||||||
description: description.value.trim(),
|
description: description.value.trim(),
|
||||||
|
status: status.value,
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
dueDate: dueDate.value,
|
dueDate: dueDate.value,
|
||||||
tags: [...tags.value],
|
tags: [...tags.value],
|
||||||
@@ -62,6 +107,15 @@ function save(): void {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从归档状态恢复。恢复后这张卡片会重新出现在看板上。 */
|
||||||
|
function restoreFromArchive(): void {
|
||||||
|
const task = props.task
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const count = store.unarchiveTasks([task.id])
|
||||||
|
if (count > 0) toast.show({ message: `已恢复「${task.title}」` })
|
||||||
|
}
|
||||||
|
|
||||||
/** 删除不弹二次确认,改由 Toast 的撤销兜底(误删成本更低) */
|
/** 删除不弹二次确认,改由 Toast 的撤销兜底(误删成本更低) */
|
||||||
function remove(): void {
|
function remove(): void {
|
||||||
const task = props.task
|
const task = props.task
|
||||||
@@ -79,8 +133,22 @@ function remove(): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
|
<SideDrawer :open="task !== null" title="任务详情" @close="requestClose">
|
||||||
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
||||||
|
<div
|
||||||
|
v-if="archived"
|
||||||
|
class="flex items-start justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<span>这个任务已归档,默认不在看板上显示。</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 font-medium underline underline-offset-2 transition hover:text-amber-950 dark:hover:text-amber-100"
|
||||||
|
@click="restoreFromArchive"
|
||||||
|
>
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1.5">
|
<label class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标题</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标题</span>
|
||||||
<input
|
<input
|
||||||
@@ -90,7 +158,9 @@ function remove(): void {
|
|||||||
aria-label="标题"
|
aria-label="标题"
|
||||||
@input="titleError = null"
|
@input="titleError = null"
|
||||||
/>
|
/>
|
||||||
<span v-if="titleError" class="text-xs text-rose-600 dark:text-rose-400">{{ titleError }}</span>
|
<span v-if="titleError" class="text-xs text-rose-600 dark:text-rose-400">{{
|
||||||
|
titleError
|
||||||
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1.5">
|
<label class="flex flex-col gap-1.5">
|
||||||
@@ -103,6 +173,27 @@ function remove(): void {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
|
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">状态</legend>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="column in COLUMNS"
|
||||||
|
:key="column.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-3 py-1 text-sm transition"
|
||||||
|
:class="
|
||||||
|
status === column.id
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="status === column.id"
|
||||||
|
@click="status = column.id"
|
||||||
|
>
|
||||||
|
{{ column.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="flex flex-col gap-1.5">
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
|
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -136,20 +227,43 @@ function remove(): void {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex items-center justify-between">
|
<div v-if="confirmingClose" class="flex flex-col gap-2" role="alert">
|
||||||
<button
|
<p class="text-sm text-amber-700 dark:text-amber-300">有未保存的修改,关闭后会丢失。</p>
|
||||||
type="button"
|
<div class="flex justify-end gap-2">
|
||||||
class="rounded-md border border-rose-300 px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
|
<button
|
||||||
@click="remove"
|
type="button"
|
||||||
>
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||||
删除
|
@click="confirmingClose = false"
|
||||||
</button>
|
>
|
||||||
|
继续编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-rose-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
|
||||||
|
@click="discardAndClose"
|
||||||
|
>
|
||||||
|
放弃修改
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-rose-300 px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<span v-if="dirty" class="text-xs text-amber-600 dark:text-amber-400">未保存</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||||
@click="emit('close')"
|
@click="requestClose"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user