From b1c0db6c03d33156ecb5e2692c8a9724eeb0976f Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 18:58:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20shelf=20rebuilt=20=E2=80=94=20stick?= =?UTF-8?q?y=20toolbar=20(search/sort/group=20in=20URL),=20rail,=20menu=20?= =?UTF-8?q?cards,=20alert-dialog=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/shelfMeta.ts | 8 + frontend/src/pages/Shelf.tsx | 435 ++++++++++++++++++---------------- 2 files changed, 233 insertions(+), 210 deletions(-) create mode 100644 frontend/src/lib/shelfMeta.ts diff --git a/frontend/src/lib/shelfMeta.ts b/frontend/src/lib/shelfMeta.ts new file mode 100644 index 0000000..567a676 --- /dev/null +++ b/frontend/src/lib/shelfMeta.ts @@ -0,0 +1,8 @@ +import type { GroupMode, SortKey } from "./shelf"; + +export const SORTS: SortKey[] = ["added", "title", "progress"]; +export const GROUPS: { v: GroupMode; label: string }[] = [ + { v: "flat", label: "平铺" }, + { v: "dir", label: "按目录" }, +]; +export const LIB0 = 0; diff --git a/frontend/src/pages/Shelf.tsx b/frontend/src/pages/Shelf.tsx index c28fd21..764db60 100644 --- a/frontend/src/pages/Shelf.tsx +++ b/frontend/src/pages/Shelf.tsx @@ -1,88 +1,84 @@ 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 { IconAlert, IconBookOpen, IconClock, IconFolder, IconRefresh, IconScan, IconSearch, IconX } from "../components/icons"; -import { TopBar } from "../components/TopBar"; -import { toast } from "../components/Toaster"; -import { btn, card, formatBadge, formatSize, input, pill, pillActive } from "../components/ui"; -import { groupByDir } from "../lib/group"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react"; +import { api } from "@/api/client"; +import type { Book } from "@/api/types"; +import { useAuth } from "@/auth/AuthContext"; +import { Cover } from "@/components/Cover"; +import { toast } from "@/components/Toaster"; +import { formatBadge } from "@/components/ui"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatSize } from "@/lib/format"; +import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf"; +import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta"; -function BookCard({ - book, - index, - onOpen, - onDelete, -}: { - book: Book; - index: number; - onOpen: () => void; - onDelete: () => void; -}) { +const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]"; + +function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) { const { isAdmin } = useAuth(); const pct = Math.round(book.percent * 100); return ( -
-
-

- {book.title} -

-

{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : "根目录"}

+ + {book.title} + + {book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`} + - {isAdmin && ( - - )} +
+ + + + + + 打开 + {isAdmin && ( + <> + + + 删除 + + + )} + + +
); } -const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9rem,1fr))]"; - -function Section({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) { +function SectionHead({ label, count }: { label: string; count: number }) { + if (!label) return null; return ( -
-

- {icon} - {title} -

- {children} -
+

+ + {label} + {count} +

); } @@ -90,23 +86,30 @@ export default function Shelf() { const qc = useQueryClient(); const nav = useNavigate(); const { isAdmin } = useAuth(); - const [lib, setLib] = useState(0); // 0 = 全部库 - const [qRaw, setQRaw] = useState(""); - const [q, setQ] = useState(""); + const [sp, setSp] = useSearchParams(); + const lib = Number(sp.get("lib")) || LIB0; + const sort = (SORTS.includes(sp.get("sort") as SortKey) ? (sp.get("sort") as SortKey) : "added") satisfies SortKey; + const group: GroupMode = sp.get("group") === "dir" ? "dir" : "flat"; + const serverQ = (sp.get("q") ?? "").trim(); + const [qRaw, setQRaw] = useState(serverQ); + const [confirmDel, setConfirmDel] = useState(null); + useEffect(() => setQRaw(serverQ), [serverQ]); useEffect(() => { - const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖 + if (qRaw.trim() === serverQ) return; + const t = setTimeout(() => { + const next = new URLSearchParams(sp); + if (qRaw.trim()) next.set("q", qRaw.trim()); + else next.delete("q"); + setSp(next, { replace: true }); + }, 300); return () => clearTimeout(t); - }, [qRaw]); + }, [qRaw]); // eslint-disable-line const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); - const booksQ = useQuery({ - queryKey: ["books", lib, q], - queryFn: () => api.listBooks({ library: lib || undefined, q }), - }); + const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); - // 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存) - const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() }); + const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() }); const del = useMutation({ mutationFn: (id: number) => api.deleteBook(id), @@ -120,162 +123,174 @@ export default function Shelf() { mutationFn: (id: number) => api.scanLibrary(id), onSuccess: () => { toast("ok", "扫描已触发"); - setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间 + 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 secs = useMemo(() => sections(booksQ.data ?? [], sort, group), [booksQ.data, sort, group]); + const searching = serverQ.length > 0; const continueRows = (progQ.data ?? []).slice(0, 12); - const libs = libsQ.data ?? []; - const searching = q.length > 0; + const setParam = (k: string, v: string) => { + const next = new URLSearchParams(sp); + if (v) next.set(k, v); + else next.delete(k); + setSp(next, { replace: true }); + }; + const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库"; return ( -
- -
- - setQRaw(e.target.value)} - /> -
- {isAdmin && lib > 0 && ( - - )} - - - } - /> -
- {libs.length === 0 && !libsQ.isPending && ( -

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

- )} - {(libs.length > 0 || libsQ.isPending) && ( - + + +
+ {GROUPS.map((g) => ( + + ))} +
+ {isAdmin && lib > LIB0 && ( + )} + +
- {continueRows.length > 0 && !searching && ( -
}> -
    - {continueRows.map((r, i) => { - const b = booksByKey.get(`${r.library_id}/${r.path}`); - if (!b) - return ( -
  • -

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

    -

    #{r.library_id}/{r.path} · 进度已保留

    -
  • - ); - const pct = Math.round(r.percent * 100); + {lib === LIB0 &&

    {`当前筛选:${libName}`}

    } + + {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && ( +

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

    + )} + + {continueRows.length > 0 && !searching && ( +
    +

    继续阅读

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

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

      +

      进度已保留

    • ); - })} -
    -
    - )} + const pct = Math.round(r.percent * 100); + return ( +
  • + +
  • + ); + })} +
+
+ )} - {booksQ.isError && ( -
- - 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"} - -
- )} + {booksQ.isError && ( +
+ + 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"} + +
+ )} - {booksQ.isPending && !booksQ.isError && ( -
- {Array.from({ length: 12 }).map((_, i) => ( -
-
-
-
-
+ {booksQ.isPending && !booksQ.isError && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ + + +
+ ))} +
+ )} + + {!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && ( +
+ +

{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}

+

{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}

+
+ )} + + {secs.map((s) => ( +
+ +
+ {s.books.map((b, i) => ( + nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} /> ))}
- )} +
+ ))} - {!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( -
- -

{searching ? `没有匹配「${q}」的书` : "此库暂无书目"}

-

- {searching ? "换个关键词试试。" : "放入文件后等待扫描,或点上方「扫描此库」。"} -

-
- )} - - {groups.map((g) => ( -
} - > -
- {g.books.map((b, i) => ( - nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> - ))} -
-
- ))} -
+ !o && setConfirmDel(null)}> + + + 删除《{confirmDel?.title}》? + 磁盘文件一并删除,阅读进度保留。 + + + 取消 + { + if (confirmDel) del.mutate(confirmDel.id); + setConfirmDel(null); + }} + > + 删除 + + + +
); }