From 9805f56d3a306b92904bd3117c1a41a35bb4baa9 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 9 May 2026 13:48:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=9B=BF=E6=8D=A2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=99=BB=E5=BD=95=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/(shell)/layout.tsx | 6 +- src/app/admin/login/page.tsx | 4 +- src/components/admin/admin-shell.tsx | 6 +- ...dmin-shell-auth-gate.tsx => auth-gate.tsx} | 8 +- .../{admin-login-form.tsx => login-form.tsx} | 10 +- src/components/admin/toolbar.tsx | 155 ++++++++++++++++++ src/components/providers.tsx | 12 +- src/lib/admin-token-local-storage.ts | 21 --- src/stores/admin-profile.ts | 43 +++++ src/stores/admin-session-store.ts | 28 ---- src/stores/admin-session.ts | 96 +++++++++++ src/stores/admin-token.ts | 21 +++ src/stores/index.ts | 10 ++ src/types/api/admin-auth.ts | 15 +- src/types/api/index.ts | 1 + 15 files changed, 359 insertions(+), 77 deletions(-) rename src/components/admin/{admin-shell-auth-gate.tsx => auth-gate.tsx} (79%) rename src/components/admin/{admin-login-form.tsx => login-form.tsx} (97%) create mode 100644 src/components/admin/toolbar.tsx delete mode 100644 src/lib/admin-token-local-storage.ts create mode 100644 src/stores/admin-profile.ts delete mode 100644 src/stores/admin-session-store.ts create mode 100644 src/stores/admin-session.ts create mode 100644 src/stores/admin-token.ts create mode 100644 src/stores/index.ts diff --git a/src/app/admin/(shell)/layout.tsx b/src/app/admin/(shell)/layout.tsx index 1ba3ee2..15f3e8e 100644 --- a/src/app/admin/(shell)/layout.tsx +++ b/src/app/admin/(shell)/layout.tsx @@ -1,5 +1,5 @@ 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({ children, @@ -7,8 +7,8 @@ export default function AdminShellLayout({ children: React.ReactNode; }) { return ( - + {children} - + ); } diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index 538396a..d6dccc5 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -1,6 +1,6 @@ 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"; export const metadata: Metadata = { @@ -8,5 +8,5 @@ export const metadata: Metadata = { }; export default function AdminLoginPage() { - return ; + return ; } diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index ef929f8..d54b210 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -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 }) { -
+
彩票后台 +
+ +
{children} diff --git a/src/components/admin/admin-shell-auth-gate.tsx b/src/components/admin/auth-gate.tsx similarity index 79% rename from src/components/admin/admin-shell-auth-gate.tsx rename to src/components/admin/auth-gate.tsx index 5b057ac..349586a 100644 --- a/src/components/admin/admin-shell-auth-gate.tsx +++ b/src/components/admin/auth-gate.tsx @@ -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; diff --git a/src/components/admin/admin-login-form.tsx b/src/components/admin/login-form.tsx similarity index 97% rename from src/components/admin/admin-login-form.tsx rename to src/components/admin/login-form.tsx index 6406999..5ba1a7f 100644 --- a/src/components/admin/admin-login-form.tsx +++ b/src/components/admin/login-form.tsx @@ -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(); diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx new file mode 100644 index 0000000..6f76f31 --- /dev/null +++ b/src/components/admin/toolbar.tsx @@ -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 ( +
+ + + + + + + + + {initialsFromProfile(adminProfile)} + + + + + {displayName} + + + {ADMIN_ROLE_LABEL} + + + + + + + +
+ {displayName} + + @{adminProfile?.username ?? "—"} + +
+
+
+ + + + + 账号设置 + + + + + + + 退出登录 + + +
+
+
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index d37c2dc..b922f56 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -6,19 +6,15 @@ import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { readStoredAdminToken } from "@/lib/admin-token-local-storage"; -import { useAdminSessionStore } from "@/stores/admin-session-store"; +import { useAdminSessionStore } from "@/stores/admin-session"; type ProvidersProps = { children: ReactNode; }; -function AdminTokenHydrator() { +function AdminSessionHydrator() { useEffect(() => { - const token = readStoredAdminToken(); - if (token) { - useAdminSessionStore.getState().setBearerToken(token); - } + useAdminSessionStore.getState().rehydrate(); }, []); return null; @@ -28,7 +24,7 @@ export function Providers({ children }: ProvidersProps) { return ( - + {children} diff --git a/src/lib/admin-token-local-storage.ts b/src/lib/admin-token-local-storage.ts deleted file mode 100644 index 4432f3c..0000000 --- a/src/lib/admin-token-local-storage.ts +++ /dev/null @@ -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); - } -} diff --git a/src/stores/admin-profile.ts b/src/stores/admin-profile.ts new file mode 100644 index 0000000..3c990d7 --- /dev/null +++ b/src/stores/admin-profile.ts @@ -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); + } +} diff --git a/src/stores/admin-session-store.ts b/src/stores/admin-session-store.ts deleted file mode 100644 index 9f4990c..0000000 --- a/src/stores/admin-session-store.ts +++ /dev/null @@ -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((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 }); - }, -})); diff --git a/src/stores/admin-session.ts b/src/stores/admin-session.ts new file mode 100644 index 0000000..9ba2766 --- /dev/null +++ b/src/stores/admin-session.ts @@ -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((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(); +} diff --git a/src/stores/admin-token.ts b/src/stores/admin-token.ts new file mode 100644 index 0000000..8e3de4f --- /dev/null +++ b/src/stores/admin-token.ts @@ -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); + } +} diff --git a/src/stores/index.ts b/src/stores/index.ts new file mode 100644 index 0000000..5d6d3b8 --- /dev/null +++ b/src/stores/index.ts @@ -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"; diff --git a/src/types/api/admin-auth.ts b/src/types/api/admin-auth.ts index 1c5aa00..284741f 100644 --- a/src/types/api/admin-auth.ts +++ b/src/types/api/admin-auth.ts @@ -12,14 +12,17 @@ export type AdminAuthLoginRequest = { captcha_code: string; }; +/** 登录成功后缓存于会话(localStorage)的管理员摘要 */ +export type AdminProfile = { + id: number; + username: string; + nickname: string; + email: string | null; +}; + /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ export type AdminAuthLoginResponse = { token: string; token_type: string; - admin: { - id: number; - username: string; - nickname: string; - email: string | null; - }; + admin: AdminProfile; }; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index ccb8ec5..e6463ac 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -2,5 +2,6 @@ export type { AdminAuthCaptchaResponse, AdminAuthLoginRequest, AdminAuthLoginResponse, + AdminProfile, } from "./admin-auth"; export type { AdminPingResponse } from "./admin-ping";