fix(ui): Toast 撤销可暂停,存储告警提升为全局
- Toast 自动关闭支持悬停/聚焦暂停,移开后按剩余时间继续 - 存储异常提示移到 App 级,看板与今日视图都能看到 - 归档隐藏与筛选隐藏分开说明,并提示列内排序暂停原因
This commit is contained in:
@@ -5,6 +5,7 @@ import { createMemoryHistory } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SCHEMA_VERSION } from '@/data/schema'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
|
||||
import App from './App.vue'
|
||||
import { createAppRouter } from './router'
|
||||
@@ -98,6 +99,22 @@ describe('App 端到端(真实 localStorage)', () => {
|
||||
expect(wrapper.findComponent({ name: 'PwaUpdatePrompt' }).exists()).toBe(true)
|
||||
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 路由', () => {
|
||||
|
||||
+15
-3
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
@@ -30,6 +31,7 @@ const CARD_SHORTCUTS = [
|
||||
const store = useTaskStore()
|
||||
const ui = useUiStore()
|
||||
const theme = useThemeStore()
|
||||
const { notice } = storeToRefs(store)
|
||||
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
@@ -48,6 +50,18 @@ useKeyboardShortcuts({
|
||||
<div class="flex h-full min-h-0 flex-col bg-slate-100 dark:bg-zinc-950">
|
||||
<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">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -86,9 +100,7 @@ useKeyboardShortcuts({
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-400 dark:text-zinc-500">
|
||||
在输入框内输入时不会触发快捷键。
|
||||
</p>
|
||||
<p class="mt-4 text-xs text-slate-400 dark:text-zinc-500">在输入框内输入时不会触发快捷键。</p>
|
||||
</SideDrawer>
|
||||
|
||||
<ToastHost />
|
||||
|
||||
@@ -46,9 +46,7 @@ describe('ToastHost', () => {
|
||||
store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const actionButton = wrapper
|
||||
.findAll('button')
|
||||
.find((node) => node.text() === '撤销')
|
||||
const actionButton = wrapper.findAll('button').find((node) => node.text() === '撤销')
|
||||
expect(actionButton).toBeDefined()
|
||||
|
||||
await actionButton?.trigger('click')
|
||||
@@ -70,4 +68,25 @@ describe('ToastHost', () => {
|
||||
await wrapper.vm.$nextTick()
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,10 @@ const { toasts } = storeToRefs(store)
|
||||
:key="toast.id"
|
||||
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"
|
||||
@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>
|
||||
|
||||
|
||||
@@ -91,4 +91,50 @@ describe('toast store', () => {
|
||||
store.show({ message: 'B', duration: 0 })
|
||||
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
@@ -2,6 +2,9 @@
|
||||
* 轻量提示(Toast)状态(T09)
|
||||
*
|
||||
* 支持携带一个动作按钮,用于「删除后撤销」(T12)。
|
||||
*
|
||||
* 自动关闭是可暂停的:鼠标悬停 / 键盘聚焦时停表,移开后再按剩余时间继续。
|
||||
* 否则用户正把指针移向「撤销」,提示先一步消失,删除就再无兜底。
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -33,6 +36,10 @@ export const DEFAULT_TOAST_DURATION = 5000
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
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 {
|
||||
const timer = timers.get(id)
|
||||
@@ -40,10 +47,21 @@ export const useToastStore = defineStore('toast', () => {
|
||||
clearTimeout(timer)
|
||||
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 {
|
||||
clearTimer(id)
|
||||
pausedRemaining.delete(id)
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
@@ -56,15 +74,35 @@ export const useToastStore = defineStore('toast', () => {
|
||||
|
||||
toasts.value = [...toasts.value, item]
|
||||
|
||||
if (duration > 0) {
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), duration),
|
||||
)
|
||||
}
|
||||
if (duration > 0) scheduleAutoDismiss(id, duration)
|
||||
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 {
|
||||
const toast = toasts.value.find((item) => item.id === id)
|
||||
@@ -74,8 +112,9 @@ export const useToastStore = defineStore('toast', () => {
|
||||
|
||||
function clear(): void {
|
||||
for (const id of [...timers.keys()]) clearTimer(id)
|
||||
pausedRemaining.clear()
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
return { toasts, show, dismiss, runAction, clear }
|
||||
return { toasts, show, dismiss, pause, resume, runAction, clear }
|
||||
})
|
||||
|
||||
@@ -83,10 +83,11 @@ describe('BoardView 结构', () => {
|
||||
expect(sections[2]?.text()).toContain('已完成事项')
|
||||
})
|
||||
|
||||
it('存在存储提示时展示提示条', () => {
|
||||
it('存在存储提示时由 App 统一展示(看板不再重复渲染)', () => {
|
||||
const { pinia, store } = setup()
|
||||
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('新标题')
|
||||
})
|
||||
|
||||
it('取消后抽屉关闭且数据不变', async () => {
|
||||
it('有未保存修改时取消先确认,放弃后抽屉关闭且数据不变', async () => {
|
||||
const { pinia, store } = setup()
|
||||
store.createTask({ title: '原标题' })
|
||||
const wrapper = mountBoard(pinia)
|
||||
@@ -180,6 +181,14 @@ describe('BoardView 详情抽屉', () => {
|
||||
await cancelButton?.trigger('click')
|
||||
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(store.tasks[0]?.title).toBe('原标题')
|
||||
})
|
||||
@@ -329,6 +338,8 @@ describe('BoardView 归档', () => {
|
||||
|
||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('已隐藏 1 个')
|
||||
// 提示里要说明是归档造成的,否则用户不知道列内排序为什么拖不动
|
||||
expect(wrapper.text()).toContain('归档 1')
|
||||
})
|
||||
|
||||
it('开启「显示归档」后归档任务重新出现', async () => {
|
||||
|
||||
+34
-9
@@ -19,8 +19,7 @@ const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
const toast = useToastStore()
|
||||
const ui = useUiStore()
|
||||
const { notice } = storeToRefs(store)
|
||||
const { filter } = storeToRefs(filters)
|
||||
const { filter, includeArchived } = storeToRefs(filters)
|
||||
|
||||
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||||
const editingId = ref<string | null>(null)
|
||||
@@ -54,6 +53,38 @@ const hiddenTotal = computed(() =>
|
||||
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))
|
||||
|
||||
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">
|
||||
<BoardToolbar
|
||||
ref="toolbarRef"
|
||||
:hidden-note="hiddenTotal > 0 ? `· 已隐藏 ${hiddenTotal} 个` : undefined"
|
||||
:hidden-note="hiddenNote"
|
||||
@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>
|
||||
|
||||
<!--
|
||||
移动端:整块区域滚动,各列按内容自然高度排布;
|
||||
|
||||
Reference in New Issue
Block a user