From 8721e8ab0b1194468f759f5032847980336ebe5f Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 18 Oct 2026 17:48:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E5=BE=85=E5=8A=9E=E7=BB=84=E3=80=81=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E9=94=AE=E7=9B=98=E6=93=8D=E4=BD=9C=E3=80=81=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 今日视图 - 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设 截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到 「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个 分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。 - 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成), 不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。 键盘操作(可访问性) - 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。 - 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全; 列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。 - 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。 深色模式 - 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。 - index.html 内联脚本在首帧之前应用主题,避免深色下闪白。 - 用 @custom-variant dark 做 class 控制(而不是纯 media query), 才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。 验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。 截图脚本新增深色与移动端深色两组,便于后续对照。 --- README.md | 30 ++++-- e2e/board.spec.ts | 36 +++++++ e2e/pwa.spec.ts | 2 +- e2e/today.spec.ts | 10 +- index.html | 16 +++ scripts/screenshot.mjs | 36 +++++-- src/App.spec.ts | 9 +- src/App.vue | 41 +++++++- src/components/AppNav.vue | 77 ++++++++++++-- src/components/BackupPanel.vue | 40 ++++--- src/components/BoardColumn.vue | 20 ++-- src/components/BoardToolbar.vue | 32 +++--- src/components/DatePicker.vue | 5 +- src/components/PwaUpdatePrompt.vue | 6 +- src/components/QuickAddTask.vue | 2 +- src/components/SideDrawer.vue | 17 +-- src/components/TagFilter.vue | 16 +-- src/components/TagInput.vue | 8 +- src/components/TaskCard.spec.ts | 54 ++++++++++ src/components/TaskCard.vue | 75 ++++++++++--- src/components/TaskDetailDrawer.vue | 26 ++--- src/components/ToastHost.vue | 6 +- src/stores/theme.spec.ts | 158 ++++++++++++++++++++++++++++ src/stores/theme.ts | 111 +++++++++++++++++++ src/style.css | 29 +++++ src/types/task.ts | 6 ++ src/utils/today.spec.ts | 37 ++++++- src/utils/today.ts | 20 +++- src/views/BoardView.spec.ts | 129 +++++++++++++++++++++++ src/views/BoardView.vue | 36 ++++++- src/views/TodayView.spec.ts | 19 ++-- src/views/TodayView.vue | 71 ++++++++++--- 32 files changed, 1022 insertions(+), 158 deletions(-) create mode 100644 src/stores/theme.spec.ts create mode 100644 src/stores/theme.ts diff --git a/README.md b/README.md index ae782cf..f640655 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ - 固定三列:**待办 / 进行中 / 已完成** - 快速新增、拖拽排序、详情编辑、删除撤销 - 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期) -- **今日视图**:按紧急度汇总今天要做的事,含本周完成等统计 -- 键盘快捷键 +- **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计 +- 键盘快捷键;任务卡片可用方向键换列与调序 +- **深色模式**:跟随系统 / 浅色 / 深色三态 - 导出导入 JSON + 启动自动备份 - **可安装为 PWA,支持离线使用** @@ -44,7 +45,15 @@ npm run dev | `?` | 打开快捷键帮助 | | `Esc` | 关闭当前面板 | -在输入框内输入时不会触发快捷键。 +卡片获得焦点(`Tab` 或点击)后: + +| 按键 | 作用 | +|---|---| +| `←` `→` | 把卡片移到上一列 / 下一列 | +| `↑` `↓` | 在列内上移 / 下移 | +| `Enter` | 打开任务详情 | + +在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义)。 --- @@ -55,11 +64,13 @@ npm run dev | 视图 | 路径 | 组织方式 | |---|---|---| | 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 | -| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 | +| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 → 待办 | -今日视图顶部有四张统计卡片(今日待办 / 逾期 / 今天到期 / 本周完成),下方是可直接勾选完成的待办列表。 +今日视图顶部有四张统计卡片(逾期 / 今天到期 / 进行中 / 本周完成),下方是可直接勾选完成的待办列表。 -分组是**互斥**的:一张卡片只会出现在一组里,归属优先级为「逾期 > 今天到期 > 进行中」,所以你能一眼数清今天到底有几件事。已完成和已归档的任务不会出现在今日视图。 +分组是**互斥**的:一张卡片只会出现在一组里,归属优先级为「逾期 > 今天到期 > 进行中 > 待办」,所以你能一眼数清今天到底有几件事。已完成和已归档的任务不会出现在今日视图。 + +只要任务未完成且未归档,就一定会出现在某个分组里 —— 包括没设截止日期的待办(归入「待办」组)。 --- @@ -177,8 +188,8 @@ scripts/ # 图标生成等辅助脚本 ## 测试 -- 单元测试:437 个用例,覆盖 `src/` 下的纯逻辑与组件行为,行覆盖率约 97%。 -- 端到端测试:23 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。 +- 单元测试:480 个用例,覆盖 `src/` 下的纯逻辑与组件行为,行覆盖率约 96%。 +- 端到端测试:25 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。 ```bash npm run test:coverage @@ -202,5 +213,6 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx - 标签是字符串数组,重命名标签需要逐个任务修改。 - 看板列固定为三列,不支持自定义。 - 归档阈值固定为「已完成超过 7 天」,暂不可配置。 -- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中),不可自定义。 +- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。 - 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。 +- 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。 diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index 5f78a12..cca4dde 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -113,3 +113,39 @@ test('输入框内输入不会触发快捷键', async ({ page }) => { await expect(search).toBeFocused() await expect(search).toHaveValue('n') }) + +test('深色模式可切换并在刷新后保持', async ({ page }) => { + await page.goto('/') + const html = page.locator('html') + const themeButton = page.getByRole('button', { name: /主题/ }) + + await expect(html).not.toHaveClass(/dark/) + + // 系统 → 浅色 → 深色 + await themeButton.click() + await expect(html).not.toHaveClass(/dark/) + await themeButton.click() + await expect(html).toHaveClass(/dark/) + + await page.reload() + await expect(html).toHaveClass(/dark/) + + // 再点一次回到跟随系统 + await themeButton.click() + await expect(html).not.toHaveClass(/dark/) +}) + +test('键盘方向键可以移动任务卡片', async ({ page }) => { + await addTask(page, '键盘移动我') + const card = page.locator('article').filter({ hasText: '键盘移动我' }) + await card.focus() + + await page.keyboard.press('ArrowRight') + await expect(page.locator('[data-column-id="doing"] article')).toHaveCount(1) + + await page.keyboard.press('ArrowLeft') + await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) + + await page.reload() + await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) +}) diff --git a/e2e/pwa.spec.ts b/e2e/pwa.spec.ts index 0d2893a..d3c2843 100644 --- a/e2e/pwa.spec.ts +++ b/e2e/pwa.spec.ts @@ -106,7 +106,7 @@ test('离线时深链 /today 也能打开(navigateFallback 生效)', async ( await page.goto('/today') await expect(page.getByRole('heading', { name: '今日' })).toBeVisible() - await expect(page.getByText('今日待办')).toBeVisible() + await expect(page.getByText('本周完成')).toBeVisible() await context.setOffline(false) }) diff --git a/e2e/today.spec.ts b/e2e/today.spec.ts index 2e443ed..edd58a1 100644 --- a/e2e/today.spec.ts +++ b/e2e/today.spec.ts @@ -34,7 +34,7 @@ test('导航可在看板与今日之间切换', async ({ page }) => { await page.getByRole('link', { name: '今日' }).click() await expect(page.getByRole('heading', { name: '今日' })).toBeVisible() - await expect(page.getByText('今日待办')).toBeVisible() + await expect(page.getByText('本周完成')).toBeVisible() await page.getByRole('link', { name: '看板' }).click() await expect(page.getByText('共 0 个任务')).toBeVisible() @@ -58,13 +58,15 @@ test('今日视图汇总今天到期与进行中的任务', async ({ page }) => await expect(page.getByRole('heading', { level: 2, name: /进行中/ })).toBeVisible() }) -test('未开始且无截止日期的任务不进入今日视图', async ({ page }) => { +test('未开始且无截止日期的任务出现在「待办」组', async ({ page }) => { await addTask(page, '以后再说') await page.getByRole('link', { name: '今日' }).click() - await expect(page.locator('article')).toHaveCount(0) - await expect(page.getByText('今天没有待办事项')).toBeVisible() + // 早期版本这类任务在今日视图里完全消失,是一个真实的缺口 + await expect(page.locator('article')).toHaveCount(1) + await expect(page.locator('article').filter({ hasText: '以后再说' })).toBeVisible() + await expect(page.getByRole('heading', { level: 2, name: /待办/ })).toBeVisible() }) test('今日视图可以勾选完成', async ({ page }) => { diff --git a/index.html b/index.html index 1e7a138..0aa1bcf 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,22 @@ 看板 · 个人任务管理 +
diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs index 840dd57..fcf1f43 100644 --- a/scripts/screenshot.mjs +++ b/scripts/screenshot.mjs @@ -125,20 +125,40 @@ async function main() { }) 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 } }, + { name: 'board', url: '/', viewport: { width: 1440, height: 900 }, theme: 'light' }, + { name: 'today', url: '/today', viewport: { width: 1440, height: 900 }, theme: 'light' }, + { + name: 'board-detail', + url: '/', + viewport: { width: 1440, height: 900 }, + theme: 'light', + openDetail: true, + }, + { name: 'board-mobile', url: '/', viewport: { width: 390, height: 844 }, theme: 'light' }, + { name: 'board-dark', url: '/', viewport: { width: 1440, height: 900 }, theme: 'dark' }, + { name: 'today-dark', url: '/today', viewport: { width: 1440, height: 900 }, theme: 'dark' }, + { + name: 'board-dark-mobile', + url: '/', + viewport: { width: 390, height: 844 }, + theme: 'dark', + }, ] for (const shot of shots) { await page.setViewportSize(shot.viewport) + await page.goto(BASE_URL) + + // 先写入种子数据与主题偏好,再重新进入目标页面 + await page.evaluate( + ({ state, theme }) => { + localStorage.setItem('kanban.tasks', JSON.stringify(state)) + localStorage.setItem('kanban.theme', theme) + }, + { state: STORAGE, theme: shot.theme }, + ) await page.goto(`${BASE_URL}${shot.url}`) await page.waitForSelector('article, [data-role="empty-state"], h1') diff --git a/src/App.spec.ts b/src/App.spec.ts index 59ddda3..787d06c 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -1,14 +1,17 @@ -import { mount } from '@vue/test-utils' +import { enableAutoUnmount, mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { nextTick } from 'vue' import { createMemoryHistory } from 'vue-router' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SCHEMA_VERSION } from '@/data/schema' import App from './App.vue' import { createAppRouter } from './router' +// App 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰 +enableAutoUnmount(afterEach) + /** PWA 的虚拟模块在测试环境下无法解析,用桩替代 */ vi.mock('virtual:pwa-register/vue', async () => { const { ref } = await import('vue') @@ -111,7 +114,7 @@ describe('App 路由', () => { it('/today 渲染今日视图', async () => { const { wrapper } = await mountApp('/today') - expect(wrapper.text()).toContain('今日待办') + expect(wrapper.text()).toContain('本周完成') expect(wrapper.text()).not.toContain('共 0 个任务') wrapper.unmount() }) diff --git a/src/App.vue b/src/App.vue index 46f5de8..debbc75 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,7 @@ import SideDrawer from '@/components/SideDrawer.vue' import ToastHost from '@/components/ToastHost.vue' import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useTaskStore } from '@/stores/tasks' +import { useThemeStore } from '@/stores/theme' import { useUiStore } from '@/stores/ui' const SHORTCUTS = [ @@ -18,11 +19,21 @@ const SHORTCUTS = [ { keys: 'Esc', description: '关闭当前面板' }, ] as const +/** 这些只在卡片获得焦点后生效 */ +const CARD_SHORTCUTS = [ + { keys: 'Tab', description: '依次聚焦到任务卡片' }, + { keys: '← →', description: '把卡片移到上一列 / 下一列' }, + { keys: '↑ ↓', description: '在列内上移 / 下移' }, + { keys: 'Enter', description: '打开任务详情' }, +] as const + const store = useTaskStore() const ui = useUiStore() +const theme = useThemeStore() onMounted(() => { store.init() + theme.init() }) // 帮助面板是应用级的,因此在任何视图下都能用 ? @@ -34,7 +45,7 @@ useKeyboardShortcuts({