From ee676c2d2ee4b5a20dca95112b2be27f8350fba4 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 17:02:02 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20modern=20redesign=20phase-1=20imp?= =?UTF-8?q?lementation=20plan=20=E2=80=94=208=20tasks=20from=20toolchain?= =?UTF-8?q?=20to=20acceptance=20walkthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-08-modern-redesign-phase1.md | 1390 +++++++++++++++++ 1 file changed, 1390 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md diff --git a/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md b/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md new file mode 100644 index 0000000..e2ffb3a --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md @@ -0,0 +1,1390 @@ +# 全站现代化重构 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 消费一致。