From aa712519b55baaa400bcdfc9e198fc7357c99144 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 04:28:13 +0800 Subject: [PATCH] fix: align Taro H5 with legacy question bank UI --- apps/taro/src/app.config.ts | 84 ++++--- apps/taro/src/app.css | 9 + apps/taro/src/app.tsx | 95 ++++---- apps/taro/src/components/AdminLegacyShell.css | 213 ++++++++++++++++++ apps/taro/src/index.html | 29 +-- apps/taro/src/pages/bootstrap/index.tsx | 12 +- .../src/pages/platform-admin/platform.css | 163 ++++++++++++++ .../pages/platform-admin/workbench/index.tsx | 2 +- apps/taro/src/pages/student/home/index.tsx | 10 +- apps/taro/src/pages/student/login/index.tsx | 13 +- apps/taro/src/pages/tenant-admin/admin.css | 199 ++++++++++++++++ .../pages/tenant-admin/workbench/index.tsx | 2 +- apps/taro/src/services/routeGuard.ts | 18 +- scripts/taro-h5-interaction-smoke.js | 161 +++++++++++-- scripts/taro-route-contract-test.js | 48 +++- 15 files changed, 904 insertions(+), 154 deletions(-) diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index 335e3ce8..510a18f9 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -1,36 +1,56 @@ +declare const process: { + env: Record; +}; + +const allPageRoutes = [ + 'pages/bootstrap/index', + 'pages/student/login/index', + 'pages/student/home/index', + 'pages/student/region/index', + 'pages/student/catalog/index', + 'pages/student/practice/index', + 'pages/student/review/index', + 'pages/student/reports/index', + 'pages/student/video/index', + 'pages/student/checkout/index', + 'pages/student/order-detail/index', + 'pages/student/vocabulary/index', + 'pages/student/handbook/index', + 'pages/student/scoreline/index', + 'pages/student/ai-school/index', + 'pages/student/assets/index', + 'pages/student/notifications/index', + 'pages/student/profile/index', + 'pages/tenant-admin/workbench/index', + 'pages/tenant-admin/dashboard/index', + 'pages/tenant-admin/students/index', + 'pages/tenant-admin/content/index', + 'pages/tenant-admin/marketing/index', + 'pages/tenant-admin/finance/index', + 'pages/tenant-admin/settings/index', + 'pages/platform-admin/workbench/index', + 'pages/platform-admin/tenants/index', + 'pages/platform-admin/billing/index', + 'pages/platform-admin/question-banks/index', + 'pages/platform-admin/staff/index', +]; + +const portalLandingRoutes: Record = { + student: 'pages/student/home/index', + 'tenant-admin': 'pages/tenant-admin/workbench/index', + 'platform-admin': 'pages/platform-admin/workbench/index', +}; + +function pagesForPortal(portal: string | undefined) { + const landingRoute = portalLandingRoutes[portal || 'student'] || portalLandingRoutes.student; + return [ + landingRoute, + ...allPageRoutes.filter(route => route !== landingRoute), + ]; +} + export default defineAppConfig({ - pages: [ - 'pages/bootstrap/index', - 'pages/student/login/index', - 'pages/student/home/index', - 'pages/student/region/index', - 'pages/student/catalog/index', - 'pages/student/practice/index', - 'pages/student/review/index', - 'pages/student/reports/index', - 'pages/student/video/index', - 'pages/student/checkout/index', - 'pages/student/order-detail/index', - 'pages/student/vocabulary/index', - 'pages/student/handbook/index', - 'pages/student/scoreline/index', - 'pages/student/ai-school/index', - 'pages/student/assets/index', - 'pages/student/notifications/index', - 'pages/student/profile/index', - 'pages/tenant-admin/workbench/index', - 'pages/tenant-admin/dashboard/index', - 'pages/tenant-admin/students/index', - 'pages/tenant-admin/content/index', - 'pages/tenant-admin/marketing/index', - 'pages/tenant-admin/finance/index', - 'pages/tenant-admin/settings/index', - 'pages/platform-admin/workbench/index', - 'pages/platform-admin/tenants/index', - 'pages/platform-admin/billing/index', - 'pages/platform-admin/question-banks/index', - 'pages/platform-admin/staff/index', - ], + pages: pagesForPortal(process.env.TARO_APP_PORTAL), window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#0f172a', diff --git a/apps/taro/src/app.css b/apps/taro/src/app.css index 9b4bcf61..17323768 100644 --- a/apps/taro/src/app.css +++ b/apps/taro/src/app.css @@ -89,11 +89,20 @@ textarea { } .route-guard-page { + position: fixed; + inset: 0; + z-index: 9999; min-height: 100vh; padding: 28px; background: var(--tiku-page); } +.route-guard-hidden { + height: 100vh; + overflow: hidden; + visibility: hidden; +} + .route-guard-shell { width: 100%; max-width: 760px; diff --git a/apps/taro/src/app.tsx b/apps/taro/src/app.tsx index 6a56acbe..d0f4621f 100644 --- a/apps/taro/src/app.tsx +++ b/apps/taro/src/app.tsx @@ -60,6 +60,38 @@ function shouldUseAdminShell(path: string) { return false; } +function RouteGuardOverlay() { + const copy = appEnv.portal === 'tenant-admin' + ? { + kicker: 'Tenant Admin', + title: '正在校验后台权限', + subtitle: '请先完成登录,系统会确认当前账号是否拥有租户后台权限。', + } + : appEnv.portal === 'platform-admin' + ? { + kicker: 'Platform Admin', + title: '正在校验平台权限', + subtitle: '请先完成登录,系统会确认当前账号是否拥有平台管理员权限。', + } + : { + kicker: 'Student', + title: '正在校验登录状态', + subtitle: '请先完成登录,系统会带你回到刚才打开的页面。', + }; + + return ( + + + + {copy.kicker} + {copy.title} + {copy.subtitle} + + + + ); +} + export default function App({ children }: PropsWithChildren) { const [routeReady, setRouteReady] = useState(false); const [path, setPath] = useState(() => currentPagePath()); @@ -80,55 +112,18 @@ export default function App({ children }: PropsWithChildren) { return installH5RouteListener(verifyCurrentRoute); }, []); - if (!routeReady && appEnv.portal === 'tenant-admin') { - return ( - - - - Tenant Admin - 正在校验后台权限 - 请先完成登录,系统会确认当前账号是否拥有租户后台权限。 - - + const content = shouldUseStudentShell(path) + ? {children} + : shouldUseAdminShell(path) + ? {children} + : children; + + return ( + <> + + {content} - ); - } - - if (!routeReady && appEnv.portal === 'platform-admin') { - return ( - - - - Platform Admin - 正在校验平台权限 - 请先完成登录,系统会确认当前账号是否拥有平台管理员权限。 - - - - ); - } - - if (!routeReady) { - return ( - - - - Student - 正在校验登录状态 - 请先完成登录,系统会带你回到刚才打开的页面。 - - - - ); - } - - if (shouldUseStudentShell(path)) { - return {children}; - } - - if (shouldUseAdminShell(path)) { - return {children}; - } - - return children; + {!routeReady ? : null} + + ); } diff --git a/apps/taro/src/components/AdminLegacyShell.css b/apps/taro/src/components/AdminLegacyShell.css index ba5024b9..cfb6ed70 100644 --- a/apps/taro/src/components/AdminLegacyShell.css +++ b/apps/taro/src/components/AdminLegacyShell.css @@ -476,3 +476,216 @@ padding: 14px; } } + +/* Legacy backoffice refinement: table-first workspace, tight nav, quiet chrome. */ +.backoffice-legacy-layout { + --backoffice-line: #eef2f7; + --backoffice-muted: #6b7280; +} + +.backoffice-mobile-nav, +.backoffice-legacy-sidebar, +.backoffice-legacy-main { + scrollbar-color: #cbd5e1 transparent; + scrollbar-width: thin; +} + +.backoffice-mobile-nav::-webkit-scrollbar, +.backoffice-nav::-webkit-scrollbar, +.backoffice-legacy-main::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.backoffice-mobile-nav::-webkit-scrollbar-thumb, +.backoffice-nav::-webkit-scrollbar-thumb, +.backoffice-legacy-main::-webkit-scrollbar-thumb { + border-radius: 999px; + background: #cbd5e1; +} + +.backoffice-legacy-main .admin-header, +.backoffice-legacy-main .platform-header { + display: grid; + gap: 5px; + padding: 18px 20px; +} + +.backoffice-legacy-main .admin-kicker, +.backoffice-legacy-main .platform-kicker { + font-size: 12px; + font-weight: 950; + text-transform: uppercase; +} + +.backoffice-legacy-main .admin-title, +.backoffice-legacy-main .platform-title { + font-size: 26px; + line-height: 1.15; +} + +.backoffice-legacy-main .admin-subtitle, +.backoffice-legacy-main .platform-subtitle { + max-width: 760px; + font-size: 14px; + line-height: 1.45; +} + +.backoffice-legacy-main .admin-section-title, +.backoffice-legacy-main .platform-section-title { + font-size: 18px; +} + +.backoffice-legacy-main .admin-section-title::before, +.backoffice-legacy-main .platform-section-title::before { + width: 4px; + height: 16px; +} + +.backoffice-legacy-main .admin-grid, +.backoffice-legacy-main .platform-grid { + gap: 10px; +} + +.backoffice-legacy-main .admin-row, +.backoffice-legacy-main .platform-row { + padding: 12px 14px 12px 18px; + border-radius: 12px; +} + +.backoffice-legacy-main .admin-row-main, +.backoffice-legacy-main .platform-row-main { + font-size: 15px; + line-height: 1.3; +} + +.backoffice-legacy-main .admin-row-meta, +.backoffice-legacy-main .platform-row-meta { + font-size: 12px; + line-height: 1.45; +} + +.backoffice-legacy-main .admin-metric, +.backoffice-legacy-main .platform-metric { + min-height: 78px; + padding: 12px 14px; + border-radius: 12px; +} + +.backoffice-legacy-main .admin-metric-label, +.backoffice-legacy-main .platform-metric-label { + font-size: 12px; +} + +.backoffice-legacy-main .admin-metric-value, +.backoffice-legacy-main .platform-metric-value { + margin-top: 5px; + font-size: 21px; +} + +.backoffice-legacy-main .admin-input, +.backoffice-legacy-main .platform-input { + height: 38px; + border-radius: 9px; + font-size: 13px; +} + +.backoffice-legacy-main .admin-textarea { + min-height: 118px; + border-radius: 10px; + font-size: 13px; +} + +.backoffice-legacy-main .admin-button, +.backoffice-legacy-main .platform-button { + height: 38px; + min-width: 86px; + border-radius: 9px; + font-size: 13px; + line-height: 38px; +} + +.backoffice-legacy-main .admin-mini-button, +.backoffice-legacy-main .platform-mini-button { + height: 32px; + min-width: 74px; + border-radius: 8px; + font-size: 12px; + line-height: 32px; +} + +@media (min-width: 980px) { + .backoffice-legacy-layout { + grid-template-columns: 236px minmax(0, 1fr); + gap: 12px; + padding: 12px; + } + + .backoffice-legacy-sidebar { + top: 12px; + height: calc(100vh - 24px); + padding: 12px; + border-radius: 18px; + } + + .backoffice-brand { + padding: 10px 8px 14px; + } + + .backoffice-brand-icon { + flex-basis: 38px; + width: 38px; + height: 38px; + border-radius: 10px; + } + + .backoffice-brand-title { + font-size: 17px; + } + + .backoffice-brand-sub { + font-size: 12px; + } + + .backoffice-select-box { + min-height: 36px; + border-radius: 10px; + } + + .backoffice-select-text { + font-size: 13px; + } + + .backoffice-nav-btn { + min-height: 40px; + padding: 0 11px; + border-radius: 10px; + } + + .backoffice-nav-mark { + width: 22px; + height: 22px; + border-radius: 7px; + font-size: 12px; + } + + .backoffice-nav-text { + font-size: 13px; + } + + .backoffice-nav-group { + margin-top: 8px; + font-size: 10px; + } + + .backoffice-legacy-main { + max-height: calc(100vh - 24px); + overflow: auto; + border-radius: 18px; + } + + .backoffice-legacy-main .admin-page, + .backoffice-legacy-main .platform-page { + padding: 18px; + } +} diff --git a/apps/taro/src/index.html b/apps/taro/src/index.html index 9447141c..33bbe6db 100644 --- a/apps/taro/src/index.html +++ b/apps/taro/src/index.html @@ -15,39 +15,28 @@ (function () { var path = window.location.pathname || ''; var portal = '<%= process.env.TARO_APP_PORTAL || "student" %>'; - var redirect = path + (window.location.search || ''); var isRootEntry = !path || path === '/' || /\/index\.html$/.test(path); - var publicStudentPaths = { - '/pages/student/login/index': true, - '/pages/student/region/index': true - }; + var landing = portal === 'tenant-admin' + ? '/pages/tenant-admin/workbench/index' + : portal === 'platform-admin' + ? '/pages/platform-admin/workbench/index' + : '/pages/student/home/index'; if (isRootEntry) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent('/')); + window.location.replace(landing); return; } if (portal === 'student' && path.indexOf('/pages/') === 0 && path.indexOf('/pages/student/') !== 0 && path.indexOf('/pages/bootstrap/') !== 0) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent('/pages/student/home/index')); + window.location.replace(landing); return; } if (portal === 'tenant-admin' && path.indexOf('/pages/') === 0 && path.indexOf('/pages/tenant-admin/') !== 0 && path.indexOf('/pages/bootstrap/') !== 0 && path.indexOf('/pages/student/login/') !== 0) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent('/pages/tenant-admin/workbench/index')); + window.location.replace(landing); return; } if (portal === 'platform-admin' && path.indexOf('/pages/') === 0 && path.indexOf('/pages/platform-admin/') !== 0 && path.indexOf('/pages/bootstrap/') !== 0 && path.indexOf('/pages/student/login/') !== 0) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent('/pages/platform-admin/workbench/index')); + window.location.replace(landing); return; } - if (path.indexOf('/pages/student/') === 0 && !publicStudentPaths[path]) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent(redirect)); - return; - } - if (portal === 'tenant-admin' && path.indexOf('/pages/tenant-admin/') === 0) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent(redirect)); - return; - } - if (portal === 'platform-admin' && path.indexOf('/pages/platform-admin/') === 0) { - window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent(redirect)); - } }()); diff --git a/apps/taro/src/pages/bootstrap/index.tsx b/apps/taro/src/pages/bootstrap/index.tsx index f03c3663..2fc04b4a 100644 --- a/apps/taro/src/pages/bootstrap/index.tsx +++ b/apps/taro/src/pages/bootstrap/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; -import Taro, { useRouter } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env'; import { resolveTenant } from '@/services/api'; -import { landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard'; +import { currentRouteParams, landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard'; import './index.css'; function hostFromRuntime() { @@ -12,12 +12,12 @@ function hostFromRuntime() { } export default function BootstrapPage() { - const router = useRouter(); const [status, setStatus] = useState('正在解析租户'); const [error, setError] = useState(''); useEffect(() => { - const redirectPath = safeRedirectPath(router.params?.redirect ? decodeURIComponent(router.params.redirect) : landingPath()); + const params = currentRouteParams(); + const redirectPath = safeRedirectPath(params.redirect ? decodeURIComponent(String(params.redirect)) : landingPath()); assertFrontendSecretsAreAbsent(); ensureRuntimeConfigLoaded() .then(() => resolveTenant({ host: hostFromRuntime() })) @@ -30,6 +30,10 @@ export default function BootstrapPage() { .then(authPayload => { if (!authPayload) return; setStatus('租户解析完成'); + if (isH5Runtime() && typeof window !== 'undefined') { + window.location.replace(redirectPath); + return; + } Taro.redirectTo({ url: redirectPath }); }) .catch((nextError: Error) => { diff --git a/apps/taro/src/pages/platform-admin/platform.css b/apps/taro/src/pages/platform-admin/platform.css index 9e83e1f9..a595ca71 100644 --- a/apps/taro/src/pages/platform-admin/platform.css +++ b/apps/taro/src/pages/platform-admin/platform.css @@ -924,3 +924,166 @@ padding: 16px; } } + +/* Legacy platform admin deep pages: billing, tenant detail, grants, staff. */ +.platform-page { + --platform-table-line: #eef2f7; +} + +.platform-section { + overflow: hidden; +} + +.platform-section + .platform-section { + margin-top: 0; +} + +.platform-form { + align-items: end; + border-color: #eef2f7; + background: + linear-gradient(to right, rgba(15, 23, 42, 0.018) 1px, transparent 1px), + #fbfdff; + background-size: 32px 32px; +} + +.platform-form.compact + .platform-actions, +.platform-actions + .platform-form.compact, +.platform-actions + .platform-list, +.platform-grid + .platform-section { + margin-top: 10px; +} + +.platform-actions { + flex-wrap: wrap; + overflow-x: visible; + padding-bottom: 0; +} + +.platform-actions .platform-input { + height: 38px; + font-size: 13px; +} + +.platform-section > .platform-actions:first-child .platform-section-title { + margin-bottom: 0; +} + +.platform-list { + gap: 8px; +} + +.platform-row { + border-left: 0; +} + +.platform-row:hover { + border-color: #dbeafe; + background: #fbfdff; +} + +.platform-row-actions { + margin-top: 10px; + padding-top: 9px; +} + +.platform-row-actions .platform-input { + max-width: 280px; +} + +.platform-empty { + margin-top: 8px; + padding: 16px; + border-style: dashed; + background: #fbfdff; +} + +.platform-error { + padding: 10px 12px; + border-radius: 10px; + background: #fff1f2; +} + +.platform-chip, +.platform-chip.readonly { + min-height: 28px; + border-radius: 7px; + font-size: 12px; +} + +.platform-chip.active, +.platform-button.active, +.platform-mini-button.active { + border-color: #1e293b; + background: #1e293b; + color: #fff; +} + +.platform-permission-panel { + border-color: #eef2f7; + border-radius: 12px; +} + +.platform-permission-header { + gap: 8px; +} + +.platform-permission-group { + padding: 8px; + border: 1px solid #eef2f7; + border-radius: 10px; + background: #fff; +} + +.platform-chip-list.compact, +.platform-row .platform-chip-list.compact { + gap: 6px; +} + +.platform-section .platform-section { + border-color: #e5e7eb; + border-radius: 12px; +} + +.platform-section .platform-section + .platform-section { + margin-top: 10px; +} + +.platform-section .platform-section .platform-list { + gap: 6px; +} + +.platform-section .platform-section .platform-row { + padding: 10px 12px 10px 16px; + box-shadow: none; +} + +.platform-metric { + min-width: 0; +} + +@media (min-width: 980px) { + .platform-section { + padding: 16px; + } + + .platform-form { + gap: 8px; + padding: 10px; + } + + .platform-module-grid { + gap: 10px; + } + + .platform-module-card { + min-height: 112px; + padding: 16px; + border-radius: 14px; + } + + .platform-module-card .platform-row-main { + margin-top: 16px; + font-size: 20px; + } +} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index 696f7a5f..2d814d77 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -172,7 +172,7 @@ export default function PlatformWorkbenchPage() { 后台模块 {modules.map(item => ( - Taro.navigateTo({ url: item.path })}> + Taro.navigateTo({ url: item.path })}> Console {item.name} {item.meta} diff --git a/apps/taro/src/pages/student/home/index.tsx b/apps/taro/src/pages/student/home/index.tsx index fbec7dac..3cf4ea3a 100644 --- a/apps/taro/src/pages/student/home/index.tsx +++ b/apps/taro/src/pages/student/home/index.tsx @@ -61,7 +61,7 @@ export default function StudentHomePage() { - 今天也是元气满满的一天 + 今日学习 继续你的备考之旅 已经连续学习的每一天,都会让你离目标院校更近一步。 @@ -113,16 +113,16 @@ export default function StudentHomePage() { - 学习入口 + 功能导航 {entryNames.length ? entryNames.map(item => ( - Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}> + Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}> 已配置入口 {item.name} {item.entryType || 'content'} )) : quickActions.map(item => ( - Taro.navigateTo({ url: item.path })}> + Taro.navigateTo({ url: item.path })}> {item.meta} {item.name} 点击进入 @@ -135,7 +135,7 @@ export default function StudentHomePage() { 全部功能 {quickActions.map(item => ( - Taro.navigateTo({ url: item.path })}> + Taro.navigateTo({ url: item.path })}> {item.name} {item.meta} diff --git a/apps/taro/src/pages/student/login/index.tsx b/apps/taro/src/pages/student/login/index.tsx index 97be58fb..e2f0c2a3 100644 --- a/apps/taro/src/pages/student/login/index.tsx +++ b/apps/taro/src/pages/student/login/index.tsx @@ -1,15 +1,14 @@ import { useEffect, useState } from 'react'; -import { useRouter } from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; import { appEnv, ensureRuntimeConfigLoaded, type Portal } from '@/env'; import { getTenantContext } from '@/services/api'; import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth'; -import { landingPath, redirectAfterLogin } from '@/services/routeGuard'; +import { currentRouteParams, landingPath, redirectAfterLogin } from '@/services/routeGuard'; import '../student.css'; export default function StudentLoginPage() { - const router = useRouter(); const tenant = getTenantContext(); + const params = currentRouteParams(); const [portal, setPortal] = useState(appEnv.portal); const [runtimeReady, setRuntimeReady] = useState(false); const [phone, setPhone] = useState(''); @@ -26,12 +25,12 @@ export default function StudentLoginPage() { .then(() => { setPortal(appEnv.portal); setRuntimeReady(true); - if (router.params?.reason) { - setReason(decodeURIComponent(router.params.reason)); + if (params.reason) { + setReason(decodeURIComponent(String(params.reason))); return; } loadCurrentUser() - .then(() => redirectAfterLogin(router.params?.redirect || landingPath())) + .then(() => redirectAfterLogin(params.redirect || landingPath())) .catch(() => undefined); }) .catch(() => { @@ -91,7 +90,7 @@ export default function StudentLoginPage() { setError(''); try { await verifySmsCode(phone, code || debugCode); - redirectAfterLogin(router.params?.redirect || landingPath()); + redirectAfterLogin(params.redirect || landingPath()); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '登录失败'); } finally { diff --git a/apps/taro/src/pages/tenant-admin/admin.css b/apps/taro/src/pages/tenant-admin/admin.css index c3c8e71a..1f309310 100644 --- a/apps/taro/src/pages/tenant-admin/admin.css +++ b/apps/taro/src/pages/tenant-admin/admin.css @@ -1033,3 +1033,202 @@ grid-column: span 2; } } + +/* Legacy tenant admin deep pages: compact forms, badges, and operational rows. */ +.admin-page { + --admin-table-line: #eef2f7; +} + +.admin-section { + overflow: hidden; +} + +.admin-section + .admin-section { + margin-top: 0; +} + +.admin-section > .admin-form-grid:first-child, +.admin-section > .admin-actions:first-child, +.admin-section > .admin-list:first-child { + margin-top: 0; +} + +.admin-form-grid { + align-items: end; + border-color: #eef2f7; + background: + linear-gradient(to right, rgba(15, 23, 42, 0.018) 1px, transparent 1px), + #fbfdff; + background-size: 32px 32px; +} + +.admin-form-grid .admin-input, +.admin-form-grid .admin-textarea { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.admin-form-grid .admin-textarea { + grid-column: 1 / -1; +} + +.admin-section > .admin-textarea { + margin-top: 8px; +} + +.admin-actions.compact + .admin-form-grid, +.admin-actions.compact + .admin-list, +.admin-actions.compact + .admin-grid, +.admin-actions.compact + .admin-row { + margin-top: 10px; +} + +.admin-actions.compact .admin-section-title { + flex: 1 1 220px; + margin-bottom: 0; +} + +.admin-actions.compact .admin-input { + height: 38px; + font-size: 13px; +} + +.admin-list { + gap: 8px; +} + +.admin-list:not(.compact) > .admin-row + .admin-row { + margin-top: 0; +} + +.admin-row { + border-left: 0; +} + +.admin-row:hover { + border-color: #dbeafe; + background: #fbfdff; +} + +.admin-row-actions { + margin-top: 10px; + padding-top: 9px; +} + +.admin-row-actions .admin-input { + max-width: 280px; +} + +.admin-empty { + margin-top: 8px; + padding: 16px; + border-style: dashed; + background: #fbfdff; +} + +.admin-error, +.success-text { + padding: 10px 12px; + border-radius: 10px; + background: #fff1f2; +} + +.admin-sub-list { + border: 1px solid #eef2f7; + border-radius: 12px; +} + +.admin-sub-row { + box-shadow: none; +} + +.admin-chip, +.admin-row .admin-chip { + min-height: 28px; + border-radius: 7px; + font-size: 12px; +} + +.admin-button.active, +.admin-mini-button.active, +.admin-chip.active { + border-color: #1e293b; + background: #1e293b; + color: #fff; +} + +.admin-grid.three .admin-metric, +.admin-grid .admin-metric { + min-width: 0; +} + +.admin-metric.theme-template { + min-height: 120px; + border-left: 0; + border-top-width: 4px; +} + +.theme-swatch-row { + gap: 7px; +} + +.theme-swatch { + width: 28px; + height: 28px; +} + +.theme-preview-panel { + border-radius: 14px; +} + +.theme-preview-banner { + min-height: 84px; +} + +.theme-preview-title { + font-size: 18px; +} + +.theme-preview-subtitle { + font-size: 12px; +} + +.break-line { + display: block; + max-height: 180px; + overflow: auto; +} + +.admin-textarea + .admin-row-meta, +.admin-form-grid + .admin-row-meta { + margin-top: 8px; +} + +@media (min-width: 980px) { + .admin-section { + padding: 16px; + } + + .admin-form-grid { + gap: 8px; + padding: 10px; + } + + .admin-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .admin-module-grid { + gap: 10px; + } + + .admin-module-card { + min-height: 112px; + padding: 16px; + border-radius: 14px; + } + + .admin-module-card .admin-row-main { + margin-top: 16px; + font-size: 20px; + } +} diff --git a/apps/taro/src/pages/tenant-admin/workbench/index.tsx b/apps/taro/src/pages/tenant-admin/workbench/index.tsx index 43e1d31a..0e662012 100644 --- a/apps/taro/src/pages/tenant-admin/workbench/index.tsx +++ b/apps/taro/src/pages/tenant-admin/workbench/index.tsx @@ -136,7 +136,7 @@ export default function TenantWorkbenchPage() { 后台模块 {modules.map(item => ( - Taro.navigateTo({ url: item.path })}> + Taro.navigateTo({ url: item.path })}> Module {item.name} {item.meta} diff --git a/apps/taro/src/services/routeGuard.ts b/apps/taro/src/services/routeGuard.ts index fb3eea20..6c753883 100644 --- a/apps/taro/src/services/routeGuard.ts +++ b/apps/taro/src/services/routeGuard.ts @@ -50,6 +50,15 @@ export function currentPagePath() { return normalizedPath; } +export function currentRouteParams() { + if (isH5Runtime() && typeof window !== 'undefined') { + const search = window.location.search || (window.location.hash.includes('?') ? `?${window.location.hash.split('?').slice(1).join('?')}` : ''); + const params = new URLSearchParams(search); + return Object.fromEntries(params.entries()); + } + return Taro.getCurrentInstance().router?.params || {}; +} + function isPublicPath(path: string) { return path === '/pages/bootstrap/index' || path === '/pages/student/login/index'; } @@ -121,7 +130,7 @@ export async function guardCurrentRoute() { const path = currentPagePath(); if (!path || !shouldGuardPath(path)) return true; if (!isPathAllowedForPortal(path)) { - redirectToAuthUrl(`/pages/bootstrap/index?redirect=${encodeURIComponent(landingPath())}`); + redirectToAuthUrl(landingPath()); return false; } if (pendingGuardPath === path) return false; @@ -144,5 +153,10 @@ export async function guardCurrentRoute() { export function redirectAfterLogin(rawRedirect?: string) { const redirectPath = rawRedirect ? decodeURIComponent(rawRedirect) : landingPath(); - Taro.redirectTo({ url: safeRedirectPath(redirectPath) }); + const url = safeRedirectPath(redirectPath); + if (isH5Runtime() && typeof window !== 'undefined') { + window.location.replace(url); + return; + } + Taro.redirectTo({ url }); } diff --git a/scripts/taro-h5-interaction-smoke.js b/scripts/taro-h5-interaction-smoke.js index 63bdbff1..546b6a7e 100644 --- a/scripts/taro-h5-interaction-smoke.js +++ b/scripts/taro-h5-interaction-smoke.js @@ -365,7 +365,22 @@ const platformPermissionCatalog = [ function mockApiPayload(pathname, method, query, body) { if (pathname === '/health') return { ok: true }; if (pathname === '/api/tenant/resolve') return baseTenantPayload(query); - if (pathname === '/api/auth/me') return { user: { id: ids.user, name: '测试学生' }, item: { id: ids.user, name: '测试学生' } }; + if (pathname === '/api/auth/me') { + return { + user: { + id: ids.user, + name: '测试学生', + primaryRole: 'platform_admin', + roles: ['platform_admin', 'tenant_admin'], + }, + item: { + id: ids.user, + name: '测试学生', + primaryRole: 'platform_admin', + roles: ['platform_admin', 'tenant_admin'], + }, + }; + } if (pathname === '/api/catalog/content-entries') { return { @@ -1082,7 +1097,8 @@ class CdpPage { returnByValue: true, }); if (result.exceptionDetails) { - throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed'); + const description = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime evaluation failed'; + throw new Error(description); } return result.result?.value; } @@ -1276,7 +1292,7 @@ async function clickText(page, text) { `); await page.acceptDialogs(); if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`); - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y); + if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(350); await page.acceptDialogs(); return result; @@ -1344,7 +1360,7 @@ async function clickTextInSection(page, sectionTitle, text) { `); await page.acceptDialogs(); if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`); - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y); + if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(350); await page.acceptDialogs(); return result; @@ -1431,7 +1447,7 @@ async function clickVisibleTextCandidate(page, texts) { })() `); if (result?.ok) { - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y); + if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(250); await page.acceptDialogs(); } @@ -1524,16 +1540,45 @@ async function fillByPlaceholder(page, placeholder, value, occurrence = 0) { const expected = ${JSON.stringify(placeholder)}; const nextValue = ${JSON.stringify(value)}; const occurrence = ${Number(occurrence)}; - const elements = Array.from(document.querySelectorAll('input, textarea')) + const visible = el => { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; + }; + const nativeElements = Array.from(document.querySelectorAll('input, textarea')) + .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); + const customElements = Array.from(document.querySelectorAll('taro-input-core, taro-textarea-core')) + .filter(visible) + .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); + const elements = nativeElements.length ? nativeElements : customElements; const target = elements[occurrence]; if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) }; target.scrollIntoView({ block: 'center', inline: 'center' }); - const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set; + const previousValue = target.value; + const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null; + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null); if (setter) setter.call(target, nextValue); else target.value = nextValue; - for (const type of ['input', 'change']) { - target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); + target.setAttribute('value', nextValue); + if (target._valueTracker) target._valueTracker.setValue(previousValue); + target.focus(); + const makeInputEvent = type => { + try { + return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' }); + } catch { + return new Event(type, { bubbles: true, cancelable: true }); + } + }; + for (const event of [ + makeInputEvent('beforeinput'), + makeInputEvent('input'), + new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }), + new Event('compositionend', { bubbles: true, cancelable: true }), + new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }), + new Event('change', { bubbles: true, cancelable: true }), + ]) { + target.dispatchEvent(event); } return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value }; })() @@ -1560,16 +1605,40 @@ async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value .filter(el => visible(el) && normalized(el.innerText || el.textContent || '').includes(sectionTitle)); const root = sections.sort((a, b) => normalized(a.innerText || a.textContent || '').length - normalized(b.innerText || b.textContent || '').length)[0]; if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) }; - const elements = Array.from(root.querySelectorAll('input, textarea')) + const nativeElements = Array.from(root.querySelectorAll('input, textarea')) + .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); + const customElements = Array.from(root.querySelectorAll('taro-input-core, taro-textarea-core')) + .filter(visible) + .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); + const elements = nativeElements.length ? nativeElements : customElements; const target = elements[occurrence]; if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) }; target.scrollIntoView({ block: 'center', inline: 'center' }); - const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set; + const previousValue = target.value; + const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null; + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null); if (setter) setter.call(target, nextValue); else target.value = nextValue; - for (const type of ['input', 'change']) { - target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); + target.setAttribute('value', nextValue); + if (target._valueTracker) target._valueTracker.setValue(previousValue); + target.focus(); + const makeInputEvent = type => { + try { + return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' }); + } catch { + return new Event(type, { bubbles: true, cancelable: true }); + } + }; + for (const event of [ + makeInputEvent('beforeinput'), + makeInputEvent('input'), + new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }), + new Event('compositionend', { bubbles: true, cancelable: true }), + new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }), + new Event('change', { bubbles: true, cancelable: true }), + ]) { + target.dispatchEvent(event); } return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value }; })() @@ -1579,6 +1648,38 @@ async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value return result; } +async function formDiagnostics(page, placeholder = '') { + return page.evaluate(` + (() => { + const expected = ${JSON.stringify(placeholder)}; + const visible = el => { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; + }; + const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim(); + return { + path: location.pathname + location.search + location.hash, + body: normalized(document.body?.innerText || '').slice(0, 1600), + fields: Array.from(document.querySelectorAll('input, textarea, taro-input-core, taro-textarea-core')) + .filter(el => !expected || String(el.getAttribute('placeholder') || '').includes(expected)) + .map(el => ({ + tag: el.tagName, + className: String(el.className || ''), + placeholder: el.getAttribute('placeholder') || '', + value: el.value || el.getAttribute('value') || '', + visible: visible(el), + })), + buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]')) + .filter(visible) + .map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 140) })) + .filter(item => item.text.includes('预览') || item.text.includes('导入') || item.text.includes('题库') || item.text.includes('json')) + .slice(0, 30), + }; + })() + `).catch(error => ({ error: error.message })); +} + async function ensureInputValue(page, placeholder, value, occurrence = 0) { const current = await page.evaluate(` (() => { @@ -1718,7 +1819,13 @@ async function runTenantJourney(browser, portal, api) { await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]'); await clickText(page, '后端预览'); - await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST'); + try { + await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST'); + } catch (error) { + const diagnostics = await formDiagnostics(page, '粘贴题目'); + const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); + throw new Error(`${error.message}\nContent import diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); + } await waitForText(page, '预览第'); await clickTextAndConfirm(page, '执行导入'); await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST'); @@ -1810,10 +1917,16 @@ async function runTenantJourney(browser, portal, api) { await clickText(page, '保存模板'); await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT'); await clickText(page, '新建成员'); - await fillByPlaceholder(page, '姓名', 'H5 烟测成员', 1); - await fillByPlaceholder(page, '手机号', '13911112222', 1); + await fillByPlaceholderInSection(page, '新建成员权限', '姓名', 'H5 烟测成员'); + await fillByPlaceholderInSection(page, '新建成员权限', '手机号', '13911112222'); await clickText(page, '保存成员'); - await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT'); + try { + await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT'); + } catch (error) { + const diagnostics = await formDiagnostics(page, '姓名'); + const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); + throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); + } checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' }); return checks; } finally { @@ -1853,13 +1966,19 @@ async function runPlatformJourney(browser, portal, api) { await waitForText(page, '账务中心'); checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) }); - await ensureInputValue(page, 'tenantId', ids.tenant, 0); - await ensureInputValue(page, 'starter_yearly', 'starter_yearly'); + await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 0); + await fillByPlaceholderInSection(page, '订阅与账单操作', 'starter_yearly', 'starter_yearly'); await clickTextAndConfirm(page, '开通订阅'); await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST'); - await ensureInputValue(page, 'tenantId', ids.tenant, 1); + await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 1); await clickTextAndConfirm(page, '生成订阅账单'); - await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST'); + try { + await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST'); + } catch (error) { + const diagnostics = await formDiagnostics(page, 'tenantId'); + const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); + throw new Error(`${error.message}\nBilling diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); + } await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 0); await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice); await fillByPlaceholderInSection(page, '收款与用量', '元', '1999'); diff --git a/scripts/taro-route-contract-test.js b/scripts/taro-route-contract-test.js index 202b1605..aa6d117d 100644 --- a/scripts/taro-route-contract-test.js +++ b/scripts/taro-route-contract-test.js @@ -7,6 +7,7 @@ const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src'); const pagesRoot = path.join(taroSrc, 'pages'); const appConfigPath = path.join(taroSrc, 'app.config.ts'); const bootstrapPath = path.join(pagesRoot, 'bootstrap', 'index.tsx'); +const routeGuardPath = path.join(taroSrc, 'services', 'routeGuard.ts'); const h5StaticSmokePath = path.join(repoRoot, 'scripts', 'taro-h5-static-smoke.js'); const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md'); @@ -34,13 +35,23 @@ function walkIndexPages(dir) { function parseAppRoutes() { const text = readText(appConfigPath); - const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m); - assert.ok(match, 'apps/taro/src/app.config.ts must define pages: [...]'); + const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m); + assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]'); return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)] .map(item => item[1].trim()) .filter(route => route.startsWith('pages/')); } +function parsePortalLandingRoutes() { + const text = readText(appConfigPath); + const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record\s*=\s*\{([\s\S]*?)\}/m); + assert.ok(match, 'apps/taro/src/app.config.ts must define portalLandingRoutes'); + return Object.fromEntries( + [...match[1].matchAll(/['"`]?([\w-]+)['"`]?\s*:\s*['"`]([^'"`]+)['"`]/g)] + .map(item => [item[1], item[2]]), + ); +} + function routeFromIndexFile(filePath) { return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index'); } @@ -59,9 +70,9 @@ const appRoutes = parseAppRoutes(); const appRouteSet = new Set(appRoutes); const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile); const actualRouteSet = new Set(actualRoutes); +const portalLandingRouteMap = parsePortalLandingRoutes(); assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes'); -assert.equal(appRoutes[0], 'pages/bootstrap/index', 'The first Taro page must be the bootstrap page for tenant/runtime config resolution'); const missingFiles = appRoutes.filter(route => !actualRouteSet.has(route)); const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route)); @@ -73,22 +84,37 @@ const expectedPortalLandingRoutes = [ 'pages/student/home/index', 'pages/tenant-admin/workbench/index', ]; -const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath)); -const bootstrapLandingRoutes = bootstrapPageRoutes.filter(route => route !== 'pages/bootstrap/index'); assert.deepEqual( - uniqueSorted(bootstrapLandingRoutes), + uniqueSorted(Object.values(portalLandingRouteMap)), expectedPortalLandingRoutes, - 'Bootstrap landing routes must stay explicit for the three H5 portals', + 'Portal-specific H5 builds must put each portal landing route first', ); -for (const route of bootstrapPageRoutes) { - assert.ok(appRouteSet.has(route), `Bootstrap route is not registered in app.config.ts: ${route}`); +for (const route of Object.values(portalLandingRouteMap)) { + assert.ok(appRouteSet.has(route), `Portal landing route is not registered in app.config.ts: ${route}`); +} + +const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath)); +const routeGuardPageRoutes = parseLiteralPagePaths(readText(routeGuardPath)); +const publicRoutes = new Set([ + 'pages/bootstrap/index', + 'pages/student/login/index', + 'pages/student/region/index', +]); +const routeGuardLandingRoutes = routeGuardPageRoutes.filter(route => !publicRoutes.has(route)); +assert.deepEqual( + uniqueSorted(routeGuardLandingRoutes), + expectedPortalLandingRoutes, + 'Route guard landing routes must stay explicit for the three H5 portals', +); +for (const route of [...bootstrapPageRoutes, ...routeGuardPageRoutes]) { + assert.ok(appRouteSet.has(route), `Referenced route is not registered in app.config.ts: ${route}`); } const staticSmokeLandingRoutes = parseLiteralPagePaths(readText(h5StaticSmokePath)); assert.deepEqual( uniqueSorted(staticSmokeLandingRoutes), - uniqueSorted(bootstrapLandingRoutes), - 'H5 static smoke landing routes must match bootstrap landing routes', + uniqueSorted(routeGuardLandingRoutes), + 'H5 static smoke landing routes must match route guard landing routes', ); for (const route of staticSmokeLandingRoutes) { assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`);