313 lines
13 KiB
Vue
313 lines
13 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; 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"
|
||
@click="emit('archive')"
|
||
>
|
||
归档 {{ archivableCount }} 个已完成
|
||
</button>
|
||
</div>
|
||
</header>
|
||
</template>
|