diff --git a/web/src/App.tsx b/web/src/App.tsx index 68eb80c..0961381 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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
{name}:待后续任务落地
; +} + export default function App() { - return
booklib web
; + return ( + + + + } /> + + + + } + /> + } /> + + + + + ); } diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..2eb0be4 --- /dev/null +++ b/web/src/auth/AuthContext.tsx @@ -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; + logout: () => void; +} + +const Ctx = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const qc = useQueryClient(); + const [token, setTok] = useState(() => 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( + () => ({ + 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 {children}; +} + +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 ; + if (!ready || !user) return
加载中…
; + return <>{children}; +} + +export function RequireAdmin({ children }: { children: ReactNode }) { + const { isAdmin } = useAuth(); + return isAdmin ? <>{children} : ; +} diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..61ef37f --- /dev/null +++ b/web/src/components/ErrorBoundary.tsx @@ -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 ( +
+
+

页面出错了

+

{String(this.state.err)}

+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/web/src/components/Toaster.tsx b/web/src/components/Toaster.tsx new file mode 100644 index 0000000..affe5f5 --- /dev/null +++ b/web/src/components/Toaster.tsx @@ -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([]); + useEffect(() => { + subs.add(setList); + return () => { + subs.delete(setList); + }; + }, []); + return ( +
+ {list.map((t) => ( +
+ {t.text} +
+ ))} +
+ ); +} diff --git a/web/src/components/ui.ts b/web/src/components/ui.ts new file mode 100644 index 0000000..5c56510 --- /dev/null +++ b/web/src/components/ui.ts @@ -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"; diff --git a/web/src/lib/qc.ts b/web/src/lib/qc.ts new file mode 100644 index 0000000..9daa415 --- /dev/null +++ b/web/src/lib/qc.ts @@ -0,0 +1,5 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } }, +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index 696e0d2..7ffae04 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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(); +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..fda8468 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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 ; + + 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 ( +
+
+

BookLib 登录

+ setU(e.target.value)} + placeholder="用户名" autoFocus autoComplete="username" /> + setP(e.target.value)} + placeholder="密码" autoComplete="current-password" /> + +
+
+ ); +}