Files
Kanban/src/components/BoardToolbar.vue
T
chenshoulin 29100b6d7d
CI / verify (push) Successful in 1m39s
CI / 部署到服务器 (push) Successful in 26s
feat: 优化任务详情抽屉时间信息展示顺序
2026-10-24 19:37:38 +08:00

313 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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; selecting?: boolean }>()
const emit = defineEmits<{
archive: []
'new-task': []
'batch-add': []
'toggle-select': []
'manage-tags': []
}>()
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.activeTasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
)
const overdueCount = computed(
() => store.activeTasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
)
/** 当前可归档(已完成且超过保留天数)的任务数量 */
const archivableCount = computed(() => findArchivableTasks(store.activeTasks).length)
function focusSearch(): void {
searchRef.value?.focus()
}
defineExpose({ focusSearch })
</script>
<template>
<header class="flex flex-col gap-2">
<!--
单行三段:搜索 │ 筛选 │ 操作。
早先是两行(第一行筛选、第二行统计 + 批量操作),「今日到期 / 逾期」
又被 ml-auto 顶到最右端,与搜索之间空出半个屏幕 —— 同一件事被切到两个空间。
-->
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-44 flex-1 max-sm:order-1 max-sm:min-w-0 sm:max-w-[260px] xl:max-w-[320px]">
<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-500 dark:text-zinc-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="h-9 w-full rounded-lg border border-slate-200 bg-white pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-500 focus:border-sky-400 focus:ring-2 focus:ring-sky-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-400 dark:focus:border-sky-600 dark:focus:ring-sky-950"
placeholder="搜索标题或描述"
aria-label="搜索任务"
/>
</div>
<!--
筛选:快捷截止日期 + 优先级 + 标签,同一个语义组。
这里**不能用 overflow-x-auto**:浏览器会把 overflow-y 一并算成 auto,
于是这一条 36px 高的行变成滚动容器,标签下拉面板(~175px 高)被裁在盒外
—— 表现就是「点了没反应」,右侧还多出一条滚动条。空间不够时改为换行。
-->
<div class="flex min-w-0 flex-wrap items-center gap-1.5 max-sm:order-3 max-sm:w-full">
<button
type="button"
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
:class="
due === 'today'
? 'border-amber-300 bg-amber-50 font-medium 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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="due === 'today'"
@click="filters.toggleDue('today')"
>
今日到期
<span
class="font-semibold tabular-nums"
:class="due === 'today' || dueTodayCount === 0 ? '' : 'text-amber-700 dark:text-amber-400'"
>
{{ dueTodayCount }}
</span>
</button>
<button
type="button"
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
:class="
due === 'overdue'
? 'border-rose-300 bg-rose-50 font-medium text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
: overdueCount > 0
? 'border-slate-200 bg-white text-slate-600 hover:border-rose-200 hover:bg-rose-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="due === 'overdue'"
@click="filters.toggleDue('overdue')"
>
逾期
<span
class="font-semibold tabular-nums"
:class="due === 'overdue' || overdueCount === 0 ? '' : 'text-rose-600 dark:text-rose-400'"
>
{{ overdueCount }}
</span>
</button>
<span class="hidden h-5 w-px shrink-0 bg-slate-200 sm:block dark:bg-zinc-700" aria-hidden="true" />
<div
class="flex h-9 shrink-0 items-center gap-0.5 rounded-lg border border-slate-200 bg-white p-0.5 dark:border-zinc-700 dark:bg-zinc-900"
role="group"
aria-label="按优先级筛选"
>
<button
v-for="value in PRIORITY_ORDER"
:key="value"
type="button"
class="flex h-full items-center rounded-md px-2.5 text-[13px] whitespace-nowrap transition"
:class="
priorities.includes(value)
? 'bg-sky-50 font-semibold text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950 dark:text-sky-300 dark:ring-sky-800'
: 'text-slate-600 hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
:aria-pressed="priorities.includes(value)"
@click="filters.togglePriority(value)"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
<TagFilter
:tags="store.allTags"
:selected="tags"
@toggle="filters.toggleTag($event)"
@clear="filters.clearTags()"
@manage="emit('manage-tags')"
/>
<!-- 筛选生效时才出现:常态下工具栏不摆一个点了没事干的按钮 -->
<button
v-if="active"
type="button"
class="flex h-9 shrink-0 items-center gap-1 rounded-lg px-2 text-[13px] whitespace-nowrap text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
@click="filters.clear()"
>
<svg
viewBox="0 0 20 20"
class="size-3.5 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
aria-hidden="true"
>
<path d="m5.5 5.5 9 9M14.5 5.5l-9 9" />
</svg>
清空筛选
</button>
</div>
<!--
操作:主操作(新建任务)+ 三个次要操作(批量添加 / 多选 / 显示归档)。
主次靠「实心 vs 描边」表达:真正高频的是「再加一条」,批量录入是偶发动作,
早先把它做成唯一实心按钮,把用户最常做的事藏进了每列的输入框。
新建任务在 ≥640 一律带文字(主操作不该在平板上退化成一个小图标),
三个次要操作在 <1024 收成图标,靠 title / aria-label 保留可访问名。
-->
<div class="ml-auto flex shrink-0 items-center gap-1.5 max-sm:order-2">
<button
type="button"
class="flex h-9 items-center gap-1.5 rounded-lg bg-sky-700 px-3 text-[13px] font-medium whitespace-nowrap text-white transition hover:bg-sky-800 active:scale-[0.98] dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400"
aria-label="新建任务"
title="新建任务(N)"
@click="emit('new-task')"
>
<svg
viewBox="0 0 20 20"
class="size-4 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M10 4.5v11M4.5 10h11" />
</svg>
<span class="max-sm:hidden">新建任务</span>
</button>
<button
type="button"
class="flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[13px] whitespace-nowrap text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 max-lg:w-9 max-lg:justify-center max-lg:px-0 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
aria-label="批量添加"
title="批量添加"
@click="emit('batch-add')"
>
<svg
viewBox="0 0 20 20"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M3.5 5.5h9M3.5 10h9M3.5 14.5h5" />
<path d="M15 11.5v5M12.5 14h5" />
</svg>
<span class="max-lg:hidden">批量添加</span>
</button>
<button
type="button"
class="flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition max-lg:w-9 max-lg:justify-center max-lg:px-0"
:class="
selecting
? 'border-sky-300 bg-sky-50 font-medium 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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="selecting === true"
:aria-label="selecting ? '退出多选' : '多选'"
:title="selecting ? '退出多选' : '多选'"
@click="emit('toggle-select')"
>
<svg
viewBox="0 0 20 20"
class="size-4 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3.5" y="3.5" width="13" height="13" rx="3" />
<path d="m7.2 10.2 2 2 3.6-4.1" stroke-width="1.7" />
</svg>
<span class="max-lg:hidden">{{ selecting ? '退出多选' : '多选' }}</span>
</button>
<button
type="button"
class="flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition max-lg:w-9 max-lg:justify-center max-lg:px-0"
:class="
includeArchived
? 'border-sky-300 bg-sky-50 font-medium 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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="includeArchived"
aria-label="显示归档"
title="显示归档"
@click="filters.toggleArchived()"
>
<svg
viewBox="0 0 20 20"
class="size-4 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="4.5" width="14" height="3.5" rx="1" />
<path d="M4.6 8v6.4c0 .9.7 1.6 1.6 1.6h7.6c.9 0 1.6-.7 1.6-1.6V8" />
<path d="M8.4 11h3.2" />
</svg>
<span class="max-lg:hidden">显示归档</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 dark:text-zinc-400">
<span>共 {{ store.activeTasks.length }} 个任务</span>
<span v-if="hiddenNote" class="cursor-help" :title="`${hiddenNote};此时列内排序会暂停`">
{{ hiddenNote }}
</span>
<!--
归档是「有活了才出现」的清理动作,不该常驻占一个按钮位;
挪到这一行后只有真的攒够可归档任务时才露头。
-->
<button
v-if="archivableCount > 0"
type="button"
class="rounded text-slate-600 underline decoration-slate-300 underline-offset-2 transition hover:text-slate-900 hover:decoration-slate-500 dark:text-zinc-400 dark:decoration-zinc-600 dark:hover:text-zinc-200 dark:hover:decoration-zinc-400"
@click="emit('archive')"
>
归档 {{ archivableCount }} 个已完成
</button>
</div>
</header>
</template>