diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21b1a93 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# ============================================================================= +# 管理端本地配置示例:复制为 .env.local 后按需修改 +# ============================================================================= + +# 必填:Laravel 应用根 URL(无尾部斜杠)。axios 会请求 {此地址}/api/v1/... +# 需保证 Laravel 已允许该来源的 CORS(本地一般为 http://localhost:3000 等)。 +NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000 diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/src/api/admin-auth.ts b/src/api/admin-auth.ts new file mode 100644 index 0000000..084e36b --- /dev/null +++ b/src/api/admin-auth.ts @@ -0,0 +1,37 @@ +import { + hasLotteryAdminApiBaseUrl, + publicAdminRequest, +} from "@/lib/admin-http"; +import type { + AdminAuthCaptchaResponse, + AdminAuthLoginRequest, + AdminAuthLoginResponse, +} from "@/types/api/admin-auth"; + +import { API_V1_PREFIX } from "@/api/paths"; + +/** `GET /api/v1/admin/auth/captcha`(无需 Token) */ +export async function getAdminCaptcha(): Promise { + if (!hasLotteryAdminApiBaseUrl()) { + return null; + } + try { + return await publicAdminRequest({ + url: `${API_V1_PREFIX}/admin/auth/captcha`, + method: "GET", + }); + } catch { + return null; + } +} + +/** `POST /api/v1/admin/auth/login`(无需 Token) */ +export async function postAdminLogin( + body: AdminAuthLoginRequest, +): Promise { + return publicAdminRequest({ + url: `${API_V1_PREFIX}/admin/auth/login`, + method: "POST", + data: body, + }); +} diff --git a/src/api/admin-ping.ts b/src/api/admin-ping.ts new file mode 100644 index 0000000..2f164d5 --- /dev/null +++ b/src/api/admin-ping.ts @@ -0,0 +1,18 @@ +import { adminRequest, hasLotteryAdminApiBaseUrl } from "@/lib/admin-http"; +import type { AdminPingResponse } from "@/types/api/admin-ping"; + +import { API_V1_PREFIX } from "@/api/paths"; + +/** `GET /api/v1/admin/ping`(需 Bearer Token) */ +export async function getAdminPing(): Promise { + if (!hasLotteryAdminApiBaseUrl()) { + return null; + } + try { + return await adminRequest.get( + `${API_V1_PREFIX}/admin/ping`, + ); + } catch { + return null; + } +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..543007c --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,9 @@ +export { API_V1_PREFIX } from "@/api/paths"; +export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth"; +export { getAdminPing } from "@/api/admin-ping"; +export type { + AdminAuthCaptchaResponse, + AdminAuthLoginRequest, + AdminAuthLoginResponse, + AdminPingResponse, +} from "@/types/api"; diff --git a/src/lib/paths.ts b/src/api/paths.ts similarity index 100% rename from src/lib/paths.ts rename to src/api/paths.ts diff --git a/src/app/admin/(shell)/layout.tsx b/src/app/admin/(shell)/layout.tsx index 6d76c03..1ba3ee2 100644 --- a/src/app/admin/(shell)/layout.tsx +++ b/src/app/admin/(shell)/layout.tsx @@ -1,9 +1,14 @@ import { AdminShell } from "@/components/admin/admin-shell"; +import { AdminShellAuthGate } from "@/components/admin/admin-shell-auth-gate"; export default function AdminShellLayout({ children, }: { children: React.ReactNode; }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/admin/(shell)/page.tsx b/src/app/admin/(shell)/page.tsx index 4f1b21c..eac584e 100644 --- a/src/app/admin/(shell)/page.tsx +++ b/src/app/admin/(shell)/page.tsx @@ -1,5 +1,5 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; -import { getAdminPing } from "@/lib/admin-http"; +import { getAdminPing } from "@/api"; import { dashboardModuleMeta } from "@/modules/dashboard/meta"; import type { Metadata } from "next"; diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index d572576..538396a 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -1,32 +1,12 @@ -import Link from "next/link"; - -import { authModuleMeta } from "@/modules/auth/meta"; import type { Metadata } from "next"; +import { AdminLoginForm } from "@/components/admin/admin-login-form"; +import { authModuleMeta } from "@/modules/auth/meta"; + export const metadata: Metadata = { title: authModuleMeta.title, }; export default function AdminLoginPage() { - return ( -
-
-

- {authModuleMeta.title} -

-

- {authModuleMeta.description} -

-

- 接入管理端鉴权后,在此放置表单与回调;当前为路由占位。 -

