From f1fa5f7adf4e85b09047cd905da8593064cd94e2 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:22:52 +0800 Subject: [PATCH] =?UTF-8?q?task-10(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E9=A1=B5=E9=9D=A2=E7=BA=A7=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E7=BB=84=E4=BB=B6=E5=8A=A0=E8=BD=BD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lazy-config 暴露 loading 延迟/超时/文案与懒加载判定;lazy-page 用 defineAsyncComponent 提供 loading/error 组件,router 对所有注册路由统一包裹; main.css 增加 .page-loading/.page-error 兜底样式。 --- admin-frontend-vue/src/router/index.ts | 8 ++- admin-frontend-vue/src/router/lazy-config.ts | 15 ++++ admin-frontend-vue/src/router/lazy-page.ts | 23 ++++++ admin-frontend-vue/src/styles/main.css | 61 ++++++++++++++++ admin-frontend-vue/tests/task-10.test.ts | 75 ++++++++++++++++++++ 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 admin-frontend-vue/src/router/lazy-config.ts create mode 100644 admin-frontend-vue/src/router/lazy-page.ts create mode 100644 admin-frontend-vue/src/styles/main.css create mode 100644 admin-frontend-vue/tests/task-10.test.ts diff --git a/admin-frontend-vue/src/router/index.ts b/admin-frontend-vue/src/router/index.ts index d20f84ce..aee595b2 100644 --- a/admin-frontend-vue/src/router/index.ts +++ b/admin-frontend-vue/src/router/index.ts @@ -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[], }, ], }) diff --git a/admin-frontend-vue/src/router/lazy-config.ts b/admin-frontend-vue/src/router/lazy-config.ts new file mode 100644 index 00000000..e43499e1 --- /dev/null +++ b/admin-frontend-vue/src/router/lazy-config.ts @@ -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 { + return typeof value === 'function' +} + +/** 动态 import 产物是否为含 default 的页面模块。 */ +export function isPageModule(module: unknown): boolean { + return typeof module === 'object' && module !== null && 'default' in module +} diff --git a/admin-frontend-vue/src/router/lazy-page.ts b/admin-frontend-vue/src/router/lazy-page.ts new file mode 100644 index 00000000..50076f43 --- /dev/null +++ b/admin-frontend-vue/src/router/lazy-page.ts @@ -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), + }) +} diff --git a/admin-frontend-vue/src/styles/main.css b/admin-frontend-vue/src/styles/main.css new file mode 100644 index 00000000..8d737580 --- /dev/null +++ b/admin-frontend-vue/src/styles/main.css @@ -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; } +} diff --git a/admin-frontend-vue/tests/task-10.test.ts b/admin-frontend-vue/tests/task-10.test.ts new file mode 100644 index 00000000..3f27876f --- /dev/null +++ b/admin-frontend-vue/tests/task-10.test.ts @@ -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/) +})