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