Compare commits

44 Commits
Author SHA1 Message Date
XingfenD ef57b194f4 chore(frontend): tighten eslint/axe gates, remove reader waivers (B5 done, spec ② complete)
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 19:27:25 +08:00
XingfenD 395be9688d refactor(frontend): remove ui.ts/icons.tsx and all rd-* legacy classes (B5) 2026-09-16 17:53:51 +08:00
XingfenD 28d8587e71 feat(frontend): shelf reading stats card + session tracking (B4) 2026-09-16 17:49:12 +08:00
XingfenD 4767ee7fec refactor(frontend): PDF reader chrome to shadcn (B4) 2026-09-16 17:43:51 +08:00
XingfenD 8831a9b277 feat(frontend): EPUB typography themes + chrome migration (B3) 2026-09-16 17:41:35 +08:00
XingfenD 272d0bc672 test(frontend): text search e2e with txt fixture (B2) 2026-09-16 17:37:50 +08:00
XingfenD 1004daf821 feat(frontend): text reader search + typography, chrome migration (B2) 2026-09-16 16:58:39 +08:00
XingfenD 74e5e9d5fe feat(frontend): in-book text search core (TDD) 2026-09-16 16:51:36 +08:00
XingfenD 0d7cd5f322 chore(frontend): remove debug probe artifact 2026-09-16 16:50:59 +08:00
XingfenD 174c498757 test(frontend): CBZ page-mode component test + e2e mode/RTL smoke (B1) 2026-09-16 16:50:55 +08:00
XingfenD 26aafb3f53 feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1) 2026-09-16 16:20:08 +08:00
XingfenD cea2414371 refactor(frontend): shared reader chrome to shadcn/radix (header, nav Sheet, bookmarks) 2026-09-16 15:46:07 +08:00
XingfenD 1f9d7e7572 feat(frontend): cbz page/spread view math (TDD) 2026-09-16 15:22:59 +08:00
XingfenD 996f492083 feat(frontend): reading stats store (TDD) + session heartbeat hook (B0) 2026-09-16 15:22:38 +08:00
XingfenD 6aefe62a9f feat(frontend): unified reader settings bar with theme linkage (B0) 2026-09-16 15:20:18 +08:00
XingfenD 8283d66db6 feat(frontend): shadcn sheet/tabs/slider primitives (B0) 2026-09-16 15:17:26 +08:00
XingfenD 655b77c2b7 feat(frontend): reading theme token group + useGlobalDark for auto linkage (B0) 2026-09-16 15:15:53 +08:00
XingfenD 64cc293878 feat(frontend): readerPrefs v2 unified structure (TDD) 2026-09-16 15:15:08 +08:00
XingfenD 72cf13c8c3 chore(frontend): extend tsc coverage to e2e/configs, tighten vitest include (B0) 2026-09-16 15:13:35 +08:00
XingfenD 154ef11c90 docs: reader revamp implementation plan (18 tasks, 6 batches) 2026-09-16 15:10:02 +08:00
XingfenD 043b7fc480 docs: reader revamp spec (subproject ②) 2026-09-16 14:53:59 +08:00
XingfenD cc18822c97 Merge remote-tracking branch 'origin/feat/frontend-quality'
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 12:16:40 +08:00
XingfenD 2fccf3f2b3 docs: record TS side-by-side + legacy-peer-deps conventions (AGENTS.md + .npmrc)
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 11:43:21 +08:00
XingfenD caacaf9c30 docs: changelog for component testing + bundle review (B4 done, spec ④ complete) 2026-09-16 11:28:12 +08:00
XingfenD 5ba522753a docs: README frontend gates (lint/e2e/analyze), bilingual 2026-09-16 11:21:35 +08:00
XingfenD 8f4c34c949 docs: bundle-review — align dependency sizes to source-level caliber, rewrite epubjs finding 2026-09-16 11:18:44 +08:00
XingfenD 0bf8129f6e docs: bundle review findings (B4) 2026-09-16 11:06:58 +08:00
XingfenD f5c33f538e chore(frontend): bundle analyzer via npm run analyze (B4) 2026-09-16 11:00:22 +08:00
XingfenD cd02a88b59 ci(e2e): render configs from templates before compose up 2026-09-16 10:56:47 +08:00
XingfenD 4ba55fcf2a ci: e2e workflow (workflow_dispatch) + changelog (B3 done) 2026-09-16 10:53:23 +08:00
XingfenD f2f06a516b test(frontend): e2e admin smoke (create library + scan) 2026-09-16 10:48:23 +08:00
XingfenD 9c6339d35e test(frontend): e2e main flow (login→shelf→cbz→bookmark→logout) + axe scans 2026-09-15 17:29:20 +08:00
XingfenD 217adf5914 test(frontend): e2e api/axe helpers 2026-09-15 14:13:58 +08:00
XingfenD ba28fe9037 test(frontend): tiny 3-page CBZ fixture for e2e 2026-09-15 14:11:24 +08:00
XingfenD b65ea93984 test(frontend): playwright e2e scaffolding (config + scripts) 2026-09-15 12:00:45 +08:00
XingfenD a3755bcfab test(frontend): sample component tests for button/dialog/theme-toggle (B2) 2026-09-15 11:54:56 +08:00
XingfenD 38957b4c2c fix(frontend): install @testing-library/dom (missing peer under legacy-peer-deps) 2026-09-15 11:50:44 +08:00
XingfenD ca419410b4 test(frontend): component testing infra (jsdom + testing-library + vitest projects) 2026-09-15 11:46:20 +08:00
XingfenD 147339083b chore(frontend): wire lint+format into check gate, changelog (B1 done) 2026-09-15 11:44:21 +08:00
XingfenD a427aa1c4a fix(frontend): resolve all eslint errors to zero (B1) 2026-09-15 11:39:21 +08:00
XingfenD 29167d3cea style(frontend): prettier baseline format (no logic change) 2026-09-15 11:27:54 +08:00
XingfenD 09a02fa0d7 chore(frontend): eslint flat config + prettier baseline config 2026-09-15 11:22:02 +08:00
XingfenD 24c5d42531 docs: frontend quality implementation plan (16 tasks, 4 batches) 2026-09-15 11:12:38 +08:00
XingfenD 42a39ceef8 docs: frontend engineering quality spec (subproject ④) 2026-09-15 10:59:43 +08:00
93 changed files with 9027 additions and 1182 deletions
+41
View File
@@ -0,0 +1,41 @@
name: e2e
on: workflow_dispatch
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Render configs
working-directory: deploy
run: ./prepare.sh
- name: Start dev stack
working-directory: deploy
env:
JWT_SECRET: ci-e2e-only-secret
ADMIN_USER: admin
ADMIN_PASSWORD: ci-e2e-password
run: docker compose -f docker-compose.dev.yml up -d --build
- name: Wait for web
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost:5173/ >/dev/null 2>&1; then exit 0; fi
sleep 5
done
echo "web not up in 450s"; docker compose -f deploy/docker-compose.dev.yml logs --tail 50; exit 1
- run: cd frontend && npm ci
- run: cd frontend && npx playwright install --with-deps chromium
- name: Run e2e
working-directory: frontend
env:
E2E_ADMIN_USER: admin
E2E_ADMIN_PASSWORD: ci-e2e-password
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report
+3
View File
@@ -3,3 +3,6 @@
tasks/
.playwright*/
frontend/tsconfig.tsbuildinfo
playwright-report/
test-results/
dist-stats/
+1
View File
@@ -28,6 +28,7 @@ Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node
- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app.
- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
- Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
- TypeScript runs side-by-side (`frontend/package.json`): `typescript` = TS 6.0.2 JS API for tooling (typescript-eslint doesn't support TS 7 yet), `@typescript/native` = native 7.0.2 providing `tsc`. Do not re-alias `typescript` to 7.x until typescript-eslint ships TS 7 support. Plain `npm install` needs `--legacy-peer-deps` (also pinned in `frontend/.npmrc`); `npm ci` is unaffected.
### deploy
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
+38 -7
View File
@@ -8,8 +8,40 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
## [Unreleased]
### Changed / 变更
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
### Added / 新增
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support.
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card.
- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
- E2E test suite (Playwright + axe-core) covering login→shelf→CBZ reading→bookmark→logout and an admin create-library/scan smoke, run against the dev compose stack via `npm run e2e` (manual pre-release gate); critical/serious a11y violations fail the run.
- 新增 e2e 测试套件(Playwright + axe-core):覆盖 登录→书架→CBZ 阅读→书签→退出 主流程与 admin 建库/扫描冒烟,针对 dev compose 栈以 `npm run e2e` 手动门禁运行;critical/serious 级 a11y 违规会使测试失败。
- Developer tooling: ESLint (flat config, typescript-eslint + react-hooks + jsx-a11y) and Prettier are now part of `npm run check` and CI; the whole codebase passes with zero eslint errors.
- 开发工具链:ESLint(flat config,typescript-eslint + react-hooks + jsx-a11y)与 Prettier 纳入 `npm run check` 和 CI;全库 0 eslint error。
- Lint toolchain note: TypeScript runs side-by-side (`typescript` = TS 6.0.2 JS API for tooling, `@typescript/native` = native 7.0.2 for `tsc`) because typescript-eslint does not support TS 7 yet; do not alias `typescript` to 7.x until typescript-eslint ships TS 7 support.
- 工具链备注:TypeScript 双轨并存(`typescript` = TS 6.0.2 JS API 供 lint 工具链,`@typescript/native` = native 7.0.2 提供 `tsc`),因 typescript-eslint 暂不支持 TS 7;待其支持前勿把 `typescript` 别名改回 7.x。
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
@@ -51,6 +83,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
### Removed / 移除
- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading.
@@ -58,6 +93,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Fixed / 修复
- Accessibility fixes: login screen muted text meets WCAG AA contrast in light theme (muted-foreground token darkened); the CBZ reading pane is now keyboard-scrollable (scroll container is focusable).
- 无障碍修复:登录页弱化文本在亮色主题下达到 WCAG AA 对比度(muted-foreground 色值加深);CBZ 阅读面板现在可用键盘滚动(滚动容器可聚焦)。
- CBZ reader no longer snaps to the top (or visibly bounces) when scrolling or dragging the scrollbar near the end of a long chapter: re-estimating every unmeasured page from each newly measured page made the scroll geometry oscillate on books with mixed page orientations (and the correction was computed from a stale anchor), so the estimator now learns once from the first measured page and then stays put; after a geometry change the viewport is re-anchored absolutely (keep the current page under the same screen spot) instead of by a delta, and while the scrollbar is being dragged the reader doesn't write scrollTop at all, leaving the browser in sole control.
- CBZ 阅读器在长章节末尾滚动或拖滚动条时不再出现"到底后被重置到上方"的跳变:此前每量完一页就用它重估所有未量页的高度,横竖版混排的书里估高来回翻转、滚动几何剧烈呼吸,且补偿量基于已过期的 anchor 计算;现改为估高只从首个实测页学习一次、之后保持稳定,几何变化后按"当前页钉回原屏幕位置"绝对重锚(不再用增量补偿),拖拽滚动条期间阅读器完全不写 scrollTop,避免与浏览器抢滚动控制权。
@@ -73,10 +111,3 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
- GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。
- Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter.
- 阅读大 txt 不再卡死浏览器:正文按章节切分(识别「第X章」/Chapter X/序章等标题,无标记的按段落切成小节),每次只渲染一章,并提供章节目录选择与上一章/下一章导航;「第X卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。
### Changed / 变更
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
+1 -1
View File
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9).
Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build).
Frontend gate: `cd frontend && npm run check` (tsc + eslint + prettier + vitest + vite build). E2E is a separate manual pre-release gate against the dev stack: `cd frontend && npm run e2e` (requires `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`, or source `deploy/.env`; one-off browser install inside the web container: `npx playwright install --with-deps chromium`). Bundle report: `npm run analyze` → `dist-stats/stats.html`.
## Old-volume migration (one-off, upgrading from the previous deploy layout)
+1 -1
View File
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。
前端门槛:`cd frontend && npm run check`(tsc + eslint + prettier + vitest + vite build)。e2e 是独立的手动发布门禁,针对 dev 栈运行:`cd frontend && npm run e2e`(需 `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`,或 source `deploy/.env`;浏览器一次性安装于 web 容器内:`npx playwright install --with-deps chromium`)。体积报告:`npm run analyze` → `dist-stats/stats.html`。
## 旧卷迁移(一次性,升级自上一版部署)
+59
View File
@@ -0,0 +1,59 @@
# 前端 bundle 审视(2026-09) / Frontend bundle review
> 由 `cd frontend && npm run analyze` 生成 `dist-stats/stats.html` 后人工审视得出;本期只记录结论,不实施优化(spec ④ S4)。
> Generated from `dist-stats/stats.html` (`npm run analyze`); findings only — no optimization in this batch (spec ④ S4).
## 现状 / Current state
实测基线(`frontend/dist/`,rollup/rolldown 构建产物):总计约 2.6 MB(assets 2,609,869 B;`du -sh` 显示 2.6M,含 PWA precache 全部条目)。每 chunk 构成如下。**注意两套口径**:chunk 级 `stat` 为 minified 实测产物大小(与 `ls dist/assets` 一致);`备注` 列中的依赖级数字为**源码级(未压缩,rollup `renderedLength`)口径**,仅用于依赖之间的相对占比,**不可与 stat/minified 字节数相加或比对**。gzip/brotli 来自 visualizer 输出;总注:gzip 列为 visualizer 容器口径,与宿主机 `gzip -c | wc -c` 在多个文件上存在 ≤2% 的小数位差异(如 index 145.3 vs 143.8 kB、TextReader 27.0 vs 26.7 kB),属统计口径差异。
Measured baseline (`frontend/dist/`): ~2.6 MB total (assets 2,609,869 B; `du -sh` 2.6M). Composition per chunk below. **Two calibers apply**: chunk-level `stat` is the measured minified artifact size (matches `ls dist/assets`); dependency-level figures in the `notes` column are **source-level (uncompressed rollup `renderedLength`)** and are only for relative shares among dependencies — **they must not be summed with or compared against stat/minified byte counts**. gzip/brotli come from the visualizer output; note: the gzip column uses the visualizer's in-container figure, which differs from host-side `gzip -c | wc -c` by ≤2% on several files (e.g. index 145.3 vs 143.8 kB, TextReader 27.0 vs 26.7 kB) — a statistics-caliber difference.
| chunk | 入口/来源 | stat | gzip | brotli | 备注 |
| --- | --- | --- | --- | --- | --- |
| `index-CGr3zft8.js` | 主入口(`src/main.tsx`,shelf/login/admin + react-dom/router/query/radix) | 462.6 kB | 145.3 kB | 122.3 kB | 依赖级(源码级口径,相对占比):react-dom 459.8 + react-router 94.9 + tailwind-merge 56 + radix-ui 各子包共 ~131 + lucide-react(34 个图标) 17.3(gzip 10.1)+ (src) 83.9 kB |
| `pdf.worker-C0DQFlrB.js` | `PdfReader.tsx` worker(`pdfjs-dist/build/pdf.worker.mjs`,Vite worker 语法独立产物) | 1,189.3 kB | 367.0 kB | ~ | 独立文件,不进 index 主包 |
| `PdfReader-Bv6vuw8i.js` | `pages/Reader.tsx` → `lazy(() => import(...))` | 434.4 kB | 130.5 kB | 137.9 kB | pdfjs-dist 主库 764.3 kB(源码级口径,本 chunk 最大依赖;brotli 135.4 kB) |
| `src-BNcXNGhn.js` | 共享 chunk:EpubReader 的 `await import("epubjs")` 链 | 345.4 kB | 103.6 kB | 135.5 kB | 依赖级(源码级口径):epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB + marks-pane/path-webpack/lodash 等 |
| `TextReader-CmvYmFNB.js` | `lazy()`(txt 与 md 共用) | 79.3 kB | 27.0 kB | 30.1 kB | 依赖级(源码级口径):marked 53.7 + dompurify 59.7 kB(gzip 14.2/14.5 kB) |
| `CbzReader-BEFlX6jt.js` | `lazy()` | 12.6 kB | 5.1 kB | 6.2 kB | 仅 (src) 21.1 kB(源码级口径)里的渲染逻辑,jszip 共享 chunk 已含 |
| `useProgress-BHPrULZV.js` | `lib/useProgress`(多 reader 共享) | 7.0 kB | 2.8 kB | 4.7 kB | |
| `EpubReader-BIpid_pd.js` | `lazy()` | 3.1 kB | 1.5 kB | 1.5 kB | 仅薄壳;epubjs 全链在 `src-BNcXNGhn.js` |
| `workbox-window.prod.es5-Bd17z0YL.js` | vite-plugin-pwa(registerSW) | 5.7 kB | 2.2 kB | 2.3 kB | |
| `rolldown-runtime-Dd_uD5pT.js` | 运行时 | 1.1 kB | ~0.9 kB | 0.8 kB | index.html 里 `<link rel="modulepreload">` |
| `index-C4Rgqjhl.css` | Tailwind v4 产物 | 69.4 kB | 12.1 kB | ~ | |
- 五个阅读器(cbz/txt/md/pdf/epub)均已通过 `pages/Reader.tsx` 的 `lazy()` 按需加载:是(实测确认——`dist/assets` 有 Cbz/Pdf/Text/Epub 四个独立 reader chunk;txt/md 共用 TextReader 一个 chunk;pdf 另有独立 pdf.worker 文件)。
Yes (verified): `dist/assets` contains separate chunks for Cbz/Pdf/Text/Epub readers; txt and md share the TextReader chunk; pdf additionally has a separate pdf.worker file.
- PWA precache(`workbox.globPatterns`)当前包含哪些大文件:generateSW 报告 "precache 15 entries (2549.92 KiB)",实测把全部 11 个 JS/CSS 都打进 precache,15 条 = 11 JS/CSS + index.html + icon.svg×2(icon.svg 在清单中出现两次)+ manifest.webmanifest;其中最大的五个:`pdf.worker` 1,189.3 kB、`index` 主包 462.6 kB、`PdfReader` 434.4 kB、`src-BNcXNGhn.js`(epubjs 链)345.4 kB、`TextReader` 79.3 kB。`maximumFileSizeToCacheInBytes` 是 generateSW 构建配置项(`vite.config.ts` 未设置,走默认 2 MiB),非 `sw.js` 内字段——默认值下 1.19 MB 的 pdf.worker 未被排除、仍在 precache 内。
The precache (reported as "15 entries, 2549.92 KiB") includes all 11 JS/CSS files; the 15 entries = 11 JS/CSS + index.html + icon.svg×2 (icon.svg appears twice in the manifest) + manifest.webmanifest; the five largest are pdf.worker 1,189.3 kB, index 462.6 kB, PdfReader 434.4 kB, src-BNcXNGhn.js (epubjs chain) 345.4 kB, TextReader 79.3 kB. `maximumFileSizeToCacheInBytes` is a generateSW build-time option (not set in `vite.config.ts`, so the default 2 MiB applies), not a field inside `sw.js` — under the default, the 1.19 MB pdf.worker is not excluded and stays in the precache.
## 可优化点 / Optimization backlog
| # | 问题 | 证据(chunk/体积) | 建议动作 | 归属 |
| --- | --- | --- | --- | --- |
| 1 | PWA precache 把全站 JS 全量缓存:pdf.worker(1.19 MB)+PdfReader(434 kB)+epubjs 链(345 kB)+TextReader(79 kB) 均在首装/首启 precache 清单,未打开对应阅读器的用户也要下载这 ~2.05 MB | sw.js precache 15 entries(11 JS/CSS + html + icon.svg×2 + webmanifest,2,549.92 KiB);globPatterns `**/*.{js,css,html,svg,woff2}` 未排除 | 首装 precache 仅保留 index/css/主包,reader chunk 改 runtime caching 或自定义 precache 过滤 | 后续 spec |
| 2 | epubjs 依赖链以单体共享 chunk `src-BNcXNGhn.js`(345.4 kB) 存在,体积大且被 precache 全量缓存(见 #1):虽然拆分本身是正确的——实测该 chunk 仅被 `EpubReader-*.js` 动态 import 引用,index 主包无静态引用、无 modulepreload,首屏不加载——但打开 epub 书需一次性下载 345.4 kB(gzip 103.6 kB),且单 chunk 无法按 epubjs/jszip 细分 | `src-BNcXNGhn.js` 345.4 kB:epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB(依赖级,源码级口径);`grep import` 确认仅 EpubReader 动态引用 | 与 #1 的 offline 策略一并处理:reader chunk 改 runtime caching 后,此 chunk 仅在用户真正打开 epub 时下载;或评估 `advancedChunks` 按 epubjs/jszip 拆小 chunk 提升缓存粒度 | 后续 spec |
| 3 | react-dom 459.8 kB(gzip 87.1 kB)是 index 主包内最大的单一依赖(依赖级,源码级口径),无 vendor 拆分;brotli 后 71.4 kB 仍是首屏最大单块 | index chunk rendered 明细:react-dom 459.8 kB / react-router 94.9 kB / query-core 64.2 kB | 评估 `build.rollupOptions.output.manualChunks`(或 vite advancedChunks)拆 vendor,提升缓存命中(业务代码改动不会使 react-dom 失效) | 后续 spec |
| 4 | `lucide-react` 34 个按需 import 的图标最终以完整组件体形式进 index(17.3 kB stat / gzip 10.1 kB——压缩比 ~59%、节省 ~41%,低于典型 JS 的 ~70% 节省水平,可能与 treeshake 不足有关,需进一步确认);且每个 reader chunk 各自带少量 lucide 模块(CbzReader 1.0 kB、PdfReader 0.7 kB、useProgress 2.8 kB)导致重复 | index 内 lucide-react rendered=17.3 kB(gzip 10.1 kB) mods=34;各 reader chunk 再零散重复 | 核查 treeshake 行为(rolldown 下 `lucide-react` named import 是否保留死代码);必要时统一走本仓库 `components/icons.tsx` 自绘 SVG | 后续 spec |
| 5 | radix-ui 单包多模块:`radix-ui` 包内 Select/Menu/Dialog/DropdownMenu/AlertDialog 等 ~29 个子模块共 ~131 kB(其中 react-select 43.0 kB、react-menu 22.1 kB;另有 radix 的定位依赖 floating-ui 46.5 kB,独立包)进 index 主包 | index chunk 内 @radix-ui/* 合计 rendered ≈ 131 kB(gzip ≈ 30 kB,依赖级口径) | 非首屏必需的 Select/DropdownMenu 所在页面(如 Shelf 筛选、admin 页)可评估下沉到对应 route chunk;仅在使用处再 import | 后续 spec |
| 6 | marked+dompurify 只被 TextReader(md 格式) 用,却进了 txt 也复用的 TextReader chunk——纯 txt 用户也下载这两个库(依赖级合计 ~113 kB,源码级口径) | `TextReader-CmvYmFNB.js` 79.3 kB 内 marked 53.7 + dompurify 59.7 kB(依赖级,源码级口径) | md 渲染再拆一层:TextReader 内对 md 分支用 `await import("marked")`/`await import("dompurify")`,txt 分支零依赖 | 后续 spec |
## 结论 / Verdict
当前总体体积(首屏 gzip 约 145 kB + CSS 12 kB)对个人书库场景可以接受,五阅读器 lazy 拆分也确实生效(epubjs 链仅被 EpubReader 动态 import,首屏不加载),pdf.worker 已是独立文件不进主包。最优先的两项是 #1(precache 全量缓存 2.05 MB reader 代码,与 spec 的离线策略直接冲突)和 #2(epubjs 链 345 kB 单体 chunk 被 precache 全量缓存,与 #1 同根),建议在下一个 spec 批次(S5 之后的优化轮)落地;#3–#6 属于收尾打磨,可与 #1 同期或更晚处理。
Total size is acceptable for a personal library (first-load gzip ≈145 kB + 12 kB CSS), and all five readers are properly split via `lazy()` (the epubjs chain is only dynamically imported by EpubReader and never loads on first paint), with pdf.worker already a standalone file. The two top priorities are #1 (precache ships ~2.05 MB of reader code users may never open — conflicts with the offline spec) and #2 (the 345 kB monolithic epubjs-chain chunk is fully precached, same root cause as #1); both belong to the next optimization round after S5. Items #3–#6 are polish that can land together with #1 or later.
---
### 实测数据来源 / Measurement provenance
- `npm run analyze`(容器内 vite build + rollup-plugin-visualizer 7.1.1,gzip/brotli 选项开启)→ `frontend/dist-stats/stats.html`;chunk/依赖级 stat、gzip、brotli 数值用 node 脚本从 stats.html 内嵌 `nodeParts`/`nodeMetas` JSON 提取(`nodeParts[uid] = {renderedLength, gzipLength, brotliLength}`,`nodeMetas[uid].moduleParts` 给出模块归属 chunk)。
- 产物文件级数值:`ls -la frontend/dist/assets/`(与 stats.html 的 chunk 级 stat/minified 大小一致;依赖级 `renderedLength` 是另一套源码级口径,见上)。
- gzip 交叉核对:宿主机 `gzip -c <file> | wc -c`(index 143,763 / PdfReader 128,873 / src-BNcXNGhn 102,696 / TextReader 26,677 / pdf.worker 366,988 / CbzReader 5,061 / EpubReader 1,500 / useProgress 2,778 / index.css 12,113 字节——与 visualizer 值存在 ≤2% 小数位差异,见现状节总注)。
- precache:`grep url:"..." frontend/dist/sw.js` 列出 15 条目(11 个 JS/CSS + index.html + icon.svg×2 + manifest.webmanifest)+ 容器内 build 输出 "precache 15 entries (2549.92 KiB)"。
- dist 总计:`du -sh frontend/dist/` = 2.6M;assets 目录 `du -b` 求和 2,609,869 字节。
- `Reader.tsx` lazy() 现状与 `dist/assets` 中四 reader chunk + pdf.worker 独立文件直接核对。
Provenance: values come from (a) the JSON embedded in `dist-stats/stats.html` produced by `npm run analyze` (rollup-plugin-visualizer 7.1.1 with gzip+brotli), extracted with a node script over `nodeParts`/`nodeMetas`; (b) `ls -la frontend/dist/assets/`; (c) host-side `gzip -c | wc -c` cross-checks; (d) `sw.js` precache URL list and the build log line "precache 15 entries (2549.92 KiB)"; (e) `du -sh frontend/dist` (2.6M) and per-file `du -b` sum (2,609,869 B); (f) direct comparison of `Reader.tsx` `lazy()` sources with the four reader chunks and the standalone pdf.worker file in `dist/assets`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
# 前端工程质量(lint/format + 组件测试 + e2e/a11y + bundle 分析)设计 / Frontend engineering quality design
日期 2026-09-15。分支 `docs/frontend-quality-spec`(设计);实施分支另建(建议 `feat/frontend-quality`)。状态:已获用户批准(会话内确认)。
本 spec 是项目优化四个子项目中的 **④**(其余:② 阅读器改版、③ 功能增强;① 后端健壮性已合入 master,见 `2026-09-14-backend-hardening-design.md`)。④ 与 ② 存在排序耦合:本 spec 先落地 lint/测试/e2e 基建,② 改阅读器时即有规范与回归网可用;阅读器组件测试明确留给 ②。
## 目标 / Goal
1. 引入 ESLint + Prettier,全库一次到位 0 error,进入 `npm run check` 与 CI 门禁。
2. 建立组件测试基建(testing-library + jsdom),公共组件有少量样板测试;阅读器组件不在本期。
3. 建立 Playwright e2e 基建,覆盖登录/书架/阅读/书签主流程与 admin 冒烟,含关键页 axe a11y 扫描;发布前手动门禁。
4. 接入 bundle 分析工具并产出书面审视结论;不做实际优化。
非目标见文末「范围外」。
## 决策记录 / Decisions
- 五个方向(lint/format、组件测试、e2e、a11y、bundle 分析)**全部纳入**本期,单 spec、四批次依序落地(方案 A)。用户选定。否决:B(全部基建一次接通再集中修,中间态长、难定位)、C(拆两份 spec 后置 e2e/a11y/bundle,会削弱 ② 的回归安全网)。
- lint 工具选 **ESLint 9 flat config + Prettier**(独立格式化),否决 Biome(a11y 规则覆盖弱于 jsx-a11y)与 Biome+ESLint 混合(双工具维护成本)。用户选定。
- lint 严格度:**全库 0 error 一次到位**,不做 baseline 渐进。过噪规则显式降 warn 或关闭,且必须在 config 内注释理由。用户选定。
- 组件测试深度:**基建 + 少量样板**(2-3 个文件,验证基建可用),阅读器组件测试留给 ② 随新 chrome 一起写。用户选定。
- e2e 运行方式:**针对 dev compose 栈**(默认 `http://localhost:5173`),`npm run e2e` 为发布前手动门禁,不进 `npm run check`;CI 加独立 job,`workflow_dispatch` 触发。用户选定。否决:进 check(日常摩擦大)、mock 后端(测不到集成层、与真实后端漂移)。
- a11y 分层:**静态 jsx-a11y(批次 1 随 eslint 接通)+ e2e 关键页 axe 扫描(批次 3)**;不做组件测试层 axe 断言(与 e2e 扫描重叠)。用户选定。
- bundle 分析:**只接工具 + 出书面结论**(`docs/bundle-review.md`),优化动作不在本期。用户选定。
- 所有新依赖在 dev 容器内安装并提交 lockfile(AGENTS.md 约束);Playwright 浏览器二进制仅 dev 容器手动安装(文档化),不进 prod 镜像。
## S0 批次总则 / Batches
四批次依序实施、依序提交,每批独立可回滚:
- B1 lint/format → B2 组件测试基建 → B3 e2e + axe → B4 bundle 分析。
- 每批验收底线:`npm run check` 保持绿 + 该批新增命令可跑通。
- B1 最先,使后续批次新写的测试/e2e 代码天然符合 lint/format 规范。
- changelog:纯 `frontend/` 改动记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新版本在上);`docs/bundle-review.md` 与 README 改动随 B4 同批,不重复记入 `docs/CHANGELOG.md`。
## S1 批次 1:ESLint + Prettier / Lint & format
- 依赖(dev):`eslint`、`typescript-eslint`、`eslint-plugin-react-hooks`、`eslint-plugin-jsx-a11y`、`eslint-config-prettier`、`prettier`(`prettier-plugin-tailwindcss` 可选,接入与否在实施时以噪音程度定,接则 class 自动排序)。
- 配置:`frontend/eslint.config.js`(flat config),基线 = `typescript-eslint` recommended + react-hooks recommended + jsx-a11y recommended + `eslint-config-prettier` 收尾关闭格式冲突规则。按目录 override:`e2e/`(B3 产物)用 node 环境 globals、放宽 e2e 惯例规则;`test/`、`*.test.tsx` 允许测试专用 globals。
- Prettier:`frontend/.prettierrc`(对齐现有代码风格:2 空格、单引号——以实施时对存量代码 diff 最小化为准)+ `.prettierignore`(`dist/`、`node_modules/`、`package-lock.json`、`dist-stats/`)。
- scripts(`frontend/package.json`):`lint`(eslint .)、`lint:fix`、`format`(prettier --write .)、`format:check`;`check` 改为 `tsc --noEmit && eslint . && prettier --check . && vitest run && vite build`。
- 存量修复:全库 eslint error 清零;prettier 首次全量 format 单独成一个 commit(纯格式、无逻辑改动),便于 review 与回滚。
- 降 warn/关闭的规则须在 `eslint.config.js` 内逐条注释理由(例如 jsx-a11y `click-events-have-key-events` 对阅读器中央点击区可先 warn,② 改版时收严)。
- CI:frontend job 已跑 `npm run check`,lint 自动覆盖,无需改 workflow。
验收:`npm run check` 绿;`eslint .` 0 error;`prettier --check .` 0 diff。
## S2 批次 2:组件测试基建 / Component testing
- 依赖(dev):`jsdom`、`@testing-library/react`、`@testing-library/user-event`、`@testing-library/jest-dom`。
- 环境分层:存量 `frontend/test/*.test.ts` 纯逻辑测试保持 node 环境不动;组件测试用 `*.test.tsx` 后缀,vitest `environmentMatchGlobs`(或等价的项目级配置)将 `**/*.test.tsx` 路由到 jsdom;`vitest run` 一次全跑,不加新 script。
- setup:`frontend/test/setup.ts` —— 注册 jest-dom matchers、自动 cleanup、补 jsdom 缺口 polyfill(`ResizeObserver`、`Element.scrollIntoView`、pointer events),radix 组件在 jsdom 下的已知坑一次配好。
- 放置约定:组件测试与组件同目录 colocate(如 `src/components/ui/button.test.tsx`);纯逻辑测试维持 `frontend/test/`。约定写入 B1 的 eslint override 与本 spec 附录(S6)。
- 样板测试(3 个,目的是验证基建而非追覆盖率):
1. `src/components/ui/button.test.tsx`:渲染、variant class、点击回调;
2. `src/components/ui/dialog.test.tsx`:radix Dialog 在 jsdom 下开合、焦点管理(验证 polyfill 方案成立——② 的 chrome 迁移依赖 Sheet/Popover 等同类原语);
3. `src/components/theme-toggle.test.tsx`:真实业务组件,验证 localStorage/主题上下文可测。
- 阅读器组件(`src/readers/{Cbz,Epub,Pdf,Text}Reader.tsx`)本期不写组件测试;spec 显式约定 ② 改版时随新 chrome 补齐并作为行为回归网。
验收:`npm run check` 绿;3 个样板测试通过;存量 9 个逻辑测试不受影响。
## S3 批次 3:e2e + a11y 运行时扫描 / e2e & axe
- 依赖(dev):`@playwright/test`、`@axe-core/playwright`;浏览器仅装 chromium(dev 容器内 `npx playwright install --with-deps chromium`,命令写入 README 与本 spec)。
- 结构:`frontend/e2e/*.spec.ts` + `frontend/playwright.config.ts`;`baseURL` 取环境变量 `E2E_BASE_URL`,默认 `http://localhost:5173`(dev compose 栈 web 端口)。**不由 Playwright 管理 server 生命周期**——前提是手动起好 dev 栈(`deploy/docker-compose.dev.yml`),连不上直接失败并给出提示。
- 测试数据:全部走 HTTP API 准备(注册/登录、上传 `e2e/fixtures/` 下的小 CBZ 文件),不直接碰 PG/Redis;登录态用 Playwright `storageState` 复用,避免每条用例重复登录。
- 用例(2 个 spec,不追全量):
1. `e2e/auth-shelf.spec.ts`:登录 → 书架可见 → 进入 CBZ 书 → 翻页 → 加入书签 → 退出登录;
2. `e2e/admin-smoke.spec.ts`:admin 登录 → 建库 → 触发扫描 → 扫描入口/状态可见(冒烟级;扫描状态 API 属 ③,此处只验证现有行为)。
- axe 扫描:在关键页面(登录页、书架页、阅读器 chrome 展开态)插入 `@axe-core/playwright` 扫描步骤。门禁分级:
- critical/serious 违规 = 测试红;moderate/minor 仅输出报告不阻断;
- 存量 critical/serious 违规本批内修掉(多为属性级小修,记 `CHANGELOG_web.md`);确属将被 ② 重写的旧阅读器 chrome 的问题,允许在测试内逐条显式 allowlist 豁免,每条注释指向 ②。
- scripts:`e2e`(playwright test)、`e2e:ui`(playwright test --ui,开发调试用)。**不进 `npm run check`**。
- CI:`ci.yml` 新增独立 `e2e` job,仅 `workflow_dispatch` 触发,job 内自起 PG/Redis/api/web(service 容器 + build),runner 未就绪期间形同文档,就绪后零改动可用。
- eslint:`e2e/` 目录 override(node globals、测试文件规则)。
验收:dev 栈起后 `npm run e2e` 绿(2 spec + 关键页 axe 无未豁免的 critical/serious)。
## S4 批次 4:bundle 分析 / Bundle analysis
- 依赖(dev):`rollup-plugin-visualizer`;接入方式为 `vite.config.ts` 内按条件启用(如 `ANALYZE=1` 或 `--mode analyze`),产物输出到 `frontend/dist-stats/`(加入 `.gitignore` 与 `.prettierignore`,不进 dist 发布产物)。
- script:`analyze`。
- 审视结论文档:`docs/bundle-review.md`(英中双语,随批 commit)。内容:各大依赖(pdfjs-dist、epubjs、marked、dompurify、react-query、radix-ui、lucide-react 等)的体积占比、当前是否已懒加载/分包、可优化点清单;每个优化项标注建议归属(② 或后续 spec),**本期不动代码**。
- README:`docs/README.md` 与 `docs/README_zh.md` 同批补充新增命令(lint/format/analyze/e2e/e2e:ui)与本地门禁清单更新,保持双语内容等价(AGENTS.md 约束)。
验收:`npm run analyze` 可出报告;`docs/bundle-review.md` 提交;README 双语等价。
## S5 整体验收 / Definition of Done
1. `npm run check` 绿 = tsc + eslint 全库 0 error + prettier check + vitest(9 存量逻辑测试 + 3 新组件样板)+ build;
2. dev 栈起后 `npm run e2e` 绿;
3. `npm run analyze` 可出报告,`docs/bundle-review.md` 已提交;
4. `docs/CHANGELOG_web.md` 双语条目齐全;`docs/README.md` / `README_zh.md` 等价更新;
5. CI:frontend job 无需改动即覆盖 lint;新增 e2e job(workflow_dispatch)就绪;
6. lockfile 已提交;prod 镜像不新增任何本期依赖。
## S6 范围外 / Out of scope
- 阅读器组件的任何测试与改动(→ ② 阅读器改版);
- bundle 体积的实际优化动作(仅出结论清单);
- moderate/minor 级 a11y 修复(仅输出报告);组件测试层 axe 断言;
- Playwright 浏览器进 prod 镜像;e2e 进 `npm run check`;
- 后端任何改动(含扫描状态 API → ③);
- `components/ui.ts`(deprecated)与 `components/icons.tsx` 的清理(→ ②,本期 lint 仅按现状规则放行或豁免,不做删除)。
## 附录:测试放置与命名约定 / Appendix: test conventions
- 纯逻辑测试:`frontend/test/<module>.test.ts`(现状不变,node 环境);
- 组件测试:与被测组件同目录 `<name>.test.tsx`(jsdom 环境);
- e2e:`frontend/e2e/<flow>.spec.ts`,fixture 放 `frontend/e2e/fixtures/`;
- 测试命名:`describe` 用被测单元名,`it` 用行为描述句。
@@ -0,0 +1,132 @@
# 阅读器改版(chrome 迁移 + 阅读新特性)设计 / Reader revamp design
日期 2026-09-16。分支 `docs/reader-revamp-spec`(设计);实施分支另建(建议 `feat/reader-revamp`)。状态:已获用户批准(会话内分节确认)。
本 spec 是项目优化四个子项目中的 **②**(① 后端健壮性、④ 前端工程质量已合入 master;③ 功能增强独立 spec)。边界来源:`2026-09-08-modern-redesign-shadcn-design.md` 的「Phase 2」节;基建依赖 ④ 已交付(eslint/组件测试/e2e/axe 回归网)。
## 目标 / Goal
1. 四阅读器(CBZ/Text/EPUB/PDF)chrome(头栏/工具条/目录/书签面板/主题卡)迁入 shadcn token + radix 原语,删除全部 `rd-*` 组件类与裸色类,删除 deprecated 的 `components/ui.ts` 与 `components/icons.tsx`。
2. 「纸/米/夜」阅读主题重映射为语义 token,并实现与全局 light/dark 的「默认联动 + 手动覆盖」规则。
3. 五个新特性:CBZ 横向翻页模式(长卷/单页/双页 + RTL)、txt/md 书内搜索、统一阅读设置面板(底部抽屉扩展)、阅读统计(localStorage + 可替换接口,书架顶部统计卡)、EPUB 排版设置(epubjs themes)。
4. 回收 ④ 留下的收严清单:eslint reader 豁免 override(4 条 jsx-a11y warn)删除回 error、axe `WAIVERS`(reader-chrome color-contrast)清空重扫、TxtView/CbzReader 的行级 disable 重估。
5. 顺带修复 ④ 终审遗留的 tsc 盲区(`e2e/`、`*.config.ts` 纳入类型检查)。
非目标见文末「范围外」。
## 决策记录 / Decisions
- 范围 = **chrome 迁移 + 新特性**(用户选定,否决「纯迁移」与「迁移为主+逐个勾选」)。五特性由用户从候选清单多选圈定;快捷键速查面板落 backlog。
- 实施策略 = **基建先行 + 按阅读器纵切**(方案 1,用户选定):B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。否决:两段式(迁移批过大、特性二次触碰)、特性先行(旧壳上写特性必返工)。
- 主题联动 = **默认联动 + 手动覆盖**(用户选定):`themeMode:"auto"` 时全局 dark→夜、light→纸;手动选卡记住手动值;设置面板提供「跟随全局」重置。否决:完全独立、完全同步。
- 统一设置面板形态 = **A 底部抽屉扩展**(用户经视觉伴侣选定):现有沉浸底部 sheet 长大,常驻行 + 按格式条件渲染的「更多设置」展开区。否决:右侧 Sheet、工具条 Popover。
- 书内搜索交互 = **导航抽屉第三 tab**(用户选定),否决顶部 overlay 搜索栏。
- 阅读统计 = **localStorage 记录 + `StatsStore` 可替换接口**(用户选定,③ 未来可换服务端实现);展示 = **书架顶部可折叠统计卡**(用户选定),书卡不加元素。
- CBZ 翻页 = **长卷/单页/双页 + RTL 独立开关**(用户选定);双页为手动配对,**不做自动跨页检测**;RTL 仅对页模式生效。
- EPUB 排版 = **字号/行距/边距 + 主题映射**(用户选定),不引入自定义字体族/字体文件。
- tsc 盲区修复**纳入 ②**(用户选定)作为 B0 的一个任务。
- **无 localStorage 兼容要求**(用户明确裁定):偏好统一收进单一 `reader-prefs` 新结构,旧独立键 `cbz-continuous`/`cbz-prefetch` 直接废弃(不读不写不迁移),老用户偏好重置为新默认值。服务端进度/书签 locator 格式本期不变(page/spread 与 `{page}` 天然兼容)。
- 行为保留约束(继承 Phase 2 边界):翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报的**行为逻辑**原样保留(只换壳 + 明确圈定的新特性);④ 期间的行为等价修复(渲染期重置等)不回退。
## S0 批次总则 / Batches
六批依序实施、依序提交:B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。
- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(既有 spec 不回归)+ 该批新增测试通过。
- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新在上);每批随批记。
- 新依赖(如有)容器内安装并提交 lockfile;本期预期**零新运行时依赖**(radix/shadcn/lucide 已在),仅可能新增 `@types/node`(dev)。
## S1 B0 横切基建 / Cross-cutting foundation
**阅读主题 token 与联动**
- 保留 `data-rd={paper|sepia|night}` 驱动机制;`--rd-bg/--rd-fg/--rd-link` 扩展为完整 token 组(补 `--rd-muted/--rd-accent/--rd-border`),供迁移后 chrome 使用。阅读面用 `--rd-*` token,抽屉/弹层容器用 shadcn 语义 token。
- `themeMode: "auto" | "paper" | "sepia" | "night"`,默认 `auto`(全局 dark→night、light→paper,实时跟随);手动选主题卡即切换为手动值;设置面板「跟随全局」按钮重置回 auto。
**readerPrefs 统一结构(无兼容负担)**
- 单一 localStorage 键 `reader-prefs`,新结构:`{ themeMode, text:{sizeIdx,lineIdx,marginIdx}, cbz:{mode:"strip"|"page"|"spread", rtl:boolean, continuous:boolean, prefetch:0|1|2|3}, epub:{sizeIdx,lineIdx,marginIdx} }`;宽容解析(缺字段/坏值回默认)。旧 `cbz-continuous`/`cbz-prefetch` 键废弃。
- `useReaderPrefs` 重构为该结构的读写 hook(现有消费点随各阅读器批次迁移)。
**统一设置抽屉(形态 A)**
- 新增 shadcn 原语文件 `ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`(基于已装的 `radix-ui` 包,零新 npm 依赖,模式对齐现有 `ui/*.tsx`)。
- `components/reader-settings.tsx`:底部 sheet(radix Sheet 原语 + shadcn token),常驻行 = 进度滑条(shadcn Slider)+ 主题卡(含「跟随」态)+ A−/A+;「更多设置」展开区按格式条件渲染:CBZ = 翻页模式/方向/连读/预读;Text = 行距/边距;EPUB = 字号/行距/边距;PDF = 无。
- 替代旧 `rd-sheet/rd-btn/rd-range` 与 `rd-slider.tsx`(组件在 B5 删除)。
**统计记录层**
- `lib/readingStats.ts`:`StatsStore` 接口(`record(seconds)` / `weekly()` / `streak()`)+ localStorage 实现(键 `reading-stats`,按日桶 `{ "YYYY-MM-DD": seconds }`)。
- 记录时机:Reader 页可见期间 30s 心跳 + 卸载 flush(`visibilitychange`/`pagehide`)。纯函数(日桶聚合/周合计/连续天数)独立导出供单测。
**tsc 盲区修复**
- `frontend/tsconfig.json` include 扩为 `["src", "test", "e2e", "*.config.ts"]` + dev 依赖 `@types/node`;存量 e2e/config 代码先过一遍 `tsc --noEmit` 清零。
## S2 B1 CBZ(迁移 + 翻页模式 + RTL)
**chrome 迁移**
- `pages/Reader.tsx` 头栏:`stone-950`/`btn`/`btnGhost`/`formatBadge` → shadcn token + `ui/button`(ghost)+ `ui/badge`;`IconArrowLeft` → lucide `ArrowLeft`。
- `components/reader-nav.tsx` → radix Sheet(side="left")+ shadcn Tabs 样式;`Bookmarks.tsx` 内容结构不变、`rd-btn` → `ui/button`。
- 底部工具条 → S1 的 `reader-settings`。
**翻页模式 + RTL**
- `strip`(长卷)= 现有渲染路径原样保留:章锁/连读/预读/滚动定位/章末卡片零改动。
- `page`/`spread` 新渲染路径:按页索引显示当前页;spread 手动配对 `[2i, 2i+1]`;翻页 = 索引步进,复用 `PageImg` 解码缓存预载相邻页;章锁在页模式 = 章末页后显示「本章完」卡片页,点击进下一章(锁章止步);`continuous` 在页模式 = 越过章末卡片自动进下一章;预读三模式通用。
- RTL 独立开关:右缘点击 = 上一页、键盘 ←/→ 映射翻转、spread 右页为先;仅 page/spread 生效。
- 点击区约定沿用:两侧翻页、中央收放 chrome。模式/方向切换保持当前页;书签 `{page}` locator 与进度上报(页索引→percent)兼容不变。
- 双页配对与 RTL 方向映射抽纯函数进 `lib/`(单测)。
**测试**:配对/方向纯函数单测;模式切换组件测试;e2e 扩展单页模式翻页 + RTL 冒烟。CbzReader 迁移后其 eslint warn 豁免与 axe WAIVERS 应自然消除(B5 验证)。
## S3 B2 Text(迁移 + 搜索 + 排版)
**chrome 迁移**:阅读面保留 `rd-surface`/`data-rd` token 机制(用 S1 扩展后变量);顶栏/章节选择器(含卷分组)/底部栏迁 shadcn + radix;主题卡/A−A+ 收进 `reader-settings`;书签抽屉复用 B1 的 `reader-nav`。分章/分卷/编码回退/进度恢复逻辑原样保留;TxtView 的 set-state disable(④ 遗留)迁移时重估:能以渲染期重置修复则修,否则保留注释并登记 B5 清单。
**书内搜索(导航抽屉第三 tab,仅 Text)**
- 输入 250ms 防抖;对内存章节文本线性扫描(大小写不敏感),全局结果上限 500;结果行 = 章节标题 + snippet(命中 ±~20 字符,`<mark>` 高亮)。
- 点击结果 → 复用章节跳转定位到章、滚动至命中处短暂高亮;不产生书签;locator 格式不变。
- 扫描/snippet/高亮切分抽纯函数进 `lib/search.ts`(单测)。
**排版设置**:行距五档(1.2/1.4/1.6/1.8/2.0)+ 边距三档(窄/中/宽 → max-width),进 `reader-settings`,持久化 `reader-prefs.text`。
**测试**:`lib/search.ts` 单测;搜索 tab 组件测试;新增 `e2e/fixtures/e2e-sample.txt` + e2e 搜索冒烟(输入→结果→跳转断言)。
## S4 B3 EPUB(迁移 + 排版设置)
- chrome 迁移:顶栏/「更多阅读选项」dropdown → shadcn + radix DropdownMenu/Sheet;书签抽屉复用 `reader-nav`;CFI 进度/书签原样保留。
- 排版:epubjs `rendition.themes` API——字号(复用 `FONT_SIZES`)/行距/边距 register 为 overrides;「纸/米/夜」映射 EPUB 背景/文字色,按 `themeMode`(含 auto 联动)`themes.select`;持久化 `reader-prefs.epub`。
- **测试限制(如实记录)**:epubjs 渲染在 iframe 内,组件测试不可行;headless 稳定性差,本期不加 EPUB e2e/fixture。验收走手工清单:开书 → 改字号/行距/边距/主题各一次 → 视觉生效 → 进度恢复。
## S5 B4 PDF(迁移)+ 书架统计卡
- PDF:pdfjs 渲染/页码定位/书签/进度原样保留;顶栏/工具条 chrome → shadcn + radix;pdf.worker 配置不动;无新增排版设置。
- 统计卡:`Shelf.tsx` 筛选栏上方可折叠卡(折叠态记忆 localStorage)——「本周阅读 X 小时 Y 分 · 连续 N 天」,展开显示近 7 日按日柱状图(纯 div 高度百分比,不引图表库);数据来自 `StatsStore.weekly()/streak()`;书卡不加元素。
- 测试:统计纯函数单测(B0 已列);统计卡组件测试用 fake `StatsStore` 注入;e2e 仅断言卡片存在(不断言时间敏感数值)。
## S6 B5 清理与收严 / Cleanup & tightening
- 删除 `components/ui.ts`(使用点先迁 `ui/button`/`ui/badge`)与 `components/icons.tsx`(→ lucide)。
- `index.css` 删除全部 `rd-*` 组件类(rd-btn/rd-sheet/rd-range/rd-row/rd-highlight/rd-divider 等)与阅读器裸色类(stone-* 等);`--rd-*` token 变量与 `data-rd` 机制保留。验收:`grep -rn 'rd-' src/ --include='*.tsx'` 零命中(`--rd-*`/`data-rd` 除外)。
- 收严清单回收:① `eslint.config.js` 删除 reader 豁免 override(4 条 jsx-a11y warn 回 error),迁移后代码必须真达标;② `e2e/helpers/axe.ts` 的 `WAIVERS` 清空,reader 相关页重扫零 critical/serious;③ TxtView/CbzReader 行级 disable 重估(修复或留带理由注释,AuthContext 的 disable 与阅读器无关、按 ④ 结论保留)。
- 测试收口:设置抽屉组件测试(改字号→断言正文 style);e2e 补「设置面板冒烟」。
- changelog:每批用户可见条目已在各批记;B5 补 Changed 条目(旧样式类删除/主题联动)。
## S7 验收 / Definition of Done
1. `npm run check` 绿(含 tsc 新覆盖 e2e/config);`npm run e2e` 绿(扩展后全部 spec)。
2. 手工走查清单:四阅读器 × 三阅读主题(+auto 联动)× 375/768/1280 三宽 × light/dark;翻页/书签/进度恢复/沉浸收放行为与迁移前一致(新特性除外)。
3. `ui.ts`/`icons.tsx` 已删;rd-* 组件类 grep 零命中;eslint reader override 与 axe WAIVERS 已移除。
4. changelog(双语)与 lockfile 齐;prod 镜像无新依赖。
## S8 范围外 / Out of scope
- EPUB 书内搜索;CBZ 自动跨页检测;快捷键速查面板(backlog);
- 阅读统计服务端化(③ 候选,本期仅留 `StatsStore` 接口缝);
- PDF 排版设置;自定义字体族/字体文件;i18n;
- 后端任何改动(API/迁移/契约零变化);
- 书卡元素变更;书架布局改版;
- localStorage 旧偏好兼容(用户裁定不需要)。
## 附录:backlog(本期未选,记录备查)
- 快捷键速查面板(阅读页 `?` 弹出);
- CBZ 自动跨页检测(宽>高单显、相邻竖图配对);
- EPUB 书内搜索(epubjs search API);
- 阅读统计服务端化 + 跨设备(③ 联动);
- ④ 遗留:`--legacy-peer-deps` 已落 `.npmrc`(闭合);vitest `src/**/*.test.ts`(非 tsx)静默缝隙——B0 顺手在 vitest.config 注释或 include 收紧。
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+6
View File
@@ -0,0 +1,6 @@
dist
dist-stats
node_modules
package-lock.json
playwright-report
test-results
+7
View File
@@ -0,0 +1,7 @@
{
"printWidth": 120,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+7 -1
View File
@@ -5,5 +5,11 @@
"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" }
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
import { creds } from "./helpers/api";
test("admin:建库 → 触发扫描(冒烟)", async ({ page }) => {
const { user, pass } = creds();
const libName = `e2e-admin-${Date.now()}`;
await page.goto("/login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
await page.goto("/admin/libraries");
await page.getByPlaceholder(/库名/).fill(libName);
await page.getByRole("button", { name: "建库" }).click();
await expect(page.getByText(`库「${libName}」已创建`)).toBeVisible();
// name cell 加 exact:路径 cell(服务端目录 /data/books/<库名>)的名称也含库名,子串匹配会 strict 双命中。
await expect(page.getByRole("cell", { name: libName, exact: true })).toBeVisible();
const row = page.getByRole("row", { name: new RegExp(libName) });
await row.getByRole("button", { name: /扫描/ }).click();
await expect(page.getByText("扫描已触发")).toBeVisible();
});
+89
View File
@@ -0,0 +1,89 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { adminApiRetry, creds, ensureSampleBook } from "./helpers/api";
import { scanA11y } from "./helpers/axe";
let admin: APIRequestContext;
let bookId: number;
let title: string;
test.beforeAll(async () => {
admin = await adminApiRetry();
bookId = await ensureSampleBook(admin, "e2e");
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
});
test.afterAll(async () => {
await admin?.dispose();
});
test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ page }) => {
const { user, pass } = creds();
// 登录页 + axe
await page.goto("/login");
await scanA11y(page, "login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
// 书架 + axe;样本卡可见并点进阅读器。
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
const card = page.locator(".group > button", { hasText: title });
await expect(card).toBeVisible();
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
await scanA11y(page, "shelf");
await card.click();
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
// 阅读器(chrome 默认展开)+ axe
const reader = page.getByLabel("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
await expect(reader).toBeVisible();
await expect(page.getByText("1/3")).toBeVisible();
await scanA11y(page, "reader-chrome");
// 右缘点击翻屏 → 页码前进。
// 单次 scrollBy 一屏未必把视口中心推过第 1 页底(页高 > 一屏),故按实况重试点击直到 2/3。
const box = await reader.boundingBox();
if (!box) throw new Error("阅读器容器无 boundingBox");
await expect(async () => {
await page.mouse.click(box.x + box.width * 0.9, box.y + box.height / 2);
await expect(page.getByText("2/3")).toBeVisible();
}).toPass({ timeout: 20_000 });
// 页模式 + RTL 冒烟:开设置面板 → 单页 → 右缘翻页 → 右开本(RTL) 方向翻转 → 回长卷。
await page.getByRole("button", { name: "更多阅读设置" }).click();
await page.getByRole("button", { name: "单页" }).click();
await expect(page.getByText(/^1\/3 · /)).toBeVisible(); // 模式切换保持当前页:此时在第 2 页
// 回到第 1 页:左缘点击
const box2 = await reader.boundingBox();
if (!box2) throw new Error("阅读器容器无 boundingBox");
await page.mouse.click(box2.x + box2.width * 0.1, box2.y + box2.height / 2);
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
// 右缘点击 → 第 2 页
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
await expect(page.getByText(/^2\/3 · /)).toBeVisible();
// RTL:右缘点击变后退
await page.getByRole("button", { name: "右开本(RTL)" }).click();
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
// 回长卷
await page.getByRole("button", { name: "长卷", exact: true }).click();
await expect(page.getByText("1/3")).toBeVisible();
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
await page.getByRole("button", { name: "导航", exact: true }).click();
const nav = page.locator("#reader-nav");
await expect(nav).toBeVisible();
await nav.getByRole("button", { name: "加书签" }).click();
await expect(page.getByText("已加书签")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
// 回书架 → 退出登录
await page.getByRole("link", { name: "书架" }).click();
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
await page.getByText("退出登录").click();
await expect(page).toHaveURL(/\/login/);
});
+29
View File
@@ -0,0 +1,29 @@
第一章 样本
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离,
因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及
当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用
明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按
章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是
搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云
收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合
端到端测试用的文本样本,昭然有效,勿改内容。
第二章 落雨
雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的
命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器
当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜
面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距,
本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读
主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中
列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行
至结尾,此章同样只包含一次标记词,合计两处命中。
第三章 风止
wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。
结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过
无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3,
即第二章标记已存。样本至此结束。
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { request, type APIRequestContext } from "@playwright/test";
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url));
export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url));
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
export function creds(): { user: string; pass: string } {
const user = process.env.E2E_ADMIN_USER ?? process.env.ADMIN_USER;
const pass = process.env.E2E_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD;
if (!user || !pass) {
throw new Error(
"缺少管理员凭据:export E2E_ADMIN_USER/E2E_ADMIN_PASSWORD," +
"或 `set -a; source ../deploy/.env; set +a` 后重跑",
);
}
return { user, pass };
}
export async function adminApi(): Promise<APIRequestContext> {
const { user, pass } = creds();
const res = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: user, password: pass }),
});
if (!res.ok) throw new Error(`admin 登录失败:HTTP ${res.status}(dev 栈起了吗?凭据对吗?)`);
const { token } = (await res.json()) as { token: string };
return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
}
/** adminApi 登录带 429 退避重试:三 spec 并发 worker 的登录总数可能顶到后端 1min/5 次限流门,等窗口即可;仅消耗等待预算,不改用例语义 */
export async function adminApiRetry(): Promise<APIRequestContext> {
for (let i = 0; ; i++) {
try {
return await adminApi();
} catch (e) {
if (i > 20 || !(e instanceof Error) || !e.message.includes("HTTP 429")) throw e;
await new Promise((r) => setTimeout(r, 3000));
}
}
}
export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise<number> {
const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[];
const hit = libs.find((l) => l.name === name);
if (hit) return hit.id;
const created = (await (await ctx.post("/api/libraries", { data: { name } })).json()) as { id: number };
return created.id;
}
async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number; title: string } | null> {
const res = await ctx.get(`/api/books?q=${encodeURIComponent(q)}`);
const books = (await res.json()) as { id: number; title: string }[];
return books.find((b) => b.title.includes(q)) ?? null;
}
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
export async function ensureBook(
ctx: APIRequestContext,
libName: string,
fileName: string,
absPath: string,
): Promise<number> {
const title = fileName.replace(/\.[a-z]+$/i, "");
const existing = await findBook(ctx, title);
if (existing) return existing.id;
const libId = await ensureLibrary(ctx, libName);
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
multipart: {
file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) },
},
});
if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`);
const scan = await ctx.post(`/api/libraries/${libId}/scan`);
if (!scan.ok()) throw new Error(`触发扫描失败:HTTP ${scan.status()}`);
const deadline = Date.now() + 30_000;
for (;;) {
const hit = await findBook(ctx, title);
if (hit) return hit.id;
if (Date.now() > deadline) throw new Error(`扫描后 30s 内未找到书「${title}」`);
await new Promise((r) => setTimeout(r, 1000));
}
}
/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ);
}
+49
View File
@@ -0,0 +1,49 @@
import { expect, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
interface Waiver {
page: string;
rule: string;
reason: string;
}
/**
* critical/serious 违规的逐条豁免清单(④ 基建):
* ② 阅读器改版后旧 chrome 重写完毕,本期清空;新增豁免必须写明 reason 并控制在一次性条件。
*/
const WAIVERS: Waiver[] = [];
export async function scanA11y(page: Page, name: string): Promise<void> {
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
await page.evaluate(() =>
Promise.race([
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]),
);
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
if (results.violations.length > 0) {
console.log(
`[axe:${name}]`,
JSON.stringify(
results.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
null,
2,
),
);
}
const blocking = results.violations.filter(
(v) =>
(v.impact === "critical" || v.impact === "serious") && !WAIVERS.some((w) => w.page === name && w.rule === v.id),
);
expect(
blocking.map((v) => ({ id: v.id, impact: v.impact })),
`${name} 存在未豁免的 critical/serious a11y 违规`,
).toEqual([]);
}
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { adminApiRetry, creds, ensureBook, FIXTURE_TXT } from "./helpers/api";
import { scanA11y } from "./helpers/axe";
let admin: APIRequestContext;
let bookId: number;
test.beforeAll(async () => {
admin = await adminApiRetry();
// 文件名避免与 CBZ 样本书同名:fixture 用 e2e-sample-txt.txt(title=e2e-sample-txt)
bookId = await ensureBook(admin, "e2e", "e2e-sample-txt.txt", FIXTURE_TXT);
});
test.afterAll(async () => {
await admin?.dispose();
});
test("登录 → txt 阅读 → 全书搜索 → 跳转", async ({ page }) => {
const { user, pass } = creds();
await page.goto("/login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
const card = page.locator(".group > button", { hasText: "e2e-sample-txt" });
await expect(card).toBeVisible();
await card.click();
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
// 导航抽屉 → 搜索 tab
await page.getByRole("button", { name: "导航", exact: true }).click();
const nav = page.locator("#reader-nav");
await expect(nav).toBeVisible();
await nav.getByRole("tab", { name: "搜索" }).click();
await nav.getByRole("textbox", { name: "搜索全书" }).fill("zebra-xc42");
// 大小写不敏感:第一章 2 处 + 第二章 1 处 = 3 行
await expect(nav.getByRole("listitem")).toHaveCount(3, { timeout: 10_000 });
expect(await nav.locator("mark").count()).toBe(3); // <mark> 高亮
// 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 2/3,抽屉关闭
await nav.getByRole("button").filter({ hasText: "第二章" }).first().click();
await expect(page.getByText("2/3")).toBeVisible({ timeout: 10_000 });
await expect(nav).not.toBeVisible();
await scanA11y(page, "reader-chrome");
// 设置面板冒烟:字号/行距即时生效 + 主题卡联动阅读面(data-rd 受控)
const pre = page.locator(".rd-surface pre");
await expect(pre).toHaveCSS("font-size", "20px");
await page.getByRole("button", { name: "更多阅读设置" }).click();
await page.getByRole("button", { name: "加大字号" }).click();
await expect(pre).toHaveCSS("font-size", "22px");
await page.getByRole("button", { name: "1.8", exact: true }).click();
await expect(pre).toHaveAttribute("style", /line-height: 1\.8/);
await page.getByRole("button", { name: "夜色主题" }).click();
await expect(page.locator(".rd-surface").first()).toHaveAttribute("data-rd", "night");
// 退出
await page.getByRole("link", { name: "书架" }).click();
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
await page.getByText("退出登录").click();
await expect(page).toHaveURL(/\/login/);
});
+36
View File
@@ -0,0 +1,36 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["dist/**", "dist-stats/**", "node_modules/**", "playwright-report/**", "test-results/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
// react-hooks 插件的 flat 导出名随版本而异(recommended-latest / configs.flat.recommended),
// 以安装版本实际导出为准,用 npx eslint --print-config src/main.tsx 验证规则已生效
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},
{
files: ["e2e/**/*.ts"],
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } },
rules: { "@typescript-eslint/no-explicit-any": "off" },
},
{
files: ["**/*.test.{ts,tsx}", "test/**/*.ts"],
// 测试代码允许 any 以贴合测试惯例(mock 数据、运行时探测)
rules: { "@typescript-eslint/no-explicit-any": "off" },
},
prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则
);
+2815 -30
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -6,9 +6,16 @@
"scripts": {
"test": "vitest run",
"dev": "vite",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"build": "tsc --noEmit && vite build",
"analyze": "ANALYZE=1 vite build",
"preview": "vite preview",
"check": "tsc --noEmit && vitest run && vite build"
"check": "tsc --noEmit && eslint . && prettier --check . && vitest run && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"keywords": [],
"author": "",
@@ -32,12 +39,29 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^22.20.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"rollup-plugin-visualizer": "^7.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.70.0",
"vite": "^8.2.2",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^5.0.0"
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
timeout: 60_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:5173",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+16 -2
View File
@@ -25,8 +25,22 @@ export default function App() {
}
>
<Route path="/" element={<Shelf />} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
<Route
path="/admin/users"
element={
<RequireAdmin>
<AdminUsers />
</RequireAdmin>
}
/>
<Route
path="/admin/libraries"
element={
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
}
/>
</Route>
<Route
path="/book/:id"
+2 -3
View File
@@ -9,7 +9,7 @@ const mem = new Map<string, string>();
const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
return hasLS ? localStorage.getItem(TOKEN_KEY) : (mem.get(TOKEN_KEY) ?? null);
}
export function setToken(t: string | null): void {
@@ -181,8 +181,7 @@ export const api = {
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
patchBookmark: (id: number, note: string) =>
apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
patchBookmark: (id: number, note: string) => apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
};
+2 -1
View File
@@ -40,6 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (token && meQ.isError) {
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
// eslint-disable-next-line react-hooks/set-state-in-effect -- 对「me 查询失败」异步结果的清理反应,与持久化 token 需同帧回收;改渲染期重置/事件驱动会改变鉴权失败时序,保守保留
setTok(null);
}
}, [token, meQ.isError]);
@@ -77,7 +78,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
if (!ready || !user) return <div className="text-muted-foreground grid h-full place-items-center">加载中…</div>;
return <>{children}</>;
}
+38 -22
View File
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
@@ -55,8 +57,8 @@ export function BookmarksTab({
return (
<>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
<Input
className="min-w-0 flex-1"
placeholder="备注(可空)"
maxLength={500}
value={note}
@@ -65,26 +67,28 @@ export function BookmarksTab({
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button
className="rd-btn inline-flex shrink-0 items-center gap-1"
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="加书签"
disabled={add.isPending}
onClick={() => add.mutate()}
>
<Plus size={14} /> 加
</button>
<Plus size={14} />
</Button>
</div>
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
<Input
className="min-w-0 flex-1"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
@@ -92,30 +96,40 @@ export function BookmarksTab({
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn inline-flex shrink-0 items-center"
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="保存备注"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
<Check size={14} />
</button>
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
</Button>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="取消编辑"
onClick={() => setEditId(null)}
>
<X size={14} />
</button>
</Button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
title={b.note || undefined}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
@@ -123,14 +137,16 @@ export function BookmarksTab({
}}
>
<Pencil size={14} />
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
</Button>
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
<X size={14} />
</button>
</Button>
</div>
)}
</li>
+26 -10
View File
@@ -1,19 +1,30 @@
import { useEffect, useState } from "react";
import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage";
import { btn, formatBadge } from "./ui";
import { FormatBadge } from "./format-badge";
import { Button } from "./ui/button";
import { TriangleAlert } from "lucide-react";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({
key: "",
src: "",
failed: false,
});
const [nonce, setNonce] = useState(0);
const key = (url ?? "") + "#" + nonce;
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染),行为等价——加载中同样呈现 src="" 的占位
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setState({ key, src: "", failed: false });
}
useEffect(() => {
if (!url) return;
let dead = false;
const k = url + "#" + nonce;
setState({ key: k, src: "", failed: false });
fetchObjectUrl(url)
.then((src) => !dead && setState({ key: k, src, failed: false }))
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
@@ -30,16 +41,21 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (!book.cover_url || failed) {
return (
<div
className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}
>
<span className="select-none font-serif text-4xl text-muted-foreground">{Array.from(book.title)[0] ?? "?"}</span>
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
<div className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}>
<span className="select-none font-serif text-4xl text-muted-foreground">
{Array.from(book.title)[0] ?? "?"}
</span>
<FormatBadge fmt={book.format} className="absolute right-1.5 bottom-1.5" />
{failed && (
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
<Button
variant="secondary"
size="xs"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 gap-1"
onClick={retry}
>
<TriangleAlert className="size-4" />
封面重试
</button>
</Button>
)}
</div>
);
+5 -2
View File
@@ -13,8 +13,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
<p className="mb-4 max-w-md text-sm break-all text-muted-foreground">{String(this.state.err)}</p>
<button
className="rounded-md bg-secondary px-3 py-1.5 text-sm"
onClick={() => this.setState({ err: null })}
>
重试
</button>
</div>
+5 -1
View File
@@ -36,7 +36,11 @@ export function Toaster() {
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" />}
{t.kind === "ok" ? (
<CheckCircle2 className="size-4 shrink-0 text-accent" />
) : (
<XCircle className="size-4 shrink-0 text-destructive" />
)}
{t.text}
</div>
))}
+44 -7
View File
@@ -4,7 +4,14 @@ import { Link, 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 {
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";
@@ -12,7 +19,11 @@ import type { ReactNode } from "react";
function Brand() {
return (
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
<Link
to="/"
aria-label="BookLib 首页"
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>
@@ -57,7 +68,10 @@ export function AppShell() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
<DropdownMenuLabel>
{user?.username}
{isAdmin ? " · admin" : ""}
</DropdownMenuLabel>
{isAdmin && (
<>
<DropdownMenuSeparator />
@@ -88,14 +102,25 @@ export function AppShell() {
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
<Brand />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
<NavItem
to="/"
icon={<BookOpen className="size-4 shrink-0" />}
label="书架"
active={loc.pathname === "/" && !libParam}
/>
</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">
{libs.map((l) => (
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} />
<NavItem
key={l.id}
to={`/?lib=${l.id}`}
icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />}
label={l.name}
active={loc.pathname === "/" && libParam === String(l.id)}
/>
))}
</div>
</div>
@@ -120,7 +145,10 @@ export function AppShell() {
<Outlet />
</main>
{/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden">
<nav
aria-label="底部导航"
className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"
>
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
</nav>
</div>
@@ -130,7 +158,16 @@ export function AppShell() {
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
return (
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}>
<NavLink
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground",
isActive && "text-accent-strong",
)
}
>
{icon}
{label}
</NavLink>
+22
View File
@@ -0,0 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { Format } from "@/api/types";
const CLASSES: Record<Format, string> = {
cbz: "border-transparent bg-orange-500/15 text-orange-900 ring-0 dark:text-orange-300",
pdf: "border-transparent bg-rose-500/15 text-rose-900 ring-0 dark:text-rose-300",
epub: "border-transparent bg-sky-500/15 text-sky-900 ring-0 dark:text-sky-300",
txt: "border-transparent bg-emerald-500/15 text-emerald-900 ring-0 dark:text-emerald-300",
md: "border-transparent bg-teal-500/15 text-teal-900 ring-0 dark:text-teal-300",
};
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
return (
<Badge
variant="outline"
className={`text-[10px] font-semibold uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}
>
{fmt}
</Badge>
);
}
-15
View File
@@ -1,15 +0,0 @@
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>
);
-37
View File
@@ -1,37 +0,0 @@
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
export function RdSlider({
label,
ariaLabel,
min,
max,
value,
onChange,
}: {
label: string;
ariaLabel: string;
min: number;
max: number;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="px-2.5 pb-1.5 pt-2">
<div className="mb-1 text-sm">{label}</div>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
aria-label={ariaLabel}
className="rd-range"
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
{Array.from({ length: max - min + 1 }, (_, i) => (
<span key={i}>{min + i}</span>
))}
</div>
</div>
);
}
+31 -46
View File
@@ -1,66 +1,51 @@
import { Bookmark, List, X } from "lucide-react";
import { Bookmark, List, Search } from "lucide-react";
import type { ReactNode } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type NavTab = "toc" | "bm";
export type NavTab = "toc" | "bm" | "search";
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
/** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
export function ReaderNav({
tab,
onTab,
onClose,
toc,
bookmarks,
search,
}: {
tab: NavTab;
onTab?: (t: NavTab) => void;
onClose: () => void;
toc?: ReactNode;
bookmarks: ReactNode;
search?: ReactNode;
}) {
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
];
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
<nav
id="reader-nav"
aria-label="导航"
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<div
role={toc != null ? "tablist" : undefined}
aria-label="导航选项卡"
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
>
{toc != null ? (
<>
<button
role="tab"
aria-selected={tab === "toc"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
onClick={() => onTab?.("toc")}
>
<List size={14} /> 目录
</button>
<button
role="tab"
aria-selected={tab === "bm"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
onClick={() => onTab?.("bm")}
>
<Bookmark size={14} /> 书签
</button>
</>
) : (
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
<Bookmark size={14} /> 书签
</span>
)}
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
<X size={14} />
</button>
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
<SheetHeader className="sr-only">
<SheetTitle>导航</SheetTitle>
</SheetHeader>
<div id="reader-nav" className="flex h-full flex-col">
<Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
<TabsList className="m-2 shrink-0 self-start">
{tabs.map(({ id, label, icon: Icon }) => (
<TabsTrigger key={id} value={id} className="gap-1.5">
<Icon size={14} /> {label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
</Tabs>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
</nav>
</>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect } from "vitest";
import { beforeEach, describe, it } from "vitest";
import { ReaderSettings } from "@/components/reader-settings";
import { ThemeProvider } from "@/components/theme";
beforeEach(() => {
localStorage.clear();
});
function renderSettings(props = {}) {
return render(
<ThemeProvider>
<ReaderSettings
slider={{ value: 2, max: 10, onChange: () => {}, ariaLabel: "页码跳转" }}
position="3/12 · 25%"
{...props}
/>
</ThemeProvider>,
);
}
describe("ReaderSettings", () => {
it("常驻行渲染:滑条 aria-label 与位置文本", () => {
renderSettings();
expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument();
expect(screen.getByText("3/12 · 25%")).toBeInTheDocument();
});
it("主题卡点击 → localStorage 的 themeMode 变手动值", () => {
renderSettings();
fireEvent.click(screen.getByRole("button", { name: "夜色主题" }));
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
expect(stored.themeMode).toBe("night");
});
it("「跟随全局」按钮存在且点击回 auto", () => {
renderSettings();
const follow = screen.getByRole("button", { name: "跟随全局" });
expect(follow).toBeInTheDocument();
expect(follow).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "米色主题" }));
expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("button", { name: "跟随全局" }));
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
expect(stored.themeMode).toBe("auto");
});
it("showFont=false 时无 A−/A+", () => {
renderSettings({ showFont: false, onFont: () => {} });
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument();
});
it("未传 onFont 时 A−/A+ 隐藏(showFont 也无字号钮)", () => {
renderSettings({ showFont: true });
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
});
it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("extra")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("extra")).toBeInTheDocument();
});
it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => {
let got = 0;
renderSettings({ showFont: true, onFont: (d: number) => (got = d) });
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
expect(got).toBe(1);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useGlobalDark } from "@/components/theme";
import {
RD_THEMES,
RD_THEME_LABEL,
FONT_SIZES,
resolveRdTheme,
useReaderPrefs,
type RdTheme,
type ReaderPrefs,
} from "@/lib/readerPrefs";
export interface ReaderSettingsProps {
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数;EPUB 无全局分数故可省 */
slider?: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
/** 位置文本,如 "3/12 · 25%" */
position?: string;
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
left?: ReactNode;
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
showFont?: boolean;
/** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */
onFont?: (d: number) => void;
/** 当前字号档位(用于 A−/A+ 边界禁用);不传按文本框位 */
fontIdx?: number;
/** 受控主题:传入时用调用方状态,保证阅读面 data-rd 与设置栏同步(不传则组件内自持) */
themeMode?: ReaderPrefs["themeMode"];
onThemeMode?: (m: ReaderPrefs["themeMode"]) => void;
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
extra?: ReactNode;
}
/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
export function ReaderSettings({
slider,
position,
left,
showFont = false,
onFont,
fontIdx,
themeMode,
onThemeMode,
extra,
}: ReaderSettingsProps) {
const internal = useReaderPrefs();
// 主题受控:调用方给状态时用调用方(阅读面 data-rd 联动);否则退回组件内自持(独立使用/测试)
const mode = themeMode ?? internal.prefs.themeMode;
const setMode = onThemeMode ?? internal.setThemeMode;
const globalDark = useGlobalDark();
const rd = resolveRdTheme(mode, globalDark);
const [open, setOpen] = useState(false);
return (
<div
className="border-t bg-background/95 text-foreground backdrop-blur pt-1.5 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-rd={rd}
>
<div className="flex items-center gap-2.5 px-2.5">
{left && <div className="flex items-center gap-1.5">{left}</div>}
{position && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground" aria-live="polite">
{position}
</span>
)}
<div className="flex items-center gap-1.5 whitespace-nowrap" role="group" aria-label="阅读主题">
{RD_THEMES.map((t) => (
<button
key={t}
aria-label={`${RD_THEME_LABEL[t]}色主题`}
aria-pressed={mode === t}
onClick={() => setMode(t)}
className="h-5 w-5 shrink-0 rounded-full transition-transform hover:scale-110"
style={{
background: SWATCH[t],
boxShadow:
rd === t
? "0 0 0 2px var(--rd-accent)"
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
}}
/>
))}
<Button
variant="ghost"
size="xs"
aria-pressed={mode === "auto"}
className="text-muted-foreground"
onClick={() => setMode("auto")}
>
跟随全局
</Button>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{showFont && onFont && (
<>
<Button
variant="ghost"
size="icon-xs"
aria-label="减小字号"
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === 0}
onClick={() => onFont(-1)}
>
A−
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="加大字号"
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === FONT_SIZES.length - 1}
onClick={() => onFont(1)}
>
A+
</Button>
</>
)}
{extra && (
<Button
variant="ghost"
size="icon-xs"
aria-label="更多阅读设置"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
</Button>
)}
</div>
</div>
{slider && (
<Slider
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
value={[slider.value]}
max={slider.max}
onValueChange={([v]) => slider.onChange(v)}
aria-label={slider.ariaLabel}
/>
)}
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
</div>
);
}
/** 设置行通用骨架:label + 右侧控件组。 */
export function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="flex items-center gap-1">{children}</span>
</div>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect } from "vitest";
import { beforeEach, describe, it } from "vitest";
import { ReadingStatsCard } from "@/components/reading-stats-card";
import type { StatsStore } from "@/lib/readingStats";
function fakeStore(): StatsStore {
const today = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
const y = new Date(today);
y.setDate(y.getDate() - 1);
const buckets: Record<string, number> = { [key(today)]: 900, [key(y)]: 300 };
return {
record() {},
weekly() {
const perDay = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() - (6 - i));
const k = key(d);
return { day: k, seconds: buckets[k] ?? 0 };
});
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
},
streak() {
return 2;
},
};
}
beforeEach(() => localStorage.clear());
describe("ReadingStatsCard", () => {
it("本周文案含时长与连续天数", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByText(/本周阅读/)).toBeInTheDocument();
expect(screen.getByText(/20 分钟/)).toBeInTheDocument();
expect(screen.getByText(/连续 2 天/)).toBeInTheDocument();
});
it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
const btn = screen.getByRole("button", { name: "折叠统计" });
expect(screen.queryAllByRole("img").length).toBeGreaterThan(0);
fireEvent.click(btn);
expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "展开统计" }));
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
});
it("注入的 store 无连续记录时不显示连续段", () => {
const s = fakeStore();
render(<ReadingStatsCard store={{ ...s, streak: () => 0 }} />);
expect(screen.queryByText(/连续/)).not.toBeInTheDocument();
});
it("7 根柱(含 0 值日)", () => {
render(<ReadingStatsCard store={fakeStore()} />);
const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" });
expect(chart.children.length).toBe(7);
});
});
@@ -0,0 +1,58 @@
import { useMemo, useState } from "react";
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
const FOLD_KEY = "stats-card-folded";
function fmtDur(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
}
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
const stats = useMemo(() => store ?? createLocalStats(), [store]);
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
const { perDay, total } = stats.weekly();
const streak = stats.streak();
const max = Math.max(1, ...perDay.map((d) => d.seconds));
return (
<section aria-label="阅读统计" className="bg-card text-card-foreground px-4 py-3 text-sm rounded-lg border">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<span>
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
</span>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label={folded ? "展开统计" : "折叠统计"}
onClick={() => {
const v = !folded;
setFolded(v);
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
}}
>
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
</Button>
</div>
{!folded && (
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
{perDay.map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div
className="bg-primary/70 w-full rounded-sm"
style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }}
/>
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,23 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ThemeProvider } from "./theme";
import { ThemeToggle } from "./theme-toggle";
describe("ThemeToggle", () => {
it("switches theme via dropdown, persists to localStorage, toggles dark class", async () => {
localStorage.clear();
document.documentElement.classList.remove("dark");
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "切换主题" }));
await userEvent.click(await screen.findByText("深色"));
await waitFor(() => expect(localStorage.getItem("ui.theme")).toBe("dark"));
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});
+13 -2
View File
@@ -1,6 +1,11 @@
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
@@ -15,7 +20,13 @@ export function ThemeToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
{theme === "dark" ? (
<Moon className="size-4" />
) : theme === "light" ? (
<Sun className="size-4" />
) : (
<Monitor className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
+16
View File
@@ -45,3 +45,19 @@ export function useTheme() {
if (!v) throw new Error("useTheme outside ThemeProvider");
return v;
}
const MQ = "(prefers-color-scheme: dark)";
/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */
export function useGlobalDark(): boolean {
const { theme } = useTheme();
const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches);
useEffect(() => {
if (theme !== "system") return;
const mq = matchMedia(MQ);
const on = () => setSysDark(mq.matches);
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, [theme]);
return theme === "dark" || (theme === "system" && sysDark);
}
-19
View File
@@ -1,19 +0,0 @@
import type { Format } from "../api/types";
export const btn =
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
export const btnGhost =
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
export const FORMAT_BADGE: Record<Format, string> = {
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
};
export function formatBadge(fmt: Format, extra = ""): string {
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
}
+35 -68
View File
@@ -1,47 +1,34 @@
"use client"
"use client";
import * as React from "react"
import { cn } from "@/lib/utils"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -49,7 +36,7 @@ function AlertDialogContent({
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
@@ -59,60 +46,51 @@ function AlertDialogContent({
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -125,23 +103,20 @@ function AlertDialogDescription({
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -153,13 +128,9 @@ function AlertDialogAction({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
)
);
}
function AlertDialogCancel({
@@ -171,13 +142,9 @@ function AlertDialogCancel({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
)
);
}
export {
@@ -193,4 +160,4 @@ export {
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
};
+13 -21
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Slot } from "radix-ui";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
@@ -9,12 +9,10 @@ const badgeVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
@@ -22,26 +20,20 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("renders label, exposes variant/size data attrs, fires onClick", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>保存</Button>);
const btn = screen.getByRole("button", { name: "保存" });
expect(btn).toHaveAttribute("data-variant", "default");
expect(btn).toHaveAttribute("data-size", "default");
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies non-default variant/size and blocks clicks when disabled", async () => {
const onClick = vi.fn();
render(
<Button variant="outline" size="sm" disabled onClick={onClick}>
x
</Button>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "outline");
expect(btn).toHaveAttribute("data-size", "sm");
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
});
+12 -14
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Slot } from "radix-ui";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -13,10 +13,8 @@ const buttonVariants = cva(
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@@ -34,8 +32,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -45,9 +43,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -57,7 +55,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+14 -50
View File
@@ -1,17 +1,14 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,72 +17,39 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,27 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog";
describe("Dialog", () => {
it("opens via trigger, renders title, closes via Escape (radix under jsdom)", async () => {
render(
<Dialog>
<DialogTrigger>打开对话框</DialogTrigger>
<DialogContent>
<DialogTitle>测试标题</DialogTitle>
<p>正文内容</p>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "打开对话框" }));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("测试标题")).toBeInTheDocument();
expect(screen.getByText("正文内容")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});
+28 -48
View File
@@ -1,48 +1,37 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -51,7 +40,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -60,7 +49,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
className,
)}
{...props}
>
@@ -76,7 +65,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +75,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -95,15 +84,12 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
@@ -113,33 +99,27 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@@ -153,4 +133,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+41 -79
View File
@@ -1,33 +1,20 @@
"use client"
"use client";
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
@@ -42,20 +29,16 @@ function DropdownMenuContent({
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
@@ -64,8 +47,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -74,11 +57,11 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -92,7 +75,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
checked={checked}
{...props}
@@ -104,18 +87,11 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
@@ -128,7 +104,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -139,7 +115,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -147,54 +123,40 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -203,7 +165,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -211,14 +173,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,11 +192,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -253,4 +215,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};
+5 -5
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+7 -10
View File
@@ -1,21 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { Label as LabelPrimitive } from "radix-ui";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+30 -58
View File
@@ -1,24 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@@ -27,7 +21,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -35,7 +29,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
className,
)}
{...props}
>
@@ -44,7 +38,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -62,7 +56,7 @@ function SelectContent({
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
align={align}
@@ -73,7 +67,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
@@ -81,78 +75,59 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -162,15 +137,12 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -184,4 +156,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+7 -7
View File
@@ -1,8 +1,8 @@
"use client"
"use client";
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { Separator as SeparatorPrimitive } from "radix-ui";
function Separator({
className,
@@ -17,11 +17,11 @@ function Separator({
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+126
View File
@@ -0,0 +1,126 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
const SIDE_CLASSES = {
top: "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
} as const;
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal data-slot="sheet-portal">
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-2 bg-background p-4 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
SIDE_CLASSES[side],
className,
)}
{...props}
>
{children}
{showCloseButton && side !== "bottom" && (
<SheetPrimitive.Close
data-slot="sheet-close"
aria-label="关闭"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
+3 -9
View File
@@ -1,13 +1,7 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-accent", className)} {...props} />;
}
export { Skeleton }
export { Skeleton };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Slider as SliderPrimitive } from "radix-ui";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const thumbs = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
// radix 的可访问 slider 角色在 Thumb 上,aria-label 提到 Root 传不进去——改下沉到每个 Thumb。
const { "aria-label": ariaLabel, ...rootProps } = props;
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...rootProps}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative w-full grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=horizontal]:top-0 data-[orientation=vertical]:w-full"
/>
</SliderPrimitive.Track>
{thumbs.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
data-slot="slider-thumb"
aria-label={Array.isArray(ariaLabel) ? ariaLabel[i] : ariaLabel}
className="block size-4 shrink-0 rounded-full border border-primary/50 bg-background shadow transition-[color,box-shadow] hover:ring-4 hover:ring-ring/30 focus-visible:ring-4 focus-visible:ring-ring/50 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+20 -58
View File
@@ -1,54 +1,32 @@
"use client"
"use client";
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -57,11 +35,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
className,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -70,11 +48,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -83,33 +61,17 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
<caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Tabs as TabsPrimitive } from "radix-ui";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex w-fit items-center justify-center rounded-lg bg-muted p-1-foreground", className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-foreground text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
}
export { Tabs, TabsContent, TabsList, TabsTrigger };
+12 -25
View File
@@ -1,30 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { Tooltip as TooltipPrimitive } from "radix-ui";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -40,7 +27,7 @@ function TooltipContent({
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
className,
)}
{...props}
>
@@ -48,7 +35,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+92 -50
View File
@@ -16,7 +16,7 @@
--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);
--muted-foreground: oklch(0.52 0.008 70);
--accent: oklch(0.68 0.16 46);
--accent-strong: oklch(0.55 0.17 46);
--accent-foreground: oklch(0.985 0.002 80);
@@ -103,15 +103,44 @@ body {
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
@keyframes fx-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.fx-rise {
animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; }
[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; }
[data-rd="paper"] {
--rd-bg: #faf8f4;
--rd-fg: #292524;
--rd-link: #9a3412;
--rd-muted: #8a8378;
--rd-accent: #b45309;
--rd-border: #e4ded2;
}
[data-rd="sepia"] {
--rd-bg: #f0e6d2;
--rd-fg: #433a29;
--rd-link: #92400e;
--rd-muted: #857a63;
--rd-accent: #92400e;
--rd-border: #ddd0b4;
}
[data-rd="night"] {
--rd-bg: #171512;
--rd-fg: #c6c0b6;
--rd-link: #e0a458;
--rd-muted: #8b8579;
--rd-accent: #e0a458;
--rd-border: #322e27;
}
.rd-surface {
background: var(--rd-bg);
color: var(--rd-fg);
@@ -120,51 +149,64 @@ body {
.rd-surface ::selection {
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
}
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
.rd-btn {
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
background: color-mix(in oklab, currentColor 8%, transparent);
}
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); }
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; }
/* 目录侧边栏行 */
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
.rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); }
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
/* Radix 菜单项高亮(指针/键盘) */
.rd-highlight { outline: none; }
.rd-highlight[data-highlighted] { background: color-mix(in oklab, currentColor 16%, transparent); }
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
.rd-range { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full; background: color-mix(in oklab, currentColor 18%, transparent); }
.rd-range::-webkit-slider-thumb {
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10; background: var(--rd-link, #f59e0b);
}
.rd-range::-moz-range-thumb { @apply h-4 w-4 rounded-full border-0; background: var(--rd-link, #f59e0b); }
/* 沉浸态底部工具抽屉的滑入动效 */
@keyframes rd-rise { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } }
.rd-sheet { animation: rd-rise 0.2s ease-out; }
@media (prefers-reduced-motion: reduce) {
.rd-sheet { animation: none; }
.rd-rise-anim { transition: none !important; }
.fx-rise { animation: none; }
.fx-rise {
animation: none;
}
}
/* markdown 正文(仅 .md-body 作用域) */
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; }
.md-body p { @apply my-3; }
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; }
.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); }
.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); }
.md-body pre code { @apply bg-transparent p-0; }
.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; }
.md-body img { @apply my-3 max-w-full rounded; }
.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); }
.md-body table { @apply my-3 w-full border-collapse text-sm; }
.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); }
.md-body h1 {
@apply mt-8 mb-3 text-2xl font-semibold;
}
.md-body h2 {
@apply mt-6 mb-2 text-xl font-semibold;
}
.md-body h3 {
@apply mt-4 mb-2 text-base font-semibold;
}
.md-body p {
@apply my-3;
}
.md-body ul {
@apply my-3 list-disc space-y-1 pl-6;
}
.md-body ol {
@apply my-3 list-decimal space-y-1 pl-6;
}
.md-body blockquote {
@apply my-3 border-l-2 pl-4;
border-color: color-mix(in oklab, currentColor 30%, transparent);
opacity: 0.75;
}
.md-body code {
@apply rounded px-1 py-0.5 text-[0.85em];
background: color-mix(in oklab, currentColor 10%, transparent);
}
.md-body pre {
@apply my-3 overflow-x-auto rounded-lg p-3;
background: color-mix(in oklab, currentColor 6%, transparent);
}
.md-body pre code {
@apply bg-transparent p-0;
}
.md-body a {
color: var(--rd-link, #f59e0b);
@apply underline underline-offset-2;
}
.md-body img {
@apply my-3 max-w-full rounded;
}
.md-body hr {
@apply my-6;
border-color: color-mix(in oklab, currentColor 15%, transparent);
}
.md-body table {
@apply my-3 w-full border-collapse text-sm;
}
.md-body th,
.md-body td {
@apply border px-2 py-1 text-left;
border-color: color-mix(in oklab, currentColor 22%, transparent);
}
+21
View File
@@ -0,0 +1,21 @@
export type PageMode = "page" | "spread";
export function viewCount(count: number, mode: PageMode): number {
return mode === "page" ? count : Math.ceil(count / 2);
}
export function pageIndexToView(i: number, mode: PageMode): number {
return mode === "page" ? i : Math.floor(i / 2);
}
export function viewToPageIndex(v: number, mode: PageMode): number {
return mode === "page" ? v : v * 2;
}
/** 一个 spread 视图要渲染的页,按显示顺序(LTR=[左,右];RTL=[右,左] 即先读页在前)。 */
export function spreadPages(first: number, count: number, rtl: boolean): number[] {
const pages = [first, ...(first + 1 < count ? [first + 1] : [])];
return rtl ? [...pages].reverse() : pages;
}
/** 翻一屏/页;rtl 时方向翻转;越界钳制。 */
export function turnView(v: number, d: number, rtl: boolean, views: number): number {
const step = rtl ? -d : d;
return Math.max(0, Math.min(views - 1, v + step));
}
+6 -7
View File
@@ -1,8 +1,4 @@
export type ProgressSend = (
locator: Record<string, unknown>,
percent: number,
keepalive: boolean,
) => Promise<unknown>;
export type ProgressSend = (locator: Record<string, unknown>, percent: number, keepalive: boolean) => Promise<unknown>;
export interface ProgressSaver {
report: (locator: Record<string, unknown>, percent: number) => void;
@@ -41,11 +37,14 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
lastAt = t;
doSend(false);
} else {
timer = setTimeout(() => {
timer = setTimeout(
() => {
timer = null;
lastAt = Date.now();
doSend(false);
}, intervalMs - (t - lastAt));
},
intervalMs - (t - lastAt),
);
}
},
flush() {
+91 -18
View File
@@ -1,35 +1,108 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
export const RD_THEMES = ["paper", "sepia", "night"] as const;
export type RdTheme = (typeof RD_THEMES)[number];
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0];
export const MARGINS = ["narrow", "medium", "wide"] as const;
export type Margin = (typeof MARGINS)[number];
export const MARGIN_LABEL: Record<Margin, string> = { narrow: "窄", medium: "中", wide: "宽" };
/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */
export const MARGIN_MAXW: Record<Margin, string> = { narrow: "42em", medium: "34em", wide: "26em" };
export const CBZ_MODES = ["strip", "page", "spread"] as const;
export type CbzMode = (typeof CBZ_MODES)[number];
export const CBZ_MODE_LABEL: Record<CbzMode, string> = { strip: "长卷", page: "单页", spread: "双页" };
const KEY = "reader-prefs";
export interface ReaderPrefs {
theme: RdTheme;
sizeIdx: number;
themeMode: "auto" | RdTheme;
text: { sizeIdx: number; lineIdx: number; margin: Margin };
cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number };
epub: { sizeIdx: number; lineIdx: number; marginIdx: number };
}
export const DEFAULT_PREFS: ReaderPrefs = {
themeMode: "auto",
text: { sizeIdx: 2, lineIdx: 2, margin: "medium" },
cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 },
epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 },
};
function idx(v: unknown, len: number, def: number): number {
return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def;
}
/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */
export function parsePrefs(raw: string | null): ReaderPrefs {
let o: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(raw ?? "");
if (parsed && typeof parsed === "object") o = parsed as Record<string, unknown>;
} catch {
/* 首次使用或坏值 */
}
const t = (o.text ?? {}) as Record<string, unknown>;
const c = (o.cbz ?? {}) as Record<string, unknown>;
const e = (o.epub ?? {}) as Record<string, unknown>;
const mode = o.themeMode;
return {
themeMode:
mode === "auto" || RD_THEMES.includes(mode as RdTheme)
? (mode as ReaderPrefs["themeMode"])
: DEFAULT_PREFS.themeMode,
text: {
sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx),
lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx),
margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin,
},
cbz: {
mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode,
rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl,
continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous,
prefetch:
Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3
? (c.prefetch as number)
: DEFAULT_PREFS.cbz.prefetch,
},
epub: {
sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx),
lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx),
marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx),
},
};
}
/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */
export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme {
if (mode !== "auto") return mode;
return globalDark ? "night" : "paper";
}
export function useReaderPrefs() {
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
try {
const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs;
if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length)
return p;
} catch {
/* 首次使用或存了坏值 */
}
return { theme: "sepia", sizeIdx: 2 };
});
const [prefs, setPrefs] = useState<ReaderPrefs>(() => parsePrefs(localStorage.getItem(KEY)));
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []);
return {
prefs,
fontSize: FONT_SIZES[prefs.sizeIdx],
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
bump: (d: number) =>
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
update,
fontSize: FONT_SIZES[prefs.text.sizeIdx],
lineHeight: LINE_HEIGHTS[prefs.text.lineIdx],
marginMaxW: MARGIN_MAXW[prefs.text.margin],
epubFontSize: FONT_SIZES[prefs.epub.sizeIdx],
setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })),
bumpText: (d: number) =>
update((p) => ({
...p,
text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) },
})),
bumpEpub: (d: number) =>
update((p) => ({
...p,
epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) },
})),
};
}
export type ReaderPrefsApi = ReturnType<typeof useReaderPrefs>;
+65
View File
@@ -0,0 +1,65 @@
export interface DayBucket {
day: string;
seconds: number;
}
export interface StatsStore {
record(seconds: number): void;
weekly(): { perDay: DayBucket[]; total: number };
streak(): number;
}
const KEY = "reading-stats";
export type Buckets = Record<string, number>;
export function dayKey(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
export function recordInto(b: Buckets, day: string, seconds: number): Buckets {
return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) };
}
export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } {
const perDay: DayBucket[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const day = dayKey(d);
perDay.push({ day, seconds: b[day] ?? 0 });
}
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
}
export function streakOf(b: Buckets, today: Date): number {
let n = 0;
const d = new Date(today);
if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断
while (b[dayKey(d)] > 0) {
n++;
d.setDate(d.getDate() - 1);
}
return n;
}
function load(): Buckets {
try {
const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}");
return o && typeof o === "object" ? (o as Buckets) : {};
} catch {
return {};
}
}
/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */
export function createLocalStats(): StatsStore {
return {
record(seconds) {
if (seconds <= 0) return;
localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds)));
},
weekly() {
return weeklySum(load(), new Date());
},
streak() {
return streakOf(load(), new Date());
},
};
}
+48
View File
@@ -0,0 +1,48 @@
import { chapterText, type TxtChapter } from "./chapters";
export interface SearchHit {
ch: number;
/** 命中在章内文本的起点 */
pos: number;
snippet: string;
matchStart: number; // snippet 内
matchLen: number;
}
const CTX = 20; // snippet 前后文
/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */
export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] {
const needle = q.trim().toLowerCase();
if (!needle) return [];
const bodies = chapters.map((_, ci) => chapterText(text, chapters, ci).toLowerCase());
const hits: SearchHit[] = [];
for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) {
const body = bodies[ci];
let from = 0;
for (;;) {
const at = body.indexOf(needle, from);
if (at < 0 || hits.length >= limit) break;
const raw = chapterText(text, chapters, ci);
const s = Math.max(0, at - CTX);
const e = Math.min(raw.length, at + needle.length + CTX);
hits.push({
ch: ci,
pos: at,
snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""),
matchStart: at - s + (s > 0 ? 1 : 0),
matchLen: needle.length,
});
from = at + needle.length;
}
}
return hits;
}
export function splitHighlight(snippet: string, matchStart: number, matchLen: number) {
return {
before: snippet.slice(0, matchStart),
match: snippet.slice(matchStart, matchStart + matchLen),
after: snippet.slice(matchStart + matchLen),
};
}
+2 -1
View File
@@ -8,7 +8,8 @@ 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));
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));
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
import { createLocalStats, type StatsStore } from "./readingStats";
const TICK = 30; // 秒:可见期间心跳粒度
/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */
export function useReadingSession(store: StatsStore = createLocalStats()) {
const acc = useRef(0);
const last = useRef(0);
useEffect(() => {
last.current = Date.now(); // 渲染期不取时钟,effect 内初始化起算点
const flush = () => {
const now = Date.now();
if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000;
last.current = now;
if (acc.current >= 1) {
store.record(acc.current);
acc.current = 0;
}
};
const t = setInterval(flush, TICK * 1000);
const onVis = () => flush();
document.addEventListener("visibilitychange", onVis);
globalThis.addEventListener?.("pagehide", onVis);
return () => {
clearInterval(t);
document.removeEventListener("visibilitychange", onVis);
globalThis.removeEventListener?.("pagehide", onVis);
flush();
};
}, [store]);
}
+15 -2
View File
@@ -47,11 +47,24 @@ export default function Login() {
<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" />
<Input
id="login-user"
value={u}
onChange={(e) => setU(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus -- 登录页整页即表单,首字段自动聚焦是通行 UX;规则无按场景豁免选项,保留行为
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" />
<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 ? "登录中…" : "登录"}
+17 -10
View File
@@ -1,11 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Format } from "../api/types";
import { IconArrowLeft } from "../components/icons";
import { btn, btnGhost, formatBadge } from "../components/ui";
import { FormatBadge } from "../components/format-badge";
import { Button } from "../components/ui/button";
import type { ReaderProps } from "../lib/useProgress";
import { createLocalStats } from "../lib/readingStats";
import { useReadingSession } from "../lib/useReadingSession";
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
@@ -26,6 +29,8 @@ export default function Reader() {
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
const stats = useMemo(() => createLocalStats(), []);
useReadingSession(stats);
const book = bookQ.data;
const body = (() => {
@@ -51,19 +56,21 @@ export default function Reader() {
<div className="flex h-full flex-col">
<header
className={
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-stone-800/80 bg-stone-950/80 px-4 text-sm backdrop-blur transition-all duration-300 " +
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-border bg-background/90 px-4 text-sm text-foreground backdrop-blur transition-all duration-300 " +
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
}
>
<Link to="/" className={btnGhost + " shrink-0"}>
<IconArrowLeft className="size-4" />
<Button variant="ghost" size="sm" asChild className="shrink-0">
<Link to="/">
<ArrowLeft className="size-4" />
书架
</Link>
</Button>
<span className="truncate font-medium">{book?.title ?? "…"}</span>
{book && (
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
<span className="hidden shrink-0 items-center gap-2 text-xs text-muted-foreground sm:flex">
{book.library && <span>{book.library}</span>}
<span className={formatBadge(book.format)}>{book.format}</span>
<FormatBadge fmt={book.format} />
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
</span>
)}
@@ -77,13 +84,13 @@ export default function Reader() {
function Msg({ text, retry }: { text: string; retry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
<div>
<p className="mb-3">{text}</p>
{retry && (
<button className={btn} onClick={retry}>
<Button variant="secondary" onClick={retry}>
重试
</button>
</Button>
)}
</div>
</div>
+143 -29
View File
@@ -1,39 +1,79 @@
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, ScanLine, Search, TriangleAlert } from "lucide-react";
import {
ArrowDownUp,
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 { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button";
import { formatBadge } from "@/components/ui";
import { ReadingStatsCard } from "@/components/reading-stats-card";
import { FormatBadge } from "@/components/format-badge";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
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 {
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 GroupMode, type SortKey } from "@/lib/shelf";
import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta";
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))]";
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 }) {
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 onClick={onOpen} className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<button
onClick={onOpen}
className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<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>
<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />
{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-destructive-foreground" title={book.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-destructive-foreground"
title={book.error}
>
<TriangleAlert className="size-3" /> 损坏
</span>
)}
@@ -45,13 +85,20 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
</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)}`}
{book.path.includes("/")
? book.path.slice(0, book.path.lastIndexOf("/"))
: `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
</span>
</button>
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={`《${book.title}》操作`}
>
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
@@ -95,22 +142,34 @@ export default function Shelf() {
const [qRaw, setQRaw] = useState(serverQ);
const [confirmDel, setConfirmDel] = useState<Book | null>(null);
useEffect(() => setQRaw(serverQ), [serverQ]);
// URL 是筛选词的事实来源:回退/前进/程序化改参时把输入框拉回 URL 值。
// 用 React 官方推荐的「渲染期按下发值重置本地 state」替代原先 effect 内的同步 setState
const [prevServerQ, setPrevServerQ] = useState(serverQ);
if (prevServerQ !== serverQ) {
setPrevServerQ(serverQ);
setQRaw(serverQ);
}
useEffect(() => {
if (qRaw.trim() === serverQ) return;
const t = setTimeout(() => {
setSp((prev) => {
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q");
return next;
}, { replace: true });
},
{ replace: true },
);
}, 300);
return () => clearTimeout(t);
}, [qRaw, serverQ, sp, setSp]);
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) });
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() });
@@ -145,14 +204,21 @@ export default function Shelf() {
else next.delete(k);
setSp(next, { replace: true });
};
const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库";
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/85 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)} />
<Input
className="pl-8"
type="search"
aria-label="搜索标题"
placeholder="搜索标题…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -164,7 +230,11 @@ export default function Shelf() {
<DropdownMenuContent align="end">
<DropdownMenuLabel>排序</DropdownMenuLabel>
{SORTS.map((k) => (
<DropdownMenuItem key={k} onSelect={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""}>
<DropdownMenuItem
key={k}
onSelect={() => setParam("sort", k === "added" ? "" : k)}
className={k === sort ? "font-medium text-accent-strong" : ""}
>
{SORT_LABEL[k]}
</DropdownMenuItem>
))}
@@ -172,7 +242,14 @@ export default function Shelf() {
</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)}>
<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>
))}
@@ -180,19 +257,32 @@ export default function Shelf() {
{isAdmin && lib > LIB0 && (
<>
<UploadButton libId={lib} variant="outline" label="上传文件" />
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
<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"] })}>
<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>}
<ReadingStatsCard />
{(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-strong">
<LibraryBig className="size-4 shrink-0" />
@@ -203,23 +293,37 @@ export default function Shelf() {
{continueRows.length > 0 && !searching && (
<section className="mb-8">
<h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- Tailwind preflight 移除了 ul 的 list 样式,Safari/VoiceOver 因此丢列表语义;role="list" 是官方推荐的补偿写法,非冗余 */}
<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` }}>
<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">
<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-strong">{b.title}</span>
<span className="block truncate text-sm font-medium group-hover:text-accent-strong">
{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>
@@ -261,8 +365,12 @@ export default function Shelf() {
{!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>
<h3 className="mt-3 text-sm font-medium">
{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}
</p>
</div>
)}
@@ -271,7 +379,13 @@ export default function Shelf() {
<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)} />
<BookCard
key={b.id}
book={b}
index={i}
onOpen={() => nav(`/book/${b.id}`)}
onDelete={() => setConfirmDel(b)}
/>
))}
</div>
</section>
+6 -1
View File
@@ -47,7 +47,12 @@ export default function AdminLibraries() {
<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)} />
<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>
+30 -6
View File
@@ -4,8 +4,15 @@ 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,
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";
@@ -50,9 +57,17 @@ export default function AdminUsers() {
<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)} />
<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>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem>
@@ -85,7 +100,13 @@ export default function AdminUsers() {
<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
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={x.id === me?.id}
title={x.id === me?.id ? "不能删除自己" : undefined}
>
删除
</Button>
</AlertDialogTrigger>
@@ -96,7 +117,10 @@ export default function AdminUsers() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => del.mutate(x.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
@@ -0,0 +1,53 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/components/theme";
import CbzReader from "@/readers/CbzReader";
const okResponse = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks?.();
// 全局网络打桩:页数接口 + 图片对象 URL + 进度上报(页图数据解不真实也无需:PageImg 有占位态)
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = String(input instanceof Request ? input.url : input);
if (url.includes("/pages")) return okResponse({ count: 3, chapters: null });
if (url.includes("/progress")) return okResponse(null);
return new Response("", { status: 200 }); // 页图 blob 等
});
Object.defineProperty(URL, "createObjectURL", { value: () => "blob:x", configurable: true });
});
const book = {
id: 1,
library_id: 1,
path: "p",
title: "t",
format: "cbz",
state: "ok",
percent: 0,
pages_url: "/pages",
page_url_fmt: "/page/{}",
} as never;
describe("CbzReader 页模式设置区", () => {
it("切「单页」后位置读数 1/3,切「双页」首页不变,切回「长卷」按钮仍可用", async () => {
render(
<ThemeProvider>
<QueryClientProvider client={new QueryClient()}>
<CbzReader book={book} chrome={{ on: true, toggle: () => {}, show: () => {} }} />
</QueryClientProvider>
</ThemeProvider>,
);
await screen.findByLabelText("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
fireEvent.click(await screen.findByRole("button", { name: "更多阅读设置" }));
fireEvent.click(await screen.findByRole("button", { name: "单页" }));
expect(screen.getByText(/^1\/3 · /)).toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "双页" }));
expect(screen.getByText(/^1\/3 · /)).toBeInTheDocument();
// 模式按钮组仍在设置面板内(长卷收进同一展开区)
expect(screen.getByRole("button", { name: "长卷" })).toBeInTheDocument();
});
});
+348 -105
View File
@@ -1,47 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react";
import { DropdownMenu } from "radix-ui";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { BookmarksTab } from "../components/Bookmarks";
import { RdSlider } from "../components/rd-slider";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { ReaderSettings, SettingRow } from "../components/reader-settings";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { fetchObjectUrl } from "../lib/authImage";
import { pageIndexToView, spreadPages, turnView, viewCount, viewToPageIndex, type PageMode } from "../lib/cbzPages";
import { CBZ_MODES, CBZ_MODE_LABEL, resolveRdTheme, useReaderPrefs, type ReaderPrefs } from "../lib/readerPrefs";
import { useGlobalDark } from "../components/theme";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
function useStoredBool(key: string, def: boolean) {
const [v, setV] = useState(() => {
const s = localStorage.getItem(key);
return s === null ? def : s === "1";
});
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
return [v, setV] as const;
}
function useStoredPrefetch() {
const [n, setN] = useState(() => {
const s = localStorage.getItem("cbz-prefetch");
if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2
const v = Number(s);
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
});
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
return [n, setN] as const;
}
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染);换页/重试时的表现与原先一致
const key = url + "#" + nonce;
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setSrc("");
setFailed(false);
}
useEffect(() => {
let dead = false;
fetchObjectUrl(url)
.then((s) => !dead && setSrc(s))
.catch(() => !dead && setFailed(true));
@@ -51,15 +41,14 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
}, [url, nonce]);
if (failed)
return (
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
<div className="bg-muted text-muted-foreground grid place-items-center gap-2 py-8 text-sm" style={{ width }}>
本页加载失败
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
<Button variant="secondary" onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</Button>
</div>
);
if (!src)
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
if (!src) return <div className="animate-pulse bg-muted" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
@@ -101,10 +90,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘
const estLearned = useRef(false); // 估高只学一次(见 measure)
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const mode = pr.prefs.cbz.mode; // strip | page | spread;双向阅读方向独立的 rtl 开关(仅页模式生效)
const patchCbz = useCallback(
(part: Partial<ReaderPrefs["cbz"]>) => pr.update((p) => ({ ...p, cbz: { ...p.cbz, ...part } })),
[pr.update],
);
const continuous = pr.prefs.cbz.continuous; // 连读:滑动可跨章
const prefetch = pr.prefs.cbz.prefetch; // 锁章预读章数 0–3
const rtl = pr.prefs.cbz.rtl;
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
const [pvCur, setPvCur] = useState(0); // 页模式当前页(PageView 上报,供滑条/HUD/位置文本)
const [pvSeek, setPvSeek] = useState<{ page: number; n: number } | null>(null); // 页模式外部跳页(书签/目录/滑动条/恢复进度)
const pvSeekN = useRef(0);
const pvTurn = useRef<((d: number) => void) | null>(null); // 页模式翻页函数(容器键盘转发用)
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo(
@@ -132,11 +134,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => {
if (restored.current || !count || !boxRef.current) return;
if (restored.current || !count || (mode === "strip" && !boxRef.current)) return;
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
}, [count, initialLocator, ph]);
}, [count, initialLocator, ph, mode]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
useEffect(() => {
@@ -203,7 +205,25 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
}
const curRaw = ph.pageAt(top + anchor + vh / 2);
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
// 页模式读数 = PageView 上报的全局页;长卷维持滚动换算
const cur = mode !== "strip" ? pvCur : locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
// 页模式 → 长卷切换后的落位:容器重挂且几何渲染完成后滚动
const [stripSeek, setStripSeek] = useState<{ page: number; n: number } | null>(null);
useEffect(() => {
if (!stripSeek || mode !== "strip" || !boxRef.current || !count) return;
const t = stripSeek.page;
setStripSeek(null);
if (locked && (t < w.start || t >= w.end)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 回长卷的跨章落位须等容器重挂后才能滚动;setCi+pending 与长卷既有「跨章跳页」路径同一实现
setCi(chapterIndexAt(chapters, t));
pending.current = t;
} else {
scrollToPage(t, false);
}
// 容器重挂后 scrollTop 归 0 且无 scroll 事件可刷 → 手动同步换算用的 top,避免沿用切走前的旧读数
setTop(boxRef.current?.scrollTop ?? 0);
}, [stripSeek, mode]);
useEffect(() => {
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
@@ -222,6 +242,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
function goPage(i: number, smooth = true, show = true) {
const t = Math.max(0, Math.min(count - 1, i));
if (show) chrome.show();
// 页模式:交给 PageView 统一落位(含跨章)
if (mode !== "strip") {
setPvSeek({ page: t, n: ++pvSeekN.current });
return;
}
if (locked && (t < w.start || t >= w.end)) {
setCi(chapterIndexAt(chapters, t));
pending.current = t;
@@ -230,6 +255,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
scrollToPage(t, smooth);
}
// 模式切换保持当前页:页↔页换算视图起点,回长卷用现有滚动定位落位
function setMode(m: ReaderPrefs["cbz"]["mode"]) {
if (m === mode) return;
const t = scrub ?? pvCur;
if (m === "strip") {
patchCbz({ mode: m });
setStripSeek({ page: t, n: ++pvSeekN.current }); // 长卷容器重挂后由 clip effect 滚动落位
return;
}
setPvSeek({ page: t, n: ++pvSeekN.current });
patchCbz({ mode: m });
}
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
function turnScreen(d: number) {
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
@@ -248,10 +286,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
return <div className="text-muted-foreground grid h-full place-items-center">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
<div className="text-muted-foreground grid h-full place-items-center p-8 text-center">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
@@ -293,19 +331,50 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- 根容器仅接键盘翻页(含 RTL 方向翻转);交互语义由 aria-label 说明
<div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 根容器聚焦以接方向键翻页;「滚动区可聚焦」通行做法
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full"
data-rd={rd}
onKeyDown={(e) => {
if (mode !== "strip") {
if (e.key === "ArrowRight" || e.key === "PageDown") pvTurn.current?.(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") pvTurn.current?.(-1);
else return;
} else {
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
else return;
}
e.preventDefault();
}}
>
{mode !== "strip" ? (
<PageView
count={count}
chapters={chapters}
locked={locked}
ci={ci}
setCi={setCi}
continuous={continuous}
fmt={fmt}
rtl={rtl}
mode={mode}
saver={saver}
chrome={chrome}
pvSeek={pvSeek}
onSeekDone={() => setPvSeek(null)}
onCur={setPvCur}
onTurnHook={pvTurn}
/>
) : (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 手势区(两侧翻屏/中间收放 chrome);键盘翻页由根容器统一提供
<div
ref={attachBox}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);「滚动区可聚焦」通行做法
tabIndex={0}
onScroll={onScroll}
onClick={onZoneClick}
onPointerDown={(e) => {
@@ -330,11 +399,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
style={{ top: ph.offset(w.end) - anchor, height: vh }}
>
{w.end >= count ? (
<span className="text-sm text-stone-500">— 全书完 —</span>
<span className="text-muted-foreground text-sm">— 全书完 —</span>
) : (
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
<Button variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
本章完 · 下一章 <ChevronRight size={14} className="inline" />
</button>
</Button>
)}
</div>
)}
@@ -353,100 +422,105 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
)}
</div>
</div>
)}
{chrome.on ? (
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={Math.max(0, count - 1)}
value={scrub ?? cur}
onChange={(e) => {
const v = Number(e.target.value);
<div className="absolute inset-x-0 bottom-0 z-20">
<ReaderSettings
themeMode={pr.prefs.themeMode}
onThemeMode={pr.setThemeMode}
slider={{
value: scrub ?? cur,
max: Math.max(0, count - 1),
onChange: (v) => {
setScrub(v);
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
},
ariaLabel: "页码跳转",
}}
onPointerUp={() => setScrub(null)}
onPointerCancel={() => setScrub(null)}
onBlur={() => setScrub(null)}
className="rd-range"
aria-label="页码跳转"
/>
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
<button
className="rd-btn inline-flex items-center gap-1.5"
position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
left={
<>
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
>
<PanelLeft size={14} /> 导航
</button>
</Button>
{chapters && (
<>
<span className="text-xs tabular-nums opacity-60">
<span className="text-xs tabular-nums text-muted-foreground">
{ci + 1}/{chapters.length}
</span>
<button
className="rd-btn inline-flex items-center gap-0.5"
<Button
variant="ghost"
size="sm"
disabled={ci === 0}
onClick={() => goPage(chapters[ci - 1].start)}
>
<ChevronLeft size={14} /> 上一章
</button>
<button
className="rd-btn inline-flex items-center gap-0.5"
</Button>
<Button
variant="ghost"
size="sm"
disabled={ci === chapters.length - 1}
onClick={() => goPage(chapters[ci + 1].start)}
>
下一章 <ChevronRight size={14} />
</button>
</Button>
</>
)}
</>
}
extra={
<>
<SettingRow label="翻页模式">
{CBZ_MODES.map((m) => (
<Button key={m} variant={mode === m ? "secondary" : "ghost"} size="xs" onClick={() => setMode(m)}>
{CBZ_MODE_LABEL[m]}
</Button>
))}
</SettingRow>
{mode !== "strip" && (
<SettingRow label="阅读方向">
<Button variant={rtl ? "secondary" : "ghost"} size="xs" onClick={() => patchCbz({ rtl: !rtl })}>
右开本(RTL)
</Button>
</SettingRow>
)}
{chapters && chapters.length >= 2 && (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="rd-btn ml-auto inline-flex items-center gap-1.5" aria-label="更多阅读选项">
<Settings2 size={14} /> 更多
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
side="top"
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
<SettingRow label="连读(跨章)">
<Button
variant={continuous ? "secondary" : "ghost"}
size="xs"
onClick={() => patchCbz({ continuous: !continuous })}
>
<DropdownMenu.CheckboxItem
className="rd-row rd-highlight cursor-pointer"
style={{ display: "flex", alignItems: "center", gap: 8 }}
checked={continuous}
onCheckedChange={(v) => setContinuous(!!v)}
>
<BookOpenCheck size={14} /> 连读(滑动跨章)
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
<Check size={14} />
</DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
{continuous ? "开" : "关"}
</Button>
</SettingRow>
)}
{locked && (
<RdSlider
label="预读:锁章末尾时提前挂载其后 N 章"
ariaLabel="预读章数"
<SettingRow label="预读章数">
<Slider
className="w-32"
min={0}
max={3}
value={prefetch}
onChange={setPrefetch}
step={1}
value={[prefetch]}
onValueChange={([v]) => patchCbz({ prefetch: v })}
aria-label="预读章数"
/>
</SettingRow>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
</>
}
/>
</div>
) : (
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-background/85 px-3 py-1.5 text-xs tabular-nums text-foreground shadow-lg ring-1 ring-border backdrop-blur">
<span>
{cur + 1}/{count}
</span>
@@ -475,7 +549,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
className={
"min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" +
(i === ci ? " bg-accent text-accent-foreground" : "")
}
onClick={() => {
goPage(c.start);
setNav(null);
@@ -493,3 +570,169 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
</div>
);
}
type Chrome = { on: boolean; toggle: () => void; show: () => void };
/** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */
function PageView({
count,
chapters,
locked,
ci,
setCi,
continuous,
fmt,
rtl,
mode,
saver,
chrome,
pvSeek,
onSeekDone,
onCur,
onTurnHook,
}: {
count: number;
chapters: { title: string; start: number }[] | null;
locked: boolean;
ci: number;
setCi: (i: number) => void;
continuous: boolean;
fmt: string;
rtl: boolean;
mode: PageMode;
saver: ReturnType<typeof useProgressSaver>;
chrome: Chrome;
pvSeek: { page: number; n: number } | null;
onSeekDone: () => void;
onCur: (p: number) => void;
onTurnHook: { current: ((d: number) => void) | null };
}) {
// 章窗口:locked 时视图范围 = 本章页;continuous/扁平 = 全书
const win =
locked && chapters
? { start: chapters[ci].start, end: ci + 1 < chapters.length ? chapters[ci + 1].start : count }
: { start: 0, end: count };
const winViews = viewCount(Math.max(0, win.end - win.start), mode);
const [v, setV] = useState(0);
const [showEndCard, setShowEndCard] = useState(false);
const pend = useRef<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [cw, setCw] = useState(0);
// 容器宽度(单页=宽度全用,双页=对半)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setCw(el.clientWidth));
ro.observe(el);
return () => ro.disconnect();
}, []);
const pageW = mode === "spread" ? Math.min(Math.floor(cw / 2), MAX_W) : Math.min(cw, MAX_W);
// 外部跳页(pvSeek):锁章时切章,等 ci 生效再落位(pending ref 模式,与 strip 路径同一惯性)
useEffect(() => {
if (pvSeek == null) return;
pend.current = pvSeek.page;
if (locked && chapters) setCi(chapterIndexAt(chapters, pvSeek.page));
onSeekDone();
}, [pvSeek, locked, chapters, setCi, onSeekDone]);
useEffect(() => {
if (pend.current === null || winViews === 0) return;
const t = pend.current;
pend.current = null;
setShowEndCard(false);
const at = Math.max(win.start, Math.min(win.end - 1, t));
setV(pageIndexToView(at - win.start, mode));
}, [pvSeek, ci, winViews, win.start, win.end, mode]);
const page0 = win.start + viewToPageIndex(v, mode);
// 当前页由 PageView 上报(供滑条/HUD/位置文本);进度随页落(与长卷路径同一 report 语义)
useEffect(() => {
onCur(page0);
saver.report({ page: page0 }, (page0 + 1) / count);
}, [page0, onCur]); // saver/report 每页一次,依赖从简(同 fetchObjectUrl 前载语义)
useEffect(() => {
// 相邻页预载(±2)
for (const p of [page0 - 2, page0 + 2])
if (p >= 0 && p < count) fetchObjectUrl(formatPageUrl(fmt, p)).catch(() => {});
}, [page0, fmt, count]);
function turn(d: number) {
chrome.show();
const fwd = rtl ? d < 0 : d > 0;
if (showEndCard) {
if (fwd) advanceChapter();
else {
setShowEndCard(false);
setV(winViews - 1);
}
return;
}
const nv = turnView(v, d, rtl, winViews);
if (nv !== v) {
setV(nv);
return;
}
if (fwd && v >= winViews - 1) {
// 视图尽头:章末卡片(锁章止步/全书完)或跨章
if (win.end >= count) setShowEndCard(true);
else if (locked && !continuous) setShowEndCard(true);
else advanceChapter();
}
}
function advanceChapter() {
if (!chapters || ci + 1 >= chapters.length) return; // 全书完:停在末页
setCi(ci + 1);
setShowEndCard(false);
setV(0);
}
useEffect(() => {
onTurnHook.current = turn;
return () => {
onTurnHook.current = null;
};
});
function onZone(e: MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
if (window.getSelection()?.toString()) return;
const r = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
if (x < 0.3) turn(-1);
else if (x > 0.7) turn(1);
else chrome.toggle();
}
const pages = spreadPages(page0, win.end, rtl);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 手势区(两侧翻页/中间收放 chrome);键盘翻页由外层根容器统一提供
<div
ref={rootRef}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 容器可聚焦以接键盘翻页;与长卷滚动容器同一「滚动区可聚焦」理由
tabIndex={0}
aria-label="漫画页阅读视图"
className="relative h-full cursor-pointer select-none overflow-hidden"
style={{ background: "var(--rd-bg)", color: "var(--rd-fg)" }}
onClick={onZone}
>
{count < 1 || winViews < 1 ? null : showEndCard ? (
<div className="grid h-full w-full place-items-center">
{win.end >= count ? (
<span className="text-sm text-muted-foreground">— 全书完 —</span>
) : (
<Button variant="secondary" onClick={advanceChapter}>
本章完 · 下一章 <ChevronRight size={14} className="inline" />
</Button>
)}
</div>
) : (
<div className={`flex h-full w-full items-center justify-center ${mode === "spread" ? "gap-1" : ""}`}>
{pages.map((p) => (
<PageImg key={p} url={formatPageUrl(fmt, p)} width={pageW} onLoaded={() => {}} />
))}
</div>
)}
</div>
);
}
+134 -21
View File
@@ -3,8 +3,11 @@ import { useEffect, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { ReaderSettings, SettingRow } from "../components/reader-settings";
import { Button } from "@/components/ui/button";
import { toast } from "../components/Toaster";
import { useGlobalDark } from "../components/theme";
import { FONT_SIZES, LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
@@ -16,6 +19,10 @@ interface EpubRendition {
next(): unknown;
prev(): unknown;
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
themes: {
register(name: string, rules: Record<string, unknown>): void;
select(name: string): void;
};
destroy(): void;
}
interface EpubBookLike {
@@ -30,20 +37,37 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
return m.default as (data: Uint8Array) => EpubBookLike;
}
export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) {
/** epubjs iframe 取不到宿主 CSS 变量 → 三主题 bg/fg 用 index.css 的具体色值常量。 */
const THEME_CSS = {
paper: { bg: "#faf8f4", fg: "#292524" },
sepia: { bg: "#f0e6d2", fg: "#433a29" },
night: { bg: "#171512", fg: "#c6c0b6" },
} as const;
export default function EpubReader({ book, initialLocator, initialPercent }: Omit<ReaderProps, "chrome">) {
const hostRef = useRef<HTMLDivElement>(null);
const rendRef = useRef<EpubRendition | null>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const [nav, setNav] = useState(false);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
const [ready, setReady] = useState(false);
// 重新加载(换书/重试)时先清错误与就绪态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染);首挂载 key 相等,重置为空操作,行为等价
const loadKey = (book.file_url ?? "") + "#" + nonce;
const [prevLoadKey, setPrevLoadKey] = useState(loadKey);
if (prevLoadKey !== loadKey) {
setPrevLoadKey(loadKey);
setErr("");
setReady(false);
}
useEffect(() => {
let dead = false;
let bookObj: EpubBookLike | null = null;
setErr("");
setReady(false);
(async () => {
const ePub = await loadEpub();
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
@@ -82,40 +106,77 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
};
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
// 排版/主题映射:rd 或 prefs.epub 变化时重新 register+select(iframe 内须用具体色值)
useEffect(() => {
const r = rendRef.current;
if (!r || !ready) return;
const { bg, fg } = THEME_CSS[rd];
r.themes.register("booklib", {
body: {
background: bg,
color: fg,
"line-height": String(LINE_HEIGHTS[pr.prefs.epub.lineIdx]),
"font-size": `${FONT_SIZES[pr.prefs.epub.sizeIdx]}px`,
padding: `${[0.5, 1, 1.75][pr.prefs.epub.marginIdx]}rem 0`,
},
});
r.themes.select("booklib");
}, [rd, pr.prefs.epub, ready]);
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div className="bg-muted grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
<p className="text-destructive mb-3">{err}</p>
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
重试
</button>
</Button>
</div>
</div>
);
return (
<div className="relative grid h-full grid-rows-[1fr_auto]">
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
<div ref={hostRef} className="absolute inset-0" />
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
<div className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground" data-rd={rd}>
<div className="bg-background ring-border relative mx-auto min-h-0 w-full max-w-4xl shadow-2xl ring-1">
<div ref={hostRef} className="absolute inset-0" style={{ background: "var(--rd-bg)" }} />
{!ready && !err && (
<div className="bg-muted text-muted-foreground grid h-full place-items-center">EPUB 加载中…</div>
)}
</div>
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
<ReaderSettings
themeMode={pr.prefs.themeMode}
onThemeMode={pr.setThemeMode}
left={
<>
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.prev()}>
<ChevronLeft size={14} /> 上一页
</button>
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.next()}>
</Button>
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.next()}>
下一页 <ChevronRight size={14} />
</button>
<button
className="rd-btn inline-flex items-center gap-1.5"
</Button>
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
</div>
</Button>
</>
}
showFont
onFont={pr.bumpEpub}
fontIdx={pr.prefs.epub.sizeIdx}
extra={
<>
<FontSizeRow pr={pr} />
<LineHeightRow pr={pr} />
<MarginRow pr={pr} />
</>
}
/>
{nav && (
<ReaderNav
tab="bm"
@@ -137,3 +198,55 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
</div>
);
}
/** 字号档位按钮组(EPUB 独立档位,存 prefs.epub.sizeIdx)。 */
function FontSizeRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
return (
<SettingRow label="字号">
{FONT_SIZES.map((fs, i) => (
<Button
key={fs}
variant={pr.prefs.epub.sizeIdx === i ? "secondary" : "ghost"}
size="xs"
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, sizeIdx: i } }))}
>
{fs}
</Button>
))}
</SettingRow>
);
}
/** EPUB 行距/边距档位(存 prefs.epub)。 */
function LineHeightRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
return (
<SettingRow label="行距">
{LINE_HEIGHTS.map((lh, i) => (
<Button
key={i}
variant={pr.prefs.epub.lineIdx === i ? "secondary" : "ghost"}
size="xs"
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, lineIdx: i } }))}
>
{lh}
</Button>
))}
</SettingRow>
);
}
function MarginRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
return (
<SettingRow label="边距">
{MARGINS.map((m, i) => (
<Button
key={m}
variant={pr.prefs.epub.marginIdx === i ? "secondary" : "ghost"}
size="xs"
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, marginIdx: i } }))}
>
{MARGIN_LABEL[m]}
</Button>
))}
</SettingRow>
);
}
+69 -28
View File
@@ -4,14 +4,14 @@ import * as pdfjs from "pdfjs-dist";
import { apiRaw } from "../api/client";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { ReaderSettings } from "../components/reader-settings";
import { Button } from "@/components/ui/button";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// Vite 原生 worker 语法;pdfjs v5 要求 module worker
pdfjs.GlobalWorkerOptions.workerPort = new Worker(
new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url),
{ type: "module" },
);
pdfjs.GlobalWorkerOptions.workerPort = new Worker(new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url), {
type: "module",
});
type RenderTask = { cancel(): void; promise: Promise<void> };
@@ -29,10 +29,17 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0); // 仅加载重试
const [redraw, setRedraw] = useState(0); // 仅 resize 重绘(不重新下载文档)
// 重新加载(换书/重试)时先清错误:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染);首挂载 key 相等,重置为空操作,行为等价
const loadKey = (book.file_url ?? "") + "#" + nonce;
const [prevLoadKey, setPrevLoadKey] = useState(loadKey);
if (prevLoadKey !== loadKey) {
setPrevLoadKey(loadKey);
setErr("");
}
useEffect(() => {
let dead = false;
setErr("");
(async () => {
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
if (dead) return;
@@ -118,52 +125,86 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div className="bg-muted grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
<p className="text-destructive mb-3">{err}</p>
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
重试
</button>
</Button>
</div>
</div>
);
if (num === 0) return <div className="grid h-full place-items-center text-stone-500">PDF 加载中…</div>;
if (num === 0) return <div className="text-muted-foreground grid h-full place-items-center">PDF 加载中…</div>;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- 根容器接键盘翻页;交互语义由 aria-label 说明
<div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 根容器聚焦以接方向键翻页;「滚动区可聚焦」通行做法
tabIndex={0}
onKeyDown={onKey}
aria-label="PDF 阅读器,可用左右方向键翻页"
className="relative grid h-full grid-rows-[1fr_auto] focus:outline-none"
className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground focus:outline-none focus-visible:outline-hidden"
>
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-stone-900/70 p-4">
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
<div ref={wrapRef} className="bg-muted/50 flex min-h-0 items-start justify-center overflow-y-auto p-4">
<canvas ref={canvasRef} className="ring-border max-w-3xl rounded-md shadow-2xl ring-1" />
</div>
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
<ReaderSettings
left={
<>
<Button
variant="ghost"
size="sm"
className="gap-0.5"
disabled={page === 0}
onClick={() => setPage(0)}
aria-label="第一页"
>
<ChevronsLeft size={14} /> 首页
</button>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
</Button>
<Button
variant="ghost"
size="sm"
className="gap-0.5"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={14} /> 上一页
</button>
<span className="min-w-16 text-center tabular-nums text-stone-400">
</Button>
<span className="min-w-16 text-center text-muted-foreground tabular-nums">
{page + 1}/{num}
</span>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
<Button
variant="ghost"
size="sm"
className="gap-0.5"
disabled={page >= num - 1}
onClick={() => setPage((p) => Math.min(num - 1, p + 1))}
>
下一页 <ChevronRight size={14} />
</button>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
</Button>
<Button
variant="ghost"
size="sm"
className="gap-0.5"
disabled={page >= num - 1}
onClick={() => setPage(num - 1)}
aria-label="最后一页"
>
<ChevronsRight size={14} /> 末页
</button>
<button
className="rd-btn inline-flex items-center gap-1.5"
</Button>
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
</div>
</Button>
</>
}
/>
{nav && (
<ReaderNav
tab="bm"
+233 -116
View File
@@ -1,38 +1,52 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { apiRaw } from "../api/client";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { btn } from "../components/ui";
import { ReaderSettings, SettingRow } from "../components/reader-settings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useGlobalDark } from "../components/theme";
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
import {
FONT_SIZES,
RD_THEMES,
RD_THEME_LABEL,
useReaderPrefs,
type RdTheme,
} from "../lib/readerPrefs";
import { LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
import { searchChapters, splitHighlight, type SearchHit } from "../lib/search";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
type Prefs = ReturnType<typeof useReaderPrefs>;
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
function useFileText(url: string) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
// url/nonce 变化时先落回加载态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染);重试时的表现与原先一致
const key = url + "#" + nonce;
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setText(null);
setErr("");
}
useEffect(() => {
let dead = false;
apiRaw(url)
.then((r) => r.arrayBuffer())
.then((b) => !dead && setText(decodeText(b)))
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
.then((r) => {
if (dead) return;
if (!r.ok) {
r.text().then((t) => {
if (!dead) setErr(t || `HTTP ${r.status}`);
});
return;
}
r.arrayBuffer().then((buf) => {
if (dead) return;
setText(decodeText(buf));
});
})
.catch((e) => {
if (!dead) setErr(e instanceof Error ? e.message : "加载失败");
});
return () => {
dead = true;
};
@@ -42,86 +56,19 @@ function useFileText(url: string) {
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
<div>
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
{!onRetry && <p className="text-stone-500">{text}</p>}
<p className="mb-3">{text}</p>
{onRetry && (
<button className={btn} onClick={onRetry}>
<Button variant="secondary" onClick={onRetry}>
重试
</button>
</Button>
)}
</div>
</div>
);
}
/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
function ReaderSheet({
pr,
frac,
onSeek,
children,
}: {
pr: Prefs;
frac: number;
onSeek: (f: number) => void;
children?: ReactNode;
}) {
return (
<div className="rd-sheet rd-divider relative z-20 shrink-0 border-t px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={1000}
value={Math.round(frac * 1000)}
onChange={(e) => onSeek(Number(e.target.value) / 1000)}
className="rd-range"
aria-label="阅读进度"
/>
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">
{Math.round(frac * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2">
{children}
<div className="ml-auto flex items-center gap-1.5">
<div className="flex items-center gap-1.5" role="group" aria-label="阅读主题">
{RD_THEMES.map((t) => (
<button
key={t}
aria-label={`${RD_THEME_LABEL[t]}色主题`}
aria-pressed={pr.prefs.theme === t}
onClick={() => pr.setTheme(t)}
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
style={{
background: SWATCH[t],
boxShadow:
pr.prefs.theme === t
? "0 0 0 2px var(--rd-link)"
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
}}
/>
))}
</div>
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
A−
</button>
<button
className="rd-btn"
onClick={() => pr.bump(1)}
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
aria-label="加大字号"
>
A+
</button>
</div>
</div>
</div>
);
}
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
@@ -139,6 +86,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const [nav, setNav] = useState(false);
const restored = useRef(false);
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const [frac, setFrac] = useState(0);
const { text, err, retry } = useFileText(book.file_url ?? "");
@@ -172,7 +121,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null) return <LoadState text="正文加载中…" />;
return (
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;正文滚轮/拖选不涉及键盘操作 */}
<div
ref={boxRef}
onScroll={onScroll}
@@ -180,22 +130,44 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
>
<article
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
style={{ fontSize: pr.fontSize }}
className="md-body mx-auto max-w-[46rem]"
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}
dangerouslySetInnerHTML={{ __html: html ?? "" }}
/>
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
<button
className="rd-btn inline-flex items-center gap-1.5"
<ReaderSettings
themeMode={pr.prefs.themeMode}
onThemeMode={pr.setThemeMode}
slider={{
value: Math.round(frac * 1000),
max: 1000,
onChange: (v) => seek(v / 1000),
ariaLabel: "阅读进度",
}}
position={`${Math.round(frac * 100)}%`}
left={
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
</ReaderSheet>
</Button>
}
showFont
onFont={pr.bumpText}
fontIdx={pr.prefs.text.sizeIdx}
extra={
<>
<LineHeightRow pr={pr} />
<MarginRow pr={pr} />
</>
}
/>
)}
{nav && (
<ReaderNav
@@ -217,6 +189,40 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
);
}
/** 行距五档按钮组(text/epub 各自消费 lineIdx)。 */
function LineHeightRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
const cur = epub ? pr.prefs.epub.lineIdx : pr.prefs.text.lineIdx;
const patch = (i: number) =>
pr.update((p) => (epub ? { ...p, epub: { ...p.epub, lineIdx: i } } : { ...p, text: { ...p.text, lineIdx: i } }));
return (
<SettingRow label="行距">
{LINE_HEIGHTS.map((lh, i) => (
<Button key={i} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
{lh}
</Button>
))}
</SettingRow>
);
}
/** 边距三档按钮组(text/epub 各自消费 margin/marginIdx)。 */
function MarginRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
const cur = epub ? pr.prefs.epub.marginIdx : MARGINS.indexOf(pr.prefs.text.margin);
const patch = (i: number) =>
pr.update((p) =>
epub ? { ...p, epub: { ...p.epub, marginIdx: i } } : { ...p, text: { ...p.text, margin: MARGINS[i] } },
);
return (
<SettingRow label="边距">
{MARGINS.map((m, i) => (
<Button key={m} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
{MARGIN_LABEL[m]}
</Button>
))}
</SettingRow>
);
}
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
@@ -228,7 +234,11 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const [ci, setCi] = useState(0);
const [frac, setFrac] = useState(0);
const [nav, setNav] = useState<null | NavTab>(null);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const { text, err, retry } = useFileText(book.file_url ?? "");
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -241,6 +251,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const rawCh = Number(initialLocator?.ch);
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
targetCi.current = Math.floor(rawCh);
// eslint-disable-next-line react-hooks/set-state-in-effect -- 首章渲染后按缓存 locator 落位阅读进度:ci 落位与下方滚动定位 effect/refs 协作,生命周期与恢复进度语义同源,保留带理由
setCi(Math.floor(rawCh));
if (hasF) restoreFrac.current = f;
} else if (hasF) {
@@ -264,6 +275,15 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
// 全书搜索:250ms 防抖,空 query 在同一 timer 内清空结果(避免 effect 内同步 setState)
useEffect(() => {
if (!chapters || !text) return;
const t = setTimeout(() => {
setHits(query.trim() ? searchChapters(text, chapters, query) : null);
}, 250);
return () => clearTimeout(t);
}, [query, chapters, text]);
function goChapter(i: number, intra = 0) {
if (!chapters) return;
restoreFrac.current = intra > 0 ? intra : null;
@@ -294,7 +314,8 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
return (
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;点按是唯一唤出途径,键盘用户由常驻工具条提供全部控制 */}
<div
ref={boxRef}
onScroll={onScroll}
@@ -302,44 +323,65 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
>
<pre
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
className="mx-auto break-words whitespace-pre-wrap"
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight, textAlign: "justify", maxWidth: pr.marginMaxW }}
>
{chapterText(text, chapters, ci)}
</pre>
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
<button
className="rd-btn inline-flex items-center gap-1.5"
<ReaderSettings
themeMode={pr.prefs.themeMode}
onThemeMode={pr.setThemeMode}
slider={{
value: Math.round(total * 1000),
max: 1000,
onChange: (v) => seek(v / 1000),
ariaLabel: "阅读进度",
}}
position={`${Math.round(total * 100)}%`}
left={
<>
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
>
<PanelLeft size={14} /> 导航
</button>
</Button>
{chapters.length > 1 && (
<>
<span className="text-xs opacity-60 tabular-nums">
<span className="text-xs tabular-nums text-muted-foreground">
{ci + 1}/{chapters.length}
</span>
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === 0}
onClick={() => goChapter(ci - 1)}
>
<Button variant="ghost" size="sm" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
<ChevronLeft size={14} /> 上一章
</button>
<button
className="rd-btn inline-flex items-center gap-0.5"
</Button>
<Button
variant="ghost"
size="sm"
disabled={ci === chapters.length - 1}
onClick={() => goChapter(ci + 1)}
>
下一章 <ChevronRight size={14} />
</button>
</Button>
</>
)}
</ReaderSheet>
</>
}
showFont
onFont={pr.bumpText}
fontIdx={pr.prefs.text.sizeIdx}
extra={
<>
<LineHeightRow pr={pr} />
<MarginRow pr={pr} />
</>
}
/>
)}
{nav && (
<ReaderNav
@@ -356,6 +398,19 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
}}
/>
}
search={
<SearchPanel
query={query}
hits={hits}
chapters={chapters}
onQuery={setQuery}
onJump={(h) => {
const len = chapterText(text, chapters, h.ch).length;
goChapter(h.ch, Math.min(1, Math.max(0, h.pos / Math.max(1, len))));
setNav(null);
}}
/>
}
toc={
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
chapters.length > 1 && (
@@ -364,7 +419,10 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
className={
"min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" +
(i === ci ? " bg-accent text-accent-foreground" : "")
}
onClick={() => {
goChapter(i);
setNav(null);
@@ -382,3 +440,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
</div>
);
}
/** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */
function SearchPanel({
query,
hits,
chapters,
onQuery,
onJump,
}: {
query: string;
hits: SearchHit[] | null;
chapters: { title: string; start: number }[];
onQuery: (q: string) => void;
onJump: (h: SearchHit) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []); // autofocus 以 effect 实现,规避 jsx-a11y/no-autofocus 与打断首帧渲染
return (
<div className="flex h-full flex-col">
<Input
ref={inputRef}
placeholder="搜索全书"
value={query}
onChange={(e) => onQuery(e.target.value)}
aria-label="搜索全书"
/>
<div className="mt-2 min-h-0 flex-1 overflow-y-auto">
{hits == null && <p className="p-2 text-xs text-muted-foreground">输入关键词检索全书</p>}
{hits != null && hits.length === 0 && <p className="p-2 text-xs text-muted-foreground">无结果</p>}
{hits != null && hits.length > 0 && (
<ul className="space-y-0.5">
{hits.map((h, i) => {
const { before, match, after } = splitHighlight(h.snippet, h.matchStart, h.matchLen);
return (
<li key={`${h.ch}-${h.pos}-${i}`}>
<button
className="w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => onJump(h)}
>
<span className="block truncate text-xs text-muted-foreground">
{i + 1}. {chapters[h.ch].title}
</span>
<span className="block truncate">
{before}
<mark className="rounded px-0.5 bg-accent text-accent-foreground">{match}</mark>
{after}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { spreadPages, pageIndexToView, turnView, viewCount, viewToPageIndex } from "../src/lib/cbzPages";
describe("cbzPages", () => {
it("page mode: one page per view", () => {
expect(viewCount(5, "page")).toBe(5);
expect(viewToPageIndex(2, "page")).toBe(2);
expect(pageIndexToView(2, "page")).toBe(2);
});
it("spread mode: pairs [0,1] [2,3] [4]", () => {
expect(viewCount(5, "spread")).toBe(3);
expect(pageIndexToView(3, "spread")).toBe(1);
expect(viewToPageIndex(1, "spread")).toBe(2);
expect(spreadPages(2, 5, false)).toEqual([2, 3]);
expect(spreadPages(4, 5, false)).toEqual([4]); // 落单页
expect(spreadPages(2, 5, true)).toEqual([3, 2]); // RTL 右页为先
});
it("turnView clamps and RTL flips direction", () => {
expect(turnView(0, 1, false, 3)).toBe(1);
expect(turnView(2, 1, false, 3)).toBe(2); // 右边界钳制
expect(turnView(0, -1, false, 3)).toBe(0); // 左边界钳制
expect(turnView(1, 1, true, 3)).toBe(0); // RTL:d=+1 变后退
});
});
+6 -2
View File
@@ -41,8 +41,12 @@ it("短文无标记 → 单节,chapterText 即全文", () => {
it("卷不感知:只按章切,卷题行留在正文里", () => {
const t =
"第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) +
"\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300);
"第一卷 天云世界\n第一章 铜镜\n" +
pad(500) +
"\n第二章 修炼\n" +
pad(500) +
"\n\n\n第二卷 仙界\n第三章 飞升\n" +
pad(300);
const chs = splitChapters(t);
expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]);
expect(chs[0].start).toBe(t.indexOf("第一章"));
+13 -2
View File
@@ -4,8 +4,19 @@ import type { Book } from "../src/api/types";
let seq = 1;
const mk = (path: string): Book => ({
id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0,
state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover",
id: seq++,
library_id: 1,
path,
title: path,
format: "cbz",
size: 1,
mtime: 1,
pages: 0,
state: "ready",
error: "",
added_at: "",
percent: 0,
cover_url: "/api/books/0/cover",
});
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PREFS, parsePrefs, resolveRdTheme } from "../src/lib/readerPrefs";
describe("parsePrefs", () => {
it("returns defaults for null/garbage", () => {
expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
expect(parsePrefs("{oops")).toEqual(DEFAULT_PREFS);
});
it("merges partial legacy-free structures with defaults", () => {
const p = parsePrefs(JSON.stringify({ themeMode: "night", cbz: { mode: "page" } }));
expect(p.themeMode).toBe("night");
expect(p.cbz).toEqual({ ...DEFAULT_PREFS.cbz, mode: "page" });
expect(p.text).toEqual(DEFAULT_PREFS.text);
});
it("rejects out-of-range values back to defaults", () => {
const p = parsePrefs(
JSON.stringify({
text: { sizeIdx: 99, lineIdx: -1, margin: "huge" },
cbz: { prefetch: 7 },
epub: { sizeIdx: 2, lineIdx: 0, marginIdx: 9 },
}),
);
expect(p.text).toEqual(DEFAULT_PREFS.text);
expect(p.cbz.prefetch).toBe(DEFAULT_PREFS.cbz.prefetch);
expect(p.epub.marginIdx).toBe(DEFAULT_PREFS.epub.marginIdx);
});
});
describe("resolveRdTheme", () => {
it("auto follows global dark/light", () => {
expect(resolveRdTheme("auto", true)).toBe("night");
expect(resolveRdTheme("auto", false)).toBe("paper");
});
it("manual overrides global", () => {
expect(resolveRdTheme("sepia", true)).toBe("sepia");
expect(resolveRdTheme("night", false)).toBe("night");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { createLocalStats, dayKey, recordInto, streakOf, weeklySum, type Buckets } from "../src/lib/readingStats";
describe("dayKey", () => {
it("本地时区 YYYY-MM-DD", () => {
expect(dayKey(new Date(2026, 0, 5))).toBe("2026-01-05");
expect(dayKey(new Date(2026, 10, 23))).toBe("2026-11-23");
});
});
describe("recordInto", () => {
it("不可变累加", () => {
const b: Buckets = { "2026-01-01": 60 };
const nb = recordInto(b, "2026-01-01", 30);
expect(nb["2026-01-01"]).toBe(90);
expect(b["2026-01-01"]).toBe(60); // 原桶不变
expect(recordInto(nb, "2026-01-02", 5)["2026-01-02"]).toBe(5);
expect(recordInto(nb, "2026-01-03", -5)).toEqual({ "2026-01-01": 90, "2026-01-03": 0 }); // 负值钳 0
});
});
describe("weeklySum", () => {
it("近 7 天含今天、缺日补 0、合计", () => {
const today = new Date(2026, 8, 16); // 2026-09-16(周三)
const b: Buckets = { "2026-09-16": 120, "2026-09-12": 300 };
const w = weeklySum(b, today);
expect(w.perDay).toHaveLength(7);
expect(w.perDay[6]).toEqual({ day: "2026-09-16", seconds: 120 });
expect(w.perDay[2]).toEqual({ day: "2026-09-12", seconds: 300 });
expect(w.perDay[0].seconds).toBe(0);
expect(w.total).toBe(420);
});
});
describe("streakOf", () => {
it("今天有记录 → 连续天数", () => {
const today = new Date(2026, 8, 16);
const b: Buckets = { "2026-09-16": 10, "2026-09-15": 10, "2026-09-14": 10 };
expect(streakOf(b, today)).toBe(3);
});
it("今天无但昨天有 → 连续天数(含昨天)", () => {
const today = new Date(2026, 8, 16);
const b: Buckets = { "2026-09-15": 10, "2026-09-14": 10 };
expect(streakOf(b, today)).toBe(2);
});
it("断档即止", () => {
const today = new Date(2026, 8, 16);
const b: Buckets = { "2026-09-15": 10, "2026-09-13": 10 };
expect(streakOf(b, today)).toBe(1);
});
it("空 → 0", () => {
const today = new Date(2026, 8, 16);
expect(streakOf({}, today)).toBe(0);
expect(streakOf({ "2026-09-14": 10 }, today)).toBe(0);
});
});
describe("createLocalStats", () => {
it("record→weekly/streak 走 localStorage(内存桩)", () => {
const mem: Record<string, string> = {};
globalThis.localStorage ??= {
getItem: (k: string) => (k in mem ? mem[k] : null),
setItem: (k: string, v: string) => (mem[k] = String(v)),
clear: () => Object.keys(mem).forEach((k) => delete mem[k]),
} as unknown as typeof localStorage;
globalThis.localStorage.clear();
const s = createLocalStats();
s.record(30);
s.record(20);
const w = s.weekly();
expect(w.perDay.at(-1)?.seconds).toBe(50);
expect(s.streak()).toBe(1);
s.record(0); // 无时间不落盘
expect(s.weekly().total).toBe(50);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { searchChapters, splitHighlight } from "../src/lib/search";
import { splitChapters } from "../src/lib/chapters";
const TEXT = ["第一章 起风", "风来了又走。", "第二章 落雨", "雨点敲窗,风声相伴。", "风止"].join("\n");
describe("searchChapters", () => {
const chs = splitChapters(TEXT);
it("finds case-insensitive hits with chapter index and position", () => {
const hits = searchChapters(TEXT, chs, "风");
expect(hits.length).toBeGreaterThanOrEqual(3);
expect(hits[0].ch).toBe(0);
expect(hits.every((h) => h.snippet.includes("风"))).toBe(true);
});
it("returns empty for no match / empty query", () => {
expect(searchChapters(TEXT, chs, "不存在")).toEqual([]);
expect(searchChapters(TEXT, chs, " ")).toEqual([]);
});
it("caps at limit", () => {
expect(searchChapters(TEXT, chs, "风", 2)).toHaveLength(2);
});
});
describe("splitHighlight", () => {
it("splits snippet into before/match/after", () => {
expect(splitHighlight("abc风def", 3, 1)).toEqual({ before: "abc", match: "风", after: "def" });
});
});
+45
View File
@@ -0,0 +1,45 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());
// jsdom 缺口 polyfill(spec S2):radix 原语在 jsdom 下的已知缺失,一次配好全体组件测试共用。
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
Element.prototype.scrollIntoView ??= function scrollIntoView() {};
// radix 的焦点/拖拽逻辑会探测 pointer capture API,jsdom 未实现
Element.prototype.hasPointerCapture ??= (() => false) as never;
Element.prototype.setPointerCapture ??= (() => {}) as never;
Element.prototype.releasePointerCapture ??= (() => {}) as never;
// jsdom 未实现 matchMedia(theme.tsx 按 prefers-color-scheme 求值):EventTarget 基类覆盖
// addEventListener/removeEventListener,addListener/removeListener 是旧版 legacy API 兼容面。
class MediaQueryListStub extends EventTarget {
media: string;
matches: boolean;
onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null = null;
constructor(media: string) {
super();
this.media = media;
this.matches = false;
}
addListener(listener: (this: MediaQueryList, ev: MediaQueryListEvent) => unknown) {
this.addEventListener("change", listener as EventListener);
}
removeListener(listener: (this: MediaQueryList, ev: MediaQueryListEvent) => unknown) {
this.removeEventListener("change", listener as EventListener);
}
}
globalThis.matchMedia ??= ((media: string) => new MediaQueryListStub(media)) as unknown as typeof matchMedia;
+29 -4
View File
@@ -3,20 +3,45 @@ 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;
({
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([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" })];
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" }),
];
// 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1
expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]);
const g = sections(books, "title", "dir");
+2 -2
View File
@@ -13,7 +13,7 @@
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client", "vite-plugin-pwa/client"]
"types": ["vite/client", "vite-plugin-pwa/client", "node"]
},
"include": ["src", "test"]
"include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}
+5
View File
@@ -2,6 +2,7 @@ import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
resolve: { alias: { "@": "/src" } },
@@ -46,6 +47,10 @@ export default defineConfig({
],
},
}),
// npm run analyze:体积报告输出到 dist-stats/(git-ignore,不进发布产物)
...(process.env.ANALYZE
? [visualizer({ filename: "dist-stats/stats.html", gzipSize: true, brotliSize: true })]
: []),
],
server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
});
+19 -1
View File
@@ -1,6 +1,24 @@
import { defineConfig } from "vitest/config";
// 组件测试一律 colocate 在 src/ 组件目录(*.test.tsx / *.test.ts);放 test/ 下的 .test.tsx 不会被任何 project 捕获
export default defineConfig({
resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } },
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
test: {
passWithNoTests: true,
projects: [
{
extends: true,
test: { name: "unit", include: ["test/**/*.test.ts"], environment: "node" },
},
{
extends: true,
test: {
name: "components",
include: ["src/**/*.test.{ts,tsx}"],
environment: "jsdom",
setupFiles: ["test/setup.ts"],
},
},
],
},
});