feat: 校验截止日期不能早于起始日期
CI / verify (push) Successful in 1m55s
CI / 部署到服务器 (push) Successful in 29s

- utils/date 新增纯函数 isDateRangeInvalid:任一端为空视为未排期,不判非法
- DatePicker 支持 min 属性,截止日期日历里选不到早于起始日期的日期
- 任务详情抽屉与批量添加面板实时提示,并在保存/提交时拦截
- 看板批量设置截止日期时逐条跳过起始日期更晚的任务,提示里说明跳过数量

测试:新增纯函数、抽屉、批量面板与看板批量四组用例,全量 957 用例通过。
This commit is contained in:
chenshoulin
2026-11-02 16:24:10 +08:00
parent 6d58227614
commit 347ec6fe27
9 changed files with 154 additions and 15 deletions
+15
View File
@@ -1,6 +1,7 @@
import { enableAutoUnmount, mount } from '@vue/test-utils' import { enableAutoUnmount, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createMemoryTaskRepository } from '@/data/taskRepository' import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
@@ -104,6 +105,20 @@ describe('BatchAddDrawer 提交', () => {
expect(store.tasks[0]?.startDate).toBe(todayIso()) expect(store.tasks[0]?.startDate).toBe(todayIso())
}) })
it('截止日期早于起始日期时提示且不提交', async () => {
const { store, wrapper } = setup()
await wrapper.find('textarea').setValue('A')
await wrapper.findAll('input[type="date"]')[1]?.setValue('2020-01-01')
await nextTick()
expect(wrapper.text()).toContain('截止日期不能早于起始日期')
await buttonByText(wrapper, '添加 1 个任务')?.trigger('click')
expect(store.tasks).toHaveLength(0)
})
it('提交后请求关闭并提示数量', async () => { it('提交后请求关闭并提示数量', async () => {
const { wrapper } = setup() const { wrapper } = setup()
await wrapper.find('textarea').setValue('A\nB') await wrapper.find('textarea').setValue('A\nB')
+11 -4
View File
@@ -15,7 +15,7 @@ import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
import { parseTaskLines } from '@/utils/batch' import { parseTaskLines } from '@/utils/batch'
import { todayIso } from '@/utils/date' import { isDateRangeInvalid, todayIso } from '@/utils/date'
import DatePicker from './DatePicker.vue' import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue' import SideDrawer from './SideDrawer.vue'
@@ -39,6 +39,10 @@ const tags = ref<string[]>([])
const titles = computed(() => parseTaskLines(text.value)) const titles = computed(() => parseTaskLines(text.value))
const canSubmit = computed(() => titles.value.length > 0) const canSubmit = computed(() => titles.value.length > 0)
/** 整批共用同一组日期,因此校验只需针对这两个值 */
const dateError = computed(() =>
isDateRangeInvalid(startDate.value, dueDate.value) ? '截止日期不能早于起始日期' : null,
)
/** 每次打开都回到干净状态,避免上一次的残留被误提交 */ /** 每次打开都回到干净状态,避免上一次的残留被误提交 */
watch( watch(
@@ -56,7 +60,7 @@ watch(
) )
function submit(): void { function submit(): void {
if (!canSubmit.value) return if (!canSubmit.value || dateError.value) return
const created = store.createTasks( const created = store.createTasks(
titles.value.map((title) => ({ titles.value.map((title) => ({
@@ -145,12 +149,15 @@ function submit(): void {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">起始日期</span> <span class="text-sm font-medium text-slate-600 dark:text-zinc-400">起始日期</span>
<DatePicker v-model="startDate" /> <DatePicker v-model="startDate" label="起始日期" />
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span> <span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
<DatePicker v-model="dueDate" /> <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>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
+2 -1
View File
@@ -27,7 +27,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
const props = defineProps<{ modelValue: string | null; label?: string }>() const props = defineProps<{ modelValue: string | null; label?: string; min?: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>() const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
@@ -65,6 +65,7 @@ function openPicker(): void {
type="date" type="date"
class="w-full rounded-md border border-slate-300 bg-white 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" class="w-full rounded-md border border-slate-300 bg-white 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"
:value="modelValue ?? ''" :value="modelValue ?? ''"
:min="min ?? undefined"
:aria-label="label ?? '截止日期'" :aria-label="label ?? '截止日期'"
@change="onChange" @change="onChange"
@keydown.enter.prevent="openPicker" @keydown.enter.prevent="openPicker"
+22
View File
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createMemoryTaskRepository } from '@/data/taskRepository' import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
@@ -130,6 +131,27 @@ describe('TaskDetailDrawer 保存', () => {
await buttonByText(wrapper, '保存')?.trigger('click') await buttonByText(wrapper, '保存')?.trigger('click')
expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBeNull() expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBeNull()
}) })
it('截止日期早于起始日期时提示并阻止保存', async () => {
const { store, wrapper, taskId } = setup()
// 起始日期默认为当天,这里把截止日期改到过去
await wrapper.findAll('input[type="date"]')[1]?.setValue('2020-01-01')
await nextTick()
expect(wrapper.text()).toContain('截止日期不能早于起始日期')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBe('2026-12-01')
expect(wrapper.emitted('close')).toBeUndefined()
})
it('截止日期输入框带上起始日期作为下限', async () => {
const { wrapper } = setup()
const dueInput = wrapper.findAll('input[type="date"]')[1]
expect(dueInput?.attributes('min')).toBe(todayIso())
})
}) })
describe('TaskDetailDrawer 取消', () => { describe('TaskDetailDrawer 取消', () => {
+19 -3
View File
@@ -4,7 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task'
import { formatDateTime } from '@/utils/date' import { formatDateTime, isDateRangeInvalid } from '@/utils/date'
import DatePicker from './DatePicker.vue' import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue' import SideDrawer from './SideDrawer.vue'
@@ -56,6 +56,16 @@ watch(
const archived = computed(() => props.task !== null && props.task.archivedAt !== null) const archived = computed(() => props.task !== null && props.task.archivedAt !== null)
/**
* 日期区间校验:截止日期不能早于起始日期。
*
* 用 computed 而不是「点保存才提示」,是因为两个日期在同一屏里,
* 改完立刻看到红字比按下保存再被打回更顺;保存时仍会再挡一次。
*/
const dateError = computed(() =>
isDateRangeInvalid(startDate.value, dueDate.value) ? '截止日期不能早于起始日期' : null,
)
/** /**
* 只读时间信息。 * 只读时间信息。
* *
@@ -123,6 +133,9 @@ function save(): void {
return return
} }
// 区间非法时不写入,错误提示已由 dateError 实时展示
if (dateError.value) return
store.updateTask(task.id, { store.updateTask(task.id, {
title: trimmed, title: trimmed,
description: description.value.trim(), description: description.value.trim(),
@@ -245,12 +258,15 @@ function remove(): void {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">起始日期</span> <span class="text-sm font-medium text-slate-600 dark:text-zinc-400">起始日期</span>
<DatePicker v-model="startDate" /> <DatePicker v-model="startDate" label="起始日期" />
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span> <span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
<DatePicker v-model="dueDate" /> <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>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
+23
View File
@@ -6,6 +6,7 @@ import {
formatDateTime, formatDateTime,
formatDueDate, formatDueDate,
formatDueDateForTask, formatDueDateForTask,
isDateRangeInvalid,
isDueToday, isDueToday,
isOverdue, isOverdue,
localDateOf, localDateOf,
@@ -51,6 +52,28 @@ describe('isDueToday', () => {
}) })
}) })
describe('isDateRangeInvalid', () => {
it('截止日期早于起始日期时非法', () => {
expect(isDateRangeInvalid('2026-11-10', '2026-11-09')).toBe(true)
expect(isDateRangeInvalid('2026-12-01', '2026-11-30')).toBe(true)
})
it('同一天不算非法', () => {
expect(isDateRangeInvalid('2026-11-10', '2026-11-10')).toBe(false)
})
it('截止日期晚于起始日期时合法', () => {
expect(isDateRangeInvalid('2026-11-10', '2026-11-11')).toBe(false)
expect(isDateRangeInvalid('2026-11-10', '2027-01-01')).toBe(false)
})
it('任一端为空时视为未排期,不判为非法', () => {
expect(isDateRangeInvalid(null, '2026-11-09')).toBe(false)
expect(isDateRangeInvalid('2026-11-10', null)).toBe(false)
expect(isDateRangeInvalid(null, null)).toBe(false)
})
})
describe('formatDueDate', () => { describe('formatDueDate', () => {
const base = '2026-10-18' const base = '2026-10-18'
+11
View File
@@ -60,6 +60,17 @@ export function isDueToday(
return dueDate === today return dueDate === today
} }
/**
* 日期区间是否非法:截止日期早于起始日期。
*
* 两者都是 YYYY-MM-DD,直接比字符串即可。任一端为空视为「未排期」,
* 不构成区间,因此不算非法 —— 只填截止日期仍然是允许的用法。
*/
export function isDateRangeInvalid(startDate: string | null, dueDate: string | null): boolean {
if (!startDate || !dueDate) return false
return dueDate < startDate
}
/** 时间戳(ISO 8601)→ 本地日期字符串 YYYY-MM-DD;空值或非法值返回 null */ /** 时间戳(ISO 8601)→ 本地日期字符串 YYYY-MM-DD;空值或非法值返回 null */
export function localDateOf(iso: string | null): string | null { export function localDateOf(iso: string | null): string | null {
if (!iso) return null if (!iso) return null
+19
View File
@@ -852,6 +852,25 @@ describe('BoardView 批量编辑', () => {
expect(store.activeTasks[0]?.tags).toEqual(['已有', '前端']) expect(store.activeTasks[0]?.tags).toEqual(['已有', '前端'])
}) })
it('批量设置截止日期时跳过起始日期更晚的任务', async () => {
const { pinia, store } = setup()
store.createTasks([
{ title: '早期任务', startDate: '2026-10-01' },
{ title: '晚期任务', startDate: '2026-12-01' },
])
const wrapper = mountBoard(pinia)
await enterSelectionAndSelectAll(wrapper)
await wrapper.find('button[aria-label="设置截止日期"]').trigger('click')
await nextTick()
await wrapper.find('input[aria-label="批量设置截止日期"]').setValue('2026-10-20')
await nextTick()
expect(store.tasks.find((task) => task.title === '早期任务')?.dueDate).toBe('2026-10-20')
expect(store.tasks.find((task) => task.title === '晚期任务')?.dueDate).toBeNull()
expect(useToastStore().toasts.at(-1)?.message).toContain('已跳过')
})
it('批量归档后卡片消失,撤销可恢复', async () => { it('批量归档后卡片消失,撤销可恢复', async () => {
const { pinia, store } = setup() const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }]) store.createTasks([{ title: 'A' }, { title: 'B' }])
+31 -6
View File
@@ -14,6 +14,7 @@ import { useToastStore } from '@/stores/toast'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task'
import { findArchivableTasks } from '@/utils/archive' import { findArchivableTasks } from '@/utils/archive'
import { isDateRangeInvalid } from '@/utils/date'
import { filterTasks } from '@/utils/filter' import { filterTasks } from '@/utils/filter'
import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder' import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder'
@@ -286,14 +287,38 @@ function batchRemoveTag(tag: string): void {
toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` }) toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` })
} }
/**
* 批量设置截止日期。
*
* 多选的任务各有各的起始日期,一个截止日期不可能对所有任务都合法。
* 因此不做「整批拒绝」,只跳过那些起始日期晚于该日期的任务 ——
* 它们本来就不该被这样排期,其余任务照常生效,并把跳过数量说清楚。
*/
function batchDue(date: string | null): void { function batchDue(date: string | null): void {
const count = store.updateTasks(selectedTaskIds(), { dueDate: date }) const ids = selectedTaskIds()
if (count === 0) return if (ids.length === 0) return
toast.show({
message: date const eligible =
? `已将 ${count} 个任务的截止日期设为 ${date}` date === null
: `已清除 ${count} 个任务的截止日期`, ? ids
: ids.filter((id) => {
const task = store.tasks.find((item) => item.id === id)
return task !== undefined && !isDateRangeInvalid(task.startDate, date)
}) })
const skipped = ids.length - eligible.length
const count = store.updateTasks(eligible, { dueDate: date })
if (count === 0) {
if (skipped > 0) {
toast.show({ message: `${skipped} 个任务的起始日期晚于 ${date},未修改` })
}
return
}
const base = date
? `已将 ${count} 个任务的截止日期设为 ${date}`
: `已清除 ${count} 个任务的截止日期`
toast.show({ message: skipped > 0 ? `${base};${skipped} 个任务起始日期更晚,已跳过` : base })
} }
/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */ /** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */