feat(web): auth context, login page, route guards, toast/error shell
This commit is contained in:
+29
-1
@@ -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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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
@@ -1,5 +1,14 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { queryClient } from "./lib/qc";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user