feat(ui): 详情抽屉支持状态切换、归档恢复与未保存确认
- 新增三列状态切换,今日视图也能把任务移到进行中 - 归档任务显示归档提示与「恢复」入口 - 关闭抽屉前检查未保存修改,二次确认后才丢弃草稿
This commit is contained in:
@@ -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