diff --git a/web/src/App.tsx b/web/src/App.tsx index 0961381..3620311 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { AuthProvider, RequireAuth } from "./auth/AuthContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Toaster } from "./components/Toaster"; import Login from "./pages/Login"; +import Shelf from "./pages/Shelf"; function Placeholder({ name }: { name: string }) { return
{name}:待后续任务落地
; @@ -18,7 +19,15 @@ export default function App() { path="/" element={ - + + + } + /> + + } /> diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx new file mode 100644 index 0000000..8428b3f --- /dev/null +++ b/web/src/components/TopBar.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; +import { btn } from "./ui"; + +export function TopBar({ right }: { right?: ReactNode }) { + const { user, isAdmin, logout } = useAuth(); + return ( +
+ + BookLib + + {isAdmin && ( + + 用户 + + )} + {isAdmin && ( + + 库管理 + + )} +
+ {right} + + {user?.username} + {isAdmin ? "(admin)" : ""} + + +
+
+ ); +} diff --git a/web/src/pages/Shelf.tsx b/web/src/pages/Shelf.tsx new file mode 100644 index 0000000..69280cc --- /dev/null +++ b/web/src/pages/Shelf.tsx @@ -0,0 +1,195 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api } from "../api/client"; +import type { Book } from "../api/types"; +import { useAuth } from "../auth/AuthContext"; +import { Cover } from "../components/Cover"; +import { TopBar } from "../components/TopBar"; +import { toast } from "../components/Toaster"; +import { btn, btnPrimary, card, input } from "../components/ui"; +import { groupByDir } from "../lib/group"; + +function BookCard({ + book, + onOpen, + onDelete, +}: { + book: Book; + onOpen: () => void; + onDelete: () => void; +}) { + const { isAdmin } = useAuth(); + return ( +
+ + {book.state === "error" && ( + + 损坏 + + )} + {book.percent > 0 && ( +
+
+
+ )} + {isAdmin && ( + + )} +
+ ); +} + +export default function Shelf() { + const qc = useQueryClient(); + const nav = useNavigate(); + const [lib, setLib] = useState(0); // 0 = 全部库 + const [qRaw, setQRaw] = useState(""); + const [q, setQ] = useState(""); + + useEffect(() => { + const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖 + return () => clearTimeout(t); + }, [qRaw]); + + const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); + const booksQ = useQuery({ + queryKey: ["books", lib, q], + queryFn: () => api.listBooks({ library: lib || undefined, q }), + }); + const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); + // 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存) + const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() }); + + const del = useMutation({ + mutationFn: (id: number) => api.deleteBook(id), + onSuccess: () => { + toast("ok", "已删除"); + qc.invalidateQueries({ queryKey: ["books"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), + }); + const scan = useMutation({ + mutationFn: (id: number) => api.scanLibrary(id), + onSuccess: () => { + toast("ok", "扫描已触发"); + setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间 + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), + }); + + const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]); + const booksByKey = useMemo(() => { + const m = new Map(); + for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b); + return m; + }, [allQ.data]); + + const continueRows = (progQ.data ?? []).slice(0, 12); + const libs = libsQ.data ?? []; + + return ( +
+ + + {lib > 0 && ( + + )} + setQRaw(e.target.value)} + /> + + } + /> +
+ {libs.length === 0 && ( +

还没有书库。admin 请到「库管理」建库并扫描。

+ )} +
+ + {libs.map((l) => ( + + ))} +
+ + {continueRows.length > 0 && ( +
+

继续阅读

+
+ {continueRows.map((r) => { + const b = booksByKey.get(`${r.library_id}/${r.path}`); + if (!b) + return ( +
+

{r.title || "已删除的书"}

+

{Math.round(r.percent * 100)}%

+
+ ); + return ( + nav(`/book/${b.id}`)} + onDelete={() => del.mutate(b.id)} + /> + ); + })} +
+
+ )} + + {booksQ.isError && ( +

+ 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "} + +

+ )} + {!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( +

此库暂无书目(放入文件后等待扫描或点「扫描此库」)。

+ )} + {groups.map((g) => ( +
+

{g.dir || "根目录"}

+
+ {g.books.map((b) => ( + nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> + ))} +
+
+ ))} +
+
+ ); +}