diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 770714c..1befeff 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,12 +1,10 @@ import { ReactNode } from "react"; import Sidebar from "@/components/sidebar"; import { ReactQueryProvider } from "@/components/query-provider"; -import { SessionWatcher } from "@/components/session-watcher"; export default function DashboardLayout({ children }: { children: ReactNode }) { return (
- {/* Main content */} diff --git a/apps/web/app/setup/page.tsx b/apps/web/app/setup/page.tsx index 7971c2b..3b25a65 100644 --- a/apps/web/app/setup/page.tsx +++ b/apps/web/app/setup/page.tsx @@ -4,8 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react"; import { Thunderbolt } from "@gravity-ui/icons"; - -const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001"; +import { api } from "@/lib/api"; export default function SetupPage() { const router = useRouter(); @@ -38,28 +37,15 @@ export default function SetupPage() { setLoading(true); try { - const res = await fetch(`${CSMS_URL}/api/setup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - name: form.name, - email: form.email, - username: form.username, - password: form.password, - }), + await api.setup.create({ + name: form.name, + email: form.email, + username: form.username, + password: form.password, }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError((data as { error?: string }).error ?? "初始化失败,请重试"); - return; - } - - // 初始化成功,跳转登录页 router.push("/login?setup=1"); - } catch { - setError("网络错误,请稍后重试"); + } catch (err) { + setError(err instanceof Error ? err.message : "初始化失败,请重试"); } finally { setLoading(false); } @@ -74,7 +60,7 @@ export default function SetupPage() {

Helios EVCS

-

首次启动 · 创建根管理员账号

+

首次启动 · 创建管理员账号

diff --git a/apps/web/components/query-provider.tsx b/apps/web/components/query-provider.tsx index fbe9d8e..2fbcc33 100644 --- a/apps/web/components/query-provider.tsx +++ b/apps/web/components/query-provider.tsx @@ -1,12 +1,67 @@ "use client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "@heroui/react"; +import { signOut, useSession } from "@/lib/auth-client"; +import { APIError } from "@/lib/api"; + +/** 监听 better-auth 会话变为 null(服务端过期/异地登出等场景)*/ +function SessionMonitor({ onExpired }: { onExpired: () => void }) { + const { data: session, isPending } = useSession(); + const wasLoggedIn = useRef(false); + + useEffect(() => { + if (isPending) return; + if (session) { + wasLoggedIn.current = true; + } else if (wasLoggedIn.current) { + wasLoggedIn.current = false; + onExpired(); + } + }, [session, isPending, onExpired]); + + return null; +} export function ReactQueryProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const isHandling = useRef(false); + + const handleExpired = useCallback(async () => { + if (isHandling.current) return; + isHandling.current = true; + try { + await signOut({ fetchOptions: { credentials: "include" } }); + } catch { + // ignore sign-out errors + } + toast.warning("登录已过期,请重新登录"); + router.push("/login"); + }, [router]); + + // Stable ref so the QueryClient (created once) always calls the latest handler + const handleExpiredRef = useRef(handleExpired); + handleExpiredRef.current = handleExpired; + const [queryClient] = useState( () => new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof APIError && error.status === 401) { + handleExpiredRef.current(); + } + }, + }), + mutationCache: new MutationCache({ + onError: (error) => { + if (error instanceof APIError && error.status === 401) { + handleExpiredRef.current(); + } + }, + }), defaultOptions: { queries: { staleTime: 10_000, @@ -14,5 +69,11 @@ export function ReactQueryProvider({ children }: { children: React.ReactNode }) }, }), ); - return {children}; + + return ( + + + {children} + + ); } diff --git a/apps/web/components/session-watcher.tsx b/apps/web/components/session-watcher.tsx deleted file mode 100644 index 91b10e0..0000000 --- a/apps/web/components/session-watcher.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; -import { toast } from "@heroui/react"; -import { useSession, signOut } from "@/lib/auth-client"; - -export function SessionWatcher() { - const router = useRouter(); - const { data: session, isPending } = useSession(); - const wasLoggedIn = useRef(false); - const isHandling = useRef(false); - - const handleExpired = useCallback(async () => { - if (isHandling.current) return; - isHandling.current = true; - try { - await signOut({ fetchOptions: { credentials: "include" } }); - } catch { - // ignore sign-out errors - } - toast.warning("登录已过期,请重新登录"); - router.push("/login"); - }, [router]); - - // Detect session becoming null after being valid (e.g. server-side expiry) - useEffect(() => { - if (isPending) return; - if (session) { - wasLoggedIn.current = true; - } else if (wasLoggedIn.current) { - handleExpired(); - } - }, [session, isPending, handleExpired]); - - // Detect 401 responses from the API (dispatched by apiFetch) - useEffect(() => { - const handler = () => handleExpired(); - window.addEventListener("session:expired", handler); - return () => window.removeEventListener("session:expired", handler); - }, [handleExpired]); - - return null; -} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 044a192..ebdfc00 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,3 +1,13 @@ +export class APIError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "APIError"; + } +} + const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001"; async function apiFetch(path: string, init?: RequestInit): Promise { @@ -10,11 +20,8 @@ async function apiFetch(path: string, init?: RequestInit): Promise { credentials: "include", }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent("session:expired")); - } const text = await res.text().catch(() => res.statusText); - throw new Error(`API ${path} failed (${res.status}): ${text}`); + throw new APIError(res.status, `API ${path} failed (${res.status}): ${text}`); } return res.json() as Promise; } @@ -253,4 +260,8 @@ export const api = { }, ) => apiFetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }), }, + setup: { + create: (data: { name: string; email: string; username: string; password: string }) => + apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }), + }, };