From 347ec6fe27a1be34f409bd234ccb8782f1e0bd9c Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Mon, 2 Nov 2026 16:24:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=A1=E9=AA=8C=E6=88=AA=E6=AD=A2?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E4=B8=8D=E8=83=BD=E6=97=A9=E4=BA=8E=E8=B5=B7?= =?UTF-8?q?=E5=A7=8B=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/date 新增纯函数 isDateRangeInvalid:任一端为空视为未排期,不判非法 - DatePicker 支持 min 属性,截止日期日历里选不到早于起始日期的日期 - 任务详情抽屉与批量添加面板实时提示,并在保存/提交时拦截 - 看板批量设置截止日期时逐条跳过起始日期更晚的任务,提示里说明跳过数量 测试:新增纯函数、抽屉、批量面板与看板批量四组用例,全量 957 用例通过。 --- src/components/BatchAddDrawer.spec.ts | 15 ++++++++++ src/components/BatchAddDrawer.vue | 15 +++++++--- src/components/DatePicker.vue | 3 +- src/components/TaskDetailDrawer.spec.ts | 22 ++++++++++++++ src/components/TaskDetailDrawer.vue | 22 ++++++++++++-- src/utils/date.spec.ts | 23 +++++++++++++++ src/utils/date.ts | 11 +++++++ src/views/BoardView.spec.ts | 19 ++++++++++++ src/views/BoardView.vue | 39 ++++++++++++++++++++----- 9 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/components/BatchAddDrawer.spec.ts b/src/components/BatchAddDrawer.spec.ts index 2862ce6..7ae6c6f 100644 --- a/src/components/BatchAddDrawer.spec.ts +++ b/src/components/BatchAddDrawer.spec.ts @@ -1,6 +1,7 @@ import { enableAutoUnmount, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { afterEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import { createMemoryTaskRepository } from '@/data/taskRepository' import { useTaskStore } from '@/stores/tasks' @@ -104,6 +105,20 @@ describe('BatchAddDrawer 提交', () => { 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 () => { const { wrapper } = setup() await wrapper.find('textarea').setValue('A\nB') diff --git a/src/components/BatchAddDrawer.vue b/src/components/BatchAddDrawer.vue index 3d40789..d5bf44b 100644 --- a/src/components/BatchAddDrawer.vue +++ b/src/components/BatchAddDrawer.vue @@ -15,7 +15,7 @@ import { useTaskStore } from '@/stores/tasks' import { useToastStore } from '@/stores/toast' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task' import { parseTaskLines } from '@/utils/batch' -import { todayIso } from '@/utils/date' +import { isDateRangeInvalid, todayIso } from '@/utils/date' import DatePicker from './DatePicker.vue' import SideDrawer from './SideDrawer.vue' @@ -39,6 +39,10 @@ const tags = ref([]) const titles = computed(() => parseTaskLines(text.value)) const canSubmit = computed(() => titles.value.length > 0) +/** 整批共用同一组日期,因此校验只需针对这两个值 */ +const dateError = computed(() => + isDateRangeInvalid(startDate.value, dueDate.value) ? '截止日期不能早于起始日期' : null, +) /** 每次打开都回到干净状态,避免上一次的残留被误提交 */ watch( @@ -56,7 +60,7 @@ watch( ) function submit(): void { - if (!canSubmit.value) return + if (!canSubmit.value || dateError.value) return const created = store.createTasks( titles.value.map((title) => ({ @@ -145,12 +149,15 @@ function submit(): void {
起始日期 - +
截止日期 - + + + {{ dateError }} +
diff --git a/src/components/DatePicker.vue b/src/components/DatePicker.vue index 55ead3c..9f7434a 100644 --- a/src/components/DatePicker.vue +++ b/src/components/DatePicker.vue @@ -27,7 +27,7 @@ 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] }>() @@ -65,6 +65,7 @@ function openPicker(): void { 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" :value="modelValue ?? ''" + :min="min ?? undefined" :aria-label="label ?? '截止日期'" @change="onChange" @keydown.enter.prevent="openPicker" diff --git a/src/components/TaskDetailDrawer.spec.ts b/src/components/TaskDetailDrawer.spec.ts index 75deeb1..6ec7a1c 100644 --- a/src/components/TaskDetailDrawer.spec.ts +++ b/src/components/TaskDetailDrawer.spec.ts @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import { createMemoryTaskRepository } from '@/data/taskRepository' import { useTaskStore } from '@/stores/tasks' @@ -130,6 +131,27 @@ describe('TaskDetailDrawer 保存', () => { await buttonByText(wrapper, '保存')?.trigger('click') 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 取消', () => { diff --git a/src/components/TaskDetailDrawer.vue b/src/components/TaskDetailDrawer.vue index d7cbaea..7e93372 100644 --- a/src/components/TaskDetailDrawer.vue +++ b/src/components/TaskDetailDrawer.vue @@ -4,7 +4,7 @@ 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 } from '@/utils/date' +import { formatDateTime, isDateRangeInvalid } from '@/utils/date' import DatePicker from './DatePicker.vue' import SideDrawer from './SideDrawer.vue' @@ -56,6 +56,16 @@ watch( 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 } + // 区间非法时不写入,错误提示已由 dateError 实时展示 + if (dateError.value) return + store.updateTask(task.id, { title: trimmed, description: description.value.trim(), @@ -245,12 +258,15 @@ function remove(): void {
起始日期 - +
截止日期 - + + + {{ dateError }} +
diff --git a/src/utils/date.spec.ts b/src/utils/date.spec.ts index fb30477..f77cc08 100644 --- a/src/utils/date.spec.ts +++ b/src/utils/date.spec.ts @@ -6,6 +6,7 @@ import { formatDateTime, formatDueDate, formatDueDateForTask, + isDateRangeInvalid, isDueToday, isOverdue, 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', () => { const base = '2026-10-18' diff --git a/src/utils/date.ts b/src/utils/date.ts index 27759b9..124c42c 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -60,6 +60,17 @@ export function isDueToday( 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 */ export function localDateOf(iso: string | null): string | null { if (!iso) return null diff --git a/src/views/BoardView.spec.ts b/src/views/BoardView.spec.ts index 1969755..72fdfa1 100644 --- a/src/views/BoardView.spec.ts +++ b/src/views/BoardView.spec.ts @@ -852,6 +852,25 @@ describe('BoardView 批量编辑', () => { 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 () => { const { pinia, store } = setup() store.createTasks([{ title: 'A' }, { title: 'B' }]) diff --git a/src/views/BoardView.vue b/src/views/BoardView.vue index c044c8d..02254d0 100644 --- a/src/views/BoardView.vue +++ b/src/views/BoardView.vue @@ -14,6 +14,7 @@ import { useToastStore } from '@/stores/toast' import { useUiStore } from '@/stores/ui' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task' import { findArchivableTasks } from '@/utils/archive' +import { isDateRangeInvalid } from '@/utils/date' import { filterTasks } from '@/utils/filter' import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder' @@ -286,14 +287,38 @@ function batchRemoveTag(tag: string): void { toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` }) } +/** + * 批量设置截止日期。 + * + * 多选的任务各有各的起始日期,一个截止日期不可能对所有任务都合法。 + * 因此不做「整批拒绝」,只跳过那些起始日期晚于该日期的任务 —— + * 它们本来就不该被这样排期,其余任务照常生效,并把跳过数量说清楚。 + */ function batchDue(date: string | null): void { - const count = store.updateTasks(selectedTaskIds(), { dueDate: date }) - if (count === 0) return - toast.show({ - message: date - ? `已将 ${count} 个任务的截止日期设为 ${date}` - : `已清除 ${count} 个任务的截止日期`, - }) + const ids = selectedTaskIds() + if (ids.length === 0) return + + const eligible = + date === null + ? 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 }) } /** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */