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) }),
+ },
};