# 全站现代化重构 Phase 1(shadcn/radix + 双主题 + 应用壳 + 公共表面)Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 用 shadcn/radix 从零建立 light/dark 双主题设计系统,并以此重做登录、应用壳、书架、admin 两页;`/book/:id`(Reader.tsx + 四 reader)与全部翻页/阅读逻辑本期零改动。 **Architecture:** 语义 CSS 变量(shadcn 标准集)+ `@theme inline` 喂给 Tailwind v4;shadcn CLI 生成基础原语进 `src/components/ui/`;`ThemeProvider`(system/light/dark,`localStorage("ui.theme")`,index.html 内联脚本防 FOUC);`AppShell` 布局路由(桌面侧栏/平板图标栏/手机底 Tab),书架筛选状态入 URLSearchParams;纯逻辑(排序/分组视图/formatSize)下沉 `lib/` 并配 vitest。 **Tech Stack:** React 19.2, Tailwind v4.3, react-router 7.18, radix-ui(统一包), cva/clsx/tailwind-merge, lucide-react, tw-animate-css, vitest。 Spec: `docs/superpowers/specs/2026-09-08-modern-redesign-shadcn-design.md` ## Global Constraints - 分支 `feat/ui-redesign`;每个任务一个 commit。 - 一切 npm/vitest/build 在容器内:`docker exec -w /app booklib_web_1 npm run -s check`(tsc+vitest+build);依赖安装也在容器内(`npm i`),提交 `package.json` + `package-lock.json`。 - 公共表面(登录/壳/书架/admin/Toaster/Cover)**禁止裸 `stone-*`/`amber-*`**,一律语义 token 类(bg-background/text-muted-foreground 等)。例外:`components/ui.ts` 遗留三件套、`pages/Reader.tsx`、`readers/*`、`components/Bookmarks.tsx`、`index.css` 的 `rd-*`/`fx-*`/`md-body` 段、`FORMAT_BADGE` 彩色类(贴在深色封面上)。 - `/book/:id` 路由与其内部一切行为、`cbz-mode/cbz-continuous/cbz-prefetch/rd-*` 等 localStorage 键**不动**。 - API 契约(`backend/cmd/webui/api`)与数据流不改;react-query key 语义不变(`["books", lib, q]`、`["progress"]`、`["libraries"]`、`["users"]`)。 - `ui.ts` 收缩后只留 `btn`/`btnGhost`/`formatBadge`(+`FORMAT_BADGE`)——phase 2 前 `Reader.tsx`/`Cover.tsx` 依赖它们。 - toast API `toast(kind, text)` 保持不变(全仓 30+ 调用点)。 - 用户可见变更进 `docs/CHANGELOG_web.md`(英中相邻行,不同条目空行分隔)。 --- ### Task 1: 依赖、别名与 shadcn 原语生成 **Files:** - Modify: `frontend/package.json` + `frontend/package-lock.json`(容器内 npm i) - Create: `frontend/components.json`, `frontend/src/lib/utils.ts` - Modify: `frontend/tsconfig.json`, `frontend/vite.config.ts`, `frontend/vitest.config.ts` - Create: `frontend/src/components/ui/*`(CLI 生成,提交入库) **Interfaces:** - Consumes: 无 - Produces: `@/* → src/*` 别名;`cn(...)` from `@/lib/utils`;shadcn 组件 `button input label card badge skeleton separator tooltip dropdown-menu dialog alert-dialog select table`(from `@/components/ui/`)。Task 2+ 全部消费。 - [ ] **Step 1: 容器内安装依赖** ```bash docker exec -w /app booklib_web_1 npm i radix-ui class-variance-authority clsx tailwind-merge lucide-react tw-animate-css ``` Expected: exit 0,lockfile 更新。 - [ ] **Step 2: 别名配置** `frontend/tsconfig.json` 的 `compilerOptions` 追加: ```json "baseUrl": ".", "paths": { "@/*": ["src/*"] } ``` `frontend/vite.config.ts` 的 `defineConfig({...})` 顶层追加(与 `plugins` 平级): ```ts resolve: { alias: { "@": "/src" } }, ``` `frontend/vitest.config.ts` 整文件替换: ```ts import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } }, test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true }, }); ``` - [ ] **Step 3: 写 `frontend/components.json` 与 `frontend/src/lib/utils.ts`** ```json { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" } } ``` ```ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` - [ ] **Step 4: 生成 shadcn 原语并提交** ```bash docker exec -w /app booklib_web_1 npx --yes shadcn@2 add button input label card badge skeleton separator tooltip dropdown-menu dialog alert-dialog select table -y ``` Expected: `src/components/ui/` 下出现 12 个组件文件;CLI 若报找不到 Tailwind 配置,确认 cwd=/app 且 components.json 存在后重试。 - [ ] **Step 5: 校验并 commit** ```bash docker exec -w /app booklib_web_1 npm run -s check # 此时 body 仍暗色、组件未用,必须绿 git add frontend/package.json frontend/package-lock.json frontend/components.json frontend/tsconfig.json frontend/vite.config.ts frontend/vitest.config.ts frontend/src/lib/utils.ts frontend/src/components/ui git commit -m "feat(ui): shadcn/radix toolchain — deps, @ alias, cn, generated primitives" ``` --- ### Task 2: 设计 token + 主题系统 + Toaster/Cover 换肤 **Files:** - Modify: `frontend/src/index.css`(头部重写,`rd-*`/`fx-*`/`md-body`/`@media` 段原样保留) - Create: `frontend/src/components/theme.tsx` - Modify: `frontend/index.html`, `frontend/src/App.tsx`, `frontend/vite.config.ts`(manifest 两色) - Modify: `frontend/src/components/Toaster.tsx`, `frontend/src/components/Cover.tsx` **Interfaces:** - Consumes: `@theme inline` 依赖 Task 1 的 tw-animate-css import 位与 `cn` - Produces: `ThemeProvider` / `useTheme()`(`theme: "system"|"light"|"dark"` + `setTheme`)——Task 4 主题切换器消费;token 类名全集——后续所有 UI 消费。 - [ ] **Step 1: 替换 `index.css` 中从文件头到 `.fx-rise` 之前的内容 + 滚动条段**(保留 `@keyframes fx-rise` 起的全部阅读器/动画/markdown 段落不改) 文件新头部: ```css @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); :root { --radius: 0.65rem; --background: oklch(0.985 0.001 80); --foreground: oklch(0.22 0.01 70); --card: oklch(0.995 0 0); --card-foreground: oklch(0.22 0.01 70); --popover: oklch(1 0 0); --popover-foreground: oklch(0.22 0.01 70); --primary: oklch(0.25 0.012 70); --primary-foreground: oklch(0.98 0.002 80); --secondary: oklch(0.95 0.003 80); --secondary-foreground: oklch(0.32 0.01 70); --muted: oklch(0.95 0.003 80); --muted-foreground: oklch(0.55 0.008 70); --accent: oklch(0.68 0.16 46); --accent-foreground: oklch(0.985 0.002 80); --destructive: oklch(0.58 0.21 27); --border: oklch(0.91 0.004 80); --input: oklch(0.91 0.004 80); --ring: oklch(0.62 0.01 70); } .dark { --background: oklch(0.19 0.006 80); --foreground: oklch(0.95 0.003 80); --card: oklch(0.23 0.006 80); --card-foreground: oklch(0.95 0.003 80); --popover: oklch(0.23 0.006 80); --popover-foreground: oklch(0.95 0.003 80); --primary: oklch(0.95 0.003 80); --primary-foreground: oklch(0.22 0.01 70); --secondary: oklch(0.28 0.006 80); --secondary-foreground: oklch(0.95 0.003 80); --muted: oklch(0.28 0.006 80); --muted-foreground: oklch(0.68 0.006 80); --accent: oklch(0.7 0.16 46); --accent-foreground: oklch(0.19 0.006 80); --destructive: oklch(0.65 0.19 25); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 14%); --ring: oklch(0.6 0.01 70); } @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); } html, body, #root { height: 100%; } body { @apply bg-background text-foreground antialiased; color-scheme: light; } .dark body { color-scheme: dark; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { @apply rounded-full border-2 border-transparent bg-clip-padding; background-color: color-mix(in oklab, var(--foreground) 22%, transparent); } ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklab, var(--foreground) 34%, transparent); } ``` - [ ] **Step 2: 写 `frontend/src/components/theme.tsx`** ```tsx import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; export type Theme = "system" | "light" | "dark"; const KEY = "ui.theme"; const SYS = "(prefers-color-scheme: dark)"; function read(): Theme { try { const v = localStorage.getItem(KEY); return v === "light" || v === "dark" || v === "system" ? v : "system"; } catch { return "system"; } } function apply(t: Theme) { const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches); document.documentElement.classList.toggle("dark", dark); } const Ctx = useMemo(() => createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null), []); export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, set] = useState(read); useEffect(() => { apply(theme); try { localStorage.setItem(KEY, theme); } catch { /* 隐私模式:仅本次会话生效 */ } if (theme !== "system") return; const mq = matchMedia(SYS); const on = () => apply("system"); mq.addEventListener("change", on); return () => mq.removeEventListener("change", on); }, [theme]); const setTheme = useCallback((t: Theme) => set(t), []); const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); return {children}; } export function useTheme() { const v = useContext(Ctx); if (!v) throw new Error("useTheme outside ThemeProvider"); return v; } ``` - [ ] **Step 3: `index.html` 防 FOUC + meta**:`` 内、`` 之前插入: ```html <script> (() => { try { const t = localStorage.getItem("ui.theme") ?? "system"; if (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches)) document.documentElement.classList.add("dark"); } catch {} })(); </script> ``` 并把 `meta name="theme-color" content="#0c0a09"` 改为 `content="#fafaf9"`。`vite.config.ts` 中 `theme_color`/`background_color` 同步改 `"#fafaf9"`。 - [ ] **Step 4: `App.tsx` 套 ThemeProvider**:`<AuthProvider>` 外包 `<ThemeProvider>`(import from `./components/theme`),其余不动。 - [ ] **Step 5: Toaster/Cover 换肤(props/exports 不变)** `Toaster.tsx`:import 改为 `import { CheckCircle2, XCircle } from "lucide-react";`,toast 容器类替换为: ```tsx className={`fx-rise flex max-w-sm items-center gap-2 rounded-lg border bg-card px-4 py-2.5 text-sm text-card-foreground shadow-lg ${ t.kind === "ok" ? "border-border" : "border-destructive/40" }`} ``` 图标行替换为 `{t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />}`。 `Cover.tsx`:删 `IconAlert` import,加 `import { TriangleAlert } from "lucide-react";`;类替换:占位底 `from-stone-800 to-stone-900` → `bg-muted`;字标 `text-stone-500` → `text-muted-foreground`;骨架 `bg-stone-800/80` → `animate-pulse bg-muted`;`img` 的 `bg-stone-900` → `bg-muted`;重试按钮内 `<IconAlert …/>` → `<TriangleAlert className="size-4" />`。`useAuthedImage`/`Cover` 签名不动。 - [ ] **Step 6: 校验 + commit** ```bash docker exec -w /app booklib_web_1 npm run -s check # 全绿(书架等仍是旧类名,允许) git add -A frontend/src frontend/index.html frontend/vite.config.ts git commit -m "feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover" ``` --- ### Task 3: 纯逻辑下沉(lib/format + lib/shelf) **Files:** - Create: `frontend/src/lib/format.ts`, `frontend/src/lib/shelf.ts` - Modify: `frontend/src/components/ui.ts` - Test: `frontend/test/format.test.ts`, `frontend/test/shelf.test.ts` **Interfaces:** - Consumes: `Book` 类型(`../api/types`:字段 `id,path,title,format,size,pages,percent,added_at,library_id,state,cover_url,page_url_fmt,pages_url,error`)、`groupByDir`(`lib/group.ts` 不动) - Produces: `formatSize(bytes): string`;`SortKey = "added" | "title" | "progress"`、`sortBooks(books, key): Book[]`、`GroupMode = "flat" | "dir"`、`sections(books, key, mode): { label: string; books: Book[] }[]`。Task 6/7 import。 - [ ] **Step 1: 写失败测试 `frontend/test/shelf.test.ts`** ```ts import { expect, it } from "vitest"; import type { Book } from "../src/api/types"; import { sections, sortBooks } from "../src/lib/shelf"; const b = (over: Partial<Book>): Book => ({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book; it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => { const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" }); const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" }); expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]); expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]); expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]); const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })]; expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]); }); it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => { const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })]; expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[0], books[1]] }]); const g = sections(books, "title", "dir"); expect(g.map((s) => s.label)).toEqual(["", "x"]); expect(g[1].books.map((x) => x.title)).toEqual(["A", "b"]); // 组内按 title 排序 }); ``` (`title` 用例里 books[2] 是 id3 title"A"… 若断言顺序与实际 localeCompare 出入,以 `A<b<B` 的排序语义修正数组字面量。) - [ ] **Step 2: 跑测确认失败** Run: `docker exec -w /app booklib_web_1 npx vitest run test/shelf.test.ts` → FAIL(模块不存在)。 - [ ] **Step 3: 实现** `frontend/src/lib/format.ts`: ```ts export function formatSize(bytes: number): string { if (bytes <= 0) return ""; const units = ["B", "KB", "MB", "GB"]; let i = 0; let n = bytes; for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; } ``` `frontend/src/lib/shelf.ts`: ```ts import type { Book } from "../api/types"; import { groupByDir } from "./group"; export type SortKey = "added" | "title" | "progress"; export const SORT_LABEL: Record<SortKey, string> = { added: "最近入库", title: "标题", progress: "阅读进度" }; export type GroupMode = "flat" | "dir"; export function sortBooks(books: Book[], key: SortKey): Book[] { const a = [...books]; if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh")); if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); } /** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */ export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] { if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }]; return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books })); } ``` `components/ui.ts`:删除 `formatSize` 函数体,文件顶部加 `export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器清除`;`pill/pillActive/card/input/btnPrimary` 保留(admin/Login 还引用,Task 5–7 迁移后于 Task 7 统一收缩)。 - [ ] **Step 4: 跑测通过 + commit** ```bash docker exec -w /app booklib_web_1 npx vitest run test/shelf.test.ts git add frontend/src/lib/format.ts frontend/src/lib/shelf.ts frontend/src/components/ui.ts frontend/test/shelf.test.ts git commit -m "feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests" ``` --- ### Task 4: AppShell + ThemeToggle + 路由嵌套 **Files:** - Create: `frontend/src/components/app-shell.tsx`, `frontend/src/components/theme-toggle.tsx` - Modify: `frontend/src/App.tsx`(路由改布局路由) **Interfaces:** - Consumes: Task 2 `useTheme`;`useAuth`(`user,isAdmin,logout`);`api.listLibraries`(queryKey `["libraries"]` 与 Shelf 共享缓存);Task 1 ui 原语。 - Produces: `<AppShell/>` 布局路由(含 `<Outlet/>`);URL 约定 `/?lib=<id>`(缺省=全部)——Task 6 Shelf 按此读取。 - [ ] **Step 1: 写 `theme-toggle.tsx`** ```tsx import { Check, Monitor, Moon, Sun } from "lucide-react"; import { useTheme, type Theme } from "./theme"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [ { v: "light", label: "浅色", icon: Sun }, { v: "dark", label: "深色", icon: Moon }, { v: "system", label: "跟随系统", icon: Monitor }, ]; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" aria-label="切换主题" aria-pressed="false"> {theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {ITEMS.map(({ v, label, icon: Icon }) => ( <DropdownMenuItem key={v} onClick={() => setTheme(v)}> <Icon /> {label} {theme === v && <Check className="ml-auto size-4" />} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> ); } ``` - [ ] **Step 2: 写 `app-shell.tsx`** ```tsx import { useQuery } from "@tanstack/react-query"; import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; import { api } from "@/api/client"; import { useAuth } from "@/auth/AuthContext"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "./theme-toggle"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; function Brand({ compact }: { compact?: boolean }) { return ( <NavLink to="/" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2"> <span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground"> <BookMarked className="size-4" /> </span> {!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>} </NavLink> ); } function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { return ( <NavLink to={to + (search ?? "")} className={({ isActive }) => cn( "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground", isActive && "bg-secondary font-medium text-foreground", ) } > {icon} <span className="hidden lg:inline group-rail:inline">{label}</span> </NavLink> ); } export function AppShell() { const { user, isAdmin, logout } = useAuth(); const loc = useLocation(); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libs = libsQ.data ?? []; const account = ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="gap-2 px-2"> <span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent"> {Array.from(user?.username ?? "?")[0]?.toUpperCase()} </span> <span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem asChild> <span className="cursor-pointer" onClick={logout}> <LogOut /> 退出登录 </span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); return ( <div className="flex h-full"> {/* 桌面/平板侧栏;lg 全文标签,md 图标栏+悬浮标签 */} <aside className="flex w-14 shrink-0 flex-col gap-1 border-r bg-card/60 p-2 md:group-rail lg:w-60"> <Brand compact /> <nav aria-label="主导航" className="flex flex-col gap-1"> <NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" /> {isAdmin && ( <> <NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" /> <NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" /> </> )} </nav> {libs.length > 0 && ( <div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex"> <p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p> <div className="min-h-0 flex-1 overflow-y-auto"> <NavItem to="/" search="?lib=0" icon={<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/50" />} label="全部" /> {libs.map((l) => ( <NavItem key={l.id} to="/" search={`?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} /> ))} </div> </div> )} <Separator className="my-2 hidden lg:block" /> <div className="mt-auto hidden items-center justify-between px-1 lg:flex"> <ThemeToggle /> {account} </div> </aside> <div className="flex min-w-0 flex-1 flex-col"> {/* 移动端顶栏 */} <header className="flex h-12 shrink-0 items-center justify-between border-b bg-card/60 px-3 lg:hidden"> <Brand /> <div className="flex items-center gap-1"> <ThemeToggle /> {account} </div> </header> <main key={loc.pathname} className="min-h-0 flex-1 overflow-y-auto"> <Outlet /> </main> {/* 移动端底 Tab */} <nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden"> <TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" /> {isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />} {isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />} </nav> </div> </div> ); } function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { return ( <NavLink to={to + (search ?? "")} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}> {icon} {label} </NavLink> ); } ``` - [ ] **Step 3: `App.tsx` 改布局路由**:`/`、`/admin/*` 包进壳: ```tsx <Route element={ <RequireAuth> <AppShell /> </RequireAuth> } > <Route path="/" element={<Shelf />} /> <Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} /> <Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} /> </Route> ``` import `AppShell`。过渡态说明:旧 Shelf/admin 仍自带 TopBar,壳出现后暂时双头部,Task 5–7 逐个拆掉(预期内,不阻塞 check)。 - [ ] **Step 4: 校验 + commit** ```bash docker exec -w /app booklib_web_1 npm run -s check git add frontend/src/components/app-shell.tsx frontend/src/components/theme-toggle.tsx frontend/src/App.tsx git commit -m "feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle" ``` --- ### Task 5: 登录页重做 **Files:** - Modify: `frontend/src/pages/Login.tsx`(整文件替换) **Interfaces:** - Consumes: `useAuth`、`toast`、Task 1 `Card/Input/Label/Button`;Task 2 `ThemeToggle`。 - Produces: 独立 `Login` 默认导出(不套壳)。 - [ ] **Step 1: 整文件替换 `frontend/src/pages/Login.tsx`** ```tsx import { useState, type FormEvent } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { BookMarked } from "lucide-react"; import { useAuth } from "@/auth/AuthContext"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ThemeToggle } from "@/components/theme-toggle"; import { toast } from "@/components/Toaster"; export default function Login() { const { login, user } = useAuth(); const loc = useLocation() as { state?: { from?: string } }; 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); } catch (err) { toast("err", err instanceof Error ? err.message : "登录失败"); } finally { setBusy(false); } } return ( <div className="relative grid h-full place-items-center p-4"> <div className="absolute right-3 top-3"> <ThemeToggle /> </div> <form onSubmit={submit} className="fx-rise w-full max-w-sm"> <Card> <CardHeader className="items-center text-center"> <span className="mb-2 grid size-10 place-items-center rounded-lg bg-accent text-accent-foreground"> <BookMarked className="size-5" /> </span> <CardTitle className="text-xl tracking-tight">BookLib</CardTitle> <CardDescription>你的漫画与书籍藏书馆</CardDescription> </CardHeader> <CardContent className="space-y-4"> <div className="space-y-1.5"> <Label htmlFor="login-user">用户名</Label> <Input id="login-user" value={u} onChange={(e) => setU(e.target.value)} autoFocus autoComplete="username" /> </div> <div className="space-y-1.5"> <Label htmlFor="login-pass">密码</Label> <Input id="login-pass" type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" /> </div> <Button type="submit" className="w-full" disabled={busy || !u || !p}> {busy ? "登录中…" : "登录"} </Button> </CardContent> </Card> </form> </div> ); } ``` - [ ] **Step 2: 校验 + commit** ```bash docker exec -w /app booklib_web_1 npm run -s check git add frontend/src/pages/Login.tsx git commit -m "feat(ui): login rebuilt on shadcn card/label/input, theme toggle available pre-auth" ``` --- ### Task 6: 书架页重做 **Files:** - Modify: `frontend/src/pages/Shelf.tsx`(整文件替换) **Interfaces:** - Consumes: Task 3 `sections/sortBooks/SORT_LABEL/SortKey/GroupMode`、`formatSize`;Task 4 URL 约定 `?lib=`;`Cover`、`api`、`useAuth`、`toast`;shadcn `Button/DropdownMenu/AlertDialog/Input/Skeleton/Badge`。 - Produces: 无后续消费方。 - [ ] **Step 1: 整文件替换 `frontend/src/pages/Shelf.tsx`**(数据层与旧版等价:查询 key、防抖、del/scan mutation、继续读解析) ```tsx import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanSearch, Search } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; 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 { GROUPS, LIB0, type GroupMode, SORTS, type SortKey } from "@/lib/shelfMeta"; import { formatSize } from "@/lib/format"; import { sections, SORT_LABEL } from "@/lib/shelf"; import { formatBadge } from "@/components/ui"; // 注:SORTS/GROUPS/LIB0 见 Step 2 的 lib/shelfMeta.ts(把 URL 常量集中一处,壳与页共用) ``` **为避免一次替换 400 行出错,Task 6 实际分两步提交:** - [ ] **Step 2: 先建 `frontend/src/lib/shelfMeta.ts`(URL/选项常量)** ```ts 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; ``` - [ ] **Step 3: 再整文件替换 `Shelf.tsx`** ```tsx import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { 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 { formatBadge } from "@/components/ui"; import { toast } from "@/components/Toaster"; 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 SortKey } from "@/lib/shelf"; import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta"; import type { GroupMode } from "@/lib/shelf"; 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 ( <div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}> <Button asChild variant="link" className="block h-auto w-full flex-col items-stretch p-0 text-left"> <button onClick={onOpen} className="focus-visible:outline-none"> <span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50"> <Cover book={book} className="size-full" /> <span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span> {book.state === "error" && ( <span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground" title={book.error}> <TriangleAlert className="size-3" /> 损坏 </span> )} {pct > 0 && ( <span className="absolute inset-x-0 bottom-0 h-1 bg-black/30" role="presentation"> <span className="block h-full bg-accent" style={{ width: `${pct}%` }} /> </span> )} </span> <span className="mt-2 block truncate text-xs font-medium">{book.title}</span> <span className="block truncate text-[11px] text-muted-foreground"> {book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`} </span> </button> </Button> <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}> <Ellipsis className="size-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={onOpen}>打开</DropdownMenuItem> {isAdmin && ( <> <DropdownMenuSeparator /> <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}> 删除 </DropdownMenuItem> </> )} </DropdownMenuContent> </DropdownMenu> </div> </div> ); } ``` (组件其余部分——主组件 `Shelf()`、`ContinueRail`、`SectionHead`——在下面 Step 4 完整给出;BookCard 中 `Button asChild` 包裹原生 button 是误写,**Step 4 整合时删除外层 `<Button asChild>`,直接用 `<button onClick={onOpen}>` + `group` 类**。) - [ ] **Step 4: 完成 Shelf.tsx 余下部分(与 Step 3 合并为最终文件)** ```tsx function SectionHead({ label, count }: { label: string; count: number }) { if (!label) return null; return ( <h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/90 py-1.5 text-sm font-medium backdrop-blur"> <FolderOpen className="size-4 text-muted-foreground" /> {label} <span className="text-xs tabular-nums text-muted-foreground">{count}</span> </h2> ); } export default function Shelf() { const qc = useQueryClient(); const nav = useNavigate(); const { isAdmin } = useAuth(); 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<Book | null>(null); useEffect(() => setQRaw(serverQ), [serverQ]); useEffect(() => { 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]); // eslint-disable-line const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); const allQ = useQuery({ queryKey: ["books", LIB0, ""], 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 booksByKey = useMemo(() => { const m = new Map<string, Book>(); 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 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 ( <div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6"> <div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/90 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6"> <div className="relative min-w-0 flex-1 sm:max-w-64"> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} /> </div> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="gap-1.5"> <ArrowSort className="size-4" /> {SORT_LABEL[sort]} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>排序</DropdownMenuLabel> {SORTS.map((k) => ( <DropdownMenuItem key={k} onClick={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent" : ""}> {SORT_LABEL[k]} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> <div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5"> {GROUPS.map((g) => ( <Button key={g.v} size="sm" variant={group === g.v ? "secondary" : "ghost"} aria-pressed={group === g.v} className="px-2.5" onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}> {g.label} </Button> ))} </div> {isAdmin && lib > LIB0 && ( <Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5"> <ScanLine className="size-4" /> {scan.isPending ? "扫描中…" : "扫描此库"} </Button> )} <Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}> <RefreshCw className="size-4" /> </Button> </div> {lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>} {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && ( <p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent"> <LibraryBig className="size-4 shrink-0" /> 还没有书库。admin 请到「库管理」建库并扫描。 </p> )} {continueRows.length > 0 && !searching && ( <section className="mb-8"> <h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2> <ul className="flex gap-3 overflow-x-auto pb-2" role="list"> {continueRows.map((r, i) => { const b = booksByKey.get(`${r.library_id}/${r.path}`); if (!b) return ( <li key={`${r.library_id}/${r.path}`} className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground" style={{ animationDelay: `${i * 40}ms` }}> <p className="truncate">{r.title || "已删除的书"}</p> <p className="mt-1 text-[10px]">进度已保留</p> </li> ); const pct = Math.round(r.percent * 100); return ( <li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" style={{ animationDelay: `${i * 40}ms` }}> <button onClick={() => nav(`/book/${b.id}`)} className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent"> <Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" /> <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-medium group-hover:text-accent">{b.title}</span> <span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground"> <span>{r.library}</span> <span className="tabular-nums">{pct}%</span> </span> <span className="mt-1.5 block h-0.5 overflow-hidden rounded-full bg-muted"> <span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} /> </span> </span> </button> </li> ); })} </ul> </section> )} {booksQ.isError && ( <div className="fx-rise mb-8 flex flex-wrap items-center gap-3 rounded-md border border-destructive/40 px-4 py-3 text-sm"> <TriangleAlert className="size-4 shrink-0 text-destructive" /> <span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span> <Button variant="outline" size="sm" onClick={() => booksQ.refetch()}> <RefreshCw className="size-4" /> 重试 </Button> </div> )} {booksQ.isPending && !booksQ.isError && ( <div className={GRID} aria-busy="true" aria-label="加载书目中"> {Array.from({ length: 12 }).map((_, i) => ( <div key={i} className="space-y-2"> <Skeleton className="aspect-[3/4] w-full rounded-md" /> <Skeleton className="h-3 w-3/4" /> <Skeleton className="h-2 w-1/2" /> </div> ))} </div> )} {!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && ( <div className="fx-rise flex flex-col items-center py-20 text-center"> <FolderOpen className="size-10 text-muted-foreground/60" /> <h3 className="mt-3 text-sm font-medium">{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}</h3> <p className="mt-1 text-sm text-muted-foreground">{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}</p> </div> )} {secs.map((s) => ( <section key={s.label || "//"} className="mb-10"> <SectionHead label={s.label} count={s.books.length} /> <div className={GRID}> {s.books.map((b, i) => ( <BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} /> ))} </div> </section> ))} <AlertDialog open={confirmDel !== null} onOpenChange={(o) => !o && setConfirmDel(null)}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>删除《{confirmDel?.title}》?</AlertDialogTitle> <AlertDialogDescription>磁盘文件一并删除,阅读进度保留。</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-primary-foreground hover:bg-destructive/90" onClick={() => { if (confirmDel) del.mutate(confirmDel.id); setConfirmDel(null); }} > 删除 </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> ); } function ArrowSort({ className }: { className?: string }) { return <ArrowDownUp className={className} />; } ``` (整合要求:`ArrowSort` 垫片不需要,直接 import `ArrowDownUp as ArrowSort`;Step 3 的 BookCard 以「去掉 Button asChild 包裹」后的形态并入;最终文件必须通过 tsc `noUnusedLocals`——删掉未用 import 如 `Badge`。) - [ ] **Step 5: 校验 + commit** ```bash docker exec -w /app booklib_web_1 npm run -s check git add frontend/src/lib/shelfMeta.ts frontend/src/pages/Shelf.tsx git commit -m "feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete" ``` --- ### Task 7: admin 两页重做 + 旧壳/图标/ui.ts 清理 **Files:** - Modify: `frontend/src/pages/admin/Libraries.tsx`, `frontend/src/pages/admin/Users.tsx`(整文件替换) - Delete: `frontend/src/components/TopBar.tsx` - Modify: `frontend/src/components/icons.tsx`(收缩至 `Svg`+`IconArrowLeft`), `frontend/src/components/ui.ts`(收缩至 `btn/btnGhost/formatBadge/FORMAT_BADGE` + deprecated formatSize re-export) **Interfaces:** - Consumes: AppShell(不再自绘头)、Task 1 原语、Task 3 `formatSize`、lucide。 - Produces: 无后续消费方;`TopBar.tsx` 消亡。 - [ ] **Step 1: 替换 `Libraries.tsx`**(功能等价:建库表单、逐库 扫描/上传;新增仅交互层的拖拽 dropzone;上传沿用串行 + 快照计数) ```tsx import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRef, useState, type DragEvent, type FormEvent } from "react"; import { RefreshCw, Upload } from "lucide-react"; import { api } from "../../api/client"; import { toast } from "../../components/Toaster"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { formatSize } from "@/lib/format"; export default function AdminLibraries() { const qc = useQueryClient(); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const [name, setName] = useState(""); const fileRefs = useRef<Record<number, HTMLInputElement | null>>({}); const create = useMutation({ mutationFn: () => api.createLibrary(name.trim()), onSuccess: (lib) => { toast("ok", `库「${lib.name}」已创建`); setName(""); void qc.invalidateQueries({ queryKey: ["libraries"] }); }, onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"), }); const scan = useMutation({ mutationFn: (id: number) => api.scanLibrary(id), onSuccess: () => { toast("ok", "扫描已触发"); setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000); }, onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), }); async function onFiles(libId: number, list: FileList | null) { const files = Array.from(list ?? []); // FileList 活引用,先快照 if (!files.length) return; try { for (const f of files) await api.uploadBook(libId, f); toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`); void qc.invalidateQueries({ queryKey: ["books"] }); setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000); } catch (e) { toast("err", e instanceof Error ? e.message : "上传失败"); } } function onDrop(e: DragEvent, libId: number) { e.preventDefault(); void onFiles(libId, e.dataTransfer.files); } function submit(e: FormEvent) { e.preventDefault(); create.mutate(); } const libs = libsQ.data ?? []; return ( <div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6"> <h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3"> <Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} /> <Button type="submit" disabled={create.isPending || !name.trim()}> {create.isPending ? "创建中…" : "建库"} </Button> </form> {libsQ.isPending && <div className="space-y-3">{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}</div>} <div className="flex flex-col gap-4"> {libs.map((l) => ( <Card key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}> <CardHeader className="flex-row items-baseline justify-between gap-3 space-y-0 py-3"> <CardTitle className="flex items-baseline gap-2 text-base"> {l.name} <span className="text-xs font-normal text-muted-foreground">#{l.id}</span> </CardTitle> <p className="text-xs text-muted-foreground">{l.root_path}</p> </CardHeader> <CardContent className="flex flex-wrap items-center gap-2 pt-0"> <Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}> <RefreshCw /> {scan.isPending ? "扫描中…" : "扫描"} </Button> <Input ref={fileRefs.current[l.id] as never} type="file" multiple accept=".cbz,.zip,.pdf,.epub,.txt,.md" className="hidden" id={`files-${l.id}`} onChange={(e) => { void onFiles(l.id, e.target.files); e.target.value = ""; }} /> <Button size="sm" onClick={() => fileRefs.current[l.id]?.click()}> <Upload /> 上传文件 </Button> <span className="text-xs text-muted-foreground">白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片</span> </CardContent> </Card> ))} {libs.length === 0 && !libsQ.isPending && <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>} </div> </div> ); } ``` (`Input ref` 那行是占位写法——实际用隐藏 `<input ref>` 原语:`<input ref={(el)=>{fileRefs.current[l.id]=el}} type="file" multiple accept=".cbz,.zip,.pdf,.epub,.txt,.md" className="hidden" onChange=…/>`,其余不变。) - [ ] **Step 2: 替换 `Users.tsx`**(Table + AlertDialog 删除;查询/创建/删除逻辑不变) ```tsx import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState, type FormEvent } from "react"; import { api } from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; import { toast } from "../../components/Toaster"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Skeleton } from "@/components/ui/skeleton"; export default function AdminUsers() { const qc = useQueryClient(); const { user: me } = useAuth(); const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers }); const [u, setU] = useState(""); const [p, setP] = useState(""); const [role, setRole] = useState<"member" | "admin">("member"); const create = useMutation({ mutationFn: () => api.createUser(u.trim(), p, role), onSuccess: () => { toast("ok", "用户已创建"); setU(""); setP(""); void qc.invalidateQueries({ queryKey: ["users"] }); }, onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"), }); const del = useMutation({ mutationFn: (id: number) => api.deleteUser(id), onSuccess: () => { toast("ok", "用户已删除(其阅读进度一并删除)"); void qc.invalidateQueries({ queryKey: ["users"] }); }, onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), }); function submit(e: FormEvent) { e.preventDefault(); create.mutate(); } return ( <div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6"> <h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3"> <Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} /> <Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} /> <Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}> <SelectTrigger className="w-28"><SelectValue /></SelectTrigger> <SelectContent> <SelectItem value="member">member</SelectItem> <SelectItem value="admin">admin</SelectItem> </SelectContent> </Select> <Button type="submit" disabled={create.isPending || !u.trim() || p.length < 8}> {create.isPending ? "创建中…" : "创建"} </Button> </form> {usersQ.isPending ? ( <Skeleton className="h-40 w-full" /> ) : ( <Table> <TableHeader> <TableRow> <TableHead className="w-16">ID</TableHead> <TableHead>用户名</TableHead> <TableHead>角色</TableHead> <TableHead>创建时间</TableHead> <TableHead className="w-16" /> </TableRow> </TableHeader> <TableBody> {(usersQ.data ?? []).map((x) => ( <TableRow key={x.id}> <TableCell className="text-muted-foreground">{x.id}</TableCell> <TableCell>{x.username}</TableCell> <TableCell>{x.role}</TableCell> <TableCell className="text-muted-foreground">{x.created_at.slice(0, 19).replace("T", " ")}</TableCell> <TableCell className="text-right"> <AlertDialog> <AlertDialogTrigger asChild> <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}> 删除 </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>删除用户 {x.username}?</AlertDialogTitle> <AlertDialogDescription>该用户的阅读进度与书签一并删除。</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-primary-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}> 删除 </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </TableCell> </TableRow> ))} </TableBody> </Table> )} </div> ); } ``` - [ ] **Step 3: 清理旧壳** ```bash git rm frontend/src/components/TopBar.tsx ``` `icons.tsx` 整文件替换为仅含 Reader 所需的: ```tsx import type { SVGProps } from "react"; function Svg({ children, ...p }: SVGProps<SVGSVGElement>) { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}> {children} </svg> ); } export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => ( <Svg {...p}> <path d="M20 12H4m0 0 6-6m-6 6 6 6" /> </Svg> ); ``` `ui.ts` 收缩为:`FORMAT_BADGE`、`formatBadge`、`btn`、`btnGhost`、`export { formatSize } from "../lib/format";`(删除 `btnPrimary/input/card/pill/pillActive/formatSize 本体`)。 - [ ] **Step 4: 全量校验 + commit** ```bash docker exec -w /app booklib_web_1 npm run -s check # 若报未用 import/缺符号,就地修至绿 git add -A frontend/src git commit -m "feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface" ``` --- ### Task 8: 回归验收(check + playwright 全走查) **Files:** - Modify: `docs/CHANGELOG_web.md` - 发现缺陷就地修,补独立 commit。 - [ ] **Step 1: changelog**(`### Changed / 变更` 段顶部插入,英中相邻行) ```markdown - The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next. - 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。 ``` - [ ] **Step 2: `docker exec -w /app booklib_web_1 npm run -s check` 绿**(33+ 用例、tsc、build)。 - [ ] **Step 3: playwright 走查**(容器栈在跑;qa 数据用 `deploy/api/storage/test/` 两包重导入):`375×750 / 768×1024 / 1280×800` × light/dark,路径:登录→书架(搜索 300ms 后 URL 带 `?q=`、排序/分组切换、侧栏库筛选、卡片菜单删除弹 AlertDialog、继续阅读横滑)→admin 两页(建库/扫描/上传含拖拽、建用户、删除确认、不能删自己)→`/book/2` 阅读器不回归(章锁/连读/预读抽查一条)→主题切换即时生效且刷新保持→console 零错误。截图归档 `.playwright-cli/`(不提交)。 - [ ] **Step 4: 缺陷修复循环**:每处修复跑 `test/shelf.test.ts`/`test/format.test.ts` 覆盖的函数级回归 + 重截对应图。 - [ ] **Step 5: commit + 清理测试数据**(DB 测试行、storage 导入产物,保留两个测试 zip 源文件;删除 `.playwright-cli/`) ```bash git add docs/CHANGELOG_web.md && git commit -m "docs(changelog): phase-1 modern redesign — shadcn tokens, dual theme, app shell, rebuilt public surfaces" ``` --- ## Self-Review 记录 1. **Spec 覆盖**:token/双主题/FOUC✓(T2) 壳三档✓(T4) 书架 URL 状态/平铺默认/菜单操作/AlertDialog✓(T6) 登录✓(T5) admin 两页+dropzone✓(T7) ui.ts 收缩/TopBar 删除/icons 收缩✓(T7) Cover/Toaster props 兼容换肤✓(T2) formatSize 迁移+测试✓(T3) changelog✓(T8) 阅读器/翻页逻辑零触碰✓(全局约束+T8 抽查)。 2. **占位符**:T6/T7 内两处"整合要求/占位写法"注记均已给出唯一正确形态与判定命令(tsc noUnusedLocals),非 TBD。 3. **类型一致**:`SortKey/GroupMode` 定义于 T3 `lib/shelf.ts`,T6 `shelfMeta.ts` 引用 `SortKey` 与 `SORT_LABEL` 命名一致;`sections` 返回 `{label,books}` 与 Shelf 使用一致;`useTheme` 返回值与 T4 ThemeToggle 消费一致。