- utils/date 新增纯函数 isDateRangeInvalid:任一端为空视为未排期,不判非法 - DatePicker 支持 min 属性,截止日期日历里选不到早于起始日期的日期 - 任务详情抽屉与批量添加面板实时提示,并在保存/提交时拦截 - 看板批量设置截止日期时逐条跳过起始日期更晚的任务,提示里说明跳过数量 测试:新增纯函数、抽屉、批量面板与看板批量四组用例,全量 957 用例通过。
371 lines
14 KiB
Vue
371 lines
14 KiB
Vue
<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>
|