fix: guard H5 direct routes and refine legacy UI

This commit is contained in:
Codex
2026-07-02 02:31:49 +08:00
parent 60b1cb227c
commit 22a87f541a
7 changed files with 371 additions and 19 deletions

View File

@@ -24,6 +24,14 @@ page {
--tiku-shadow-blue: 0 14px 28px rgba(17, 82, 212, 0.18); --tiku-shadow-blue: 0 14px 28px rgba(17, 82, 212, 0.18);
} }
body {
background:
linear-gradient(to right, rgba(15, 23, 42, 0.035) 1px, transparent 1px),
linear-gradient(to bottom, rgba(15, 23, 42, 0.035) 1px, transparent 1px),
#f4f6f9;
background-size: 40px 40px;
}
view, view,
text, text,
input, input,

View File

@@ -1,11 +1,48 @@
import { PropsWithChildren, useEffect, useState } from 'react'; import { PropsWithChildren, useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components'; import { Text, View } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { appEnv } from '@/env'; import { appEnv, isH5Runtime } from '@/env';
import { currentPagePath, guardCurrentRoute, shouldGuardPath } from '@/services/routeGuard'; import { currentPagePath, guardCurrentRoute, shouldGuardPath } from '@/services/routeGuard';
import './app.css'; import './app.css';
const routeChangeEvent = 'tiku-route-change';
type GuardedHistory = History & {
__tikuRouteGuardPatched?: boolean;
};
function installH5RouteListener(callback: () => void) {
if (!isH5Runtime() || typeof window === 'undefined') return () => undefined;
const guardedHistory = window.history as GuardedHistory;
if (!guardedHistory.__tikuRouteGuardPatched) {
const rawPushState = guardedHistory.pushState.bind(guardedHistory);
const rawReplaceState = guardedHistory.replaceState.bind(guardedHistory);
guardedHistory.pushState = (...args) => {
const result = rawPushState(...args);
window.dispatchEvent(new Event(routeChangeEvent));
return result;
};
guardedHistory.replaceState = (...args) => {
const result = rawReplaceState(...args);
window.dispatchEvent(new Event(routeChangeEvent));
return result;
};
guardedHistory.__tikuRouteGuardPatched = true;
}
const onRouteChange = () => window.setTimeout(callback, 0);
window.addEventListener(routeChangeEvent, onRouteChange);
window.addEventListener('popstate', onRouteChange);
window.addEventListener('hashchange', onRouteChange);
return () => {
window.removeEventListener(routeChangeEvent, onRouteChange);
window.removeEventListener('popstate', onRouteChange);
window.removeEventListener('hashchange', onRouteChange);
};
}
export default function App({ children }: PropsWithChildren) { export default function App({ children }: PropsWithChildren) {
const [routeReady, setRouteReady] = useState(() => !shouldGuardPath(currentPagePath())); const [routeReady, setRouteReady] = useState(() => !shouldGuardPath(currentPagePath()));
@@ -20,12 +57,9 @@ export default function App({ children }: PropsWithChildren) {
useEffect(() => { useEffect(() => {
verifyCurrentRoute(); verifyCurrentRoute();
return installH5RouteListener(verifyCurrentRoute);
}, []); }, []);
useDidShow(() => {
verifyCurrentRoute();
});
if (!routeReady && appEnv.portal === 'tenant-admin') { if (!routeReady && appEnv.portal === 'tenant-admin') {
return ( return (
<View className='route-guard-page'> <View className='route-guard-page'>

View File

@@ -11,6 +11,21 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<title>工学题库</title> <title>工学题库</title>
<script>
(function () {
var path = window.location.pathname || '';
var publicStudentPaths = {
'/pages/student/login/index': true,
'/pages/student/region/index': true,
'/pages/student/catalog/index': true,
'/pages/student/scoreline/index': true
};
if (path.indexOf('/pages/student/') === 0 && !publicStudentPaths[path]) {
var redirect = path + (window.location.search || '');
window.location.replace('/pages/bootstrap/index?redirect=' + encodeURIComponent(redirect));
}
}());
</script>
<script><%= htmlWebpackPlugin.options.script %></script> <script><%= htmlWebpackPlugin.options.script %></script>
</head> </head>
<body> <body>

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro'; import Taro, { useRouter } 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, requireTenantAdmin } from '@/services/routeGuard'; import { landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard';
import './index.css'; import './index.css';
function hostFromRuntime() { function hostFromRuntime() {
@@ -12,23 +12,25 @@ 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());
assertFrontendSecretsAreAbsent(); assertFrontendSecretsAreAbsent();
ensureRuntimeConfigLoaded() ensureRuntimeConfigLoaded()
.then(() => resolveTenant({ host: hostFromRuntime() })) .then(() => resolveTenant({ host: hostFromRuntime() }))
.then(async () => { .then(async () => {
if (appEnv.portal === 'student') return null;
setStatus('正在校验登录状态'); setStatus('正在校验登录状态');
if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(landingPath()); if (appEnv.portal === 'student') return requireSignedIn(redirectPath);
return requireTenantAdmin(landingPath()); if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(redirectPath);
return requireTenantAdmin(redirectPath);
}) })
.then(authPayload => { .then(authPayload => {
if (appEnv.portal !== 'student' && !authPayload) return; if (!authPayload) return;
setStatus('租户解析完成'); setStatus('租户解析完成');
Taro.redirectTo({ url: landingPath() }); Taro.redirectTo({ url: redirectPath });
}) })
.catch((nextError: Error) => { .catch((nextError: Error) => {
setError(nextError.message); setError(nextError.message);

View File

@@ -1,6 +1,72 @@
.student-home-layout {
position: relative;
min-height: 100vh;
}
.student-page { .student-page {
min-height: 100vh; min-height: 100vh;
padding: 28px; padding: 28px;
padding-bottom: 132px;
}
.legacy-student-sidebar {
display: none;
}
.legacy-mobile-dock {
position: fixed;
right: 18px;
bottom: 18px;
left: 18px;
z-index: 30;
display: flex;
justify-content: space-around;
gap: 4px;
padding: 14px 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 28px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18);
}
.legacy-dock-item {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 5px;
color: #9ca3af;
}
.legacy-dock-item.active {
color: var(--tiku-primary);
}
.legacy-dock-dot {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 16px;
background: #f1f5f9;
font-size: 21px;
font-weight: 900;
line-height: 42px;
}
.legacy-dock-item.active .legacy-dock-dot {
background: var(--tiku-primary);
color: #fff;
box-shadow: 0 8px 18px rgba(17, 82, 212, 0.24);
}
.legacy-dock-text {
display: block;
font-size: 18px;
font-weight: 760;
line-height: 1.2;
} }
.topbar { .topbar {
@@ -325,10 +391,183 @@
} }
@media (min-width: 900px) { @media (min-width: 900px) {
.student-home-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 28px;
min-height: 100vh;
padding: 24px;
}
.legacy-student-sidebar {
position: sticky;
top: 24px;
display: flex;
height: calc(100vh - 48px);
flex-direction: column;
padding: 22px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 30px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.08);
}
.legacy-brand-block {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
padding: 6px 4px 24px;
}
.legacy-brand-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 48px;
width: 48px;
height: 48px;
border-radius: 16px;
background: var(--tiku-primary);
box-shadow: 0 10px 20px rgba(17, 82, 212, 0.26);
}
.legacy-brand-mark {
color: #fff;
font-size: 26px;
font-weight: 950;
}
.legacy-brand-copy {
min-width: 0;
}
.legacy-brand-name {
display: block;
color: #111827;
font-size: 19px;
font-weight: 950;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legacy-brand-sub {
display: block;
margin-top: 3px;
color: var(--tiku-primary);
font-size: 14px;
font-weight: 850;
}
.legacy-nav-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.legacy-nav-item {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
padding: 0 14px;
border-radius: 16px;
color: #64748b;
}
.legacy-nav-item.active {
background: #111827;
color: #fff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
}
.legacy-nav-dot {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 10px;
background: #f1f5f9;
color: inherit;
font-size: 16px;
font-weight: 900;
}
.legacy-nav-item.active .legacy-nav-dot {
background: rgba(255, 255, 255, 0.16);
}
.legacy-nav-text {
font-size: 18px;
font-weight: 850;
}
.legacy-sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding-top: 18px;
border-top: 1px solid #f1f5f9;
}
.legacy-mini-avatar {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
border-radius: 999px;
background: linear-gradient(135deg, #1152d4, #60a5fa);
color: #fff;
}
.legacy-mini-avatar-mark {
color: #fff;
font-size: 20px;
font-weight: 900;
}
.legacy-sidebar-user-copy {
min-width: 0;
}
.legacy-sidebar-user-name {
display: block;
color: #334155;
font-size: 16px;
font-weight: 850;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legacy-sidebar-user-tag {
display: inline-flex;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
background: linear-gradient(90deg, #fef3c7, #fde68a);
color: #92400e;
font-size: 13px;
font-weight: 850;
}
.legacy-mobile-dock {
display: none;
}
.student-page { .student-page {
max-width: 980px; max-width: 980px;
margin: 0 auto; margin: 0 auto;
padding: 36px; padding: 36px;
padding-bottom: 48px;
} }
.hero-band { .hero-band {

View File

@@ -40,9 +40,42 @@ export default function StudentHomePage() {
{ name: '消息通知', path: '/pages/student/notifications/index', meta: '通知 / 待办', tone: 'orange' }, { name: '消息通知', path: '/pages/student/notifications/index', meta: '通知 / 待办', tone: 'orange' },
{ name: '个人中心', path: '/pages/student/profile/index', meta: '会员 / 订单', tone: 'slate' }, { name: '个人中心', path: '/pages/student/profile/index', meta: '会员 / 订单', tone: 'slate' },
]; ];
const navItems = [
{ name: '首页', path: '/pages/student/home/index', active: true },
{ name: '背单词', path: '/pages/student/vocabulary/index' },
{ name: '知识手册', path: '/pages/student/handbook/index' },
{ name: '购买', path: '/pages/student/checkout/index' },
{ name: '分数线', path: '/pages/student/scoreline/index' },
{ name: '我的', path: '/pages/student/profile/index' },
];
return ( return (
<View className='student-page'> <View className='student-home-layout'>
<View className='legacy-student-sidebar'>
<View className='legacy-brand-block'>
<View className='legacy-brand-icon'><Text className='legacy-brand-mark'></Text></View>
<View className='legacy-brand-copy'>
<Text className='legacy-brand-name'>{brandName}</Text>
<Text className='legacy-brand-sub'></Text>
</View>
</View>
<View className='legacy-nav-list'>
{navItems.map(item => (
<View className={`legacy-nav-item ${item.active ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<Text className='legacy-nav-dot'>{item.name.slice(0, 1)}</Text>
<Text className='legacy-nav-text'>{item.name}</Text>
</View>
))}
</View>
<View className='legacy-sidebar-user'>
<View className='legacy-mini-avatar'><Text className='legacy-mini-avatar-mark'>{(userName || '同').slice(0, 1)}</Text></View>
<View className='legacy-sidebar-user-copy'>
<Text className='legacy-sidebar-user-name'>{userName || '同学'}</Text>
<Text className='legacy-sidebar-user-tag'>SVIP</Text>
</View>
</View>
</View>
<View className='student-page'>
<View className='topbar'> <View className='topbar'>
<View className='home-user'> <View className='home-user'>
<View className='home-avatar'> <View className='home-avatar'>
@@ -116,6 +149,15 @@ export default function StudentHomePage() {
))} ))}
</View> </View>
</View> </View>
</View>
<View className='legacy-mobile-dock'>
{navItems.slice(0, 5).map(item => (
<View className={`legacy-dock-item ${item.active ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<Text className='legacy-dock-dot'>{item.name.slice(0, 1)}</Text>
<Text className='legacy-dock-text'>{item.name}</Text>
</View>
))}
</View>
</View> </View>
); );
} }

View File

@@ -11,8 +11,11 @@ function hostFromRuntime() {
return ''; return '';
} }
function safeRedirectPath(path: string) { export function safeRedirectPath(path: string) {
return path.startsWith('/pages/') && !path.startsWith('/pages/student/login/') ? path : '/pages/student/home/index'; if (!path.startsWith('/pages/') || path.startsWith('/pages/student/login/') || path.startsWith('/pages/bootstrap/')) return landingPath();
if (appEnv.portal === 'tenant-admin') return path.startsWith('/pages/tenant-admin/') ? path : landingPath();
if (appEnv.portal === 'platform-admin') return path.startsWith('/pages/platform-admin/') ? path : landingPath();
return path.startsWith('/pages/student/') ? path : landingPath();
} }
function loginUrl(redirectPath: string) { function loginUrl(redirectPath: string) {
@@ -23,10 +26,19 @@ function forbiddenUrl(reason: string, redirectPath: string) {
return `/pages/student/login/index?reason=${encodeURIComponent(reason)}&redirect=${encodeURIComponent(safeRedirectPath(redirectPath))}`; return `/pages/student/login/index?reason=${encodeURIComponent(reason)}&redirect=${encodeURIComponent(safeRedirectPath(redirectPath))}`;
} }
function redirectToAuthUrl(url: string) {
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.replace(url);
return;
}
Taro.redirectTo({ url });
}
export function currentPagePath() { export function currentPagePath() {
if (isH5Runtime() && typeof window !== 'undefined') { if (isH5Runtime() && typeof window !== 'undefined') {
const pathname = window.location.pathname || ''; const pathname = window.location.pathname || '';
if (pathname.startsWith('/pages/')) return pathname; if (pathname.startsWith('/pages/')) return pathname;
if (!pathname || pathname === '/' || pathname.endsWith('/index.html')) return landingPath();
} }
const instance = Taro.getCurrentInstance(); const instance = Taro.getCurrentInstance();
const path = instance.router?.path || ''; const path = instance.router?.path || '';
@@ -67,7 +79,7 @@ export async function requireSignedIn(redirectPath: string): Promise<ApiEnvelope
try { try {
return await loadCurrentUser(); return await loadCurrentUser();
} catch { } catch {
Taro.redirectTo({ url: loginUrl(redirectPath) }); redirectToAuthUrl(loginUrl(redirectPath));
return null; return null;
} }
} }
@@ -78,7 +90,7 @@ export async function requirePlatformAdmin(redirectPath: string) {
const user = payload.user || payload.item; const user = payload.user || payload.item;
const roles = user?.roles || []; const roles = user?.roles || [];
if (user?.primaryRole === 'platform_admin' || roles.includes('platform_admin')) return payload; if (user?.primaryRole === 'platform_admin' || roles.includes('platform_admin')) return payload;
Taro.redirectTo({ url: forbiddenUrl('当前账号不是平台管理员', redirectPath) }); redirectToAuthUrl(forbiddenUrl('当前账号不是平台管理员', redirectPath));
return null; return null;
} }
@@ -91,7 +103,7 @@ export async function requireTenantAdmin(redirectPath: string) {
if (user?.primaryRole === 'platform_admin') return payload; if (user?.primaryRole === 'platform_admin') return payload;
if (user?.primaryRole && allowedRoles.has(user.primaryRole)) return payload; if (user?.primaryRole && allowedRoles.has(user.primaryRole)) return payload;
if (roles.some(role => allowedRoles.has(role) || role === 'platform_admin')) return payload; if (roles.some(role => allowedRoles.has(role) || role === 'platform_admin')) return payload;
Taro.redirectTo({ url: forbiddenUrl('当前账号没有租户后台权限', redirectPath) }); redirectToAuthUrl(forbiddenUrl('当前账号没有租户后台权限', redirectPath));
return null; return null;
} }