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
+127 -13
View File
@@ -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>