- - 返回工作台 - -
-
- ); + return ; } diff --git a/src/components/admin/admin-login-form.tsx b/src/components/admin/admin-login-form.tsx new file mode 100644 index 0000000..6406999 --- /dev/null +++ b/src/components/admin/admin-login-form.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { ShieldCheckIcon, TriangleAlertIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { isAxiosError } from "axios"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +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 { authModuleMeta } from "@/modules/auth/meta"; +import { useAdminSessionStore } from "@/stores/admin-session-store"; +import { LotteryApiBizError } from "@/types/api/errors"; + +export function AdminLoginForm() { + const router = useRouter(); + const setBearerToken = useAdminSessionStore((s) => s.setBearerToken); + + const apiConfigured = + process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "" && + process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL !== undefined; + + const [account, setAccount] = useState(""); + const [password, setPassword] = useState(""); + const [captchaCode, setCaptchaCode] = useState(""); + const [captchaKey, setCaptchaKey] = useState(null); + const [captchaSrc, setCaptchaSrc] = useState(null); + + const [loadingCaptcha, setLoadingCaptcha] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const loadCaptcha = useCallback(async () => { + if (!apiConfigured) { + return; + } + setLoadingCaptcha(true); + try { + const data = await getAdminCaptcha(); + if (!data) { + toast.error("无法获取验证码,请检查接口或网络"); + setCaptchaKey(null); + setCaptchaSrc(null); + + return; + } + setCaptchaKey(data.captcha_key); + setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`); + setCaptchaCode(""); + } finally { + setLoadingCaptcha(false); + } + }, [apiConfigured]); + + useEffect(() => { + if (readStoredAdminToken()) { + router.replace("/admin"); + + return; + } + const t = window.setTimeout(() => { + void loadCaptcha(); + }, 0); + + return () => window.clearTimeout(t); + }, [loadCaptcha, router]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!apiConfigured) { + toast.error("未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL"); + + return; + } + if (!captchaKey || !captchaSrc) { + toast.error("请先刷新验证码"); + void loadCaptcha(); + + return; + } + + setSubmitting(true); + try { + const result = await postAdminLogin({ + account: account.trim(), + password, + captcha_key: captchaKey, + captcha_code: captchaCode.trim(), + }); + setBearerToken(result.token); + toast.success(`欢迎,${result.admin.nickname || result.admin.username}`); + router.replace("/admin"); + router.refresh(); + } catch (err) { + void loadCaptcha(); + if (err instanceof LotteryApiBizError) { + toast.error(err.message); + + return; + } + if (isAxiosError(err)) { + toast.error(err.message || "网络请求失败"); + + return; + } + toast.error("登录失败"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+
+ + + +
+ +
+
+ + {authModuleMeta.title} + + + 使用账号与密码登录;请输入图形验证码(不区分大小写)。 + +
+
+
+ + {!apiConfigured ? ( + + + 未配置 API 地址 + + 请在环境中设置{" "} + + NEXT_PUBLIC_LOTTERY_API_BASE_URL + {" "} + (Laravel 根 URL,如 http://127.0.0.1:8000)。 + + + ) : null} +
+ + setAccount(ev.target.value)} + placeholder="登录账号" + required + disabled={submitting} + className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25" + /> +
+
+ + setPassword(ev.target.value)} + placeholder="密码" + required + disabled={submitting} + className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25" + /> +
+
+ +
+ setCaptchaCode(ev.target.value)} + placeholder="图中字符" + maxLength={32} + required + disabled={submitting} + className="h-11 min-w-0 flex-1 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25 sm:max-w-[12rem]" + /> + +
+
+
+ + + +
+
+
+ ); +} diff --git a/src/components/admin/admin-shell-auth-gate.tsx b/src/components/admin/admin-shell-auth-gate.tsx new file mode 100644 index 0000000..5b057ac --- /dev/null +++ b/src/components/admin/admin-shell-auth-gate.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState, type ReactNode } from "react"; + +import { readStoredAdminToken } from "@/lib/admin-token-local-storage"; + +type AdminShellAuthGateProps = { + children: ReactNode; +}; + +/** + * 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。 + * 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。 + */ +export function AdminShellAuthGate({ children }: AdminShellAuthGateProps) { + const router = useRouter(); + const [allowed, setAllowed] = useState(false); + + useEffect(() => { + const token = readStoredAdminToken(); + if (!token) { + router.replace("/admin/login"); + return; + } + queueMicrotask(() => { + setAllowed(true); + }); + }, [router]); + + if (!allowed) { + return ( +
+ 正在校验登录状态… +
+ ); + } + + return children; +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 9431bea..d37c2dc 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -1,19 +1,34 @@ "use client"; import type { ReactNode } from "react"; +import { useEffect } from "react"; 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"; type ProvidersProps = { children: ReactNode; }; +function AdminTokenHydrator() { + useEffect(() => { + const token = readStoredAdminToken(); + if (token) { + useAdminSessionStore.getState().setBearerToken(token); + } + }, []); + + return null; +} + export function Providers({ children }: ProvidersProps) { return ( + {children} diff --git a/src/lib/admin-http.ts b/src/lib/admin-http.ts index 6c10eda..0ecb57f 100644 --- a/src/lib/admin-http.ts +++ b/src/lib/admin-http.ts @@ -5,12 +5,16 @@ import axios, { } from "axios"; import { withAdminAuthHeader } from "@/lib/admin-auth"; -import { API_V1_PREFIX } from "@/lib/paths"; import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors"; import { isApiEnvelope } from "@/types/api/envelope"; const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim(); +/** 是否已配置后台 API 根地址(客户端/服务端均可用 `NEXT_PUBLIC_*`) */ +export function hasLotteryAdminApiBaseUrl(): boolean { + return baseURL !== undefined && baseURL !== ""; +} + export const adminHttp = axios.create({ baseURL: baseURL && baseURL !== "" ? baseURL : undefined, timeout: 30_000, @@ -31,6 +35,24 @@ export function unwrapResponse(res: AxiosResponse): T { return unwrapData(res.data); } +/** 登录/验证码等:**不**附加 `Authorization`。 */ +export async function publicAdminRequest( + config: AxiosRequestConfig, +): Promise { + try { + const res = await adminHttp.request(config); + return unwrapResponse(res); + } catch (err: unknown) { + if (isAxiosError(err) && err.response?.data !== undefined) { + const body = err.response.data; + if (isApiEnvelope(body) && body.code !== 0) { + throw new LotteryApiBizError(body.msg, body.code, body.data); + } + } + throw err; + } +} + export async function request(config: AxiosRequestConfig): Promise { const merged = withAdminAuthHeader(config); try { @@ -74,19 +96,3 @@ export const adminRequest = { config?: Omit, ) => request({ ...config, url, method: "PUT", data }), }; - -export type AdminPingData = { scope: string }; - -export async function getAdminPing(): Promise { - if (!baseURL || baseURL === "") { - return null; - } - try { - const data = await adminRequest.get( - `${API_V1_PREFIX}/admin/ping`, - ); - return data; - } catch { - return null; - } -} diff --git a/src/lib/admin-token-local-storage.ts b/src/lib/admin-token-local-storage.ts new file mode 100644 index 0000000..4432f3c --- /dev/null +++ b/src/lib/admin-token-local-storage.ts @@ -0,0 +1,21 @@ +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/modules/auth/meta.ts b/src/modules/auth/meta.ts index 010c667..dfaed74 100644 --- a/src/modules/auth/meta.ts +++ b/src/modules/auth/meta.ts @@ -1,5 +1,5 @@ export const authModuleMeta = { segment: "auth", title: "登录", - description: "后台登录流程(占位,与侧边栏工作台分离)。", + description: "账号、密码与图形验证码登录;对接 Laravel Sanctum。", } as const; diff --git a/src/stores/admin-session-store.ts b/src/stores/admin-session-store.ts index 9e07013..9f4990c 100644 --- a/src/stores/admin-session-store.ts +++ b/src/stores/admin-session-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { setAdminBearerToken } from "@/lib/admin-auth"; +import { writeStoredAdminToken } from "@/lib/admin-token-local-storage"; type AdminSessionState = { /** 已与 `Authorization: Bearer …` 同步的原始片段(不带 `Bearer ` 前缀) */ @@ -15,11 +16,13 @@ export const useAdminSessionStore = create((set) => ({ 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/types/api/admin-auth.ts b/src/types/api/admin-auth.ts new file mode 100644 index 0000000..1c5aa00 --- /dev/null +++ b/src/types/api/admin-auth.ts @@ -0,0 +1,25 @@ +/** `GET /api/v1/admin/auth/captcha` 成功信封内的 `data` */ +export type AdminAuthCaptchaResponse = { + captcha_key: string; + image_base64: string; +}; + +/** `POST /api/v1/admin/auth/login` 请求体 */ +export type AdminAuthLoginRequest = { + account: string; + password: string; + captcha_key: string; + captcha_code: string; +}; + +/** `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; + }; +}; diff --git a/src/types/api/admin-ping.ts b/src/types/api/admin-ping.ts new file mode 100644 index 0000000..0656430 --- /dev/null +++ b/src/types/api/admin-ping.ts @@ -0,0 +1,4 @@ +/** `GET /api/v1/admin/ping` 成功信封内的 `data` */ +export type AdminPingResponse = { + scope: string; +}; diff --git a/src/types/api/index.ts b/src/types/api/index.ts new file mode 100644 index 0000000..ccb8ec5 --- /dev/null +++ b/src/types/api/index.ts @@ -0,0 +1,6 @@ +export type { + AdminAuthCaptchaResponse, + AdminAuthLoginRequest, + AdminAuthLoginResponse, +} from "./admin-auth"; +export type { AdminPingResponse } from "./admin-ping";