62 KiB
全站现代化重构 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 1Card/Input/Label/Button;Task 2ThemeToggle。 -
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;shadcnButton/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 记录
- 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 抽查)。
- 占位符:T6/T7 内两处"整合要求/占位写法"注记均已给出唯一正确形态与判定命令(tsc noUnusedLocals),非 TBD。
- 类型一致:
SortKey/GroupMode定义于 T3lib/shelf.ts,T6shelfMeta.ts引用SortKey与SORT_LABEL命名一致;sections返回{label,books}与 Shelf 使用一致;useTheme返回值与 T4 ThemeToggle 消费一致。