style: 重做界面视觉与信息层级
先截图审视现状,再针对性修改。主要问题与改动如下。 工具栏 - 标签筛选从「平铺全部标签」改为下拉面板,标签多时不再换行挤爆工具栏 - 备份与快捷键帮助移到导航栏 —— 它们是应用级操作,不该挤在看板工具栏里, 顺带修好了「? 帮助只在看板可用」的问题 - 到期统计(今日到期 / 逾期)合并为可点击的筛选入口,去掉两个独立控件 - 移动端工具栏从占半屏降到三行紧凑布局 卡片 - 优先级从彩色胶囊改为左侧色条,扫视一列时比读文字更快分辨轻重 - 元信息改为「优先级 · 日期 · 标签」的轻量文本行,不再三种胶囊叠在一起 - 完成态用勾选图标 + 降低不透明度 布局 - 今日视图限宽居中(max-w-3xl):此前 1440px 下卡片被拉满整屏, 标题与元信息之间出现大片空白 - 修复移动端卡片被压成细条:TaskCard 的 overflow-hidden(用于裁切左侧色条) 会让 flex 项的最小尺寸解析为 0,补 shrink-0 修正; 同时改为移动端按内容高度堆叠、桌面端列内滚动 其他 - 导航栏品牌改用看板图标,消除「看板」重复出现两次 - 统一卡片阴影、圆角与键盘焦点样式 - 新增 scripts/screenshot.mjs,便于后续对照审视视觉改动 验证:单元测试 452 个、端到端测试 23 个全部通过,lint 与构建无错误。
This commit is contained in:
@@ -18,6 +18,9 @@ playwright-report
|
|||||||
blob-report
|
blob-report
|
||||||
.playwright
|
.playwright
|
||||||
|
|
||||||
|
# 界面截图(开发辅助产物)
|
||||||
|
.screenshots
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ npm run dev
|
|||||||
| `npm run test` | 单元测试(Vitest) |
|
| `npm run test` | 单元测试(Vitest) |
|
||||||
| `npm run test:coverage` | 单元测试 + 覆盖率报告 |
|
| `npm run test:coverage` | 单元测试 + 覆盖率报告 |
|
||||||
| `npm run test:e2e` | 端到端测试(Playwright,默认驱动系统已安装的 Chrome) |
|
| `npm run test:e2e` | 端到端测试(Playwright,默认驱动系统已安装的 Chrome) |
|
||||||
|
| `node scripts/screenshot.mjs` | 生成界面截图(需先起 dev server),产物在 `.screenshots/` |
|
||||||
| `npm run lint` / `npm run format` | 代码检查与格式化 |
|
| `npm run lint` / `npm run format` | 代码检查与格式化 |
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ test('搜索筛选后列计数同步变化', async ({ page }) => {
|
|||||||
// 关键词有 200ms 防抖
|
// 关键词有 200ms 防抖
|
||||||
await expect(page.locator('article')).toHaveCount(1)
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible()
|
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 }) => {
|
test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => {
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export default tseslint.config(
|
|||||||
globals: { ...globals.browser },
|
globals: { ...globals.browser },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 构建/截图等脚本运行在 Node 环境
|
||||||
|
files: ['scripts/**/*.{js,mjs,ts}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
// 允许用下划线前缀标记「有意不使用」的参数与变量
|
// 允许用下划线前缀标记「有意不使用」的参数与变量
|
||||||
|
|||||||
@@ -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
@@ -34,7 +34,8 @@ async function mountApp(initialPath = '/') {
|
|||||||
await router.push(initialPath)
|
await router.push(initialPath)
|
||||||
|
|
||||||
const wrapper = mount(App, {
|
const wrapper = mount(App, {
|
||||||
global: { plugins: [pinia, router] },
|
// 抽屉与面板的内容会被 Teleport 到 body,测试中改为就地渲染以便断言
|
||||||
|
global: { plugins: [pinia, router], stubs: { teleport: true } },
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
})
|
})
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
@@ -143,3 +144,49 @@ describe('App 路由', () => {
|
|||||||
wrapper.unmount()
|
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
@@ -3,15 +3,34 @@ import { onMounted } from 'vue'
|
|||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
import AppNav from '@/components/AppNav.vue'
|
import AppNav from '@/components/AppNav.vue'
|
||||||
|
import BackupPanel from '@/components/BackupPanel.vue'
|
||||||
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
|
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
|
||||||
|
import SideDrawer from '@/components/SideDrawer.vue'
|
||||||
import ToastHost from '@/components/ToastHost.vue'
|
import ToastHost from '@/components/ToastHost.vue'
|
||||||
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
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 store = useTaskStore()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.init()
|
store.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 帮助面板是应用级的,因此在任何视图下都能用 ?
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
'?': () => {
|
||||||
|
if (!ui.anyPanelOpen) ui.openHelp()
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,6 +42,22 @@ onMounted(() => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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 />
|
<ToastHost />
|
||||||
<PwaUpdatePrompt />
|
<PwaUpdatePrompt />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { routes } from '@/router'
|
import { routes } from '@/router'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
import AppNav from './AppNav.vue'
|
import AppNav from './AppNav.vue'
|
||||||
|
|
||||||
async function setup(initialPath = '/') {
|
async function setup(initialPath = '/') {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||||
await router.push(initialPath)
|
await router.push(initialPath)
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] }, attachTo: document.body })
|
const wrapper = mount(AppNav, {
|
||||||
return { wrapper, router }
|
global: { plugins: [pinia, router] },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { wrapper, router, ui: useUiStore() }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AppNav', () => {
|
describe('AppNav 导航', () => {
|
||||||
it('渲染看板与今日两个入口', () => {
|
it('渲染看板与今日两个入口', async () => {
|
||||||
return setup().then(({ wrapper }) => {
|
const { wrapper } = await setup()
|
||||||
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('当前路由的入口标记 aria-current', async () => {
|
it('当前路由的入口标记 aria-current', async () => {
|
||||||
@@ -53,3 +61,26 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('主导航')
|
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('备份')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ to: '/', label: '看板' },
|
{ to: '/', label: '看板' },
|
||||||
{ to: '/today', label: '今日' },
|
{ to: '/today', label: '今日' },
|
||||||
@@ -9,11 +13,24 @@ const LINKS = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<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
|
<RouterLink
|
||||||
v-for="link in LINKS"
|
v-for="link in LINKS"
|
||||||
:key="link.to"
|
:key="link.to"
|
||||||
@@ -23,11 +40,11 @@ const LINKS = [
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
:href="href"
|
: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="
|
:class="
|
||||||
isExactActive
|
isExactActive
|
||||||
? 'bg-slate-800 font-medium text-white'
|
? 'bg-slate-900 font-medium text-white shadow-sm'
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||||
"
|
"
|
||||||
:aria-current="isExactActive ? 'page' : undefined"
|
:aria-current="isExactActive ? 'page' : undefined"
|
||||||
@click="navigate"
|
@click="navigate"
|
||||||
@@ -36,5 +53,48 @@ const LINKS = [
|
|||||||
</a>
|
</a>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ describe('BoardColumn 渲染', () => {
|
|||||||
it('渲染列标题与计数', () => {
|
it('渲染列标题与计数', () => {
|
||||||
const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })])
|
const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })])
|
||||||
expect(wrapper.find('h2').text()).toBe('待办')
|
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('空列显示空状态', () => {
|
it('空列显示空状态', () => {
|
||||||
const { wrapper } = setup([])
|
const { wrapper } = setup([])
|
||||||
@@ -136,7 +135,7 @@ describe('BoardColumn 长列表', () => {
|
|||||||
const { wrapper } = setup(makeMany(200))
|
const { wrapper } = setup(makeMany(200))
|
||||||
|
|
||||||
expect(wrapper.findAll('article')).toHaveLength(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 升序渲染', () => {
|
it('200 张卡片仍保持 sortOrder 升序渲染', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useDraggable } from 'vue-draggable-plus'
|
import { useDraggable } from 'vue-draggable-plus'
|
||||||
|
|
||||||
import type { ColumnDef, ColumnId, Task } from '@/types/task'
|
import type { ColumnDef, ColumnId, Task } from '@/types/task'
|
||||||
@@ -16,6 +16,15 @@ const emit = defineEmits<{
|
|||||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
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 listRef = ref<HTMLElement | null>(null)
|
||||||
const quickAddRef = ref<InstanceType<typeof QuickAddTask> | null>(null)
|
const quickAddRef = ref<InstanceType<typeof QuickAddTask> | null>(null)
|
||||||
|
|
||||||
@@ -46,10 +55,14 @@ useDraggable(listRef, {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex min-h-0 flex-col rounded-lg bg-slate-200/60">
|
<section class="flex flex-col rounded-xl border border-slate-200 bg-slate-100/70 md:min-h-0">
|
||||||
<header class="flex items-center justify-between px-3 py-2.5">
|
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||||
<h2 class="text-sm font-medium text-slate-600">{{ column.title }}</h2>
|
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
|
||||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-slate-500">
|
<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 }}
|
{{ tasks.length }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -59,7 +72,7 @@ useDraggable(listRef, {
|
|||||||
<div
|
<div
|
||||||
ref="listRef"
|
ref="listRef"
|
||||||
:data-column-id="column.id"
|
: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
|
<TaskCard
|
||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
@@ -68,7 +81,11 @@ useDraggable(listRef, {
|
|||||||
@toggle="emit('toggle', $event)"
|
@toggle="emit('toggle', $event)"
|
||||||
@open="emit('open', $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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ describe('BoardToolbar 统计', () => {
|
|||||||
store.createTask({ title: '逾期二', dueDate: yesterday })
|
store.createTask({ title: '逾期二', dueDate: yesterday })
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(buttonByText(wrapper, '今日到期')?.text()).toContain('今日到期 1')
|
expect(buttonByText(wrapper, '今日到期')?.text()).toMatch(/今日到期\s*1/)
|
||||||
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 2')
|
expect(buttonByText(wrapper, '逾期')?.text()).toMatch(/逾期\s*2/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('已完成任务不计入到期统计', async () => {
|
it('已完成任务不计入到期统计', async () => {
|
||||||
const { store, wrapper } = setup()
|
const { store, wrapper } = setup()
|
||||||
store.createTask({ title: '已完成', dueDate: yesterday, status: 'done' })
|
store.createTask({ title: '已完成', dueDate: yesterday, status: 'done' })
|
||||||
await wrapper.vm.$nextTick()
|
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')
|
expect(filters.due).toBe('today')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('优先级按钮切换筛选条件', async () => {
|
it('优先级分段控件切换筛选条件', async () => {
|
||||||
const { filters, wrapper } = setup()
|
const { filters, wrapper } = setup()
|
||||||
await buttonByText(wrapper, '高')?.trigger('click')
|
await buttonByText(wrapper, '高')?.trigger('click')
|
||||||
expect(filters.priorities).toEqual([3])
|
expect(filters.priorities).toEqual([3])
|
||||||
@@ -91,14 +91,18 @@ describe('BoardToolbar 筛选交互', () => {
|
|||||||
expect(filters.priorities).toEqual([])
|
expect(filters.priorities).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('标签候选来自现有任务,点击可筛选', async () => {
|
it('标签收在下拉里,展开后可选', async () => {
|
||||||
const { store, filters, wrapper } = setup()
|
const { store, filters, wrapper } = setup()
|
||||||
store.createTask({ title: 'A', tags: ['设计'] })
|
store.createTask({ title: 'A', tags: ['设计'] })
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
// 收起状态下不应把标签平铺出来
|
||||||
|
expect(wrapper.text()).not.toContain('#设计')
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-haspopup="true"]').trigger('click')
|
||||||
expect(wrapper.text()).toContain('#设计')
|
expect(wrapper.text()).toContain('#设计')
|
||||||
|
|
||||||
await buttonByText(wrapper, '#设计')?.trigger('click')
|
await wrapper.find('input[type="checkbox"]').trigger('change')
|
||||||
expect(filters.tags).toEqual(['设计'])
|
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 焦点', () => {
|
describe('BoardToolbar 焦点', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { computed, ref } from 'vue'
|
|||||||
import { useFilterStore } from '@/stores/filters'
|
import { useFilterStore } from '@/stores/filters'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { PRIORITY_LABELS, type Priority } from '@/types/task'
|
import { PRIORITY_LABELS, type Priority } from '@/types/task'
|
||||||
import { isDueToday, isOverdue } from '@/utils/date'
|
|
||||||
import { findArchivableTasks } from '@/utils/archive'
|
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 store = useTaskStore()
|
||||||
const filters = useFilterStore()
|
const filters = useFilterStore()
|
||||||
@@ -16,7 +20,7 @@ const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(
|
|||||||
|
|
||||||
const searchRef = ref<HTMLInputElement | null>(null)
|
const searchRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
/** 由高到低排列,与看板中「高优先级优先处理」的心智一致 */
|
/** 由高到低排列,与「高优先级优先处理」的心智一致 */
|
||||||
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
||||||
|
|
||||||
const dueTodayCount = computed(
|
const dueTodayCount = computed(
|
||||||
@@ -47,77 +51,42 @@ defineExpose({ focusSearch })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="flex flex-col gap-3">
|
<header class="flex flex-col gap-2.5">
|
||||||
<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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
||||||
ref="searchRef"
|
<svg
|
||||||
v-model="keyword"
|
viewBox="0 0 20 20"
|
||||||
type="search"
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400"
|
||||||
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"
|
fill="none"
|
||||||
placeholder="搜索标题或描述(/ 聚焦)"
|
aria-hidden="true"
|
||||||
aria-label="搜索任务"
|
>
|
||||||
/>
|
<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 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
|
<button
|
||||||
v-for="value in PRIORITY_ORDER"
|
v-for="value in PRIORITY_ORDER"
|
||||||
:key="value"
|
:key="value"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border px-2 py-1 text-xs transition"
|
class="rounded px-2.5 py-1 text-xs transition"
|
||||||
:class="
|
:class="
|
||||||
priorities.includes(value)
|
priorities.includes(value)
|
||||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
? 'bg-slate-900 font-medium text-white'
|
||||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
"
|
"
|
||||||
:aria-pressed="priorities.includes(value)"
|
:aria-pressed="priorities.includes(value)"
|
||||||
@click="filters.togglePriority(value)"
|
@click="filters.togglePriority(value)"
|
||||||
@@ -126,36 +95,57 @@ defineExpose({ focusSearch })
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<TagFilter
|
||||||
v-for="tag in allTags"
|
:tags="allTags"
|
||||||
:key="tag"
|
:selected="tags"
|
||||||
type="button"
|
@toggle="filters.toggleTag($event)"
|
||||||
class="rounded-full border px-2 py-0.5 text-xs transition"
|
@clear="filters.clearTags()"
|
||||||
: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
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
v-if="active"
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-2 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-700 hover:underline"
|
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
||||||
@click="filters.clear()"
|
:class="
|
||||||
>
|
due === 'today'
|
||||||
清空筛选
|
? 'border-amber-300 bg-amber-50 text-amber-800'
|
||||||
</button>
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||||
|
"
|
||||||
|
:aria-pressed="due === 'today'"
|
||||||
|
@click="filters.toggleDue('today')"
|
||||||
|
>
|
||||||
|
今日到期
|
||||||
|
<span class="font-semibold">{{ dueTodayCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
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="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
|
<button
|
||||||
v-if="archivableCount > 0"
|
v-if="archivableCount > 0"
|
||||||
type="button"
|
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')"
|
@click="emit('archive')"
|
||||||
>
|
>
|
||||||
归档 {{ archivableCount }} 个已完成
|
归档 {{ archivableCount }} 个已完成
|
||||||
@@ -163,17 +153,26 @@ defineExpose({ focusSearch })
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="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="
|
:class="
|
||||||
includeArchived
|
includeArchived
|
||||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
? 'border-sky-300 bg-sky-50 text-sky-700'
|
||||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||||
"
|
"
|
||||||
:aria-pressed="includeArchived"
|
:aria-pressed="includeArchived"
|
||||||
@click="filters.toggleArchived()"
|
@click="filters.toggleArchived()"
|
||||||
>
|
>
|
||||||
显示归档
|
显示归档
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ defineExpose({ focusInput })
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
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"
|
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="+ 添加任务,回车确认"
|
placeholder="+ 添加任务"
|
||||||
aria-label="添加任务"
|
aria-label="添加任务"
|
||||||
@compositionstart="composing = true"
|
@compositionstart="composing = true"
|
||||||
@compositionend="composing = false"
|
@compositionend="composing = false"
|
||||||
|
|||||||
@@ -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('还没有任何标签')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
+68
-44
@@ -15,67 +15,91 @@ const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
|||||||
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
|
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 hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
|
||||||
|
|
||||||
/** 三档优先级使用不同色系,保证一眼可区分 */
|
/**
|
||||||
const priorityClass = computed(() => {
|
* 优先级用左侧色条表达,而不是一枚彩色胶囊。
|
||||||
|
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
|
||||||
|
*/
|
||||||
|
const priorityBarClass = computed(() => {
|
||||||
switch (props.task.priority) {
|
switch (props.task.priority) {
|
||||||
case 3:
|
case 3:
|
||||||
return 'bg-rose-100 text-rose-700'
|
return 'bg-rose-400'
|
||||||
case 2:
|
case 2:
|
||||||
return 'bg-amber-100 text-amber-700'
|
return 'bg-amber-400'
|
||||||
default:
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="cursor-pointer rounded-md border border-slate-200 bg-white px-2.5 py-2 shadow-sm transition"
|
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:border-slate-300 hover:shadow'"
|
:class="isDone ? 'opacity-60' : 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover'"
|
||||||
:data-task-id="task.id"
|
:data-task-id="task.id"
|
||||||
@click="emit('open', task.id)"
|
@click="emit('open', task.id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||||
<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
|
<div class="py-2 pr-2.5 pl-3.5">
|
||||||
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
<div class="flex items-start gap-2">
|
||||||
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
<button
|
||||||
>
|
type="button"
|
||||||
{{ task.title }}
|
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
|
||||||
</h3>
|
:class="
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-1.5 pl-6">
|
<h3
|
||||||
<span class="rounded px-1.5 py-0.5 text-xs" :class="priorityClass">
|
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
||||||
{{ PRIORITY_LABELS[task.priority] }}
|
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
||||||
</span>
|
>
|
||||||
|
{{ task.title }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span
|
<div class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 pl-6 text-xs">
|
||||||
v-if="task.dueDate"
|
<span class="font-medium" :class="priorityTextClass">
|
||||||
class="rounded px-1.5 py-0.5 text-xs"
|
{{ PRIORITY_LABELS[task.priority] }}
|
||||||
:class="overdue ? 'bg-rose-100 font-medium text-rose-700' : 'bg-slate-100 text-slate-600'"
|
</span>
|
||||||
>
|
|
||||||
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-for="tag in visibleTags"
|
v-if="task.dueDate"
|
||||||
:key="tag"
|
class="font-medium"
|
||||||
class="rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
|
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
|
||||||
>
|
>
|
||||||
#{{ tag }}
|
{{ formatDueDate(task.dueDate) }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export const useFilterStore = defineStore('filters', () => {
|
|||||||
due.value = due.value === value ? null : value
|
due.value = due.value === value ? null : value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearTags(): void {
|
||||||
|
tags.value = []
|
||||||
|
}
|
||||||
|
|
||||||
function toggleArchived(): void {
|
function toggleArchived(): void {
|
||||||
includeArchived.value = !includeArchived.value
|
includeArchived.value = !includeArchived.value
|
||||||
}
|
}
|
||||||
@@ -97,6 +101,7 @@ export const useFilterStore = defineStore('filters', () => {
|
|||||||
togglePriority,
|
togglePriority,
|
||||||
toggleTag,
|
toggleTag,
|
||||||
toggleDue,
|
toggleDue,
|
||||||
|
clearTags,
|
||||||
toggleArchived,
|
toggleArchived,
|
||||||
clear,
|
clear,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
+35
-7
@@ -3,14 +3,42 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
|
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,
|
@layer base {
|
||||||
body,
|
html,
|
||||||
#app {
|
body,
|
||||||
height: 100%;
|
#app {
|
||||||
}
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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,6 +9,7 @@ import { createMemoryTaskRepository } from '@/data/taskRepository'
|
|||||||
import { useFilterStore } from '@/stores/filters'
|
import { useFilterStore } from '@/stores/filters'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
import BoardView from './BoardView.vue'
|
import BoardView from './BoardView.vue'
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ describe('BoardView 结构', () => {
|
|||||||
store.createTask({ title: '进行中事项', status: 'doing' })
|
store.createTask({ title: '进行中事项', status: 'doing' })
|
||||||
|
|
||||||
const counters = mountBoard(pinia)
|
const counters = mountBoard(pinia)
|
||||||
.findAll('section header span')
|
.findAll('[data-role="column-count"]')
|
||||||
.map((node) => node.text())
|
.map((node) => node.text())
|
||||||
expect(counters).toEqual(['1', '1', '0'])
|
expect(counters).toEqual(['1', '1', '0'])
|
||||||
})
|
})
|
||||||
@@ -245,7 +246,7 @@ describe('BoardView 筛选', () => {
|
|||||||
expect(wrapper.findAll('article')).toHaveLength(1)
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
expect(wrapper.text()).toContain('高优先级')
|
expect(wrapper.text()).toContain('高优先级')
|
||||||
expect(wrapper.text()).not.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 () => {
|
it('显示命中数量提示', async () => {
|
||||||
@@ -257,7 +258,7 @@ describe('BoardView 筛选', () => {
|
|||||||
useFilterStore().togglePriority(3)
|
useFilterStore().togglePriority(3)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
|
expect(wrapper.text()).toContain('已隐藏 1 个')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('清空筛选后恢复全部卡片', async () => {
|
it('清空筛选后恢复全部卡片', async () => {
|
||||||
@@ -273,7 +274,7 @@ describe('BoardView 筛选', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.findAll('article')).toHaveLength(2)
|
expect(wrapper.findAll('article')).toHaveLength(2)
|
||||||
expect(wrapper.text()).not.toContain('显示 1 / 2')
|
expect(wrapper.text()).not.toContain('已隐藏')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击「逾期」只显示逾期任务', async () => {
|
it('点击「逾期」只显示逾期任务', async () => {
|
||||||
@@ -327,7 +328,7 @@ describe('BoardView 归档', () => {
|
|||||||
const wrapper = mountBoard(pinia)
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
|
expect(wrapper.text()).toContain('已隐藏 1 个')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('开启「显示归档」后归档任务重新出现', async () => {
|
it('开启「显示归档」后归档任务重新出现', async () => {
|
||||||
@@ -400,17 +401,6 @@ describe('BoardView 快捷键', () => {
|
|||||||
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
|
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 () => {
|
it('在输入框内按 N 不触发快捷键', async () => {
|
||||||
const { pinia } = setup()
|
const { pinia } = setup()
|
||||||
const wrapper = mountBoard(pinia)
|
const wrapper = mountBoard(pinia)
|
||||||
@@ -422,29 +412,16 @@ describe('BoardView 快捷键', () => {
|
|||||||
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('面板打开时不响应快捷键', async () => {
|
it('应用级面板打开时不响应快捷键', async () => {
|
||||||
const { pinia } = setup()
|
const { pinia } = setup()
|
||||||
const wrapper = mountBoard(pinia)
|
const wrapper = mountBoard(pinia)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
// 帮助面板由 App 渲染,这里只验证看板会让出快捷键
|
||||||
|
useUiStore().openHelp()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
pressKey('n')
|
pressKey('n')
|
||||||
|
|
||||||
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
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
@@ -2,15 +2,14 @@
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import BackupPanel from '@/components/BackupPanel.vue'
|
|
||||||
import BoardColumn from '@/components/BoardColumn.vue'
|
import BoardColumn from '@/components/BoardColumn.vue'
|
||||||
import BoardToolbar from '@/components/BoardToolbar.vue'
|
import BoardToolbar from '@/components/BoardToolbar.vue'
|
||||||
import SideDrawer from '@/components/SideDrawer.vue'
|
|
||||||
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useFilterStore } from '@/stores/filters'
|
import { useFilterStore } from '@/stores/filters'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
import { COLUMNS, type ColumnId } from '@/types/task'
|
import { COLUMNS, type ColumnId } from '@/types/task'
|
||||||
import { findArchivableTasks } from '@/utils/archive'
|
import { findArchivableTasks } from '@/utils/archive'
|
||||||
import { filterTasks } from '@/utils/filter'
|
import { filterTasks } from '@/utils/filter'
|
||||||
@@ -19,23 +18,14 @@ import { groupByColumn } from '@/utils/sortOrder'
|
|||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
const filters = useFilterStore()
|
const filters = useFilterStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
const ui = useUiStore()
|
||||||
const { notice } = storeToRefs(store)
|
const { notice } = storeToRefs(store)
|
||||||
const { filter } = storeToRefs(filters)
|
const { filter } = storeToRefs(filters)
|
||||||
|
|
||||||
const SHORTCUTS = [
|
|
||||||
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
|
|
||||||
{ keys: '/', description: '聚焦搜索框' },
|
|
||||||
{ keys: '?', description: '打开本帮助' },
|
|
||||||
{ keys: 'Esc', description: '关闭当前面板' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||||||
const editingId = ref<string | null>(null)
|
const editingId = ref<string | null>(null)
|
||||||
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? 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 toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
|
||||||
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
|
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 visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
|
||||||
|
|
||||||
const visibleCount = computed(() =>
|
|
||||||
COLUMNS.reduce((total, column) => total + visibleColumns.value[column.id].length, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
||||||
* 大于 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 {
|
function whenNoPanel(action: () => void): () => void {
|
||||||
return () => {
|
return () => {
|
||||||
if (anyPanelOpen()) return
|
if (editingId.value !== null || ui.anyPanelOpen) return
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +81,6 @@ function whenNoPanel(action: () => void): () => void {
|
|||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
|
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
|
||||||
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
|
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
|
||||||
'?': whenNoPanel(() => {
|
|
||||||
helpOpen.value = true
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleTask(id: string): void {
|
function toggleTask(id: string): void {
|
||||||
@@ -124,26 +103,25 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<BoardToolbar
|
||||||
ref="toolbarRef"
|
ref="toolbarRef"
|
||||||
@open-backup="backupOpen = true"
|
:hidden-note="hiddenTotal > 0 ? `· 已隐藏 ${hiddenTotal} 个` : undefined"
|
||||||
@open-help="helpOpen = true"
|
|
||||||
@archive="archiveCompleted"
|
@archive="archiveCompleted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="notice"
|
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 }}
|
{{ notice }}
|
||||||
</p>
|
</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
|
<BoardColumn
|
||||||
v-for="(column, index) in COLUMNS"
|
v-for="(column, index) in COLUMNS"
|
||||||
:key="column.id"
|
:key="column.id"
|
||||||
@@ -158,21 +136,5 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+60
-56
@@ -13,9 +13,9 @@ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '
|
|||||||
|
|
||||||
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
|
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
|
||||||
const TONES = {
|
const TONES = {
|
||||||
rose: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700' },
|
rose: { dot: 'bg-rose-500', badge: 'border-rose-200 bg-rose-50 text-rose-700' },
|
||||||
amber: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700' },
|
amber: { dot: 'bg-amber-500', badge: 'border-amber-200 bg-amber-50 text-amber-700' },
|
||||||
sky: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700' },
|
sky: { dot: 'bg-sky-500', badge: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
@@ -31,10 +31,10 @@ const stats = computed(() => computeStats(tasks.value))
|
|||||||
const todayCount = computed(() => countTodayTasks(buckets.value))
|
const todayCount = computed(() => countTodayTasks(buckets.value))
|
||||||
|
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{ label: '今日待办', value: todayCount.value },
|
{ label: '今日待办', value: todayCount.value, dot: 'bg-slate-400' },
|
||||||
{ label: '逾期', value: stats.value.overdue },
|
{ label: '逾期', value: stats.value.overdue, dot: 'bg-rose-500' },
|
||||||
{ label: '今天到期', value: stats.value.dueToday },
|
{ label: '今天到期', value: stats.value.dueToday, dot: 'bg-amber-500' },
|
||||||
{ label: '本周完成', value: stats.value.completedThisWeek },
|
{ label: '本周完成', value: stats.value.completedThisWeek, dot: 'bg-emerald-500' },
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,58 +79,62 @@ function toggleTask(id: string): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4 md:p-6">
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<header class="flex flex-col gap-1">
|
<!-- 限宽居中:否则 1440px 下卡片会被拉满整屏,标题与元信息之间出现大片空白 -->
|
||||||
<h1 class="text-xl font-semibold text-slate-800 md:text-2xl">今日</h1>
|
<div class="mx-auto flex w-full max-w-3xl flex-col gap-5 p-4 md:p-6">
|
||||||
<p class="text-sm text-slate-500">{{ todayLabel }}</p>
|
<header class="flex flex-col gap-0.5">
|
||||||
</header>
|
<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>
|
||||||
|
|
||||||
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
v-for="card in statCards"
|
v-for="card in statCards"
|
||||||
:key="card.label"
|
: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>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="todayCount === 0"
|
|
||||||
class="rounded-lg bg-white px-4 py-12 text-center text-sm text-slate-400 shadow-sm"
|
|
||||||
>
|
|
||||||
今天没有待办事项
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-for="section in sections"
|
|
||||||
:key="section.key"
|
|
||||||
class="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-600">
|
|
||||||
<span class="size-2 rounded-full" :class="TONES[section.tone].dot" />
|
|
||||||
{{ section.title }}
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2 py-0.5 text-xs"
|
|
||||||
:class="TONES[section.tone].badge"
|
|
||||||
data-role="section-count"
|
|
||||||
>
|
>
|
||||||
{{ section.items.length }}
|
<dt class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
</span>
|
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
|
||||||
<span class="text-xs font-normal text-slate-400">{{ section.hint }}</span>
|
{{ card.label }}
|
||||||
</h2>
|
</dt>
|
||||||
|
<dd class="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900">
|
||||||
|
{{ card.value }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<p
|
||||||
<TaskCard
|
v-if="todayCount === 0"
|
||||||
v-for="task in section.items"
|
class="rounded-xl border border-dashed border-slate-300 bg-white/60 px-4 py-16 text-center text-sm text-slate-400"
|
||||||
:key="task.id"
|
>
|
||||||
:task="task"
|
今天没有待办事项
|
||||||
@toggle="toggleTask"
|
</p>
|
||||||
@open="editingId = $event"
|
|
||||||
/>
|
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
|
||||||
</div>
|
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
</section>
|
<span class="size-1.5 rounded-full" :class="TONES[section.tone].dot" aria-hidden="true" />
|
||||||
|
{{ section.title }}
|
||||||
|
<span
|
||||||
|
data-role="section-count"
|
||||||
|
class="rounded-full border px-2 py-0.5 text-xs"
|
||||||
|
:class="TONES[section.tone].badge"
|
||||||
|
>
|
||||||
|
{{ section.items.length }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal text-slate-400">{{ section.hint }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TaskCard
|
||||||
|
v-for="task in section.items"
|
||||||
|
:key="task.id"
|
||||||
|
:task="task"
|
||||||
|
@toggle="toggleTask"
|
||||||
|
@open="editingId = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TaskDetailDrawer :task="editingTask" @close="editingId = null" />
|
<TaskDetailDrawer :task="editingTask" @close="editingId = null" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user