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

1391 lines
62 KiB
Markdown
Raw Permalink Blame History

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