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
+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>