fix: guard admin h5 entry pages

This commit is contained in:
Codex
2026-07-02 01:41:00 +08:00
parent 7d629843b7
commit d8e3db79d2
8 changed files with 283 additions and 16 deletions

View File

@@ -3,7 +3,7 @@ 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, requireSignedIn } from '@/services/routeGuard'; import { landingPath, requirePlatformAdmin, requireTenantAdmin } from '@/services/routeGuard';
import './index.css'; import './index.css';
function hostFromRuntime() { function hostFromRuntime() {
@@ -22,7 +22,8 @@ export default function BootstrapPage() {
.then(async () => { .then(async () => {
if (appEnv.portal === 'student') return null; if (appEnv.portal === 'student') return null;
setStatus('正在校验登录状态'); setStatus('正在校验登录状态');
return requireSignedIn(landingPath()); if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(landingPath());
return requireTenantAdmin(landingPath());
}) })
.then(authPayload => { .then(authPayload => {
if (appEnv.portal !== 'student' && !authPayload) return; if (appEnv.portal !== 'student' && !authPayload) return;

View File

@@ -172,6 +172,62 @@
box-shadow: 0 5px 16px rgba(15, 23, 42, 0.04); box-shadow: 0 5px 16px rgba(15, 23, 42, 0.04);
} }
.platform-module-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.platform-module-card {
position: relative;
overflow: hidden;
min-height: 170px;
padding: 22px;
border-radius: 28px;
background: linear-gradient(135deg, #1152d4, #38bdf8);
box-shadow: 0 12px 24px rgba(17, 82, 212, 0.14);
}
.platform-module-card:nth-child(2) { background: linear-gradient(135deg, #10b981, #2dd4bf); }
.platform-module-card:nth-child(3) { background: linear-gradient(135deg, #7c3aed, #a78bfa); }
.platform-module-card:nth-child(4) { background: linear-gradient(135deg, #f97316, #fbbf24); }
.platform-module-card::after {
content: "";
position: absolute;
right: -28px;
bottom: -44px;
width: 130px;
height: 130px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.platform-module-card .platform-row-main,
.platform-module-card .platform-row-meta,
.platform-module-label {
position: relative;
z-index: 1;
}
.platform-module-card .platform-row-main {
margin-top: 30px;
color: #fff;
font-size: 30px;
font-weight: 950;
}
.platform-module-card .platform-row-meta {
color: rgba(255, 255, 255, 0.82);
}
.platform-module-label {
display: block;
color: rgba(255, 255, 255, 0.82);
font-size: 20px;
font-weight: 850;
}
.platform-row-main { .platform-row-main {
display: block; display: block;
color: var(--tiku-text); color: var(--tiku-text);

View File

@@ -59,10 +59,12 @@ export default function PlatformWorkbenchPage() {
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]); const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(''); const [busy, setBusy] = useState('');
const [authorized, setAuthorized] = useState(false);
useEffect(() => { useEffect(() => {
requirePlatformAdmin('/pages/platform-admin/workbench/index').then(payload => { requirePlatformAdmin('/pages/platform-admin/workbench/index').then(payload => {
if (!payload) return; if (!payload) return;
setAuthorized(true);
return Promise.all([ return Promise.all([
loadPlatformOverview().catch(() => ({ item: null })), loadPlatformOverview().catch(() => ({ item: null })),
loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })),
@@ -100,6 +102,20 @@ export default function PlatformWorkbenchPage() {
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' }, { name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
]; ];
if (!authorized) {
return (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Platform Admin</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'></Text>
</View>
</View>
</View>
);
}
async function exportAuditLogs() { async function exportAuditLogs() {
setBusy('audit-export'); setBusy('audit-export');
setError(''); setError('');
@@ -154,9 +170,10 @@ export default function PlatformWorkbenchPage() {
<View className='platform-section'> <View className='platform-section'>
<Text className='platform-section-title'></Text> <Text className='platform-section-title'></Text>
<View className='platform-list'> <View className='platform-module-grid'>
{modules.map(item => ( {modules.map(item => (
<View className='platform-row' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}> <View className='platform-module-card' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<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>
</View> </View>

View File

@@ -14,10 +14,15 @@ export default function StudentLoginPage() {
const [debugCode, setDebugCode] = useState(''); const [debugCode, setDebugCode] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [reason, setReason] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
useEffect(() => { useEffect(() => {
if (router.params?.reason) {
setReason(decodeURIComponent(router.params.reason));
return;
}
loadCurrentUser() loadCurrentUser()
.then(() => redirectAfterLogin(router.params?.redirect || landingPath())) .then(() => redirectAfterLogin(router.params?.redirect || landingPath()))
.catch(() => undefined); .catch(() => undefined);
@@ -53,16 +58,20 @@ export default function StudentLoginPage() {
} }
return ( return (
<View className='student-page'> <View className='student-page login-page'>
<View className='student-topbar'> <View className='login-hero'>
<View className='student-title-block'> <View className='login-avatar'>
<Text className='student-kicker'>{tenant?.branding.shortName || tenant?.branding.brandName || '题库系统'}</Text> <Text className='login-avatar-mark'>{(tenant?.branding.shortName || tenant?.branding.brandName || '题').slice(0, 1)}</Text>
<Text className='student-title'></Text>
<Text className='student-subtitle'></Text>
</View> </View>
<Text className='login-kicker'>{tenant?.branding.shortName || tenant?.branding.brandName || '题库系统'}</Text>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View> </View>
<View className='list-stack'> <View className='login-card list-stack'>
<Text className='row-main'></Text>
<Text className='row-meta'></Text>
{reason ? <Text className='error-text'>{reason}</Text> : null}
<Input className='input' type='number' maxlength={11} placeholder='请输入手机号' value={phone} onInput={event => setPhone(String(event.detail.value || ''))} /> <Input className='input' type='number' maxlength={11} placeholder='请输入手机号' value={phone} onInput={event => setPhone(String(event.detail.value || ''))} />
<View className='toolbar'> <View className='toolbar'>
<Input className='input' type='number' maxlength={6} placeholder='验证码' value={code} onInput={event => setCode(String(event.detail.value || ''))} /> <Input className='input' type='number' maxlength={6} placeholder='验证码' value={code} onInput={event => setCode(String(event.detail.value || ''))} />

View File

@@ -4,6 +4,92 @@
background: var(--tiku-page); background: var(--tiku-page);
} }
.login-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
gap: 24px;
}
.login-hero {
position: relative;
overflow: hidden;
padding: 34px 30px;
border-radius: 30px;
background: linear-gradient(135deg, #1152d4 0%, #2563eb 58%, #60a5fa 100%);
box-shadow: var(--tiku-shadow-blue);
}
.login-hero::after {
content: "";
position: absolute;
right: -52px;
bottom: -72px;
width: 220px;
height: 220px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.login-avatar,
.login-kicker,
.login-title,
.login-subtitle {
position: relative;
z-index: 1;
}
.login-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 82px;
height: 82px;
border: 4px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
background: #fff;
}
.login-avatar-mark {
color: var(--tiku-primary);
font-size: 36px;
font-weight: 950;
}
.login-kicker {
display: block;
margin-top: 24px;
color: rgba(255, 255, 255, 0.82);
font-size: 23px;
font-weight: 850;
}
.login-title {
display: block;
margin-top: 10px;
color: #fff;
font-size: 48px;
font-weight: 950;
line-height: 1.12;
}
.login-subtitle {
display: block;
margin-top: 12px;
color: rgba(255, 255, 255, 0.84);
font-size: 25px;
line-height: 1.45;
}
.login-card {
padding: 28px;
border: 1px solid var(--tiku-border);
border-radius: 28px;
background: #fff;
box-shadow: var(--tiku-shadow);
}
.student-topbar { .student-topbar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -211,6 +211,65 @@
background: var(--tiku-primary-soft); background: var(--tiku-primary-soft);
} }
.admin-module-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-module-card {
position: relative;
overflow: hidden;
min-height: 170px;
padding: 22px;
border: 0;
border-radius: 28px;
background: linear-gradient(135deg, #1152d4, #38bdf8);
box-shadow: 0 12px 24px rgba(17, 82, 212, 0.14);
}
.admin-module-card::after {
content: "";
position: absolute;
right: -28px;
bottom: -44px;
width: 130px;
height: 130px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.admin-module-card.module-students { background: linear-gradient(135deg, #10b981, #2dd4bf); }
.admin-module-card.module-content { background: linear-gradient(135deg, #7c3aed, #a78bfa); }
.admin-module-card.module-marketing { background: linear-gradient(135deg, #f97316, #fbbf24); }
.admin-module-card.module-commerce { background: linear-gradient(135deg, #ec4899, #f472b6); }
.admin-module-card.module-settings { background: linear-gradient(135deg, #334155, #64748b); }
.admin-module-card .admin-row-main,
.admin-module-card .admin-row-meta,
.admin-module-label {
position: relative;
z-index: 1;
}
.admin-module-card .admin-row-main {
margin-top: 30px;
color: #fff;
font-size: 30px;
font-weight: 950;
}
.admin-module-card .admin-row-meta {
color: rgba(255, 255, 255, 0.82);
}
.admin-module-label {
display: block;
color: rgba(255, 255, 255, 0.82);
font-size: 20px;
font-weight: 850;
}
.admin-row-main { .admin-row-main {
display: block; display: block;
color: var(--tiku-text); color: var(--tiku-text);

View File

@@ -10,7 +10,7 @@ import {
type TenantOverview, type TenantOverview,
type TenantPermissionsPayload, type TenantPermissionsPayload,
} from '@/services/tenantAdmin'; } from '@/services/tenantAdmin';
import { requireSignedIn } from '@/services/routeGuard'; import { requireTenantAdmin } from '@/services/routeGuard';
import '../admin.css'; import '../admin.css';
interface AdminModule { interface AdminModule {
@@ -72,11 +72,13 @@ export default function TenantWorkbenchPage() {
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null); const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
const [overview, setOverview] = useState<TenantOverview | null>(null); const [overview, setOverview] = useState<TenantOverview | null>(null);
const [permissions, setPermissions] = useState<TenantPermissionsPayload>({}); const [permissions, setPermissions] = useState<TenantPermissionsPayload>({});
const [authorized, setAuthorized] = useState(false);
useEffect(() => { useEffect(() => {
requireSignedIn('/pages/tenant-admin/workbench/index') requireTenantAdmin('/pages/tenant-admin/workbench/index')
.then(payload => { .then(payload => {
if (!payload) return; if (!payload) return;
setAuthorized(true);
loadTenantDashboard('30d').then(next => setDashboard(next.item || null)).catch(() => setDashboard(null)); loadTenantDashboard('30d').then(next => setDashboard(next.item || null)).catch(() => setDashboard(null));
loadTenantOverview().then(next => setOverview(next.item || null)).catch(() => setOverview(null)); loadTenantOverview().then(next => setOverview(next.item || null)).catch(() => setOverview(null));
loadTenantPermissions().then(next => setPermissions(next)).catch(() => setPermissions({})); loadTenantPermissions().then(next => setPermissions(next)).catch(() => setPermissions({}));
@@ -88,6 +90,20 @@ export default function TenantWorkbenchPage() {
const payment = dashboard?.paymentStats || {}; const payment = dashboard?.paymentStats || {};
const modules = MODULES.filter(item => canOpenModule(permissions, item)); const modules = MODULES.filter(item => canOpenModule(permissions, item));
if (!authorized) {
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Tenant Admin</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'></Text>
</View>
</View>
</View>
);
}
return ( return (
<View className='admin-page'> <View className='admin-page'>
<View className='admin-shell'> <View className='admin-shell'>
@@ -118,9 +134,10 @@ export default function TenantWorkbenchPage() {
<View className='admin-section'> <View className='admin-section'>
<Text className='admin-section-title'></Text> <Text className='admin-section-title'></Text>
<View className='admin-list'> <View className='admin-module-grid'>
{modules.map(item => ( {modules.map(item => (
<View className='admin-row' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}> <View className={`admin-module-card module-${item.key}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<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>
</View> </View>

View File

@@ -19,6 +19,10 @@ function loginUrl(redirectPath: string) {
return `/pages/student/login/index?redirect=${encodeURIComponent(safeRedirectPath(redirectPath))}`; return `/pages/student/login/index?redirect=${encodeURIComponent(safeRedirectPath(redirectPath))}`;
} }
function forbiddenUrl(reason: string, redirectPath: string) {
return `/pages/student/login/index?reason=${encodeURIComponent(reason)}&redirect=${encodeURIComponent(safeRedirectPath(redirectPath))}`;
}
function currentPagePath() { function currentPagePath() {
const instance = Taro.getCurrentInstance(); const instance = Taro.getCurrentInstance();
const path = instance.router?.path || ''; const path = instance.router?.path || '';
@@ -69,7 +73,21 @@ 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;
throw new Error('当前账号不是平台管理员。'); Taro.redirectTo({ url: forbiddenUrl('当前账号不是平台管理员', redirectPath) });
return null;
}
export async function requireTenantAdmin(redirectPath: string) {
const payload = await requireSignedIn(redirectPath);
if (!payload) return null;
const user = payload.user || payload.item;
const roles = user?.roles || [];
const allowedRoles = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent']);
if (user?.primaryRole === 'platform_admin') return payload;
if (user?.primaryRole && allowedRoles.has(user.primaryRole)) return payload;
if (roles.some(role => allowedRoles.has(role) || role === 'platform_admin')) return payload;
Taro.redirectTo({ url: forbiddenUrl('当前账号没有租户后台权限', redirectPath) });
return null;
} }
export async function guardCurrentRoute() { export async function guardCurrentRoute() {
@@ -82,6 +100,10 @@ export async function guardCurrentRoute() {
await requirePlatformAdmin(path); await requirePlatformAdmin(path);
return; return;
} }
if (appEnv.portal === 'tenant-admin') {
await requireTenantAdmin(path);
return;
}
await requireSignedIn(path); await requireSignedIn(path);
} finally { } finally {
pendingGuardPath = ''; pendingGuardPath = '';