feat: 个人轻量看板工具 MVP

从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。

功能
- 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序
- 快速新增、详情编辑、删除撤销、已完成归档
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
- 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片
- 导出导入 JSON;启动时自动留快照,保留最近 3 份
- 键盘快捷键(N / 「/」/ ? / Esc)
- PWA:可安装、离线可用、更新提示

技术要点
- 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表
  显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt)
- 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽
- 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据
- 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据
- 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005)

验证
- 单元测试 437 个(33 个文件),行覆盖率 96.8%
- 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景
- npm run build 通过,产物可静态托管

注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
chenshoulin
2026-10-18 11:59:49 +08:00
commit 4e064e82ea
98 changed files with 20084 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { nextTick } from 'vue'
import { createMemoryHistory } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SCHEMA_VERSION } from '@/data/schema'
import App from './App.vue'
import { createAppRouter } from './router'
/** PWA 的虚拟模块在测试环境下无法解析,用桩替代 */
vi.mock('virtual:pwa-register/vue', async () => {
const { ref } = await import('vue')
return {
useRegisterSW: () => ({
needRefresh: ref(false),
offlineReady: ref(false),
updateServiceWorker: vi.fn(),
}),
}
})
/**
* 端到端冒烟:走真实的 localStorage 仓库(不注入内存仓库),
* 验证「界面操作 → 落盘 → 重新打开仍在」这条最关键的链路。
*
* 注意:store.init() 在 onMounted 中赋值,Vue 需要一次 tick 才会重渲染,
* 因此挂载后必须 await 一次,否则读到的是首帧空看板。
*/
async function mountApp(initialPath = '/') {
const pinia = createPinia()
const router = createAppRouter(createMemoryHistory())
await router.push(initialPath)
const wrapper = mount(App, {
global: { plugins: [pinia, router] },
attachTo: document.body,
})
await router.isReady()
await nextTick()
return { wrapper, router }
}
describe('App 端到端(真实 localStorage)', () => {
beforeEach(() => {
localStorage.clear()
})
it('界面新增的任务写入 localStorage,重新挂载后仍在', async () => {
const { wrapper: first } = await mountApp()
const input = first.findAll('section')[0]?.find('input')
await input?.setValue('持久化验证')
await first.findAll('form')[0]?.trigger('submit')
expect(localStorage.getItem('kanban.tasks')).toContain('持久化验证')
first.unmount()
const { wrapper: second } = await mountApp()
expect(second.text()).toContain('持久化验证')
expect(second.findAll('article')).toHaveLength(1)
second.unmount()
})
it('落盘内容带 schemaVersion', async () => {
const { wrapper } = await mountApp()
const input = wrapper.findAll('section')[0]?.find('input')
await input?.setValue('检查版本字段')
await wrapper.findAll('form')[0]?.trigger('submit')
const raw = localStorage.getItem('kanban.tasks') ?? '{}'
expect(JSON.parse(raw).schemaVersion).toBe(SCHEMA_VERSION)
wrapper.unmount()
})
it('点击完成按钮后状态写入 localStorage', async () => {
const { wrapper } = await mountApp()
const input = wrapper.findAll('section')[0]?.find('input')
await input?.setValue('待完成任务')
await wrapper.findAll('form')[0]?.trigger('submit')
await wrapper.find('article button').trigger('click')
const raw = localStorage.getItem('kanban.tasks') ?? '{}'
expect(JSON.parse(raw).tasks[0].status).toBe('done')
wrapper.unmount()
})
it('渲染导航、Toast 容器与 PWA 提示', async () => {
const { wrapper } = await mountApp()
expect(wrapper.findComponent({ name: 'AppNav' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ToastHost' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'PwaUpdatePrompt' }).exists()).toBe(true)
wrapper.unmount()
})
})
describe('App 路由', () => {
beforeEach(() => {
localStorage.clear()
})
it('根路径渲染看板', async () => {
const { wrapper } = await mountApp('/')
expect(wrapper.text()).toContain('共 0 个任务')
expect(wrapper.text()).not.toContain('今日待办')
wrapper.unmount()
})
it('/today 渲染今日视图', async () => {
const { wrapper } = await mountApp('/today')
expect(wrapper.text()).toContain('今日待办')
expect(wrapper.text()).not.toContain('共 0 个任务')
wrapper.unmount()
})
it('未知路径回落到看板', async () => {
const { wrapper, router } = await mountApp('/not-exist')
await nextTick()
expect(router.currentRoute.value.path).toBe('/')
wrapper.unmount()
})
it('两个视图共享同一份数据', async () => {
const { wrapper, router } = await mountApp('/')
const input = wrapper.findAll('section')[0]?.find('input')
await input?.setValue('跨视图任务')
await wrapper.findAll('form')[0]?.trigger('submit')
await nextTick()
// 在看板上标记完成(今日视图只统计,不显示已完成的卡片)
await wrapper.find('article button').trigger('click')
await nextTick()
await router.push('/today')
await nextTick()
const values = wrapper.findAll('dd').map((node) => node.text())
// [今日待办, 逾期, 今天到期, 本周完成]
expect(values[3]).toBe('1')
wrapper.unmount()
})
})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import AppNav from '@/components/AppNav.vue'
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
import ToastHost from '@/components/ToastHost.vue'
import { useTaskStore } from '@/stores/tasks'
const store = useTaskStore()
onMounted(() => {
store.init()
})
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-slate-100">
<AppNav />
<main class="flex min-h-0 flex-1 flex-col">
<RouterView />
</main>
</div>
<ToastHost />
<PwaUpdatePrompt />
</template>
+55
View File
@@ -0,0 +1,55 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { routes } from '@/router'
import AppNav from './AppNav.vue'
async function setup(initialPath = '/') {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push(initialPath)
await router.isReady()
const wrapper = mount(AppNav, { global: { plugins: [router] }, attachTo: document.body })
return { wrapper, router }
}
describe('AppNav', () => {
it('渲染看板与今日两个入口', () => {
return setup().then(({ wrapper }) => {
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
})
})
it('当前路由的入口标记 aria-current', async () => {
const { wrapper } = await setup('/')
const links = wrapper.findAll('a')
expect(links[0]?.attributes('aria-current')).toBe('page')
expect(links[1]?.attributes('aria-current')).toBeUndefined()
})
it('在今日页时高亮今日入口', async () => {
const { wrapper } = await setup('/today')
const links = wrapper.findAll('a')
expect(links[0]?.attributes('aria-current')).toBeUndefined()
expect(links[1]?.attributes('aria-current')).toBe('page')
})
it('点击可切换路由', async () => {
const { wrapper, router } = await setup('/')
await wrapper.findAll('a')[1]?.trigger('click')
// 路由跳转是异步的,必须等 Promise 落地
await flushPromises()
expect(router.currentRoute.value.path).toBe('/today')
})
it('导航带可访问性标签', async () => {
const { wrapper } = await setup()
expect(wrapper.find('nav').attributes('aria-label')).toBe('主导航')
})
})
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
const LINKS = [
{ to: '/', label: '看板' },
{ to: '/today', label: '今日' },
] as const
</script>
<template>
<header
class="flex items-center gap-4 border-b border-slate-200 bg-white px-4 py-2.5 md:px-6"
>
<span class="text-sm font-semibold tracking-wide text-slate-800">看板</span>
<nav class="flex items-center gap-1" aria-label="主导航">
<RouterLink
v-for="link in LINKS"
:key="link.to"
v-slot="{ href, navigate, isExactActive }"
:to="link.to"
custom
>
<a
:href="href"
class="rounded-md px-2.5 py-1 text-sm transition"
:class="
isExactActive
? 'bg-slate-800 font-medium text-white'
: 'text-slate-600 hover:bg-slate-100'
"
:aria-current="isExactActive ? 'page' : undefined"
@click="navigate"
>
{{ link.label }}
</a>
</RouterLink>
</nav>
</header>
</template>
+261
View File
@@ -0,0 +1,261 @@
import { flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import BackupPanel from './BackupPanel.vue'
vi.mock('@/utils/exportFile', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/exportFile')>()
return { ...actual, downloadText: vi.fn() }
})
const { downloadText } = await import('@/utils/exportFile')
function makePayload(titles: string[]): string {
return JSON.stringify({
schemaVersion: 1,
tasks: titles.map((title, index) => ({
id: `imported-${index}`,
title,
status: 'todo',
priority: 2,
sortOrder: (index + 1) * 1024,
})),
})
}
async function setup(open = true) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
const repository = createMemoryTaskRepository()
store.init({ repository, crossTab: false })
store.createTask({ title: '原有任务' })
const wrapper = mount(BackupPanel, {
props: { open },
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, wrapper }
}
async function chooseFile(wrapper: VueWrapper, content: string, fileName = 'backup.json') {
const input = wrapper.find('input[type="file"]')
const file = new File([content], fileName, { type: 'application/json' })
// jsdom 的 input.files 是只读的,需要直接定义
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
await flushPromises()
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BackupPanel 显示', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('open 为 false 时不渲染内容', () => {
const wrapper = mount(BackupPanel, {
props: { open: false },
global: { stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('导出为 JSON')
})
it('展示数据风险提示', async () => {
const { wrapper } = await setup()
expect(wrapper.text()).toContain('清理浏览器数据会导致任务丢失')
})
})
describe('BackupPanel 导出', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('点击导出会生成带日期的文件名并写入内容', async () => {
const { store, wrapper } = await setup()
await buttonByText(wrapper, '导出为 JSON')?.trigger('click')
expect(downloadText).toHaveBeenCalledTimes(1)
const [content, fileName] = vi.mocked(downloadText).mock.calls[0] ?? []
expect(fileName).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
expect(content).toContain('原有任务')
expect(wrapper.text()).toContain(`已导出 ${store.tasks.length} 个任务`)
})
})
describe('BackupPanel 导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('选择合法文件后先请求确认,不立即改动数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
expect(wrapper.text()).toContain('导入 2 个任务')
expect(wrapper.text()).toContain('覆盖当前全部 1 个任务')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('确认后覆盖导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
await buttonByText(wrapper, '确认覆盖导入')?.trigger('click')
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
})
it('取消确认后数据保持不变', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务']))
await buttonByText(wrapper, '取消')?.trigger('click')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('非法 JSON 给出明确错误且不破坏现有数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, '{broken json')
expect(wrapper.text()).toContain('不是合法的 JSON')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('结构不合法时给出错误且不进入确认流程', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ tasks: [{ id: 'broken' }] }))
expect(wrapper.text()).toContain('第 1 条任务格式不合法')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('版本过高时拒绝导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ schemaVersion: 99, tasks: [] }))
expect(wrapper.text()).toContain('高于当前支持')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
})
describe('BackupPanel 自动备份', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('启动时写入的快照会出现在列表中', async () => {
const repository = createMemoryTaskRepository()
// 第一次启动:看板为空,不产生快照
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '已有任务' })
// 第二次启动:此时已有数据,写入启动快照
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('空看板不写快照,显示暂无备份', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).toContain('暂无自动备份')
})
it('看板为空时仍会列出已有快照(数据丢失后要能恢复)', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
// 第二次启动:写入快照
setActivePinia(createPinia())
const second = useTaskStore()
second.init({ repository, crossTab: false })
expect(second.snapshots).toHaveLength(1)
// 清空数据后第三次启动:不能因为看板为空就丢掉快照列表
second.replaceAll([])
const pinia = createPinia()
setActivePinia(pinia)
const third = useTaskStore()
third.init({ repository, crossTab: false })
expect(third.tasks).toHaveLength(0)
expect(third.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('点击恢复会用快照内容覆盖当前数据', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
// 模拟数据丢失
store.replaceAll([])
expect(store.tasks).toHaveLength(0)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.tasks.map((task) => task.title)).toEqual(['快照里的任务'])
expect(wrapper.text()).toContain('已从备份恢复 1 个任务')
})
})
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { parseImportJson } from '@/data/schema'
import { useTaskStore } from '@/stores/tasks'
import { downloadText, exportFileName } from '@/utils/exportFile'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const { snapshots } = storeToRefs(store)
const message = ref<string | null>(null)
const error = ref<string | null>(null)
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
function exportNow(): void {
const fileName = exportFileName()
try {
downloadText(store.exportJson(), fileName)
message.value = `已导出 ${store.tasks.length} 个任务到 ${fileName}`
error.value = null
} catch (cause) {
// 浏览器可能因权限或存储限制拒绝下载,这里必须让用户看到失败原因
error.value = cause instanceof Error ? cause.message : '导出失败'
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 清空以便连续选择同一个文件时也能触发 change
input.value = ''
if (!file) return
message.value = null
error.value = null
try {
const text = await file.text()
// 先做一次纯校验,确认合法后才让用户确认覆盖
const tasks = parseImportJson(text)
pending.value = { text, fileName: file.name, count: tasks.length }
} catch (cause) {
pending.value = null
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
}
}
function confirmImport(): void {
const target = pending.value
if (!target) return
try {
const count = store.importJson(target.text)
message.value = `已从 ${target.fileName} 导入 ${count} 个任务`
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '导入失败'
} finally {
pending.value = null
}
}
function restore(key: string): void {
try {
const count = store.restoreSnapshot(key)
message.value = count > 0 ? `已从备份恢复 ${count} 个任务` : '该备份是空的,未做改动'
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '恢复失败'
}
}
function formatTime(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD HH:mm:ss')
}
</script>
<template>
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
<div class="flex flex-col gap-6">
<p class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800">
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
</p>
<p
v-if="message"
role="status"
class="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800"
>
{{ message }}
</p>
<p
v-if="error"
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700"
>
{{ error }}
</p>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">导出</h3>
<button
type="button"
class="self-start rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
@click="exportNow"
>
导出为 JSON
</button>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">导入</h3>
<input
type="file"
accept="application/json,.json"
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border file:border-slate-300 file:bg-white file:px-3 file:py-1.5 file:text-sm file:text-slate-600"
aria-label="选择导入文件"
@change="onFileChange"
/>
<div
v-if="pending"
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2"
>
<p class="text-sm text-amber-900">
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700"
@click="confirmImport"
>
确认覆盖导入
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white"
@click="pending = null"
>
取消
</button>
</div>
</div>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">自动备份</h3>
<p class="text-xs text-slate-500">每次打开看板时自动留一份,最多保留 3 份。</p>
<ul v-if="snapshots.length > 0" class="flex flex-col gap-2">
<li
v-for="item in snapshots"
:key="item.key"
class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2"
>
<span class="text-sm text-slate-600">{{ formatTime(item.savedAt) }}</span>
<button
type="button"
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50"
@click="restore(item.key)"
>
恢复
</button>
</li>
</ul>
<p v-else class="text-sm text-slate-400">暂无自动备份</p>
</section>
</div>
</SideDrawer>
</template>
+217
View File
@@ -0,0 +1,217 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useDraggable } from 'vue-draggable-plus'
import { describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import type { ColumnDef, Task } from '@/types/task'
import { resolveDrop } from '@/utils/drag'
import BoardColumn from './BoardColumn.vue'
/**
* 屏蔽真实的 Sortable:jsdom 没有布局,真实实例在测试中不起作用,
* 而且我们要断言的是「拖拽事件 → emit」这段接线,直接调用库回调更可靠。
*/
vi.mock('vue-draggable-plus', () => ({ useDraggable: vi.fn() }))
interface DragOptions {
onEnd?: (event: unknown) => void
onMove?: (event: unknown, originalEvent: unknown) => unknown
}
/** 取最近一次传给 useDraggable 的配置 */
function lastDragOptions(): DragOptions {
const calls = vi.mocked(useDraggable).mock.calls
return (calls[calls.length - 1]?.[1] ?? {}) as DragOptions
}
const TODO_COLUMN: ColumnDef = { id: 'todo', title: '待办' }
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
function setup(tasks: Task[] = [makeTask()], sortDisabled = false) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
const wrapper = mount(BoardColumn, {
props: { column: TODO_COLUMN, tasks: store.byColumn.todo, sortDisabled },
global: { plugins: [pinia] },
})
return { store, wrapper }
}
describe('BoardColumn 渲染', () => {
it('渲染列标题与计数', () => {
const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })])
expect(wrapper.find('h2').text()).toBe('待办')
expect(wrapper.find('header span').text()).toBe('2')
})
it('空列显示空状态', () => {
const { wrapper } = setup([])
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('暂无任务')
})
it('列内包含快速新增输入框', () => {
const { wrapper } = setup([])
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
})
})
describe('BoardColumn 拖拽契约', () => {
it('拖拽容器带 data-column-id', () => {
const { wrapper } = setup()
expect(wrapper.find('[data-column-id="todo"]').exists()).toBe(true)
})
it('卡片带 data-task-id', () => {
const { wrapper } = setup([makeTask({ id: 'task-xyz' })])
expect(wrapper.find('[data-task-id="task-xyz"]').exists()).toBe(true)
})
it('渲染出的真实 DOM 能满足 resolveDrop 的解析契约', () => {
const { wrapper } = setup([makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })])
const list = wrapper.find('[data-column-id="todo"]').element as HTMLElement
const item = wrapper.find('[data-task-id="task-2"]').element as HTMLElement
expect(resolveDrop({ item, to: list, newIndex: 0 })).toEqual({
taskId: 'task-2',
toColumn: 'todo',
toIndex: 0,
})
})
})
describe('BoardColumn 交互', () => {
it('点击完成按钮 emit toggle', async () => {
const { wrapper } = setup([makeTask({ id: 'task-1' })])
await wrapper.find('article button').trigger('click')
expect(wrapper.emitted('toggle')).toEqual([['task-1']])
})
it('点击卡片 emit open', async () => {
const { wrapper } = setup([makeTask({ id: 'task-1' })])
await wrapper.find('article').trigger('click')
expect(wrapper.emitted('open')).toEqual([['task-1']])
})
it('通过界面新增任务会写入 store', async () => {
const { store, wrapper } = setup([])
await wrapper.find('input[aria-label="添加任务"]').setValue('新任务')
await wrapper.find('form').trigger('submit')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['新任务'])
})
})
describe('BoardColumn 长列表', () => {
function makeMany(count: number): Task[] {
return Array.from({ length: count }, (_, index) =>
makeTask({ id: `task-${index}`, title: `任务 ${index}`, sortOrder: (index + 1) * 1024 }),
)
}
it('单列 200 张卡片全部渲染且计数正确', () => {
const { wrapper } = setup(makeMany(200))
expect(wrapper.findAll('article')).toHaveLength(200)
expect(wrapper.find('header span').text()).toBe('200')
})
it('200 张卡片仍保持 sortOrder 升序渲染', () => {
const { wrapper } = setup(makeMany(200))
const titles = wrapper.findAll('article h3').map((node) => node.text())
expect(titles[0]).toBe('任务 0')
expect(titles[199]).toBe('任务 199')
})
it('200 张卡片时仍能通过界面新增任务', async () => {
const { store, wrapper } = setup(makeMany(200))
await wrapper.find('input[aria-label="添加任务"]').setValue('插到最前')
await wrapper.find('form').trigger('submit')
expect(store.byColumn.todo).toHaveLength(201)
expect(store.byColumn.todo[0]?.title).toBe('插到最前')
})
})
describe('BoardColumn 拖拽事件接线', () => {
it('onEnd 解析落点并 emit move', () => {
const { wrapper } = setup([makeTask({ id: 'task-1' })])
const item = wrapper.find('[data-task-id="task-1"]').element
const to = wrapper.find('[data-column-id="todo"]').element
lastDragOptions().onEnd?.({ item, to, newIndex: 0 })
expect(wrapper.emitted('move')).toEqual([[{ id: 'task-1', toColumn: 'todo', toIndex: 0 }]])
})
it('onEnd 缺少任务信息时不 emit', () => {
const { wrapper } = setup()
const to = wrapper.find('[data-column-id="todo"]').element
lastDragOptions().onEnd?.({ item: document.createElement('article'), to, newIndex: 0 })
expect(wrapper.emitted('move')).toBeUndefined()
})
it('onEnd 下标缺失时不 emit', () => {
const { wrapper } = setup([makeTask({ id: 'task-1' })])
const item = wrapper.find('[data-task-id="task-1"]').element
const to = wrapper.find('[data-column-id="todo"]').element
lastDragOptions().onEnd?.({ item, to })
expect(wrapper.emitted('move')).toBeUndefined()
})
it('列内为全量渲染时 onMove 一律允许', () => {
const { wrapper } = setup([makeTask()], false)
const same = wrapper.find('[data-column-id="todo"]').element
const result = lastDragOptions().onMove?.({ from: same, to: same }, new Event('move'))
expect(result).toBe(true)
})
it('sortDisabled 时 onMove 禁止列内排序但允许跨列', () => {
const { wrapper } = setup([makeTask()], true)
const same = wrapper.find('[data-column-id="todo"]').element
const other = document.createElement('div')
const onMove = lastDragOptions().onMove
expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false)
expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(true)
})
it('传给 useDraggable 的分组与动画配置正确', () => {
setup()
const calls = vi.mocked(useDraggable).mock.calls
const options = calls[calls.length - 1]?.[1] as Record<string, unknown>
expect(options.group).toBe('kanban-board')
expect(options.filter).toBe('button')
})
})
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useDraggable } from 'vue-draggable-plus'
import type { ColumnDef, ColumnId, Task } from '@/types/task'
import { resolveDrop, shouldAllowMove } from '@/utils/drag'
import QuickAddTask from './QuickAddTask.vue'
import TaskCard from './TaskCard.vue'
const props = defineProps<{ column: ColumnDef; tasks: Task[]; sortDisabled?: boolean }>()
const emit = defineEmits<{
toggle: [id: string]
open: [id: string]
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
}>()
const listRef = ref<HTMLElement | null>(null)
const quickAddRef = ref<InstanceType<typeof QuickAddTask> | null>(null)
/** 供快捷键(N)调用,聚焦到该列的快速新增输入框 */
function focusQuickAdd(): void {
quickAddRef.value?.focusInput()
}
defineExpose({ focusQuickAdd })
/**
* 不传 list:库不会接管数据,也不会安装它自己的 DOM 修正逻辑,
* 我们只消费事件并把结果交给 store,保持「store 是唯一数据源」。
*/
useDraggable(listRef, {
group: 'kanban-board',
animation: 150,
ghostClass: 'opacity-40',
// 在完成勾选按钮上按下时不启动拖拽
filter: 'button',
onMove: (event) => shouldAllowMove(event.from, event.to, props.sortDisabled === true),
onEnd: (event) => {
const drop = resolveDrop(event)
if (!drop) return
emit('move', { id: drop.taskId, toColumn: drop.toColumn, toIndex: drop.toIndex })
},
})
</script>
<template>
<section class="flex min-h-0 flex-col rounded-lg bg-slate-200/60">
<header class="flex items-center justify-between px-3 py-2.5">
<h2 class="text-sm font-medium text-slate-600">{{ column.title }}</h2>
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-slate-500">
{{ tasks.length }}
</span>
</header>
<QuickAddTask ref="quickAddRef" :status="column.id" />
<div
ref="listRef"
:data-column-id="column.id"
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2"
>
<TaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="emit('toggle', $event)"
@open="emit('open', $event)"
/>
<p v-if="tasks.length === 0" class="px-2 py-6 text-center text-sm text-slate-400">
暂无任务
</p>
</div>
</section>
</template>
+204
View File
@@ -0,0 +1,204 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import dayjs from 'dayjs'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import BoardToolbar from './BoardToolbar.vue'
function setup() {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const filters = useFilterStore()
const wrapper = mount(BoardToolbar, {
global: { plugins: [pinia] },
attachTo: document.body,
})
return { store, filters, wrapper }
}
const today = dayjs().format('YYYY-MM-DD')
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BoardToolbar 统计', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('显示任务总数', async () => {
const { store, wrapper } = setup()
store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('共 2 个任务')
})
it('统计今日到期与逾期数量', async () => {
const { store, wrapper } = setup()
store.createTask({ title: '今天到期', dueDate: today })
store.createTask({ title: '逾期一', dueDate: yesterday })
store.createTask({ title: '逾期二', dueDate: yesterday })
await wrapper.vm.$nextTick()
expect(buttonByText(wrapper, '今日到期')?.text()).toContain('今日到期 1')
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 2')
})
it('已完成任务不计入到期统计', async () => {
const { store, wrapper } = setup()
store.createTask({ title: '已完成', dueDate: yesterday, status: 'done' })
await wrapper.vm.$nextTick()
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 0')
})
})
describe('BoardToolbar 筛选交互', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('点击「逾期」切换截止日期筛选', async () => {
const { filters, wrapper } = setup()
await buttonByText(wrapper, '逾期')?.trigger('click')
expect(filters.due).toBe('overdue')
await buttonByText(wrapper, '逾期')?.trigger('click')
expect(filters.due).toBeNull()
})
it('点击「今日到期」切换截止日期筛选', async () => {
const { filters, wrapper } = setup()
await buttonByText(wrapper, '今日到期')?.trigger('click')
expect(filters.due).toBe('today')
})
it('优先级按钮切换筛选条件', async () => {
const { filters, wrapper } = setup()
await buttonByText(wrapper, '高')?.trigger('click')
expect(filters.priorities).toEqual([3])
await buttonByText(wrapper, '高')?.trigger('click')
expect(filters.priorities).toEqual([])
})
it('标签候选来自现有任务,点击可筛选', async () => {
const { store, filters, wrapper } = setup()
store.createTask({ title: 'A', tags: ['设计'] })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('#设计')
await buttonByText(wrapper, '#设计')?.trigger('click')
expect(filters.tags).toEqual(['设计'])
})
it('搜索框写入关键词', async () => {
const { filters, wrapper } = setup()
await wrapper.find('input[aria-label="搜索任务"]').setValue('方案')
expect(filters.keyword).toBe('方案')
})
it('无筛选时不显示「清空筛选」', () => {
const { wrapper } = setup()
expect(buttonByText(wrapper, '清空筛选')).toBeUndefined()
})
it('有筛选时显示「清空筛选」并可用', async () => {
const { filters, wrapper } = setup()
filters.togglePriority(3)
await wrapper.vm.$nextTick()
await buttonByText(wrapper, '清空筛选')?.trigger('click')
expect(filters.active).toBe(false)
})
})
describe('BoardToolbar 面板入口', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('点击「备份」发出 open-backup', async () => {
const { wrapper } = setup()
await buttonByText(wrapper, '备份')?.trigger('click')
expect(wrapper.emitted('open-backup')).toHaveLength(1)
})
it('点击 ? 发出 open-help', async () => {
const { wrapper } = setup()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
expect(wrapper.emitted('open-help')).toHaveLength(1)
})
})
describe('BoardToolbar 焦点', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('focusSearch 聚焦搜索框', async () => {
const { wrapper } = setup()
const search = wrapper.find('input[aria-label="搜索任务"]')
;(wrapper.vm as unknown as { focusSearch: () => void }).focusSearch()
expect(document.activeElement).toBe(search.element)
})
})
describe('BoardToolbar 归档', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
async function makeArchivable(store: ReturnType<typeof useTaskStore>) {
const task = store.createTask({ title: '很久前完成', status: 'done' })
store.updateTask(task?.id ?? '', {
completedAt: dayjs().subtract(30, 'day').toISOString(),
})
return task
}
it('没有可归档任务时不显示归档按钮', () => {
const { wrapper } = setup()
expect(buttonByText(wrapper, '个已完成')).toBeUndefined()
})
it('刚完成的任务不进入可归档范围', async () => {
const { store, wrapper } = setup()
store.createTask({ title: '刚完成', status: 'done' })
await wrapper.vm.$nextTick()
expect(buttonByText(wrapper, '个已完成')).toBeUndefined()
})
it('有可归档任务时显示数量并 emit archive', async () => {
const { store, wrapper } = setup()
await makeArchivable(store)
await wrapper.vm.$nextTick()
const button = buttonByText(wrapper, '个已完成')
expect(button?.text()).toContain('归档 1 个已完成')
await button?.trigger('click')
expect(wrapper.emitted('archive')).toHaveLength(1)
})
it('「显示归档」切换视图开关', async () => {
const { filters, wrapper } = setup()
await buttonByText(wrapper, '显示归档')?.trigger('click')
expect(filters.includeArchived).toBe(true)
await buttonByText(wrapper, '显示归档')?.trigger('click')
expect(filters.includeArchived).toBe(false)
})
})
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { PRIORITY_LABELS, type Priority } from '@/types/task'
import { isDueToday, isOverdue } from '@/utils/date'
import { findArchivableTasks } from '@/utils/archive'
const emit = defineEmits<{ 'open-backup': []; 'open-help': []; archive: [] }>()
const store = useTaskStore()
const filters = useFilterStore()
const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(filters)
const searchRef = ref<HTMLInputElement | null>(null)
/** 由高到低排列,与看板中「高优先级优先处理」的心智一致 */
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
const dueTodayCount = computed(
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
)
const overdueCount = computed(
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
)
/** 从现有任务里收集标签,作为筛选候选项 */
const allTags = computed(() => {
const collected = new Set<string>()
for (const task of store.tasks) {
for (const tag of task.tags) collected.add(tag)
}
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
})
/** 当前可归档(已完成且超过保留天数)的任务数量 */
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
function focusSearch(): void {
searchRef.value?.focus()
}
defineExpose({ focusSearch })
</script>
<template>
<header class="flex flex-col gap-3">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="text-sm text-slate-500">共 {{ store.tasks.length }} 个任务</span>
<button
type="button"
class="rounded px-1.5 py-0.5 text-sm transition"
:class="
due === 'today' ? 'bg-amber-100 text-amber-800' : 'text-slate-500 hover:bg-slate-200'
"
:aria-pressed="due === 'today'"
@click="filters.toggleDue('today')"
>
今日到期 {{ dueTodayCount }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-sm transition"
:class="
due === 'overdue'
? 'bg-rose-100 text-rose-700'
: overdueCount > 0
? 'text-rose-600 hover:bg-slate-200'
: 'text-slate-500 hover:bg-slate-200'
"
:aria-pressed="due === 'overdue'"
@click="filters.toggleDue('overdue')"
>
逾期 {{ overdueCount }}
</button>
<div class="ml-auto flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-300 px-2 py-0.5 text-sm text-slate-500 transition hover:bg-white"
aria-label="快捷键帮助"
@click="emit('open-help')"
>
?
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-2.5 py-1 text-sm text-slate-600 transition hover:bg-white"
@click="emit('open-backup')"
>
备份
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
ref="searchRef"
v-model="keyword"
type="search"
class="w-full max-w-xs rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 focus:border-sky-400"
placeholder="搜索标题或描述(/ 聚焦)"
aria-label="搜索任务"
/>
<div class="flex items-center gap-1">
<button
v-for="value in PRIORITY_ORDER"
:key="value"
type="button"
class="rounded-md border px-2 py-1 text-xs transition"
:class="
priorities.includes(value)
? 'border-sky-400 bg-sky-50 text-sky-700'
: 'border-slate-300 text-slate-600 hover:bg-white'
"
:aria-pressed="priorities.includes(value)"
@click="filters.togglePriority(value)"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
<button
v-for="tag in allTags"
:key="tag"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition"
:class="
tags.includes(tag)
? 'border-sky-400 bg-sky-50 text-sky-700'
: 'border-slate-300 text-slate-600 hover:bg-white'
"
:aria-pressed="tags.includes(tag)"
@click="filters.toggleTag(tag)"
>
#{{ tag }}
</button>
<button
v-if="active"
type="button"
class="rounded-md px-2 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-700 hover:underline"
@click="filters.clear()"
>
清空筛选
</button>
<div class="ml-auto flex items-center gap-2">
<button
v-if="archivableCount > 0"
type="button"
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-white"
@click="emit('archive')"
>
归档 {{ archivableCount }} 个已完成
</button>
<button
type="button"
class="rounded-md border px-2 py-1 text-xs transition"
:class="
includeArchived
? 'border-sky-400 bg-sky-50 text-sky-700'
: 'border-slate-300 text-slate-600 hover:bg-white'
"
:aria-pressed="includeArchived"
@click="filters.toggleArchived()"
>
显示归档
</button>
</div>
</div>
</header>
</template>
+60
View File
@@ -0,0 +1,60 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DatePicker from './DatePicker.vue'
function mountPicker(modelValue: string | null, label?: string) {
return mount(DatePicker, { props: label === undefined ? { modelValue } : { modelValue, label } })
}
describe('DatePicker', () => {
it('使用原生 date 输入,保证输出为 YYYY-MM-DD', () => {
expect(mountPicker(null).find('input').attributes('type')).toBe('date')
})
it('渲染已有值', () => {
expect((mountPicker('2026-10-20').find('input').element as HTMLInputElement).value).toBe(
'2026-10-20',
)
})
it('无值时输入框为空且不显示清除按钮', () => {
const wrapper = mountPicker(null)
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button').exists()).toBe(false)
})
it('有值时显示清除按钮', () => {
expect(mountPicker('2026-10-20').find('button').exists()).toBe(true)
})
it('选择日期后 emit 字符串', async () => {
const wrapper = mountPicker(null)
const input = wrapper.find('input')
// 注意:VTU 的 setValue 会同时触发 input 与 change,无需再手动 trigger('change')
await input.setValue('2026-10-20')
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-10-20']])
})
it('清空输入时 emit null', async () => {
const wrapper = mountPicker('2026-10-20')
const input = wrapper.find('input')
await input.setValue('')
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
it('点击清除按钮 emit null', async () => {
const wrapper = mountPicker('2026-10-20')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
it('默认带「截止日期」可访问性标签,可覆盖', () => {
expect(mountPicker(null).find('input').attributes('aria-label')).toBe('截止日期')
expect(mountPicker(null, '开始日期').find('input').attributes('aria-label')).toBe('开始日期')
})
})
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* 日期选择器(T09)
*
* 直接使用浏览器原生 `<input type="date">`:
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
* - 自带可访问性、键盘操作与本地化日历,无需自己实现
*/
defineProps<{ modelValue: string | null; label?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
function onChange(event: Event): void {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? null : value)
}
</script>
<template>
<div class="flex items-center gap-2">
<input
type="date"
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
:value="modelValue ?? ''"
:aria-label="label ?? '截止日期'"
@change="onChange"
/>
<button
v-if="modelValue"
type="button"
class="shrink-0 rounded px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-100"
@click="emit('update:modelValue', null)"
>
清除
</button>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
import { mount } from '@vue/test-utils'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PwaUpdatePrompt from './PwaUpdatePrompt.vue'
/** 用 hoisted 持有引用,让测试可以驱动 needRefresh / offlineReady */
const stub = vi.hoisted(() => ({
needRefresh: null as unknown as Ref<boolean>,
offlineReady: null as unknown as Ref<boolean>,
updateServiceWorker: null as unknown as ReturnType<typeof vi.fn>,
}))
vi.mock('virtual:pwa-register/vue', async () => {
const { ref } = await import('vue')
stub.needRefresh = ref(false)
stub.offlineReady = ref(false)
stub.updateServiceWorker = vi.fn()
return { useRegisterSW: () => stub }
})
function mountPrompt() {
return mount(PwaUpdatePrompt, { attachTo: document.body })
}
describe('PwaUpdatePrompt', () => {
beforeEach(() => {
stub.needRefresh.value = false
stub.offlineReady.value = false
stub.updateServiceWorker.mockClear()
})
it('无状态时不渲染', () => {
const wrapper = mountPrompt()
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
it('有新版本时提示并可立即更新', async () => {
stub.needRefresh.value = true
const wrapper = mountPrompt()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('有新版本可用')
const updateButton = wrapper.findAll('button').find((node) => node.text() === '立即更新')
await updateButton?.trigger('click')
// 传 true 表示更新后立即刷新页面
expect(stub.updateServiceWorker).toHaveBeenCalledWith(true)
})
it('已可离线使用时提示,但不显示更新按钮', async () => {
stub.offlineReady.value = true
const wrapper = mountPrompt()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('已缓存,可离线使用')
expect(wrapper.findAll('button').map((node) => node.text())).not.toContain('立即更新')
})
it('点击关闭后提示消失', async () => {
stub.needRefresh.value = true
const wrapper = mountPrompt()
await wrapper.vm.$nextTick()
await wrapper.find('button[aria-label="关闭提示"]').trigger('click')
await wrapper.vm.$nextTick()
expect(stub.needRefresh.value).toBe(false)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
it('带 role=status 便于读屏播报', async () => {
stub.offlineReady.value = true
const wrapper = mountPrompt()
await wrapper.vm.$nextTick()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* PWA 更新提示
*
* registerType 为 prompt,因此新版本不会自动生效 ——
* 由用户决定何时刷新,避免打断正在进行的编辑。
*/
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { computed } from 'vue'
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW()
const visible = computed(() => needRefresh.value || offlineReady.value)
function dismiss(): void {
needRefresh.value = false
offlineReady.value = false
}
function reload(): void {
void updateServiceWorker(true)
}
</script>
<template>
<div v-if="visible" class="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div
role="status"
class="flex items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
>
<span>{{ needRefresh ? '有新版本可用' : '已缓存,可离线使用' }}</span>
<button
v-if="needRefresh"
type="button"
class="shrink-0 rounded px-2 py-0.5 font-medium text-sky-300 transition hover:bg-white/10"
@click="reload"
>
立即更新
</button>
<button
type="button"
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
aria-label="关闭提示"
@click="dismiss"
>
✕
</button>
</div>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import type { ColumnId } from '@/types/task'
import QuickAddTask from './QuickAddTask.vue'
function setup(status: ColumnId = 'todo') {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(QuickAddTask, {
props: { status },
global: { plugins: [pinia] },
attachTo: document.body,
})
return { store, wrapper }
}
describe('QuickAddTask', () => {
it('提交后创建任务到指定列并清空输入', async () => {
const { store, wrapper } = setup('doing')
const input = wrapper.find('input')
await input.setValue('新任务')
await wrapper.find('form').trigger('submit')
expect(store.tasks.map((task) => task.title)).toEqual(['新任务'])
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['新任务'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('提交后保持聚焦,便于连续录入', async () => {
const { wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('第一条')
await wrapper.find('form').trigger('submit')
expect(document.activeElement).toBe(input.element)
})
it('连续录入两条,后者位于列首', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('第一条')
await wrapper.find('form').trigger('submit')
await input.setValue('第二条')
await wrapper.find('form').trigger('submit')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第二条', '第一条'])
})
it('空白输入不创建任务', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue(' ')
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
})
it('输入法组合期间回车不提交(中文选词不误触发)', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.trigger('compositionstart')
await input.setValue('zhongwen')
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
await input.trigger('compositionend')
await input.setValue('中文')
await wrapper.find('form').trigger('submit')
expect(store.tasks.map((task) => task.title)).toEqual(['中文'])
})
it('标题首尾空白被去除后入库', async () => {
const { store, wrapper } = setup()
await wrapper.find('input').setValue(' 写方案 ')
await wrapper.find('form').trigger('submit')
expect(store.tasks[0]?.title).toBe('写方案')
})
it('输入框带可访问性标签', () => {
const { wrapper } = setup()
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
})
})
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import type { ColumnId } from '@/types/task'
const props = defineProps<{ status: ColumnId }>()
const store = useTaskStore()
const title = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
/**
* 中文输入法保护:使用拼音输入时,回车用于「选词」而非「提交」。
* 若不屏蔽,选词的回车会误触发提交,导致录入半截标题。
*/
const composing = ref(false)
function submit(): void {
const created = store.createTask({ title: title.value, status: props.status })
if (!created) return
title.value = ''
inputRef.value?.focus()
}
/** 供快捷键(N)调用 */
function focusInput(): void {
inputRef.value?.focus()
}
defineExpose({ focusInput })
</script>
<template>
<form class="px-2 pb-2" @submit.prevent="composing ? undefined : submit()">
<input
ref="inputRef"
v-model="title"
type="text"
class="w-full rounded-md border border-transparent bg-white/60 px-2.5 py-1.5 text-sm text-slate-700 transition outline-none placeholder:text-slate-400 focus:border-sky-400 focus:bg-white"
placeholder="+ 添加任务,回车确认"
aria-label="添加任务"
@compositionstart="composing = true"
@compositionend="composing = false"
/>
</form>
</template>
+131
View File
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it } from 'vitest'
import SideDrawer from './SideDrawer.vue'
const mounted: Array<{ unmount: () => void }> = []
function mountDrawer(props: { open: boolean; title?: string } = { open: true }) {
const wrapper = mount(SideDrawer, {
props: { title: '任务详情', ...props },
slots: { default: '<button type="button">内部按钮</button>' },
global: { stubs: { teleport: true } },
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
/** watcher 内部还会再等一次 nextTick,这里统一多刷一轮 */
async function flush(): Promise<void> {
await nextTick()
await nextTick()
}
function pressEscape(): void {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
}
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount()
document.body.innerHTML = ''
})
describe('SideDrawer 渲染', () => {
it('open 为 false 时不渲染面板', () => {
const wrapper = mountDrawer({ open: false })
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
it('open 为 true 时渲染标题与插槽内容', () => {
const wrapper = mountDrawer({ open: true })
expect(wrapper.find('[role="dialog"]').exists()).toBe(true)
expect(wrapper.text()).toContain('任务详情')
expect(wrapper.text()).toContain('内部按钮')
})
it('面板带 aria-modal 属性', () => {
const wrapper = mountDrawer({ open: true })
expect(wrapper.find('[role="dialog"]').attributes('aria-modal')).toBe('true')
})
it('渲染 footer 插槽', () => {
const wrapper = mount(SideDrawer, {
props: { open: true },
slots: { footer: '<span>底部操作</span>' },
global: { stubs: { teleport: true } },
})
expect(wrapper.text()).toContain('底部操作')
wrapper.unmount()
})
})
describe('SideDrawer 关闭方式', () => {
it('点击关闭按钮 emit close', async () => {
const wrapper = mountDrawer({ open: true })
await wrapper.find('button[aria-label="关闭"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('点击遮罩 emit close', async () => {
const wrapper = mountDrawer({ open: true })
await wrapper.find('.absolute.inset-0').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('按 ESC emit close', async () => {
const wrapper = mountDrawer({ open: true })
pressEscape()
await nextTick()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('关闭后不再响应 ESC', async () => {
const wrapper = mountDrawer({ open: true })
await wrapper.setProps({ open: false })
pressEscape()
await nextTick()
expect(wrapper.emitted('close')).toBeUndefined()
})
})
describe('SideDrawer 焦点管理', () => {
it('打开后焦点移入面板内的第一个可聚焦元素', async () => {
mountDrawer({ open: true })
await flush()
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
})
it('关闭后焦点回到打开前的元素', async () => {
const trigger = document.createElement('button')
trigger.textContent = '触发按钮'
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mountDrawer({ open: false })
await wrapper.setProps({ open: true })
await flush()
expect(document.activeElement).not.toBe(trigger)
await wrapper.setProps({ open: false })
expect(document.activeElement).toBe(trigger)
})
it('Tab 焦点陷阱:在最后一个元素上按 Tab 回到第一个', async () => {
const wrapper = mountDrawer({ open: true })
await flush()
const buttons = wrapper.findAll('button')
const first = buttons[0]
const last = buttons[buttons.length - 1]
expect(buttons.length).toBeGreaterThan(1)
;(last?.element as HTMLElement).focus()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }))
await nextTick()
expect(document.activeElement).toBe(first?.element)
})
})
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{ open: boolean; title?: string }>()
const emit = defineEmits<{ close: [] }>()
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
function focusableElements(): HTMLElement[] {
const root = panelRef.value
if (!root) return []
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}
/** ESC 关闭 + Tab 焦点陷阱 */
function onDocumentKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
if (event.key !== 'Tab') return
const items = focusableElements()
if (items.length === 0) {
event.preventDefault()
panelRef.value?.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement as HTMLElement | null
const inside = active !== null && items.includes(active)
if (event.shiftKey) {
if (!inside || active === first) {
event.preventDefault()
last?.focus()
}
} else if (!inside || active === last) {
event.preventDefault()
first?.focus()
}
}
function stopListening(): void {
document.removeEventListener('keydown', onDocumentKeydown, true)
}
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
previouslyFocused = document.activeElement as HTMLElement | null
document.addEventListener('keydown', onDocumentKeydown, true)
void nextTick(() => {
const target = focusableElements()[0] ?? panelRef.value
target?.focus()
})
} else {
stopListening()
previouslyFocused?.focus?.()
previouslyFocused = null
}
},
{ immediate: true },
)
onBeforeUnmount(stopListening)
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-slate-900/40" @click="emit('close')" />
<aside
ref="panelRef"
class="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-xl outline-none"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<h2 class="text-base font-medium text-slate-800">{{ title }}</h2>
<button
type="button"
class="rounded px-2 py-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
aria-label="关闭"
@click="emit('close')"
>
✕
</button>
</header>
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<slot />
</div>
<footer v-if="$slots.footer" class="border-t border-slate-200 px-4 py-3">
<slot name="footer" />
</footer>
</aside>
</div>
</Teleport>
</template>
+139
View File
@@ -0,0 +1,139 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import TagInput from './TagInput.vue'
function mountTags(modelValue: string[] = []) {
return mount(TagInput, { props: { modelValue } })
}
/** 显式指定 key,避免依赖测试工具对修饰符的解析方式 */
async function pressEnter(wrapper: VueWrapper): Promise<void> {
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
}
async function pressBackspace(wrapper: VueWrapper): Promise<void> {
await wrapper.find('input').trigger('keydown', { key: 'Backspace' })
}
/** 取最后一次 emit 的数组值 */
function lastEmitted(wrapper: VueWrapper): string[] | undefined {
const events = wrapper.emitted('update:modelValue')
if (!events) return undefined
return events[events.length - 1]?.[0] as string[] | undefined
}
describe('TagInput 渲染', () => {
it('渲染已有标签', () => {
const wrapper = mountTags(['设计', '前端'])
expect(wrapper.text()).toContain('#设计')
expect(wrapper.text()).toContain('#前端')
})
it('无标签时显示占位提示', () => {
expect(mountTags([]).find('input').attributes('placeholder')).toBe('回车添加标签')
})
it('有标签时隐藏占位提示', () => {
expect(mountTags(['a']).find('input').attributes('placeholder')).toBe('')
})
it('带可访问性标签', () => {
expect(mountTags([]).find('input').attributes('aria-label')).toBe('添加标签')
})
})
describe('TagInput 添加', () => {
it('回车添加标签', async () => {
const wrapper = mountTags([])
await wrapper.find('input').setValue('设计')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计'])
})
it('添加后清空输入框', async () => {
const wrapper = mountTags([])
const input = wrapper.find('input')
await input.setValue('设计')
await pressEnter(wrapper)
expect((input.element as HTMLInputElement).value).toBe('')
})
it('去除首尾空白', async () => {
const wrapper = mountTags([])
await wrapper.find('input').setValue(' 设计 ')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计'])
})
it('空白输入不添加', async () => {
const wrapper = mountTags([])
await wrapper.find('input').setValue(' ')
await pressEnter(wrapper)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('重复标签不添加', async () => {
const wrapper = mountTags(['设计'])
await wrapper.find('input').setValue('设计')
await pressEnter(wrapper)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('追加到已有标签之后', async () => {
const wrapper = mountTags(['设计'])
await wrapper.find('input').setValue('前端')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计', '前端'])
})
it('输入法组合期间回车不添加', async () => {
const wrapper = mountTags([])
const input = wrapper.find('input')
await input.trigger('compositionstart')
await input.setValue('sheji')
await pressEnter(wrapper)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await input.trigger('compositionend')
await input.setValue('设计')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计'])
})
it('失焦时提交未确认的草稿', async () => {
const wrapper = mountTags([])
await wrapper.find('input').setValue('草稿')
await wrapper.find('input').trigger('blur')
expect(lastEmitted(wrapper)).toEqual(['草稿'])
})
})
describe('TagInput 删除', () => {
it('点击 ✕ 移除对应标签', async () => {
const wrapper = mountTags(['设计', '前端'])
await wrapper.find('button[aria-label="移除标签 设计"]').trigger('click')
expect(lastEmitted(wrapper)).toEqual(['前端'])
})
it('输入框为空时退格删除最后一个标签', async () => {
const wrapper = mountTags(['设计', '前端'])
await pressBackspace(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计'])
})
it('输入框非空时退格不删除标签', async () => {
const wrapper = mountTags(['设计'])
await wrapper.find('input').setValue('x')
await pressBackspace(wrapper)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('没有标签时退格不报错', async () => {
const wrapper = mountTags([])
await expect(pressBackspace(wrapper)).resolves.toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ modelValue: string[]; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const draft = ref('')
/** 中文输入法保护:选词的回车不应被当作「添加标签」 */
const composing = ref(false)
function commit(): void {
const value = draft.value.trim()
draft.value = ''
if (!value) return
if (props.modelValue.includes(value)) return
emit('update:modelValue', [...props.modelValue, value])
}
function remove(tag: string): void {
emit(
'update:modelValue',
props.modelValue.filter((item) => item !== tag),
)
}
/** 输入框为空时按退格删除最后一个标签 */
function onBackspace(): void {
if (draft.value !== '') return
const last = props.modelValue[props.modelValue.length - 1]
if (last === undefined) return
remove(last)
}
</script>
<template>
<div
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 focus-within:border-sky-400"
>
<span
v-for="tag in modelValue"
:key="tag"
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
>
#{{ tag }}
<button
type="button"
class="text-sky-500 transition hover:text-sky-800"
:aria-label="`移除标签 ${tag}`"
@click="remove(tag)"
>
✕
</button>
</span>
<input
v-model="draft"
type="text"
class="min-w-24 flex-1 border-none bg-transparent px-1 py-0.5 text-sm text-slate-700 outline-none placeholder:text-slate-400"
:placeholder="modelValue.length === 0 ? (placeholder ?? '回车添加标签') : ''"
aria-label="添加标签"
@keydown.enter.prevent="composing ? undefined : commit()"
@keydown.backspace="onBackspace"
@compositionstart="composing = true"
@compositionend="composing = false"
@blur="commit"
/>
</div>
</template>
+150
View File
@@ -0,0 +1,150 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import TaskCard from './TaskCard.vue'
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
function mountCard(overrides: Partial<Task> = {}) {
return mount(TaskCard, { props: { task: makeTask(overrides) } })
}
/** 通过文案定位优先级徽章,避免依赖 DOM 层级 */
function priorityBadge(wrapper: VueWrapper) {
return wrapper.findAll('span').find((node) => ['低', '中', '高'].includes(node.text()))
}
describe('TaskCard 基础渲染', () => {
it('渲染标题', () => {
expect(mountCard({ title: '写方案' }).find('h3').text()).toBe('写方案')
})
it('渲染优先级徽章', () => {
expect(priorityBadge(mountCard({ priority: 3 }))?.text()).toBe('高')
})
it('无标签无截止日期时仍然显示优先级徽章', () => {
const wrapper = mountCard({ priority: 1, tags: [], dueDate: null })
expect(priorityBadge(wrapper)?.text()).toBe('低')
})
it('无截止日期时不渲染日期徽章', () => {
const wrapper = mountCard({ dueDate: null })
expect(wrapper.text()).not.toContain('今天')
expect(wrapper.text()).not.toContain('逾期')
})
})
describe('优先级视觉区分', () => {
it('三档优先级使用不同色系', () => {
const high = priorityBadge(mountCard({ priority: 3 }))?.classes().join(' ') ?? ''
const medium = priorityBadge(mountCard({ priority: 2 }))?.classes().join(' ') ?? ''
const low = priorityBadge(mountCard({ priority: 1 }))?.classes().join(' ') ?? ''
expect(high).toContain('rose')
expect(medium).toContain('amber')
expect(low).toContain('slate')
expect(new Set([high, medium, low]).size).toBe(3)
})
})
describe('截止日期', () => {
it('逾期任务使用警示样式', () => {
const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD')
const wrapper = mountCard({ dueDate: yesterday })
expect(wrapper.text()).toContain('逾期 2 天')
const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期'))
expect(badge?.classes().join(' ')).toContain('rose')
})
it('已完成任务不再标记逾期', () => {
const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD')
const wrapper = mountCard({ dueDate: yesterday, status: 'done' })
const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期'))
expect(badge?.classes().join(' ')).not.toContain('rose')
})
it('今天到期显示「今天」', () => {
expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天')
})
})
describe('已完成样式', () => {
it('标题加删除线并整体降饱和', () => {
const wrapper = mountCard({ status: 'done' })
expect(wrapper.find('h3').classes().join(' ')).toContain('line-through')
expect(wrapper.find('article').classes().join(' ')).toContain('opacity-60')
})
it('未完成时不加删除线', () => {
expect(mountCard({ status: 'todo' }).find('h3').classes().join(' ')).not.toContain(
'line-through',
)
})
})
describe('标签展示', () => {
it('最多显示 3 个标签', () => {
const wrapper = mountCard({ tags: ['a', 'b', 'c', 'd', 'e'] })
expect(wrapper.text()).toContain('#a')
expect(wrapper.text()).toContain('#c')
expect(wrapper.text()).not.toContain('#d')
expect(wrapper.text()).toContain('+2')
})
it('标签不超过 3 个时不显示折叠计数', () => {
const wrapper = mountCard({ tags: ['a', 'b', 'c'] })
expect(wrapper.text()).not.toContain('+')
})
it('无标签时不渲染标签', () => {
expect(mountCard({ tags: [] }).text()).not.toContain('#')
})
})
describe('交互', () => {
it('点击完成按钮 emit toggle 并带上 id', async () => {
const wrapper = mountCard({ id: 'task-42' })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('toggle')).toEqual([['task-42']])
})
it('完成按钮带可访问性属性', () => {
expect(mountCard({ status: 'todo' }).find('button').attributes('aria-label')).toBe(
'标记为已完成',
)
expect(mountCard({ status: 'done' }).find('button').attributes('aria-pressed')).toBe('true')
})
it('点击卡片 emit open 而不是 toggle', async () => {
const wrapper = mountCard({ id: 'task-7' })
await wrapper.find('article').trigger('click')
expect(wrapper.emitted('open')).toEqual([['task-7']])
expect(wrapper.emitted('toggle')).toBeUndefined()
})
it('卡片可点击(带 cursor-pointer)', () => {
expect(mountCard().find('article').classes().join(' ')).toContain('cursor-pointer')
})
})
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PRIORITY_LABELS, type Task } from '@/types/task'
import { formatDueDate, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{ toggle: [id: string]; open: [id: string] }>()
const isDone = computed(() => props.task.status === 'done')
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/** 三档优先级使用不同色系,保证一眼可区分 */
const priorityClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'bg-rose-100 text-rose-700'
case 2:
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-500'
}
})
</script>
<template>
<article
class="cursor-pointer rounded-md border border-slate-200 bg-white px-2.5 py-2 shadow-sm transition"
:class="isDone ? 'opacity-60' : 'hover:border-slate-300 hover:shadow'"
:data-task-id="task.id"
@click="emit('open', task.id)"
>
<div class="flex items-start gap-2">
<button
type="button"
class="mt-0.5 size-4 shrink-0 rounded-full border transition"
:class="
isDone ? 'border-emerald-500 bg-emerald-500' : 'border-slate-300 hover:border-emerald-500'
"
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
:aria-pressed="isDone"
@click.stop="emit('toggle', task.id)"
/>
<h3
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
>
{{ task.title }}
</h3>
</div>
<div class="mt-2 flex flex-wrap items-center gap-1.5 pl-6">
<span class="rounded px-1.5 py-0.5 text-xs" :class="priorityClass">
{{ PRIORITY_LABELS[task.priority] }}
</span>
<span
v-if="task.dueDate"
class="rounded px-1.5 py-0.5 text-xs"
:class="overdue ? 'bg-rose-100 font-medium text-rose-700' : 'bg-slate-100 text-slate-600'"
>
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
</span>
<span
v-for="tag in visibleTags"
:key="tag"
class="rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
>
#{{ tag }}
</span>
<span v-if="hiddenTagCount > 0" class="text-xs text-slate-400">+{{ hiddenTagCount }}</span>
</div>
</article>
</template>
+181
View File
@@ -0,0 +1,181 @@
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 { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import TaskDetailDrawer from './TaskDetailDrawer.vue'
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
function setup() {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const toast = useToastStore()
const created = store.createTask({
title: '原标题',
description: '原描述',
priority: 1,
tags: ['设计'],
dueDate: '2026-12-01',
})
const taskId = created?.id ?? ''
const current = store.tasks.find((task) => task.id === taskId) ?? null
const wrapper = mount(TaskDetailDrawer, {
props: { task: current },
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, toast, wrapper, taskId }
}
describe('TaskDetailDrawer 回填', () => {
it('打开时回填任务各字段', () => {
const { wrapper } = setup()
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
'原标题',
)
expect(
(wrapper.find('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value,
).toBe('原描述')
expect(wrapper.text()).toContain('#设计')
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
'2026-12-01',
)
})
it('回填当前优先级(低)', () => {
const { wrapper } = setup()
const low = buttonByText(wrapper, '低')
expect(low?.attributes('aria-pressed')).toBe('true')
})
it('task 为 null 时不渲染表单', () => {
const pinia = createPinia()
setActivePinia(pinia)
useTaskStore().init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(TaskDetailDrawer, {
props: { task: null },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.find('form').exists()).toBe(false)
})
})
describe('TaskDetailDrawer 保存', () => {
it('保存后写入 store 并 emit close', async () => {
const { store, wrapper, taskId } = setup()
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')
await wrapper.find('input[aria-label="添加标签"]').setValue('前端')
await wrapper.find('input[aria-label="添加标签"]').trigger('keydown', { key: 'Enter' })
await buttonByText(wrapper, '保存')?.trigger('click')
const task = store.tasks.find((item) => item.id === taskId)
expect(task?.title).toBe('新标题')
expect(task?.description).toBe('新描述')
expect(task?.priority).toBe(3)
expect(task?.dueDate).toBe('2026-11-11')
expect(task?.tags).toEqual(['设计', '前端'])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('标题为空时显示错误且不写入 store', async () => {
const { store, wrapper, taskId } = setup()
await wrapper.find('input[aria-label="标题"]').setValue(' ')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(wrapper.text()).toContain('标题不能为空')
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
expect(wrapper.emitted('close')).toBeUndefined()
})
it('重新输入标题后错误提示消失', async () => {
const { wrapper } = setup()
await wrapper.find('input[aria-label="标题"]').setValue('')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(wrapper.text()).toContain('标题不能为空')
await wrapper.find('input[aria-label="标题"]').setValue('有内容了')
expect(wrapper.text()).not.toContain('标题不能为空')
})
it('清空截止日期会写入 null', async () => {
const { store, wrapper, taskId } = setup()
await buttonByText(wrapper, '清除')?.trigger('click')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBeNull()
})
})
describe('TaskDetailDrawer 取消', () => {
it('取消不产生脏数据', async () => {
const { store, wrapper, taskId } = setup()
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('编辑后重新打开会回到最新数据,不残留草稿', async () => {
const { store, wrapper, taskId } = setup()
await wrapper.find('input[aria-label="标题"]').setValue('临时草稿')
await buttonByText(wrapper, '取消')?.trigger('click')
// 模拟真实的「关闭再打开」:BoardView 会把 editingId 置空再设回,
// 因此 prop 会经历 null -> task 的变化并触发 watcher
await wrapper.setProps({ task: null })
const task = store.tasks.find((item) => item.id === taskId) ?? null
await wrapper.setProps({ task })
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
'原标题',
)
})
})
describe('TaskDetailDrawer 删除与撤销', () => {
it('删除后任务消失并弹出可撤销提示', async () => {
const { store, toast, wrapper } = setup()
await buttonByText(wrapper, '删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.emitted('close')).toHaveLength(1)
expect(toast.toasts).toHaveLength(1)
expect(toast.toasts[0]?.action?.label).toBe('撤销')
expect(toast.toasts[0]?.message).toContain('原标题')
})
it('点击撤销后任务连同原位置一起恢复', async () => {
const { store, toast, wrapper, taskId } = setup()
await buttonByText(wrapper, '删除')?.trigger('click')
const toastId = toast.toasts[0]?.id ?? ''
toast.runAction(toastId)
expect(store.tasks).toHaveLength(1)
expect(store.tasks[0]?.id).toBe(taskId)
expect(store.tasks[0]?.title).toBe('原标题')
expect(toast.toasts).toHaveLength(0)
})
})
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { PRIORITY_LABELS, type Priority, type Task } from '@/types/task'
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 priority = ref<Priority>(2)
const dueDate = ref<string | null>(null)
const tags = ref<string[]>([])
const titleError = ref<string | null>(null)
/**
* 打开时把任务字段复制到本地草稿。
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
*/
watch(
() => props.task,
(task) => {
title.value = task?.title ?? ''
description.value = task?.description ?? ''
priority.value = task?.priority ?? 2
dueDate.value = task?.dueDate ?? null
tags.value = task ? [...task.tags] : []
titleError.value = null
},
{ immediate: true },
)
function save(): void {
const task = props.task
if (!task) return
const trimmed = title.value.trim()
if (!trimmed) {
titleError.value = '标题不能为空'
return
}
store.updateTask(task.id, {
title: trimmed,
description: description.value.trim(),
priority: priority.value,
dueDate: dueDate.value,
tags: [...tags.value],
})
emit('close')
}
/** 删除不弹二次确认,改由 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="emit('close')">
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">标题</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"
aria-label="标题"
@input="titleError = null"
/>
<span v-if="titleError" class="text-xs text-rose-600">{{ titleError }}</span>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">描述</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"
aria-label="描述"
/>
</label>
<fieldset class="flex flex-col gap-1.5">
<legend class="mb-1.5 text-sm font-medium text-slate-600">优先级</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'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
"
: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">截止日期</span>
<DatePicker v-model="dueDate" />
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">标签</span>
<TagInput v-model="tags" />
</div>
</form>
<template #footer>
<div class="flex items-center justify-between">
<button
type="button"
class="rounded-md px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50"
@click="remove"
>
删除
</button>
<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"
@click="emit('close')"
>
取消
</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"
@click="save"
>
保存
</button>
</div>
</div>
</template>
</SideDrawer>
</template>
+73
View File
@@ -0,0 +1,73 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useToastStore } from '@/stores/toast'
import ToastHost from './ToastHost.vue'
function setup() {
const pinia = createPinia()
setActivePinia(pinia)
const store = useToastStore()
const wrapper = mount(ToastHost, { global: { plugins: [pinia] } })
return { store, wrapper }
}
describe('ToastHost', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('无提示时不渲染任何内容', () => {
const { wrapper } = setup()
expect(wrapper.findAll('[role="status"]')).toHaveLength(0)
})
it('渲染提示文案', async () => {
const { store, wrapper } = setup()
store.show({ message: '已保存', duration: 0 })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('已保存')
})
it('点击关闭按钮移除提示', async () => {
const { store, wrapper } = setup()
store.show({ message: 'A', duration: 0 })
await wrapper.vm.$nextTick()
await wrapper.find('button[aria-label="关闭提示"]').trigger('click')
expect(store.toasts).toHaveLength(0)
})
it('带动作时渲染动作按钮并执行回调', async () => {
const { store, wrapper } = setup()
const run = vi.fn()
store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
await wrapper.vm.$nextTick()
const actionButton = wrapper
.findAll('button')
.find((node) => node.text() === '撤销')
expect(actionButton).toBeDefined()
await actionButton?.trigger('click')
expect(run).toHaveBeenCalledTimes(1)
expect(store.toasts).toHaveLength(0)
})
it('无动作时不渲染动作按钮', async () => {
const { store, wrapper } = setup()
store.show({ message: '普通提示', duration: 0 })
await wrapper.vm.$nextTick()
expect(wrapper.findAll('button')).toHaveLength(1)
})
it('提示容器带 role=status 便于读屏播报', async () => {
const { store, wrapper } = setup()
store.show({ message: 'A', duration: 0 })
await wrapper.vm.$nextTick()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useToastStore } from '@/stores/toast'
const store = useToastStore()
const { toasts } = storeToRefs(store)
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4"
>
<div
v-for="toast in toasts"
:key="toast.id"
role="status"
class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
>
<span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span>
<button
v-if="toast.action"
type="button"
class="shrink-0 rounded px-2 py-0.5 text-sm font-medium text-sky-300 transition hover:bg-white/10"
@click="store.runAction(toast.id)"
>
{{ toast.action.label }}
</button>
<button
type="button"
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
aria-label="关闭提示"
@click="store.dismiss(toast.id)"
>
✕
</button>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import { isEditableTarget, matchShortcut, type ShortcutEventLike } from './useKeyboardShortcuts'
function makeEvent(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
return {
key: 'n',
target: document.body,
defaultPrevented: false,
ctrlKey: false,
metaKey: false,
altKey: false,
...overrides,
}
}
function makeElement(tag: string, contentEditable = false): HTMLElement {
const element = document.createElement(tag)
// 用 setAttribute 而非 property:jsdom 不会把 contentEditable 属性反射到 attribute
if (contentEditable) element.setAttribute('contenteditable', 'true')
return element
}
describe('isEditableTarget', () => {
it('识别输入类元素', () => {
expect(isEditableTarget(makeElement('input'))).toBe(true)
expect(isEditableTarget(makeElement('textarea'))).toBe(true)
expect(isEditableTarget(makeElement('select'))).toBe(true)
})
it('识别 contenteditable', () => {
expect(isEditableTarget(makeElement('div', true))).toBe(true)
})
it('普通元素不算可编辑', () => {
expect(isEditableTarget(makeElement('div'))).toBe(false)
expect(isEditableTarget(document.body)).toBe(false)
})
it('null 不算可编辑', () => {
expect(isEditableTarget(null)).toBe(false)
})
})
describe('matchShortcut', () => {
it('命中时返回处理函数', () => {
const handler = vi.fn()
expect(matchShortcut(makeEvent({ key: 'n' }), { n: handler })).toBe(handler)
})
it('按键大小写不敏感', () => {
const handler = vi.fn()
expect(matchShortcut(makeEvent({ key: 'N' }), { n: handler })).toBe(handler)
})
it('支持 / 与 ? 这类符号键', () => {
const slash = vi.fn()
const question = vi.fn()
const shortcuts = { '/': slash, '?': question }
expect(matchShortcut(makeEvent({ key: '/' }), shortcuts)).toBe(slash)
expect(matchShortcut(makeEvent({ key: '?' }), shortcuts)).toBe(question)
})
it('输入框内不触发', () => {
const handler = vi.fn()
expect(matchShortcut(makeEvent({ target: makeElement('input') }), { n: handler })).toBeNull()
expect(matchShortcut(makeEvent({ target: makeElement('textarea') }), { n: handler })).toBeNull()
})
it('带 Ctrl / Cmd / Alt 修饰键时不触发', () => {
const handler = vi.fn()
expect(matchShortcut(makeEvent({ ctrlKey: true }), { n: handler })).toBeNull()
expect(matchShortcut(makeEvent({ metaKey: true }), { n: handler })).toBeNull()
expect(matchShortcut(makeEvent({ altKey: true }), { n: handler })).toBeNull()
})
it('已被处理的事件不重复触发', () => {
const handler = vi.fn()
expect(matchShortcut(makeEvent({ defaultPrevented: true }), { n: handler })).toBeNull()
})
it('未注册的按键返回 null', () => {
expect(matchShortcut(makeEvent({ key: 'z' }), { n: vi.fn() })).toBeNull()
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* 全局键盘快捷键(T16)
*
* 关键约束:**在输入框内打字时不劫持按键**。
* 否则用户在搜索框里输入 "n" 会触发「新增任务」。
*/
import { onBeforeUnmount, onMounted } from 'vue'
export type ShortcutMap = Record<string, () => void>
/** 事件是否发生在可编辑元素内 */
export function isEditableTarget(target: EventTarget | null): boolean {
if (typeof HTMLElement === 'undefined' || !(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
// jsdom 未实现 isContentEditable,因此同时检查属性,保证行为可测且与浏览器一致
if (target.isContentEditable === true) return true
const attribute = target.getAttribute('contenteditable')
return attribute === '' || attribute === 'true'
}
export interface ShortcutEventLike {
key: string
target: EventTarget | null
defaultPrevented: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}
/**
* 解析出应执行的快捷键处理函数;不满足条件时返回 null。
* 独立成纯函数便于测试。
*/
export function matchShortcut(
event: ShortcutEventLike,
shortcuts: ShortcutMap,
): (() => void) | null {
if (event.defaultPrevented) return null
// 带修饰键的组合交给浏览器(如 Ctrl+F 查找、Cmd+R 刷新)
if (event.ctrlKey || event.metaKey || event.altKey) return null
if (isEditableTarget(event.target)) return null
return shortcuts[event.key.toLowerCase()] ?? null
}
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
function onKeydown(event: KeyboardEvent): void {
const handler = matchShortcut(event, shortcuts)
if (!handler) return
event.preventDefault()
handler()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
}
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest'
import {
DataValidationError,
SCHEMA_VERSION,
coerceTask,
coerceTaskList,
isValidDateString,
parseImportJson,
parsePersistedState,
serializeState,
validateImportPayload,
} from './schema'
function validTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'task-1',
title: '写方案',
description: '细节',
status: 'todo',
priority: 2,
tags: ['设计'],
dueDate: '2026-10-20',
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('isValidDateString', () => {
it('接受合法日期', () => {
expect(isValidDateString('2026-10-18')).toBe(true)
expect(isValidDateString('2024-02-29')).toBe(true)
})
it('拒绝格式不对的值', () => {
expect(isValidDateString('2026-1-1')).toBe(false)
expect(isValidDateString('2026/10/18')).toBe(false)
expect(isValidDateString('')).toBe(false)
expect(isValidDateString(20261018)).toBe(false)
expect(isValidDateString(null)).toBe(false)
})
it('拒绝「格式正确但日期不存在」的值', () => {
expect(isValidDateString('2026-02-31')).toBe(false)
expect(isValidDateString('2026-13-01')).toBe(false)
expect(isValidDateString('2025-02-29')).toBe(false)
})
})
describe('coerceTask', () => {
it('接受完整合法任务', () => {
expect(coerceTask(validTask())).toEqual(validTask())
})
it('缺少 id 或 title 时返回 null', () => {
expect(coerceTask(validTask({ id: undefined }))).toBeNull()
expect(coerceTask(validTask({ title: ' ' }))).toBeNull()
expect(coerceTask(validTask({ title: 123 }))).toBeNull()
})
it('status 或 priority 非法时返回 null', () => {
expect(coerceTask(validTask({ status: 'archived' }))).toBeNull()
expect(coerceTask(validTask({ priority: 9 }))).toBeNull()
expect(coerceTask(validTask({ priority: '2' }))).toBeNull()
})
it('非对象一律返回 null', () => {
expect(coerceTask(null)).toBeNull()
expect(coerceTask('task')).toBeNull()
expect(coerceTask([])).toBeNull()
})
it('可选项缺失时补默认值', () => {
const result = coerceTask({
id: 'a',
title: '只有必填项',
status: 'doing',
priority: 1,
})
expect(result).not.toBeNull()
expect(result?.description).toBe('')
expect(result?.tags).toEqual([])
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
expect(result?.completedAt).toBeNull()
expect(result?.createdAt).toBeTruthy()
})
it('非法 dueDate 收敛为 null,非法 sortOrder 收敛为 0', () => {
const result = coerceTask(validTask({ dueDate: '2026-02-31', sortOrder: 'abc' }))
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
})
it('tags 过滤掉非字符串与空白项', () => {
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
expect(result?.tags).toEqual(['ok', 'ok2'])
})
it('非法 archivedAt 收敛为 null', () => {
expect(coerceTask(validTask({ archivedAt: 123 }))?.archivedAt).toBeNull()
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
})
it('保留合法的 archivedAt', () => {
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
'2026-10-01T00:00:00.000Z',
)
})
})
describe('coerceTaskList', () => {
it('跳过损坏条目但保留合法条目', () => {
const list = coerceTaskList([validTask(), { id: 'broken' }, validTask({ id: 'task-2' })])
expect(list.map((task) => task.id)).toEqual(['task-1', 'task-2'])
})
it('非数组返回空数组', () => {
expect(coerceTaskList(null)).toEqual([])
expect(coerceTaskList({})).toEqual([])
})
})
describe('parsePersistedState', () => {
it('空值返回空看板', () => {
expect(parsePersistedState(null)).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
expect(parsePersistedState(' ')).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
})
it('非 JSON 抛 DataValidationError', () => {
expect(() => parsePersistedState('{oops')).toThrow(DataValidationError)
})
it('非对象抛 DataValidationError', () => {
expect(() => parsePersistedState('[1,2,3]')).toThrow(DataValidationError)
})
it('缺少 schemaVersion 的旧数据迁移为当前版本', () => {
const legacy = JSON.stringify({ tasks: [validTask()] })
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks).toHaveLength(1)
})
it('版本高于当前支持时抛错(不静默降级)', () => {
const future = JSON.stringify({ schemaVersion: SCHEMA_VERSION + 1, tasks: [] })
expect(() => parsePersistedState(future)).toThrow(/高于当前支持/)
})
it('损坏条目被跳过,其余数据仍可用', () => {
const mixed = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
tasks: [validTask(), { id: 'x', title: '' }],
})
expect(parsePersistedState(mixed).tasks).toHaveLength(1)
})
})
describe('版本迁移', () => {
it('v1 数据迁移到 v2 时补上 archivedAt,其余字段不变', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{
id: 'legacy',
title: '旧任务',
status: 'done',
priority: 3,
sortOrder: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-02T00:00:00.000Z',
},
],
})
const state = parsePersistedState(v1)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
expect(state.tasks[0]?.title).toBe('旧任务')
expect(state.tasks[0]?.priority).toBe(3)
expect(state.tasks[0]?.completedAt).toBe('2026-01-02T00:00:00.000Z')
})
it('无版本号的最早期数据能一路迁移到当前版本', () => {
const legacy = JSON.stringify({
tasks: [{ id: 'a', title: '最早的任务', status: 'todo', priority: 2 }],
})
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
})
it('归档状态在序列化往返后保留', () => {
const tasks = coerceTaskList([validTask({ archivedAt: '2026-10-01T00:00:00.000Z' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks[0]?.archivedAt).toBe('2026-10-01T00:00:00.000Z')
})
it('多版本迁移对多条任务都生效', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{ id: 'a', title: 'A', status: 'todo', priority: 1 },
{ id: 'b', title: 'B', status: 'done', priority: 2 },
],
})
const state = parsePersistedState(v1)
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
})
})
describe('序列化往返', () => {
it('serializeState 与 parsePersistedState 互逆', () => {
const tasks = coerceTaskList([validTask(), validTask({ id: 'task-2', status: 'done' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks).toEqual(tasks)
expect(roundTrip.schemaVersion).toBe(SCHEMA_VERSION)
})
it('pretty 模式仍可解析', () => {
const tasks = coerceTaskList([validTask()])
const pretty = serializeState(tasks, true)
expect(pretty).toContain('\n')
expect(parsePersistedState(pretty).tasks).toEqual(tasks)
})
})
describe('导入校验(严格)', () => {
it('任何一条任务不合法即整体拒绝', () => {
expect(() => validateImportPayload({ tasks: [validTask(), { id: 'broken' }] })).toThrow(
/第 2 条任务格式不合法/,
)
})
it('缺少 tasks 数组时抛错', () => {
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
})
it('非对象时抛错', () => {
expect(() => validateImportPayload([])).toThrow(/必须是一个 JSON 对象/)
})
it('版本过高时拒绝导入', () => {
expect(() => validateImportPayload({ schemaVersion: 99, tasks: [] })).toThrow(/高于当前支持/)
})
it('允许省略 schemaVersion', () => {
expect(validateImportPayload({ tasks: [validTask()] })).toHaveLength(1)
})
it('parseImportJson 对非法 JSON 给出明确错误', () => {
expect(() => parseImportJson('not json')).toThrow(/不是合法的 JSON/)
})
})
+221
View File
@@ -0,0 +1,221 @@
/**
* 持久化数据结构与迁移(T04)
*
* 职责:
* - 定义落盘结构 PersistedState(含 schemaVersion)
* - 解析与迁移:parsePersistedState(宽容,用于读取本地数据)
* - 导入校验:parseImportJson(严格,用于导入外部文件)
*/
import { isColumnId, isPriority, type Task } from '@/types/task'
export const SCHEMA_VERSION = 2
/** 主数据在 localStorage 中的键名 */
export const STORAGE_KEY = 'kanban.tasks'
/** 自动快照键前缀(T14) */
export const SNAPSHOT_KEY_PREFIX = 'kanban.snapshot.'
export interface PersistedState {
schemaVersion: number
tasks: Task[]
}
/** 数据不符合预期结构时抛出,调用方据此提示用户而不是白屏 */
export class DataValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'DataValidationError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
/** 校验 YYYY-MM-DD,并排除 2026-02-31 这类「格式对但日期不存在」的值 */
export function isValidDateString(value: unknown): value is string {
if (typeof value !== 'string' || !DATE_PATTERN.test(value)) return false
const parsed = new Date(`${value}T00:00:00.000Z`)
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' ? value : null
}
/**
* 将未知值收敛为合法 Task;无法收敛时返回 null。
* 单个任务损坏不应导致整份数据不可用,因此这里采用「跳过」而非「抛错」。
*/
export function coerceTask(value: unknown): Task | null {
if (!isRecord(value)) return null
const id = readString(value, 'id')
const title = readString(value, 'title')
if (!id || !title || title.trim() === '') return null
const status = value.status
if (!isColumnId(status)) return null
const priority = value.priority
if (!isPriority(priority)) return null
const rawTags = Array.isArray(value.tags) ? value.tags : []
const tags = rawTags.filter(
(tag): tag is string => typeof tag === 'string' && tag.trim() !== '',
)
const dueDate = value.dueDate
const sortOrder = value.sortOrder
const createdAt = readString(value, 'createdAt') ?? new Date().toISOString()
const updatedAt = readString(value, 'updatedAt') ?? createdAt
const completedAt = readString(value, 'completedAt')
const archivedAt = readString(value, 'archivedAt')
return {
id,
title,
description: readString(value, 'description') ?? '',
status,
priority,
tags,
dueDate: isValidDateString(dueDate) ? dueDate : null,
sortOrder: typeof sortOrder === 'number' && Number.isFinite(sortOrder) ? sortOrder : 0,
createdAt,
updatedAt,
completedAt,
archivedAt,
}
}
export function coerceTaskList(value: unknown): Task[] {
if (!Array.isArray(value)) return []
const tasks: Task[] = []
for (const item of value) {
const task = coerceTask(item)
if (task) tasks.push(task)
}
return tasks
}
type Migration = (state: PersistedState) => PersistedState
/**
* 版本迁移表,键为「迁移前的版本号」。
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
* - v1 -> v2:新增 archivedAt 字段(归档功能)
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
*/
const MIGRATIONS: Record<number, Migration> = {
0: (state) => ({ ...state, schemaVersion: 1 }),
1: (state) => ({
schemaVersion: 2,
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
}),
}
/**
* 解析本地持久化数据(宽容策略)。
* 空值返回空看板;结构性错误抛出 DataValidationError。
*/
export function parsePersistedState(raw: string | null): PersistedState {
if (raw === null || raw.trim() === '') {
return { schemaVersion: SCHEMA_VERSION, tasks: [] }
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new DataValidationError('本地数据不是合法的 JSON')
}
if (!isRecord(parsed)) {
throw new DataValidationError('本地数据结构异常:期望一个对象')
}
const rawVersion = parsed.schemaVersion
const startVersion =
typeof rawVersion === 'number' && Number.isInteger(rawVersion) && rawVersion >= 0
? rawVersion
: 0
if (startVersion > SCHEMA_VERSION) {
throw new DataValidationError(
`数据版本 ${startVersion} 高于当前支持的 ${SCHEMA_VERSION},请升级应用后再打开`,
)
}
let state: PersistedState = {
schemaVersion: startVersion,
tasks: coerceTaskList(parsed.tasks),
}
while (state.schemaVersion < SCHEMA_VERSION) {
const migration = MIGRATIONS[state.schemaVersion]
if (!migration) {
throw new DataValidationError(
`缺少从版本 ${state.schemaVersion} 到 ${state.schemaVersion + 1} 的迁移逻辑`,
)
}
state = migration(state)
}
return state
}
/**
* 严格校验导入内容(T14)。任何一条任务不合法即整体拒绝,
* 以保证「非法文件不破坏现有数据」。
*/
export function validateImportPayload(value: unknown): Task[] {
if (!isRecord(value)) {
throw new DataValidationError('导入内容必须是一个 JSON 对象')
}
const version = value.schemaVersion
if (version !== undefined) {
if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
throw new DataValidationError('schemaVersion 字段不合法')
}
if (version > SCHEMA_VERSION) {
throw new DataValidationError(
`导入文件的版本 ${version} 高于当前支持的 ${SCHEMA_VERSION},无法导入`,
)
}
}
if (!Array.isArray(value.tasks)) {
throw new DataValidationError('导入内容缺少 tasks 数组')
}
const tasks: Task[] = []
value.tasks.forEach((item, index) => {
const task = coerceTask(item)
if (!task) {
throw new DataValidationError(`第 ${index + 1} 条任务格式不合法,已取消导入`)
}
tasks.push(task)
})
return tasks
}
export function parseImportJson(raw: string): Task[] {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new DataValidationError('导入文件不是合法的 JSON')
}
return validateImportPayload(parsed)
}
export function serializeState(tasks: readonly Task[], pretty = false): string {
const state: PersistedState = { schemaVersion: SCHEMA_VERSION, tasks: [...tasks] }
return pretty ? JSON.stringify(state, null, 2) : JSON.stringify(state)
}
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { DataValidationError, SNAPSHOT_KEY_PREFIX } from './schema'
import {
MAX_SNAPSHOTS,
SNAPSHOT_INDEX_KEY,
listSnapshots,
readSnapshot,
writeSnapshot,
} from './snapshot'
import type { StorageLike } from './storage'
interface FakeStorage extends StorageLike {
data: Record<string, string>
}
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
const data: Record<string, string> = { ...initial }
return {
data,
getItem: (key) => (key in data ? (data[key] ?? null) : null),
setItem: (key, value) => {
data[key] = value
},
removeItem: (key) => {
delete data[key]
},
}
}
function makeTask(id: string): Task {
return {
id,
title: `任务 ${id}`,
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
}
}
/** 依次生成互不相同的时间点,避免同毫秒导致键名冲突 */
function at(minute: number): Date {
return new Date(Date.UTC(2026, 9, 18, 10, minute, 0))
}
function snapshotKeys(storage: FakeStorage): string[] {
return Object.keys(storage.data).filter(
(key) => key.startsWith(SNAPSHOT_KEY_PREFIX) && key !== SNAPSHOT_INDEX_KEY,
)
}
describe('listSnapshots', () => {
it('空存储返回空数组', () => {
expect(listSnapshots(createFakeStorage())).toEqual([])
})
it('索引损坏时返回空数组而不是抛错', () => {
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{broken' })
expect(listSnapshots(storage)).toEqual([])
})
it('索引不是数组时返回空数组', () => {
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{"a":1}' })
expect(listSnapshots(storage)).toEqual([])
})
it('跳过内容已丢失的条目', () => {
const storage = createFakeStorage({
[SNAPSHOT_INDEX_KEY]: JSON.stringify([
{ key: `${SNAPSHOT_KEY_PREFIX}missing`, savedAt: '2026-10-18T10:00:00.000Z' },
]),
})
expect(listSnapshots(storage)).toEqual([])
})
})
describe('writeSnapshot', () => {
it('写入后可以列出,且返回快照信息', () => {
const storage = createFakeStorage()
const info = writeSnapshot(storage, [makeTask('a')], at(0))
expect(info).not.toBeNull()
expect(info?.savedAt).toBe(at(0).toISOString())
expect(listSnapshots(storage)).toEqual([info])
})
it('最新的排在最前', () => {
const storage = createFakeStorage()
writeSnapshot(storage, [makeTask('a')], at(0))
writeSnapshot(storage, [makeTask('b')], at(1))
writeSnapshot(storage, [makeTask('c')], at(2))
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
expect(savedAtList).toEqual([at(2).toISOString(), at(1).toISOString(), at(0).toISOString()])
})
it('最多保留 MAX_SNAPSHOTS 份,超出的数据也被删除', () => {
const storage = createFakeStorage()
for (let index = 0; index < MAX_SNAPSHOTS + 3; index += 1) {
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
}
expect(listSnapshots(storage)).toHaveLength(MAX_SNAPSHOTS)
expect(snapshotKeys(storage)).toHaveLength(MAX_SNAPSHOTS)
})
it('保留的是最新的若干份', () => {
const storage = createFakeStorage()
for (let index = 0; index < 5; index += 1) {
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
}
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
expect(savedAtList).toEqual([at(4).toISOString(), at(3).toISOString(), at(2).toISOString()])
})
it('keep 可自定义', () => {
const storage = createFakeStorage()
writeSnapshot(storage, [makeTask('a')], at(0), 1)
writeSnapshot(storage, [makeTask('b')], at(1), 1)
expect(listSnapshots(storage)).toHaveLength(1)
})
it('存储写入失败时返回 null 而不抛错', () => {
const storage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
removeItem: () => undefined,
}
expect(writeSnapshot(storage, [makeTask('a')])).toBeNull()
})
it('可以保存空看板(调用方决定是否调用)', () => {
const storage = createFakeStorage()
const info = writeSnapshot(storage, [], at(0))
expect(info).not.toBeNull()
expect(readSnapshot(storage, info?.key ?? '')).toEqual([])
})
})
describe('readSnapshot', () => {
it('读回写入的任务', () => {
const storage = createFakeStorage()
const tasks = [makeTask('a'), makeTask('b')]
const info = writeSnapshot(storage, tasks, at(0))
expect(readSnapshot(storage, info?.key ?? '')).toEqual(tasks)
})
it('拒绝非快照键', () => {
const storage = createFakeStorage()
expect(() => readSnapshot(storage, 'kanban.tasks')).toThrow(DataValidationError)
})
it('快照不存在时返回空数组(parsePersistedState 对空值返回空看板)', () => {
const storage = createFakeStorage()
expect(readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}missing`)).toEqual([])
})
it('快照内容损坏时抛错', () => {
const storage = createFakeStorage({ [`${SNAPSHOT_KEY_PREFIX}bad`]: '{broken' })
expect(() => readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}bad`)).toThrow(DataValidationError)
})
})
+101
View File
@@ -0,0 +1,101 @@
/**
* 启动快照(T14)
*
* local-first 场景下,localStorage 是唯一数据副本。除了手动导出,
* 每次启动再自动留一份快照,用于「主数据损坏/被误清」时的兜底恢复。
*
* 索引单独存放在 SNAPSHOT_INDEX_KEY 中,而不是靠枚举存储键,
* 这样只需 StorageLike 的最小能力即可实现。
*/
import type { Task } from '@/types/task'
import { DataValidationError, SNAPSHOT_KEY_PREFIX, parsePersistedState, serializeState } from './schema'
import type { StorageLike } from './storage'
/**
* 索引键刻意不使用 SNAPSHOT_KEY_PREFIX 前缀,
* 避免任何「按前缀枚举」的代码把它误当成一份快照。
*/
export const SNAPSHOT_INDEX_KEY = 'kanban.snapshots'
/** 最多保留的快照份数 */
export const MAX_SNAPSHOTS = 3
export interface SnapshotInfo {
key: string
savedAt: string
}
function isSnapshotInfo(value: unknown): value is SnapshotInfo {
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
return typeof record.key === 'string' && typeof record.savedAt === 'string'
}
function readIndex(storage: StorageLike): SnapshotInfo[] {
const raw = storage.getItem(SNAPSHOT_INDEX_KEY)
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isSnapshotInfo)
} catch {
return []
}
}
/** 列出可用快照,按时间倒序;内容已丢失的条目会被跳过 */
export function listSnapshots(storage: StorageLike): SnapshotInfo[] {
return readIndex(storage).filter((info) => storage.getItem(info.key) !== null)
}
/**
* 写入一份快照,并只保留最近 keep 份(超出的连同数据一起删除)。
* 快照属于「锦上添花」,因此任何存储异常都返回 null 而不抛出,
* 避免因为备份失败而阻断正常使用。
*/
export function writeSnapshot(
storage: StorageLike,
tasks: readonly Task[],
now: Date = new Date(),
keep: number = MAX_SNAPSHOTS,
): SnapshotInfo | null {
const savedAt = now.toISOString()
const key = `${SNAPSHOT_KEY_PREFIX}${savedAt}`
const previous = readIndex(storage)
try {
storage.setItem(key, serializeState(tasks))
} catch {
return null
}
const next = [{ key, savedAt }, ...previous].slice(0, Math.max(1, keep))
const keptKeys = new Set(next.map((info) => info.key))
for (const info of previous) {
if (keptKeys.has(info.key)) continue
try {
storage.removeItem(info.key)
} catch {
// 清理旧快照失败不影响本次快照
}
}
try {
storage.setItem(SNAPSHOT_INDEX_KEY, JSON.stringify(next))
} catch {
return null
}
return { key, savedAt }
}
/** 从指定快照读取任务 */
export function readSnapshot(storage: StorageLike, key: string): Task[] {
if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) {
throw new DataValidationError(`不是合法的快照键:${key}`)
}
return parsePersistedState(storage.getItem(key)).tasks
}
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserStorage } from './storage'
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
afterEach(() => {
if (originalLocalStorage) Object.defineProperty(globalThis, 'localStorage', originalLocalStorage)
})
describe('resolveBrowserStorage', () => {
it('localStorage 可写时返回它', () => {
const storage = resolveBrowserStorage()
expect(storage).not.toBeNull()
expect(typeof storage?.getItem).toBe('function')
})
it('写入抛错时返回 null(Safari 隐私模式)', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: () => {
throw new Error('SecurityError')
},
removeItem: () => undefined,
},
})
expect(resolveBrowserStorage()).toBeNull()
})
it('localStorage 不存在时返回 null', () => {
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: undefined })
expect(resolveBrowserStorage()).toBeNull()
})
it('探测用的临时键会被清理', () => {
const probeKeys: string[] = []
const data = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
probeKeys.push(key)
data.set(key, value)
},
removeItem: (key: string) => {
data.delete(key)
},
},
})
resolveBrowserStorage()
expect(probeKeys).toContain('__kanban_probe__')
expect(data.size).toBe(0)
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* 最小存储抽象(T04)
*
* 独立成模块是为了让 taskRepository 与 snapshot 都依赖它,
* 而不必互相 import(避免形成运行时循环依赖)。
*/
export interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
/**
* 探测浏览器 localStorage 是否真正可写。
* Safari 隐私模式下 localStorage 存在但写入会抛错,因此必须实际试写一次。
*/
export function resolveBrowserStorage(): StorageLike | null {
try {
const storage = globalThis.localStorage
if (!storage) return null
const probeKey = '__kanban_probe__'
storage.setItem(probeKey, '1')
storage.removeItem(probeKey)
return storage
} catch {
return null
}
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { DataValidationError, SCHEMA_VERSION, serializeState } from './schema'
import {
StorageUnavailableError,
StorageWriteError,
createLocalStorageTaskRepository,
createMemoryTaskRepository,
type StorageLike,
} from './taskRepository'
interface FakeStorage extends StorageLike {
data: Record<string, string>
}
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
const data: Record<string, string> = { ...initial }
return {
data,
getItem: (key) => (key in data ? (data[key] ?? null) : null),
setItem: (key, value) => {
data[key] = value
},
removeItem: (key) => {
delete data[key]
},
}
}
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('createLocalStorageTaskRepository', () => {
it('存储不可用时抛出 StorageUnavailableError', () => {
expect(() => createLocalStorageTaskRepository(null)).toThrow(StorageUnavailableError)
})
it('空存储读取为空数组', () => {
const repository = createLocalStorageTaskRepository(createFakeStorage(), 'kanban.tasks')
expect(repository.load()).toEqual([])
})
it('保存后可以读回,且落盘内容带 schemaVersion', () => {
const storage = createFakeStorage()
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
const tasks = [makeTask(), makeTask({ id: 'task-2', status: 'done' })]
repository.save(tasks)
expect(repository.load()).toEqual(tasks)
const raw = storage.getItem('kanban.tasks')
expect(raw).not.toBeNull()
expect(JSON.parse(raw ?? '{}').schemaVersion).toBe(SCHEMA_VERSION)
})
it('数据损坏时抛错,并把原始内容备份到 corrupt 键(不覆盖用户数据)', () => {
const storage = createFakeStorage({ 'kanban.tasks': '{broken json' })
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
expect(() => repository.load()).toThrow(DataValidationError)
const backupKeys = Object.keys(storage.data).filter((key) =>
key.startsWith('kanban.tasks.corrupt.'),
)
expect(backupKeys).toHaveLength(1)
expect(storage.data[backupKeys[0] ?? '']).toBe('{broken json')
// 原键未被清空,用户仍有机会手工恢复
expect(storage.getItem('kanban.tasks')).toBe('{broken json')
})
it('写入失败时抛出 StorageWriteError', () => {
const storage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
removeItem: () => undefined,
}
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
expect(() => repository.save([makeTask()])).toThrow(StorageWriteError)
})
it('使用默认键名 kanban.tasks', () => {
const storage = createFakeStorage()
const repository = createLocalStorageTaskRepository(storage)
repository.save([makeTask()])
expect(storage.getItem('kanban.tasks')).toBe(serializeState([makeTask()]))
})
})
describe('createMemoryTaskRepository', () => {
it('初始为空', () => {
expect(createMemoryTaskRepository().load()).toEqual([])
})
it('接受初始数据并返回副本(外部修改不影响内部)', () => {
const initial = [makeTask()]
const repository = createMemoryTaskRepository(initial)
const loaded = repository.load()
loaded.push(makeTask({ id: 'task-2' }))
expect(repository.load()).toHaveLength(1)
})
it('保存后可以读回', () => {
const repository = createMemoryTaskRepository()
const tasks = [makeTask(), makeTask({ id: 'task-2' })]
repository.save(tasks)
expect(repository.load()).toEqual(tasks)
})
})
+146
View File
@@ -0,0 +1,146 @@
/**
* 任务持久化端口(T04 / T14)
*
* 设计(见 docs/adr.md ADR-002):
* - 接口与实现分离,Store 只依赖 TaskRepository,不感知 localStorage
* - 采用「整份快照读写」而非细粒度 CRUD:数据量小(约 60KB),
* 整份写入不会出现部分写入的不一致状态,且排序/导入/跨标签页同步
* 本质上都是快照操作
* - 快照(snapshot)是同一端口上的附加能力:自动备份与恢复走这里,
* 调用方无需关心底层是 localStorage 还是内存
*/
import type { Task } from '@/types/task'
import { DataValidationError, STORAGE_KEY, parsePersistedState, serializeState } from './schema'
import {
MAX_SNAPSHOTS,
listSnapshots,
readSnapshot,
writeSnapshot,
type SnapshotInfo,
} from './snapshot'
import { resolveBrowserStorage, type StorageLike } from './storage'
export type { StorageLike } from './storage'
export { resolveBrowserStorage } from './storage'
export type { SnapshotInfo } from './snapshot'
export interface TaskRepository {
/** 读取全部任务;数据为空时返回空数组 */
load(): Task[]
/** 覆盖写入全部任务 */
save(tasks: readonly Task[]): void
/** 写入一份快照并保留最近若干份;失败返回 null(不影响主流程) */
snapshot(tasks: readonly Task[]): SnapshotInfo | null
/** 列出可用快照,按时间倒序 */
snapshots(): SnapshotInfo[]
/** 从指定快照恢复任务 */
restoreSnapshot(key: string): Task[]
}
export class StorageUnavailableError extends Error {
constructor(message: string) {
super(message)
this.name = 'StorageUnavailableError'
}
}
export class StorageWriteError extends Error {
constructor(message: string) {
super(message)
this.name = 'StorageWriteError'
}
}
/**
* localStorage 实现。
* 读取时若数据损坏,会先把原始内容备份到 `<key>.corrupt.<时间戳>` 再抛错,
* 避免后续写入把用户原本可能恢复的数据覆盖掉。
*/
export function createLocalStorageTaskRepository(
storage: StorageLike | null = resolveBrowserStorage(),
key: string = STORAGE_KEY,
): TaskRepository {
const target = storage
if (!target) {
throw new StorageUnavailableError('浏览器本地存储不可用(可能处于隐私模式或已被浏览器禁用)')
}
const backupCorrupt = (raw: string): string | null => {
try {
const backupKey = `${key}.corrupt.${Date.now()}`
target.setItem(backupKey, raw)
return backupKey
} catch {
return null
}
}
return {
load() {
const raw = target.getItem(key)
try {
return parsePersistedState(raw).tasks
} catch (error) {
if (raw !== null && error instanceof DataValidationError) {
const backupKey = backupCorrupt(raw)
const suffix = backupKey ? `,原数据已备份到 ${backupKey}` : ''
throw new DataValidationError(`${error.message}${suffix}`)
}
throw error
}
},
save(tasks) {
try {
target.setItem(key, serializeState(tasks))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new StorageWriteError(`写入本地存储失败:${message}`)
}
},
snapshot: (tasks) => writeSnapshot(target, tasks),
snapshots: () => listSnapshots(target),
restoreSnapshot: (snapshotKey) => readSnapshot(target, snapshotKey),
}
}
interface MemorySnapshot {
key: string
savedAt: string
tasks: Task[]
}
/** 内存实现:用于测试,以及 localStorage 不可用时的降级(此时数据不会被保存) */
export function createMemoryTaskRepository(initial: readonly Task[] = []): TaskRepository {
let cache: Task[] = [...initial]
let snapshots: MemorySnapshot[] = []
return {
load: () => [...cache],
save: (tasks) => {
cache = [...tasks]
},
snapshot(tasks) {
const savedAt = new Date().toISOString()
const key = `memory-snapshot:${savedAt}`
snapshots = [
{ key, savedAt, tasks: [...tasks] },
...snapshots.filter((item) => item.key !== key),
].slice(0, MAX_SNAPSHOTS)
return { key, savedAt }
},
snapshots: () => snapshots.map(({ key, savedAt }) => ({ key, savedAt })),
restoreSnapshot(key) {
const found = snapshots.find((item) => item.key === key)
if (!found) throw new DataValidationError(`快照不存在:${key}`)
return [...found.tasks]
},
}
}
+8
View File
@@ -0,0 +1,8 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import { createAppRouter } from './router'
import './style.css'
createApp(App).use(createPinia()).use(createAppRouter()).mount('#app')
+34
View File
@@ -0,0 +1,34 @@
/**
* 路由(见 docs/adr.md ADR-005)
*
* ADR-004 当初决定不引入路由,并注明「若未来增加今日视图、统计等真正的独立页面,
* 再引入路由」。现在正是那个时刻:今日视图是与看板并列的独立视图,
* 而且 PWA 的 manifest shortcuts 需要能深链到 /today。
*
* 导出工厂函数而非单例,便于测试注入 memory history。
*/
import type { RouterHistory } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import BoardView from '@/views/BoardView.vue'
export const routes = [
{
path: '/',
name: 'board',
component: BoardView,
meta: { title: '看板' },
},
{
path: '/today',
name: 'today',
component: () => import('@/views/TodayView.vue'),
meta: { title: '今日' },
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
export function createAppRouter(history: RouterHistory = createWebHistory()): ReturnType<typeof createRouter> {
return createRouter({ history, routes })
}
+142
View File
@@ -0,0 +1,142 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { KEYWORD_DEBOUNCE_MS, useFilterStore } from './filters'
function setup() {
setActivePinia(createPinia())
return useFilterStore()
}
describe('筛选条件切换', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('初始为空且未激活', () => {
const store = setup()
expect(store.active).toBe(false)
expect(store.priorities).toEqual([])
expect(store.tags).toEqual([])
expect(store.due).toBeNull()
})
it('优先级可反复切换', () => {
const store = setup()
store.togglePriority(3)
expect(store.priorities).toEqual([3])
store.togglePriority(1)
expect(store.priorities).toEqual([3, 1])
store.togglePriority(3)
expect(store.priorities).toEqual([1])
})
it('标签可反复切换', () => {
const store = setup()
store.toggleTag('设计')
expect(store.tags).toEqual(['设计'])
store.toggleTag('设计')
expect(store.tags).toEqual([])
})
it('截止日期条件再次点击即取消', () => {
const store = setup()
store.toggleDue('overdue')
expect(store.due).toBe('overdue')
store.toggleDue('overdue')
expect(store.due).toBeNull()
})
it('切换条件后 active 变为 true', () => {
const store = setup()
store.togglePriority(3)
expect(store.active).toBe(true)
})
})
describe('关键词防抖', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('防抖时间到之前不参与筛选', async () => {
const store = setup()
store.keyword = '方案'
await nextTick()
expect(store.filter.keyword).toBe('')
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
expect(store.filter.keyword).toBe('方案')
})
it('连续输入只保留最后一次', async () => {
const store = setup()
store.keyword = '方'
await nextTick()
vi.advanceTimersByTime(100)
store.keyword = '方案'
await nextTick()
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
expect(store.filter.keyword).toBe('方案')
})
it('active 使用即时关键词,输入后立刻为真(拖拽保护不留空窗)', async () => {
const store = setup()
store.keyword = '方'
await nextTick()
expect(store.filter.keyword).toBe('')
expect(store.active).toBe(true)
})
})
describe('清空筛选', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('立即清空全部条件(不等待防抖)', async () => {
const store = setup()
store.keyword = '方案'
await nextTick()
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
store.togglePriority(3)
store.toggleTag('设计')
store.toggleDue('today')
store.clear()
expect(store.keyword).toBe('')
expect(store.filter.keyword).toBe('')
expect(store.filter.priorities).toEqual([])
expect(store.filter.tags).toEqual([])
expect(store.filter.due).toBeNull()
expect(store.active).toBe(false)
})
it('清空后残留的防抖回调不会把关键词写回来', async () => {
const store = setup()
store.keyword = '方案'
await nextTick()
store.clear()
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
expect(store.filter.keyword).toBe('')
})
})
+103
View File
@@ -0,0 +1,103 @@
/**
* 筛选状态(T15 / T16)
*
* 关键词做防抖:每次按键都会重算分组并重渲染整块看板,
* 防抖可以避免连续输入时的重复渲染。
*
* 注意 active 用的是「即时关键词」而非防抖后的值 —— 这样拖拽保护
* 会在用户刚开始输入时就生效,不留空窗期。
*/
import { useDebounceFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import type { Priority } from '@/types/task'
import { isFilterActive, type DueFilter, type TaskFilter } from '@/utils/filter'
export const KEYWORD_DEBOUNCE_MS = 200
export const useFilterStore = defineStore('filters', () => {
/** 输入框中的即时关键词 */
const keyword = ref('')
/** 实际参与筛选的关键词(防抖后) */
const debouncedKeyword = ref('')
const priorities = ref<Priority[]>([])
const tags = ref<string[]>([])
const due = ref<DueFilter | null>(null)
/** 是否显示已归档任务(视图开关,不算筛选条件) */
const includeArchived = ref(false)
const applyKeyword = useDebounceFn((value: string) => {
// 清空筛选后可能仍有在途的防抖回调,此时以当前 keyword 为准,丢弃过期值
if (value !== keyword.value) return
debouncedKeyword.value = value
}, KEYWORD_DEBOUNCE_MS)
watch(keyword, (value) => applyKeyword(value))
/** 实际参与筛选的条件 */
const filter = computed<TaskFilter>(() => ({
keyword: debouncedKeyword.value,
priorities: priorities.value,
tags: tags.value,
due: due.value,
includeArchived: includeArchived.value,
}))
/** 是否处于筛选态(拖拽保护依据) */
const active = computed(() =>
isFilterActive({
keyword: keyword.value,
priorities: priorities.value,
tags: tags.value,
due: due.value,
includeArchived: includeArchived.value,
}),
)
function togglePriority(value: Priority): void {
priorities.value = priorities.value.includes(value)
? priorities.value.filter((item) => item !== value)
: [...priorities.value, value]
}
function toggleTag(value: string): void {
tags.value = tags.value.includes(value)
? tags.value.filter((item) => item !== value)
: [...tags.value, value]
}
/** 再次点击同一条件即取消 */
function toggleDue(value: DueFilter): void {
due.value = due.value === value ? null : value
}
function toggleArchived(): void {
includeArchived.value = !includeArchived.value
}
function clear(): void {
keyword.value = ''
debouncedKeyword.value = ''
priorities.value = []
tags.value = []
due.value = null
}
return {
keyword,
debouncedKeyword,
priorities,
tags,
due,
includeArchived,
filter,
active,
togglePriority,
toggleTag,
toggleDue,
toggleArchived,
clear,
}
})
+446
View File
@@ -0,0 +1,446 @@
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
import { useTaskStore } from './tasks'
/**
* 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。
* crossTab 关闭,避免测试环境创建 BroadcastChannel。
*/
function freshStore(repository: TaskRepository = createMemoryTaskRepository()) {
setActivePinia(createPinia())
const store = useTaskStore()
store.init({ repository, crossTab: false })
return store
}
function titles(tasks: readonly { title: string }[]): string[] {
return tasks.map((task) => task.title)
}
describe('初始化', () => {
it('从仓库加载已有数据', () => {
const repository = createMemoryTaskRepository([
{
id: 'existing',
title: '已有任务',
description: '',
status: 'doing',
priority: 3,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
},
])
const store = freshStore(repository)
expect(store.ready).toBe(true)
expect(store.tasks).toHaveLength(1)
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已有任务'])
})
it('counts 反映各列数量', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.createTask({ title: 'B', status: 'doing' })
store.createTask({ title: 'C', status: 'done' })
expect(store.counts).toEqual({ todo: 1, doing: 1, done: 1 })
})
it('重复调用 init 不会清空数据', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.init({ crossTab: false })
expect(store.tasks).toHaveLength(1)
})
})
describe('快速新增', () => {
it('新任务落到待办列首', () => {
const store = freshStore()
store.createTask({ title: '先建的' })
store.createTask({ title: '后建的' })
expect(titles(store.byColumn.todo)).toEqual(['后建的', '先建的'])
})
it('空标题或纯空白不创建任务', () => {
const store = freshStore()
expect(store.createTask({ title: ' ' })).toBeNull()
expect(store.createTask({ title: '' })).toBeNull()
expect(store.tasks).toHaveLength(0)
})
it('标题首尾空白被去除', () => {
const store = freshStore()
const task = store.createTask({ title: ' 写方案 ' })
expect(task?.title).toBe('写方案')
})
it('标签去重并去除空白项', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', tags: ['设计', ' 设计 ', '', '前端'] })
expect(task?.tags).toEqual(['设计', '前端'])
})
it('直接建到已完成列时写入 completedAt', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'done' })
expect(task?.completedAt).not.toBeNull()
})
it('默认优先级为中', () => {
const store = freshStore()
expect(store.createTask({ title: 'A' })?.priority).toBe(2)
})
})
describe('编辑', () => {
it('修改标题并刷新 updatedAt', async () => {
const store = freshStore()
const task = store.createTask({ title: '旧标题' })
const before = task?.updatedAt ?? ''
await new Promise((resolve) => setTimeout(resolve, 2))
const updated = store.updateTask(task?.id ?? '', { title: '新标题' })
expect(updated?.title).toBe('新标题')
expect(updated?.updatedAt).not.toBe(before)
})
it('标题被清空时保留原标题', () => {
const store = freshStore()
const task = store.createTask({ title: '原标题' })
expect(store.updateTask(task?.id ?? '', { title: ' ' })?.title).toBe('原标题')
})
it('移到已完成写入 completedAt,移出时清空', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
const id = task?.id ?? ''
store.updateTask(id, { status: 'done' })
expect(store.tasks.find((item) => item.id === id)?.completedAt).not.toBeNull()
store.updateTask(id, { status: 'todo' })
expect(store.tasks.find((item) => item.id === id)?.completedAt).toBeNull()
})
it('状态变更后落到目标列末尾', () => {
const store = freshStore()
const first = store.createTask({ title: '已在进行中', status: 'doing' })
const moved = store.createTask({ title: '从待办移入' })
store.updateTask(moved?.id ?? '', { status: 'doing' })
expect(titles(store.byColumn.doing)).toEqual(['已在进行中', '从待办移入'])
expect(first?.status).toBe('doing')
})
it('对不存在的 id 返回 null', () => {
const store = freshStore()
expect(store.updateTask('missing', { title: 'x' })).toBeNull()
})
})
describe('删除与撤销', () => {
it('删除后返回被删任务', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
expect(store.removeTask(task?.id ?? '')?.title).toBe('A')
expect(store.tasks).toHaveLength(0)
})
it('撤销后连原顺序一起恢复', () => {
const store = freshStore()
store.createTask({ title: 'A' })
const middle = store.createTask({ title: 'B' })
store.createTask({ title: 'C' })
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
const removed = store.removeTask(middle?.id ?? '')
expect(titles(store.byColumn.todo)).toEqual(['C', 'A'])
store.restoreTask(removed!)
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
})
it('重复撤销不会产生重复任务', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
const removed = store.removeTask(task?.id ?? '')
store.restoreTask(removed!)
store.restoreTask(removed!)
expect(store.tasks).toHaveLength(1)
})
})
describe('拖拽移动', () => {
it('跨列拖动更新 status 与 completedAt', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
store.moveTask(task?.id ?? '', 'done', 0)
const moved = store.tasks.find((item) => item.id === task?.id)
expect(moved?.status).toBe('done')
expect(moved?.completedAt).not.toBeNull()
expect(titles(store.byColumn.done)).toEqual(['A'])
expect(store.byColumn.todo).toHaveLength(0)
})
it('从已完成拖回待办时清空 completedAt', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'done' })
store.moveTask(task?.id ?? '', 'todo', 0)
expect(store.tasks.find((item) => item.id === task?.id)?.completedAt).toBeNull()
})
it('跨列拖动到指定下标', () => {
const store = freshStore()
store.createTask({ title: '目标列第一', status: 'doing' })
store.createTask({ title: '目标列第二', status: 'doing' })
const incoming = store.createTask({ title: '移入' })
// 快速新增是「插入列首」,因此目标列当前顺序为:目标列第二, 目标列第一
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '目标列第一'])
store.moveTask(incoming?.id ?? '', 'doing', 1)
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '移入', '目标列第一'])
})
it('列内拖动改变顺序', () => {
const store = freshStore()
const a = store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
store.createTask({ title: 'C' })
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
store.moveTask(a?.id ?? '', 'todo', 0)
expect(titles(store.byColumn.todo)).toEqual(['A', 'C', 'B'])
})
it('列内拖动后重建 store 顺序仍保持(刷新不丢)', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
const a = first.createTask({ title: 'A' })
first.createTask({ title: 'B' })
first.createTask({ title: 'C' })
first.moveTask(a?.id ?? '', 'todo', 0)
const second = freshStore(repository)
expect(titles(second.byColumn.todo)).toEqual(['A', 'C', 'B'])
})
it('跨列拖动后重建 store,状态与位置都保持', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
const task = first.createTask({ title: 'A' })
first.moveTask(task?.id ?? '', 'doing', 0)
const second = freshStore(repository)
expect(second.byColumn.doing.map((item) => item.title)).toEqual(['A'])
expect(second.byColumn.todo).toHaveLength(0)
})
it('对不存在的 id 无副作用', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.moveTask('missing', 'done', 0)
expect(titles(store.byColumn.todo)).toEqual(['A'])
})
})
describe('持久化', () => {
it('新增后重建 store 能读回(刷新不丢)', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
first.createTask({ title: '持久化任务' })
const second = freshStore(repository)
expect(titles(second.tasks)).toEqual(['持久化任务'])
})
it('删除后重建 store 不会复活任务', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
const task = first.createTask({ title: 'A' })
first.removeTask(task?.id ?? '')
const second = freshStore(repository)
expect(second.tasks).toHaveLength(0)
})
})
describe('导出与导入', () => {
it('导出后再导入,数据一致', () => {
const store = freshStore()
store.createTask({ title: 'A', priority: 3, tags: ['x', 'x', 'y'] })
store.createTask({ title: 'B', status: 'doing' })
const json = store.exportJson()
const other = freshStore()
expect(other.importJson(json)).toBe(2)
expect(titles(other.tasks).sort()).toEqual(['A', 'B'])
expect(other.tasks.find((task) => task.title === 'A')?.tags).toEqual(['x', 'y'])
expect(other.tasks.find((task) => task.title === 'B')?.status).toBe('doing')
})
it('导入非法数据时抛错且现有数据不变', () => {
const store = freshStore()
store.createTask({ title: '保留我' })
expect(() => store.importJson('{"tasks":[{"id":"x"}]}')).toThrow()
expect(titles(store.tasks)).toEqual(['保留我'])
expect(() => store.importJson('不是 JSON')).toThrow()
expect(titles(store.tasks)).toEqual(['保留我'])
})
it('导入为覆盖式', () => {
const store = freshStore()
store.createTask({ title: '旧数据' })
const payload = JSON.stringify({
schemaVersion: 1,
tasks: [
{
id: 'new',
title: '新数据',
status: 'todo',
priority: 2,
sortOrder: 1024,
},
],
})
store.importJson(payload)
expect(titles(store.tasks)).toEqual(['新数据'])
})
})
describe('归档', () => {
it('新建任务默认未归档', () => {
const store = freshStore()
expect(store.createTask({ title: 'A' })?.archivedAt).toBeNull()
})
it('归档写入 archivedAt 并返回数量', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'done' })
expect(store.archiveTasks([task?.id ?? ''])).toBe(1)
expect(store.tasks[0]?.archivedAt).not.toBeNull()
})
it('重复归档同一任务不重复计数', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'done' })
const id = task?.id ?? ''
expect(store.archiveTasks([id])).toBe(1)
expect(store.archiveTasks([id])).toBe(0)
})
it('对不存在的 id 无副作用', () => {
const store = freshStore()
store.createTask({ title: 'A' })
expect(store.archiveTasks(['missing'])).toBe(0)
expect(store.tasks[0]?.archivedAt).toBeNull()
})
it('取消归档清空 archivedAt', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'done' })
const id = task?.id ?? ''
store.archiveTasks([id])
expect(store.unarchiveTasks([id])).toBe(1)
expect(store.tasks[0]?.archivedAt).toBeNull()
})
it('取消未归档的任务不计数', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
expect(store.unarchiveTasks([task?.id ?? ''])).toBe(0)
})
it('归档状态在重建 store 后保留(刷新不丢)', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
const task = first.createTask({ title: 'A', status: 'done' })
first.archiveTasks([task?.id ?? ''])
const second = freshStore(repository)
expect(second.tasks[0]?.archivedAt).not.toBeNull()
})
it('批量归档返回实际归档数量', () => {
const store = freshStore()
const a = store.createTask({ title: 'A', status: 'done' })
const b = store.createTask({ title: 'B', status: 'done' })
const c = store.createTask({ title: 'C' })
const count = store.archiveTasks([a?.id ?? '', b?.id ?? '', c?.id ?? ''])
expect(count).toBe(3)
})
it('归档不影响任务内容本身', () => {
const store = freshStore()
const task = store.createTask({ title: '保留标题', description: '保留描述', status: 'done' })
store.archiveTasks([task?.id ?? ''])
expect(store.tasks[0]?.title).toBe('保留标题')
expect(store.tasks[0]?.description).toBe('保留描述')
expect(store.tasks[0]?.status).toBe('done')
})
})
describe('规模与稳定性', () => {
it('200 个任务时新增、拖动、归档后数据仍自洽', () => {
const store = freshStore()
for (let index = 0; index < 200; index += 1) {
store.createTask({ title: `任务 ${index}` })
}
expect(store.byColumn.todo).toHaveLength(200)
// 反复把最后一张卡片拖到列首,验证下标换算与精度保护不退化
for (let round = 0; round < 50; round += 1) {
const last = store.byColumn.todo[199]
if (!last) break
store.moveTask(last.id, 'todo', 0)
}
const column = store.byColumn.todo
expect(column).toHaveLength(200)
expect(new Set(column.map((task) => task.id)).size).toBe(200)
const orders = column.map((task) => task.sortOrder)
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
for (let index = 1; index < orders.length; index += 1) {
const previous = orders[index - 1] ?? 0
const current = orders[index] ?? 0
expect(current - previous).toBeGreaterThan(0)
}
})
it('批量跨列移动后各列数量守恒', () => {
const store = freshStore()
const ids: string[] = []
for (let index = 0; index < 60; index += 1) {
const task = store.createTask({ title: `任务 ${index}` })
if (task) ids.push(task.id)
}
for (const [index, id] of ids.entries()) {
store.moveTask(id, index % 2 === 0 ? 'doing' : 'done', 0)
}
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.doing).toHaveLength(30)
expect(store.byColumn.done).toHaveLength(30)
expect(store.tasks).toHaveLength(60)
})
})
+409
View File
@@ -0,0 +1,409 @@
/**
* 任务 Store(T06)
*
* 职责边界:
* - Store 是内存态的唯一来源,所有读写都经由 TaskRepository
* - 每次变更后 persist + broadcast,保证刷新不丢、多标签页一致
* - 纯计算(排序、分组)委托给 @/utils/sortOrder,便于单独测试
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { parseImportJson, serializeState } from '@/data/schema'
import {
createLocalStorageTaskRepository,
createMemoryTaskRepository,
type SnapshotInfo,
type TaskRepository,
} from '@/data/taskRepository'
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
import { createId } from '@/utils/id'
import { groupByColumn, moveWithinColumn, sortByOrder, sortOrderForPosition } from '@/utils/sortOrder'
/** 跨标签页同步使用的频道名 */
export const SYNC_CHANNEL_NAME = 'kanban.sync'
export interface CreateTaskInput {
title: string
description?: string
priority?: Priority
tags?: string[]
dueDate?: string | null
status?: ColumnId
}
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
export interface InitOptions {
/** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */
repository?: TaskRepository
/** 是否启用跨标签页同步,测试中应关闭 */
crossTab?: boolean
}
interface SyncMessage {
type: 'tasks-changed'
}
function isSyncMessage(value: unknown): value is SyncMessage {
return (
typeof value === 'object' &&
value !== null &&
(value as { type?: unknown }).type === 'tasks-changed'
)
}
function normalizeTags(tags: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const tag of tags) {
const value = tag.trim()
if (!value || seen.has(value)) continue
seen.add(value)
result.push(value)
}
return result
}
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const ready = ref(false)
/** 数据是否真正落盘;false 表示当前处于内存降级模式 */
const persistent = ref(false)
/** 需要向用户展示的存储异常提示 */
const notice = ref<string | null>(null)
/** 可用的启动快照,按时间倒序 */
const snapshots = ref<SnapshotInfo[]>([])
let repository: TaskRepository | null = null
let channel: BroadcastChannel | null = null
const byColumn = computed(() => groupByColumn(tasks.value))
const counts = computed(() => {
const result = {} as Record<ColumnId, number>
for (const columnId of Object.keys(byColumn.value) as ColumnId[]) {
result[columnId] = byColumn.value[columnId].length
}
return result
})
function persist(): void {
if (!repository) return
try {
repository.save(tasks.value)
} catch (error) {
notice.value = error instanceof Error ? error.message : '保存失败'
}
}
function broadcastChange(): void {
channel?.postMessage({ type: 'tasks-changed' } satisfies SyncMessage)
}
function commit(): void {
persist()
broadcastChange()
}
function reloadFromRepository(): void {
if (!repository) return
try {
tasks.value = sortByOrder(repository.load())
} catch {
// 另一标签页可能正在写入,读取失败时保留当前内存态即可
}
}
function refreshSnapshots(): void {
if (!repository) return
try {
snapshots.value = repository.snapshots()
} catch {
snapshots.value = []
}
}
/**
* 启动时留一份快照。
*
* 注意:即使看板为空也要刷新快照列表 —— 数据刚丢失时正是最需要
* 看到历史备份的时刻,此时若因为「空看板不写快照」而连带跳过刷新,
* 用户就彻底没有恢复入口了。
*/
function writeStartupSnapshot(): void {
if (!repository) return
if (persistent.value && tasks.value.length > 0) {
try {
repository.snapshot(tasks.value)
} catch {
// 备份失败不应影响正常使用
}
}
refreshSnapshots()
}
/** 从快照恢复;返回实际恢复的任务数 */
function restoreSnapshot(key: string): number {
if (!repository) return 0
const restored = repository.restoreSnapshot(key)
if (restored.length === 0) return 0
tasks.value = sortByOrder(restored)
commit()
return restored.length
}
function setupChannel(): void {
if (channel || typeof BroadcastChannel === 'undefined') return
channel = new BroadcastChannel(SYNC_CHANNEL_NAME)
channel.addEventListener('message', (event: MessageEvent) => {
if (isSyncMessage(event.data)) reloadFromRepository()
})
}
function init(options: InitOptions = {}): void {
if (ready.value) return
if (options.repository) {
repository = options.repository
persistent.value = true
} else {
try {
repository = createLocalStorageTaskRepository()
persistent.value = true
} catch (error) {
repository = createMemoryTaskRepository()
persistent.value = false
notice.value =
error instanceof Error ? error.message : '本地存储不可用,本次改动不会被保存'
}
}
try {
tasks.value = sortByOrder(repository.load())
} catch (error) {
tasks.value = []
notice.value = error instanceof Error ? error.message : '本地数据读取失败'
}
ready.value = true
writeStartupSnapshot()
if (options.crossTab !== false) setupChannel()
}
function createTask(input: CreateTaskInput): Task | null {
const title = input.title.trim()
if (!title) return null
const status = input.status ?? 'todo'
const now = new Date().toISOString()
const task: Task = {
id: createId(),
title,
description: input.description?.trim() ?? '',
status,
priority: input.priority ?? DEFAULT_PRIORITY,
tags: normalizeTags(input.tags ?? []),
dueDate: input.dueDate ?? null,
// 快速新增插入列首
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
createdAt: now,
updatedAt: now,
completedAt: status === 'done' ? now : null,
archivedAt: null,
}
tasks.value = [...tasks.value, task]
commit()
return task
}
function updateTask(id: string, patch: TaskPatch): Task | null {
const index = tasks.value.findIndex((task) => task.id === id)
const current = tasks.value[index]
if (!current) return null
const now = new Date().toISOString()
const nextStatus = patch.status ?? current.status
let completedAt = current.completedAt
if (patch.completedAt !== undefined) {
completedAt = patch.completedAt
} else if (nextStatus === 'done' && current.status !== 'done') {
completedAt = now
} else if (nextStatus !== 'done' && current.status === 'done') {
completedAt = null
}
// 状态变更且未显式指定顺序时,追加到目标列末尾
let sortOrder = patch.sortOrder ?? current.sortOrder
if (nextStatus !== current.status && patch.sortOrder === undefined) {
const target = groupByColumn(tasks.value)[nextStatus].filter((task) => task.id !== id)
sortOrder = sortOrderForPosition(target, target.length)
}
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
const updated: Task = {
...current,
...patch,
title,
status: nextStatus,
sortOrder,
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
completedAt,
updatedAt: now,
}
const next = [...tasks.value]
next[index] = updated
tasks.value = next
commit()
return updated
}
/** 删除任务,返回被删除的任务本身,供撤销使用(T12) */
function removeTask(id: string): Task | null {
const task = tasks.value.find((item) => item.id === id)
if (!task) return null
tasks.value = tasks.value.filter((item) => item.id !== id)
commit()
return task
}
/** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */
function restoreTask(task: Task): void {
if (tasks.value.some((item) => item.id === task.id)) return
tasks.value = [...tasks.value, task]
commit()
}
/**
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
*/
function moveTask(id: string, toStatus: ColumnId, toIndex: number): void {
const task = tasks.value.find((item) => item.id === id)
if (!task) return
const now = new Date().toISOString()
if (task.status === toStatus) {
const column = groupByColumn(tasks.value)[toStatus]
const fromIndex = column.findIndex((item) => item.id === id)
if (fromIndex === -1) return
const moved = moveWithinColumn(column, fromIndex, toIndex)
const byId = new Map(moved.map((item) => [item.id, item]))
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
} else {
const target = groupByColumn(tasks.value)[toStatus].filter((item) => item.id !== id)
const sortOrder = sortOrderForPosition(target, toIndex)
tasks.value = tasks.value.map((item) =>
item.id === id
? {
...item,
status: toStatus,
sortOrder,
completedAt: toStatus === 'done' ? now : null,
updatedAt: now,
}
: item,
)
}
commit()
}
/** 导出为可读 JSON(T14) */
function exportJson(): string {
return serializeState(tasks.value, true)
}
/**
* 覆盖式导入(T14)。校验失败会抛错,此时现有数据保持不变。
* @returns 成功导入的任务数量
*/
function importJson(raw: string): number {
const imported = parseImportJson(raw)
tasks.value = sortByOrder(imported)
commit()
return imported.length
}
/** 覆盖全部数据(供导入后的自定义处理或测试使用) */
function replaceAll(next: readonly Task[]): void {
tasks.value = sortByOrder(next)
commit()
}
/**
* 归档指定任务(T17)。返回实际归档的数量。
* 归档只改 archivedAt,数据完整保留,可随时撤销。
*/
function archiveTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const now = new Date().toISOString()
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt !== null) return task
count += 1
return { ...task, archivedAt: now, updatedAt: now }
})
if (count > 0) commit()
return count
}
/** 取消归档;返回实际恢复的数量 */
function unarchiveTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const now = new Date().toISOString()
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt === null) return task
count += 1
return { ...task, archivedAt: null, updatedAt: now }
})
if (count > 0) commit()
return count
}
/** 释放跨标签页频道;主要供测试使用 */
function dispose(): void {
channel?.close()
channel = null
repository = null
}
return {
tasks,
ready,
persistent,
notice,
snapshots,
byColumn,
counts,
init,
createTask,
updateTask,
removeTask,
restoreTask,
moveTask,
exportJson,
importJson,
replaceAll,
archiveTasks,
unarchiveTasks,
refreshSnapshots,
restoreSnapshot,
dispose,
}
})
+94
View File
@@ -0,0 +1,94 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_TOAST_DURATION, useToastStore } from './toast'
describe('toast store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('show 添加一条提示并返回 id', () => {
const store = useToastStore()
const id = store.show({ message: '已保存' })
expect(typeof id).toBe('string')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0]?.message).toBe('已保存')
})
it('默认使用默认时长', () => {
const store = useToastStore()
store.show({ message: 'x' })
expect(store.toasts[0]?.duration).toBe(DEFAULT_TOAST_DURATION)
})
it('dismiss 移除指定提示', () => {
const store = useToastStore()
const first = store.show({ message: 'A', duration: 0 })
store.show({ message: 'B', duration: 0 })
store.dismiss(first)
expect(store.toasts.map((toast) => toast.message)).toEqual(['B'])
})
it('到期后自动关闭', () => {
const store = useToastStore()
store.show({ message: '自动关闭', duration: 1000 })
vi.advanceTimersByTime(999)
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(store.toasts).toHaveLength(0)
})
it('duration 为 0 时不自动关闭', () => {
const store = useToastStore()
store.show({ message: '常驻', duration: 0 })
vi.advanceTimersByTime(60_000)
expect(store.toasts).toHaveLength(1)
})
it('runAction 先执行动作再关闭提示', () => {
const store = useToastStore()
const run = vi.fn()
const id = store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
store.runAction(id)
expect(run).toHaveBeenCalledTimes(1)
expect(store.toasts).toHaveLength(0)
})
it('runAction 对不存在的 id 不抛错', () => {
const store = useToastStore()
expect(() => store.runAction('missing')).not.toThrow()
})
it('clear 清空全部提示并取消自动关闭', () => {
const store = useToastStore()
store.show({ message: 'A', duration: 1000 })
store.show({ message: 'B', duration: 1000 })
store.clear()
expect(store.toasts).toHaveLength(0)
vi.advanceTimersByTime(5000)
expect(store.toasts).toHaveLength(0)
})
it('多条提示按加入顺序排列', () => {
const store = useToastStore()
store.show({ message: 'A', duration: 0 })
store.show({ message: 'B', duration: 0 })
expect(store.toasts.map((toast) => toast.message)).toEqual(['A', 'B'])
})
})
+81
View File
@@ -0,0 +1,81 @@
/**
* 轻量提示(Toast)状态(T09)
*
* 支持携带一个动作按钮,用于「删除后撤销」(T12)。
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createId } from '@/utils/id'
export interface ToastAction {
label: string
run: () => void
}
export interface ToastOptions {
message: string
action?: ToastAction
/** 毫秒;0 表示不自动关闭 */
duration?: number
}
export interface ToastItem {
id: string
message: string
action?: ToastAction
duration: number
}
export const DEFAULT_TOAST_DURATION = 5000
export const useToastStore = defineStore('toast', () => {
const toasts = ref<ToastItem[]>([])
const timers = new Map<string, ReturnType<typeof setTimeout>>()
function clearTimer(id: string): void {
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.delete(id)
}
}
function dismiss(id: string): void {
clearTimer(id)
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
function show(options: ToastOptions): string {
const id = createId()
const duration = options.duration ?? DEFAULT_TOAST_DURATION
const item: ToastItem = { id, message: options.message, duration }
if (options.action) item.action = options.action
toasts.value = [...toasts.value, item]
if (duration > 0) {
timers.set(
id,
setTimeout(() => dismiss(id), duration),
)
}
return id
}
/** 执行提示上的动作,并立即关闭该提示 */
function runAction(id: string): void {
const toast = toasts.value.find((item) => item.id === id)
dismiss(id)
toast?.action?.run()
}
function clear(): void {
for (const id of [...timers.keys()]) clearTimer(id)
toasts.value = []
}
return { toasts, show, dismiss, runAction, clear }
})
+16
View File
@@ -0,0 +1,16 @@
@import 'tailwindcss';
@theme {
--font-sans:
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { COLUMNS, COLUMN_IDS, PRIORITY_LABELS, isColumnId, isPriority } from './task'
describe('列定义', () => {
it('固定三列,顺序为 待办 / 进行中 / 已完成', () => {
expect(COLUMNS.map((column) => column.id)).toEqual(['todo', 'doing', 'done'])
})
it('COLUMN_IDS 与 COLUMNS 完全一致', () => {
expect([...COLUMN_IDS]).toEqual(COLUMNS.map((column) => column.id))
})
it('列定义为只读常量,不包含重复 id', () => {
expect(new Set(COLUMN_IDS).size).toBe(COLUMN_IDS.length)
})
})
describe('优先级', () => {
it('三个优先级都有中文标签', () => {
expect(PRIORITY_LABELS[1]).toBe('低')
expect(PRIORITY_LABELS[2]).toBe('中')
expect(PRIORITY_LABELS[3]).toBe('高')
})
it('isPriority 只接受 1/2/3', () => {
expect(isPriority(1)).toBe(true)
expect(isPriority(3)).toBe(true)
expect(isPriority(0)).toBe(false)
expect(isPriority(4)).toBe(false)
expect(isPriority('2')).toBe(false)
expect(isPriority(null)).toBe(false)
})
})
describe('isColumnId', () => {
it('接受合法列 id', () => {
expect(isColumnId('todo')).toBe(true)
expect(isColumnId('done')).toBe(true)
})
it('拒绝非法值', () => {
expect(isColumnId('archived')).toBe(false)
expect(isColumnId(undefined)).toBe(false)
expect(isColumnId(1)).toBe(false)
})
})
+63
View File
@@ -0,0 +1,63 @@
/**
* 领域类型与常量(T03)
*
* 设计约定(见 docs/adr.md):
* - 列固定为三列,不使用可配置列,因此 status 直接用字面量联合类型而非 enum
* - dueDate 仅表示「日期」,格式 YYYY-MM-DD,不含时刻,以此规避时区问题
*/
export const COLUMN_IDS = ['todo', 'doing', 'done'] as const
export type ColumnId = (typeof COLUMN_IDS)[number]
/** 优先级:1 = 低,2 = 中,3 = 高 */
export type Priority = 1 | 2 | 3
export interface Task {
id: string
title: string
description: string
status: ColumnId
priority: Priority
tags: string[]
/** 仅日期,格式 YYYY-MM-DD;无截止日期时为 null */
dueDate: string | null
/** 同列内排序用的浮点数,采用相邻中点插入 */
sortOrder: number
createdAt: string
updatedAt: string
completedAt: string | null
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
archivedAt: string | null
}
export interface ColumnDef {
readonly id: ColumnId
readonly title: string
}
/** 看板列定义,顺序即展示顺序 */
export const COLUMNS: readonly ColumnDef[] = [
{ id: 'todo', title: '待办' },
{ id: 'doing', title: '进行中' },
{ id: 'done', title: '已完成' },
]
export const PRIORITY_LABELS: Readonly<Record<Priority, string>> = {
1: '低',
2: '中',
3: '高',
}
/** 新建任务时的默认值 */
export const DEFAULT_PRIORITY: Priority = 2
/** 判断某个值是否为合法的列 id */
export function isColumnId(value: unknown): value is ColumnId {
return typeof value === 'string' && (COLUMN_IDS as readonly string[]).includes(value)
}
/** 判断某个值是否为合法的优先级 */
export function isPriority(value: unknown): value is Priority {
return value === 1 || value === 2 || value === 3
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { DEFAULT_ARCHIVE_AFTER_DAYS, applyArchiveVisibility, findArchivableTasks } from './archive'
const NOW = new Date('2026-10-18T12:00:00.000Z')
const DAY_MS = 24 * 60 * 60 * 1000
/**
* 用毫秒运算构造「N 毫秒前」的时间点。
* 不能用 dayjs().subtract(7.01, 'day'):dayjs 会把小数天数四舍五入,
* 结果变成整整 7 天前,边界用例就失去意义了。
*/
function ago(ms: number): string {
return new Date(NOW.getTime() - ms).toISOString()
}
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '任务',
description: '',
status: 'done',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: ago(10 * DAY_MS),
archivedAt: null,
...overrides,
}
}
describe('findArchivableTasks', () => {
it('已完成且超过 N 天的任务可归档', () => {
const task = makeTask({ completedAt: ago(10 * DAY_MS) })
expect(findArchivableTasks([task], 7, NOW)).toEqual([task])
})
it('完成时间在 N 天以内的不归档', () => {
expect(findArchivableTasks([makeTask({ completedAt: ago(3 * DAY_MS) })], 7, NOW)).toEqual([])
})
it('未完成的任务不归档', () => {
const task = makeTask({ status: 'doing', completedAt: null })
expect(findArchivableTasks([task], 7, NOW)).toEqual([])
})
it('缺少 completedAt 的已完成任务不归档', () => {
expect(findArchivableTasks([makeTask({ completedAt: null })], 7, NOW)).toEqual([])
})
it('已归档的任务不会重复归档', () => {
const task = makeTask({ completedAt: ago(30 * DAY_MS), archivedAt: ago(DAY_MS) })
expect(findArchivableTasks([task], 7, NOW)).toEqual([])
})
it('days = 0 时所有已完成且未归档的任务都可归档', () => {
const justNow = makeTask({ id: 'just-now', completedAt: ago(12 * 60 * 60 * 1000) })
expect(findArchivableTasks([justNow], 0, NOW)).toEqual([justNow])
})
it('恰好等于阈值时间点的任务不归档(isBefore 是严格早于)', () => {
const exactly = makeTask({ completedAt: ago(7 * DAY_MS) })
expect(findArchivableTasks([exactly], 7, NOW)).toEqual([])
})
it('比阈值早一分钟即可归档', () => {
const slightlyEarlier = makeTask({ completedAt: ago(7 * DAY_MS + 60_000) })
expect(findArchivableTasks([slightlyEarlier], 7, NOW)).toEqual([slightlyEarlier])
})
it('默认阈值为 DEFAULT_ARCHIVE_AFTER_DAYS 天', () => {
const old = makeTask({ id: 'old', completedAt: ago((DEFAULT_ARCHIVE_AFTER_DAYS + 1) * DAY_MS) })
const fresh = makeTask({
id: 'fresh',
completedAt: ago((DEFAULT_ARCHIVE_AFTER_DAYS - 1) * DAY_MS),
})
expect(findArchivableTasks([old, fresh], undefined, NOW).map((task) => task.id)).toEqual(['old'])
})
it('只挑出符合条件的任务,其余原样保留在入参中', () => {
const archivable = makeTask({ id: 'archivable', completedAt: ago(30 * DAY_MS) })
const fresh = makeTask({ id: 'fresh', completedAt: ago(DAY_MS) })
const todo = makeTask({ id: 'todo', status: 'todo', completedAt: null })
const result = findArchivableTasks([archivable, fresh, todo], 7, NOW)
expect(result.map((task) => task.id)).toEqual(['archivable'])
})
it('不修改入参', () => {
const tasks = [makeTask({ completedAt: ago(10 * DAY_MS) })]
findArchivableTasks(tasks, 7, NOW)
expect(tasks).toHaveLength(1)
})
})
describe('applyArchiveVisibility', () => {
const visible = makeTask({ id: 'visible' })
const archived = makeTask({ id: 'archived', archivedAt: ago(DAY_MS) })
it('默认隐藏归档任务', () => {
expect(applyArchiveVisibility([visible, archived], false).map((task) => task.id)).toEqual([
'visible',
])
})
it('开启后显示全部', () => {
expect(applyArchiveVisibility([visible, archived], true).map((task) => task.id)).toEqual([
'visible',
'archived',
])
})
it('返回新数组,不修改入参', () => {
const tasks = [visible, archived]
const result = applyArchiveVisibility(tasks, true)
expect(result).not.toBe(tasks)
expect(tasks).toHaveLength(2)
})
})
+36
View File
@@ -0,0 +1,36 @@
/**
* 已完成任务归档(T17)
*
* 目的:看板的「已完成」列会无限增长,既影响观感也拖慢渲染。
* 把完成很久的任务归档,默认不显示,但数据仍保留、可随时恢复。
*/
import dayjs from 'dayjs'
import type { Task } from '@/types/task'
/** 已完成任务默认保留多少天后可被归档 */
export const DEFAULT_ARCHIVE_AFTER_DAYS = 7
/**
* 找出可归档的任务:已完成、尚未归档、且完成时间早于 N 天前。
* days = 0 表示「所有已完成且未归档的任务」。
*/
export function findArchivableTasks(
tasks: readonly Task[],
days: number = DEFAULT_ARCHIVE_AFTER_DAYS,
now: Date = new Date(),
): Task[] {
const threshold = dayjs(now).subtract(days, 'day')
return tasks.filter((task) => {
if (task.archivedAt !== null) return false
if (task.status !== 'done' || task.completedAt === null) return false
return dayjs(task.completedAt).isBefore(threshold)
})
}
/** 按是否显示归档过滤任务列表 */
export function applyArchiveVisibility(tasks: readonly Task[], includeArchived: boolean): Task[] {
if (includeArchived) return [...tasks]
return tasks.filter((task) => task.archivedAt === null)
}
+69
View File
@@ -0,0 +1,69 @@
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import { formatDueDate, isDueToday, isOverdue, todayIso } from './date'
const today = todayIso()
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
describe('todayIso', () => {
it('返回 YYYY-MM-DD 格式', () => {
expect(todayIso()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('isOverdue', () => {
it('没有截止日期时不算逾期', () => {
expect(isOverdue(null, 'todo')).toBe(false)
})
it('截止日期早于今天且未完成时算逾期', () => {
expect(isOverdue(yesterday, 'todo')).toBe(true)
expect(isOverdue(yesterday, 'doing')).toBe(true)
})
it('已完成的任务不再算逾期', () => {
expect(isOverdue(yesterday, 'done')).toBe(false)
})
it('今天与未来都不算逾期', () => {
expect(isOverdue(today, 'todo')).toBe(false)
expect(isOverdue(tomorrow, 'todo')).toBe(false)
})
})
describe('isDueToday', () => {
it('仅当截止日期为今天且未完成时为真', () => {
expect(isDueToday(today, 'todo')).toBe(true)
expect(isDueToday(today, 'done')).toBe(false)
expect(isDueToday(yesterday, 'todo')).toBe(false)
expect(isDueToday(null, 'todo')).toBe(false)
})
})
describe('formatDueDate', () => {
const base = '2026-10-18'
it('相对日期使用自然语言', () => {
expect(formatDueDate('2026-10-18', base)).toBe('今天')
expect(formatDueDate('2026-10-19', base)).toBe('明天')
expect(formatDueDate('2026-10-20', base)).toBe('后天')
expect(formatDueDate('2026-10-21', base)).toBe('3 天后')
expect(formatDueDate('2026-10-25', base)).toBe('7 天后')
})
it('超过一周显示具体日期', () => {
expect(formatDueDate('2026-10-26', base)).toBe('10月26日')
expect(formatDueDate('2026-12-01', base)).toBe('12月1日')
})
it('过去的日期显示逾期天数', () => {
expect(formatDueDate('2026-10-17', base)).toBe('逾期 1 天')
expect(formatDueDate('2026-10-01', base)).toBe('逾期 17 天')
})
it('跨月计算正确', () => {
expect(formatDueDate('2026-11-01', '2026-10-31')).toBe('明天')
})
})
+50
View File
@@ -0,0 +1,50 @@
/**
* 日期工具(T08 / T16)
*
* 依据 ADR-003,dueDate 只表示「日期」(YYYY-MM-DD),不含时刻与时区信息。
* 因此「是否逾期」直接比较字符串即可,无需任何时区换算。
*/
import dayjs from 'dayjs'
import type { ColumnId } from '@/types/task'
/** 今天的本地日期,格式 YYYY-MM-DD */
export function todayIso(): string {
return dayjs().format('YYYY-MM-DD')
}
/** 逾期:截止日期早于今天,且任务尚未完成。today 参数用于测试注入 */
export function isOverdue(
dueDate: string | null,
status: ColumnId,
today: string = todayIso(),
): boolean {
if (!dueDate || status === 'done') return false
return dueDate < today
}
/** 今天到期:截止日期正是今天,且任务尚未完成。today 参数用于测试注入 */
export function isDueToday(
dueDate: string | null,
status: ColumnId,
today: string = todayIso(),
): boolean {
if (!dueDate || status === 'done') return false
return dueDate === today
}
/**
* 人类可读的截止日期文案。
* today 参数用于测试注入,业务代码不应传入。
*/
export function formatDueDate(dueDate: string, today: string = todayIso()): string {
const diff = dayjs(dueDate).startOf('day').diff(dayjs(today).startOf('day'), 'day')
if (diff < 0) return `逾期 ${Math.abs(diff)} 天`
if (diff === 0) return '今天'
if (diff === 1) return '明天'
if (diff === 2) return '后天'
if (diff <= 7) return `${diff} 天后`
return dayjs(dueDate).format('M月D日')
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { resolveDrop, shouldAllowMove } from './drag'
function makeList(columnId?: string): HTMLElement {
const element = document.createElement('div')
if (columnId !== undefined) element.dataset.columnId = columnId
return element
}
function makeItem(taskId?: string): HTMLElement {
const element = document.createElement('article')
if (taskId !== undefined) element.dataset.taskId = taskId
return element
}
describe('resolveDrop', () => {
it('解析出任务 id、目标列与下标', () => {
const drop = resolveDrop({
item: makeItem('task-1'),
to: makeList('doing'),
newIndex: 2,
})
expect(drop).toEqual({ taskId: 'task-1', toColumn: 'doing', toIndex: 2 })
})
it('下标为 0 是合法值', () => {
expect(resolveDrop({ item: makeItem('t'), to: makeList('todo'), newIndex: 0 })).toEqual({
taskId: 't',
toColumn: 'todo',
toIndex: 0,
})
})
it('缺少 taskId 时返回 null', () => {
expect(resolveDrop({ item: makeItem(), to: makeList('todo'), newIndex: 0 })).toBeNull()
})
it('目标列 id 非法时返回 null', () => {
expect(
resolveDrop({ item: makeItem('t'), to: makeList('archived'), newIndex: 0 }),
).toBeNull()
expect(resolveDrop({ item: makeItem('t'), to: makeList(), newIndex: 0 })).toBeNull()
})
it('下标缺失或非法时返回 null', () => {
const item = makeItem('t')
const to = makeList('todo')
expect(resolveDrop({ item, to })).toBeNull()
expect(resolveDrop({ item, to, newIndex: -1 })).toBeNull()
expect(resolveDrop({ item, to, newIndex: 1.5 })).toBeNull()
})
})
describe('shouldAllowMove', () => {
it('列内为全量渲染时一律允许', () => {
const a = makeList('todo')
const b = makeList('doing')
expect(shouldAllowMove(a, a, false)).toBe(true)
expect(shouldAllowMove(a, b, false)).toBe(true)
})
it('列内为子集时禁止列内排序', () => {
const column = makeList('todo')
expect(shouldAllowMove(column, column, true)).toBe(false)
})
it('列内为子集时仍允许跨列移动', () => {
expect(shouldAllowMove(makeList('todo'), makeList('doing'), true)).toBe(true)
})
it('子集状态下元素信息缺失时保守拒绝', () => {
expect(shouldAllowMove(null, makeList('todo'), true)).toBe(false)
expect(shouldAllowMove(makeList('todo'), undefined, true)).toBe(false)
})
})
+52
View File
@@ -0,0 +1,52 @@
/**
* 拖拽落点解析(T13)
*
* 把 SortableJS 的原始事件收敛为领域概念,并在这里承载一条关键规则:
* **筛选/搜索激活时禁止列内排序**。
*
* 原因:筛选后列内只渲染子集,而 Sortable 给出的 newIndex 是「子集内下标」,
* 直接写回会把全量顺序改坏。此时只允许跨列改状态。
*/
import { isColumnId, type ColumnId } from '@/types/task'
export interface DropDescriptor {
taskId: string
toColumn: ColumnId
toIndex: number
}
export interface DropEventLike {
item: HTMLElement
to: HTMLElement
newIndex?: number | undefined
}
/** 从 onEnd 事件解析出「哪个任务落到了哪一列的哪个位置」;信息不全时返回 null */
export function resolveDrop(event: DropEventLike): DropDescriptor | null {
const taskId = event.item.dataset.taskId
const rawColumn = event.to.dataset.columnId
const index = event.newIndex
if (!taskId || !rawColumn || !isColumnId(rawColumn)) return null
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return null
return { taskId, toColumn: rawColumn, toIndex: index }
}
/**
* 是否允许这次移动。
*
* sortDisabled 表示「当前列渲染的是子集」(存在筛选,或隐藏了归档任务)。
* 此时只允许跨列移动,禁止列内排序 —— 因为 Sortable 给出的 newIndex 是子集内下标,
* 直接写回会把全量顺序改坏。
*/
export function shouldAllowMove(
from: HTMLElement | null | undefined,
to: HTMLElement | null | undefined,
sortDisabled: boolean,
): boolean {
if (!sortDisabled) return true
if (!from || !to) return false
return from !== to
}
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadText, exportFileName } from './exportFile'
const originalCreate = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
const originalRevoke = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
const originalClick = HTMLAnchorElement.prototype.click
function restore(): void {
if (originalCreate) Object.defineProperty(URL, 'createObjectURL', originalCreate)
else Reflect.deleteProperty(URL, 'createObjectURL')
if (originalRevoke) Object.defineProperty(URL, 'revokeObjectURL', originalRevoke)
else Reflect.deleteProperty(URL, 'revokeObjectURL')
HTMLAnchorElement.prototype.click = originalClick
}
afterEach(restore)
describe('exportFileName', () => {
it('包含日期,格式为 kanban-YYYY-MM-DD.json', () => {
expect(exportFileName('2026-10-18')).toBe('kanban-2026-10-18.json')
})
it('默认使用今天', () => {
expect(exportFileName()).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
})
it('不同日期产生不同文件名', () => {
expect(exportFileName('2026-10-18')).not.toBe(exportFileName('2026-10-19'))
})
it('扩展名是 .json', () => {
expect(exportFileName('2026-01-01').endsWith('.json')).toBe(true)
})
})
describe('downloadText', () => {
it('支持 createObjectURL 时创建链接、点击并释放 URL', () => {
const createObjectURL = vi.fn(() => 'blob:mock-url')
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, 'createObjectURL', {
value: createObjectURL,
configurable: true,
writable: true,
})
Object.defineProperty(URL, 'revokeObjectURL', {
value: revokeObjectURL,
configurable: true,
writable: true,
})
const downloaded: string[] = []
HTMLAnchorElement.prototype.click = function click(this: HTMLAnchorElement) {
downloaded.push(this.download)
}
downloadText('{"a":1}', 'kanban-2026-10-18.json')
expect(createObjectURL).toHaveBeenCalledTimes(1)
expect(downloaded).toEqual(['kanban-2026-10-18.json'])
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('点击后不残留链接节点', () => {
Object.defineProperty(URL, 'createObjectURL', {
value: vi.fn(() => 'blob:mock-url'),
configurable: true,
writable: true,
})
Object.defineProperty(URL, 'revokeObjectURL', {
value: vi.fn(),
configurable: true,
writable: true,
})
HTMLAnchorElement.prototype.click = () => undefined
downloadText('{}', 'a.json')
expect(document.querySelectorAll('a[download]')).toHaveLength(0)
})
it('不支持 createObjectURL 时静默跳过(不抛错)', () => {
// 注意:不能 delete —— Node 环境下 URL.createObjectURL 是内置的,删不掉
Object.defineProperty(URL, 'createObjectURL', {
value: undefined,
configurable: true,
writable: true,
})
expect(() => downloadText('{}', 'a.json')).not.toThrow()
})
it('可自定义 MIME 类型', () => {
const createObjectURL = vi.fn((_blob: Blob) => 'blob:mock-url')
Object.defineProperty(URL, 'createObjectURL', {
value: createObjectURL,
configurable: true,
writable: true,
})
Object.defineProperty(URL, 'revokeObjectURL', {
value: vi.fn(),
configurable: true,
writable: true,
})
HTMLAnchorElement.prototype.click = () => undefined
downloadText('a,b', 'a.csv', 'text/csv')
expect(createObjectURL).toHaveBeenCalledTimes(1)
const blob = createObjectURL.mock.calls[0]?.[0]
expect(blob?.type).toBe('text/csv;charset=utf-8')
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* 导出文件工具(T14)
*
* 把「文件名生成」与「触发下载」分开:前者是纯函数可直接测试,
* 后者依赖浏览器 API(jsdom 不实现 URL.createObjectURL)。
*/
import { todayIso } from '@/utils/date'
/** 导出文件名,含日期便于区分多次备份 */
export function exportFileName(today: string = todayIso()): string {
return `kanban-${today}.json`
}
/** 触发浏览器下载;非浏览器环境静默跳过 */
export function downloadText(content: string, fileName: string, mimeType = 'application/json'): void {
if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
+189
View File
@@ -0,0 +1,189 @@
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { EMPTY_FILTER, filterTasks, isFilterActive, matchesFilter, type TaskFilter } from './filter'
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '详细描述',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
function withFilter(overrides: Partial<TaskFilter>): TaskFilter {
return { ...EMPTY_FILTER, ...overrides }
}
const today = dayjs().format('YYYY-MM-DD')
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
describe('isFilterActive', () => {
it('空筛选不算激活', () => {
expect(isFilterActive(EMPTY_FILTER)).toBe(false)
expect(isFilterActive(withFilter({ keyword: ' ' }))).toBe(false)
})
it('任一维度有值即算激活', () => {
expect(isFilterActive(withFilter({ keyword: 'a' }))).toBe(true)
expect(isFilterActive(withFilter({ priorities: [3] }))).toBe(true)
expect(isFilterActive(withFilter({ tags: ['设计'] }))).toBe(true)
expect(isFilterActive(withFilter({ due: 'overdue' }))).toBe(true)
})
})
describe('关键词匹配', () => {
it('匹配标题', () => {
expect(matchesFilter(makeTask({ title: '写方案' }), withFilter({ keyword: '方案' }))).toBe(true)
})
it('匹配描述', () => {
expect(
matchesFilter(makeTask({ description: '需要先调研' }), withFilter({ keyword: '调研' })),
).toBe(true)
})
it('大小写不敏感', () => {
expect(matchesFilter(makeTask({ title: 'Fix Bug' }), withFilter({ keyword: 'fix' }))).toBe(true)
})
it('不匹配标签(标签有独立维度)', () => {
expect(
matchesFilter(makeTask({ tags: ['设计'] }), withFilter({ keyword: '设计' })),
).toBe(false)
})
it('不匹配时返回 false', () => {
expect(matchesFilter(makeTask({ title: '写方案' }), withFilter({ keyword: '做饭' }))).toBe(false)
})
})
describe('优先级与标签匹配', () => {
it('优先级为空时全通过', () => {
expect(matchesFilter(makeTask({ priority: 1 }), EMPTY_FILTER)).toBe(true)
})
it('优先级命中其一即通过', () => {
const filter = withFilter({ priorities: [3, 1] })
expect(matchesFilter(makeTask({ priority: 1 }), filter)).toBe(true)
expect(matchesFilter(makeTask({ priority: 3 }), filter)).toBe(true)
expect(matchesFilter(makeTask({ priority: 2 }), filter)).toBe(false)
})
it('标签命中其一即通过', () => {
const filter = withFilter({ tags: ['设计', '前端'] })
expect(matchesFilter(makeTask({ tags: ['前端'] }), filter)).toBe(true)
expect(matchesFilter(makeTask({ tags: ['后端'] }), filter)).toBe(false)
expect(matchesFilter(makeTask({ tags: [] }), filter)).toBe(false)
})
})
describe('截止日期筛选', () => {
it('今日到期只包含未完成且截止今天的任务', () => {
const filter = withFilter({ due: 'today' })
expect(matchesFilter(makeTask({ dueDate: today }), filter)).toBe(true)
expect(matchesFilter(makeTask({ dueDate: today, status: 'done' }), filter)).toBe(false)
expect(matchesFilter(makeTask({ dueDate: tomorrow }), filter)).toBe(false)
expect(matchesFilter(makeTask({ dueDate: null }), filter)).toBe(false)
})
it('逾期只包含未完成且已过期的任务', () => {
const filter = withFilter({ due: 'overdue' })
expect(matchesFilter(makeTask({ dueDate: yesterday }), filter)).toBe(true)
expect(matchesFilter(makeTask({ dueDate: yesterday, status: 'done' }), filter)).toBe(false)
expect(matchesFilter(makeTask({ dueDate: today }), filter)).toBe(false)
})
})
describe('多条件组合', () => {
it('条件之间是「与」关系', () => {
const filter = withFilter({ keyword: '方案', priorities: [3] })
expect(matchesFilter(makeTask({ title: '写方案', priority: 3 }), filter)).toBe(true)
expect(matchesFilter(makeTask({ title: '写方案', priority: 2 }), filter)).toBe(false)
expect(matchesFilter(makeTask({ title: '做原型', priority: 3 }), filter)).toBe(false)
})
})
describe('filterTasks', () => {
const tasks = [
makeTask({ id: 'a', title: '写方案', priority: 3, tags: ['设计'] }),
makeTask({ id: 'b', title: '做原型', priority: 2, tags: ['设计', '前端'] }),
makeTask({ id: 'c', title: '买咖啡', priority: 1, tags: [] }),
]
it('无筛选时返回全部(且是新数组)', () => {
const result = filterTasks(tasks, EMPTY_FILTER)
expect(result).toHaveLength(3)
expect(result).not.toBe(tasks)
})
it('按关键词过滤', () => {
expect(filterTasks(tasks, withFilter({ keyword: '原型' })).map((task) => task.id)).toEqual(['b'])
})
it('按标签过滤', () => {
expect(filterTasks(tasks, withFilter({ tags: ['设计'] })).map((task) => task.id)).toEqual([
'a',
'b',
])
})
it('无结果时返回空数组', () => {
expect(filterTasks(tasks, withFilter({ keyword: '不存在的词' }))).toEqual([])
})
it('不修改入参', () => {
filterTasks(tasks, withFilter({ keyword: '方案' }))
expect(tasks).toHaveLength(3)
})
})
describe('归档可见性', () => {
const visible = makeTask({ id: 'visible' })
const archived = makeTask({ id: 'archived', archivedAt: '2026-10-01T00:00:00.000Z' })
it('默认隐藏归档任务', () => {
expect(filterTasks([visible, archived], EMPTY_FILTER).map((task) => task.id)).toEqual([
'visible',
])
})
it('开启 includeArchived 后显示全部', () => {
expect(
filterTasks([visible, archived], withFilter({ includeArchived: true })).map((task) => task.id),
).toEqual(['visible', 'archived'])
})
it('归档可见性不影响 isFilterActive(它是视图开关,不是筛选条件)', () => {
expect(isFilterActive(withFilter({ includeArchived: true }))).toBe(false)
})
it('归档任务同样受关键词筛选', () => {
const archivedNamed = makeTask({
id: 'archived-named',
title: '归档的方案',
archivedAt: '2026-10-01T00:00:00.000Z',
})
// 默认标题是「写方案」,会命中关键词,这里显式换成不命中的标题
const plain = makeTask({ id: 'plain', title: '普通任务' })
const result = filterTasks(
[plain, archivedNamed],
withFilter({ includeArchived: true, keyword: '方案' }),
)
expect(result.map((task) => task.id)).toEqual(['archived-named'])
})
})
+86
View File
@@ -0,0 +1,86 @@
/**
* 筛选规则(T15 / T16)
*
* 全部为纯函数,便于独立测试。
* 关键词只匹配标题与描述(标签有独立的筛选维度),
* 中文直接用 includes,不引入分词库。
*/
import type { Priority, Task } from '@/types/task'
import { isDueToday, isOverdue } from '@/utils/date'
/** 截止日期维度:今天到期 / 已逾期 */
export type DueFilter = 'today' | 'overdue'
export interface TaskFilter {
keyword: string
priorities: readonly Priority[]
tags: readonly string[]
due: DueFilter | null
/** 是否把已归档任务也纳入看板 */
includeArchived: boolean
}
export const EMPTY_FILTER: TaskFilter = {
keyword: '',
priorities: [],
tags: [],
due: null,
includeArchived: false,
}
function matchesKeyword(task: Task, keyword: string): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return (
task.title.toLowerCase().includes(needle) ||
task.description.toLowerCase().includes(needle)
)
}
function matchesPriority(task: Task, priorities: readonly Priority[]): boolean {
if (priorities.length === 0) return true
return priorities.includes(task.priority)
}
function matchesTags(task: Task, tags: readonly string[]): boolean {
if (tags.length === 0) return true
return tags.some((tag) => task.tags.includes(tag))
}
function matchesDue(task: Task, due: DueFilter | null): boolean {
if (due === null) return true
if (due === 'overdue') return isOverdue(task.dueDate, task.status)
return isDueToday(task.dueDate, task.status)
}
/** 多个条件之间是「与」关系;归档可见性优先判断 */
export function matchesFilter(task: Task, filter: TaskFilter): boolean {
if (!filter.includeArchived && task.archivedAt !== null) return false
return (
matchesKeyword(task, filter.keyword) &&
matchesPriority(task, filter.priorities) &&
matchesTags(task, filter.tags) &&
matchesDue(task, filter.due)
)
}
/**
* 是否处于「筛选态」。
* 刻意不把 includeArchived 计入:它只是视图开关,
* 而这里的结果用于判断「列内渲染的是否为子集」,进而决定要不要禁用列内排序。
* 该判断改由调用方按「隐藏数量 > 0」精确计算,见 BoardView。
*/
export function isFilterActive(filter: TaskFilter): boolean {
return (
filter.keyword.trim() !== '' ||
filter.priorities.length > 0 ||
filter.tags.length > 0 ||
filter.due !== null
)
}
export function filterTasks(tasks: readonly Task[], filter: TaskFilter): Task[] {
return tasks.filter((task) => matchesFilter(task, filter))
}
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createId } from './id'
const originalRandomUUID = Object.getOwnPropertyDescriptor(crypto, 'randomUUID')
afterEach(() => {
if (originalRandomUUID) Object.defineProperty(crypto, 'randomUUID', originalRandomUUID)
})
describe('createId', () => {
it('优先使用 crypto.randomUUID', () => {
Object.defineProperty(crypto, 'randomUUID', {
value: () => 'fixed-uuid',
configurable: true,
writable: true,
})
expect(createId()).toBe('fixed-uuid')
})
it('缺少 crypto.randomUUID 时退回随机 id', () => {
Object.defineProperty(crypto, 'randomUUID', {
value: undefined,
configurable: true,
writable: true,
})
const id = createId()
expect(id).toMatch(/^id-[a-z0-9]+-[a-z0-9]+$/)
})
it('回退实现也能保证唯一性', () => {
Object.defineProperty(crypto, 'randomUUID', {
value: undefined,
configurable: true,
writable: true,
})
const ids = new Set(Array.from({ length: 200 }, () => createId()))
expect(ids.size).toBe(200)
})
})
+7
View File
@@ -0,0 +1,7 @@
/** 生成唯一 id:优先使用浏览器内置的 crypto.randomUUID */
export function createId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
+221
View File
@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import {
MIN_SORT_ORDER_GAP,
SORT_ORDER_STEP,
computeSortOrder,
groupByColumn,
moveWithinColumn,
needsNormalize,
normalizeColumn,
sortByOrder,
sortOrderForPosition,
} from './sortOrder'
let sequence = 0
function makeTask(overrides: Partial<Task> = {}): Task {
sequence += 1
return {
id: `task-${sequence}`,
title: `任务 ${sequence}`,
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: sequence * SORT_ORDER_STEP,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
/** 构造一列任务,sortOrder 依次为 1024, 2048, ... */
function makeColumn(count: number): Task[] {
return Array.from({ length: count }, (_, index) =>
makeTask({ sortOrder: (index + 1) * SORT_ORDER_STEP }),
)
}
function titles(tasks: readonly Task[]): string[] {
return tasks.map((task) => task.title)
}
describe('computeSortOrder', () => {
it('空列为 STEP', () => {
expect(computeSortOrder(null, null)).toBe(SORT_ORDER_STEP)
})
it('插入列首取 next - STEP(线性递减,避免反复折半)', () => {
expect(computeSortOrder(null, 1024)).toBe(0)
expect(computeSortOrder(null, 2048)).toBe(1024)
})
it('插入列尾取 prev + STEP', () => {
expect(computeSortOrder(1024, null)).toBe(2048)
})
it('插入中间取中点', () => {
expect(computeSortOrder(1024, 2048)).toBe(1536)
})
})
describe('sortOrderForPosition', () => {
it('空列任意位置都得到 STEP', () => {
expect(sortOrderForPosition([], 0)).toBe(SORT_ORDER_STEP)
})
it('列首小于原首个元素', () => {
const column = makeColumn(2)
expect(sortOrderForPosition(column, 0)).toBeLessThan(column[0]?.sortOrder ?? 0)
})
it('中间位置严格介于前后之间', () => {
const column = makeColumn(2)
const order = sortOrderForPosition(column, 1)
expect(order).toBeGreaterThan(column[0]?.sortOrder ?? 0)
expect(order).toBeLessThan(column[1]?.sortOrder ?? 0)
})
it('末尾位置大于原末个元素', () => {
const column = makeColumn(2)
expect(sortOrderForPosition(column, 2)).toBeGreaterThan(column[1]?.sortOrder ?? 0)
})
it('越界下标被裁剪到合法范围', () => {
const column = makeColumn(2)
expect(sortOrderForPosition(column, -5)).toBe(sortOrderForPosition(column, 0))
expect(sortOrderForPosition(column, 99)).toBe(sortOrderForPosition(column, 2))
})
})
describe('moveWithinColumn', () => {
it('把首张卡片移到索引 2', () => {
const column = makeColumn(4)
const moved = moveWithinColumn(column, 0, 2)
expect(titles(moved)).toEqual([
column[1]?.title,
column[2]?.title,
column[0]?.title,
column[3]?.title,
])
})
it('把末张卡片移到索引 0', () => {
const column = makeColumn(4)
const moved = moveWithinColumn(column, 3, 0)
expect(titles(moved)).toEqual([
column[3]?.title,
column[0]?.title,
column[1]?.title,
column[2]?.title,
])
})
it('移动后数组仍按 sortOrder 升序', () => {
const moved = moveWithinColumn(makeColumn(5), 1, 3)
const orders = moved.map((task) => task.sortOrder)
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
})
it('不修改入参数组与其中对象', () => {
const column = makeColumn(3)
const snapshot = column.map((task) => task.sortOrder)
moveWithinColumn(column, 0, 2)
expect(column.map((task) => task.sortOrder)).toEqual(snapshot)
})
it('非法 fromIndex 返回原数组副本', () => {
const column = makeColumn(2)
expect(titles(moveWithinColumn(column, 9, 0))).toEqual(titles(column))
})
})
describe('精度保护', () => {
it('needsNormalize 能识别过小间距', () => {
const tight = [
makeTask({ sortOrder: 1024 }),
makeTask({ sortOrder: 1024 + MIN_SORT_ORDER_GAP / 2 }),
]
expect(needsNormalize(tight)).toBe(true)
expect(needsNormalize(makeColumn(5))).toBe(false)
})
it('连续 100 次「插到锚点之后」后间距仍然充足', () => {
let column = makeColumn(1)
let normalizations = 0
for (let round = 0; round < 100; round += 1) {
const order = sortOrderForPosition(column, 1)
const inserted = makeTask({ sortOrder: order })
column = [...column.slice(0, 1), inserted, ...column.slice(1)]
if (needsNormalize(column)) {
column = normalizeColumn(column)
normalizations += 1
}
}
expect(column).toHaveLength(101)
expect(needsNormalize(column)).toBe(false)
for (let index = 1; index < column.length; index += 1) {
const previous = column[index - 1]?.sortOrder ?? 0
const current = column[index]?.sortOrder ?? 0
expect(current - previous).toBeGreaterThanOrEqual(MIN_SORT_ORDER_GAP)
}
// 每约 30 次插入才需要一次 normalize,说明中点插入是有效的
expect(normalizations).toBeGreaterThan(0)
expect(normalizations).toBeLessThan(10)
})
it('normalizeColumn 重新均匀分配且保持相对顺序', () => {
const column = [
makeTask({ title: 'A', sortOrder: 1 }),
makeTask({ title: 'B', sortOrder: 1.0000001 }),
makeTask({ title: 'C', sortOrder: 99 }),
]
const normalized = normalizeColumn(column)
expect(normalized.map((task) => task.sortOrder)).toEqual([
SORT_ORDER_STEP,
SORT_ORDER_STEP * 2,
SORT_ORDER_STEP * 3,
])
expect(titles(normalized)).toEqual(['A', 'B', 'C'])
})
})
describe('groupByColumn', () => {
it('按列分组且每列按 sortOrder 升序', () => {
const tasks = [
makeTask({ title: '待办后', status: 'todo', sortOrder: 2048 }),
makeTask({ title: '进行中', status: 'doing', sortOrder: 1024 }),
makeTask({ title: '待办前', status: 'todo', sortOrder: 1024 }),
]
const grouped = groupByColumn(tasks)
expect(grouped.todo.map((task) => task.title)).toEqual(['待办前', '待办后'])
expect(grouped.doing.map((task) => task.title)).toEqual(['进行中'])
expect(grouped.done).toEqual([])
})
it('空列也会出现在结果中', () => {
const grouped = groupByColumn([])
expect(Object.keys(grouped).sort()).toEqual(['doing', 'done', 'todo'])
})
})
describe('sortByOrder', () => {
it('不修改入参', () => {
const tasks = [
makeTask({ title: 'B', sortOrder: 2048 }),
makeTask({ title: 'A', sortOrder: 1024 }),
]
expect(titles(sortByOrder(tasks))).toEqual(['A', 'B'])
expect(titles(tasks)).toEqual(['B', 'A'])
})
})
+89
View File
@@ -0,0 +1,89 @@
/**
* 同列排序算法(T05)
*
* 采用「相邻中点插入」:在两张卡片之间插入时取两者 sortOrder 的中间值,
* 避免每次插入都重写整列。当中点间距小于 MIN_SORT_ORDER_GAP 时,
* 对该列做一次 normalize(按 SORT_ORDER_STEP 重新均匀分配)。
*/
import { COLUMN_IDS, type ColumnId, type Task } from '@/types/task'
/** normalize 时同列相邻任务的排序间隔 */
export const SORT_ORDER_STEP = 1024
/** 小于该间距即认为浮点精度即将耗尽,需要 normalize */
export const MIN_SORT_ORDER_GAP = 1e-6
/** 计算插入到 previous 与 next 之间应使用的 sortOrder */
export function computeSortOrder(previous: number | null, next: number | null): number {
if (previous === null) {
return next === null ? SORT_ORDER_STEP : next - SORT_ORDER_STEP
}
if (next === null) return previous + SORT_ORDER_STEP
return (previous + next) / 2
}
/**
* 依据已排序的同列任务,计算插入到 index 位置应使用的 sortOrder。
* index 取值范围 [0, sorted.length],等于 length 表示追加到末尾。
*/
export function sortOrderForPosition(sorted: readonly Task[], index: number): number {
const clamped = Math.max(0, Math.min(index, sorted.length))
const previous = clamped > 0 ? (sorted[clamped - 1]?.sortOrder ?? null) : null
const next = clamped < sorted.length ? (sorted[clamped]?.sortOrder ?? null) : null
return computeSortOrder(previous, next)
}
export function compareBySortOrder(a: Task, b: Task): number {
return a.sortOrder - b.sortOrder
}
export function sortByOrder(tasks: readonly Task[]): Task[] {
return [...tasks].sort(compareBySortOrder)
}
/** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */
export function normalizeColumn(sorted: readonly Task[]): Task[] {
return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP }))
}
/** 判断该列是否已出现精度不足的相邻间距 */
export function needsNormalize(sorted: readonly Task[]): boolean {
for (let index = 1; index < sorted.length; index += 1) {
const previous = sorted[index - 1]
const current = sorted[index]
if (!previous || !current) continue
if (current.sortOrder - previous.sortOrder < MIN_SORT_ORDER_GAP) return true
}
return false
}
/**
* 列内移动:把 fromIndex 处的任务移动到 toIndex。
* toIndex 为「移动完成后该任务所在的最终下标」(与拖拽库给出的 newIndex 语义一致)。
* 返回新数组,不修改入参。
*/
export function moveWithinColumn(
sorted: readonly Task[],
fromIndex: number,
toIndex: number,
): Task[] {
const moving = sorted[fromIndex]
if (!moving) return [...sorted]
const rest = sorted.filter((_, index) => index !== fromIndex)
const target = Math.max(0, Math.min(toIndex, rest.length))
const moved: Task = { ...moving, sortOrder: sortOrderForPosition(rest, target) }
const result = [...rest.slice(0, target), moved, ...rest.slice(target)]
return needsNormalize(result) ? normalizeColumn(result) : result
}
/** 按列分组并按 sortOrder 升序排列;空列也会出现,值为空数组 */
export function groupByColumn(tasks: readonly Task[]): Record<ColumnId, Task[]> {
const grouped = {} as Record<ColumnId, Task[]>
for (const columnId of COLUMN_IDS) grouped[columnId] = []
for (const task of tasks) grouped[task.status].push(task)
for (const columnId of COLUMN_IDS) grouped[columnId].sort(compareBySortOrder)
return grouped
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { computeStats, startOfWeekIso } from './stats'
/** 2026-10-18 是周日,因此本周(周一为始)应从 2026-10-12 开始 */
const NOW = new Date(2026, 9, 18, 12, 0, 0)
const TODAY = '2026-10-18'
const WEEK_START = '2026-10-12'
function localIso(year: number, month: number, day: number, hour = 10): string {
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
}
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '任务',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('startOfWeekIso', () => {
it('以周一为一周之始', () => {
expect(startOfWeekIso(NOW)).toBe(WEEK_START)
})
it('周日属于「刚过去的那一周」而不是下一周', () => {
const sunday = new Date(2026, 9, 18, 23, 0, 0)
expect(sunday.getDay()).toBe(0)
expect(startOfWeekIso(sunday)).toBe(WEEK_START)
})
it('周一当天返回自己', () => {
const monday = new Date(2026, 9, 12, 8, 0, 0)
expect(monday.getDay()).toBe(1)
expect(startOfWeekIso(monday)).toBe(WEEK_START)
})
it('跨月也能正确回退', () => {
// 2026-11-01 是周日,本周起始应为 2026-10-26
const sunday = new Date(2026, 10, 1, 12, 0, 0)
expect(sunday.getDay()).toBe(0)
expect(startOfWeekIso(sunday)).toBe('2026-10-26')
})
})
describe('computeStats', () => {
it('统计各列数量与未完成数', () => {
const stats = computeStats(
[
makeTask({ id: 'a', status: 'todo' }),
makeTask({ id: 'b', status: 'doing' }),
makeTask({ id: 'c', status: 'done', completedAt: localIso(2026, 10, 18) }),
],
NOW,
)
expect(stats.total).toBe(3)
expect(stats.byColumn).toEqual({ todo: 1, doing: 1, done: 1 })
expect(stats.active).toBe(2)
})
it('统计逾期与今天到期', () => {
const stats = computeStats(
[
makeTask({ id: 'a', dueDate: '2026-10-01' }),
makeTask({ id: 'b', dueDate: '2026-10-17' }),
makeTask({ id: 'c', dueDate: TODAY }),
makeTask({ id: 'd', dueDate: '2026-12-01' }),
],
NOW,
)
expect(stats.overdue).toBe(2)
expect(stats.dueToday).toBe(1)
})
it('已完成任务不计入逾期与今天到期', () => {
const stats = computeStats(
[
makeTask({ id: 'a', status: 'done', dueDate: '2026-10-01', completedAt: localIso(2026, 10, 18) }),
makeTask({ id: 'b', status: 'done', dueDate: TODAY, completedAt: localIso(2026, 10, 18) }),
],
NOW,
)
expect(stats.overdue).toBe(0)
expect(stats.dueToday).toBe(0)
})
it('本周完成数只统计本周一之后完成的', () => {
const stats = computeStats(
[
makeTask({ id: 'this-week', status: 'done', completedAt: localIso(2026, 10, 12) }),
makeTask({ id: 'sunday', status: 'done', completedAt: localIso(2026, 10, 18) }),
makeTask({ id: 'last-week', status: 'done', completedAt: localIso(2026, 10, 11) }),
makeTask({ id: 'long-ago', status: 'done', completedAt: localIso(2026, 9, 1) }),
],
NOW,
)
expect(stats.completedThisWeek).toBe(2)
})
it('未完成的任务不计入本周完成数', () => {
const stats = computeStats([makeTask({ id: 'a', status: 'todo', completedAt: null })], NOW)
expect(stats.completedThisWeek).toBe(0)
})
it('归档任务仍计入完成数与归档数', () => {
const stats = computeStats(
[
makeTask({
id: 'archived',
status: 'done',
completedAt: localIso(2026, 10, 15),
archivedAt: localIso(2026, 10, 16),
}),
],
NOW,
)
expect(stats.archived).toBe(1)
expect(stats.completedThisWeek).toBe(1)
expect(stats.total).toBe(1)
})
it('空列表返回全零', () => {
const stats = computeStats([], NOW)
expect(stats.total).toBe(0)
expect(stats.active).toBe(0)
expect(stats.overdue).toBe(0)
expect(stats.dueToday).toBe(0)
expect(stats.completedThisWeek).toBe(0)
expect(stats.archived).toBe(0)
expect(stats.byColumn).toEqual({ todo: 0, doing: 0, done: 0 })
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* 简单统计(今日视图顶部卡片)
*
* 全部为纯函数,「当前时间」可注入,便于测试跨周边界的用例。
*/
import dayjs from 'dayjs'
import { COLUMN_IDS, type ColumnId, type Task } from '@/types/task'
import { isDueToday, isOverdue } from '@/utils/date'
export interface TaskStats {
total: number
byColumn: Record<ColumnId, number>
/** 未完成的任务数 */
active: number
overdue: number
dueToday: number
/** 本周(周一为一周之始)完成的任务数 */
completedThisWeek: number
archived: number
}
/**
* 本周起始日(周一)的日期字符串。
* dayjs 的 day() 是 0=周日 … 6=周六,而中文语境习惯以周一为一周之始,
* 因此偏移量为 (day + 6) % 7。
*/
export function startOfWeekIso(now: Date = new Date()): string {
const current = dayjs(now).startOf('day')
const offset = (current.day() + 6) % 7
return current.subtract(offset, 'day').format('YYYY-MM-DD')
}
/** 把时间戳转成本地日期字符串(completedAt 存的是 UTC ISO,直接截取会差一天) */
function localDateOf(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD')
}
export function computeStats(tasks: readonly Task[], now: Date = new Date()): TaskStats {
const byColumn = {} as Record<ColumnId, number>
for (const columnId of COLUMN_IDS) byColumn[columnId] = 0
const today = dayjs(now).format('YYYY-MM-DD')
const weekStart = startOfWeekIso(now)
let overdue = 0
let dueToday = 0
let completedThisWeek = 0
let archived = 0
for (const task of tasks) {
byColumn[task.status] += 1
if (task.archivedAt !== null) archived += 1
if (isOverdue(task.dueDate, task.status, today)) overdue += 1
if (isDueToday(task.dueDate, task.status, today)) dueToday += 1
if (task.completedAt !== null && localDateOf(task.completedAt) >= weekStart) {
completedThisWeek += 1
}
}
return {
total: tasks.length,
byColumn,
active: tasks.length - byColumn.done,
overdue,
dueToday,
completedThisWeek,
archived,
}
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { buildTodayBuckets, countTodayTasks, sortByUrgency } from './today'
const TODAY = '2026-10-18'
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '任务',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
function ids(tasks: readonly Task[]): string[] {
return tasks.map((task) => task.id)
}
describe('buildTodayBuckets 分组', () => {
it('逾期任务进入 overdue', () => {
const task = makeTask({ id: 'a', dueDate: '2026-10-17' })
expect(ids(buildTodayBuckets([task], TODAY).overdue)).toEqual(['a'])
})
it('今天到期的进入 dueToday', () => {
const task = makeTask({ id: 'a', dueDate: TODAY })
expect(ids(buildTodayBuckets([task], TODAY).dueToday)).toEqual(['a'])
})
it('进行中且无更紧急截止日期的进入 inProgress', () => {
const task = makeTask({ id: 'a', status: 'doing' })
expect(ids(buildTodayBuckets([task], TODAY).inProgress)).toEqual(['a'])
})
it('未开始且没有截止日期的任务不出现', () => {
const task = makeTask({ id: 'a', status: 'todo', dueDate: null })
const buckets = buildTodayBuckets([task], TODAY)
expect(countTodayTasks(buckets)).toBe(0)
})
it('未来截止但未开始的任务不出现', () => {
const task = makeTask({ id: 'a', status: 'todo', dueDate: '2026-12-01' })
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
})
it('已完成任务不出现', () => {
const task = makeTask({ id: 'a', status: 'done', dueDate: '2026-10-01', completedAt: null })
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
})
it('已归档任务不出现', () => {
const task = makeTask({ id: 'a', dueDate: '2026-10-01', archivedAt: '2026-10-05T00:00:00.000Z' })
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
})
it('每个任务只归属一组:逾期优先于今天到期,今天到期优先于进行中', () => {
const overdue = makeTask({ id: 'overdue', status: 'doing', dueDate: '2026-10-01' })
const dueToday = makeTask({ id: 'dueToday', status: 'doing', dueDate: TODAY })
const doing = makeTask({ id: 'doing', status: 'doing' })
const buckets = buildTodayBuckets([overdue, dueToday, doing], TODAY)
expect(ids(buckets.overdue)).toEqual(['overdue'])
expect(ids(buckets.dueToday)).toEqual(['dueToday'])
expect(ids(buckets.inProgress)).toEqual(['doing'])
expect(countTodayTasks(buckets)).toBe(3)
})
})
describe('sortByUrgency', () => {
it('优先级高的排前面', () => {
const low = makeTask({ id: 'low', priority: 1 })
const high = makeTask({ id: 'high', priority: 3 })
const medium = makeTask({ id: 'medium', priority: 2 })
expect(ids(sortByUrgency([low, high, medium]))).toEqual(['high', 'medium', 'low'])
})
it('同优先级下截止日期早的排前面', () => {
const later = makeTask({ id: 'later', priority: 2, dueDate: '2026-10-20' })
const earlier = makeTask({ id: 'earlier', priority: 2, dueDate: '2026-10-10' })
expect(ids(sortByUrgency([later, earlier]))).toEqual(['earlier', 'later'])
})
it('没有截止日期的排在有截止日期的后面', () => {
const none = makeTask({ id: 'none', priority: 2, dueDate: null })
const dated = makeTask({ id: 'dated', priority: 2, dueDate: '2026-12-31' })
expect(ids(sortByUrgency([none, dated]))).toEqual(['dated', 'none'])
})
it('条件完全相同时按 sortOrder 稳定排序', () => {
const second = makeTask({ id: 'second', priority: 2, dueDate: TODAY, sortOrder: 2048 })
const first = makeTask({ id: 'first', priority: 2, dueDate: TODAY, sortOrder: 1024 })
expect(ids(sortByUrgency([second, first]))).toEqual(['first', 'second'])
})
it('不修改入参', () => {
const tasks = [makeTask({ id: 'a', priority: 1 }), makeTask({ id: 'b', priority: 3 })]
sortByUrgency(tasks)
expect(ids(tasks)).toEqual(['a', 'b'])
})
})
describe('buildTodayBuckets 排序', () => {
it('各组内部按紧急度排序', () => {
const low = makeTask({ id: 'low', dueDate: '2026-10-01', priority: 1 })
const high = makeTask({ id: 'high', dueDate: '2026-10-02', priority: 3 })
expect(ids(buildTodayBuckets([low, high], TODAY).overdue)).toEqual(['high', 'low'])
})
})
describe('countTodayTasks', () => {
it('统计三组之和', () => {
const tasks = [
makeTask({ id: 'a', dueDate: '2026-10-01' }),
makeTask({ id: 'b', dueDate: TODAY }),
makeTask({ id: 'c', status: 'doing' }),
]
expect(countTodayTasks(buildTodayBuckets(tasks, TODAY))).toBe(3)
})
it('空列表为 0', () => {
expect(countTodayTasks(buildTodayBuckets([], TODAY))).toBe(0)
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* 「今日」视图的分组逻辑
*
* 把「今天该做的事」分成三组,每个任务只归属一组,归属优先级为:
* **逾期 > 今天到期 > 进行中**。
*
* 之所以要保证「只归属一组」,是因为同一张卡片在页面上出现两次
* 会让人怀疑自己看错了;分组之间互斥,页面上就能一眼数清今天有几件事。
*/
import type { Task } from '@/types/task'
import { todayIso } from '@/utils/date'
export interface TodayBuckets {
/** 已过截止日期,最紧急 */
overdue: Task[]
/** 今天到期 */
dueToday: Task[]
/** 正在进行、且没有更紧急的截止日期 */
inProgress: Task[]
}
/**
* 紧急度排序:优先级降序 → 截止日期升序(无截止日期排最后)→ 列内顺序。
* 最后一级保证排序稳定,避免相同条件的卡片每次渲染都换位置。
*/
export function sortByUrgency(tasks: readonly Task[]): Task[] {
return [...tasks].sort((a, b) => {
if (a.priority !== b.priority) return b.priority - a.priority
if (a.dueDate !== b.dueDate) {
if (a.dueDate === null) return 1
if (b.dueDate === null) return -1
return a.dueDate < b.dueDate ? -1 : 1
}
return a.sortOrder - b.sortOrder
})
}
/** 已完成与已归档的任务不进入今日视图 */
function isActionable(task: Task): boolean {
return task.status !== 'done' && task.archivedAt === null
}
export function buildTodayBuckets(tasks: readonly Task[], today: string = todayIso()): TodayBuckets {
const overdue: Task[] = []
const dueToday: Task[] = []
const inProgress: Task[] = []
for (const task of tasks) {
if (!isActionable(task)) continue
if (task.dueDate !== null && task.dueDate < today) {
overdue.push(task)
} else if (task.dueDate === today) {
dueToday.push(task)
} else if (task.status === 'doing') {
inProgress.push(task)
}
}
return {
overdue: sortByUrgency(overdue),
dueToday: sortByUrgency(dueToday),
inProgress: sortByUrgency(inProgress),
}
}
/** 今日视图中的任务总数 */
export function countTodayTasks(buckets: TodayBuckets): number {
return buckets.overdue.length + buckets.dueToday.length + buckets.inProgress.length
}
+450
View File
@@ -0,0 +1,450 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import dayjs from 'dayjs'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it } from 'vitest'
import BoardColumn from '@/components/BoardColumn.vue'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import BoardView from './BoardView.vue'
// BoardView 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰
enableAutoUnmount(afterEach)
function setup() {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
return { pinia, store }
}
function mountBoard(pinia: ReturnType<typeof createPinia>) {
// 抽屉内容会被 Teleport 到 body,测试中改为就地渲染以便断言
return mount(BoardView, {
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
}
describe('BoardView 结构', () => {
it('渲染三个固定列', () => {
const { pinia } = setup()
const headings = mountBoard(pinia)
.findAll('h2')
.map((node) => node.text())
expect(headings).toEqual(['待办', '进行中', '已完成'])
})
it('空看板显示空状态', () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('暂无任务')
})
it('每列显示任务计数', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办事项' })
store.createTask({ title: '进行中事项', status: 'doing' })
const counters = mountBoard(pinia)
.findAll('section header span')
.map((node) => node.text())
expect(counters).toEqual(['1', '1', '0'])
})
it('渲染任务标题并显示总数', () => {
const { pinia, store } = setup()
store.createTask({ title: '写方案' })
store.createTask({ title: '做原型', status: 'doing' })
const wrapper = mountBoard(pinia)
expect(wrapper.text()).toContain('写方案')
expect(wrapper.text()).toContain('做原型')
expect(wrapper.text()).toContain('共 2 个任务')
})
it('任务按列归位', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办事项' })
store.createTask({ title: '进行中事项', status: 'doing' })
store.createTask({ title: '已完成事项', status: 'done' })
const sections = mountBoard(pinia).findAll('section')
expect(sections[0]?.text()).toContain('待办事项')
expect(sections[0]?.text()).not.toContain('进行中事项')
expect(sections[1]?.text()).toContain('进行中事项')
expect(sections[2]?.text()).toContain('已完成事项')
})
it('存在存储提示时展示提示条', () => {
const { pinia, store } = setup()
store.notice = '本地存储不可用'
expect(mountBoard(pinia).text()).toContain('本地存储不可用')
})
})
describe('BoardView 端到端交互', () => {
it('在界面上输入并回车即可新增任务', async () => {
const { pinia, store } = setup()
const wrapper = mountBoard(pinia)
const firstColumnInput = wrapper.findAll('section')[0]?.find('input')
expect(firstColumnInput).toBeDefined()
await firstColumnInput?.setValue('界面新增的任务')
await wrapper.findAll('form')[0]?.trigger('submit')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['界面新增的任务'])
expect(wrapper.text()).toContain('界面新增的任务')
})
it('点击完成按钮后卡片移动到已完成列', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article button').trigger('click')
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.done.map((task) => task.title)).toEqual(['待办任务'])
expect(wrapper.findAll('section')[2]?.text()).toContain('待办任务')
})
it('再次点击可撤销完成', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article button').trigger('click')
await wrapper.find('article button').trigger('click')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['待办任务'])
expect(store.byColumn.done).toHaveLength(0)
})
})
describe('BoardView 详情抽屉', () => {
it('初始不渲染详情表单', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
expect(mountBoard(pinia).find('input[aria-label="标题"]').exists()).toBe(false)
})
it('点击卡片打开抽屉并回填标题', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
'待办任务',
)
})
it('保存后看板同步更新', async () => {
const { pinia, store } = setup()
store.createTask({ title: '旧标题' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
const saveButton = wrapper.findAll('button').find((node) => node.text() === '保存')
await saveButton?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('新标题')
expect(store.tasks[0]?.title).toBe('新标题')
})
it('取消后抽屉关闭且数据不变', async () => {
const { pinia, store } = setup()
store.createTask({ title: '原标题' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
const cancelButton = wrapper.findAll('button').find((node) => node.text() === '取消')
await cancelButton?.trigger('click')
await nextTick()
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
expect(store.tasks[0]?.title).toBe('原标题')
})
it('删除后抽屉关闭且任务从看板消失', async () => {
const { pinia, store } = setup()
store.createTask({ title: '要删除的任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
const deleteButton = wrapper.findAll('button').find((node) => node.text() === '删除')
await deleteButton?.trigger('click')
await nextTick()
expect(store.tasks).toHaveLength(0)
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
})
})
describe('BoardView 拖拽接线', () => {
it('列组件 emit move 时驱动 store 改变状态', async () => {
const { pinia, store } = setup()
const task = store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
const column = wrapper.findAllComponents(BoardColumn)[0]
column?.vm.$emit('move', { id: task?.id ?? '', toColumn: 'doing', toIndex: 0 })
await nextTick()
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
})
it('列内移动事件驱动顺序变化', async () => {
const { pinia, store } = setup()
const a = store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
store.createTask({ title: 'C' })
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'B', 'A'])
const wrapper = mountBoard(pinia)
const column = wrapper.findAllComponents(BoardColumn)[0]
column?.vm.$emit('move', { id: a?.id ?? '', toColumn: 'todo', toIndex: 0 })
await nextTick()
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'C', 'B'])
expect(wrapper.findAll('section')[0]?.text()).toContain('A')
})
})
describe('BoardView 筛选', () => {
it('按优先级筛选后只显示匹配卡片,列计数同步变化', async () => {
const { pinia, store } = setup()
store.createTask({ title: '高优先级', priority: 3 })
store.createTask({ title: '低优先级', priority: 1 })
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(2)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('高优先级')
expect(wrapper.text()).not.toContain('低优先级')
expect(wrapper.findAll('section header span')[0]?.text()).toBe('1')
})
it('显示命中数量提示', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A', priority: 3 })
store.createTask({ title: 'B', priority: 1 })
const wrapper = mountBoard(pinia)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
})
it('清空筛选后恢复全部卡片', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A', priority: 3 })
store.createTask({ title: 'B', priority: 1 })
const wrapper = mountBoard(pinia)
const filters = useFilterStore()
filters.togglePriority(3)
await nextTick()
filters.clear()
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(2)
expect(wrapper.text()).not.toContain('显示 1 / 2')
})
it('点击「逾期」只显示逾期任务', async () => {
const { pinia, store } = setup()
store.createTask({
title: '逾期任务',
dueDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
})
store.createTask({ title: '未来任务', dueDate: dayjs().add(3, 'day').format('YYYY-MM-DD') })
const wrapper = mountBoard(pinia)
const overdueButton = wrapper.findAll('button').find((node) => node.text().includes('逾期'))
await overdueButton?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('逾期任务')
expect(wrapper.text()).not.toContain('未来任务')
})
it('列内渲染子集时给列组件传 sortDisabled(拖拽保护)', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(true)
})
})
describe('BoardView 归档', () => {
function archiveButton(wrapper: ReturnType<typeof mountBoard>) {
return wrapper.findAll('button').find((node) => node.text().includes('个已完成'))
}
async function createArchivable(store: ReturnType<typeof useTaskStore>) {
const task = store.createTask({ title: '很久前完成的任务', status: 'done' })
store.updateTask(task?.id ?? '', {
completedAt: dayjs().subtract(30, 'day').toISOString(),
})
return task
}
it('默认不显示归档任务', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
})
it('开启「显示归档」后归档任务重新出现', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
const toggle = wrapper.findAll('button').find((node) => node.text() === '显示归档')
await toggle?.trigger('click')
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('很久前完成的任务')
})
it('归档后给出可撤销提示,撤销后任务回到看板', async () => {
const { pinia, store } = setup()
await createArchivable(store)
const wrapper = mountBoard(pinia)
await archiveButton(wrapper)?.trigger('click')
await nextTick()
expect(store.tasks[0]?.archivedAt).not.toBeNull()
expect(wrapper.findAll('article')).toHaveLength(0)
const toast = useToastStore()
expect(toast.toasts[0]?.action?.label).toBe('撤销')
toast.runAction(toast.toasts[0]?.id ?? '')
await nextTick()
expect(store.tasks[0]?.archivedAt).toBeNull()
expect(wrapper.findAll('article')).toHaveLength(1)
})
it('隐藏归档时「已完成」列禁用列内排序', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
// 归档任务只可能在「已完成」列
expect(wrapper.findAllComponents(BoardColumn)[2]?.props('sortDisabled')).toBe(true)
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
})
})
describe('BoardView 快捷键', () => {
function pressKey(key: string, target: EventTarget = document): void {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
it('按 N 聚焦待办列的快速新增输入框', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('n')
expect(document.activeElement).toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('按 / 聚焦搜索框', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('/')
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
})
it('按 ? 打开快捷键帮助', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('?')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
})
it('在输入框内按 N 不触发快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
const search = wrapper.find('input[aria-label="搜索任务"]')
pressKey('n', search.element)
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('面板打开时不响应快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
await nextTick()
pressKey('n')
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('按 Esc 关闭已打开的面板', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
pressKey('Escape')
await nextTick()
expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框')
})
})
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import BackupPanel from '@/components/BackupPanel.vue'
import BoardColumn from '@/components/BoardColumn.vue'
import BoardToolbar from '@/components/BoardToolbar.vue'
import SideDrawer from '@/components/SideDrawer.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, type ColumnId } from '@/types/task'
import { findArchivableTasks } from '@/utils/archive'
import { filterTasks } from '@/utils/filter'
import { groupByColumn } from '@/utils/sortOrder'
const store = useTaskStore()
const filters = useFilterStore()
const toast = useToastStore()
const { notice } = storeToRefs(store)
const { filter } = storeToRefs(filters)
const SHORTCUTS = [
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
{ keys: '/', description: '聚焦搜索框' },
{ keys: '?', description: '打开本帮助' },
{ keys: 'Esc', description: '关闭当前面板' },
] as const
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
const backupOpen = ref(false)
const helpOpen = ref(false)
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
function setColumnRef(element: unknown, index: number): void {
if (element) columnRefs.set(index, element as InstanceType<typeof BoardColumn>)
else columnRefs.delete(index)
}
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
const visibleCount = computed(() =>
COLUMNS.reduce((total, column) => total + visibleColumns.value[column.id].length, 0),
)
/**
* 每列被隐藏的数量(被筛选掉 + 已归档)。
* 大于 0 说明该列渲染的是子集,此时必须禁用列内排序,
* 否则 Sortable 给出的子集下标会把全量顺序写坏。
*/
const hiddenCounts = computed(() => {
const result = {} as Record<ColumnId, number>
for (const column of COLUMNS) {
result[column.id] = store.byColumn[column.id].length - visibleColumns.value[column.id].length
}
return result
})
const hiddenTotal = computed(() =>
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
)
const archivableTasks = computed(() => findArchivableTasks(store.tasks))
function archiveCompleted(): void {
const targets = archivableTasks.value
if (targets.length === 0) return
const ids = targets.map((task) => task.id)
const count = store.archiveTasks(ids)
if (count === 0) return
toast.show({
message: `已归档 ${count} 个已完成任务`,
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
})
}
function anyPanelOpen(): boolean {
return editingId.value !== null || backupOpen.value || helpOpen.value
}
/** 有面板打开时不响应全局快捷键,避免误触 */
function whenNoPanel(action: () => void): () => void {
return () => {
if (anyPanelOpen()) return
action()
}
}
useKeyboardShortcuts({
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
'?': whenNoPanel(() => {
helpOpen.value = true
}),
})
function toggleTask(id: string): void {
const task = store.tasks.find((item) => item.id === id)
if (!task) return
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
}
function openTask(id: string): void {
editingId.value = id
}
function closeDetail(): void {
editingId.value = null
}
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
}
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4 md:p-6">
<BoardToolbar
ref="toolbarRef"
@open-backup="backupOpen = true"
@open-help="helpOpen = true"
@archive="archiveCompleted"
/>
<p
v-if="notice"
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
{{ notice }}
</p>
<p v-if="hiddenTotal > 0" class="text-xs text-slate-500">
显示 {{ visibleCount }} / {{ store.tasks.length }} 个任务
</p>
<div class="grid min-h-0 flex-1 gap-4 overflow-y-auto md:grid-cols-3 md:overflow-hidden">
<BoardColumn
v-for="(column, index) in COLUMNS"
:key="column.id"
:ref="(element) => setColumnRef(element, index)"
:column="column"
:tasks="visibleColumns[column.id]"
:sort-disabled="hiddenCounts[column.id] > 0"
@toggle="toggleTask"
@open="openTask"
@move="handleMove"
/>
</div>
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
<BackupPanel :open="backupOpen" @close="backupOpen = false" />
<SideDrawer :open="helpOpen" title="快捷键" @close="helpOpen = false">
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded border border-slate-300 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
>
{{ item.keys }}
</dt>
<dd class="text-slate-600">{{ item.description }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
</SideDrawer>
</div>
</template>
+181
View File
@@ -0,0 +1,181 @@
import { mount } from '@vue/test-utils'
import dayjs from 'dayjs'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import type { Task } from '@/types/task'
import TodayView from './TodayView.vue'
const today = dayjs().format('YYYY-MM-DD')
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
function setup(seed: Array<Partial<Task> & { title: string }> = []) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
for (const item of seed) {
const created = store.createTask({ title: item.title, status: item.status ?? 'todo' })
if (!created) continue
const patch: Partial<Task> = {}
if (item.dueDate !== undefined) patch.dueDate = item.dueDate
if (item.priority !== undefined) patch.priority = item.priority
if (item.status !== undefined) patch.status = item.status
if (Object.keys(patch).length > 0) store.updateTask(created.id, patch)
}
const wrapper = mount(TodayView, {
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, wrapper }
}
function sectionTitles(wrapper: ReturnType<typeof mount>): string[] {
return wrapper.findAll('h2').map((node) => node.text())
}
describe('TodayView 顶部', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('显示标题与当天日期', () => {
const { wrapper } = setup()
expect(wrapper.find('h1').text()).toBe('今日')
expect(wrapper.text()).toContain(dayjs().format('YYYY年M月D日'))
})
it('显示四张统计卡片', () => {
const { wrapper } = setup()
const labels = wrapper.findAll('dt').map((node) => node.text())
expect(labels).toEqual(['今日待办', '逾期', '今天到期', '本周完成'])
})
it('统计卡片数值正确', () => {
const { wrapper } = setup([
{ title: '逾期的', dueDate: yesterday },
{ title: '今天到期', dueDate: today },
{ title: '进行中', status: 'doing' },
{ title: '已完成的', status: 'done' },
])
const values = wrapper.findAll('dd').map((node) => node.text())
// 今日待办 3(逾期 + 今天到期 + 进行中),逾期 1,今天到期 1,本周完成 1
expect(values).toEqual(['3', '1', '1', '1'])
})
})
describe('TodayView 分组', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('按逾期 / 今天到期 / 进行中分组', () => {
const { wrapper } = setup([
{ title: '逾期的', dueDate: yesterday },
{ title: '今天到期', dueDate: today },
{ title: '进行中', status: 'doing' },
])
const titles = sectionTitles(wrapper)
expect(titles).toHaveLength(3)
expect(titles[0]).toContain('已逾期')
expect(titles[1]).toContain('今天到期')
expect(titles[2]).toContain('进行中')
// 每组各 1 条
const badges = wrapper.findAll('[data-role="section-count"]').map((node) => node.text())
expect(badges).toEqual(['1', '1', '1'])
})
it('没有任务的分组不渲染', () => {
const { wrapper } = setup([{ title: '只有进行中', status: 'doing' }])
const titles = sectionTitles(wrapper)
expect(titles).toHaveLength(1)
expect(titles[0]).toContain('进行中')
})
it('已完成任务不出现在任何分组', () => {
const { wrapper } = setup([{ title: '已完成', status: 'done', dueDate: yesterday }])
expect(wrapper.findAll('article')).toHaveLength(0)
})
it('未开始且无截止日期的任务不出现', () => {
const { wrapper } = setup([{ title: '以后再说' }])
expect(wrapper.findAll('article')).toHaveLength(0)
})
it('未来截止但未开始的任务不出现', () => {
const { wrapper } = setup([{ title: '下周的事', dueDate: tomorrow }])
expect(wrapper.findAll('article')).toHaveLength(0)
})
it('同一任务只出现一次', () => {
const { wrapper } = setup([
{ title: '既逾期又进行中', dueDate: yesterday, status: 'doing' },
])
expect(wrapper.findAll('article')).toHaveLength(1)
expect(sectionTitles(wrapper)[0]).toContain('已逾期')
})
it('组内按优先级降序排列', () => {
const { wrapper } = setup([
{ title: '低优先级', dueDate: yesterday, priority: 1 },
{ title: '高优先级', dueDate: yesterday, priority: 3 },
])
const titles = wrapper.findAll('article h3').map((node) => node.text())
expect(titles).toEqual(['高优先级', '低优先级'])
})
})
describe('TodayView 空状态', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('没有待办时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('今天没有待办事项')
expect(wrapper.findAll('article')).toHaveLength(0)
})
it('有待办时不显示空状态', () => {
const { wrapper } = setup([{ title: '有事做', status: 'doing' }])
expect(wrapper.text()).not.toContain('今天没有待办事项')
})
})
describe('TodayView 交互', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('点击完成按钮后任务从今日视图消失', async () => {
const { store, wrapper } = setup([{ title: '要做的事', status: 'doing' }])
expect(wrapper.findAll('article')).toHaveLength(1)
await wrapper.find('article button').trigger('click')
await wrapper.vm.$nextTick()
expect(store.tasks[0]?.status).toBe('done')
expect(wrapper.findAll('article')).toHaveLength(0)
})
it('点击卡片打开详情抽屉', async () => {
const { wrapper } = setup([{ title: '要做的事', status: 'doing' }])
await wrapper.find('article').trigger('click')
await wrapper.vm.$nextTick()
expect(
(wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value,
).toBe('要做的事')
})
})
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import TaskCard from '@/components/TaskCard.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useTaskStore } from '@/stores/tasks'
import { computeStats } from '@/utils/stats'
import { buildTodayBuckets, countTodayTasks } from '@/utils/today'
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] as const
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
const TONES = {
rose: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700' },
amber: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700' },
sky: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700' },
} as const
const store = useTaskStore()
const { tasks } = storeToRefs(store)
const todayLabel = computed(() => {
const now = dayjs()
return `${now.format('YYYY年M月D日')} ${WEEKDAYS[now.day()]}`
})
const buckets = computed(() => buildTodayBuckets(tasks.value))
const stats = computed(() => computeStats(tasks.value))
const todayCount = computed(() => countTodayTasks(buckets.value))
const statCards = computed(() => [
{ label: '今日待办', value: todayCount.value },
{ label: '逾期', value: stats.value.overdue },
{ label: '今天到期', value: stats.value.dueToday },
{ label: '本周完成', value: stats.value.completedThisWeek },
])
/**
* 空分组直接过滤掉。
* 不能把 v-if 和 v-for 写在同一元素上 —— Vue 3 里 v-if 优先级更高,
* 拿不到 v-for 的作用域变量,组件会直接渲染失败。
*/
const sections = computed(() =>
[
{
key: 'overdue',
title: '已逾期',
hint: '建议优先处理',
tone: 'rose' as const,
items: buckets.value.overdue,
},
{
key: 'dueToday',
title: '今天到期',
hint: '今天要完成',
tone: 'amber' as const,
items: buckets.value.dueToday,
},
{
key: 'inProgress',
title: '进行中',
hint: '已开始,尚无紧迫截止日期',
tone: 'sky' as const,
items: buckets.value.inProgress,
},
].filter((section) => section.items.length > 0),
)
const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
function toggleTask(id: string): void {
const task = store.tasks.find((item) => item.id === id)
if (!task) return
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
}
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4 md:p-6">
<header class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-slate-800 md:text-2xl">今日</h1>
<p class="text-sm text-slate-500">{{ todayLabel }}</p>
</header>
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div
v-for="card in statCards"
:key="card.label"
class="rounded-lg bg-white px-4 py-3 shadow-sm"
>
<dt class="text-xs text-slate-500">{{ card.label }}</dt>
<dd class="mt-1 text-2xl font-semibold text-slate-800">{{ card.value }}</dd>
</div>
</dl>
<p
v-if="todayCount === 0"
class="rounded-lg bg-white px-4 py-12 text-center text-sm text-slate-400 shadow-sm"
>
今天没有待办事项
</p>
<section
v-for="section in sections"
:key="section.key"
class="flex flex-col gap-2"
>
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-600">
<span class="size-2 rounded-full" :class="TONES[section.tone].dot" />
{{ section.title }}
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="TONES[section.tone].badge"
data-role="section-count"
>
{{ section.items.length }}
</span>
<span class="text-xs font-normal text-slate-400">{{ section.hint }}</span>
</h2>
<div class="flex flex-col gap-2">
<TaskCard
v-for="task in section.items"
:key="task.id"
:task="task"
@toggle="toggleTask"
@open="editingId = $event"
/>
</div>
</section>
<TaskDetailDrawer :task="editingTask" @close="editingId = null" />
</div>
</template>