task-13(壳层/路由): 实现全局请求错误提示容器
error-bus 提供框架无关的订阅式错误总线:notify/dismiss/clear、负载归一化、 3 条上限截断、订阅返回取消函数防泄漏;GlobalErrorContainer 订阅并渲染可关闭 错误条,挂在 AdminLayout 内容区顶部。
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
EMPTY_MENU_TITLE,
|
EMPTY_MENU_TITLE,
|
||||||
shouldShowEmptyMenu,
|
shouldShowEmptyMenu,
|
||||||
} from '@/layout/empty-state'
|
} from '@/layout/empty-state'
|
||||||
|
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -84,6 +85,7 @@ async function signOut() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-content">
|
<main class="admin-content">
|
||||||
|
<GlobalErrorContainer />
|
||||||
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
||||||
<el-empty
|
<el-empty
|
||||||
v-if="emptyMenu && route.path === '/'"
|
v-if="emptyMenu && route.path === '/'"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
dismissGlobalError,
|
||||||
|
subscribeGlobalError,
|
||||||
|
type GlobalErrorItem,
|
||||||
|
} from '@/layout/error-bus'
|
||||||
|
|
||||||
|
const errors = ref<GlobalErrorItem[]>([])
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
unsubscribe = subscribeGlobalError((list) => {
|
||||||
|
errors.value = list
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribe?.()
|
||||||
|
unsubscribe = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="errors.length" class="global-error-stack" role="alert" aria-live="assertive">
|
||||||
|
<el-alert
|
||||||
|
v-for="item in errors"
|
||||||
|
:key="item.id"
|
||||||
|
:title="item.message"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="true"
|
||||||
|
@close="dismissGlobalError(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/** 全局请求错误提示容器(任务 13):框架无关的订阅式错误总线。 */
|
||||||
|
|
||||||
|
export interface GlobalErrorItem {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOBAL_ERROR_LIMIT = 3
|
||||||
|
export const GLOBAL_ERROR_FALLBACK = '请求失败,请稍后重试'
|
||||||
|
|
||||||
|
/** 把任意负载归一化为可展示文案。 */
|
||||||
|
export function normalizeErrorMessage(message: unknown): string {
|
||||||
|
if (message instanceof Error) return message.message.trim() || GLOBAL_ERROR_FALLBACK
|
||||||
|
if (typeof message === 'string') return message.trim() || GLOBAL_ERROR_FALLBACK
|
||||||
|
return GLOBAL_ERROR_FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (items: GlobalErrorItem[]) => void
|
||||||
|
|
||||||
|
let items: GlobalErrorItem[] = []
|
||||||
|
let nextId = 1
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
for (const listener of listeners) listener(items.slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅错误流,返回取消订阅函数(组件卸载时必须调用以防泄漏)。 */
|
||||||
|
export function subscribeGlobalError(listener: Listener): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
listener(items.slice())
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知一条全局请求错误;超出上限丢弃最旧,保证不堆积。 */
|
||||||
|
export function notifyGlobalError(message: unknown): void {
|
||||||
|
items = [...items, { id: nextId++, message: normalizeErrorMessage(message) }].slice(-GLOBAL_ERROR_LIMIT)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 关闭单条。 */
|
||||||
|
export function dismissGlobalError(id: number): void {
|
||||||
|
items = items.filter((item) => item.id !== id)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGlobalErrors(): void {
|
||||||
|
items = []
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotGlobalErrors(): GlobalErrorItem[] {
|
||||||
|
return items.slice()
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ button, input, textarea, select { font: inherit; }
|
|||||||
.page-loading { color: var(--admin-muted); }
|
.page-loading { color: var(--admin-muted); }
|
||||||
.page-error { color: #b91c1c; }
|
.page-error { color: #b91c1c; }
|
||||||
.not-found { padding: 56px 20px; }
|
.not-found { padding: 56px 20px; }
|
||||||
|
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.admin-sidebar { flex-basis: 64px; }
|
.admin-sidebar { flex-basis: 64px; }
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
clearGlobalErrors,
|
||||||
|
dismissGlobalError,
|
||||||
|
GLOBAL_ERROR_FALLBACK,
|
||||||
|
GLOBAL_ERROR_LIMIT,
|
||||||
|
normalizeErrorMessage,
|
||||||
|
notifyGlobalError,
|
||||||
|
snapshotGlobalErrors,
|
||||||
|
subscribeGlobalError,
|
||||||
|
} from '../src/layout/error-bus.ts'
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_normal_primary_path', () => {
|
||||||
|
// 正常主路径:通知一条错误进入提示流。
|
||||||
|
clearGlobalErrors()
|
||||||
|
notifyGlobalError('登录已过期')
|
||||||
|
const list = snapshotGlobalErrors()
|
||||||
|
assert.equal(list.length, 1)
|
||||||
|
assert.equal(list[0].message, '登录已过期')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_normal_variant_input', () => {
|
||||||
|
// 正常变体:不同错误逐条追加、顺序保留。
|
||||||
|
clearGlobalErrors()
|
||||||
|
notifyGlobalError('错误A')
|
||||||
|
notifyGlobalError('错误B')
|
||||||
|
assert.deepEqual(snapshotGlobalErrors().map((i) => i.message), ['错误A', '错误B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
// 正常重复:相同消息重复通知产生稳定独立的条目(id 单调递增)。
|
||||||
|
clearGlobalErrors()
|
||||||
|
notifyGlobalError('重试')
|
||||||
|
const first = snapshotGlobalErrors()[0]
|
||||||
|
notifyGlobalError('重试')
|
||||||
|
const second = snapshotGlobalErrors()[1]
|
||||||
|
assert.notEqual(first.id, second.id)
|
||||||
|
assert.equal(first.message, second.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_boundary_empty_input', () => {
|
||||||
|
// 边界空值:空/缺省消息回落到通用提示,不出现空白条目。
|
||||||
|
clearGlobalErrors()
|
||||||
|
assert.equal(normalizeErrorMessage(' '), GLOBAL_ERROR_FALLBACK)
|
||||||
|
notifyGlobalError('')
|
||||||
|
assert.equal(snapshotGlobalErrors()[0].message, GLOBAL_ERROR_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单条可按 id 关闭。
|
||||||
|
clearGlobalErrors()
|
||||||
|
notifyGlobalError('单条')
|
||||||
|
const [item] = snapshotGlobalErrors()
|
||||||
|
dismissGlobalError(item.id)
|
||||||
|
assert.deepEqual(snapshotGlobalErrors(), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限:超过上限只保留最近 N 条,不堆积。
|
||||||
|
clearGlobalErrors()
|
||||||
|
for (let i = 1; i <= GLOBAL_ERROR_LIMIT + 2; i += 1) notifyGlobalError(`err${i}`)
|
||||||
|
const list = snapshotGlobalErrors()
|
||||||
|
assert.equal(list.length, GLOBAL_ERROR_LIMIT)
|
||||||
|
assert.equal(list[list.length - 1].message, `err${GLOBAL_ERROR_LIMIT + 2}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:Error/数字/对象负载均归一化到可展示文案。
|
||||||
|
clearGlobalErrors()
|
||||||
|
assert.equal(normalizeErrorMessage(new Error('连接被拒绝')), '连接被拒绝')
|
||||||
|
assert.equal(normalizeErrorMessage(500), GLOBAL_ERROR_FALLBACK)
|
||||||
|
assert.equal(normalizeErrorMessage({ code: 1 }), GLOBAL_ERROR_FALLBACK)
|
||||||
|
assert.equal(normalizeErrorMessage(new Error(' ')), GLOBAL_ERROR_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_013_global_error_notice_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败:订阅提供取消函数,取消后不再回调(容器卸载无泄漏)。
|
||||||
|
clearGlobalErrors()
|
||||||
|
const seen: number[] = []
|
||||||
|
const unsubscribe = subscribeGlobalError((list) => seen.push(list.length))
|
||||||
|
notifyGlobalError('a')
|
||||||
|
unsubscribe()
|
||||||
|
notifyGlobalError('b')
|
||||||
|
assert.deepEqual(seen, [0, 1], '取消订阅后不应再收到更新')
|
||||||
|
clearGlobalErrors()
|
||||||
|
// 容器组件本身在卸载时执行清理并保留可关闭错误项。
|
||||||
|
const container = readSource('src/layout/GlobalErrorContainer.vue')
|
||||||
|
assert.match(container, /unsubscribe/)
|
||||||
|
assert.match(container, /dismissGlobalError/)
|
||||||
|
const layout = readSource('src/layout/AdminLayout.vue')
|
||||||
|
assert.match(layout, /GlobalErrorContainer/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user