task-13(壳层/路由): 实现全局请求错误提示容器

error-bus 提供框架无关的订阅式错误总线:notify/dismiss/clear、负载归一化、
3 条上限截断、订阅返回取消函数防泄漏;GlobalErrorContainer 订阅并渲染可关闭
错误条,挂在 AdminLayout 内容区顶部。
This commit is contained in:
2026-09-05 12:25:19 +08:00
parent f4f7268045
commit c3f160afa5
5 changed files with 188 additions and 0 deletions
@@ -11,6 +11,7 @@ import {
EMPTY_MENU_TITLE,
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
const route = useRoute()
const router = useRouter()
@@ -84,6 +85,7 @@ async function signOut() {
</div>
</header>
<main class="admin-content">
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty
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()
}
+1
View File
@@ -51,6 +51,7 @@ button, input, textarea, select { font: inherit; }
.page-loading { color: var(--admin-muted); }
.page-error { color: #b91c1c; }
.not-found { padding: 56px 20px; }
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
@media (max-width: 820px) {
.admin-sidebar { flex-basis: 64px; }