feat: 优化页面样式
CI / verify (push) Successful in 1m43s
CI / 部署到服务器 (push) Successful in 26s

This commit is contained in:
chenshoulin
2026-10-24 16:44:54 +08:00
parent 9ee1261b90
commit 81ef0cc43f
4 changed files with 268 additions and 96 deletions
+22
View File
@@ -231,3 +231,25 @@ describe('BoardToolbar 多选', () => {
expect(exactButton(wrapper, '多选')).toBeUndefined()
})
})
describe('BoardToolbar 溢出容器', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
/**
* 回归守卫:筛选组一旦变成滚动容器(overflow-x-auto 会让 overflow-y 也算 auto),
* 「标签」下拉面板就会被裁在 36px 高的行里 —— 点开等于没反应,右侧还多一条滚动条。
* 空间不够时应当换行(flex-wrap),而不是把内容推到滚动区外。
*/
it('筛选组不是滚动容器,标签面板不会被裁', () => {
const { wrapper } = setup()
const chip = buttonByText(wrapper, '今日到期')
const group = chip?.element.parentElement
expect(group).toBeTruthy()
const classes = group?.className ?? ''
expect(classes).toContain('flex-wrap')
expect(classes).not.toMatch(/overflow-(x-|y-)?(auto|scroll|hidden)/)
})
})
+177 -91
View File
@@ -47,12 +47,17 @@ defineExpose({ focusSearch })
</script>
<template>
<header class="flex flex-col gap-2.5">
<header class="flex flex-col gap-2">
<!--
单行三段:搜索 │ 筛选 │ 操作。
早先是两行(第一行筛选、第二行统计 + 批量操作),「今日到期 / 逾期」
又被 ml-auto 顶到最右端,与搜索之间空出半个屏幕 —— 同一件事被切到两个空间。
-->
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-56 flex-1 sm:max-w-xs">
<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-400 dark:text-zinc-500"
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"
>
@@ -63,136 +68,217 @@ defineExpose({ focusSearch })
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-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500 dark:focus:border-sky-600 dark:focus:ring-sky-950"
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>
<div
class="flex h-9 items-center gap-0.5 rounded-md 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 px-2.5 text-xs transition"
:class="
priorities.includes(value)
? 'bg-slate-900 font-medium text-white dark:bg-zinc-100 dark:text-zinc-900'
: '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')"
/>
<div class="ml-auto flex items-center gap-1.5">
<!--
筛选:快捷截止日期 + 优先级 + 标签,同一个语义组。
这里**不能用 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 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
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 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
? '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">{{ dueTodayCount }}</span>
<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 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
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 text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
? '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-rose-600 hover:border-rose-200 hover:bg-rose-50 dark:border-zinc-700 dark:bg-zinc-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-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">{{ overdueCount }}</span>
<span
class="font-semibold tabular-nums"
:class="due === 'overdue' || overdueCount === 0 ? '' : 'text-rose-600 dark:text-rose-400'"
>
{{ 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-zinc-400">
<span>共 {{ store.activeTasks.length }} 个任务</span>
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
<span class="hidden h-5 w-px shrink-0 bg-slate-200 sm:block dark:bg-zinc-700" aria-hidden="true" />
<div class="ml-auto flex items-center gap-1.5">
<button
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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
@click="emit('batch-add')"
<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>
<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>
<button
type="button"
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
:class="
selecting
? '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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="selecting === true"
@click="emit('toggle-select')"
>
{{ selecting ? '退出多选' : '多选' }}
</button>
<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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-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-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
"
:aria-pressed="includeArchived"
@click="filters.toggleArchived()"
>
显示归档
</button>
<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-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-zinc-400 dark:hover:text-zinc-200"
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>
<!-- 操作:主操作(批量添加)+ 两个视图开关,全部平铺,不再收进菜单 -->
<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] max-lg:w-9 max-lg:justify-center max-lg:px-0 dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400"
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>
+33
View File
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it } from 'vitest'
import TagFilter from './TagFilter.vue'
@@ -137,3 +138,35 @@ describe('TagFilter 空状态', () => {
expect(wrapper.text()).toContain('还没有任何标签')
})
})
describe('TagFilter 面板定位', () => {
it('靠近视口右缘时把面板往左推回视口内', async () => {
const wrapper = mountFilter()
const root = wrapper.element as HTMLElement
// 模拟「标签」被挤到窗口右侧 60px 处:224px 的面板必然伸出窗口
root.getBoundingClientRect = () =>
({ left: window.innerWidth - 60, top: 0, width: 60, height: 36 }) as DOMRect
await triggerButton(wrapper).trigger('click')
await nextTick()
const panel = wrapper.find('div.absolute').element as HTMLElement
const shift = Number.parseFloat(panel.style.marginLeft || '0')
expect(shift).toBeLessThan(0)
// 右缘不能越过视口
expect(window.innerWidth - 60 + shift + 224).toBeLessThanOrEqual(window.innerWidth)
})
it('左侧空间充足时不偏移', async () => {
const wrapper = mountFilter()
const root = wrapper.element as HTMLElement
root.getBoundingClientRect = () =>
({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect
await triggerButton(wrapper).trigger('click')
await nextTick()
const panel = wrapper.find('div.absolute').element as HTMLElement
expect(Number.parseFloat(panel.style.marginLeft || '0')).toBe(0)
})
})
+36 -5
View File
@@ -6,7 +6,7 @@
* 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
@@ -15,6 +15,34 @@ const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
/**
* 面板固定 224px(w-56)且默认左对齐触发按钮。
* 当「标签」被挤到靠近视口右缘时(窄屏工具栏、筛选换行的情况),
* 面板会伸出窗口被裁掉 —— 这里把面板往左推回视口内。
* 用 margin-left 而不是 transform:面板自身的 pop-in 动画也在动 transform,两者会打架。
*/
const PANEL_WIDTH = 224
const EDGE = 8
const shiftX = ref(0)
function reposition(): void {
const root = rootRef.value
if (!root) return
const rect = root.getBoundingClientRect()
const overflowRight = rect.left + PANEL_WIDTH - (window.innerWidth - EDGE)
shiftX.value = overflowRight > 0 ? -Math.min(overflowRight, Math.max(rect.left - EDGE, 0)) : 0
}
function onResize(): void {
if (open.value) reposition()
}
watch(open, async (value) => {
if (!value) return
await nextTick()
reposition()
})
function onDocumentClick(event: MouseEvent): void {
if (!open.value) return
const root = rootRef.value
@@ -38,11 +66,13 @@ function openManager(): void {
onMounted(() => {
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown, true)
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
document.removeEventListener('keydown', onDocumentKeydown, true)
window.removeEventListener('resize', onResize)
})
</script>
@@ -50,10 +80,10 @@ onBeforeUnmount(() => {
<div ref="rootRef" class="relative">
<button
type="button"
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
:class="
selected.length > 0
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
? '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-expanded="open"
@@ -63,7 +93,7 @@ onBeforeUnmount(() => {
标签
<span
v-if="selected.length > 0"
class="rounded-full bg-sky-600 px-1.5 text-[11px] leading-4 font-medium text-white dark:bg-sky-500"
class="rounded-full bg-sky-700 px-1.5 text-[11px] leading-4 font-medium text-white tabular-nums dark:bg-sky-400 dark:text-sky-950"
>
{{ selected.length }}
</span>
@@ -87,8 +117,9 @@ onBeforeUnmount(() => {
<div
v-if="open"
class="absolute top-full left-0 z-20 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
:style="{ marginLeft: `${shiftX}px` }"
>
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400 dark:text-zinc-500">
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-500 dark:text-zinc-400">
还没有任何标签
</p>