feat(web): auth context, login page, route guards, toast/error shell

This commit is contained in:
2026-09-07 15:27:11 +08:00
parent 2f0fc25c7c
commit c34289ebbe
8 changed files with 236 additions and 2 deletions
+29 -1
View File
@@ -1,3 +1,31 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAuth } from "./auth/AuthContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import Login from "./pages/Login";
function Placeholder({ name }: { name: string }) {
return <div className="p-8 text-zinc-500">{name}:待后续任务落地</div>;
}
export default function App() {
return <div className="p-8 text-zinc-400">booklib web</div>;
return (
<ErrorBoundary>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Placeholder name="书架" />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client";
import type { Me } from "../api/types";
interface AuthState {
user: Me | null;
isAdmin: boolean;
ready: boolean;
login: (u: string, p: string) => Promise<void>;
logout: () => void;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
const [token, setTok] = useState<string | null>(() => getToken());
useEffect(() => {
const off = () => setTok(null); // client 的 401 拦截在这里回收 React 状态
window.addEventListener(LOGOUT_EVENT, off);
return () => window.removeEventListener(LOGOUT_EVENT, off);
}, []);
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: !!token, retry: 0 });
useEffect(() => {
if (token && meQ.isError) setTok(null);
}, [token, meQ.isError]);
const value = useMemo<AuthState>(
() => ({
user: meQ.data ?? null,
isAdmin: meQ.data?.role === "admin",
ready: !token || !meQ.isPending,
login: async (u, p) => {
const r = await api.login(u, p);
setToken(r.token);
setTok(r.token);
await qc.invalidateQueries({ queryKey: ["me"] });
},
logout: () => {
setToken(null);
setTok(null);
qc.clear();
},
}),
[meQ.data, meQ.isPending, meQ.isError, token, qc],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthState {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth 必须在 AuthProvider 内使用");
return v;
}
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
return <>{children}</>;
}
export function RequireAdmin({ children }: { children: ReactNode }) {
const { isAdmin } = useAuth();
return isAdmin ? <>{children}</> : <Navigate to="/" replace />;
}
+26
View File
@@ -0,0 +1,26 @@
import { Component, type ReactNode } from "react";
export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
state: { err: Error | null } = { err: null };
static getDerivedStateFromError(err: Error) {
return { err };
}
render() {
if (this.state.err) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
重试
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
const items: Toast[] = [];
const subs = new Set<(t: Toast[]) => void>();
let nextId = 1;
export function toast(kind: Toast["kind"], text: ReactNode): void {
const t: Toast = { id: nextId++, kind, text };
items.push(t);
subs.forEach((f) => f([...items]));
setTimeout(() => {
const i = items.findIndex((x) => x.id === t.id);
if (i >= 0) items.splice(i, 1);
subs.forEach((f) => f([...items]));
}, 4000);
}
export function Toaster() {
const [list, setList] = useState<Toast[]>([]);
useEffect(() => {
subs.add(setList);
return () => {
subs.delete(setList);
};
}, []);
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{list.map((t) => (
<div
key={t.id}
role="status"
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
}`}
>
{t.text}
</div>
))}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export const btn =
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
export const btnPrimary =
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
export const input =
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
+5
View File
@@ -0,0 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
});
+10 -1
View File
@@ -1,5 +1,14 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { createRoot } from "react-dom/client";
import App from "./App";
import { queryClient } from "./lib/qc";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>,
);
+44
View File
@@ -0,0 +1,44 @@
import { useState, type FormEvent } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { toast } from "../components/Toaster";
import { btnPrimary, card, input } from "../components/ui";
export default function Login() {
const { login, user } = useAuth();
const loc = useLocation() as { state?: { from?: string } };
const nav = useNavigate();
const [u, setU] = useState("");
const [p, setP] = useState("");
const [busy, setBusy] = useState(false);
if (user) return <Navigate to={loc.state?.from ?? "/"} replace />;
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
try {
await login(u, p);
nav("/", { replace: true });
} catch (err) {
toast("err", err instanceof Error ? err.message : "登录失败");
} finally {
setBusy(false);
}
}
return (
<div className="grid h-full place-items-center p-4">
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
<h1 className="text-lg font-semibold">BookLib 登录</h1>
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
placeholder="用户名" autoFocus autoComplete="username" />
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
placeholder="密码" autoComplete="current-password" />
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"}
</button>
</form>
</div>
);
}