task-10(壳层/路由): 实现页面级异步组件加载边界
lazy-config 暴露 loading 延迟/超时/文案与懒加载判定;lazy-page 用 defineAsyncComponent 提供 loading/error 组件,router 对所有注册路由统一包裹; main.css 增加 .page-loading/.page-error 兜底样式。
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import type { AdminMenuNode } from '@/types/admin'
|
||||
import AdminLayout from '@/layout/AdminLayout.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { APP_BASE_PATH } from '@/config/app'
|
||||
import { adminRouteRecords } from './routes'
|
||||
import { defineLazyPage } from './lazy-page'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(APP_BASE_PATH),
|
||||
@@ -11,7 +12,10 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
children: adminRouteRecords,
|
||||
children: adminRouteRecords.map((record) => ({
|
||||
...record,
|
||||
component: defineLazyPage(record.component as () => Promise<{ default: unknown }>),
|
||||
})) as RouteRecordRaw[],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 异步页面加载边界可调参数(任务 10):loading 延迟/超时/文案。 */
|
||||
export const PAGE_LOADING_DELAY = 200
|
||||
export const PAGE_LOADING_TIMEOUT = 15000
|
||||
export const PAGE_LOADING_TEXT = '页面加载中'
|
||||
export const PAGE_LOAD_ERROR_TEXT = '页面加载失败,请刷新重试'
|
||||
|
||||
/** 是否为异步加载器(函数式)。 */
|
||||
export function isLazyLoader(value: unknown): value is () => Promise<unknown> {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
/** 动态 import 产物是否为含 default 的页面模块。 */
|
||||
export function isPageModule(module: unknown): boolean {
|
||||
return typeof module === 'object' && module !== null && 'default' in module
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineAsyncComponent, h, type Component } from 'vue'
|
||||
import {
|
||||
PAGE_LOADING_DELAY,
|
||||
PAGE_LOADING_TEXT,
|
||||
PAGE_LOADING_TIMEOUT,
|
||||
PAGE_LOAD_ERROR_TEXT,
|
||||
} from './lazy-config'
|
||||
|
||||
type PageLoader = () => Promise<{ default: unknown }>
|
||||
|
||||
/**
|
||||
* 页面级异步加载边界(任务 10):按路由分包页面,加载中/失败都有明确反馈,
|
||||
* 不把 chunk 加载错误静默吞掉。
|
||||
*/
|
||||
export function defineLazyPage(loader: PageLoader): Component {
|
||||
return defineAsyncComponent({
|
||||
loader: () => loader().then((mod) => mod.default as Component),
|
||||
delay: PAGE_LOADING_DELAY,
|
||||
timeout: PAGE_LOADING_TIMEOUT,
|
||||
loadingComponent: () => h('div', { class: 'page-loading' }, PAGE_LOADING_TEXT),
|
||||
errorComponent: () => h('div', { class: 'page-error' }, PAGE_LOAD_ERROR_TEXT),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
:root {
|
||||
--admin-sidebar: #192132;
|
||||
--admin-sidebar-deep: #121827;
|
||||
--admin-primary: #6366f1;
|
||||
--admin-primary-soft: #eef0ff;
|
||||
--admin-bg: #f3f5f9;
|
||||
--admin-border: #e5e7ef;
|
||||
--admin-text: #1f2937;
|
||||
--admin-muted: #7b8495;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; min-height: 100%; height: 100%; }
|
||||
body { background: var(--admin-bg); color: var(--admin-text); }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
|
||||
.admin-shell { display: flex; min-height: 100vh; background: var(--admin-bg); }
|
||||
.admin-sidebar { display: flex; flex: 0 0 248px; flex-direction: column; background: linear-gradient(180deg, var(--admin-sidebar) 0%, var(--admin-sidebar-deep) 100%); color: #b9c2d5; transition: flex-basis .2s ease; }
|
||||
.admin-shell.is-collapsed .admin-sidebar { flex-basis: 64px; }
|
||||
.admin-brand { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 16px; border-bottom: 1px solid rgba(255,255,255,.08); }
|
||||
.admin-brand-logo { width: 36px; height: 36px; border-radius: 10px; object-fit: cover; background: white; }
|
||||
.admin-brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.admin-brand-copy strong { color: #fff; font-size: 16px; }
|
||||
.admin-brand-copy span { color: #929db4; font-size: 11px; white-space: nowrap; }
|
||||
.admin-menu-scroll { flex: 1; padding: 14px 10px; }
|
||||
.admin-menu { border-right: 0; background: transparent; }
|
||||
.admin-menu .el-sub-menu__title, .admin-menu .el-menu-item { height: 42px; line-height: 42px; border-radius: 8px; margin: 3px 0; color: #aeb8cc; }
|
||||
.admin-menu .el-sub-menu__title:hover, .admin-menu .el-menu-item:hover { background: rgba(255,255,255,.08); color: #fff; }
|
||||
.admin-menu .el-menu-item.is-active { background: linear-gradient(90deg, rgba(99,102,241,.95), rgba(99,102,241,.62)); color: #fff; }
|
||||
.admin-menu .el-menu { background: transparent; }
|
||||
.menu-group-title { font-weight: 600; }
|
||||
.sidebar-collapse { margin: 12px; padding: 9px 10px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: #aeb8cc; background: transparent; cursor: pointer; }
|
||||
.sidebar-collapse:hover { color: #fff; border-color: rgba(255,255,255,.3); }
|
||||
.admin-main { display: flex; min-width: 0; flex: 1; flex-direction: column; }
|
||||
.admin-topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 84px; padding: 18px 28px; background: #fff; border-bottom: 1px solid var(--admin-border); }
|
||||
.admin-kicker { color: var(--admin-primary); font-size: 12px; font-weight: 700; letter-spacing: .08em; }
|
||||
.admin-topbar h1 { margin: 5px 0 0; font-size: 22px; }
|
||||
.admin-user { display: flex; align-items: center; gap: 18px; }
|
||||
.admin-user-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.admin-user-meta strong { font-size: 14px; }
|
||||
.admin-user-meta span { color: var(--admin-muted); font-size: 12px; }
|
||||
.admin-content { flex: 1; min-width: 0; padding: 26px 28px; overflow: auto; }
|
||||
.page-stack { display: flex; flex-direction: column; gap: 18px; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.page-heading h2 { margin: 0; font-size: 22px; }
|
||||
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
|
||||
.table-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 18px; color: var(--admin-muted); font-size: 13px; }
|
||||
.el-card { border-color: var(--admin-border); border-radius: 12px; }
|
||||
.page-loading, .page-error { padding: 48px 20px; text-align: center; }
|
||||
.page-loading { color: var(--admin-muted); }
|
||||
.page-error { color: #b91c1c; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.admin-sidebar { flex-basis: 64px; }
|
||||
.admin-brand-copy, .admin-menu .el-sub-menu__title span, .admin-menu .el-menu-item { display: none; }
|
||||
.admin-topbar { align-items: flex-start; flex-direction: column; }
|
||||
.admin-user { width: 100%; justify-content: space-between; }
|
||||
.admin-user-meta { align-items: flex-start; }
|
||||
.admin-content { padding: 18px; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
isLazyLoader,
|
||||
isPageModule,
|
||||
PAGE_LOADING_DELAY,
|
||||
PAGE_LOADING_TEXT,
|
||||
PAGE_LOADING_TIMEOUT,
|
||||
PAGE_LOAD_ERROR_TEXT,
|
||||
} from '../src/router/lazy-config.ts'
|
||||
import { adminPages } from '../src/router/routes.ts'
|
||||
|
||||
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
||||
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
||||
assert.equal(adminPages.length, 3)
|
||||
for (const page of adminPages) {
|
||||
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
||||
}
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_normal_variant_input', async () => {
|
||||
// 正常变体:加载器产物为页面模块(含 default)时识别成功。
|
||||
const loader = async () => ({ default: {} })
|
||||
const mod = await loader()
|
||||
assert.equal(isPageModule(mod), true)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:懒加载判定稳定。
|
||||
assert.equal(isLazyLoader(() => Promise.resolve({})), true)
|
||||
assert.equal(isLazyLoader(() => Promise.resolve({})), true)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_boundary_empty_input', () => {
|
||||
// 边界空值:缺省/空输入按非加载器/非页面模块处理,不崩溃。
|
||||
assert.equal(isLazyLoader(undefined), false)
|
||||
assert.equal(isLazyLoader(null), false)
|
||||
assert.equal(isPageModule(null), false)
|
||||
assert.equal(isPageModule(undefined), false)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_boundary_single_item', () => {
|
||||
// 边界单元素:单个含 default 的模块即可作为页面。
|
||||
assert.equal(isPageModule({ default: () => null }), true)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限:loading 延迟/超时为正且超时足够等待网络;文案不空。
|
||||
assert.ok(PAGE_LOADING_DELAY > 0)
|
||||
assert.ok(PAGE_LOADING_TIMEOUT > PAGE_LOADING_DELAY)
|
||||
assert.ok(PAGE_LOADING_TIMEOUT <= 60000, '页面加载超时应处于合理上限内')
|
||||
assert.ok(PAGE_LOADING_TEXT.length > 0)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_invalid_input_rejected', () => {
|
||||
// 异常输入:非函数/数组对象都不是懒加载器;无 default 模块不是页面模块。
|
||||
assert.equal(isLazyLoader('load'), false)
|
||||
assert.equal(isLazyLoader(123), false)
|
||||
assert.equal(isLazyLoader({}), false)
|
||||
assert.equal(isPageModule({}), false)
|
||||
assert.equal(isPageModule([]), false)
|
||||
})
|
||||
|
||||
test('test_task_010_lazy_page_boundary_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败:router 对懒加载统一加异步边界;失败/加载中有样式与文案兜底。
|
||||
const router = readSource('src/router/index.ts')
|
||||
assert.match(router, /defineLazyPage/)
|
||||
assert.equal(router.includes('adminRouteRecords.map'), true)
|
||||
const lazyPage = readSource('src/router/lazy-page.ts')
|
||||
assert.match(lazyPage, /PAGE_LOAD_ERROR_TEXT/)
|
||||
const css = readSource('src/styles/main.css')
|
||||
assert.match(css, /\.page-loading/)
|
||||
assert.match(css, /\.page-error/)
|
||||
})
|
||||
Reference in New Issue
Block a user