feat: 校验截止日期不能早于起始日期
- utils/date 新增纯函数 isDateRangeInvalid:任一端为空视为未排期,不判非法 - DatePicker 支持 min 属性,截止日期日历里选不到早于起始日期的日期 - 任务详情抽屉与批量添加面板实时提示,并在保存/提交时拦截 - 看板批量设置截止日期时逐条跳过起始日期更晚的任务,提示里说明跳过数量 测试:新增纯函数、抽屉、批量面板与看板批量四组用例,全量 957 用例通过。
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 取消', () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }])
|
||||||
|
|||||||
+32
-7
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */
|
/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */
|
||||||
|
|||||||
Reference in New Issue
Block a user