feat(ui): 详情抽屉支持状态切换、归档恢复与未保存确认

- 新增三列状态切换,今日视图也能把任务移到进行中
- 归档任务显示归档提示与「恢复」入口
- 关闭抽屉前检查未保存修改,二次确认后才丢弃草稿
This commit is contained in:
chenshoulin
2026-10-19 17:20:58 +08:00
parent ede4bfe88f
commit f69a24de94
2 changed files with 203 additions and 17 deletions
+76 -4
View File
@@ -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()
+127 -13
View File
@@ -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>