Files
Kanban/src/components/TaskDetailDrawer.vue
T
chenshoulin 347ec6fe27
CI / verify (push) Successful in 1m55s
CI / 部署到服务器 (push) Successful in 29s
feat: 校验截止日期不能早于起始日期
- utils/date 新增纯函数 isDateRangeInvalid:任一端为空视为未排期,不判非法
- DatePicker 支持 min 属性,截止日期日历里选不到早于起始日期的日期
- 任务详情抽屉与批量添加面板实时提示,并在保存/提交时拦截
- 看板批量设置截止日期时逐条跳过起始日期更晚的任务,提示里说明跳过数量

测试:新增纯函数、抽屉、批量面板与看板批量四组用例,全量 957 用例通过。
2026-11-02 16:24:10 +08:00

371 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task'
import { formatDateTime, isDateRangeInvalid } from '@/utils/date'
import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue'
import TagInput from './TagInput.vue'
const props = defineProps<{ task: Task | null }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
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 startDate = ref<string | null>(null)
const tags = ref<string[]>([])
const titleError = ref<string | null>(null)
/**
* 关闭确认:有未保存的修改时先问一句,而不是让遮罩点击把草稿直接吞掉。
* 只有用户明确选择「放弃修改」才真正关闭。
*/
const confirmingClose = ref(false)
/**
* 打开时把任务字段复制到本地草稿。
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
*/
watch(
() => props.task,
(task) => {
title.value = task?.title ?? ''
description.value = task?.description ?? ''
status.value = task?.status ?? 'todo'
priority.value = task?.priority ?? 2
dueDate.value = task?.dueDate ?? null
startDate.value = task?.startDate ?? null
tags.value = task ? [...task.tags] : []
titleError.value = null
confirmingClose.value = false
},
{ immediate: true },
)
const archived = computed(() => props.task !== null && props.task.archivedAt !== null)
/**
* 日期区间校验:截止日期不能早于起始日期。
*
* 用 computed 而不是「点保存才提示」,是因为两个日期在同一屏里,
* 改完立刻看到红字比按下保存再被打回更顺;保存时仍会再挡一次。
*/
const dateError = computed(() =>
isDateRangeInvalid(startDate.value, dueDate.value) ? '截止日期不能早于起始日期' : null,
)
/**
* 只读时间信息。
*
* createdAt 与 updatedAt 由 store 维护,completedAt 在状态进出「已完成」时
* 由 applyTaskPatch 写入 / 清除;这里只负责展示,不参与草稿与 dirty 判定
* —— 时间不是用户能编辑的字段,改了标题也不该让「创建于」看起来被改过。
*/
const createdAtText = computed(() => formatDateTime(props.task?.createdAt ?? null))
const updatedAtText = computed(() => formatDateTime(props.task?.updatedAt ?? null))
const completedAtText = computed(() => formatDateTime(props.task?.completedAt ?? null))
const archivedAtText = computed(() => formatDateTime(props.task?.archivedAt ?? null))
/**
* 完成时间的兜底文案。
*
* 旧数据或手工导入的数据可能出现「status 是已完成、completedAt 却是空」的情况,
* 此时显示「未记录」而不是「未完成」—— 后者会和卡片上的已完成状态自相矛盾。
*/
const completedLabel = computed(() => {
if (completedAtText.value) return completedAtText.value
return props.task?.status === 'done' ? '未记录' : '未完成'
})
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 ||
startDate.value !== task.startDate ||
!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
const trimmed = title.value.trim()
if (!trimmed) {
titleError.value = '标题不能为空'
return
}
// 区间非法时不写入,错误提示已由 dateError 实时展示
if (dateError.value) return
store.updateTask(task.id, {
title: trimmed,
description: description.value.trim(),
status: status.value,
priority: priority.value,
dueDate: dueDate.value,
startDate: startDate.value,
tags: [...tags.value],
})
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
if (!task) return
const removed = store.removeTask(task.id)
emit('close')
if (!removed) return
toast.show({
message: `已删除「${removed.title}」`,
action: { label: '撤销', run: () => store.restoreTask(removed) },
})
}
</script>
<template>
<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
v-model="title"
type="text"
class="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
aria-label="标题"
@input="titleError = null"
/>
<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">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">描述</span>
<textarea
v-model="description"
rows="4"
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
aria-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">
<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="value in PRIORITIES"
:key="value"
type="button"
class="rounded-md border px-3 py-1 text-sm transition"
:class="
priority === value
? '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="priority === value"
@click="priority = value"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
</fieldset>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">起始日期</span>
<DatePicker v-model="startDate" label="起始日期" />
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
<DatePicker v-model="dueDate" label="截止日期" :min="startDate" />
<span v-if="dateError" role="alert" class="text-xs text-rose-600 dark:text-rose-400">
{{ dateError }}
</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
<TagInput v-model="tags" :suggestions="store.allTags" />
</div>
<!--
时间:标题与「标签」同级平铺,明细单独用一个描边框圈起来。
只靠字号(标题 text-sm / 明细 text-xs)分层,框只负责把只读信息
和上方的可编辑字段区分开,不参与层级表达。
明细顺序固定为 创建 → 更新 → 完成(归档时再追加一行),
按时间线上的发生次序排列,而不是按字段重要性。
-->
<section class="flex flex-col gap-1.5" aria-labelledby="task-time-heading">
<h3 id="task-time-heading" class="text-sm font-medium text-slate-600 dark:text-zinc-400">
时间
</h3>
<dl
class="grid grid-cols-[auto_1fr] items-baseline gap-x-3 gap-y-0.5 rounded-md border border-slate-300 px-3 py-2 text-xs dark:border-zinc-700"
>
<dt class="text-slate-400 dark:text-zinc-500">创建</dt>
<dd data-role="created-at" class="tabular-nums text-slate-600 dark:text-zinc-300">
{{ createdAtText ?? '—' }}
</dd>
<dt class="text-slate-400 dark:text-zinc-500">更新</dt>
<dd data-role="updated-at" class="tabular-nums text-slate-600 dark:text-zinc-300">
{{ updatedAtText ?? '—' }}
</dd>
<dt class="text-slate-400 dark:text-zinc-500">完成</dt>
<dd data-role="completed-at" class="tabular-nums text-slate-600 dark:text-zinc-300">
<span :class="completedAtText ? '' : 'text-slate-400 dark:text-zinc-500'">
{{ completedLabel }}
</span>
</dd>
<template v-if="archivedAtText">
<dt class="text-slate-400 dark:text-zinc-500">归档</dt>
<dd data-role="archived-at" class="tabular-nums text-slate-600 dark:text-zinc-300">
{{ archivedAtText }}
</dd>
</template>
</dl>
</section>
</form>
<template #footer>
<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="requestClose"
>
取消
</button>
<button
type="button"
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 dark:hover:bg-sky-500"
@click="save"
>
保存
</button>
</div>
</div>
</template>
</SideDrawer>
</template>