feat: 添加起始日期功能并更新相关组件与测试用例
- 在任务创建和编辑界面中新增起始日期字段,默认值为当天。 - 更新任务卡片展示逻辑,仅在起始日期不是当天时显示相关信息。 - 修改相关测试用例以覆盖起始日期的功能,包括批量创建和编辑任务的场景。 - 数据库模式版本更新至4,以支持起始日期字段的存储与迁移。
This commit is contained in:
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { todayIso } from '@/utils/date'
|
||||
|
||||
import BatchAddDrawer from './BatchAddDrawer.vue'
|
||||
|
||||
@@ -68,13 +69,16 @@ describe('BatchAddDrawer 提交', () => {
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '第三条'])
|
||||
})
|
||||
|
||||
it('目标列、优先级、截止日期与标签整批共用', async () => {
|
||||
it('目标列、优先级、起始日期、截止日期与标签整批共用', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('第一条\n第二条')
|
||||
|
||||
await buttonByText(wrapper, '进行中')?.trigger('click')
|
||||
await buttonByText(wrapper, '高')?.trigger('click')
|
||||
await wrapper.find('input[type="date"]').setValue('2026-10-20')
|
||||
// 两个日期输入按出现顺序为:起始日期、截止日期;
|
||||
// 每次赋值后 DOM 会被重渲染重建,必须重新查询再操作下一个
|
||||
await wrapper.findAll('input[type="date"]')[0]?.setValue('2026-10-12')
|
||||
await wrapper.findAll('input[type="date"]')[1]?.setValue('2026-10-20')
|
||||
|
||||
const tagInput = wrapper.find('input[aria-label="添加标签"]')
|
||||
await tagInput.setValue('前端')
|
||||
@@ -85,11 +89,21 @@ describe('BatchAddDrawer 提交', () => {
|
||||
const created = store.byColumn.doing
|
||||
expect(created.map((task) => task.title)).toEqual(['第一条', '第二条'])
|
||||
expect(created[0]?.priority).toBe(3)
|
||||
expect(created[0]?.startDate).toBe('2026-10-12')
|
||||
expect(created[0]?.dueDate).toBe('2026-10-20')
|
||||
expect(created[0]?.tags).toEqual(['前端'])
|
||||
expect(created[1]?.tags).toEqual(['前端'])
|
||||
})
|
||||
|
||||
it('起始日期默认预填当天', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('A')
|
||||
|
||||
await buttonByText(wrapper, '添加 1 个任务')?.trigger('click')
|
||||
|
||||
expect(store.tasks[0]?.startDate).toBe(todayIso())
|
||||
})
|
||||
|
||||
it('提交后请求关闭并提示数量', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('A\nB')
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 一行一条,整段粘贴即可录入一批 —— 从笔记、Markdown 清单、聊天记录里
|
||||
* 直接复制过来就能用,行首的「-」「1.」这类列表符号会被自动去掉。
|
||||
*
|
||||
* 目标列、优先级、标签、截止日期是这一批**共用**的属性:批量录入时逐个
|
||||
* 目标列、优先级、标签、起始日期、截止日期是这一批**共用**的属性:批量录入时逐个
|
||||
* 任务调字段不现实,而「先统一建出来、再单独改个别任务」正是看板的用法。
|
||||
*/
|
||||
|
||||
@@ -15,6 +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 DatePicker from './DatePicker.vue'
|
||||
import SideDrawer from './SideDrawer.vue'
|
||||
@@ -33,6 +34,7 @@ const text = ref('')
|
||||
const status = ref<ColumnId>('todo')
|
||||
const priority = ref<Priority>(2)
|
||||
const dueDate = ref<string | null>(null)
|
||||
const startDate = ref<string | null>(todayIso())
|
||||
const tags = ref<string[]>([])
|
||||
|
||||
const titles = computed(() => parseTaskLines(text.value))
|
||||
@@ -47,6 +49,8 @@ watch(
|
||||
status.value = 'todo'
|
||||
priority.value = 2
|
||||
dueDate.value = null
|
||||
// 每批都从「今天开工」开始,与 store 的默认值保持一致
|
||||
startDate.value = todayIso()
|
||||
tags.value = []
|
||||
},
|
||||
)
|
||||
@@ -60,6 +64,7 @@ function submit(): void {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
dueDate: dueDate.value,
|
||||
startDate: startDate.value,
|
||||
tags: [...tags.value],
|
||||
})),
|
||||
)
|
||||
@@ -138,6 +143,11 @@ function submit(): void {
|
||||
</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" />
|
||||
</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" />
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
@@ -131,6 +132,24 @@ describe('截止日期', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('起始日期', () => {
|
||||
it('起始日期为当天时不显示「开始」', () => {
|
||||
const wrapper = mountCard({ startDate: dayjs().format('YYYY-MM-DD') })
|
||||
expect(wrapper.text()).not.toContain('开始')
|
||||
})
|
||||
|
||||
it('起始日期不是当天时显示「M月D日 开始」', () => {
|
||||
const startDate = dayjs().add(3, 'day').format('YYYY-MM-DD')
|
||||
const wrapper = mountCard({ startDate })
|
||||
expect(wrapper.text()).toContain('开始')
|
||||
expect(wrapper.text()).toContain(dayjs(startDate).format('M月D日'))
|
||||
})
|
||||
|
||||
it('未设置起始日期时不显示「开始」', () => {
|
||||
expect(mountCard({ startDate: null }).text()).not.toContain('开始')
|
||||
})
|
||||
})
|
||||
|
||||
describe('已完成样式', () => {
|
||||
it('标题加删除线,但不再整卡降透明度', () => {
|
||||
const wrapper = mountCard({ status: 'done' })
|
||||
|
||||
@@ -3,7 +3,13 @@ import { computed } from 'vue'
|
||||
|
||||
import { useTagColorStore } from '@/stores/tagColors'
|
||||
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
|
||||
import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date'
|
||||
import {
|
||||
formatCompactDate,
|
||||
formatDateTime,
|
||||
formatDueDateForTask,
|
||||
isOverdue,
|
||||
todayIso,
|
||||
} from '@/utils/date'
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3
|
||||
|
||||
@@ -73,6 +79,19 @@ const dueLabel = computed(() =>
|
||||
? formatDueDateForTask(props.task.dueDate, props.task.status, props.task.completedAt)
|
||||
: null,
|
||||
)
|
||||
/**
|
||||
* 起始日期:只在「不是今天」时显示。
|
||||
*
|
||||
* 新建任务的起始日期默认就是当天,若照常展示,几乎每张卡片都会挂一条
|
||||
* 相同的「M月D日 开始」,把标签和截止日期挤到第二行却提供不了新信息。
|
||||
* 保留下来的是真正有排期差异的那些:提前排的、拖到未来开工的。
|
||||
*/
|
||||
const startLabel = computed(() => {
|
||||
const startDate = props.task.startDate
|
||||
if (!startDate || startDate === todayIso()) return null
|
||||
const compact = formatCompactDate(startDate)
|
||||
return compact ? `${compact} 开始` : null
|
||||
})
|
||||
const visibleTags = computed(() =>
|
||||
props.task.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => ({
|
||||
name: tag,
|
||||
@@ -244,6 +263,14 @@ const priorityTextClass = computed(() => {
|
||||
{{ PRIORITY_LABELS[task.priority] }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="startLabel"
|
||||
class="text-slate-500 dark:text-zinc-400"
|
||||
:title="`开始于 ${props.task.startDate}`"
|
||||
>
|
||||
{{ startLabel }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="dueLabel"
|
||||
:class="
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { todayIso } from '@/utils/date'
|
||||
|
||||
import TaskDetailDrawer from './TaskDetailDrawer.vue'
|
||||
|
||||
@@ -50,9 +51,10 @@ describe('TaskDetailDrawer 回填', () => {
|
||||
'原描述',
|
||||
)
|
||||
expect(wrapper.text()).toContain('#设计')
|
||||
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
|
||||
'2026-12-01',
|
||||
)
|
||||
// 两个日期输入按出现顺序为:起始日期、截止日期;起始日期由 store 默认为当天
|
||||
const dateInputs = wrapper.findAll('input[type="date"]')
|
||||
expect((dateInputs[0]?.element as HTMLInputElement).value).toBe(todayIso())
|
||||
expect((dateInputs[1]?.element as HTMLInputElement).value).toBe('2026-12-01')
|
||||
})
|
||||
|
||||
it('回填当前优先级(低)', () => {
|
||||
@@ -81,7 +83,10 @@ describe('TaskDetailDrawer 保存', () => {
|
||||
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
|
||||
await wrapper.find('textarea[aria-label="描述"]').setValue('新描述')
|
||||
await buttonByText(wrapper, '高')?.trigger('click')
|
||||
await wrapper.find('input[type="date"]').setValue('2026-11-11')
|
||||
// 两个日期输入按出现顺序为:起始日期、截止日期;
|
||||
// 每次赋值后 DOM 会被重渲染重建,必须重新查询再操作下一个
|
||||
await wrapper.findAll('input[type="date"]')[0]?.setValue('2026-11-05')
|
||||
await wrapper.findAll('input[type="date"]')[1]?.setValue('2026-11-11')
|
||||
await wrapper.find('input[aria-label="添加标签"]').setValue('前端')
|
||||
await wrapper.find('input[aria-label="添加标签"]').trigger('keydown', { key: 'Enter' })
|
||||
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||
@@ -90,6 +95,7 @@ describe('TaskDetailDrawer 保存', () => {
|
||||
expect(task?.title).toBe('新标题')
|
||||
expect(task?.description).toBe('新描述')
|
||||
expect(task?.priority).toBe(3)
|
||||
expect(task?.startDate).toBe('2026-11-05')
|
||||
expect(task?.dueDate).toBe('2026-11-11')
|
||||
expect(task?.tags).toEqual(['设计', '前端'])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
@@ -118,7 +124,9 @@ describe('TaskDetailDrawer 保存', () => {
|
||||
|
||||
it('清空截止日期会写入 null', async () => {
|
||||
const { store, wrapper, taskId } = setup()
|
||||
await buttonByText(wrapper, '清除')?.trigger('click')
|
||||
// 起始日期与截止日期各有一个「清除」,按出现顺序取后者
|
||||
const clearButtons = wrapper.findAll('button').filter((node) => node.text() === '清除')
|
||||
await clearButtons[1]?.trigger('click')
|
||||
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||
expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBeNull()
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ 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)
|
||||
|
||||
@@ -45,6 +46,7 @@ watch(
|
||||
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
|
||||
@@ -92,6 +94,7 @@ const dirty = computed(() => {
|
||||
status.value !== task.status ||
|
||||
priority.value !== task.priority ||
|
||||
dueDate.value !== task.dueDate ||
|
||||
startDate.value !== task.startDate ||
|
||||
!sameTags(tags.value, task.tags)
|
||||
)
|
||||
})
|
||||
@@ -126,6 +129,7 @@ function save(): void {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
dueDate: dueDate.value,
|
||||
startDate: startDate.value,
|
||||
tags: [...tags.value],
|
||||
})
|
||||
emit('close')
|
||||
@@ -239,6 +243,11 @@ function remove(): void {
|
||||
</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" />
|
||||
</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" />
|
||||
|
||||
+38
-2
@@ -21,6 +21,7 @@ function validTask(overrides: Record<string, unknown> = {}): Record<string, unkn
|
||||
priority: 2,
|
||||
tags: ['设计'],
|
||||
dueDate: '2026-10-20',
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
@@ -97,6 +98,12 @@ describe('coerceTask', () => {
|
||||
expect(result?.sortOrder).toBe(0)
|
||||
})
|
||||
|
||||
it('合法 startDate 保留,非法 startDate 收敛为 null', () => {
|
||||
expect(coerceTask(validTask({ startDate: '2026-10-01' }))?.startDate).toBe('2026-10-01')
|
||||
expect(coerceTask(validTask({ startDate: '2026-02-31' }))?.startDate).toBeNull()
|
||||
expect(coerceTask(validTask({ startDate: undefined }))?.startDate).toBeNull()
|
||||
})
|
||||
|
||||
it('tags 过滤掉非字符串与空白项', () => {
|
||||
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
|
||||
expect(result?.tags).toEqual(['ok', 'ok2'])
|
||||
@@ -265,17 +272,46 @@ describe('版本迁移', () => {
|
||||
expect(state.tasks[0]?.archivedAt).toBe('2026-01-03T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('v0/v1/v2 都能一路迁移到 v3', () => {
|
||||
it('v3 数据迁移到 v4 时补上 startDate,其余字段不变', () => {
|
||||
const v3 = JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
tasks: [
|
||||
{
|
||||
id: 'legacy',
|
||||
title: '旧任务',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const state = parsePersistedState(v3)
|
||||
|
||||
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
expect(state.tasks[0]?.startDate).toBeNull()
|
||||
expect(state.tasks[0]?.deletedAt).toBeNull()
|
||||
expect(state.tasks[0]?.title).toBe('旧任务')
|
||||
})
|
||||
|
||||
it('v0/v1/v2/v3 都能一路迁移到当前版本', () => {
|
||||
const builds = [
|
||||
() => ({ tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
() => ({ schemaVersion: 1, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
() => ({ schemaVersion: 2, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
() => ({ schemaVersion: 3, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
]
|
||||
|
||||
for (const build of builds) {
|
||||
const state = parsePersistedState(JSON.stringify(build()))
|
||||
expect(state.schemaVersion).toBe(3)
|
||||
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
expect(state.tasks[0]?.deletedAt).toBeNull()
|
||||
expect(state.tasks[0]?.startDate).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+8
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
import { isColumnId, isPriority, type Task } from '@/types/task'
|
||||
|
||||
export const SCHEMA_VERSION = 3
|
||||
export const SCHEMA_VERSION = 4
|
||||
|
||||
/** 主数据在 localStorage 中的键名 */
|
||||
export const STORAGE_KEY = 'kanban.tasks'
|
||||
@@ -69,6 +69,7 @@ export function coerceTask(value: unknown): Task | null {
|
||||
const tags = rawTags.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
|
||||
|
||||
const dueDate = value.dueDate
|
||||
const startDate = value.startDate
|
||||
const sortOrder = value.sortOrder
|
||||
const createdAt = readString(value, 'createdAt') ?? new Date().toISOString()
|
||||
const updatedAt = readString(value, 'updatedAt') ?? createdAt
|
||||
@@ -85,6 +86,7 @@ export function coerceTask(value: unknown): Task | null {
|
||||
priority,
|
||||
tags,
|
||||
dueDate: isValidDateString(dueDate) ? dueDate : null,
|
||||
startDate: isValidDateString(startDate) ? startDate : null,
|
||||
sortOrder: typeof sortOrder === 'number' && Number.isFinite(sortOrder) ? sortOrder : 0,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@@ -117,6 +119,7 @@ type Migration = (state: PersistedState) => PersistedState
|
||||
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
|
||||
* - v1 -> v2:新增 archivedAt 字段(归档功能)
|
||||
* - v2 -> v3:新增 deletedAt 字段(回收站 / 软删除)
|
||||
* - v3 -> v4:新增 startDate 字段(起始日期)
|
||||
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
|
||||
*/
|
||||
const MIGRATIONS: Record<number, Migration> = {
|
||||
@@ -129,6 +132,10 @@ const MIGRATIONS: Record<number, Migration> = {
|
||||
schemaVersion: 3,
|
||||
tasks: state.tasks.map((task) => ({ ...task, deletedAt: null })),
|
||||
}),
|
||||
3: (state) => ({
|
||||
schemaVersion: 4,
|
||||
tasks: state.tasks.map((task) => ({ ...task, startDate: null })),
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeTask(id: string): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
|
||||
import type { Task } from '@/types/task'
|
||||
import { todayIso } from '@/utils/date'
|
||||
|
||||
import { useTagColorStore } from './tagColors'
|
||||
import { UNDO_LIMIT, useTaskStore } from './tasks'
|
||||
@@ -33,6 +34,7 @@ describe('初始化', () => {
|
||||
priority: 3,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
@@ -162,7 +164,14 @@ describe('批量创建', () => {
|
||||
it('每条任务可以带各自的列、优先级、标签与截止日期', () => {
|
||||
const store = freshStore()
|
||||
const created = store.createTasks([
|
||||
{ title: 'A', status: 'doing', priority: 3, tags: ['前端'], dueDate: '2026-10-20' },
|
||||
{
|
||||
title: 'A',
|
||||
status: 'doing',
|
||||
priority: 3,
|
||||
tags: ['前端'],
|
||||
dueDate: '2026-10-20',
|
||||
startDate: '2026-10-12',
|
||||
},
|
||||
{ title: 'B', status: 'done' },
|
||||
])
|
||||
|
||||
@@ -170,9 +179,21 @@ describe('批量创建', () => {
|
||||
expect(created[0]?.priority).toBe(3)
|
||||
expect(created[0]?.tags).toEqual(['前端'])
|
||||
expect(created[0]?.dueDate).toBe('2026-10-20')
|
||||
expect(created[0]?.startDate).toBe('2026-10-12')
|
||||
expect(created[1]?.completedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('起始日期默认为当天,显式传 null 时保持为空', () => {
|
||||
const store = freshStore()
|
||||
const created = store.createTasks([
|
||||
{ title: '未指定' },
|
||||
{ title: '显式清空', startDate: null },
|
||||
])
|
||||
|
||||
expect(created[0]?.startDate).toBe(todayIso())
|
||||
expect(created[1]?.startDate).toBeNull()
|
||||
})
|
||||
|
||||
it('跨列批量时各列内部都保持输入顺序', () => {
|
||||
const store = freshStore()
|
||||
store.createTasks([
|
||||
@@ -533,6 +554,7 @@ describe('导出与导入', () => {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
@@ -691,6 +713,7 @@ function makePersistedTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||
@@ -938,6 +961,19 @@ describe('批量编辑', () => {
|
||||
expect(store.activeTasks.every((task) => task.dueDate === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('批量设置与清除起始日期', () => {
|
||||
const store = freshStore()
|
||||
const a = store.createTask({ title: 'A' })
|
||||
const b = store.createTask({ title: 'B' })
|
||||
const ids = [a?.id ?? '', b?.id ?? '']
|
||||
|
||||
store.updateTasks(ids, { startDate: '2026-11-03' })
|
||||
expect(store.activeTasks.every((task) => task.startDate === '2026-11-03')).toBe(true)
|
||||
|
||||
store.updateTasks(ids, { startDate: null })
|
||||
expect(store.activeTasks.every((task) => task.startDate === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('批量归档只处理未删除任务', () => {
|
||||
const store = freshStore()
|
||||
const a = store.createTask({ title: 'A', status: 'done' })
|
||||
@@ -1279,6 +1315,7 @@ describe('撤销与重做', () => {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: longAgo,
|
||||
updatedAt: longAgo,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TaskRepository,
|
||||
} from '@/data/taskRepository'
|
||||
import { COLUMN_IDS, DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
|
||||
import { todayIso } from '@/utils/date'
|
||||
import { createId } from '@/utils/id'
|
||||
import { mergeTasks as mergeTaskLists } from '@/utils/merge'
|
||||
import {
|
||||
@@ -61,6 +62,8 @@ export interface CreateTaskInput {
|
||||
priority?: Priority
|
||||
tags?: string[]
|
||||
dueDate?: string | null
|
||||
/** 不传则默认当天;显式传 null 表示不填起始日期 */
|
||||
startDate?: string | null
|
||||
status?: ColumnId
|
||||
}
|
||||
|
||||
@@ -358,6 +361,8 @@ export const useTaskStore = defineStore('tasks', () => {
|
||||
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||
tags: normalizeTags(input.tags ?? []),
|
||||
dueDate: input.dueDate ?? null,
|
||||
// 起始日期默认当天:新建任务天然「今天开工」,用户显式清空时仍保持 null
|
||||
startDate: input.startDate === undefined ? todayIso() : input.startDate,
|
||||
sortOrder: sortOrderForPosition(working[status], 0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -431,6 +436,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
||||
if (patch.status !== undefined) taskPatch.status = patch.status
|
||||
if (patch.priority !== undefined) taskPatch.priority = patch.priority
|
||||
if (patch.dueDate !== undefined) taskPatch.dueDate = patch.dueDate
|
||||
if (patch.startDate !== undefined) taskPatch.startDate = patch.startDate
|
||||
|
||||
if (patch.addTags !== undefined || patch.removeTags !== undefined) {
|
||||
const tags = tagsAfterBatchPatch(task.tags, patch)
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Task {
|
||||
tags: string[]
|
||||
/** 仅日期,格式 YYYY-MM-DD;无截止日期时为 null */
|
||||
dueDate: string | null
|
||||
/** 仅日期,格式 YYYY-MM-DD;任务尚未排期时为 null */
|
||||
startDate: string | null
|
||||
/** 同列内排序用的浮点数,采用相邻中点插入 */
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Ta
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -27,6 +27,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: sequence * SORT_ORDER_STEP,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeTask(id: string, tags: string[]): Task {
|
||||
priority: 2,
|
||||
tags,
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: ['设计'],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface BatchPatch {
|
||||
priority?: Priority
|
||||
/** null 表示清空截止日期 */
|
||||
dueDate?: string | null
|
||||
/** null 表示清空起始日期 */
|
||||
startDate?: string | null
|
||||
addTags?: readonly string[]
|
||||
removeTags?: readonly string[]
|
||||
/** true 归档,false 取消归档 */
|
||||
@@ -78,6 +80,7 @@ export function isBatchPatchEmpty(patch: BatchPatch): boolean {
|
||||
patch.status === undefined &&
|
||||
patch.priority === undefined &&
|
||||
patch.dueDate === undefined &&
|
||||
patch.startDate === undefined &&
|
||||
!patch.addTags?.length &&
|
||||
!patch.removeTags?.length &&
|
||||
patch.archived === undefined
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Ta
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
startDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
|
||||
Reference in New Issue
Block a user