merge: booklib frontend Plan 2 (shelf, four readers, admin, PWA, dockerized SPA, web smoke green)
This commit is contained in:
@@ -3,3 +3,6 @@
|
|||||||
library/
|
library/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
backend/booklib*
|
backend/booklib*
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/dev-dist
|
||||||
|
|||||||
@@ -36,3 +36,18 @@ go test -p 1 -count=1 ./...
|
|||||||
## 改 schema 前必读
|
## 改 schema 前必读
|
||||||
|
|
||||||
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
||||||
|
|
||||||
|
## 前端开发(web/)
|
||||||
|
|
||||||
|
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。
|
||||||
|
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
|
||||||
|
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
|
||||||
|
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
|
||||||
|
- 门槛:`npm run check`(tsc + vitest + vite build)。
|
||||||
|
|
||||||
|
## 生产部署(含前端)
|
||||||
|
|
||||||
|
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
|
||||||
|
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
|
||||||
|
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
|
||||||
|
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
|
||||||
|
|||||||
+11
-1
@@ -1,3 +1,13 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY deploy/web-dist /usr/share/nginx/html
|
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
# entrypoint 先按 /etc/resolv.conf 注入 resolver(Docker/podman 双运行时),再执行继承的 nginx CMD
|
||||||
|
ENTRYPOINT ["/entrypoint-resolver.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 双运行时(Docker/podman)DNS 修复:镜像内 baked 的 resolver 地址无法同时成立
|
||||||
|
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
|
||||||
|
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
|
||||||
|
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
|
||||||
|
set -eu
|
||||||
|
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
|
||||||
|
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
|
||||||
|
CONF=/etc/nginx/conf.d/default.conf
|
||||||
|
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
|
||||||
|
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
|
||||||
|
if [ $# -gt 0 ]; then exec "$@"; fi
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
+7
-2
@@ -1,11 +1,16 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 200m;
|
client_max_body_size 200m;
|
||||||
|
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);镜像 entrypoint 启动时会按
|
||||||
|
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
|
||||||
resolver 127.0.0.11 valid=10s;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
# spec §7 风险接受所假设的 CSP:全部同源,blob:/data: 供 SW/reader 用
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; object-src 'none'; frame-src 'self' blob:" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本
|
set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11)
|
||||||
proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发
|
proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>
|
|
||||||
@@ -1248,6 +1248,7 @@ function BookCard({
|
|||||||
export default function Shelf() {
|
export default function Shelf() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
const [lib, setLib] = useState(0); // 0 = 全部库
|
const [lib, setLib] = useState(0); // 0 = 全部库
|
||||||
const [qRaw, setQRaw] = useState("");
|
const [qRaw, setQRaw] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
@@ -1301,7 +1302,7 @@ export default function Shelf() {
|
|||||||
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
{lib > 0 && (
|
{isAdmin && lib > 0 && (
|
||||||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
|
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
|
||||||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1618,7 +1619,7 @@ export function useProgressSaver(bookId: number): ProgressSaver {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from "react";
|
import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; // lazy 自 Task 7 起随首个 READERS 条目补进本行(noUnusedLocals)
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Format } from "../api/types";
|
import type { Format } from "../api/types";
|
||||||
@@ -1884,8 +1885,8 @@ export class PageHeights {
|
|||||||
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
|
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
|
||||||
if (this.n === 0) return [0, 0];
|
if (this.n === 0) return [0, 0];
|
||||||
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
|
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
|
||||||
const b = Math.min(this.n - 1, this.pageAt(scrollTop + viewport) + overscan);
|
const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan); // [start, end)
|
||||||
return [a, b + 1];
|
return [a, b];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -2040,7 +2041,7 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
|||||||
|
|
||||||
- [ ] **Step 5: `src/pages/Reader.tsx` 接线 cbz**
|
- [ ] **Step 5: `src/pages/Reader.tsx` 接线 cbz**
|
||||||
|
|
||||||
`READERS` 整块替换为(lazy 动态导入,不新增顶部静态 import,保持 code-splitting):
|
先把 react import 行还原为含 `lazy`(见 Task 6 注释),再整块替换 READERS:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||||
@@ -3065,3 +3066,17 @@ git commit -m "feat(web): pwa offline caching, dockerized SPA, full-stack nginx
|
|||||||
1. admin 建库→上传 cbz→扫描→书架出封面;member 登录只见书架无删除按钮。
|
1. admin 建库→上传 cbz→扫描→书架出封面;member 登录只见书架无删除按钮。
|
||||||
2. 四格式各开一本:翻页/翻篇后刷新同位置续上;关页秒保存。
|
2. 四格式各开一本:翻页/翻篇后刷新同位置续上;关页秒保存。
|
||||||
3. 断网(F12 offline)重开:SPA 壳与读过的 CBZ 页可看。
|
3. 断网(F12 offline)重开:SPA 壳与读过的 CBZ 页可看。
|
||||||
|
|
||||||
|
## 执行修订记录(plan2-frontend 分支最终态)
|
||||||
|
|
||||||
|
执行期对上文计划文本的有效修订(评审/环境驱动,均已随代码落地):
|
||||||
|
|
||||||
|
- **sendBeacon → fetch keepalive**(写计划时已注):后端 Bearer-only,beacon 无法带 header,语义等价。
|
||||||
|
- **Task 5**:扫描按钮补 `isAdmin` 守卫(全局约束"写操作仅 admin 可见"优先于示例代码;已就地修订上文)。
|
||||||
|
- **Task 6/7**:Reader.tsx 的 `lazy` import 随首个 READERS 条目(Task 7)引入(`noUnusedLocals` 不允许空表时导入)。
|
||||||
|
- **Task 7**:`PageHeights.range` 返回 `[start, end)` 端点排除(brief 参考实现与 brief 测试矛盾,以测试为准;已就地修订上文)。
|
||||||
|
- **Task 9**:已装 pdfjs-dist 为 v6.3 — `page.render({ canvas, viewport })`(canvasContext 已废)、`destroy()` 移到 loading task;仅调用点适配。
|
||||||
|
- **Task 10/终评**:`display(cfi)` 失败是 promise reject 非同步 throw → 改为 promise 链回退无参 display 再 setErr;`setReady` 仅在 display 成功后。
|
||||||
|
- **Task 12**:新增 `deploy/entrypoint-resolver.sh` —— `deploy/nginx.conf` 恢复 spec §11 变量式 `proxy_pass`,`resolver` 地址启动时从 `/etc/resolv.conf` 首个 nameserver 注入(Docker=127.0.0.11 / podman=gateway 双运行时兼容;计划负责人裁决"保 spec 语义")。Dockerfile.web 的 nginx stage `COPY --chmod=755` 该脚本并覆写 ENTRYPOINT(exec "$@" 保留 CMD)。
|
||||||
|
- **终评分支修复波**(cc66a64):nginx 增加 CSP header(spec §7 的既定缓解);AuthContext 401 登出与显式 logout 统一 `clearSession()`(qc.clear + 双 SW 缓存删除);admin 密码框 `type=password`;SW 不可变匹配 `searchParams.has("v")`;CBZ 缺 pages_url 出错误占位。
|
||||||
|
- 冒烟脚本 `--wait` 在 podman-compose 下失败时回退 + healthz 轮询(仅脚本层,仓库文件未降级)。
|
||||||
|
|||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 全栈部署冒烟:经 nginx 验证 SPA 静态、/api 反代、登录、SW/manifest。
|
||||||
|
# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。
|
||||||
|
set -euo pipefail
|
||||||
|
BASE=${BASE:-http://localhost:8080}
|
||||||
|
[ -f .env ] && set -a && . ./.env && set +a
|
||||||
|
: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}"
|
||||||
|
|
||||||
|
say(){ echo "smoke-web: $1"; }
|
||||||
|
die(){ echo "SMOKE-WEB FAIL: $1"; exit 1; }
|
||||||
|
|
||||||
|
say "build web + api images"
|
||||||
|
docker compose build web api
|
||||||
|
|
||||||
|
say "up --wait"
|
||||||
|
# docker compose 支持 --wait;podman-compose 不支持 → 回退 up -d + 轮询健康
|
||||||
|
if docker compose up -d --wait postgres redis api web 2>/dev/null; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
say "no --wait support: up -d + poll healthz"
|
||||||
|
docker compose up -d postgres redis api web
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -fsS --max-time 3 "$BASE/api/healthz" >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/healthz" >/dev/null || die "api healthz 未就绪"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "SPA index served by nginx"
|
||||||
|
INDEX=$(curl -fsS "$BASE/")
|
||||||
|
echo "$INDEX" | grep -q 'id="root"' || die "index 缺 #root"
|
||||||
|
ASSET=$(echo "$INDEX" | sed -nE 's#.*src="(/assets/[^"]+\.js)".*#\1#p' | head -1)
|
||||||
|
[ -n "$ASSET" ] || die "index 未引用 /assets/*.js"
|
||||||
|
curl -fsS -o /dev/null "$BASE$ASSET" || die "asset $ASSET 拉取失败"
|
||||||
|
|
||||||
|
say "SPA deep link falls back to index"
|
||||||
|
curl -fsS "$BASE/admin/users" | grep -q 'id="root"' || die "try_files 回退失败"
|
||||||
|
|
||||||
|
say "api via /api/ proxy"
|
||||||
|
curl -fsS "$BASE/api/healthz" >/dev/null || die "healthz via nginx"
|
||||||
|
TOK=$(curl -fsS "$BASE/api/auth/login" -H 'content-type: application/json' \
|
||||||
|
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||||
|
| sed -E 's/.*"token":"([^"]+)".*/\1/')
|
||||||
|
[ -n "$TOK" ] || die "nginx 反代登录失败"
|
||||||
|
curl -fsS "$BASE/api/auth/me" -H "authorization: Bearer $TOK" | grep -q "$ADMIN_USER" || die "me via nginx"
|
||||||
|
|
||||||
|
say "pwa artifacts"
|
||||||
|
curl -fsS "$BASE/sw.js" >/dev/null || die "sw.js 缺失"
|
||||||
|
curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失"
|
||||||
|
|
||||||
|
say "ALL WEB SMOKE PASSED"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dev-dist
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<title>BookLib — 个人书库</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+8180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "booklib-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc --noEmit && vitest run && vite build"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"private": "true",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.102.8",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"epubjs": "^0.3.93",
|
||||||
|
"marked": "^18.0.11",
|
||||||
|
"pdfjs-dist": "^6.3.289",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vitest": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="24" fill="#09090b"/><path d="M24 30h34v68H24zM70 30h34v68H70z" fill="none" stroke="#34d399" stroke-width="6"/><path d="M58 30v68" stroke="#34d399" stroke-width="6"/></svg>
|
||||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1,59 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { Toaster } from "./components/Toaster";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Reader from "./pages/Reader";
|
||||||
|
import Shelf from "./pages/Shelf";
|
||||||
|
import AdminLibraries from "./pages/admin/Libraries";
|
||||||
|
import AdminUsers from "./pages/admin/Users";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Shelf />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/book/:id"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Reader />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminUsers />
|
||||||
|
</RequireAdmin>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/libraries"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminLibraries />
|
||||||
|
</RequireAdmin>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Book, Library, Me, ProgressRow, User } from "./types";
|
||||||
|
|
||||||
|
export const TOKEN_KEY = "booklib.token";
|
||||||
|
export const LOGOUT_EVENT = "booklib:logout";
|
||||||
|
|
||||||
|
// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效
|
||||||
|
const mem = new Map<string, string>();
|
||||||
|
const hasLS = typeof localStorage !== "undefined";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(t: string | null): void {
|
||||||
|
if (hasLS) {
|
||||||
|
if (t) localStorage.setItem(TOKEN_KEY, t);
|
||||||
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
|
} else if (t) {
|
||||||
|
mem.set(TOKEN_KEY, t);
|
||||||
|
} else {
|
||||||
|
mem.delete(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
constructor(status: number, code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHttpError(res: Response): Promise<HttpError> {
|
||||||
|
let code = "http_" + res.status;
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
if (j?.error?.code) code = j.error.code;
|
||||||
|
if (j?.error?.message) message = j.error.message;
|
||||||
|
} catch {
|
||||||
|
/* 非 JSON 错误体:用状态码兜底 */
|
||||||
|
}
|
||||||
|
return new HttpError(res.status, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {};
|
||||||
|
const tok = getToken();
|
||||||
|
if (tok) h.Authorization = "Bearer " + tok;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle401(res: Response): void {
|
||||||
|
if (res.status !== 401) return;
|
||||||
|
setToken(null);
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOGOUT_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function full(path: string): string {
|
||||||
|
return path.startsWith("/api") ? path : "/api" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReqOpts {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
form?: FormData;
|
||||||
|
keepalive?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, opts: ReqOpts = {}): Promise<T> {
|
||||||
|
const headers = authHeaders();
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
if (opts.form) {
|
||||||
|
body = opts.form;
|
||||||
|
} else if (opts.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(opts.body);
|
||||||
|
}
|
||||||
|
const res = await fetch(full(path), {
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
keepalive: opts.keepalive,
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
handle401(res);
|
||||||
|
if (!res.ok) throw await toHttpError(res);
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二进制/原文通道:封面、CBZ 页、PDF/EPUB 字节。同 401 语义,不解析 JSON
|
||||||
|
export async function apiRaw(
|
||||||
|
path: string,
|
||||||
|
opts: { signal?: AbortSignal; keepalive?: boolean } = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const res = await fetch(full(path), {
|
||||||
|
headers: authHeaders(),
|
||||||
|
signal: opts.signal,
|
||||||
|
keepalive: opts.keepalive,
|
||||||
|
});
|
||||||
|
handle401(res);
|
||||||
|
if (!res.ok) throw await toHttpError(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPageUrl(fmt: string, n: number): string {
|
||||||
|
return fmt.replace("%d", String(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }),
|
||||||
|
me: () => apiFetch<Me>("/auth/me"),
|
||||||
|
|
||||||
|
listUsers: () => apiFetch<User[]>("/users"),
|
||||||
|
createUser: (username: string, password: string, role: string) =>
|
||||||
|
apiFetch<User>("/users", { method: "POST", body: { username, password, role } }),
|
||||||
|
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
||||||
|
createLibrary: (name: string, root_path: string) =>
|
||||||
|
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
|
||||||
|
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
|
||||||
|
uploadBook: (id: number, file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
form: fd,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
listBooks: (params: { library?: number; q?: string } = {}) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.library) sp.set("library", String(params.library));
|
||||||
|
if (params.q) sp.set("q", params.q);
|
||||||
|
const qs = sp.toString();
|
||||||
|
return apiFetch<Book[]>(`/books${qs ? "?" + qs : ""}`);
|
||||||
|
},
|
||||||
|
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
|
||||||
|
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
|
||||||
|
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
|
||||||
|
|
||||||
|
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
|
||||||
|
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
|
||||||
|
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type Format = "cbz" | "pdf" | "epub" | "txt" | "md";
|
||||||
|
|
||||||
|
export interface Book {
|
||||||
|
id: number;
|
||||||
|
library_id: number;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
format: Format;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
pages: number;
|
||||||
|
state: "ready" | "error";
|
||||||
|
error: string;
|
||||||
|
added_at: string;
|
||||||
|
percent: number;
|
||||||
|
cover_url: string;
|
||||||
|
library?: string;
|
||||||
|
pages_url?: string;
|
||||||
|
page_url_fmt?: string;
|
||||||
|
file_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Library {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
root_path: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressRow {
|
||||||
|
library_id: number;
|
||||||
|
library: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
locator: Record<string, unknown>;
|
||||||
|
percent: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client";
|
||||||
|
import type { Me } from "../api/types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: Me | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
login: (u: string, p: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [token, setTok] = useState<string | null>(() => getToken());
|
||||||
|
|
||||||
|
// 401 到期登出与显式登出同样清理:跨用户数据不得残留在 query 缓存 / SW CacheStorage
|
||||||
|
const clearSession = () => {
|
||||||
|
qc.clear();
|
||||||
|
if (typeof caches !== "undefined") {
|
||||||
|
void caches.delete("booklib-api");
|
||||||
|
void caches.delete("booklib-immutable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const off = () => {
|
||||||
|
setTok(null); // client 的 401 拦截在这里回收 React 状态(token 已由 client.ts 清除)
|
||||||
|
clearSession();
|
||||||
|
};
|
||||||
|
window.addEventListener(LOGOUT_EVENT, off);
|
||||||
|
return () => window.removeEventListener(LOGOUT_EVENT, off);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: !!token, retry: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && meQ.isError) {
|
||||||
|
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
|
||||||
|
setTok(null);
|
||||||
|
}
|
||||||
|
}, [token, meQ.isError]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(
|
||||||
|
() => ({
|
||||||
|
user: meQ.data ?? null,
|
||||||
|
isAdmin: meQ.data?.role === "admin",
|
||||||
|
ready: !token || !meQ.isPending,
|
||||||
|
login: async (u, p) => {
|
||||||
|
const r = await api.login(u, p);
|
||||||
|
setToken(r.token);
|
||||||
|
setTok(r.token);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
setToken(null);
|
||||||
|
setTok(null);
|
||||||
|
clearSession();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[meQ.data, meQ.isPending, meQ.isError, token, qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const v = useContext(Ctx);
|
||||||
|
if (!v) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { user, ready } = useAuth();
|
||||||
|
const loc = useLocation();
|
||||||
|
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||||||
|
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
return isAdmin ? <>{children}</> : <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Book } from "../api/types";
|
||||||
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
|
import { btn } from "./ui";
|
||||||
|
|
||||||
|
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 [nonce, setNonce] = useState(0);
|
||||||
|
const key = (url ?? "") + "#" + nonce;
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
};
|
||||||
|
}, [url, nonce]);
|
||||||
|
|
||||||
|
const same = state.key === key;
|
||||||
|
return { src: same ? state.src : "", failed: same && state.failed, retry: () => setNonce((n) => n + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Cover({ book, className }: { book: Book; className?: string }) {
|
||||||
|
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
||||||
|
if (failed)
|
||||||
|
return (
|
||||||
|
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
|
||||||
|
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
|
||||||
|
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
|
||||||
|
state: { err: Error | null } = { err: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(err: Error) {
|
||||||
|
return { err };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.err) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||||||
|
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
|
||||||
|
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
|
||||||
|
|
||||||
|
const items: Toast[] = [];
|
||||||
|
const subs = new Set<(t: Toast[]) => void>();
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function toast(kind: Toast["kind"], text: ReactNode): void {
|
||||||
|
const t: Toast = { id: nextId++, kind, text };
|
||||||
|
items.push(t);
|
||||||
|
subs.forEach((f) => f([...items]));
|
||||||
|
setTimeout(() => {
|
||||||
|
const i = items.findIndex((x) => x.id === t.id);
|
||||||
|
if (i >= 0) items.splice(i, 1);
|
||||||
|
subs.forEach((f) => f([...items]));
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const [list, setList] = useState<Toast[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
subs.add(setList);
|
||||||
|
return () => {
|
||||||
|
subs.delete(setList);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{list.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
role="status"
|
||||||
|
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
|
||||||
|
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { btn } from "./ui";
|
||||||
|
|
||||||
|
export function TopBar({ right }: { right?: ReactNode }) {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
|
||||||
|
<Link to="/" className="font-semibold">
|
||||||
|
BookLib
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
|
||||||
|
用户
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
|
||||||
|
库管理
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{right}
|
||||||
|
<span className="text-sm text-zinc-400">
|
||||||
|
{user?.username}
|
||||||
|
{isAdmin ? "(admin)" : ""}
|
||||||
|
</span>
|
||||||
|
<button className={btn} onClick={logout}>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const btn =
|
||||||
|
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
export const btnPrimary =
|
||||||
|
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
export const input =
|
||||||
|
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
|
||||||
|
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-zinc-950 text-zinc-100 antialiased;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded bg-zinc-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 border-zinc-600 pl-4 text-zinc-400; }
|
||||||
|
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
|
||||||
|
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
|
||||||
|
.md-body pre code { @apply bg-transparent p-0; }
|
||||||
|
.md-body a { @apply text-emerald-400 underline; }
|
||||||
|
.md-body img { @apply my-3 max-w-full rounded; }
|
||||||
|
.md-body hr { @apply my-6 border-zinc-800; }
|
||||||
|
.md-body table { @apply my-3 w-full border-collapse text-sm; }
|
||||||
|
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { apiRaw } from "../api/client";
|
||||||
|
|
||||||
|
const MAX = 600;
|
||||||
|
const cache = new Map<string, Promise<string>>(); // 插入序 ≈ LRU(命中时重插)
|
||||||
|
|
||||||
|
// 带鉴权取图 → objectURL。后端 Bearer-only,<img>/<iframe> 直挂 /api 必 401,
|
||||||
|
// 故图片一律走此通道;URL 含内容 hash 不可变,会话内可安全长驻缓存。
|
||||||
|
export async function fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
const hit = cache.get(url);
|
||||||
|
if (hit) {
|
||||||
|
cache.delete(url);
|
||||||
|
cache.set(url, hit);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
const p = (async () => {
|
||||||
|
const res = await apiRaw(url, { signal });
|
||||||
|
return URL.createObjectURL(await res.blob());
|
||||||
|
})();
|
||||||
|
p.catch(() => cache.delete(url)); // 失败出缓存,下次调用重试
|
||||||
|
cache.set(url, p);
|
||||||
|
while (cache.size > MAX) {
|
||||||
|
const oldest = cache.keys().next().value as string;
|
||||||
|
cache.delete(oldest); // 不 revoke:可能仍被在屏 <img> 引用;≤600 条会话内 blob 可接受
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetImageCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Book } from "../api/types";
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
dir: string; // path 去掉文件名即分组键(spec §4);根目录为 ""
|
||||||
|
books: Book[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDir(books: Book[]): Group[] {
|
||||||
|
const m = new Map<string, Book[]>();
|
||||||
|
for (const bk of books) {
|
||||||
|
const i = bk.path.lastIndexOf("/");
|
||||||
|
const dir = i < 0 ? "" : bk.path.slice(0, i);
|
||||||
|
let arr = m.get(dir);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
m.set(dir, arr);
|
||||||
|
}
|
||||||
|
arr.push(bk);
|
||||||
|
}
|
||||||
|
return [...m.entries()]
|
||||||
|
.sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0])))
|
||||||
|
.map(([dir, bs]) => ({ dir, books: bs }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type ProgressSend = (
|
||||||
|
locator: Record<string, unknown>,
|
||||||
|
percent: number,
|
||||||
|
keepalive: boolean,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
export interface ProgressSaver {
|
||||||
|
report: (locator: Record<string, unknown>, percent: number) => void;
|
||||||
|
flush: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// spec §8:PUT 节流 5s(leading-edge + 尾部合并);关页兜底走 keepalive fetch
|
||||||
|
//(spec 原文 sendBeacon,但 beacon 无法带 Authorization,后端 Bearer-only,语义等价替换)
|
||||||
|
export function createProgressSaver(send: ProgressSend, intervalMs = 5000): ProgressSaver {
|
||||||
|
let lastKey = "";
|
||||||
|
let pending: { loc: Record<string, unknown>; pct: number } | null = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastAt = 0;
|
||||||
|
|
||||||
|
function doSend(keepalive: boolean): void {
|
||||||
|
if (!pending) return;
|
||||||
|
const { loc, pct } = pending;
|
||||||
|
pending = null;
|
||||||
|
const key = JSON.stringify([loc, pct]);
|
||||||
|
if (key === lastKey) return;
|
||||||
|
lastKey = key;
|
||||||
|
send(loc, pct, keepalive).catch(() => {
|
||||||
|
if (lastKey === key) lastKey = ""; // 失败允许下次重发同值
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
report(locator, percent) {
|
||||||
|
pending = { loc: locator, pct: percent };
|
||||||
|
if (timer) return; // 已有尾部定时器:只更新挂起值
|
||||||
|
const t = Date.now();
|
||||||
|
if (lastAt === 0 || t - lastAt >= intervalMs) {
|
||||||
|
lastAt = t;
|
||||||
|
doSend(false);
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
lastAt = Date.now();
|
||||||
|
doSend(false);
|
||||||
|
}, intervalMs - (t - lastAt));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
lastAt = Date.now();
|
||||||
|
doSend(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Book } from "../api/types";
|
||||||
|
import { createProgressSaver, type ProgressSaver } from "./progress";
|
||||||
|
|
||||||
|
export interface ReaderProps {
|
||||||
|
book: Book;
|
||||||
|
initialLocator?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgressSaver(bookId: number): ProgressSaver {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const saver = useMemo(
|
||||||
|
() =>
|
||||||
|
createProgressSaver(async (loc, pct, ka) => {
|
||||||
|
await api.putProgress(bookId, loc, pct, ka);
|
||||||
|
if (!ka) qc.invalidateQueries({ queryKey: ["book", bookId] }); // spec §8:只 invalidate 单本
|
||||||
|
}),
|
||||||
|
[bookId, qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPageHide = () => saver.flush();
|
||||||
|
const onVis = () => {
|
||||||
|
if (document.visibilityState === "hidden") saver.flush();
|
||||||
|
};
|
||||||
|
globalThis.addEventListener?.("pagehide", onPageHide);
|
||||||
|
document?.addEventListener?.("visibilitychange", onVis);
|
||||||
|
return () => {
|
||||||
|
saver.flush();
|
||||||
|
globalThis.removeEventListener?.("pagehide", onPageHide);
|
||||||
|
document?.removeEventListener?.("visibilitychange", onVis);
|
||||||
|
};
|
||||||
|
}, [saver]);
|
||||||
|
|
||||||
|
return saver;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正,
|
||||||
|
// 前缀和 + 二分定位。纯逻辑,无 DOM,vitest 全量覆盖。
|
||||||
|
export class PageHeights {
|
||||||
|
private actual: (number | undefined)[];
|
||||||
|
private cum: number[]; // cum[i] = 第 i 页顶部偏移;cum[n] = total
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private n: number,
|
||||||
|
private est: number,
|
||||||
|
) {
|
||||||
|
this.actual = new Array(n).fill(undefined);
|
||||||
|
this.cum = new Array(n + 1);
|
||||||
|
this.rebuild(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuild(from: number): void {
|
||||||
|
if (from === 0) this.cum[0] = 0;
|
||||||
|
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
|
||||||
|
set(i: number, h: number): number {
|
||||||
|
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
|
||||||
|
const old = this.actual[i];
|
||||||
|
if (old === h) return 0;
|
||||||
|
this.actual[i] = h;
|
||||||
|
this.rebuild(i);
|
||||||
|
return h - (old ?? this.est);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset(i: number): number {
|
||||||
|
if (this.n === 0) return 0;
|
||||||
|
return this.cum[Math.max(0, Math.min(i, this.n))];
|
||||||
|
}
|
||||||
|
|
||||||
|
total(): number {
|
||||||
|
return this.cum[this.n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖 y 的那一页。 */
|
||||||
|
pageAt(y: number): number {
|
||||||
|
if (this.n === 0) return 0;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = this.n - 1;
|
||||||
|
let ans = 0;
|
||||||
|
const v = Math.max(0, y);
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (this.cum[mid] <= v) {
|
||||||
|
ans = mid;
|
||||||
|
lo = mid + 1;
|
||||||
|
} else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return ans;
|
||||||
|
}
|
||||||
|
|
||||||
|
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
|
||||||
|
if (this.n === 0) return [0, 0];
|
||||||
|
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
|
||||||
|
const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan);
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { queryClient } from "./lib/qc";
|
||||||
|
import "./index.css";
|
||||||
|
import "./pwa";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { toast } from "../components/Toaster";
|
||||||
|
import { btnPrimary, card, input } from "../components/ui";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, user } = useAuth();
|
||||||
|
const loc = useLocation() as { state?: { from?: string } };
|
||||||
|
const [u, setU] = useState("");
|
||||||
|
const [p, setP] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (user) return <Navigate to={loc.state?.from ?? "/"} replace />;
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await login(u, p);
|
||||||
|
} catch (err) {
|
||||||
|
toast("err", err instanceof Error ? err.message : "登录失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-4">
|
||||||
|
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
|
||||||
|
<h1 className="text-lg font-semibold">BookLib 登录</h1>
|
||||||
|
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
|
||||||
|
placeholder="用户名" autoFocus autoComplete="username" />
|
||||||
|
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
|
||||||
|
placeholder="密码" autoComplete="current-password" />
|
||||||
|
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
|
||||||
|
{busy ? "登录中…" : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Format } from "../api/types";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { toast } from "../components/Toaster";
|
||||||
|
import { btn } from "../components/ui";
|
||||||
|
import type { ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
|
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||||
|
cbz: lazy(() => import("../readers/CbzReader")),
|
||||||
|
txt: lazy(() => import("../readers/TextReader")),
|
||||||
|
md: lazy(() => import("../readers/TextReader")),
|
||||||
|
pdf: lazy(() => import("../readers/PdfReader")),
|
||||||
|
epub: lazy(() => import("../readers/EpubReader")),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Reader() {
|
||||||
|
const { id: idStr } = useParams();
|
||||||
|
const id = Number(idStr);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||||||
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: () => api.deleteBook(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "已删除");
|
||||||
|
qc.invalidateQueries({ queryKey: ["books"] });
|
||||||
|
nav("/");
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const book = bookQ.data;
|
||||||
|
const body = (() => {
|
||||||
|
if (!Number.isFinite(id)) return <Msg text="无效的书 id" />;
|
||||||
|
if (bookQ.isPending) return <Msg text="加载中…" />;
|
||||||
|
if (bookQ.isError)
|
||||||
|
return (
|
||||||
|
<Msg
|
||||||
|
text={"加载失败:" + (bookQ.error instanceof Error ? bookQ.error.message : "")}
|
||||||
|
retry={() => bookQ.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!book) return <Msg text="未找到这本书" />;
|
||||||
|
if (book.state === "error") return <Msg text={`文件损坏:${book.error}`} />;
|
||||||
|
if (progQ.isPending) return <Msg text="恢复进度…" />;
|
||||||
|
const R = READERS[book.format];
|
||||||
|
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
|
||||||
|
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
|
||||||
|
return <R book={book} initialLocator={row?.locator} />;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex shrink-0 items-center gap-3 border-b border-zinc-800 px-4 py-2 text-sm">
|
||||||
|
<Link to="/" className="text-zinc-400 hover:text-zinc-100">
|
||||||
|
← 书架
|
||||||
|
</Link>
|
||||||
|
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{book?.library ? book.library + " · " : ""}
|
||||||
|
{book?.format}
|
||||||
|
{book ? ` · ${Math.round(book.percent * 100)}%` : ""}
|
||||||
|
</span>
|
||||||
|
{isAdmin && book && (
|
||||||
|
<button
|
||||||
|
className={btn + " ml-auto text-red-300"}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<Suspense fallback={<Msg text="加载阅读器…" />}>{body}</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3">{text}</p>
|
||||||
|
{retry && (
|
||||||
|
<button className={btn} onClick={retry}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Book } from "../api/types";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { Cover } from "../components/Cover";
|
||||||
|
import { TopBar } from "../components/TopBar";
|
||||||
|
import { toast } from "../components/Toaster";
|
||||||
|
import { btn, btnPrimary, card, input } from "../components/ui";
|
||||||
|
import { groupByDir } from "../lib/group";
|
||||||
|
|
||||||
|
function BookCard({
|
||||||
|
book,
|
||||||
|
onOpen,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
book: Book;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
return (
|
||||||
|
<div className="group relative w-32 shrink-0">
|
||||||
|
<button onClick={onOpen} className="block w-full text-left">
|
||||||
|
<Cover book={book} className="aspect-[3/4] w-32 rounded-lg" />
|
||||||
|
<p className="mt-1 truncate text-xs text-zinc-300">{book.title}</p>
|
||||||
|
<p className="truncate text-[10px] text-zinc-500">
|
||||||
|
{book.format} · {book.path}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{book.state === "error" && (
|
||||||
|
<span
|
||||||
|
className="absolute left-1 top-1 rounded bg-red-900 px-1 text-[10px] text-red-100"
|
||||||
|
title={book.error}
|
||||||
|
>
|
||||||
|
损坏
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.percent > 0 && (
|
||||||
|
<div className="mt-1 h-1 w-32 overflow-hidden rounded bg-zinc-800">
|
||||||
|
<div className="h-full bg-emerald-600" style={{ width: `${Math.round(book.percent * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="absolute right-1 top-1 hidden rounded bg-zinc-900/80 px-1.5 text-xs text-red-300 group-hover:block"
|
||||||
|
title="删除(连磁盘文件)"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shelf() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [lib, setLib] = useState(0); // 0 = 全部库
|
||||||
|
const [qRaw, setQRaw] = useState("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [qRaw]);
|
||||||
|
|
||||||
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
|
const booksQ = useQuery({
|
||||||
|
queryKey: ["books", lib, q],
|
||||||
|
queryFn: () => api.listBooks({ library: lib || undefined, q }),
|
||||||
|
});
|
||||||
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
|
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存)
|
||||||
|
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteBook(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "已删除");
|
||||||
|
qc.invalidateQueries({ queryKey: ["books"] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||||||
|
});
|
||||||
|
const scan = useMutation({
|
||||||
|
mutationFn: (id: number) => api.scanLibrary(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "扫描已触发");
|
||||||
|
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
|
||||||
|
const booksByKey = useMemo(() => {
|
||||||
|
const m = new Map<string, Book>();
|
||||||
|
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
|
||||||
|
return m;
|
||||||
|
}, [allQ.data]);
|
||||||
|
|
||||||
|
const continueRows = (progQ.data ?? []).slice(0, 12);
|
||||||
|
const libs = libsQ.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<TopBar
|
||||||
|
right={
|
||||||
|
<>
|
||||||
|
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
{isAdmin && lib > 0 && (
|
||||||
|
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
|
||||||
|
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={input + " w-44"}
|
||||||
|
placeholder="搜索标题…"
|
||||||
|
value={qRaw}
|
||||||
|
onChange={(e) => setQRaw(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{libs.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
|
||||||
|
)}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
{libs.map((l) => (
|
||||||
|
<button key={l.id} className={lib === l.id ? btnPrimary : btn} onClick={() => setLib(l.id)}>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{continueRows.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-zinc-400">继续阅读</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
|
{continueRows.map((r) => {
|
||||||
|
const b = booksByKey.get(`${r.library_id}/${r.path}`);
|
||||||
|
if (!b)
|
||||||
|
return (
|
||||||
|
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-zinc-600 ${card}`}>
|
||||||
|
<p className="truncate">{r.title || "已删除的书"}</p>
|
||||||
|
<p>{Math.round(r.percent * 100)}%</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<BookCard
|
||||||
|
key={`${r.library_id}/${r.path}`}
|
||||||
|
book={b}
|
||||||
|
onOpen={() => nav(`/book/${b.id}`)}
|
||||||
|
onDelete={() => del.mutate(b.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booksQ.isError && (
|
||||||
|
<p className="mb-4 text-sm text-red-400">
|
||||||
|
加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "}
|
||||||
|
<button className={btn} onClick={() => booksQ.refetch()}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
|
||||||
|
<p className="text-sm text-zinc-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
|
||||||
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.dir || "//"} className="mb-8">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-zinc-400">{g.dir || "根目录"}</h2>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{g.books.map((b) => (
|
||||||
|
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { TopBar } from "../../components/TopBar";
|
||||||
|
import { toast } from "../../components/Toaster";
|
||||||
|
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||||||
|
|
||||||
|
export default function AdminLibraries() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [root, setRoot] = useState("");
|
||||||
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => api.createLibrary(name.trim(), root.trim()),
|
||||||
|
onSuccess: (lib) => {
|
||||||
|
toast("ok", `库「${lib.name}」已创建`);
|
||||||
|
setName("");
|
||||||
|
setRoot("");
|
||||||
|
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
||||||
|
});
|
||||||
|
const scan = useMutation({
|
||||||
|
mutationFn: (id: number) => api.scanLibrary(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "扫描已触发");
|
||||||
|
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000);
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onFiles(libId: number, list: FileList | null) {
|
||||||
|
if (!list?.length) return;
|
||||||
|
try {
|
||||||
|
for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
|
||||||
|
toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["books"] });
|
||||||
|
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
||||||
|
} catch (e) {
|
||||||
|
toast("err", e instanceof Error ? e.message : "上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
create.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
||||||
|
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||||||
|
<input className={input} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<input
|
||||||
|
className={input + " flex-1"}
|
||||||
|
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)"
|
||||||
|
value={root}
|
||||||
|
onChange={(e) => setRoot(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}>
|
||||||
|
{create.isPending ? "创建中…" : "建库"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="flex max-w-2xl flex-col gap-4">
|
||||||
|
{(libsQ.data ?? []).map((l) => (
|
||||||
|
<div key={l.id} className={`${card} p-4`}>
|
||||||
|
<div className="mb-1 flex items-baseline gap-3">
|
||||||
|
<span className="font-medium">{l.name}</span>
|
||||||
|
<span className="text-xs text-zinc-500">#{l.id}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||||||
|
{scan.isPending ? "扫描中…" : "扫描"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
fileRefs.current[l.id] = el;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".cbz,.pdf,.epub,.txt,.md"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void onFiles(l.id, e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
||||||
|
上传文件
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-zinc-600">白名单:cbz/pdf/epub/txt/md</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
||||||
|
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { TopBar } from "../../components/TopBar";
|
||||||
|
import { toast } from "../../components/Toaster";
|
||||||
|
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||||||
|
|
||||||
|
export default function AdminUsers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers });
|
||||||
|
const [u, setU] = useState("");
|
||||||
|
const [p, setP] = useState("");
|
||||||
|
const [role, setRole] = useState<"member" | "admin">("member");
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => api.createUser(u.trim(), p, role),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "用户已创建");
|
||||||
|
setU("");
|
||||||
|
setP("");
|
||||||
|
void qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("ok", "用户已删除(其阅读进度一并删除)");
|
||||||
|
void qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
create.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<h1 className="mb-4 text-lg font-semibold">用户管理</h1>
|
||||||
|
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||||||
|
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
|
||||||
|
<input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
|
||||||
|
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
|
||||||
|
<option value="member">member</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
|
||||||
|
{create.isPending ? "创建中…" : "创建"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<table className="w-full max-w-2xl text-sm">
|
||||||
|
<thead className="text-left text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1">ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(usersQ.data ?? []).map((x) => (
|
||||||
|
<tr key={x.id} className="border-t border-zinc-800">
|
||||||
|
<td className="py-1 text-zinc-500">{x.id}</td>
|
||||||
|
<td>{x.username}</td>
|
||||||
|
<td>{x.role}</td>
|
||||||
|
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
<button
|
||||||
|
className={btn + " text-red-300"}
|
||||||
|
disabled={x.id === me?.id}
|
||||||
|
title={x.id === me?.id ? "不能删除自己" : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
|
||||||
|
registerSW({ immediate: true });
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { api, formatPageUrl } from "../api/client";
|
||||||
|
import { btn } from "../components/ui";
|
||||||
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
import { PageHeights } from "../lib/virt";
|
||||||
|
|
||||||
|
const MAX_W = 720;
|
||||||
|
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
||||||
|
|
||||||
|
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
|
||||||
|
const [src, setSrc] = useState("");
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [nonce, setNonce] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false;
|
||||||
|
setSrc("");
|
||||||
|
setFailed(false);
|
||||||
|
fetchObjectUrl(url)
|
||||||
|
.then((s) => !dead && setSrc(s))
|
||||||
|
.catch(() => !dead && setFailed(true));
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
};
|
||||||
|
}, [url, nonce]);
|
||||||
|
if (failed)
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center gap-2 bg-zinc-900 py-8 text-sm text-zinc-500" style={{ width }}>
|
||||||
|
本页加载失败
|
||||||
|
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!src)
|
||||||
|
return <div className="animate-pulse bg-zinc-800" style={{ width, height: width * EST_RATIO }} />;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
style={{ width }}
|
||||||
|
className="block"
|
||||||
|
onLoad={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
||||||
|
const countQ = useQuery({
|
||||||
|
queryKey: ["pages", book.id],
|
||||||
|
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||||||
|
enabled: !!book.pages_url,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
const count = countQ.data?.count ?? 0;
|
||||||
|
const saver = useProgressSaver(book.id);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(480);
|
||||||
|
const [vh, setVh] = useState(600);
|
||||||
|
const [top, setTop] = useState(0);
|
||||||
|
const [, bump] = useState(0);
|
||||||
|
const restored = useRef(false);
|
||||||
|
|
||||||
|
const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]);
|
||||||
|
|
||||||
|
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
setWidth(Math.min(el.clientWidth, MAX_W));
|
||||||
|
setVh(el.clientHeight);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 首次拿到 count 后恢复进度页
|
||||||
|
useEffect(() => {
|
||||||
|
if (restored.current || !count || !boxRef.current) return;
|
||||||
|
restored.current = true;
|
||||||
|
const p = Number(initialLocator?.page);
|
||||||
|
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
|
||||||
|
}, [count, initialLocator, ph]);
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const t = el.scrollTop;
|
||||||
|
setTop(t);
|
||||||
|
if (count) {
|
||||||
|
const cur = ph.pageAt(t + vh / 2);
|
||||||
|
saver.report({ page: cur }, (cur + 1) / count);
|
||||||
|
for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
|
||||||
|
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
||||||
|
if (countQ.isPending && !!book.pages_url)
|
||||||
|
return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>;
|
||||||
|
if (countQ.isError || !book.page_url_fmt)
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||||||
|
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fmt: string = book.page_url_fmt;
|
||||||
|
const [a, b] = ph.range(top, vh, 2);
|
||||||
|
const cur = ph.pageAt(top + vh / 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto">
|
||||||
|
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: MAX_W }}>
|
||||||
|
{Array.from({ length: b - a }, (_, k) => {
|
||||||
|
const i = a + k;
|
||||||
|
return (
|
||||||
|
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
|
||||||
|
<PageImg
|
||||||
|
url={formatPageUrl(fmt, i)}
|
||||||
|
width={width}
|
||||||
|
onLoaded={(h) => {
|
||||||
|
const el = boxRef.current;
|
||||||
|
const topOfI = ph.offset(i);
|
||||||
|
const d = ph.set(i, h);
|
||||||
|
if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
|
||||||
|
bump((x) => x + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-3 right-3 rounded bg-zinc-900/80 px-2 py-1 text-xs text-zinc-300">
|
||||||
|
{cur + 1}/{count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { apiRaw } from "../api/client";
|
||||||
|
import { btn } from "../components/ui";
|
||||||
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
|
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||||||
|
interface EpubLocation {
|
||||||
|
start?: { cfi?: string; sectionIndex?: number };
|
||||||
|
}
|
||||||
|
interface EpubRendition {
|
||||||
|
display(target?: string): Promise<unknown> | unknown;
|
||||||
|
next(): unknown;
|
||||||
|
prev(): unknown;
|
||||||
|
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
interface EpubBookLike {
|
||||||
|
ready: Promise<unknown>;
|
||||||
|
spine?: unknown[];
|
||||||
|
renderTo(el: HTMLElement, opts: Record<string, unknown>): EpubRendition;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
||||||
|
const m = (await import("epubjs")) as { default?: unknown };
|
||||||
|
return m.default as (data: Uint8Array) => EpubBookLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendRef = useRef<EpubRendition | null>(null);
|
||||||
|
const saver = useProgressSaver(book.id);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [nonce, setNonce] = useState(0);
|
||||||
|
const [ready, setReady] = useState(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());
|
||||||
|
if (dead) return;
|
||||||
|
bookObj = ePub(new Uint8Array(buf)); // 字节已在内存,内部 iframe 不再回源,绕开鉴权问题
|
||||||
|
await bookObj.ready;
|
||||||
|
if (dead || !hostRef.current) return;
|
||||||
|
const spineLen = bookObj.spine?.length ?? 1;
|
||||||
|
const r = bookObj.renderTo(hostRef.current, { width: "100%", height: "100%", flow: "paginated" });
|
||||||
|
rendRef.current = r;
|
||||||
|
r.on("relocated", (loc) => {
|
||||||
|
const cfi = loc.start?.cfi ?? "";
|
||||||
|
const idx = loc.start?.sectionIndex ?? 0;
|
||||||
|
const pct = spineLen > 1 ? (idx + 1) / spineLen : 0.5;
|
||||||
|
saver.report(cfi ? { cfi } : {}, Math.min(1, pct));
|
||||||
|
});
|
||||||
|
const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined;
|
||||||
|
// epubjs 对失效 cfi 以 reject Promise signalling,而非同步 throw → 走 then/catch 回退
|
||||||
|
Promise.resolve(cfi ? r.display(cfi) : r.display())
|
||||||
|
.then(() => setReady(true))
|
||||||
|
.catch(() => {
|
||||||
|
if (!cfi) {
|
||||||
|
setErr("EPUB 无法打开");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Promise.resolve(r.display())
|
||||||
|
.then(() => setReady(true))
|
||||||
|
.catch(() => setErr("EPUB 无法打开"));
|
||||||
|
});
|
||||||
|
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败"));
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
rendRef.current?.destroy();
|
||||||
|
rendRef.current = null;
|
||||||
|
bookObj?.destroy();
|
||||||
|
};
|
||||||
|
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (err)
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-red-400">{err}</p>
|
||||||
|
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-rows-[1fr_auto]">
|
||||||
|
<div className="relative min-h-0 bg-white">
|
||||||
|
<div ref={hostRef} className="absolute inset-0" />
|
||||||
|
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||||
|
<button className={btn} onClick={() => rendRef.current?.prev()}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button className={btn} onClick={() => rendRef.current?.next()}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
import { apiRaw } from "../api/client";
|
||||||
|
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" },
|
||||||
|
);
|
||||||
|
|
||||||
|
type RenderTask = { cancel(): void; promise: Promise<void> };
|
||||||
|
|
||||||
|
export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
|
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
|
||||||
|
const taskRef = useRef<RenderTask | null>(null);
|
||||||
|
const restored = useRef(false);
|
||||||
|
const saver = useProgressSaver(book.id);
|
||||||
|
const [num, setNum] = useState(0);
|
||||||
|
const [page, setPage] = useState(0); // 0 基
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [nonce, setNonce] = useState(0); // 仅加载重试
|
||||||
|
const [redraw, setRedraw] = useState(0); // 仅 resize 重绘(不重新下载文档)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false;
|
||||||
|
setErr("");
|
||||||
|
(async () => {
|
||||||
|
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
|
||||||
|
if (dead) return;
|
||||||
|
const task = pdfjs.getDocument({ data: new Uint8Array(buf) });
|
||||||
|
docTaskRef.current = task;
|
||||||
|
const doc = await task.promise;
|
||||||
|
if (dead) {
|
||||||
|
await task.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
docRef.current = doc;
|
||||||
|
setNum(doc.numPages);
|
||||||
|
if (!restored.current) {
|
||||||
|
restored.current = true;
|
||||||
|
const p = Number(initialLocator?.page);
|
||||||
|
if (Number.isInteger(p) && p > 0 && p < doc.numPages) setPage(p);
|
||||||
|
}
|
||||||
|
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "PDF 加载失败"));
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
taskRef.current?.cancel();
|
||||||
|
docTaskRef.current?.destroy();
|
||||||
|
docTaskRef.current = null;
|
||||||
|
docRef.current = null;
|
||||||
|
};
|
||||||
|
}, [book.file_url, nonce]); // initialLocator 是查询缓存值,加载一次即稳定
|
||||||
|
|
||||||
|
// 渲染当前页
|
||||||
|
useEffect(() => {
|
||||||
|
const doc = docRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!doc || !canvas || !wrap || num === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const p = await doc.getPage(page + 1);
|
||||||
|
if (cancelled) return;
|
||||||
|
const cssW = wrap.clientWidth;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const base = p.getViewport({ scale: 1 });
|
||||||
|
const vp = p.getViewport({ scale: (cssW * dpr) / base.width });
|
||||||
|
canvas.width = Math.floor(vp.width);
|
||||||
|
canvas.height = Math.floor(vp.height);
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("canvas 2d 上下文不可用");
|
||||||
|
const t = p.render({ canvas, viewport: vp }) as RenderTask;
|
||||||
|
taskRef.current = t;
|
||||||
|
await t.promise;
|
||||||
|
})().catch((e) => {
|
||||||
|
if (!cancelled && (e as { name?: string }).name !== "AbortException")
|
||||||
|
setErr(e instanceof Error ? e.message : "渲染失败");
|
||||||
|
});
|
||||||
|
saver.report({ page }, (page + 1) / num);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
taskRef.current?.cancel();
|
||||||
|
};
|
||||||
|
}, [page, num, redraw, saver]);
|
||||||
|
|
||||||
|
// 容器尺寸变化 → 重绘
|
||||||
|
useEffect(() => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
let t: ReturnType<typeof setTimeout>;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => setRedraw((x) => x + 1), 200);
|
||||||
|
});
|
||||||
|
ro.observe(wrap);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowRight" || e.key === "PageDown") setPage((p) => Math.min(num - 1, p + 1));
|
||||||
|
else if (e.key === "ArrowLeft" || e.key === "PageUp") setPage((p) => Math.max(0, p - 1));
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err)
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-red-400">{err}</p>
|
||||||
|
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
|
||||||
|
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
|
||||||
|
<canvas ref={canvasRef} className="rounded shadow-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||||
|
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{page + 1}/{num}
|
||||||
|
</span>
|
||||||
|
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { apiRaw } from "../api/client";
|
||||||
|
import { btn } from "../components/ui";
|
||||||
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
|
export default function TextReader({ book, initialLocator }: ReaderProps) {
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saver = useProgressSaver(book.id);
|
||||||
|
const restored = useRef(false);
|
||||||
|
const [text, setText] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [nonce, setNonce] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false;
|
||||||
|
setText(null);
|
||||||
|
setErr("");
|
||||||
|
apiRaw(book.file_url ?? "")
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((t) => !dead && setText(t))
|
||||||
|
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
};
|
||||||
|
}, [book.file_url, nonce]);
|
||||||
|
|
||||||
|
const html = useMemo(() => {
|
||||||
|
if (text == null || book.format !== "md") return null;
|
||||||
|
return DOMPurify.sanitize(marked.parse(text, { async: false }));
|
||||||
|
}, [text, book.format]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restored.current || text == null || !boxRef.current) return;
|
||||||
|
restored.current = true;
|
||||||
|
const f = Number(initialLocator?.scrollFraction);
|
||||||
|
if (Number.isFinite(f) && f > 0 && f <= 1) {
|
||||||
|
const el = boxRef.current;
|
||||||
|
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
||||||
|
}
|
||||||
|
}, [text, initialLocator]);
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const max = el.scrollHeight - el.clientHeight;
|
||||||
|
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||||
|
saver.report({ scrollFraction: frac }, frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err)
|
||||||
|
return (
|
||||||
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-red-400">{err}</p>
|
||||||
|
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (text == null) return <div className="grid h-full place-items-center text-zinc-500">正文加载中…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
|
||||||
|
{html !== null ? (
|
||||||
|
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
) : (
|
||||||
|
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">{text}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "epubjs";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { setToken } from "../src/api/client";
|
||||||
|
import { __resetImageCache, fetchObjectUrl } from "../src/lib/authImage";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetImageCache();
|
||||||
|
setToken("T");
|
||||||
|
(URL as any).createObjectURL = vi.fn(() => "blob:gen");
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("blob → objectURL;同 URL 只发一次请求", async () => {
|
||||||
|
const f = vi.fn(async (_url: any, init: any) => {
|
||||||
|
expect(init.headers.Authorization).toBe("Bearer T");
|
||||||
|
return new Response(new Blob(["img"]), { status: 200 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", f);
|
||||||
|
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
|
||||||
|
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
|
||||||
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("失败不入缓存,重试重新请求", async () => {
|
||||||
|
let n = 0;
|
||||||
|
vi.stubGlobal("fetch", async () => {
|
||||||
|
n++;
|
||||||
|
return n === 1 ? new Response("x", { status: 500 }) : new Response(new Blob(["img"]), { status: 200 });
|
||||||
|
});
|
||||||
|
await expect(fetchObjectUrl("/api/books/2/pages/0?v=h")).rejects.toThrow();
|
||||||
|
expect(await fetchObjectUrl("/api/books/2/pages/0?v=h")).toBe("blob:gen");
|
||||||
|
expect(n).toBe(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client";
|
||||||
|
|
||||||
|
const jres = (status: number, body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||||
|
|
||||||
|
beforeEach(() => setToken(null));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("login 成功存 token,请求体带凭据", async () => {
|
||||||
|
const f = vi.fn(async (_url: any, _init: any) => jres(200, { token: "T" }));
|
||||||
|
vi.stubGlobal("fetch", f);
|
||||||
|
await expect(api.login("a", "b")).resolves.toEqual({ token: "T" });
|
||||||
|
expect(f.mock.calls[0][0]).toBe("/api/auth/login");
|
||||||
|
expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 清 token 并抛 HttpError(parsed code)", async () => {
|
||||||
|
setToken("T");
|
||||||
|
vi.stubGlobal("fetch", async (_url: any, init: any) => {
|
||||||
|
expect(init.headers.Authorization).toBe("Bearer T");
|
||||||
|
return jres(401, { error: { code: "unauthorized", message: "bad credentials" } });
|
||||||
|
});
|
||||||
|
const e = await api.login("a", "b").catch((e) => e);
|
||||||
|
expect(e).toBeInstanceOf(HttpError);
|
||||||
|
expect((e as HttpError).code).toBe("unauthorized");
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非 JSON 错误体回退 http_<status>", async () => {
|
||||||
|
vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 }));
|
||||||
|
const e = await api.listUsers().catch((e) => e);
|
||||||
|
expect((e as HttpError).code).toBe("http_500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("204 无体 resolve undefined", async () => {
|
||||||
|
vi.stubGlobal("fetch", async () => new Response(null, { status: 204 }));
|
||||||
|
await expect(api.deleteBook(1)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
vi.stubGlobal("fetch", async (url: any) => {
|
||||||
|
seen.push(String(url));
|
||||||
|
return jres(200, []);
|
||||||
|
});
|
||||||
|
await api.listBooks({ library: 3, q: "x" });
|
||||||
|
await apiRaw("/api/books/1/cover?v=h");
|
||||||
|
expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page_url_fmt %d 替换", () => {
|
||||||
|
expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("putProgress keepalive 透传 + JSON body", async () => {
|
||||||
|
const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 }));
|
||||||
|
vi.stubGlobal("fetch", f);
|
||||||
|
await api.putProgress(1, { page: 2 }, 0.5, true);
|
||||||
|
expect(f.mock.calls[0][1].keepalive).toBe(true);
|
||||||
|
expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5');
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { groupByDir } from "../src/lib/group";
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
|
||||||
|
const gs = groupByDir([mk("b.cbz"), mk("z/1.cbz"), mk("a/2.cbz"), mk("a/sub/3.cbz")]);
|
||||||
|
expect(gs.map((g) => g.dir)).toEqual(["", "a", "a/sub", "z"]);
|
||||||
|
expect(gs[0].books.map((x) => x.path)).toEqual(["b.cbz"]);
|
||||||
|
expect(gs[1].books.map((x) => x.path)).toEqual(["a/2.cbz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空输入出空数组", () => {
|
||||||
|
expect(groupByDir([])).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { createProgressSaver } from "../src/lib/progress";
|
||||||
|
|
||||||
|
type Sent = [Record<string, unknown>, number, boolean];
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("leading 立即发;间隔内多次 report 合并成一次 trailing", () => {
|
||||||
|
const sent: Sent[] = [];
|
||||||
|
const s = createProgressSaver((l, p, ka) => {
|
||||||
|
sent.push([l, p, ka]);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
s.report({ page: 1 }, 0.1);
|
||||||
|
expect(sent.length).toBe(1);
|
||||||
|
s.report({ page: 2 }, 0.2);
|
||||||
|
s.report({ page: 3 }, 0.3);
|
||||||
|
expect(sent.length).toBe(1);
|
||||||
|
vi.advanceTimersByTime(4999);
|
||||||
|
expect(sent.length).toBe(1);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(sent.length).toBe(2);
|
||||||
|
expect(sent[1]).toEqual([{ page: 3 }, 0.3, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", () => {
|
||||||
|
const sent: Sent[] = [];
|
||||||
|
const s = createProgressSaver((l, p, ka) => {
|
||||||
|
sent.push([l, p, ka]);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
s.report({ page: 0 }, 0);
|
||||||
|
s.report({ page: 5 }, 0.5);
|
||||||
|
s.flush();
|
||||||
|
expect(sent.length).toBe(2);
|
||||||
|
expect(sent[1]).toEqual([{ page: 5 }, 0.5, true]);
|
||||||
|
s.flush();
|
||||||
|
expect(sent.length).toBe(2);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(sent.length).toBe(2); // 挂起已被 flush 消费,定时器不再补发
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { PageHeights } from "../src/lib/virt";
|
||||||
|
|
||||||
|
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
|
||||||
|
const ph = new PageHeights(10, 100);
|
||||||
|
expect(ph.offset(3)).toBe(300);
|
||||||
|
expect(ph.total()).toBe(1000);
|
||||||
|
expect(ph.pageAt(350)).toBe(3);
|
||||||
|
expect(ph.pageAt(-10)).toBe(0);
|
||||||
|
expect(ph.pageAt(99_999)).toBe(9);
|
||||||
|
expect(ph.range(0, 100, 2)).toEqual([0, 3]);
|
||||||
|
expect(ph.range(950, 100, 2)).toEqual([7, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
|
||||||
|
const ph = new PageHeights(10, 100);
|
||||||
|
expect(ph.set(2, 150)).toBe(50);
|
||||||
|
expect(ph.offset(2)).toBe(200); // 自身 offset 不受自己影响
|
||||||
|
expect(ph.offset(3)).toBe(350); // 后续整体 +50
|
||||||
|
expect(ph.total()).toBe(1050);
|
||||||
|
expect(ph.pageAt(349)).toBe(2);
|
||||||
|
expect(ph.pageAt(351)).toBe(3);
|
||||||
|
expect(ph.set(2, 150)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n=0 时一切归空", () => {
|
||||||
|
const ph = new PageHeights(0, 100);
|
||||||
|
expect(ph.total()).toBe(0);
|
||||||
|
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
|
||||||
|
expect(ph.set(0, 50)).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["icon.svg"],
|
||||||
|
manifest: {
|
||||||
|
name: "BookLib 个人书库",
|
||||||
|
short_name: "BookLib",
|
||||||
|
description: "个人书库/漫画库在线阅读",
|
||||||
|
theme_color: "#09090b",
|
||||||
|
background_color: "#09090b",
|
||||||
|
display: "standalone",
|
||||||
|
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,svg,woff2}"],
|
||||||
|
navigateFallback: "/index.html",
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3)
|
||||||
|
urlPattern: ({ url }) =>
|
||||||
|
url.pathname.startsWith("/api/books/") &&
|
||||||
|
url.searchParams.has("v") &&
|
||||||
|
(/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 普通 API 请求 network-first,离线可读最近一次列表/进度
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "booklib-api",
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
expiration: { maxAgeSeconds: 3600, maxEntries: 200 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: { proxy: { "/api": "http://localhost:8080" } },
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user