Files
book-comic-library/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md
T

62 KiB
Raw Blame History

全站现代化重构 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/<name>)。Task 2+ 全部消费。

  • Step 1: 容器内安装依赖

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 追加:

    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }

frontend/vite.config.ts 的 defineConfig({...}) 顶层追加(与 plugins 平级):

  resolve: { alias: { "@": "/src" } },

frontend/vitest.config.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
{
  "$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" }
}
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • Step 4: 生成 shadcn 原语并提交
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
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 段落不改)

文件新头部:

@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
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<Theme>(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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}

export function useTheme() {
  const v = useContext(Ctx);
  if (!v) throw new Error("useTheme outside ThemeProvider");
  return v;
}
  • Step 3: index.html 防 FOUC + meta:<head> 内、<title> 之前插入:
    <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 容器类替换为:

          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
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

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:

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:

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
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

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
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/* 包进壳:
          <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
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

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
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、继续读解析)

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/选项常量)
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
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 合并为最终文件)
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
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;上传沿用串行 + 快照计数)

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 删除;查询/创建/删除逻辑不变)
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: 清理旧壳
git rm frontend/src/components/TopBar.tsx

icons.tsx 整文件替换为仅含 Reader 所需的:

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
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 / 变更 段顶部插入,英中相邻行)

- 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/)
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 消费一致。