refactor: 替换管理员登录组件并更新会话管理
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { AdminShell } from "@/components/admin/admin-shell";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
import { AdminShellAuthGate } from "@/components/admin/admin-shell-auth-gate";
|
import { ShellAuthGate } from "@/components/admin/auth-gate";
|
||||||
|
|
||||||
export default function AdminShellLayout({
|
export default function AdminShellLayout({
|
||||||
children,
|
children,
|
||||||
@@ -7,8 +7,8 @@ export default function AdminShellLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AdminShellAuthGate>
|
<ShellAuthGate>
|
||||||
<AdminShell>{children}</AdminShell>
|
<AdminShell>{children}</AdminShell>
|
||||||
</AdminShellAuthGate>
|
</ShellAuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { AdminLoginForm } from "@/components/admin/admin-login-form";
|
import { LoginForm } from "@/components/admin/login-form";
|
||||||
import { authModuleMeta } from "@/modules/auth/meta";
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -8,5 +8,5 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
return <AdminLoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
||||||
|
import { ShellToolbar } from "@/components/admin/toolbar";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
@@ -14,11 +15,14 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
|||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
<AdminAppSidebar />
|
<AdminAppSidebar />
|
||||||
<SidebarInset className="max-md:overflow-x-hidden">
|
<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 />
|
<SidebarTrigger />
|
||||||
<span className="text-sm font-medium text-muted-foreground md:hidden">
|
<span className="text-sm font-medium text-muted-foreground md:hidden">
|
||||||
彩票后台
|
彩票后台
|
||||||
</span>
|
</span>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center">
|
||||||
|
<ShellToolbar />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1 flex-col px-6 py-6 md:px-8 md:py-8">
|
<div className="flex flex-1 flex-col px-6 py-6 md:px-8 md:py-8">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
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;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,12 +13,12 @@ type AdminShellAuthGateProps = {
|
|||||||
* 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。
|
* 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。
|
||||||
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
|
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
|
||||||
*/
|
*/
|
||||||
export function AdminShellAuthGate({ children }: AdminShellAuthGateProps) {
|
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [allowed, setAllowed] = useState(false);
|
const [allowed, setAllowed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = readStoredAdminToken();
|
const token = readToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.replace("/admin/login");
|
router.replace("/admin/login");
|
||||||
return;
|
return;
|
||||||
@@ -19,14 +19,15 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
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 { authModuleMeta } from "@/modules/auth/meta";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session-store";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
export function AdminLoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||||
|
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||||
|
|
||||||
const apiConfigured =
|
const apiConfigured =
|
||||||
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "" &&
|
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "" &&
|
||||||
@@ -64,7 +65,7 @@ export function AdminLoginForm() {
|
|||||||
}, [apiConfigured]);
|
}, [apiConfigured]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readStoredAdminToken()) {
|
if (readToken()) {
|
||||||
router.replace("/admin");
|
router.replace("/admin");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -99,6 +100,7 @@ export function AdminLoginForm() {
|
|||||||
captcha_code: captchaCode.trim(),
|
captcha_code: captchaCode.trim(),
|
||||||
});
|
});
|
||||||
setBearerToken(result.token);
|
setBearerToken(result.token);
|
||||||
|
setAdminProfile(result.admin);
|
||||||
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
|
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
|
||||||
router.replace("/admin");
|
router.replace("/admin");
|
||||||
router.refresh();
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,19 +6,15 @@ import { ThemeProvider } from "next-themes";
|
|||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session-store";
|
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AdminTokenHydrator() {
|
function AdminSessionHydrator() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = readStoredAdminToken();
|
useAdminSessionStore.getState().rehydrate();
|
||||||
if (token) {
|
|
||||||
useAdminSessionStore.getState().setBearerToken(token);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -28,7 +24,7 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<AdminTokenHydrator />
|
<AdminSessionHydrator />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
const STORAGE_KEY = "lottery_admin_token";
|
|
||||||
|
|
||||||
export function readStoredAdminToken(): string | null {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)?.trim();
|
|
||||||
|
|
||||||
return raw && raw !== "" ? raw : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredAdminToken(token: string | null): void {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (token && token.trim() !== "") {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, token.trim());
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
43
src/stores/admin-profile.ts
Normal file
43
src/stores/admin-profile.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
|
|
||||||
|
const KEY = "lottery_admin_profile";
|
||||||
|
|
||||||
|
export function readProfile(): AdminProfile | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as AdminProfile;
|
||||||
|
if (
|
||||||
|
typeof v?.id === "number" &&
|
||||||
|
typeof v?.username === "string" &&
|
||||||
|
typeof v?.nickname === "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
username: v.username,
|
||||||
|
nickname: v.nickname,
|
||||||
|
email: typeof v.email === "string" || v.email === null ? v.email : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeProfile(p: AdminProfile | null): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p) {
|
||||||
|
window.localStorage.setItem(KEY, JSON.stringify(p));
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import { setAdminBearerToken } from "@/lib/admin-auth";
|
|
||||||
import { writeStoredAdminToken } from "@/lib/admin-token-local-storage";
|
|
||||||
|
|
||||||
type AdminSessionState = {
|
|
||||||
/** 已与 `Authorization: Bearer …` 同步的原始片段(不带 `Bearer ` 前缀) */
|
|
||||||
bearerToken: string | null;
|
|
||||||
setBearerToken: (token: string | null) => void;
|
|
||||||
clearBearerToken: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAdminSessionStore = create<AdminSessionState>((set) => ({
|
|
||||||
bearerToken: null,
|
|
||||||
|
|
||||||
setBearerToken: (token) => {
|
|
||||||
const normalized = token?.trim() ? token.trim() : null;
|
|
||||||
setAdminBearerToken(normalized);
|
|
||||||
writeStoredAdminToken(normalized);
|
|
||||||
set({ bearerToken: normalized });
|
|
||||||
},
|
|
||||||
|
|
||||||
clearBearerToken: () => {
|
|
||||||
setAdminBearerToken(null);
|
|
||||||
writeStoredAdminToken(null);
|
|
||||||
set({ bearerToken: null });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
96
src/stores/admin-session.ts
Normal file
96
src/stores/admin-session.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 管理端会话:Bearer Token + 登录接口返回的 {@link AdminProfile}。
|
||||||
|
*
|
||||||
|
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
||||||
|
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
||||||
|
*/
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { setAdminBearerToken } from "@/lib/admin-auth";
|
||||||
|
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||||
|
import { readToken, writeToken } from "@/stores/admin-token";
|
||||||
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
|
|
||||||
|
export type AdminSessionState = {
|
||||||
|
bearerToken: string | null;
|
||||||
|
adminProfile: AdminProfile | null;
|
||||||
|
setBearerToken: (token: string | null) => void;
|
||||||
|
setAdminProfile: (profile: AdminProfile | null) => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
/** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */
|
||||||
|
rehydrate: () => void;
|
||||||
|
/** @deprecated 使用 {@link clearSession} */
|
||||||
|
clearBearerToken: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||||
|
bearerToken: null,
|
||||||
|
adminProfile: null,
|
||||||
|
|
||||||
|
setBearerToken: (token) => {
|
||||||
|
const normalized = token?.trim() ? token.trim() : null;
|
||||||
|
setAdminBearerToken(normalized);
|
||||||
|
writeToken(normalized);
|
||||||
|
if (!normalized) {
|
||||||
|
writeProfile(null);
|
||||||
|
set({ bearerToken: null, adminProfile: null });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ bearerToken: normalized });
|
||||||
|
},
|
||||||
|
|
||||||
|
setAdminProfile: (profile) => {
|
||||||
|
writeProfile(profile);
|
||||||
|
set({ adminProfile: profile });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSession: () => {
|
||||||
|
setAdminBearerToken(null);
|
||||||
|
writeToken(null);
|
||||||
|
writeProfile(null);
|
||||||
|
set({ bearerToken: null, adminProfile: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
rehydrate: () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = readToken();
|
||||||
|
const profile = readProfile();
|
||||||
|
if (token) {
|
||||||
|
setAdminBearerToken(token);
|
||||||
|
set({ bearerToken: token, adminProfile: profile });
|
||||||
|
} else {
|
||||||
|
setAdminBearerToken(null);
|
||||||
|
set({ bearerToken: null, adminProfile: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBearerToken: () => {
|
||||||
|
get().clearSession();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** React 组件:订阅当前管理员摘要(未登录或未缓存时为 `null`) */
|
||||||
|
export function useAdminProfile(): AdminProfile | null {
|
||||||
|
return useAdminSessionStore((s) => s.adminProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React 组件:是否已有 Token(已通过登录或 `rehydrate`) */
|
||||||
|
export function useAdminSignedIn(): boolean {
|
||||||
|
return useAdminSessionStore((s) => s.bearerToken !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任意运行上下文读取管理员摘要(不触发重渲染)。
|
||||||
|
* 服务端为 `null`;客户端与页面水合后才有值。
|
||||||
|
*/
|
||||||
|
export function getAdminProfile(): AdminProfile | null {
|
||||||
|
return useAdminSessionStore.getState().adminProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非 React 代码读取完整会话状态(含 `setBearerToken`、`clearSession` 等) */
|
||||||
|
export function getAdminSessionState(): AdminSessionState {
|
||||||
|
return useAdminSessionStore.getState();
|
||||||
|
}
|
||||||
21
src/stores/admin-token.ts
Normal file
21
src/stores/admin-token.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const KEY = "lottery_admin_token";
|
||||||
|
|
||||||
|
export function readToken(): string | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(KEY)?.trim();
|
||||||
|
|
||||||
|
return raw && raw !== "" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeToken(t: string | null): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t && t.trim() !== "") {
|
||||||
|
window.localStorage.setItem(KEY, t.trim());
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/stores/index.ts
Normal file
10
src/stores/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type { AdminSessionState } from "@/stores/admin-session";
|
||||||
|
export { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||||
|
export {
|
||||||
|
getAdminProfile,
|
||||||
|
getAdminSessionState,
|
||||||
|
useAdminProfile,
|
||||||
|
useAdminSessionStore,
|
||||||
|
useAdminSignedIn,
|
||||||
|
} from "@/stores/admin-session";
|
||||||
|
export { readToken, writeToken } from "@/stores/admin-token";
|
||||||
@@ -12,14 +12,17 @@ export type AdminAuthLoginRequest = {
|
|||||||
captcha_code: string;
|
captcha_code: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
||||||
|
export type AdminProfile = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||||
export type AdminAuthLoginResponse = {
|
export type AdminAuthLoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
admin: {
|
admin: AdminProfile;
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
nickname: string;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export type {
|
|||||||
AdminAuthCaptchaResponse,
|
AdminAuthCaptchaResponse,
|
||||||
AdminAuthLoginRequest,
|
AdminAuthLoginRequest,
|
||||||
AdminAuthLoginResponse,
|
AdminAuthLoginResponse,
|
||||||
|
AdminProfile,
|
||||||
} from "./admin-auth";
|
} from "./admin-auth";
|
||||||
export type { AdminPingResponse } from "./admin-ping";
|
export type { AdminPingResponse } from "./admin-ping";
|
||||||
|
|||||||
Reference in New Issue
Block a user