fix(ui): Toast 撤销可暂停,存储告警提升为全局

- Toast 自动关闭支持悬停/聚焦暂停,移开后按剩余时间继续
- 存储异常提示移到 App 级,看板与今日视图都能看到
- 归档隐藏与筛选隐藏分开说明,并提示列内排序暂停原因
This commit is contained in:
chenshoulin
2026-10-19 17:21:05 +08:00
parent f69a24de94
commit 4aba003a9e
8 changed files with 198 additions and 25 deletions
+17
View File
@@ -5,6 +5,7 @@ import { createMemoryHistory } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SCHEMA_VERSION } from '@/data/schema' import { SCHEMA_VERSION } from '@/data/schema'
import { useTaskStore } from '@/stores/tasks'
import App from './App.vue' import App from './App.vue'
import { createAppRouter } from './router' import { createAppRouter } from './router'
@@ -98,6 +99,22 @@ describe('App 端到端(真实 localStorage)', () => {
expect(wrapper.findComponent({ name: 'PwaUpdatePrompt' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'PwaUpdatePrompt' }).exists()).toBe(true)
wrapper.unmount() wrapper.unmount()
}) })
it('存储异常提示在应用级可见,切换视图也不消失', async () => {
const { wrapper, router } = await mountApp('/today')
const store = useTaskStore()
store.notice = '本地存储不可用'
await nextTick()
expect(wrapper.find('[role="alert"]').text()).toContain('本地存储不可用')
await router.push('/')
await nextTick()
expect(wrapper.find('[role="alert"]').text()).toContain('本地存储不可用')
wrapper.unmount()
})
}) })
describe('App 路由', () => { describe('App 路由', () => {
+15 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
@@ -30,6 +31,7 @@ const CARD_SHORTCUTS = [
const store = useTaskStore() const store = useTaskStore()
const ui = useUiStore() const ui = useUiStore()
const theme = useThemeStore() const theme = useThemeStore()
const { notice } = storeToRefs(store)
onMounted(() => { onMounted(() => {
store.init() store.init()
@@ -48,6 +50,18 @@ useKeyboardShortcuts({
<div class="flex h-full min-h-0 flex-col bg-slate-100 dark:bg-zinc-950"> <div class="flex h-full min-h-0 flex-col bg-slate-100 dark:bg-zinc-950">
<AppNav /> <AppNav />
<!--
存储异常必须在两个视图都可见:今日视图同样会丢数据,
把提示放在应用级而不是看板里,避免「切到今日就看不见告警」。
-->
<p
v-if="notice"
role="alert"
class="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-300 md:px-6"
>
{{ notice }}
</p>
<main class="flex min-h-0 flex-1 flex-col"> <main class="flex min-h-0 flex-1 flex-col">
<RouterView /> <RouterView />
</main> </main>
@@ -86,9 +100,7 @@ useKeyboardShortcuts({
</div> </div>
</dl> </dl>
<p class="mt-4 text-xs text-slate-400 dark:text-zinc-500"> <p class="mt-4 text-xs text-slate-400 dark:text-zinc-500">在输入框内输入时不会触发快捷键。</p>
在输入框内输入时不会触发快捷键。
</p>
</SideDrawer> </SideDrawer>
<ToastHost /> <ToastHost />
+22 -3
View File
@@ -46,9 +46,7 @@ describe('ToastHost', () => {
store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 }) store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const actionButton = wrapper const actionButton = wrapper.findAll('button').find((node) => node.text() === '撤销')
.findAll('button')
.find((node) => node.text() === '撤销')
expect(actionButton).toBeDefined() expect(actionButton).toBeDefined()
await actionButton?.trigger('click') await actionButton?.trigger('click')
@@ -70,4 +68,25 @@ describe('ToastHost', () => {
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('[role="status"]').exists()).toBe(true) expect(wrapper.find('[role="status"]').exists()).toBe(true)
}) })
it('悬停时暂停自动关闭,移开后继续', async () => {
vi.useFakeTimers()
try {
const { store, wrapper } = setup()
store.show({ message: '可撤销', duration: 1000 })
await wrapper.vm.$nextTick()
const toast = wrapper.find('[role="status"]')
await toast.trigger('mouseenter')
vi.advanceTimersByTime(10_000)
expect(store.toasts).toHaveLength(1)
await toast.trigger('mouseleave')
vi.advanceTimersByTime(1000)
expect(store.toasts).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})
}) })
+4
View File
@@ -16,6 +16,10 @@ const { toasts } = storeToRefs(store)
:key="toast.id" :key="toast.id"
role="status" role="status"
class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-panel dark:bg-zinc-100 dark:text-zinc-900" class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-panel dark:bg-zinc-100 dark:text-zinc-900"
@mouseenter="store.pause(toast.id)"
@mouseleave="store.resume(toast.id)"
@focusin="store.pause(toast.id)"
@focusout="store.resume(toast.id)"
> >
<span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span> <span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span>
+46
View File
@@ -91,4 +91,50 @@ describe('toast store', () => {
store.show({ message: 'B', duration: 0 }) store.show({ message: 'B', duration: 0 })
expect(store.toasts.map((toast) => toast.message)).toEqual(['A', 'B']) expect(store.toasts.map((toast) => toast.message)).toEqual(['A', 'B'])
}) })
it('暂停后不再自动关闭,恢复后按剩余时间继续', () => {
const store = useToastStore()
const id = store.show({ message: '撤销我', duration: 1000 })
vi.advanceTimersByTime(600)
store.pause(id)
vi.advanceTimersByTime(10_000)
expect(store.toasts).toHaveLength(1)
store.resume(id)
vi.advanceTimersByTime(399)
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(store.toasts).toHaveLength(0)
})
it('重复暂停或恢复不会叠加计时器', () => {
const store = useToastStore()
const id = store.show({ message: 'x', duration: 1000 })
store.pause(id)
store.pause(id)
store.resume(id)
store.resume(id)
vi.advanceTimersByTime(999)
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(store.toasts).toHaveLength(0)
})
it('duration 为 0 时暂停与恢复无副作用', () => {
const store = useToastStore()
const id = store.show({ message: '常驻', duration: 0 })
expect(() => {
store.pause(id)
store.resume(id)
}).not.toThrow()
vi.advanceTimersByTime(60_000)
expect(store.toasts).toHaveLength(1)
})
}) })
+46 -7
View File
@@ -2,6 +2,9 @@
* 轻量提示(Toast)状态(T09) * 轻量提示(Toast)状态(T09)
* *
* 支持携带一个动作按钮,用于「删除后撤销」(T12)。 * 支持携带一个动作按钮,用于「删除后撤销」(T12)。
*
* 自动关闭是可暂停的:鼠标悬停 / 键盘聚焦时停表,移开后再按剩余时间继续。
* 否则用户正把指针移向「撤销」,提示先一步消失,删除就再无兜底。
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -33,6 +36,10 @@ export const DEFAULT_TOAST_DURATION = 5000
export const useToastStore = defineStore('toast', () => { export const useToastStore = defineStore('toast', () => {
const toasts = ref<ToastItem[]>([]) const toasts = ref<ToastItem[]>([])
const timers = new Map<string, ReturnType<typeof setTimeout>>() const timers = new Map<string, ReturnType<typeof setTimeout>>()
/** 自动关闭的截止时间戳,暂停时用它与当前时间算出剩余量 */
const deadlines = new Map<string, number>()
/** 暂停中的提示还剩多少毫秒 */
const pausedRemaining = new Map<string, number>()
function clearTimer(id: string): void { function clearTimer(id: string): void {
const timer = timers.get(id) const timer = timers.get(id)
@@ -40,10 +47,21 @@ export const useToastStore = defineStore('toast', () => {
clearTimeout(timer) clearTimeout(timer)
timers.delete(id) timers.delete(id)
} }
deadlines.delete(id)
}
function scheduleAutoDismiss(id: string, delay: number): void {
clearTimer(id)
deadlines.set(id, Date.now() + delay)
timers.set(
id,
setTimeout(() => dismiss(id), delay),
)
} }
function dismiss(id: string): void { function dismiss(id: string): void {
clearTimer(id) clearTimer(id)
pausedRemaining.delete(id)
toasts.value = toasts.value.filter((toast) => toast.id !== id) toasts.value = toasts.value.filter((toast) => toast.id !== id)
} }
@@ -56,15 +74,35 @@ export const useToastStore = defineStore('toast', () => {
toasts.value = [...toasts.value, item] toasts.value = [...toasts.value, item]
if (duration > 0) { if (duration > 0) scheduleAutoDismiss(id, duration)
timers.set(
id,
setTimeout(() => dismiss(id), duration),
)
}
return id return id
} }
/** 悬停 / 聚焦时暂停自动关闭;重复调用无副作用 */
function pause(id: string): void {
const timer = timers.get(id)
if (timer === undefined) return
const deadline = deadlines.get(id)
if (deadline === undefined) return
pausedRemaining.set(id, Math.max(0, deadline - Date.now()))
clearTimer(id)
}
/** 移开 / 失焦后按剩余时间继续计时 */
function resume(id: string): void {
const remaining = pausedRemaining.get(id)
if (remaining === undefined) return
pausedRemaining.delete(id)
if (remaining <= 0) {
dismiss(id)
return
}
scheduleAutoDismiss(id, remaining)
}
/** 执行提示上的动作,并立即关闭该提示 */ /** 执行提示上的动作,并立即关闭该提示 */
function runAction(id: string): void { function runAction(id: string): void {
const toast = toasts.value.find((item) => item.id === id) const toast = toasts.value.find((item) => item.id === id)
@@ -74,8 +112,9 @@ export const useToastStore = defineStore('toast', () => {
function clear(): void { function clear(): void {
for (const id of [...timers.keys()]) clearTimer(id) for (const id of [...timers.keys()]) clearTimer(id)
pausedRemaining.clear()
toasts.value = [] toasts.value = []
} }
return { toasts, show, dismiss, runAction, clear } return { toasts, show, dismiss, pause, resume, runAction, clear }
}) })
+14 -3
View File
@@ -83,10 +83,11 @@ describe('BoardView 结构', () => {
expect(sections[2]?.text()).toContain('已完成事项') expect(sections[2]?.text()).toContain('已完成事项')
}) })
it('存在存储提示时展示提示条', () => { it('存在存储提示时由 App 统一展示(看板不再重复渲染)', () => {
const { pinia, store } = setup() const { pinia, store } = setup()
store.notice = '本地存储不可用' store.notice = '本地存储不可用'
expect(mountBoard(pinia).text()).toContain('本地存储不可用') // 提示已提升到 App 层,BoardView 自身不应再渲染一遍
expect(mountBoard(pinia).text()).not.toContain('本地存储不可用')
}) })
}) })
@@ -167,7 +168,7 @@ describe('BoardView 详情抽屉', () => {
expect(store.tasks[0]?.title).toBe('新标题') expect(store.tasks[0]?.title).toBe('新标题')
}) })
it('取消后抽屉关闭且数据不变', async () => { it('有未保存修改时取消先确认,放弃后抽屉关闭且数据不变', async () => {
const { pinia, store } = setup() const { pinia, store } = setup()
store.createTask({ title: '原标题' }) store.createTask({ title: '原标题' })
const wrapper = mountBoard(pinia) const wrapper = mountBoard(pinia)
@@ -180,6 +181,14 @@ describe('BoardView 详情抽屉', () => {
await cancelButton?.trigger('click') await cancelButton?.trigger('click')
await nextTick() await nextTick()
// 先给出确认,抽屉保持打开
expect(wrapper.text()).toContain('有未保存的修改')
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(true)
const discardButton = wrapper.findAll('button').find((node) => node.text() === '放弃修改')
await discardButton?.trigger('click')
await nextTick()
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false) expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
expect(store.tasks[0]?.title).toBe('原标题') expect(store.tasks[0]?.title).toBe('原标题')
}) })
@@ -329,6 +338,8 @@ describe('BoardView 归档', () => {
expect(wrapper.findAll('article')).toHaveLength(0) expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('已隐藏 1 个') expect(wrapper.text()).toContain('已隐藏 1 个')
// 提示里要说明是归档造成的,否则用户不知道列内排序为什么拖不动
expect(wrapper.text()).toContain('归档 1')
}) })
it('开启「显示归档」后归档任务重新出现', async () => { it('开启「显示归档」后归档任务重新出现', async () => {
+34 -9
View File
@@ -19,8 +19,7 @@ const store = useTaskStore()
const filters = useFilterStore() const filters = useFilterStore()
const toast = useToastStore() const toast = useToastStore()
const ui = useUiStore() const ui = useUiStore()
const { notice } = storeToRefs(store) const { filter, includeArchived } = storeToRefs(filters)
const { filter } = storeToRefs(filters)
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */ /** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
const editingId = ref<string | null>(null) const editingId = ref<string | null>(null)
@@ -54,6 +53,38 @@ const hiddenTotal = computed(() =>
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0), COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
) )
/**
* 被「隐藏归档」挡掉、且本来能通过当前筛选的任务数。
*
* 单独算一遍是为了给提示分层:用户看到「已隐藏 3 个」时,
* 应该知道其中多少是搜索/筛选造成的、多少只是归档没显示。
*/
const archivedHiddenTotal = computed(() => {
if (includeArchived.value) return 0
const archived = store.tasks.filter((task) => task.archivedAt !== null)
// filter.includeArchived 为 false 时会把归档任务直接排除,这里显式放开
return filterTasks(archived, { ...filter.value, includeArchived: true }).length
})
const filteredHiddenTotal = computed(() =>
Math.max(0, hiddenTotal.value - archivedHiddenTotal.value),
)
/**
* 列内排序在「渲染的是子集」时会被禁用(见 drag.ts),
* 这里把原因写出来,避免用户拖不动却不知道为什么。
*/
const hiddenNote = computed(() => {
if (hiddenTotal.value === 0) return undefined
const reasons: string[] = []
if (filteredHiddenTotal.value > 0) reasons.push(`筛选 ${filteredHiddenTotal.value}`)
if (archivedHiddenTotal.value > 0) reasons.push(`归档 ${archivedHiddenTotal.value}`)
const detail = reasons.length > 0 ? `(${reasons.join(' + ')},列内排序已暂停)` : ''
return `已隐藏 ${hiddenTotal.value} 个${detail}`
})
const archivableTasks = computed(() => findArchivableTasks(store.tasks)) const archivableTasks = computed(() => findArchivableTasks(store.tasks))
function archiveCompleted(): void { function archiveCompleted(): void {
@@ -137,16 +168,10 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
<div class="flex min-h-0 flex-1 flex-col gap-3 p-4 md:p-6"> <div class="flex min-h-0 flex-1 flex-col gap-3 p-4 md:p-6">
<BoardToolbar <BoardToolbar
ref="toolbarRef" ref="toolbarRef"
:hidden-note="hiddenTotal > 0 ? `· 已隐藏 ${hiddenTotal} 个` : undefined" :hidden-note="hiddenNote"
@archive="archiveCompleted" @archive="archiveCompleted"
/> />
<p
v-if="notice"
class="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
{{ notice }}
</p>
<!-- <!--
移动端:整块区域滚动,各列按内容自然高度排布; 移动端:整块区域滚动,各列按内容自然高度排布;