style: 重做界面视觉与信息层级

先截图审视现状,再针对性修改。主要问题与改动如下。

工具栏
- 标签筛选从「平铺全部标签」改为下拉面板,标签多时不再换行挤爆工具栏
- 备份与快捷键帮助移到导航栏 —— 它们是应用级操作,不该挤在看板工具栏里,
  顺带修好了「? 帮助只在看板可用」的问题
- 到期统计(今日到期 / 逾期)合并为可点击的筛选入口,去掉两个独立控件
- 移动端工具栏从占半屏降到三行紧凑布局

卡片
- 优先级从彩色胶囊改为左侧色条,扫视一列时比读文字更快分辨轻重
- 元信息改为「优先级 · 日期 · 标签」的轻量文本行,不再三种胶囊叠在一起
- 完成态用勾选图标 + 降低不透明度

布局
- 今日视图限宽居中(max-w-3xl):此前 1440px 下卡片被拉满整屏,
  标题与元信息之间出现大片空白
- 修复移动端卡片被压成细条:TaskCard 的 overflow-hidden(用于裁切左侧色条)
  会让 flex 项的最小尺寸解析为 0,补 shrink-0 修正;
  同时改为移动端按内容高度堆叠、桌面端列内滚动

其他
- 导航栏品牌改用看板图标,消除「看板」重复出现两次
- 统一卡片阴影、圆角与键盘焦点样式
- 新增 scripts/screenshot.mjs,便于后续对照审视视觉改动

