forked from wangziqi/gongxue-base
fix: align Taro H5 with legacy question bank UI
This commit is contained in:
@@ -1,36 +1,56 @@
|
|||||||
|
declare const process: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
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({
|
export default defineAppConfig({
|
||||||
pages: [
|
pages: pagesForPortal(process.env.TARO_APP_PORTAL),
|
||||||
'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',
|
|
||||||
],
|
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
navigationBarBackgroundColor: '#0f172a',
|
navigationBarBackgroundColor: '#0f172a',
|
||||||
|
|||||||
@@ -89,11 +89,20 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.route-guard-page {
|
.route-guard-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
background: var(--tiku-page);
|
background: var(--tiku-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-guard-hidden {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.route-guard-shell {
|
.route-guard-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
|
|||||||
@@ -60,6 +60,38 @@ function shouldUseAdminShell(path: string) {
|
|||||||
return false;
|
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 (
|
||||||
|
<View className='route-guard-page'>
|
||||||
|
<View className='route-guard-shell'>
|
||||||
|
<View className='route-guard-panel'>
|
||||||
|
<Text className='route-guard-kicker'>{copy.kicker}</Text>
|
||||||
|
<Text className='route-guard-title'>{copy.title}</Text>
|
||||||
|
<Text className='route-guard-subtitle'>{copy.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App({ children }: PropsWithChildren) {
|
export default function App({ children }: PropsWithChildren) {
|
||||||
const [routeReady, setRouteReady] = useState(false);
|
const [routeReady, setRouteReady] = useState(false);
|
||||||
const [path, setPath] = useState(() => currentPagePath());
|
const [path, setPath] = useState(() => currentPagePath());
|
||||||
@@ -80,55 +112,18 @@ export default function App({ children }: PropsWithChildren) {
|
|||||||
return installH5RouteListener(verifyCurrentRoute);
|
return installH5RouteListener(verifyCurrentRoute);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!routeReady && appEnv.portal === 'tenant-admin') {
|
const content = shouldUseStudentShell(path)
|
||||||
return (
|
? <StudentLegacyShell>{children}</StudentLegacyShell>
|
||||||
<View className='route-guard-page'>
|
: shouldUseAdminShell(path)
|
||||||
<View className='route-guard-shell'>
|
? <AdminLegacyShell>{children}</AdminLegacyShell>
|
||||||
<View className='route-guard-panel'>
|
: children;
|
||||||
<Text className='route-guard-kicker'>Tenant Admin</Text>
|
|
||||||
<Text className='route-guard-title'>正在校验后台权限</Text>
|
return (
|
||||||
<Text className='route-guard-subtitle'>请先完成登录,系统会确认当前账号是否拥有租户后台权限。</Text>
|
<>
|
||||||
</View>
|
<View className={routeReady ? '' : 'route-guard-hidden'}>
|
||||||
</View>
|
{content}
|
||||||
</View>
|
</View>
|
||||||
);
|
{!routeReady ? <RouteGuardOverlay /> : null}
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
if (!routeReady && appEnv.portal === 'platform-admin') {
|
|
||||||
return (
|
|
||||||
<View className='route-guard-page'>
|
|
||||||
<View className='route-guard-shell'>
|
|
||||||
<View className='route-guard-panel'>
|
|
||||||
<Text className='route-guard-kicker'>Platform Admin</Text>
|
|
||||||
<Text className='route-guard-title'>正在校验平台权限</Text>
|
|
||||||
<Text className='route-guard-subtitle'>请先完成登录,系统会确认当前账号是否拥有平台管理员权限。</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!routeReady) {
|
|
||||||
return (
|
|
||||||
<View className='route-guard-page'>
|
|
||||||
<View className='route-guard-shell'>
|
|
||||||
<View className='route-guard-panel'>
|
|
||||||
<Text className='route-guard-kicker'>Student</Text>
|
|
||||||
<Text className='route-guard-title'>正在校验登录状态</Text>
|
|
||||||
<Text className='route-guard-subtitle'>请先完成登录,系统会带你回到刚才打开的页面。</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUseStudentShell(path)) {
|
|
||||||
return <StudentLegacyShell>{children}</StudentLegacyShell>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUseAdminShell(path)) {
|
|
||||||
return <AdminLegacyShell>{children}</AdminLegacyShell>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,3 +476,216 @@
|
|||||||
padding: 14px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,39 +15,28 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var path = window.location.pathname || '';
|
var path = window.location.pathname || '';
|
||||||
var portal = '<%= process.env.TARO_APP_PORTAL || "student" %>';
|
var portal = '<%= process.env.TARO_APP_PORTAL || "student" %>';
|
||||||
var redirect = path + (window.location.search || '');
|
|
||||||
var isRootEntry = !path || path === '/' || /\/index\.html$/.test(path);
|
var isRootEntry = !path || path === '/' || /\/index\.html$/.test(path);
|
||||||
var publicStudentPaths = {
|
var landing = portal === 'tenant-admin'
|
||||||
'/pages/student/login/index': true,
|
? '/pages/tenant-admin/workbench/index'
|
||||||
'/pages/student/region/index': true
|
: portal === 'platform-admin'
|
||||||
};
|
? '/pages/platform-admin/workbench/index'
|
||||||
|
: '/pages/student/home/index';
|
||||||
if (isRootEntry) {
|
if (isRootEntry) {
|
||||||
window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent('/'));
|
window.location.replace(landing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (portal === 'student' && path.indexOf('/pages/') === 0 && path.indexOf('/pages/student/') !== 0 && path.indexOf('/pages/bootstrap/') !== 0) {
|
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;
|
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) {
|
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;
|
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) {
|
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;
|
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));
|
|
||||||
}
|
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { Button, Text, View } from '@tarojs/components';
|
import { Button, Text, View } from '@tarojs/components';
|
||||||
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
|
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
|
||||||
import { resolveTenant } from '@/services/api';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
function hostFromRuntime() {
|
function hostFromRuntime() {
|
||||||
@@ -12,12 +12,12 @@ function hostFromRuntime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BootstrapPage() {
|
export default function BootstrapPage() {
|
||||||
const router = useRouter();
|
|
||||||
const [status, setStatus] = useState('正在解析租户');
|
const [status, setStatus] = useState('正在解析租户');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
assertFrontendSecretsAreAbsent();
|
||||||
ensureRuntimeConfigLoaded()
|
ensureRuntimeConfigLoaded()
|
||||||
.then(() => resolveTenant({ host: hostFromRuntime() }))
|
.then(() => resolveTenant({ host: hostFromRuntime() }))
|
||||||
@@ -30,6 +30,10 @@ export default function BootstrapPage() {
|
|||||||
.then(authPayload => {
|
.then(authPayload => {
|
||||||
if (!authPayload) return;
|
if (!authPayload) return;
|
||||||
setStatus('租户解析完成');
|
setStatus('租户解析完成');
|
||||||
|
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||||
|
window.location.replace(redirectPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Taro.redirectTo({ url: redirectPath });
|
Taro.redirectTo({ url: redirectPath });
|
||||||
})
|
})
|
||||||
.catch((nextError: Error) => {
|
.catch((nextError: Error) => {
|
||||||
|
|||||||
@@ -924,3 +924,166 @@
|
|||||||
padding: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function PlatformWorkbenchPage() {
|
|||||||
<Text className='platform-section-title'>后台模块</Text>
|
<Text className='platform-section-title'>后台模块</Text>
|
||||||
<View className='platform-module-grid'>
|
<View className='platform-module-grid'>
|
||||||
{modules.map(item => (
|
{modules.map(item => (
|
||||||
<View className='platform-module-card' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
<View className='platform-module-card' key={item.path} role='button' onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||||
<Text className='platform-module-label'>Console</Text>
|
<Text className='platform-module-label'>Console</Text>
|
||||||
<Text className='platform-row-main'>{item.name}</Text>
|
<Text className='platform-row-main'>{item.name}</Text>
|
||||||
<Text className='platform-row-meta'>{item.meta}</Text>
|
<Text className='platform-row-meta'>{item.meta}</Text>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function StudentHomePage() {
|
|||||||
|
|
||||||
<View className='hero-band'>
|
<View className='hero-band'>
|
||||||
<View className='hero-content'>
|
<View className='hero-content'>
|
||||||
<Text className='hero-kicker'>今天也是元气满满的一天</Text>
|
<Text className='hero-kicker'>今日学习</Text>
|
||||||
<Text className='hero-title'>继续你的备考之旅</Text>
|
<Text className='hero-title'>继续你的备考之旅</Text>
|
||||||
<Text className='hero-copy'>已经连续学习的每一天,都会让你离目标院校更近一步。</Text>
|
<Text className='hero-copy'>已经连续学习的每一天,都会让你离目标院校更近一步。</Text>
|
||||||
<View className='hero-actions'>
|
<View className='hero-actions'>
|
||||||
@@ -113,16 +113,16 @@ export default function StudentHomePage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='section'>
|
<View className='section'>
|
||||||
<Text className='section-title'>学习入口</Text>
|
<Text className='section-title'>功能导航</Text>
|
||||||
<View className='entry-grid'>
|
<View className='entry-grid'>
|
||||||
{entryNames.length ? entryNames.map(item => (
|
{entryNames.length ? entryNames.map(item => (
|
||||||
<View className='entry-tile tone-blue' key={item.id} onClick={() => Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}>
|
<View className='entry-tile tone-blue' role='button' key={item.id} onClick={() => Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}>
|
||||||
<Text className='entry-label'>已配置入口</Text>
|
<Text className='entry-label'>已配置入口</Text>
|
||||||
<Text className='entry-name'>{item.name}</Text>
|
<Text className='entry-name'>{item.name}</Text>
|
||||||
<Text className='entry-type'>{item.entryType || 'content'}</Text>
|
<Text className='entry-type'>{item.entryType || 'content'}</Text>
|
||||||
</View>
|
</View>
|
||||||
)) : quickActions.map(item => (
|
)) : quickActions.map(item => (
|
||||||
<View className={`entry-tile tone-${item.tone}`} key={item.name} onClick={() => Taro.navigateTo({ url: item.path })}>
|
<View className={`entry-tile tone-${item.tone}`} role='button' key={item.name} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||||
<Text className='entry-label'>{item.meta}</Text>
|
<Text className='entry-label'>{item.meta}</Text>
|
||||||
<Text className='entry-name'>{item.name}</Text>
|
<Text className='entry-name'>{item.name}</Text>
|
||||||
<Text className='entry-type'>点击进入</Text>
|
<Text className='entry-type'>点击进入</Text>
|
||||||
@@ -135,7 +135,7 @@ export default function StudentHomePage() {
|
|||||||
<Text className='section-title'>全部功能</Text>
|
<Text className='section-title'>全部功能</Text>
|
||||||
<View className='quick-list'>
|
<View className='quick-list'>
|
||||||
{quickActions.map(item => (
|
{quickActions.map(item => (
|
||||||
<View className='quick-row' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
<View className='quick-row' role='button' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||||
<View>
|
<View>
|
||||||
<Text className='quick-name'>{item.name}</Text>
|
<Text className='quick-name'>{item.name}</Text>
|
||||||
<Text className='quick-meta'>{item.meta}</Text>
|
<Text className='quick-meta'>{item.meta}</Text>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from '@tarojs/taro';
|
|
||||||
import { Button, Input, Text, View } from '@tarojs/components';
|
import { Button, Input, Text, View } from '@tarojs/components';
|
||||||
import { appEnv, ensureRuntimeConfigLoaded, type Portal } from '@/env';
|
import { appEnv, ensureRuntimeConfigLoaded, type Portal } from '@/env';
|
||||||
import { getTenantContext } from '@/services/api';
|
import { getTenantContext } from '@/services/api';
|
||||||
import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth';
|
import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth';
|
||||||
import { landingPath, redirectAfterLogin } from '@/services/routeGuard';
|
import { currentRouteParams, landingPath, redirectAfterLogin } from '@/services/routeGuard';
|
||||||
import '../student.css';
|
import '../student.css';
|
||||||
|
|
||||||
export default function StudentLoginPage() {
|
export default function StudentLoginPage() {
|
||||||
const router = useRouter();
|
|
||||||
const tenant = getTenantContext();
|
const tenant = getTenantContext();
|
||||||
|
const params = currentRouteParams();
|
||||||
const [portal, setPortal] = useState<Portal>(appEnv.portal);
|
const [portal, setPortal] = useState<Portal>(appEnv.portal);
|
||||||
const [runtimeReady, setRuntimeReady] = useState(false);
|
const [runtimeReady, setRuntimeReady] = useState(false);
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
@@ -26,12 +25,12 @@ export default function StudentLoginPage() {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setPortal(appEnv.portal);
|
setPortal(appEnv.portal);
|
||||||
setRuntimeReady(true);
|
setRuntimeReady(true);
|
||||||
if (router.params?.reason) {
|
if (params.reason) {
|
||||||
setReason(decodeURIComponent(router.params.reason));
|
setReason(decodeURIComponent(String(params.reason)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadCurrentUser()
|
loadCurrentUser()
|
||||||
.then(() => redirectAfterLogin(router.params?.redirect || landingPath()))
|
.then(() => redirectAfterLogin(params.redirect || landingPath()))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -91,7 +90,7 @@ export default function StudentLoginPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await verifySmsCode(phone, code || debugCode);
|
await verifySmsCode(phone, code || debugCode);
|
||||||
redirectAfterLogin(router.params?.redirect || landingPath());
|
redirectAfterLogin(params.redirect || landingPath());
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : '登录失败');
|
setError(nextError instanceof Error ? nextError.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1033,3 +1033,202 @@
|
|||||||
grid-column: span 2;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function TenantWorkbenchPage() {
|
|||||||
<Text className='admin-section-title'>后台模块</Text>
|
<Text className='admin-section-title'>后台模块</Text>
|
||||||
<View className='admin-module-grid'>
|
<View className='admin-module-grid'>
|
||||||
{modules.map(item => (
|
{modules.map(item => (
|
||||||
<View className={`admin-module-card module-${item.key}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
<View className={`admin-module-card module-${item.key}`} key={item.path} role='button' onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||||
<Text className='admin-module-label'>Module</Text>
|
<Text className='admin-module-label'>Module</Text>
|
||||||
<Text className='admin-row-main'>{item.name}</Text>
|
<Text className='admin-row-main'>{item.name}</Text>
|
||||||
<Text className='admin-row-meta'>{item.meta}</Text>
|
<Text className='admin-row-meta'>{item.meta}</Text>
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ export function currentPagePath() {
|
|||||||
return normalizedPath;
|
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) {
|
function isPublicPath(path: string) {
|
||||||
return path === '/pages/bootstrap/index' || path === '/pages/student/login/index';
|
return path === '/pages/bootstrap/index' || path === '/pages/student/login/index';
|
||||||
}
|
}
|
||||||
@@ -121,7 +130,7 @@ export async function guardCurrentRoute() {
|
|||||||
const path = currentPagePath();
|
const path = currentPagePath();
|
||||||
if (!path || !shouldGuardPath(path)) return true;
|
if (!path || !shouldGuardPath(path)) return true;
|
||||||
if (!isPathAllowedForPortal(path)) {
|
if (!isPathAllowedForPortal(path)) {
|
||||||
redirectToAuthUrl(`/pages/bootstrap/index?redirect=${encodeURIComponent(landingPath())}`);
|
redirectToAuthUrl(landingPath());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (pendingGuardPath === path) return false;
|
if (pendingGuardPath === path) return false;
|
||||||
@@ -144,5 +153,10 @@ export async function guardCurrentRoute() {
|
|||||||
|
|
||||||
export function redirectAfterLogin(rawRedirect?: string) {
|
export function redirectAfterLogin(rawRedirect?: string) {
|
||||||
const redirectPath = rawRedirect ? decodeURIComponent(rawRedirect) : landingPath();
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,22 @@ const platformPermissionCatalog = [
|
|||||||
function mockApiPayload(pathname, method, query, body) {
|
function mockApiPayload(pathname, method, query, body) {
|
||||||
if (pathname === '/health') return { ok: true };
|
if (pathname === '/health') return { ok: true };
|
||||||
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
|
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') {
|
if (pathname === '/api/catalog/content-entries') {
|
||||||
return {
|
return {
|
||||||
@@ -1082,7 +1097,8 @@ class CdpPage {
|
|||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
if (result.exceptionDetails) {
|
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;
|
return result.result?.value;
|
||||||
}
|
}
|
||||||
@@ -1276,7 +1292,7 @@ async function clickText(page, text) {
|
|||||||
`);
|
`);
|
||||||
await page.acceptDialogs();
|
await page.acceptDialogs();
|
||||||
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
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 delay(350);
|
||||||
await page.acceptDialogs();
|
await page.acceptDialogs();
|
||||||
return result;
|
return result;
|
||||||
@@ -1344,7 +1360,7 @@ async function clickTextInSection(page, sectionTitle, text) {
|
|||||||
`);
|
`);
|
||||||
await page.acceptDialogs();
|
await page.acceptDialogs();
|
||||||
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
|
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 delay(350);
|
||||||
await page.acceptDialogs();
|
await page.acceptDialogs();
|
||||||
return result;
|
return result;
|
||||||
@@ -1431,7 +1447,7 @@ async function clickVisibleTextCandidate(page, texts) {
|
|||||||
})()
|
})()
|
||||||
`);
|
`);
|
||||||
if (result?.ok) {
|
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 delay(250);
|
||||||
await page.acceptDialogs();
|
await page.acceptDialogs();
|
||||||
}
|
}
|
||||||
@@ -1524,16 +1540,45 @@ async function fillByPlaceholder(page, placeholder, value, occurrence = 0) {
|
|||||||
const expected = ${JSON.stringify(placeholder)};
|
const expected = ${JSON.stringify(placeholder)};
|
||||||
const nextValue = ${JSON.stringify(value)};
|
const nextValue = ${JSON.stringify(value)};
|
||||||
const occurrence = ${Number(occurrence)};
|
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));
|
.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];
|
const target = elements[occurrence];
|
||||||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
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);
|
if (setter) setter.call(target, nextValue);
|
||||||
else target.value = nextValue;
|
else target.value = nextValue;
|
||||||
for (const type of ['input', 'change']) {
|
target.setAttribute('value', nextValue);
|
||||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
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 };
|
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));
|
.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];
|
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) };
|
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));
|
.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];
|
const target = elements[occurrence];
|
||||||
if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) };
|
if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) };
|
||||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
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);
|
if (setter) setter.call(target, nextValue);
|
||||||
else target.value = nextValue;
|
else target.value = nextValue;
|
||||||
for (const type of ['input', 'change']) {
|
target.setAttribute('value', nextValue);
|
||||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
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 };
|
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||||||
})()
|
})()
|
||||||
@@ -1579,6 +1648,38 @@ async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value
|
|||||||
return result;
|
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) {
|
async function ensureInputValue(page, placeholder, value, occurrence = 0) {
|
||||||
const current = await page.evaluate(`
|
const current = await page.evaluate(`
|
||||||
(() => {
|
(() => {
|
||||||
@@ -1718,7 +1819,13 @@ async function runTenantJourney(browser, portal, api) {
|
|||||||
|
|
||||||
await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]');
|
await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]');
|
||||||
await clickText(page, '后端预览');
|
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 waitForText(page, '预览第');
|
||||||
await clickTextAndConfirm(page, '执行导入');
|
await clickTextAndConfirm(page, '执行导入');
|
||||||
await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST');
|
await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST');
|
||||||
@@ -1810,10 +1917,16 @@ async function runTenantJourney(browser, portal, api) {
|
|||||||
await clickText(page, '保存模板');
|
await clickText(page, '保存模板');
|
||||||
await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT');
|
await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT');
|
||||||
await clickText(page, '新建成员');
|
await clickText(page, '新建成员');
|
||||||
await fillByPlaceholder(page, '姓名', 'H5 烟测成员', 1);
|
await fillByPlaceholderInSection(page, '新建成员权限', '姓名', 'H5 烟测成员');
|
||||||
await fillByPlaceholder(page, '手机号', '13911112222', 1);
|
await fillByPlaceholderInSection(page, '新建成员权限', '手机号', '13911112222');
|
||||||
await clickText(page, '保存成员');
|
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 已触发' });
|
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
|
||||||
return checks;
|
return checks;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1853,13 +1966,19 @@ async function runPlatformJourney(browser, portal, api) {
|
|||||||
await waitForText(page, '账务中心');
|
await waitForText(page, '账务中心');
|
||||||
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
|
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
|
||||||
|
|
||||||
await ensureInputValue(page, 'tenantId', ids.tenant, 0);
|
await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 0);
|
||||||
await ensureInputValue(page, 'starter_yearly', 'starter_yearly');
|
await fillByPlaceholderInSection(page, '订阅与账单操作', 'starter_yearly', 'starter_yearly');
|
||||||
await clickTextAndConfirm(page, '开通订阅');
|
await clickTextAndConfirm(page, '开通订阅');
|
||||||
await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST');
|
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 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, '收款与用量', 'tenantId', ids.tenant, 0);
|
||||||
await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice);
|
await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice);
|
||||||
await fillByPlaceholderInSection(page, '收款与用量', '元', '1999');
|
await fillByPlaceholderInSection(page, '收款与用量', '元', '1999');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
|
|||||||
const pagesRoot = path.join(taroSrc, 'pages');
|
const pagesRoot = path.join(taroSrc, 'pages');
|
||||||
const appConfigPath = path.join(taroSrc, 'app.config.ts');
|
const appConfigPath = path.join(taroSrc, 'app.config.ts');
|
||||||
const bootstrapPath = path.join(pagesRoot, 'bootstrap', 'index.tsx');
|
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 h5StaticSmokePath = path.join(repoRoot, 'scripts', 'taro-h5-static-smoke.js');
|
||||||
const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md');
|
const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md');
|
||||||
|
|
||||||
@@ -34,13 +35,23 @@ function walkIndexPages(dir) {
|
|||||||
|
|
||||||
function parseAppRoutes() {
|
function parseAppRoutes() {
|
||||||
const text = readText(appConfigPath);
|
const text = readText(appConfigPath);
|
||||||
const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m);
|
const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m);
|
||||||
assert.ok(match, 'apps/taro/src/app.config.ts must define pages: [...]');
|
assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]');
|
||||||
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
|
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
|
||||||
.map(item => item[1].trim())
|
.map(item => item[1].trim())
|
||||||
.filter(route => route.startsWith('pages/'));
|
.filter(route => route.startsWith('pages/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePortalLandingRoutes() {
|
||||||
|
const text = readText(appConfigPath);
|
||||||
|
const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record<string,\s*string>\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) {
|
function routeFromIndexFile(filePath) {
|
||||||
return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index');
|
return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index');
|
||||||
}
|
}
|
||||||
@@ -59,9 +70,9 @@ const appRoutes = parseAppRoutes();
|
|||||||
const appRouteSet = new Set(appRoutes);
|
const appRouteSet = new Set(appRoutes);
|
||||||
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
|
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
|
||||||
const actualRouteSet = new Set(actualRoutes);
|
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.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 missingFiles = appRoutes.filter(route => !actualRouteSet.has(route));
|
||||||
const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route));
|
const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route));
|
||||||
@@ -73,22 +84,37 @@ const expectedPortalLandingRoutes = [
|
|||||||
'pages/student/home/index',
|
'pages/student/home/index',
|
||||||
'pages/tenant-admin/workbench/index',
|
'pages/tenant-admin/workbench/index',
|
||||||
];
|
];
|
||||||
const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath));
|
|
||||||
const bootstrapLandingRoutes = bootstrapPageRoutes.filter(route => route !== 'pages/bootstrap/index');
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
uniqueSorted(bootstrapLandingRoutes),
|
uniqueSorted(Object.values(portalLandingRouteMap)),
|
||||||
expectedPortalLandingRoutes,
|
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) {
|
for (const route of Object.values(portalLandingRouteMap)) {
|
||||||
assert.ok(appRouteSet.has(route), `Bootstrap route is not registered in app.config.ts: ${route}`);
|
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));
|
const staticSmokeLandingRoutes = parseLiteralPagePaths(readText(h5StaticSmokePath));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
uniqueSorted(staticSmokeLandingRoutes),
|
uniqueSorted(staticSmokeLandingRoutes),
|
||||||
uniqueSorted(bootstrapLandingRoutes),
|
uniqueSorted(routeGuardLandingRoutes),
|
||||||
'H5 static smoke landing routes must match bootstrap landing routes',
|
'H5 static smoke landing routes must match route guard landing routes',
|
||||||
);
|
);
|
||||||
for (const route of staticSmokeLandingRoutes) {
|
for (const route of staticSmokeLandingRoutes) {
|
||||||
assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`);
|
assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user