{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";