refactor: 替换管理员登录组件并更新会话管理
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
||||
import { ShellToolbar } from "@/components/admin/toolbar";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
@@ -14,11 +15,14 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
||||
<SidebarProvider defaultOpen>
|
||||
<AdminAppSidebar />
|
||||
<SidebarInset className="max-md:overflow-x-hidden">
|
||||
<header className="sticky top-0 z-30 flex items-center gap-2 border-b border-border bg-background/80 px-4 py-2 backdrop-blur-md">
|
||||
<header className="sticky top-0 z-30 flex min-h-12 items-center gap-3 border-b border-border bg-background/80 px-4 py-2 backdrop-blur-md">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium text-muted-foreground md:hidden">
|
||||
彩票后台
|
||||
</span>
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<ShellToolbar />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-6 py-6 md:px-8 md:py-8">
|
||||
{children}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
|
||||
type AdminShellAuthGateProps = {
|
||||
type ShellAuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -13,12 +13,12 @@ type AdminShellAuthGateProps = {
|
||||
* 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。
|
||||
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
|
||||
*/
|
||||
export function AdminShellAuthGate({ children }: AdminShellAuthGateProps) {
|
||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
const router = useRouter();
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredAdminToken();
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
router.replace("/admin/login");
|
||||
return;
|
||||
@@ -19,14 +19,15 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
||||
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
import { authModuleMeta } from "@/modules/auth/meta";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session-store";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminLoginForm() {
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||
|
||||
const apiConfigured =
|
||||
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "" &&
|
||||
@@ -64,7 +65,7 @@ export function AdminLoginForm() {
|
||||
}, [apiConfigured]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readStoredAdminToken()) {
|
||||
if (readToken()) {
|
||||
router.replace("/admin");
|
||||
|
||||
return;
|
||||
@@ -99,6 +100,7 @@ export function AdminLoginForm() {
|
||||
captcha_code: captchaCode.trim(),
|
||||
});
|
||||
setBearerToken(result.token);
|
||||
setAdminProfile(result.admin);
|
||||
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
155
src/components/admin/toolbar.tsx
Normal file
155
src/components/admin/toolbar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
useAdminProfile,
|
||||
useAdminSessionStore,
|
||||
} from "@/stores/admin-session";
|
||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||
|
||||
const ADMIN_ROLE_LABEL = "超级管理员";
|
||||
|
||||
/** 暂未接入通知中心时的占位未读数(与设计稿一致可改为接口数据) */
|
||||
const NOTIFICATION_PLACEHOLDER_COUNT = 6;
|
||||
|
||||
function initialsFromProfile(profile: AdminProfile | null): string {
|
||||
if (!profile) {
|
||||
return "—";
|
||||
}
|
||||
const s = (profile.nickname?.trim() || profile.username?.trim() || "").trim();
|
||||
if (!s) {
|
||||
return "?";
|
||||
}
|
||||
const runes = Array.from(s);
|
||||
if (runes.length === 1) {
|
||||
return runes[0].toUpperCase();
|
||||
}
|
||||
const cp = runes[0].codePointAt(0);
|
||||
const isCjk =
|
||||
cp !== undefined &&
|
||||
((cp >= 0x4e00 && cp <= 0x9fff) ||
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
||||
(cp >= 0xf900 && cp <= 0xfaff));
|
||||
if (isCjk) {
|
||||
return runes.slice(0, 2).join("");
|
||||
}
|
||||
const parts = s.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
const a = parts[0][0];
|
||||
const b = parts[1][0];
|
||||
|
||||
return `${a}${b}`.toUpperCase();
|
||||
}
|
||||
|
||||
return s.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function ShellToolbar() {
|
||||
const router = useRouter();
|
||||
const adminProfile = useAdminProfile();
|
||||
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
||||
|
||||
const displayName =
|
||||
adminProfile?.nickname?.trim() ||
|
||||
adminProfile?.username?.trim() ||
|
||||
"管理员";
|
||||
|
||||
function onLogout() {
|
||||
clearSession();
|
||||
toast.success("已退出登录");
|
||||
router.replace("/admin/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative shrink-0 text-primary hover:text-primary"
|
||||
aria-label="通知"
|
||||
title="通知"
|
||||
onClick={() => toast.message("通知功能开发中")}
|
||||
>
|
||||
<BellIcon className="size-5 stroke-[1.75]" />
|
||||
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-2 ring-background">
|
||||
{NOTIFICATION_PLACEHOLDER_COUNT > 99
|
||||
? "99+"
|
||||
: NOTIFICATION_PLACEHOLDER_COUNT}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg px-1.5 py-1 text-left outline-none hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring sm:max-w-[16rem]">
|
||||
<Avatar size="sm" className="ring-1 ring-border">
|
||||
<AvatarFallback className="bg-muted text-xs font-medium text-foreground">
|
||||
{initialsFromProfile(adminProfile)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden min-w-0 flex-1 flex-col sm:flex">
|
||||
<span className="truncate text-sm font-semibold leading-tight">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{ADMIN_ROLE_LABEL}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-4 shrink-0 text-muted-foreground sm:block" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{adminProfile?.username ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem disabled className="gap-2">
|
||||
<UserRoundIcon />
|
||||
账号设置
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOutIcon />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user