diff --git a/.gitignore b/.gitignore index b077cdd..f5a86eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ playwright-report blob-report .playwright +# 界面截图(开发辅助产物) +.screenshots + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/README.md b/README.md index 7f5b32e..ae782cf 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ npm run dev | `npm run test` | 单元测试(Vitest) | | `npm run test:coverage` | 单元测试 + 覆盖率报告 | | `npm run test:e2e` | 端到端测试(Playwright,默认驱动系统已安装的 Chrome) | +| `node scripts/screenshot.mjs` | 生成界面截图(需先起 dev server),产物在 `.screenshots/` | | `npm run lint` / `npm run format` | 代码检查与格式化 | ## 快捷键 diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index a704be5..5f78a12 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -86,7 +86,7 @@ test('搜索筛选后列计数同步变化', async ({ page }) => { // 关键词有 200ms 防抖 await expect(page.locator('article')).toHaveCount(1) await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible() - await expect(page.locator('section').first().locator('header span')).toHaveText('1') + await expect(page.locator('section').first().locator('[data-role="column-count"]')).toHaveText('1') }) test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => { diff --git a/eslint.config.js b/eslint.config.js index c974a1a..b437ccc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,13 @@ export default tseslint.config( globals: { ...globals.browser }, }, }, + { + // 构建/截图等脚本运行在 Node 环境 + files: ['scripts/**/*.{js,mjs,ts}'], + languageOptions: { + globals: { ...globals.node }, + }, + }, { rules: { // 允许用下划线前缀标记「有意不使用」的参数与变量 diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs new file mode 100644 index 0000000..840dd57 --- /dev/null +++ b/scripts/screenshot.mjs @@ -0,0 +1,160 @@ +/** + * 界面截图工具(开发辅助) + * + * 用法:先起 dev server,再执行 + * node scripts/screenshot.mjs [输出目录] + * + * 会注入一份有代表性的种子数据,分别截取看板、今日视图与移动端布局, + * 便于对照审视视觉改动。 + */ + +import { mkdir } from 'node:fs/promises' +import path from 'node:path' + +import { chromium } from '@playwright/test' + +const BASE_URL = process.env.SCREENSHOT_URL ?? 'http://localhost:5199' +const OUT_DIR = path.resolve(process.argv[2] ?? '.screenshots') +const CHANNEL = process.env.PLAYWRIGHT_CHANNEL ?? 'chrome' + +/** 相对今天偏移若干天的日期 */ +function dayOffset(days) { + const date = new Date() + date.setDate(date.getDate() + days) + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${date.getFullYear()}-${month}-${day}` +} + +function iso(hoursAgo) { + return new Date(Date.now() - hoursAgo * 3600 * 1000).toISOString() +} + +function task(id, title, status, priority, extra = {}) { + return { + id, + title, + description: extra.description ?? '', + status, + priority, + tags: extra.tags ?? [], + dueDate: extra.dueDate ?? null, + sortOrder: extra.sortOrder ?? 1024, + createdAt: iso(72), + updatedAt: iso(2), + completedAt: extra.completedAt ?? (status === 'done' ? iso(20) : null), + archivedAt: extra.archivedAt ?? null, + } +} + +const SEED = [ + task('t1', '整理下季度 OKR 初稿', 'todo', 3, { + tags: ['规划'], + dueDate: dayOffset(-2), + description: '先把去年 Q4 的复盘结论过一遍,再拉上两位组长对一下目标口径。', + sortOrder: 1024, + }), + task('t2', '修复导出 JSON 时文件名重复的问题', 'todo', 3, { + tags: ['前端', 'Bug'], + dueDate: dayOffset(0), + sortOrder: 2048, + }), + task('t3', '调研 IndexedDB 替换 localStorage 的可行性', 'todo', 2, { + tags: ['技术调研'], + dueDate: dayOffset(3), + sortOrder: 3072, + }), + task('t4', '给看板加一个「本周完成」的统计', 'todo', 2, { + tags: ['前端'], + sortOrder: 4096, + }), + task('t5', '把设计稿里的图标补齐', 'todo', 1, { + tags: ['设计'], + dueDate: dayOffset(9), + sortOrder: 5120, + }), + task('t6', '写 PWA 离线缓存的说明文档', 'todo', 1, { tags: ['文档'], sortOrder: 6144 }), + + task('t7', '重写任务排序算法,改用相邻中点插入', 'doing', 3, { + tags: ['前端', '重构'], + dueDate: dayOffset(-1), + sortOrder: 1024, + }), + task('t8', '补充端到端测试覆盖拖拽场景', 'doing', 2, { + tags: ['测试'], + dueDate: dayOffset(1), + sortOrder: 2048, + }), + task('t9', '梳理快捷键与输入法的冲突场景', 'doing', 2, { tags: ['前端'], sortOrder: 3072 }), + + task('t10', '搭好项目骨架与工程规范', 'done', 2, { + tags: ['基建'], + completedAt: iso(30), + sortOrder: 1024, + }), + task('t11', '实现任务的增删改查与持久化', 'done', 3, { + tags: ['前端'], + completedAt: iso(26), + sortOrder: 2048, + }), + task('t12', '接入 Tailwind 并统一设计变量', 'done', 2, { + tags: ['设计', '基建'], + completedAt: iso(22), + sortOrder: 3072, + }), + task('t13', '写第一版 ADR', 'done', 1, { + tags: ['文档'], + completedAt: iso(400), + archivedAt: iso(300), + sortOrder: 4096, + }), +] + +const STORAGE = { + schemaVersion: 2, + tasks: SEED, +} + +async function main() { + await mkdir(OUT_DIR, { recursive: true }) + + const browser = await chromium.launch({ channel: CHANNEL === 'chromium' ? undefined : CHANNEL }) + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }) + + const page = await context.newPage() + await page.goto(BASE_URL) + await page.evaluate((state) => { + localStorage.setItem('kanban.tasks', JSON.stringify(state)) + }, STORAGE) + + const shots = [ + { name: 'board', url: '/', viewport: { width: 1440, height: 900 } }, + { name: 'today', url: '/today', viewport: { width: 1440, height: 900 } }, + { name: 'board-detail', url: '/', viewport: { width: 1440, height: 900 }, openDetail: true }, + { name: 'board-mobile', url: '/', viewport: { width: 390, height: 844 } }, + ] + + for (const shot of shots) { + await page.setViewportSize(shot.viewport) + await page.goto(`${BASE_URL}${shot.url}`) + await page.waitForSelector('article, [data-role="empty-state"], h1') + + if (shot.openDetail) { + await page.locator('article').first().click() + await page.waitForSelector('[role="dialog"]') + await page.waitForTimeout(300) + } + + await page.waitForTimeout(400) + const file = path.join(OUT_DIR, `${shot.name}.png`) + await page.screenshot({ path: file }) + console.log(`saved ${path.relative(process.cwd(), file)}`) + } + + await browser.close() +} + +await main() diff --git a/src/App.spec.ts b/src/App.spec.ts index 2f9493b..59ddda3 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -34,7 +34,8 @@ async function mountApp(initialPath = '/') { await router.push(initialPath) const wrapper = mount(App, { - global: { plugins: [pinia, router] }, + // 抽屉与面板的内容会被 Teleport 到 body,测试中改为就地渲染以便断言 + global: { plugins: [pinia, router], stubs: { teleport: true } }, attachTo: document.body, }) await router.isReady() @@ -143,3 +144,49 @@ describe('App 路由', () => { wrapper.unmount() }) }) + +describe('App 应用级面板', () => { + beforeEach(() => { + localStorage.clear() + }) + + function pressKey(key: string): void { + document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + } + + it('按 ? 打开快捷键帮助,Esc 关闭', async () => { + const { wrapper } = await mountApp() + await nextTick() + + pressKey('?') + await nextTick() + expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框') + + pressKey('Escape') + await nextTick() + expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框') + wrapper.unmount() + }) + + it('在今日视图下 ? 同样可用(帮助是应用级的)', async () => { + const { wrapper } = await mountApp('/today') + await nextTick() + + pressKey('?') + await nextTick() + expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框') + wrapper.unmount() + }) + + it('导航的备份按钮可打开备份面板', async () => { + const { wrapper } = await mountApp() + await nextTick() + + const backupButton = wrapper.findAll('button').find((node) => node.text() === '备份') + await backupButton?.trigger('click') + await nextTick() + + expect(wrapper.text()).toContain('导出为 JSON') + wrapper.unmount() + }) +}) diff --git a/src/App.vue b/src/App.vue index 7f2e117..46f5de8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,15 +3,34 @@ import { onMounted } from 'vue' import { RouterView } from 'vue-router' import AppNav from '@/components/AppNav.vue' +import BackupPanel from '@/components/BackupPanel.vue' import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue' +import SideDrawer from '@/components/SideDrawer.vue' import ToastHost from '@/components/ToastHost.vue' +import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useTaskStore } from '@/stores/tasks' +import { useUiStore } from '@/stores/ui' + +const SHORTCUTS = [ + { keys: 'N', description: '聚焦「待办」列的快速新增输入框' }, + { keys: '/', description: '聚焦搜索框' }, + { keys: '?', description: '打开本帮助' }, + { keys: 'Esc', description: '关闭当前面板' }, +] as const const store = useTaskStore() +const ui = useUiStore() onMounted(() => { store.init() }) + +// 帮助面板是应用级的,因此在任何视图下都能用 ? +useKeyboardShortcuts({ + '?': () => { + if (!ui.anyPanelOpen) ui.openHelp() + }, +}) diff --git a/src/components/AppNav.spec.ts b/src/components/AppNav.spec.ts index edb5c41..7952167 100644 --- a/src/components/AppNav.spec.ts +++ b/src/components/AppNav.spec.ts @@ -1,25 +1,33 @@ import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' import { describe, expect, it } from 'vitest' import { routes } from '@/router' +import { useUiStore } from '@/stores/ui' import AppNav from './AppNav.vue' async function setup(initialPath = '/') { + const pinia = createPinia() + setActivePinia(pinia) + 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 } + const wrapper = mount(AppNav, { + global: { plugins: [pinia, router] }, + attachTo: document.body, + }) + + return { wrapper, router, ui: useUiStore() } } -describe('AppNav', () => { - it('渲染看板与今日两个入口', () => { - return setup().then(({ wrapper }) => { - expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日']) - }) +describe('AppNav 导航', () => { + it('渲染看板与今日两个入口', async () => { + const { wrapper } = await setup() + expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日']) }) it('当前路由的入口标记 aria-current', async () => { @@ -53,3 +61,26 @@ describe('AppNav', () => { expect(wrapper.find('nav').attributes('aria-label')).toBe('主导航') }) }) + +describe('AppNav 应用级操作', () => { + it('点击「备份」打开备份面板', async () => { + const { wrapper, ui } = await setup() + expect(ui.backupOpen).toBe(false) + + const backupButton = wrapper.findAll('button').find((node) => node.text() === '备份') + await backupButton?.trigger('click') + + expect(ui.backupOpen).toBe(true) + }) + + it('点击 ? 打开快捷键帮助', async () => { + const { wrapper, ui } = await setup() + await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click') + expect(ui.helpOpen).toBe(true) + }) + + it('应用级操作在看板与今日视图下都可用', async () => { + const { wrapper } = await setup('/today') + expect(wrapper.findAll('button').map((node) => node.text())).toContain('备份') + }) +}) diff --git a/src/components/AppNav.vue b/src/components/AppNav.vue index f112755..bc32123 100644 --- a/src/components/AppNav.vue +++ b/src/components/AppNav.vue @@ -1,6 +1,10 @@