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('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value,
|
||||
).toBe('原描述')
|
||||
expect((wrapper.find('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value).toBe(
|
||||
'原描述',
|
||||
)
|
||||
expect(wrapper.text()).toContain('#设计')
|
||||
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
|
||||
'2026-12-01',
|
||||
@@ -125,12 +125,31 @@ 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()
|
||||
|
||||
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
||||
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(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
@@ -140,6 +159,7 @@ describe('TaskDetailDrawer 取消', () => {
|
||||
|
||||
await wrapper.find('input[aria-label="标题"]').setValue('临时草稿')
|
||||
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||
await buttonByText(wrapper, '放弃修改')?.trigger('click')
|
||||
|
||||
// 模拟真实的「关闭再打开」:BoardView 会把 editingId 置空再设回,
|
||||
// 因此 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 删除与撤销', () => {
|
||||
it('删除后任务消失并弹出可撤销提示', async () => {
|
||||
const { store, toast, wrapper } = setup()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
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 SideDrawer from './SideDrawer.vue'
|
||||
@@ -20,11 +20,18 @@ const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const status = ref<ColumnId>('todo')
|
||||
const priority = ref<Priority>(2)
|
||||
const dueDate = ref<string | null>(null)
|
||||
const tags = ref<string[]>([])
|
||||
const titleError = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 关闭确认:有未保存的修改时先问一句,而不是让遮罩点击把草稿直接吞掉。
|
||||
* 只有用户明确选择「放弃修改」才真正关闭。
|
||||
*/
|
||||
const confirmingClose = ref(false)
|
||||
|
||||
/**
|
||||
* 打开时把任务字段复制到本地草稿。
|
||||
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
|
||||
@@ -34,14 +41,51 @@ watch(
|
||||
(task) => {
|
||||
title.value = task?.title ?? ''
|
||||
description.value = task?.description ?? ''
|
||||
status.value = task?.status ?? 'todo'
|
||||
priority.value = task?.priority ?? 2
|
||||
dueDate.value = task?.dueDate ?? null
|
||||
tags.value = task ? [...task.tags] : []
|
||||
titleError.value = null
|
||||
confirmingClose.value = false
|
||||
},
|
||||
{ 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 {
|
||||
const task = props.task
|
||||
if (!task) return
|
||||
@@ -55,6 +99,7 @@ function save(): void {
|
||||
store.updateTask(task.id, {
|
||||
title: trimmed,
|
||||
description: description.value.trim(),
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
dueDate: dueDate.value,
|
||||
tags: [...tags.value],
|
||||
@@ -62,6 +107,15 @@ function save(): void {
|
||||
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 的撤销兜底(误删成本更低) */
|
||||
function remove(): void {
|
||||
const task = props.task
|
||||
@@ -79,8 +133,22 @@ function remove(): void {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标题</span>
|
||||
<input
|
||||
@@ -90,7 +158,9 @@ function remove(): void {
|
||||
aria-label="标题"
|
||||
@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 class="flex flex-col gap-1.5">
|
||||
@@ -103,6 +173,27 @@ function remove(): void {
|
||||
/>
|
||||
</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">
|
||||
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
|
||||
<div class="flex gap-2">
|
||||
@@ -136,20 +227,43 @@ function remove(): void {
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<div v-if="confirmingClose" class="flex flex-col gap-2" role="alert">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">有未保存的修改,关闭后会丢失。</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<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"
|
||||
@click="confirmingClose = false"
|
||||
>
|
||||
继续编辑
|
||||
</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">
|
||||
<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"
|
||||
@click="emit('close')"
|
||||
@click="requestClose"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user