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:
@@ -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>
|
||||
Reference in New Issue
Block a user