style: 重做界面视觉与信息层级
先截图审视现状,再针对性修改。主要问题与改动如下。 工具栏 - 标签筛选从「平铺全部标签」改为下拉面板,标签多时不再换行挤爆工具栏 - 备份与快捷键帮助移到导航栏 —— 它们是应用级操作,不该挤在看板工具栏里, 顺带修好了「? 帮助只在看板可用」的问题 - 到期统计(今日到期 / 逾期)合并为可点击的筛选入口,去掉两个独立控件 - 移动端工具栏从占半屏降到三行紧凑布局 卡片 - 优先级从彩色胶囊改为左侧色条,扫视一列时比读文字更快分辨轻重 - 元信息改为「优先级 · 日期 · 标签」的轻量文本行,不再三种胶囊叠在一起 - 完成态用勾选图标 + 降低不透明度 布局 - 今日视图限宽居中(max-w-3xl):此前 1440px 下卡片被拉满整屏, 标题与元信息之间出现大片空白 - 修复移动端卡片被压成细条:TaskCard 的 overflow-hidden(用于裁切左侧色条) 会让 flex 项的最小尺寸解析为 0,补 shrink-0 修正; 同时改为移动端按内容高度堆叠、桌面端列内滚动 其他 - 导航栏品牌改用看板图标,消除「看板」重复出现两次 - 统一卡片阴影、圆角与键盘焦点样式 - 新增 scripts/screenshot.mjs,便于后续对照审视视觉改动 验证:单元测试 452 个、端到端测试 23 个全部通过,lint 与构建无错误。
This commit is contained in:
@@ -1,25 +1,33 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { routes } from '@/router'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
import AppNav from './AppNav.vue'
|
||||
|
||||
async function setup(initialPath = '/') {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push(initialPath)
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AppNav, { global: { plugins: [router] }, attachTo: document.body })
|
||||
return { wrapper, router }
|
||||
const wrapper = mount(AppNav, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
return { wrapper, router, ui: useUiStore() }
|
||||
}
|
||||
|
||||
describe('AppNav', () => {
|
||||
it('渲染看板与今日两个入口', () => {
|
||||
return setup().then(({ wrapper }) => {
|
||||
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
||||
})
|
||||
describe('AppNav 导航', () => {
|
||||
it('渲染看板与今日两个入口', async () => {
|
||||
const { wrapper } = await setup()
|
||||
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
||||
})
|
||||
|
||||
it('当前路由的入口标记 aria-current', async () => {
|
||||
@@ -53,3 +61,26 @@ describe('AppNav', () => {
|
||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('主导航')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppNav 应用级操作', () => {
|
||||
it('点击「备份」打开备份面板', async () => {
|
||||
const { wrapper, ui } = await setup()
|
||||
expect(ui.backupOpen).toBe(false)
|
||||
|
||||
const backupButton = wrapper.findAll('button').find((node) => node.text() === '备份')
|
||||
await backupButton?.trigger('click')
|
||||
|
||||
expect(ui.backupOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('点击 ? 打开快捷键帮助', async () => {
|
||||
const { wrapper, ui } = await setup()
|
||||
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
||||
expect(ui.helpOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('应用级操作在看板与今日视图下都可用', async () => {
|
||||
const { wrapper } = await setup('/today')
|
||||
expect(wrapper.findAll('button').map((node) => node.text())).toContain('备份')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 升序渲染', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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">
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
class="w-full max-w-xs rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 focus:border-sky-400"
|
||||
placeholder="搜索标题或描述(/ 聚焦)"
|
||||
aria-label="搜索任务"
|
||||
/>
|
||||
<div class="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 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>
|
||||
|
||||
<button
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs transition"
|
||||
:class="
|
||||
tags.includes(tag)
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||
"
|
||||
:aria-pressed="tags.includes(tag)"
|
||||
@click="filters.toggleTag(tag)"
|
||||
>
|
||||
#{{ tag }}
|
||||
</button>
|
||||
<TagFilter
|
||||
:tags="allTags"
|
||||
:selected="tags"
|
||||
@toggle="filters.toggleTag($event)"
|
||||
@clear="filters.clearTags()"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="active"
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-700 hover:underline"
|
||||
@click="filters.clear()"
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
||||
:class="
|
||||
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="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
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-full border transition"
|
||||
:class="
|
||||
isDone ? 'border-emerald-500 bg-emerald-500' : 'border-slate-300 hover:border-emerald-500'
|
||||
"
|
||||
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
|
||||
:aria-pressed="isDone"
|
||||
@click.stop="emit('toggle', task.id)"
|
||||
/>
|
||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||
|
||||
<h3
|
||||
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
||||
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
||||
>
|
||||
{{ task.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="py-2 pr-2.5 pl-3.5">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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 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">
|
||||
<span class="rounded px-1.5 py-0.5 text-xs" :class="priorityClass">
|
||||
{{ PRIORITY_LABELS[task.priority] }}
|
||||
</span>
|
||||
<h3
|
||||
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
||||
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
||||
>
|
||||
{{ task.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="task.dueDate"
|
||||
class="rounded px-1.5 py-0.5 text-xs"
|
||||
:class="overdue ? 'bg-rose-100 font-medium text-rose-700' : 'bg-slate-100 text-slate-600'"
|
||||
>
|
||||
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
|
||||
</span>
|
||||
<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-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-if="task.dueDate"
|
||||
class="font-medium"
|
||||
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
|
||||
>
|
||||
{{ formatDueDate(task.dueDate) }}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user