From c3f160afa54f4e7a8d47d05776d719b7707e28f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:25:19 +0800 Subject: [PATCH] =?UTF-8?q?task-13(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E5=85=A8=E5=B1=80=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=AE=B9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error-bus 提供框架无关的订阅式错误总线:notify/dismiss/clear、负载归一化、 3 条上限截断、订阅返回取消函数防泄漏;GlobalErrorContainer 订阅并渲染可关闭 错误条,挂在 AdminLayout 内容区顶部。 --- admin-frontend-vue/src/layout/AdminLayout.vue | 2 + .../src/layout/GlobalErrorContainer.vue | 35 +++++++ admin-frontend-vue/src/layout/error-bus.ts | 56 +++++++++++ admin-frontend-vue/src/styles/main.css | 1 + admin-frontend-vue/tests/task-13.test.ts | 94 +++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 admin-frontend-vue/src/layout/GlobalErrorContainer.vue create mode 100644 admin-frontend-vue/src/layout/error-bus.ts create mode 100644 admin-frontend-vue/tests/task-13.test.ts diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue index 7e8b3071..9519949c 100644 --- a/admin-frontend-vue/src/layout/AdminLayout.vue +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -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() {
+ +import { onBeforeUnmount, onMounted, ref } from 'vue' +import { + dismissGlobalError, + subscribeGlobalError, + type GlobalErrorItem, +} from '@/layout/error-bus' + +const errors = ref([]) +let unsubscribe: (() => void) | null = null + +onMounted(() => { + unsubscribe = subscribeGlobalError((list) => { + errors.value = list + }) +}) +onBeforeUnmount(() => { + unsubscribe?.() + unsubscribe = null +}) + + + diff --git a/admin-frontend-vue/src/layout/error-bus.ts b/admin-frontend-vue/src/layout/error-bus.ts new file mode 100644 index 00000000..1f14fec4 --- /dev/null +++ b/admin-frontend-vue/src/layout/error-bus.ts @@ -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() + +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() +} diff --git a/admin-frontend-vue/src/styles/main.css b/admin-frontend-vue/src/styles/main.css index 61090caa..93f07060 100644 --- a/admin-frontend-vue/src/styles/main.css +++ b/admin-frontend-vue/src/styles/main.css @@ -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; } diff --git a/admin-frontend-vue/tests/task-13.test.ts b/admin-frontend-vue/tests/task-13.test.ts new file mode 100644 index 00000000..4ae96525 --- /dev/null +++ b/admin-frontend-vue/tests/task-13.test.ts @@ -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/) +})