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:
+145
@@ -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
@@ -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>
|
||||
@@ -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('主导航')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 个任务')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('开始日期')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('添加任务')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('明天')
|
||||
})
|
||||
})
|
||||
@@ -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日')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('聚焦「待办」列的快速新增输入框')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('要做的事')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user