Merge remote-tracking branch 'origin/feat/frontend-quality'
This commit is contained in:
@@ -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,3 +3,6 @@
|
||||
tasks/
|
||||
.playwright*/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
dist-stats/
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-7
@@ -10,6 +10,18 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- 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 +63,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 +73,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 +91,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
@@ -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
@@ -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`。
|
||||
|
||||
## 旧卷迁移(一次性,升级自上一版部署)
|
||||
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -0,0 +1,6 @@
|
||||
dist
|
||||
dist-stats
|
||||
node_modules
|
||||
package-lock.json
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { adminApi, creds, ensureSampleBook } from "./helpers/api";
|
||||
import { scanA11y } from "./helpers/axe";
|
||||
|
||||
let admin: APIRequestContext;
|
||||
let bookId: number;
|
||||
let title: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
admin = await adminApi();
|
||||
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 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 });
|
||||
|
||||
// 导航抽屉(本 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/);
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
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));
|
||||
|
||||
/** 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}` } });
|
||||
}
|
||||
|
||||
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 ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
||||
const title = "e2e-sample";
|
||||
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: "e2e-sample.cbz", mimeType: "application/zip", buffer: readFileSync(FIXTURE_CBZ) },
|
||||
},
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
interface Waiver {
|
||||
page: string;
|
||||
rule: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* critical/serious 违规的逐条豁免清单(spec S3):
|
||||
* 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。
|
||||
* 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" }
|
||||
*/
|
||||
const WAIVERS: Waiver[] = [
|
||||
{
|
||||
page: "reader-chrome",
|
||||
rule: "color-contrast",
|
||||
reason:
|
||||
"旧 chrome:Reader.tsx 头栏(书架返回钮/书名/库名/进度)用 stone-* 配色,对比不足;② 阅读器改版迁移 shadcn token 后移除本豁免",
|
||||
},
|
||||
];
|
||||
|
||||
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([]);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// 旧阅读器 chrome 的点击区/交互元素将被 ② 阅读器改版迁入 radix 原语,
|
||||
// 届时收严为 error;本期对存量文件降 warn(spec S1 决策)
|
||||
files: [
|
||||
"src/readers/**/*.tsx",
|
||||
"src/components/Bookmarks.tsx",
|
||||
"src/components/reader-nav.tsx",
|
||||
"src/components/rd-slider.tsx",
|
||||
"src/pages/Reader.tsx",
|
||||
],
|
||||
rules: {
|
||||
"jsx-a11y/click-events-have-key-events": "warn",
|
||||
"jsx-a11y/no-static-element-interactions": "warn",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "warn",
|
||||
// 键盘焦点容器:CBZ/PDF 阅读器根 div 用 tabIndex={0} 接方向键翻页,
|
||||
// 与上面三条同属 ② 改版范围(radix 原语自带焦点管理),本期随块降 warn
|
||||
"jsx-a11y/no-noninteractive-tabindex": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
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 冲突的格式规则
|
||||
);
|
||||
Generated
+2797
-30
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -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,28 @@
|
||||
"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/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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -100,7 +100,11 @@ export function BookmarksTab({
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
|
||||
<button
|
||||
className="rd-btn inline-flex shrink-0 items-center"
|
||||
aria-label="取消编辑"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,25 @@ import { btn, formatBadge } from "./ui";
|
||||
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,10 +40,10 @@ 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>
|
||||
<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>
|
||||
{failed && (
|
||||
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
|
||||
|
||||
@@ -14,7 +14,10 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
|
||||
<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 })}>
|
||||
<button
|
||||
className="rounded-md bg-stone-800 px-3 py-1.5 text-sm"
|
||||
onClick={() => this.setState({ err: null })}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,16 @@ 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}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...p}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+137
-38
@@ -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,35 @@ 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;
|
||||
}
|
||||
[data-rd="sepia"] {
|
||||
--rd-bg: #f0e6d2;
|
||||
--rd-fg: #433a29;
|
||||
--rd-link: #92400e;
|
||||
}
|
||||
[data-rd="night"] {
|
||||
--rd-bg: #171512;
|
||||
--rd-fg: #c6c0b6;
|
||||
--rd-link: #e0a458;
|
||||
}
|
||||
.rd-surface {
|
||||
background: var(--rd-bg);
|
||||
color: var(--rd-fg);
|
||||
@@ -125,46 +145,125 @@ body {
|
||||
@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-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); }
|
||||
.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-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 {
|
||||
@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);
|
||||
@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);
|
||||
}
|
||||
.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; }
|
||||
@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; }
|
||||
.rd-sheet {
|
||||
animation: none;
|
||||
}
|
||||
.rd-rise-anim {
|
||||
transition: none !important;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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 = null;
|
||||
lastAt = Date.now();
|
||||
doSend(false);
|
||||
}, intervalMs - (t - lastAt));
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
timer = null;
|
||||
lastAt = Date.now();
|
||||
doSend(false);
|
||||
},
|
||||
intervalMs - (t - lastAt),
|
||||
);
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? "登录中…" : "登录"}
|
||||
|
||||
+142
-31
@@ -1,7 +1,16 @@
|
||||
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";
|
||||
@@ -10,30 +19,60 @@ import { toast } from "@/components/Toaster";
|
||||
import { UploadButton } from "@/components/upload-button";
|
||||
import { formatBadge } from "@/components/ui";
|
||||
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>
|
||||
{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 +84,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 +141,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) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (qRaw.trim()) next.set("q", qRaw.trim());
|
||||
else next.delete("q");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
setSp(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (qRaw.trim()) next.set("q", qRaw.trim());
|
||||
else next.delete("q");
|
||||
return next;
|
||||
},
|
||||
{ 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 +203,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 +229,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 +241,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,13 +256,24 @@ 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>
|
||||
@@ -203,23 +290,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 +362,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 +376,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,10 +38,17 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
||||
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));
|
||||
@@ -58,8 +65,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
||||
</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-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
@@ -306,6 +312,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
>
|
||||
<div
|
||||
ref={attachBox}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
||||
tabIndex={0}
|
||||
onScroll={onScroll}
|
||||
onClick={onZoneClick}
|
||||
onPointerDown={(e) => {
|
||||
@@ -408,7 +416,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
)}
|
||||
{chapters && chapters.length >= 2 && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="rd-btn ml-auto inline-flex items-center gap-1.5" aria-label="更多阅读选项">
|
||||
<DropdownMenu.Trigger
|
||||
className="rd-btn ml-auto inline-flex items-center gap-1.5"
|
||||
aria-label="更多阅读选项"
|
||||
>
|
||||
<Settings2 size={14} /> 更多
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
|
||||
@@ -38,12 +38,19 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
||||
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());
|
||||
@@ -98,7 +105,9 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
||||
<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>}
|
||||
{!ready && !err && (
|
||||
<div className="grid h-full place-items-center bg-stone-950 text-stone-500">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()}>
|
||||
|
||||
@@ -8,10 +8,9 @@ import { btn } from "../components/ui";
|
||||
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 +28,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;
|
||||
@@ -140,19 +146,37 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
|
||||
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
||||
</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="第一页">
|
||||
<button
|
||||
className={btn + " inline-flex items-center 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
|
||||
className={btn + " inline-flex items-center 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">
|
||||
{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
|
||||
className={btn + " inline-flex items-center 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
|
||||
className={btn + " inline-flex items-center gap-0.5"}
|
||||
disabled={page >= num - 1}
|
||||
onClick={() => setPage(num - 1)}
|
||||
aria-label="最后一页"
|
||||
>
|
||||
<ChevronsRight size={14} /> 末页
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -8,13 +8,7 @@ import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||
import { btn } from "../components/ui";
|
||||
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 { FONT_SIZES, RD_THEMES, RD_THEME_LABEL, useReaderPrefs, type RdTheme } from "../lib/readerPrefs";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
||||
@@ -25,10 +19,17 @@ 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)))
|
||||
@@ -80,9 +81,7 @@ function ReaderSheet({
|
||||
className="rd-range"
|
||||
aria-label="阅读进度"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">
|
||||
{Math.round(frac * 100)}%
|
||||
</span>
|
||||
<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}
|
||||
@@ -105,7 +104,12 @@ function ReaderSheet({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
||||
<button
|
||||
className="rd-btn"
|
||||
onClick={() => pr.bump(-1)}
|
||||
disabled={pr.prefs.sizeIdx === 0}
|
||||
aria-label="减小字号"
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
@@ -241,6 +245,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) {
|
||||
|
||||
@@ -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("第一章"));
|
||||
|
||||
@@ -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("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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" } },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 组件测试一律 colocate 在 src/ 组件目录(*.test.tsx);放 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.tsx"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["test/setup.ts"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user