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:
+22
-6
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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(平台)→平台管理 NAV;carrier(园区/载体)→园区工作台 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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user