验证:单元测试 452 个、端到端测试 23 个全部通过,lint 与构建无错误。
This commit is contained in:
chenshoulin
2026-10-18 14:05:06 +08:00
parent 4e064e82ea
commit 442cbbd734
23 changed files with 981 additions and 333 deletions
+3
View File
@@ -18,6 +18,9 @@ playwright-report
blob-report
.playwright
# 界面截图(开发辅助产物)
.screenshots
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+1
View File
@@ -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` | 代码检查与格式化 |
## 快捷键
+1 -1
View File
@@ -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 }) => {
+7
View File
@@ -20,6 +20,13 @@ export default tseslint.config(
globals: { ...globals.browser },
},
},
{
// 构建/截图等脚本运行在 Node 环境
files: ['scripts/**/*.{js,mjs,ts}'],
languageOptions: {
globals: { ...globals.node },
},
},
{
rules: {
// 允许用下划线前缀标记「有意不使用」的参数与变量
+160
View File
@@ -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()
+48 -1
View File
@@ -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()
})
})
+35
View File
@@ -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()
},
})
</script>
<template>
@@ -23,6 +42,22 @@ onMounted(() => {
</main>
</div>
<BackupPanel :open="ui.backupOpen" @close="ui.closeBackup()" />
<SideDrawer :open="ui.helpOpen" title="快捷键" @close="ui.closeHelp()">
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
>
{{ item.keys }}
</dt>
<dd class="text-slate-600">{{ item.description }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
</SideDrawer>
<ToastHost />
<PwaUpdatePrompt />
</template>
+37 -6
View File
@@ -1,26 +1,34 @@
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 }) => {
describe('AppNav 导航', () => {
it('渲染看板与今日两个入口', async () => {
const { wrapper } = await setup()
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
})
})
it('当前路由的入口标记 aria-current', async () => {
const { wrapper } = await setup('/')
@@ -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('备份')
})
})
+66 -6
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
const LINKS = [
{ to: '/', label: '看板' },
{ to: '/today', label: '今日' },
@@ -9,11 +13,24 @@ const LINKS = [
<template>
<header
class="flex items-center gap-4 border-b border-slate-200 bg-white px-4 py-2.5 md:px-6"
class="flex items-center gap-3 border-b border-slate-200 bg-white/85 px-4 py-2.5 backdrop-blur-sm md:px-6"
>
<span class="text-sm font-semibold tracking-wide text-slate-800">看板</span>
<div class="flex items-center gap-2">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-900 text-white"
>
<svg viewBox="0 0 16 16" class="size-3.5" aria-hidden="true">
<rect x="1" y="2" width="3.4" height="12" rx="1.2" fill="currentColor" opacity="0.5" />
<rect x="6.3" y="2" width="3.4" height="8.5" rx="1.2" fill="currentColor" />
<rect x="11.6" y="2" width="3.4" height="5" rx="1.2" fill="currentColor" opacity="0.75" />
</svg>
</span>
<span class="hidden text-sm font-semibold tracking-tight text-slate-900 sm:inline">看板</span>
</div>
<nav class="flex items-center gap-1" aria-label="主导航">
<span class="h-5 w-px bg-slate-200" aria-hidden="true" />
<nav class="flex items-center gap-0.5" aria-label="主导航">
<RouterLink
v-for="link in LINKS"
:key="link.to"
@@ -23,11 +40,11 @@ const LINKS = [
>
<a
:href="href"
class="rounded-md px-2.5 py-1 text-sm transition"
class="rounded-md px-3 py-1.5 text-sm transition"
:class="
isExactActive
? 'bg-slate-800 font-medium text-white'
: 'text-slate-600 hover:bg-slate-100'
? 'bg-slate-900 font-medium text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
"
:aria-current="isExactActive ? 'page' : undefined"
@click="navigate"
@@ -36,5 +53,48 @@ const LINKS = [
</a>
</RouterLink>
</nav>
<div class="ml-auto flex items-center gap-1.5">
<button
type="button"
class="flex size-8 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
aria-label="快捷键帮助"
@click="ui.openHelp()"
>
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="7.25" stroke="currentColor" stroke-width="1.5" />
<path
d="M8.2 7.9a1.9 1.9 0 1 1 2.6 1.75c-.5.22-.8.7-.8 1.25v.35"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<circle cx="10" cy="13.7" r="0.9" fill="currentColor" />
</svg>
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
@click="ui.openBackup()"
>
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
<path
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.5 12.5v2A2 2 0 0 0 5.5 16.5h9a2 2 0 0 0 2-2v-2"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
备份
</button>
</div>
</header>
</template>
+2 -3
View File
@@ -64,8 +64,7 @@ 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')
})
expect(wrapper.find('[data-role="column-count"]').text()).toBe('2') })
it('空列显示空状态', () => {
const { wrapper } = setup([])
@@ -136,7 +135,7 @@ describe('BoardColumn 长列表', () => {
const { wrapper } = setup(makeMany(200))
expect(wrapper.findAll('article')).toHaveLength(200)
expect(wrapper.find('header span').text()).toBe('200')
expect(wrapper.find('[data-role="column-count"]').text()).toBe('200')
})
it('200 张卡片仍保持 sortOrder 升序渲染', () => {
+24 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useDraggable } from 'vue-draggable-plus'
import type { ColumnDef, ColumnId, Task } from '@/types/task'
@@ -16,6 +16,15 @@ const emit = defineEmits<{
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
}>()
/** 每列一个色点:三列在余光里也能区分开 */
const COLUMN_DOT: Record<ColumnId, string> = {
todo: 'bg-slate-400',
doing: 'bg-sky-500',
done: 'bg-emerald-500',
}
const dotClass = computed(() => COLUMN_DOT[props.column.id])
const listRef = ref<HTMLElement | null>(null)
const quickAddRef = ref<InstanceType<typeof QuickAddTask> | null>(null)
@@ -46,10 +55,14 @@ useDraggable(listRef, {
</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">
<section class="flex flex-col rounded-xl border border-slate-200 bg-slate-100/70 md:min-h-0">
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
<h2 class="text-sm font-medium text-slate-700">{{ column.title }}</h2>
<span
data-role="column-count"
class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500"
>
{{ tasks.length }}
</span>
</header>
@@ -59,7 +72,7 @@ useDraggable(listRef, {
<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"
class="flex flex-col gap-2 px-2 pb-2 md:min-h-0 md:flex-1 md:overflow-y-auto"
>
<TaskCard
v-for="task in tasks"
@@ -68,7 +81,11 @@ useDraggable(listRef, {
@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
v-if="tasks.length === 0"
class="rounded-lg border border-dashed border-slate-300/70 px-3 py-10 text-center text-xs text-slate-400"
>
暂无任务
</p>
</div>
+10 -24
View File
@@ -50,15 +50,15 @@ describe('BoardToolbar 统计', () => {
store.createTask({ title: '逾期二', dueDate: yesterday })
await wrapper.vm.$nextTick()
expect(buttonByText(wrapper, '今日到期')?.text()).toContain('今日到期 1')
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 2')
expect(buttonByText(wrapper, '今日到期')?.text()).toMatch(/今日到期\s*1/)
expect(buttonByText(wrapper, '逾期')?.text()).toMatch(/逾期\s*2/)
})
it('已完成任务不计入到期统计', async () => {
const { store, wrapper } = setup()
store.createTask({ title: '已完成', dueDate: yesterday, status: 'done' })
await wrapper.vm.$nextTick()
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 0')
expect(buttonByText(wrapper, '逾期')?.text()).toMatch(/逾期\s*0/)
})
})
@@ -82,7 +82,7 @@ describe('BoardToolbar 筛选交互', () => {
expect(filters.due).toBe('today')
})
it('优先级按钮切换筛选条件', async () => {
it('优先级分段控件切换筛选条件', async () => {
const { filters, wrapper } = setup()
await buttonByText(wrapper, '高')?.trigger('click')
expect(filters.priorities).toEqual([3])
@@ -91,14 +91,18 @@ describe('BoardToolbar 筛选交互', () => {
expect(filters.priorities).toEqual([])
})
it('标签候选来自现有任务,点击可筛选', async () => {
it('标签收在下拉里,展开后可选', async () => {
const { store, filters, wrapper } = setup()
store.createTask({ title: 'A', tags: ['设计'] })
await wrapper.vm.$nextTick()
// 收起状态下不应把标签平铺出来
expect(wrapper.text()).not.toContain('#设计')
await wrapper.find('button[aria-haspopup="true"]').trigger('click')
expect(wrapper.text()).toContain('#设计')
await buttonByText(wrapper, '#设计')?.trigger('click')
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(filters.tags).toEqual(['设计'])
})
@@ -123,24 +127,6 @@ describe('BoardToolbar 筛选交互', () => {
})
})
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())
+77 -78
View File
@@ -5,10 +5,14 @@ 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'
import { isDueToday, isOverdue } from '@/utils/date'
const emit = defineEmits<{ 'open-backup': []; 'open-help': []; archive: [] }>()
import TagFilter from './TagFilter.vue'
defineProps<{ hiddenNote?: string }>()
const emit = defineEmits<{ archive: [] }>()
const store = useTaskStore()
const filters = useFilterStore()
@@ -16,7 +20,7 @@ const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(
const searchRef = ref<HTMLInputElement | null>(null)
/** 由高到低排列,与看板中「高优先级优先处理」的心智一致 */
/** 由高到低排列,与「高优先级优先处理」的心智一致 */
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
const dueTodayCount = computed(
@@ -47,77 +51,42 @@ 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>
<header class="flex flex-col gap-2.5">
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-56 flex-1 sm:max-w-xs">
<svg
viewBox="0 0 20 20"
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400"
fill="none"
aria-hidden="true"
>
<circle cx="9" cy="9" r="5.25" stroke="currentColor" stroke-width="1.5" />
<path d="m13 13 3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<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="搜索标题或描述(/ 聚焦)"
class="w-full rounded-md border border-slate-200 bg-white py-1.5 pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
placeholder="搜索标题或描述"
aria-label="搜索任务"
/>
</div>
<div class="flex items-center gap-1">
<div
class="flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5"
role="group"
aria-label="按优先级筛选"
>
<button
v-for="value in PRIORITY_ORDER"
:key="value"
type="button"
class="rounded-md border px-2 py-1 text-xs transition"
class="rounded px-2.5 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'
? 'bg-slate-900 font-medium text-white'
: 'text-slate-600 hover:bg-slate-100'
"
:aria-pressed="priorities.includes(value)"
@click="filters.togglePriority(value)"
@@ -126,36 +95,57 @@ defineExpose({ focusSearch })
</button>
</div>
<TagFilter
:tags="allTags"
:selected="tags"
@toggle="filters.toggleTag($event)"
@clear="filters.clearTags()"
/>
<div class="ml-auto flex items-center gap-1.5">
<button
v-for="tag in allTags"
:key="tag"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition"
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.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'
due === 'today'
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
"
:aria-pressed="tags.includes(tag)"
@click="filters.toggleTag(tag)"
:aria-pressed="due === 'today'"
@click="filters.toggleDue('today')"
>
#{{ tag }}
今日到期
<span class="font-semibold">{{ dueTodayCount }}</span>
</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()"
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
:class="
due === 'overdue'
? 'border-rose-300 bg-rose-50 text-rose-700'
: overdueCount > 0
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
"
:aria-pressed="due === 'overdue'"
@click="filters.toggleDue('overdue')"
>
清空筛选
逾期
<span class="font-semibold">{{ overdueCount }}</span>
</button>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500">
<span>共 {{ store.tasks.length }} 个任务</span>
<span v-if="hiddenNote" class="text-slate-400">{{ hiddenNote }}</span>
<div class="ml-auto flex items-center gap-1.5">
<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"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
@click="emit('archive')"
>
归档 {{ archivableCount }} 个已完成
@@ -163,17 +153,26 @@ defineExpose({ focusSearch })
<button
type="button"
class="rounded-md border px-2 py-1 text-xs transition"
class="rounded-md border px-2.5 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'
? 'border-sky-300 bg-sky-50 text-sky-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
"
:aria-pressed="includeArchived"
@click="filters.toggleArchived()"
>
显示归档
</button>
<button
v-if="active"
type="button"
class="rounded-md px-2.5 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline"
@click="filters.clear()"
>
清空筛选
</button>
</div>
</div>
</header>
+2 -2
View File
@@ -37,8 +37,8 @@ defineExpose({ focusInput })
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="+ 添加任务,回车确认"
class="w-full rounded-lg border border-transparent bg-white/60 px-3 py-2 text-sm text-slate-700 transition outline-none placeholder:text-slate-400 hover:bg-white focus:border-sky-300 focus:bg-white focus:ring-2 focus:ring-sky-100"
placeholder="+ 添加任务"
aria-label="添加任务"
@compositionstart="composing = true"
@compositionend="composing = false"
+139
View File
@@ -0,0 +1,139 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import TagFilter from './TagFilter.vue'
const mounted: Array<{ unmount: () => void }> = []
function mountFilter(tags: string[] = ['设计', '前端'], selected: string[] = []) {
const wrapper = mount(TagFilter, {
props: { tags, selected },
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
function triggerButton(wrapper: ReturnType<typeof mountFilter>) {
return wrapper.find('button[aria-haspopup="true"]')
}
function lastEmitted(wrapper: ReturnType<typeof mountFilter>, event: 'toggle' | 'clear') {
const events = wrapper.emitted(event)
return events?.[events.length - 1]?.[0]
}
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount()
document.body.innerHTML = ''
})
describe('TagFilter 展开与收起', () => {
it('默认收起,不渲染标签列表', () => {
const wrapper = mountFilter()
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
expect(triggerButton(wrapper).attributes('aria-expanded')).toBe('false')
})
it('点击按钮展开', async () => {
const wrapper = mountFilter()
await triggerButton(wrapper).trigger('click')
expect(triggerButton(wrapper).attributes('aria-expanded')).toBe('true')
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(2)
})
it('再次点击收起', async () => {
const wrapper = mountFilter()
await triggerButton(wrapper).trigger('click')
await triggerButton(wrapper).trigger('click')
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
})
it('点击面板外部收起', async () => {
const wrapper = mountFilter()
await triggerButton(wrapper).trigger('click')
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
})
it('按 Esc 收起', async () => {
const wrapper = mountFilter()
await triggerButton(wrapper).trigger('click')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
})
})
describe('TagFilter 选择', () => {
it('渲染全部候选标签', async () => {
const wrapper = mountFilter(['设计', '前端', '后端'])
await triggerButton(wrapper).trigger('click')
expect(wrapper.text()).toContain('#设计')
expect(wrapper.text()).toContain('#前端')
expect(wrapper.text()).toContain('#后端')
})
it('已选中的标签复选框为选中态', async () => {
const wrapper = mountFilter(['设计', '前端'], ['前端'])
await triggerButton(wrapper).trigger('click')
const boxes = wrapper.findAll('input[type="checkbox"]')
expect((boxes[0]?.element as HTMLInputElement).checked).toBe(false)
expect((boxes[1]?.element as HTMLInputElement).checked).toBe(true)
})
it('勾选时 emit toggle 并带上标签名', async () => {
const wrapper = mountFilter(['设计'])
await triggerButton(wrapper).trigger('click')
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(lastEmitted(wrapper, 'toggle')).toBe('设计')
})
it('按钮上显示已选数量', async () => {
const wrapper = mountFilter(['设计', '前端'], ['设计', '前端'])
expect(triggerButton(wrapper).text()).toContain('2')
})
it('未选中时不显示数量角标', () => {
const wrapper = mountFilter(['设计'], [])
expect(triggerButton(wrapper).text()).not.toMatch(/\d/)
})
it('有选中项时提供清除入口', async () => {
const wrapper = mountFilter(['设计'], ['设计'])
await triggerButton(wrapper).trigger('click')
const clearButton = wrapper.findAll('button').find((node) => node.text() === '清除标签筛选')
expect(clearButton).toBeDefined()
await clearButton?.trigger('click')
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('无选中项时不显示清除入口', async () => {
const wrapper = mountFilter(['设计'], [])
await triggerButton(wrapper).trigger('click')
expect(wrapper.findAll('button').map((node) => node.text())).not.toContain('清除标签筛选')
})
})
describe('TagFilter 空状态', () => {
it('没有任何标签时给出提示', async () => {
const wrapper = mountFilter([], [])
await triggerButton(wrapper).trigger('click')
expect(wrapper.text()).toContain('还没有任何标签')
})
})
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
/**
* 标签筛选下拉
*
* 之前把所有标签平铺成一行 chip,标签一多就会换行挤爆工具栏(移动端尤其严重)。
* 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
const emit = defineEmits<{ toggle: [tag: string]; clear: [] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
function onDocumentClick(event: MouseEvent): void {
if (!open.value) return
const root = rootRef.value
// 点击面板内部(含触发按钮)不关闭
if (root && event.target instanceof Node && root.contains(event.target)) return
open.value = false
}
function onDocumentKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !open.value) return
event.stopPropagation()
open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
document.removeEventListener('keydown', onDocumentKeydown, true)
})
</script>
<template>
<div ref="rootRef" class="relative">
<button
type="button"
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm transition"
:class="
selected.length > 0
? 'border-sky-300 bg-sky-50 text-sky-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
"
:aria-expanded="open"
aria-haspopup="true"
@click="open = !open"
>
标签
<span
v-if="selected.length > 0"
class="rounded-full bg-sky-600 px-1.5 text-[11px] leading-4 font-medium text-white"
>
{{ selected.length }}
</span>
<svg
viewBox="0 0 12 12"
class="size-3 transition"
:class="open ? 'rotate-180' : ''"
fill="none"
aria-hidden="true"
>
<path
d="m3 4.5 3 3 3-3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div
v-if="open"
class="absolute top-full left-0 z-20 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop"
>
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400">
还没有任何标签
</p>
<ul v-else class="max-h-64 overflow-y-auto">
<li v-for="tag in tags" :key="tag">
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-slate-50"
>
<input
type="checkbox"
class="size-3.5 accent-sky-600"
:checked="selected.includes(tag)"
@change="emit('toggle', tag)"
/>
<span class="truncate">#{{ tag }}</span>
</label>
</li>
</ul>
<button
v-if="selected.length > 0"
type="button"
class="mt-1 w-full rounded-md px-2 py-1.5 text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
@click="emit('clear')"
>
清除标签筛选
</button>
</div>
</div>
</template>
+46 -22
View File
@@ -15,37 +15,66 @@ 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(() => {
/**
* 优先级用左侧色条表达,而不是一枚彩色胶囊。
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
*/
const priorityBarClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'bg-rose-100 text-rose-700'
return 'bg-rose-400'
case 2:
return 'bg-amber-100 text-amber-700'
return 'bg-amber-400'
default:
return 'bg-slate-100 text-slate-500'
return 'bg-slate-300'
}
})
const priorityTextClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'text-rose-600'
case 2:
return 'text-amber-700'
default:
return 'text-slate-400'
}
})
</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'"
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150"
:class="isDone ? 'opacity-60' : 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover'"
:data-task-id="task.id"
@click="emit('open', task.id)"
>
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
<div class="py-2 pr-2.5 pl-3.5">
<div class="flex items-start gap-2">
<button
type="button"
class="mt-0.5 size-4 shrink-0 rounded-full border transition"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
:class="
isDone ? 'border-emerald-500 bg-emerald-500' : 'border-slate-300 hover:border-emerald-500'
isDone
? 'border-emerald-500 bg-emerald-500 text-white'
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50'
"
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
:aria-pressed="isDone"
@click.stop="emit('toggle', task.id)"
>
<svg v-if="isDone" viewBox="0 0 12 12" class="size-2.5" fill="none" aria-hidden="true">
<path
d="m2.5 6.2 2.2 2.2 4.8-4.8"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<h3
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
@@ -55,27 +84,22 @@ const priorityClass = computed(() => {
</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">
<div class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 pl-6 text-xs">
<span class="font-medium" :class="priorityTextClass">
{{ 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'"
class="font-medium"
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
>
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
{{ 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>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400">#{{ tag }}</span>
<span v-if="hiddenTagCount > 0" class="text-slate-400">+{{ hiddenTagCount }}</span>
</div>
</div>
</article>
</template>
+5
View File
@@ -73,6 +73,10 @@ export const useFilterStore = defineStore('filters', () => {
due.value = due.value === value ? null : value
}
function clearTags(): void {
tags.value = []
}
function toggleArchived(): void {
includeArchived.value = !includeArchived.value
}
@@ -97,6 +101,7 @@ export const useFilterStore = defineStore('filters', () => {
togglePriority,
toggleTag,
toggleDue,
clearTags,
toggleArchived,
clear,
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 应用级面板状态
*
* 备份面板与快捷键帮助都是**应用级**操作(在哪个视图都该能用),
* 因此状态放在这里,由 App.vue 统一渲染,
* 导航栏负责打开,看板负责在它们打开时让出快捷键。
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const backupOpen = ref(false)
const helpOpen = ref(false)
const anyPanelOpen = computed(() => backupOpen.value || helpOpen.value)
function openBackup(): void {
backupOpen.value = true
}
function closeBackup(): void {
backupOpen.value = false
}
function openHelp(): void {
helpOpen.value = true
}
function closeHelp(): void {
helpOpen.value = false
}
function closeAll(): void {
backupOpen.value = false
helpOpen.value = false
}
return {
backupOpen,
helpOpen,
anyPanelOpen,
openBackup,
closeBackup,
openHelp,
closeHelp,
closeAll,
}
})
+33 -5
View File
@@ -3,14 +3,42 @@
@theme {
--font-sans:
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
/* 卡片与面板的阴影:用极低不透明度的多层阴影代替单层重阴影,看起来更干净 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.04), 0 1px 3px rgb(15 23 42 / 0.06);
--shadow-card-hover: 0 6px 16px rgb(15 23 42 / 0.09), 0 1px 3px rgb(15 23 42 / 0.05);
--shadow-panel: 0 16px 40px rgb(15 23 42 / 0.12), 0 2px 8px rgb(15 23 42 / 0.06);
--shadow-pop: 0 8px 24px rgb(15 23 42 / 0.12), 0 1px 3px rgb(15 23 42 / 0.06);
}
html,
body,
#app {
@layer base {
html,
body,
#app {
height: 100%;
}
}
body {
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* 统一的键盘焦点样式,鼠标点击不显示 */
:focus-visible {
outline: 2px solid var(--color-sky-500);
outline-offset: 2px;
border-radius: 4px;
}
::selection {
background: var(--color-sky-200);
}
/* 细滚动条,避免在列里出现粗重的系统滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-slate-300) transparent;
}
}
+9 -32
View File
@@ -9,6 +9,7 @@ import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { useUiStore } from '@/stores/ui'
import BoardView from './BoardView.vue'
@@ -53,7 +54,7 @@ describe('BoardView 结构', () => {
store.createTask({ title: '进行中事项', status: 'doing' })
const counters = mountBoard(pinia)
.findAll('section header span')
.findAll('[data-role="column-count"]')
.map((node) => node.text())
expect(counters).toEqual(['1', '1', '0'])
})
@@ -245,7 +246,7 @@ describe('BoardView 筛选', () => {
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('高优先级')
expect(wrapper.text()).not.toContain('低优先级')
expect(wrapper.findAll('section header span')[0]?.text()).toBe('1')
expect(wrapper.findAll('[data-role="column-count"]')[0]?.text()).toBe('1')
})
it('显示命中数量提示', async () => {
@@ -257,7 +258,7 @@ describe('BoardView 筛选', () => {
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
expect(wrapper.text()).toContain('已隐藏 1 个')
})
it('清空筛选后恢复全部卡片', async () => {
@@ -273,7 +274,7 @@ describe('BoardView 筛选', () => {
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(2)
expect(wrapper.text()).not.toContain('显示 1 / 2')
expect(wrapper.text()).not.toContain('已隐藏')
})
it('点击「逾期」只显示逾期任务', async () => {
@@ -327,7 +328,7 @@ describe('BoardView 归档', () => {
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
expect(wrapper.text()).toContain('已隐藏 1 个')
})
it('开启「显示归档」后归档任务重新出现', async () => {
@@ -400,17 +401,6 @@ describe('BoardView 快捷键', () => {
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
})
it('按 ? 打开快捷键帮助', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('?')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
})
it('在输入框内按 N 不触发快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
@@ -422,29 +412,16 @@ describe('BoardView 快捷键', () => {
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('面板打开时不响应快捷键', async () => {
it('应用级面板打开时不响应快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
// 帮助面板由 App 渲染,这里只验证看板会让出快捷键
useUiStore().openHelp()
await nextTick()
pressKey('n')
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('按 Esc 关闭已打开的面板', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
pressKey('Escape')
await nextTick()
expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框')
})
})
+11 -49
View File
@@ -2,15 +2,14 @@
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import BackupPanel from '@/components/BackupPanel.vue'
import BoardColumn from '@/components/BoardColumn.vue'
import BoardToolbar from '@/components/BoardToolbar.vue'
import SideDrawer from '@/components/SideDrawer.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { useUiStore } from '@/stores/ui'
import { COLUMNS, type ColumnId } from '@/types/task'
import { findArchivableTasks } from '@/utils/archive'
import { filterTasks } from '@/utils/filter'
@@ -19,23 +18,14 @@ import { groupByColumn } from '@/utils/sortOrder'
const store = useTaskStore()
const filters = useFilterStore()
const toast = useToastStore()
const ui = useUiStore()
const { notice } = storeToRefs(store)
const { filter } = storeToRefs(filters)
const SHORTCUTS = [
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
{ keys: '/', description: '聚焦搜索框' },
{ keys: '?', description: '打开本帮助' },
{ keys: 'Esc', description: '关闭当前面板' },
] as const
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
const backupOpen = ref(false)
const helpOpen = ref(false)
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
@@ -47,10 +37,6 @@ function setColumnRef(element: unknown, index: number): void {
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
const visibleCount = computed(() =>
COLUMNS.reduce((total, column) => total + visibleColumns.value[column.id].length, 0),
)
/**
* 每列被隐藏的数量(被筛选掉 + 已归档)。
* 大于 0 说明该列渲染的是子集,此时必须禁用列内排序,
@@ -84,14 +70,10 @@ function archiveCompleted(): void {
})
}
function anyPanelOpen(): boolean {
return editingId.value !== null || backupOpen.value || helpOpen.value
}
/** 有面板打开时不响应全局快捷键,避免误触 */
function whenNoPanel(action: () => void): () => void {
return () => {
if (anyPanelOpen()) return
if (editingId.value !== null || ui.anyPanelOpen) return
action()
}
}
@@ -99,9 +81,6 @@ function whenNoPanel(action: () => void): () => void {
useKeyboardShortcuts({
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
'?': whenNoPanel(() => {
helpOpen.value = true
}),
})
function toggleTask(id: string): void {
@@ -124,26 +103,25 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4 md:p-6">
<div class="flex min-h-0 flex-1 flex-col gap-3 p-4 md:p-6">
<BoardToolbar
ref="toolbarRef"
@open-backup="backupOpen = true"
@open-help="helpOpen = true"
:hidden-note="hiddenTotal > 0 ? `· 已隐藏 ${hiddenTotal} 个` : undefined"
@archive="archiveCompleted"
/>
<p
v-if="notice"
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
class="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
{{ notice }}
</p>
<p v-if="hiddenTotal > 0" class="text-xs text-slate-500">
显示 {{ visibleCount }} / {{ store.tasks.length }} 个任务
</p>
<div class="grid min-h-0 flex-1 gap-4 overflow-y-auto md:grid-cols-3 md:overflow-hidden">
<!--
移动端:整块区域滚动,各列按内容自然高度排布;
桌面端:容器固定高度,每列内部各自滚动。
-->
<div class="grid flex-1 gap-3 overflow-y-auto md:min-h-0 md:grid-cols-3 md:overflow-hidden">
<BoardColumn
v-for="(column, index) in COLUMNS"
:key="column.id"
@@ -158,21 +136,5 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
</div>
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
<BackupPanel :open="backupOpen" @close="backupOpen = false" />
<SideDrawer :open="helpOpen" title="快捷键" @close="helpOpen = false">
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded border border-slate-300 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
>
{{ item.keys }}
</dt>
<dd class="text-slate-600">{{ item.description }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
</SideDrawer>
</div>
</template>
+27 -23
View File
@@ -13,9 +13,9 @@ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
const TONES = {
rose: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700' },
amber: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700' },
sky: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700' },
rose: { dot: 'bg-rose-500', badge: 'border-rose-200 bg-rose-50 text-rose-700' },
amber: { dot: 'bg-amber-500', badge: 'border-amber-200 bg-amber-50 text-amber-700' },
sky: { dot: 'bg-sky-500', badge: 'border-sky-200 bg-sky-50 text-sky-700' },
} as const
const store = useTaskStore()
@@ -31,10 +31,10 @@ const stats = computed(() => computeStats(tasks.value))
const todayCount = computed(() => countTodayTasks(buckets.value))
const statCards = computed(() => [
{ label: '今日待办', value: todayCount.value },
{ label: '逾期', value: stats.value.overdue },
{ label: '今天到期', value: stats.value.dueToday },
{ label: '本周完成', value: stats.value.completedThisWeek },
{ label: '今日待办', value: todayCount.value, dot: 'bg-slate-400' },
{ label: '逾期', value: stats.value.overdue, dot: 'bg-rose-500' },
{ label: '今天到期', value: stats.value.dueToday, dot: 'bg-amber-500' },
{ label: '本周完成', value: stats.value.completedThisWeek, dot: 'bg-emerald-500' },
])
/**
@@ -79,9 +79,11 @@ function toggleTask(id: string): void {
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4 md:p-6">
<header class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-slate-800 md:text-2xl">今日</h1>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<!-- 限宽居中:否则 1440px 下卡片会被拉满整屏,标题与元信息之间出现大片空白 -->
<div class="mx-auto flex w-full max-w-3xl flex-col gap-5 p-4 md:p-6">
<header class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold tracking-tight text-slate-900 md:text-2xl">今日</h1>
<p class="text-sm text-slate-500">{{ todayLabel }}</p>
</header>
@@ -89,32 +91,33 @@ function toggleTask(id: string): void {
<div
v-for="card in statCards"
:key="card.label"
class="rounded-lg bg-white px-4 py-3 shadow-sm"
class="rounded-xl border border-slate-200 bg-white px-4 py-3.5 shadow-card"
>
<dt class="text-xs text-slate-500">{{ card.label }}</dt>
<dd class="mt-1 text-2xl font-semibold text-slate-800">{{ card.value }}</dd>
<dt class="flex items-center gap-1.5 text-xs text-slate-500">
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
{{ card.label }}
</dt>
<dd class="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900">
{{ card.value }}
</dd>
</div>
</dl>
<p
v-if="todayCount === 0"
class="rounded-lg bg-white px-4 py-12 text-center text-sm text-slate-400 shadow-sm"
class="rounded-xl border border-dashed border-slate-300 bg-white/60 px-4 py-16 text-center text-sm text-slate-400"
>
今天没有待办事项
</p>
<section
v-for="section in sections"
:key="section.key"
class="flex flex-col gap-2"
>
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-600">
<span class="size-2 rounded-full" :class="TONES[section.tone].dot" />
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-700">
<span class="size-1.5 rounded-full" :class="TONES[section.tone].dot" aria-hidden="true" />
{{ section.title }}
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="TONES[section.tone].badge"
data-role="section-count"
class="rounded-full border px-2 py-0.5 text-xs"
:class="TONES[section.tone].badge"
>
{{ section.items.length }}
</span>
@@ -131,6 +134,7 @@ function toggleTask(id: string): void {
/>
</div>
</section>
</div>
<TaskDetailDrawer :task="editingTask" @close="editingId = null" />
</div>