feat(auth): 管理端统一鉴权+身份标签校正,菜单按权限码显隐

- AppShell 身份标签按 role 映射(operator→运营方),不再信任脏 account_type_label
- auth.tsx role 取 user.role(单角色);登录去多身份 Dialog、按 role 进端口
- rbac.tsx 菜单项补 perm 权限码,navForRole 按 user.permissions 过滤(无权限菜单不显示)
- App/login 去多身份跳转

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-26 19:03:09 +08:00
parent 435d342d13
commit 9097f52fe4
5 changed files with 149 additions and 141 deletions
+22 -6
View File
@@ -1,6 +1,5 @@
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "@/lib/auth";
import { isParkUser } from "@/lib/parkAuth";
import { hasPermission } from "@/lib/rbac";
import { AppShell } from "@/components/layout/AppShell";
import LoginPage from "@/pages/login";
@@ -19,15 +18,20 @@ import ContentPage from "@/pages/content";
import TasksPage from "@/pages/tasks";
import ProvidersPage from "@/pages/providers";
import ConfigPage from "@/pages/config";
import ParkPage from "@/pages/park";
import ParkLayout from "@/pages/park";
import ParkScreensPage from "@/pages/park-screens";
import {
ScreensPage, ScreenPage, CompaniesPage, KbPage, AgentPage,
ScreenDataPage, VisionPage, AdmissionsPage, TransfersPage,
} from "@/pages/park/pages";
import AdmissionPage from "@/pages/admission";
import ParkTransferPage from "@/pages/park-transfer";
import SettingsPage from "@/pages/settings";
function RequireAuth() {
const { isAuthed } = useAuth();
// 平台(运营端) 或 园区账密(园区端)均可进入管理台
if (!isAuthed && !isParkUser()) return <Navigate to="/login" replace />;
// 平台统一登录(运营端/园区端载体方均为平台账号)→ 持平台令牌即可进入管理台
if (!isAuthed) return <Navigate to="/login" replace />;
return <AppShell />;
}
@@ -69,10 +73,22 @@ export default function App() {
<Route path="/tasks" element={<TasksPage />} />
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/park-screens" element={<ParkScreensPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* 园区管理:平台(运营端)全园管理 / 园区账密(园区端)仅本园区 —— 园区端无平台角色,故不套 RoleRoute */}
<Route path="/park" element={<ParkPage />} />
{/* 园区:平台(operator)→平台园区管理页(ParkLayout 内判断);园区账密(carrier)→园区工作台布局+子路由。园区端无平台角色,故不套 RoleRoute */}
<Route path="/park" element={<ParkLayout />}>
<Route index element={<Navigate to="/park/screens" replace />} />
<Route path="screens" element={<ScreensPage />} />
<Route path="screen" element={<ScreenPage />} />
<Route path="companies" element={<CompaniesPage />} />
<Route path="kb" element={<KbPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="data" element={<ScreenDataPage />} />
<Route path="vision" element={<VisionPage />} />
<Route path="admissions" element={<AdmissionsPage />} />
<Route path="transfers" element={<TransfersPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+10 -2
View File
@@ -20,8 +20,16 @@ import {
export function AppShell() {
const { role, user, logout } = useAuth();
const groups = navForRole(role);
// 展示文本标签(账号类型): 运营方/载体方/OPC/服务方 —— 后端下发 account_type_label, 不做代码映射
const roleLabel = (user as { account_type_label?: string })?.account_type_label || role || "运营方";
// 展示文本标签(账号类型): 运营方/载体方/OPC/服务方
// 优先按 role 映射(取自身份/端口角色, 可靠), 兜底用后端下发的 account_type_label。
const roleToLabel: Record<string, string> = {
operator: "运营方",
carrier: "载体方",
opc_member: "OPC",
service: "服务方",
};
const atypeLabel = (user as { account_type_label?: string })?.account_type_label;
const roleLabel = roleToLabel[role ?? ""] || atypeLabel || role || "运营方";
return (
<div className="flex min-h-screen">
+2 -3
View File
@@ -104,9 +104,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
identities,
currentIdentity,
isAuthed: !!token,
// 运营管理端只保留运营/园区单一身份:角色恒取账号真实角色(user.role),不
// 首个身份(currentIdentity[0])跳动 —— 避免「登录后显示无权限,刷新后才恢复」。
role: user?.role || currentIdentity?.role || null,
// 账号单一角色:role 恒取账号真实类型(user.role),不再多身份/currentIdentity。
role: user?.role || null,
permissions: user?.permissions ?? [],
username: user?.username ?? null,
login,
+65 -37
View File
@@ -14,9 +14,14 @@ import {
Store,
Settings,
UserCircle2,
Monitor,
MonitorPlay,
BookOpen,
Sparkles,
Video,
ArrowRightLeft,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { isParkUser } from "@/lib/parkAuth";
export interface NavItem {
to: string;
@@ -24,6 +29,8 @@ export interface NavItem {
icon: LucideIcon;
/** 允许访问的端口角色(identity.role */
roles?: string[];
/** 需要的权限码(menu:*),账号权限集含才显示;缺省则恒显示 */
perm?: string;
}
export interface NavGroup {
@@ -35,65 +42,86 @@ export interface NavGroup {
export const NAV: NavGroup[] = [
{
items: [
{ to: "/", label: "总览", icon: LayoutDashboard, roles: ["operator"] },
{ to: "/", label: "总览", icon: LayoutDashboard, roles: ["operator"], perm: "menu:operator_dashboard" },
],
},
{
title: "平台管理",
items: [
{ to: "/users", label: "用户管理", icon: Users, roles: ["operator"] },
{ to: "/certification", label: "OPC认证", icon: BadgeCheck, roles: ["operator"] },
{ to: "/admission", label: "入驻审核", icon: Building2, roles: ["operator"] },
{ to: "/park-transfer", label: "转园审核", icon: Building2, roles: ["operator"] },
{ to: "/compute", label: "算力管理", icon: Cpu, roles: ["operator"] },
{ to: "/park", label: "园区管理", icon: Building2, roles: ["operator", "carrier"] },
{ to: "/users", label: "用户管理", icon: Users, roles: ["operator"], perm: "menu:admin_user_mgmt" },
{ to: "/certification", label: "OPC认证", icon: BadgeCheck, roles: ["operator"], perm: "menu:opc_cert" },
{ to: "/admission", label: "入驻审核", icon: Building2, roles: ["operator"], perm: "menu:admission" },
{ to: "/park-transfer", label: "转园审核", icon: Building2, roles: ["operator"], perm: "menu:park_transfer" },
{ to: "/compute", label: "算力管理", icon: Cpu, roles: ["operator"], perm: "menu:admin_compute" },
{ to: "/park", label: "平台园区", icon: Building2, roles: ["operator"], perm: "menu:admin_park" },
{ to: "/park-screens", label: "屏幕绑定", icon: Monitor, roles: ["operator"], perm: "menu:admin_park_screens" },
{ to: "/settings", label: "个人中心", icon: UserCircle2, roles: ["operator"] },
],
},
{
title: "培训内容",
items: [
{ to: "/courses", label: "课程管理", icon: GraduationCap, roles: ["operator"] },
{ to: "/activities", label: "活动管理", icon: Calendar, roles: ["operator"] },
{ to: "/bookings", label: "报名管理", icon: ClipboardList, roles: ["operator"] },
{ to: "/tests", label: "测评管理", icon: FileCheck2, roles: ["operator"] },
{ to: "/surveys", label: "调研结果", icon: ClipboardList, roles: ["operator"] },
{ to: "/policies", label: "政策结果", icon: FileCheck2, roles: ["operator"] },
{ to: "/plans", label: "流程启动", icon: Flag, roles: ["operator"] },
{ to: "/courses", label: "课程管理", icon: GraduationCap, roles: ["operator"], perm: "menu:admin_courses" },
{ to: "/activities", label: "活动管理", icon: Calendar, roles: ["operator"], perm: "menu:admin_activities" },
{ to: "/bookings", label: "报名管理", icon: ClipboardList, roles: ["operator"], perm: "menu:admin_bookings" },
{ to: "/tests", label: "测评管理", icon: FileCheck2, roles: ["operator"], perm: "menu:admin_tests" },
{ to: "/surveys", label: "调研结果", icon: ClipboardList, roles: ["operator"], perm: "menu:admin_surveys" },
{ to: "/policies", label: "政策结果", icon: FileCheck2, roles: ["operator"], perm: "menu:admin_policies" },
{ to: "/plans", label: "流程启动", icon: Flag, roles: ["operator"], perm: "menu:admin_plans" },
],
},
{
title: "业务运营",
items: [
{ to: "/content", label: "资讯管理", icon: Newspaper, roles: ["operator"] },
{ to: "/tasks", label: "任务管理", icon: Flag, roles: ["operator"] },
{ to: "/providers", label: "服务商管理", icon: Store, roles: ["operator"] },
{ to: "/config", label: "系统配置", icon: Settings, roles: ["operator"] },
{ to: "/content", label: "资讯管理", icon: Newspaper, roles: ["operator"], perm: "menu:admin_content" },
{ to: "/tasks", label: "任务管理", icon: Flag, roles: ["operator"], perm: "menu:admin_tasks" },
{ to: "/providers", label: "服务商管理", icon: Store, roles: ["operator"], perm: "menu:admin_providers" },
{ to: "/config", label: "系统配置", icon: Settings, roles: ["operator"], perm: "menu:admin_config" },
],
},
];
// 按当前身份角色过滤导航
// 园区端(载体方 carrier)工作台菜单:功能区按主题分组(本园区)
export const CARRIER_NAV: NavGroup[] = [
{
title: "园区运营",
items: [
{ to: "/park/screens", label: "屏幕管理", icon: Monitor, perm: "menu:park_screens" },
{ to: "/park/screen", label: "大屏控制台", icon: MonitorPlay, perm: "menu:park_screen" },
{ to: "/park/companies", label: "入驻企业", icon: Building2, perm: "menu:park_companies" },
{ to: "/park/agent", label: "园区智能体", icon: Sparkles, perm: "menu:park_agent" },
],
},
{
title: "园区数据",
items: [
{ to: "/park/kb", label: "园区知识库", icon: BookOpen, perm: "menu:park_kb" },
{ to: "/park/data", label: "大屏数据", icon: MonitorPlay, perm: "menu:park_data" },
{ to: "/park/vision", label: "视频识别", icon: Video, perm: "menu:park_vision" },
],
},
{
title: "园区审核",
items: [
{ to: "/park/admissions", label: "入园申请", icon: ClipboardList, perm: "menu:park_admissions" },
{ to: "/park/transfers", label: "转园申请", icon: ArrowRightLeft, perm: "menu:park_transfers" },
],
},
];
// 按当前身份角色(role)分流 + 权限码(permissions)精细过滤 —— 无权限菜单一律不显示
export function navForRole(role: string | null): NavGroup[] {
const { isAuthed } = useAuth();
// 平台管理方:持平台令牌即恒显示完整运营管理导航。
// 不再叠加 !isParkUser() —— 避免「本会话曾登录过园区端/残留园区凭据」导致登录后只见园区管理,
// 只有刷新/重新拉取身份后才恢复。平台令牌存在即平台优先。
if (isAuthed) {
const r = role === "operator" ? role : "operator";
return NAV.map((g) => ({
...g,
items: g.items.filter((i) => !i.roles || i.roles.includes(r)),
})).filter((g) => g.items.length > 0);
}
// 园区端(园区账密登录,无平台令牌):只显示园区管理(本园区)
if (isParkUser()) {
return [{ title: "园区管理", items: [{ to: "/park", label: "园区管理", icon: Building2 }] }];
}
if (!role) return [];
const { isAuthed, permissions } = useAuth();
if (!isAuthed) return [];
// 平台统一登录:只按账号类型(role)分流到对应菜单组;组内再按权限码(perm)过滤。
// operator(平台)→平台管理 NAVcarrier(园区/载体)→园区工作台 CARRIER_NAV
// 其它(opc_member/service/无)→ 无运营菜单(走各自 C 端,不在此管理台显示)。
const allowed = (i: NavItem) => !i.perm || !permissions?.length || permissions.includes(i.perm);
if (role === "carrier") return CARRIER_NAV.map((g) => ({ ...g, items: g.items.filter(allowed) })).filter((g) => g.items.length > 0);
if (role !== "operator") return [];
return NAV.map((g) => ({
...g,
items: g.items.filter((i) => !i.roles || i.roles.includes(role)),
items: g.items.filter((i) => allowed(i) && (!i.roles || i.roles.includes("operator"))),
})).filter((g) => g.items.length > 0);
}
+50 -93
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { useNavigate, Navigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { useAuth } from "@/lib/auth";
import { parkLogin, isParkUser, clearParkAuth } from "@/lib/parkAuth";
import { clearParkAuth } from "@/lib/parkAuth";
import * as authApi from "@/api/auth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -46,7 +46,7 @@ function PollingQr({
setQrImage(qr_image || "");
setStatus("pending");
const timer = window.setInterval(async () => {
if (busy) return; // 防重入
if (busy) return;
setBusy(true);
try {
const res = await pollRef.current(scene);
@@ -59,9 +59,7 @@ function PollingQr({
setStatus("error");
setError("二维码已过期,请刷新");
}
} finally {
setBusy(false);
}
} finally { setBusy(false); }
}, 2000);
} catch (err) {
setStatus("error");
@@ -93,7 +91,6 @@ function PollingQr({
export default function LoginPage() {
const { isAuthed, login, loginWithResponse } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = React.useState<"platform" | "park">("platform");
const [pmode, setPmode] = React.useState<"account" | "sms" | "miniprogram">("account");
const [username, setUsername] = React.useState("pine");
const [password, setPassword] = React.useState("123456");
@@ -105,13 +102,12 @@ export default function LoginPage() {
const [loading, setLoading] = React.useState(false);
if (isAuthed) return <Navigate to="/" replace />;
if (isParkUser()) return <Navigate to="/park" replace />;
/** 平台登录结果:登录即以运营端进入(单一身份,无需选择/切换身份 */
/** 平台登录结果:按账号类型(role)直接进对应端口(carrier→园区工作台,其余→平台 */
async function applyPlatformResult(res: LoginResponse | null) {
if (!res) return;
clearParkAuth();
navigate("/", { replace: true });
navigate(res.role === "carrier" ? "/park" : "/", { replace: true });
}
async function onAccountSubmit(e: React.FormEvent) {
@@ -149,111 +145,72 @@ export default function LoginPage() {
}
function onQrResult(res: LoginResponse) {
// 与短信/账号一致:先 loginWithResponse 真正落库(设 token + 单身份自动选中),再跳转,
// 否则 RequireAuth 判未登录又回 login。
void loginWithResponse(res).then((r) => applyPlatformResult(r));
}
async function onParkSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null); setLoading(true);
try {
await parkLogin(username, password);
navigate("/park", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally { setLoading(false); }
}
return (
<Centered>
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<ShieldCheck className="mb-2 h-8 w-8 text-(--mascot-accent)" />
<CardTitle className="text-lg"> · </CardTitle>
<CardDescription>() () </CardDescription>
<CardDescription> / () </CardDescription>
</CardHeader>
<CardContent>
<Tabs value={mode} onValueChange={(v) => { setMode(v as "platform" | "park"); setError(null); }}>
<TabsList className="w-full">
<TabsTrigger value="platform" className="flex-1"></TabsTrigger>
<TabsTrigger value="park" className="flex-1"></TabsTrigger>
<Tabs value={pmode} onValueChange={(v) => { setPmode(v as typeof pmode); setError(null); }}>
<TabsList className="w-full mt-2">
<TabsTrigger value="account" className="flex-1"><Lock className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="sms" className="flex-1"><Smartphone className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="miniprogram" className="flex-1"><QrCode className="mr-1 h-3 w-3" /></TabsTrigger>
</TabsList>
<TabsContent value="platform">
<Tabs value={pmode} onValueChange={(v) => { setPmode(v as typeof pmode); setError(null); }}>
<TabsList className="w-full mt-3">
<TabsTrigger value="account" className="flex-1"><Lock className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="sms" className="flex-1"><Smartphone className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="miniprogram" className="flex-1"><QrCode className="mr-1 h-3 w-3" /></TabsTrigger>
</TabsList>
<TabsContent value="account">
<form onSubmit={onAccountSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
<Label htmlFor="username"></Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</div>
<div className="space-y-1">
<Label htmlFor="password"></Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="animate-spin" />}
</Button>
</form>
</TabsContent>
<TabsContent value="sms">
<form onSubmit={onSmsSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
<Label htmlFor="sms-phone"></Label>
<Input id="sms-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="请输入 11 位手机号" />
</div>
<div className="space-y-1">
<Label htmlFor="sms-code">{smsStub ? `验证码(演示:${smsStub}` : "验证码"}</Label>
<div className="flex gap-2">
<Input id="sms-code" value={smsCode} onChange={(e) => setSmsCode(e.target.value)} placeholder="请输入验证码" />
<Button type="button" variant="outline" className="shrink-0" disabled={smsCountdown > 0} onClick={onSmsCode}>
{smsCountdown > 0 ? `${smsCountdown}s` : "发送验证码"}
</Button>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="animate-spin" />}
</Button>
</form>
</TabsContent>
<TabsContent value="miniprogram">
<PollingQr
start={authApi.mpQrStart}
poll={authApi.mpQrPoll}
tip="使用微信扫一扫打开小程序,在小程序内确认登录"
onResult={onQrResult}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="park">
<form onSubmit={onParkSubmit} className="mt-3 space-y-4">
<TabsContent value="account">
<form onSubmit={onAccountSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
<Label htmlFor="p-username"></Label>
<Input id="p-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
<Label htmlFor="username"></Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</div>
<div className="space-y-1">
<Label htmlFor="p-password"></Label>
<Input id="p-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
<Label htmlFor="password"></Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="animate-spin" />}
{loading && <Loader2 className="animate-spin" />}
</Button>
</form>
</TabsContent>
<TabsContent value="sms">
<form onSubmit={onSmsSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
<Label htmlFor="sms-phone"></Label>
<Input id="sms-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="请输入 11 位手机号" autoComplete="tel" />
</div>
<div className="space-y-1">
<Label htmlFor="sms-code">{smsStub ? `验证码(演示:${smsStub}` : "验证码"}</Label>
<div className="flex gap-2">
<Input id="sms-code" value={smsCode} onChange={(e) => setSmsCode(e.target.value)} placeholder="请输入验证码" />
<Button type="button" variant="outline" className="shrink-0" disabled={smsCountdown > 0} onClick={onSmsCode}>
{smsCountdown > 0 ? `${smsCountdown}s` : "发送验证码"}
</Button>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="animate-spin" />}
</Button>
</form>
</TabsContent>
<TabsContent value="miniprogram">
<PollingQr
start={authApi.mpQrStart}
poll={authApi.mpQrPoll}
tip="使用微信扫一扫打开小程序,在小程序内确认登录"
onResult={onQrResult}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>