feat(web): integrate session management and improve API error handling

This commit is contained in:
2026-03-11 17:19:14 +08:00
parent f1932676be
commit 168a5b5613
5 changed files with 88 additions and 76 deletions

View File

@@ -1,12 +1,10 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import { ReactQueryProvider } from "@/components/query-provider"; import { ReactQueryProvider } from "@/components/query-provider";
import { SessionWatcher } from "@/components/session-watcher";
export default function DashboardLayout({ children }: { children: ReactNode }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="flex h-dvh bg-background"> <div className="flex h-dvh bg-background">
<SessionWatcher />
<Sidebar /> <Sidebar />
{/* Main content */} {/* Main content */}

View File

@@ -4,8 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react"; import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Thunderbolt } from "@gravity-ui/icons"; import { Thunderbolt } from "@gravity-ui/icons";
import { api } from "@/lib/api";
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
export default function SetupPage() { export default function SetupPage() {
const router = useRouter(); const router = useRouter();
@@ -38,28 +37,15 @@ export default function SetupPage() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${CSMS_URL}/api/setup`, { await api.setup.create({
method: "POST", name: form.name,
headers: { "Content-Type": "application/json" }, email: form.email,
credentials: "include", username: form.username,
body: JSON.stringify({ password: form.password,
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"); router.push("/login?setup=1");
} catch { } catch (err) {
setError("网络错误,请稍后重试"); setError(err instanceof Error ? err.message : "初始化失败,请重试");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -74,7 +60,7 @@ export default function SetupPage() {
</div> </div>
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1> <h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
<p className="mt-1 text-sm text-muted"> · </p> <p className="mt-1 text-sm text-muted"> · </p>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,67 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; 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 }) { 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( const [queryClient] = useState(
() => () =>
new QueryClient({ 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: { defaultOptions: {
queries: { queries: {
staleTime: 10_000, staleTime: 10_000,
@@ -14,5 +69,11 @@ export function ReactQueryProvider({ children }: { children: React.ReactNode })
}, },
}), }),
); );
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<SessionMonitor onExpired={handleExpired} />
{children}
</QueryClientProvider>
);
} }

View File

@@ -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;
}

View File

@@ -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"; const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -10,11 +20,8 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("session:expired"));
}
const text = await res.text().catch(() => res.statusText); 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<T>; return res.json() as Promise<T>;
} }
@@ -253,4 +260,8 @@ export const api = {
}, },
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }), ) => apiFetch<UserRow>(`/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) }),
},
}; };