1391 lines
62 KiB
Markdown
1391 lines
62 KiB
Markdown
# 全站现代化重构 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 消费一致。
|