问题:工具栏第一行的控件高度不一致(搜索框与标签按钮 34px,优先级分组与 到期统计按钮 30px,第二行按钮 26px),排在一起参差不齐。 根因:高度是靠 padding + line-height 撑出来的,而各组字号不同——搜索框和 标签按钮用 text-sm(行高 20px),优先级与统计按钮用 text-xs(行高 16px), 两者的 py 又都是 1.5 和 1,于是自然差出 4~8px。 修复:改为显式固定高度,不再受字号影响。 - 第一行(搜索框 / 优先级分组 / 标签 / 今日到期 / 逾期)统一 h-9(36px) - 优先级分组内的按钮改用 h-full 填满容器内区,不再靠 padding 撑 - 第二行(归档 / 显示归档 / 清空筛选)统一 h-8(32px), 与第一行形成明确的次级层级 顺手把 .screenshots/ 加入 ESLint 与 Prettier 忽略——那是截图脚本的产物目录。 验证:用 Playwright 实测各控件 boundingBox,第一行全部 36px;单元测试 480 个、 E2E 25 个通过。
180 lines
7.2 KiB
Vue
180 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
import { storeToRefs } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
|
|
import { useFilterStore } from '@/stores/filters'
|
|
import { useTaskStore } from '@/stores/tasks'
|
|
import { PRIORITY_LABELS, type Priority } from '@/types/task'
|
|
import { findArchivableTasks } from '@/utils/archive'
|
|
import { isDueToday, isOverdue } from '@/utils/date'
|
|
|
|
import TagFilter from './TagFilter.vue'
|
|
|
|
defineProps<{ hiddenNote?: string }>()
|
|
|
|
const emit = defineEmits<{ archive: [] }>()
|
|
|
|
const store = useTaskStore()
|
|
const filters = useFilterStore()
|
|
const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(filters)
|
|
|
|
const searchRef = ref<HTMLInputElement | null>(null)
|
|
|
|
/** 由高到低排列,与「高优先级优先处理」的心智一致 */
|
|
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
|
|
|
const dueTodayCount = computed(
|
|
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
|
|
)
|
|
|
|
const overdueCount = computed(
|
|
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
|
|
)
|
|
|
|
/** 从现有任务里收集标签,作为筛选候选项 */
|
|
const allTags = computed(() => {
|
|
const collected = new Set<string>()
|
|
for (const task of store.tasks) {
|
|
for (const tag of task.tags) collected.add(tag)
|
|
}
|
|
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
|
})
|
|
|
|
/** 当前可归档(已完成且超过保留天数)的任务数量 */
|
|
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
|
|
|
|
function focusSearch(): void {
|
|
searchRef.value?.focus()
|
|
}
|
|
|
|
defineExpose({ focusSearch })
|
|
</script>
|
|
|
|
<template>
|
|
<header class="flex flex-col gap-2.5">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
|
<svg
|
|
viewBox="0 0 20 20"
|
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
|
|
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="h-9 w-full rounded-md border border-slate-200 bg-white 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 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:placeholder:text-slate-500 dark:focus:border-sky-600 dark:focus:ring-sky-950"
|
|
placeholder="搜索标题或描述"
|
|
aria-label="搜索任务"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="flex h-9 items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
|
|
role="group"
|
|
aria-label="按优先级筛选"
|
|
>
|
|
<button
|
|
v-for="value in PRIORITY_ORDER"
|
|
:key="value"
|
|
type="button"
|
|
class="flex h-full items-center rounded px-2.5 text-xs transition"
|
|
:class="
|
|
priorities.includes(value)
|
|
? 'bg-slate-900 font-medium text-white dark:bg-slate-100 dark:text-slate-900'
|
|
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800'
|
|
"
|
|
:aria-pressed="priorities.includes(value)"
|
|
@click="filters.togglePriority(value)"
|
|
>
|
|
{{ PRIORITY_LABELS[value] }}
|
|
</button>
|
|
</div>
|
|
|
|
<TagFilter
|
|
:tags="allTags"
|
|
:selected="tags"
|
|
@toggle="filters.toggleTag($event)"
|
|
@clear="filters.clearTags()"
|
|
/>
|
|
|
|
<div class="ml-auto flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
|
|
:class="
|
|
due === 'today'
|
|
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
|
"
|
|
:aria-pressed="due === 'today'"
|
|
@click="filters.toggleDue('today')"
|
|
>
|
|
今日到期
|
|
<span class="font-semibold">{{ dueTodayCount }}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
|
|
:class="
|
|
due === 'overdue'
|
|
? 'border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
|
: overdueCount > 0
|
|
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50 dark:border-slate-700 dark:bg-slate-900 dark:text-rose-400 dark:hover:border-rose-800 dark:hover:bg-rose-950'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
|
"
|
|
: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 dark:text-slate-400">
|
|
<span>共 {{ store.tasks.length }} 个任务</span>
|
|
<span v-if="hiddenNote" class="text-slate-400 dark:text-slate-500">{{ hiddenNote }}</span>
|
|
|
|
<div class="ml-auto flex items-center gap-1.5">
|
|
<button
|
|
v-if="archivableCount > 0"
|
|
type="button"
|
|
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
|
@click="emit('archive')"
|
|
>
|
|
归档 {{ archivableCount }} 个已完成
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
|
|
:class="
|
|
includeArchived
|
|
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
|
"
|
|
:aria-pressed="includeArchived"
|
|
@click="filters.toggleArchived()"
|
|
>
|
|
显示归档
|
|
</button>
|
|
|
|
<button
|
|
v-if="active"
|
|
type="button"
|
|
class="flex h-8 items-center rounded-md px-2.5 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline dark:text-slate-400 dark:hover:text-slate-200"
|
|
@click="filters.clear()"
|
|
>
|
|
清空筛选
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
</template>
|