Compare commits
23
Commits
b09bcf5772
...
22ae1717f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ae1717f4 | ||
|
|
73af5c1d78 | ||
|
|
1adb596981 | ||
|
|
cc66a648b0 | ||
|
|
67c95e7bdc | ||
|
|
96e96fec51 | ||
|
|
1bd7688ee6 | ||
|
|
4f4eff871c | ||
|
|
de3015e537 | ||
|
|
b2e40a5049 | ||
|
|
e08f1dd5b9 | ||
|
|
654e4febf1 | ||
|
|
052b94bb6a | ||
|
|
97f6d2d3b6 | ||
|
|
36a2014a8b | ||
|
|
bc81b03d6a | ||
|
|
cf512b7d5a | ||
|
|
63b41f333e | ||
|
|
b231b62720 | ||
|
|
c34289ebbe | ||
|
|
2f0fc25c7c | ||
|
|
0cc040d750 | ||
|
|
b730b7acb4 |
@@ -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>
|
|
||||||
@@ -71,14 +71,14 @@ docker-compose.yml # web, api(xN), postgres, redis, testprofile
|
|||||||
- Consumes: 无
|
- Consumes: 无
|
||||||
- Produces: `config.Config{Addr, DatabaseURL, RedisURL string, JWTSecret []byte, AdminUser, AdminPassword, BooksDir, CacheDir string, ScanInterval time.Duration, UploadMaxMB int64}`;`config.Load() (*Config, error)`;`api.NewRouter(cfg *config.Config) *gin.Engine`
|
- Produces: `config.Config{Addr, DatabaseURL, RedisURL string, JWTSecret []byte, AdminUser, AdminPassword, BooksDir, CacheDir string, ScanInterval time.Duration, UploadMaxMB int64}`;`config.Load() (*Config, error)`;`api.NewRouter(cfg *config.Config) *gin.Engine`
|
||||||
|
|
||||||
- [ ] **Step 1: 初始化 module**
|
- [x] **Step 1: 初始化 module**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && go mod init booklib
|
cd backend && go mod init booklib
|
||||||
go get github.com/gin-gonic/gin@latest github.com/jackc/pgx/v5@latest github.com/redis/go-redis/v9@latest github.com/golang-jwt/jwt/v5@latest golang.org/x/crypto@latest
|
go get github.com/gin-gonic/gin@latest github.com/jackc/pgx/v5@latest github.com/redis/go-redis/v9@latest github.com/golang-jwt/jwt/v5@latest golang.org/x/crypto@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 写失败测试 `internal/api/router_test.go`**
|
- [x] **Step 2: 写失败测试 `internal/api/router_test.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package api
|
package api
|
||||||
@@ -107,12 +107,12 @@ func TestHealthz(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑测试确认失败**
|
- [x] **Step 3: 跑测试确认失败**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/api/ -run TestHealthz -v`
|
Run: `cd backend && go test ./internal/api/ -run TestHealthz -v`
|
||||||
Expected: 编译失败 "undefined: NewRouter"
|
Expected: 编译失败 "undefined: NewRouter"
|
||||||
|
|
||||||
- [ ] **Step 4: 实现 config + router**
|
- [x] **Step 4: 实现 config + router**
|
||||||
|
|
||||||
`internal/config/config.go`:
|
`internal/config/config.go`:
|
||||||
|
|
||||||
@@ -202,12 +202,12 @@ func NewRouter(cfg *config.Config) *gin.Engine {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: 跑测试确认通过**
|
- [x] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/api/ -v`
|
Run: `cd backend && go test ./internal/api/ -v`
|
||||||
Expected: PASS
|
Expected: PASS
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git init -b main . && git add backend && git commit -m "feat(backend): module scaffold, config, gin router with healthz"
|
git init -b main . && git add backend && git commit -m "feat(backend): module scaffold, config, gin router with healthz"
|
||||||
@@ -268,7 +268,7 @@ func db.Connect(ctx, url string) (*pgxpool.Pool, error)
|
|||||||
func db.Migrate(ctx, p *pgxpool.Pool) error // 执行内嵌 schema.sql,幂等
|
func db.Migrate(ctx, p *pgxpool.Pool) error // 执行内嵌 schema.sql,幂等
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 起测试用 PG/Redis**
|
- [x] **Step 1: 起测试用 PG/Redis**
|
||||||
|
|
||||||
`deploy/docker-compose.dev.yml`:
|
`deploy/docker-compose.dev.yml`:
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ services:
|
|||||||
Run: `docker compose -f deploy/docker-compose.dev.yml up -d --wait`
|
Run: `docker compose -f deploy/docker-compose.dev.yml up -d --wait`
|
||||||
Expected: 两个 healthy。后续所有集成测试用 `DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable`。
|
Expected: 两个 healthy。后续所有集成测试用 `DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable`。
|
||||||
|
|
||||||
- [ ] **Step 2: 写 schema.sql(内嵌,幂等)**
|
- [x] **Step 2: 写 schema.sql(内嵌,幂等)**
|
||||||
|
|
||||||
`internal/db/schema.sql`:
|
`internal/db/schema.sql`:
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
|||||||
PRIMARY KEY (user_id, library_id, book_path));
|
PRIMARY KEY (user_id, library_id, book_path));
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 写失败测试 `store_test.go`(全部 SQL 行为在此锁死)**
|
- [x] **Step 3: 写失败测试 `store_test.go`(全部 SQL 行为在此锁死)**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package store
|
package store
|
||||||
@@ -438,12 +438,12 @@ func TestProgressUpsertAndJoin(t *testing.T) {
|
|||||||
|
|
||||||
(BookMeta 只带 diff 需要的 ID/Size/ModTS/Format;page_count 走 GetBook,测试里已有覆盖。)
|
(BookMeta 只带 diff 需要的 ID/Size/ModTS/Format;page_count 走 GetBook,测试里已有覆盖。)
|
||||||
|
|
||||||
- [ ] **Step 4: 跑测试确认失败**
|
- [x] **Step 4: 跑测试确认失败**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v`
|
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v`
|
||||||
Expected: 编译失败 "booklib/internal/db: no such package"
|
Expected: 编译失败 "booklib/internal/db: no such package"
|
||||||
|
|
||||||
- [ ] **Step 5: 实现 db.go 与 store.go**
|
- [x] **Step 5: 实现 db.go 与 store.go**
|
||||||
|
|
||||||
`internal/db/db.go`:
|
`internal/db/db.go`:
|
||||||
|
|
||||||
@@ -790,12 +790,12 @@ func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath s
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: 跑测试确认通过**
|
- [x] **Step 6: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v`
|
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v`
|
||||||
Expected: 2 个测试 PASS(无 DATABASE_URL 时 SKIP)
|
Expected: 2 个测试 PASS(无 DATABASE_URL 时 SKIP)
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend deploy && git commit -m "feat(backend): pg schema + store queries incl. path-keyed progress"
|
git add backend deploy && git commit -m "feat(backend): pg schema + store queries incl. path-keyed progress"
|
||||||
@@ -824,7 +824,7 @@ auth.Parse(secret []byte, token string) (*auth.Claims, error)
|
|||||||
seed.Admin(ctx context.Context, s *store.Store, user, pass string) error // users 为空才建;user/pass 空则跳过;失败仅由调用方记日志
|
seed.Admin(ctx context.Context, s *store.Store, user, pass string) error // users 为空才建;user/pass 空则跳过;失败仅由调用方记日志
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/auth/auth_test.go`:
|
`internal/auth/auth_test.go`:
|
||||||
|
|
||||||
@@ -917,12 +917,12 @@ func TestSeedOnlyWhenEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 跑,确认编译失败**
|
- [x] **Step 2: 跑,确认编译失败**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/auth/ ./internal/seed/ -v`
|
Run: `cd backend && go test ./internal/auth/ ./internal/seed/ -v`
|
||||||
Expected: FAIL — undefined
|
Expected: FAIL — undefined
|
||||||
|
|
||||||
- [ ] **Step 3: 实现**
|
- [x] **Step 3: 实现**
|
||||||
|
|
||||||
`internal/auth/auth.go`:
|
`internal/auth/auth.go`:
|
||||||
|
|
||||||
@@ -1017,12 +1017,12 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 跑测试确认通过**
|
- [x] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/auth/ -v && DATABASE_URL=... go test ./internal/seed/ -v`
|
Run: `cd backend && go test ./internal/auth/ -v && DATABASE_URL=... go test ./internal/seed/ -v`
|
||||||
Expected: PASS
|
Expected: PASS
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend && git commit -m "feat(backend): bcrypt+jwt auth package and admin seeding"
|
git add backend && git commit -m "feat(backend): bcrypt+jwt auth package and admin seeding"
|
||||||
@@ -1055,7 +1055,7 @@ api 中间件设置 ctx key: "uid" int64 / "role" string;helpers api.uid(c)/api.
|
|||||||
NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine // scanner 参数 Task 9 再加
|
NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine // scanner 参数 Task 9 再加
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试 `auth_test.go`**
|
- [x] **Step 1: 写失败测试 `auth_test.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package api
|
package api
|
||||||
@@ -1193,12 +1193,12 @@ func TestDisabledIsSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 跑,确认失败**
|
- [x] **Step 2: 跑,确认失败**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/redispkg/ -v`
|
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/redispkg/ -v`
|
||||||
Expected: 编译失败 undefined: redispkg / NewRouter 签名不符
|
Expected: 编译失败 undefined: redispkg / NewRouter 签名不符
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 redispkg**
|
- [x] **Step 3: 实现 redispkg**
|
||||||
|
|
||||||
`internal/redispkg/redis.go`:
|
`internal/redispkg/redis.go`:
|
||||||
|
|
||||||
@@ -1283,7 +1283,7 @@ func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bo
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 实现 api struct/错误/中间件 + auth handlers,重写 NewRouter**
|
- [x] **Step 4: 实现 api struct/错误/中间件 + auth handlers,重写 NewRouter**
|
||||||
|
|
||||||
`internal/api/api.go`:
|
`internal/api/api.go`:
|
||||||
|
|
||||||
@@ -1442,12 +1442,12 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: 跑测试确认通过**
|
- [x] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test ./internal/api/ ./internal/redispkg/ -v`
|
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test ./internal/api/ ./internal/redispkg/ -v`
|
||||||
Expected: PASS(REDIS_URL 不给也应 PASS — no-op 路径)
|
Expected: PASS(REDIS_URL 不给也应 PASS — no-op 路径)
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend && git commit -m "feat(backend): jwt middleware, login with redis rate limit, /auth/me"
|
git add backend && git commit -m "feat(backend): jwt middleware, login with redis rate limit, /auth/me"
|
||||||
@@ -1470,7 +1470,7 @@ git add backend && git commit -m "feat(backend): jwt middleware, login with redi
|
|||||||
`bookfile.FormatFromExt(name string) string` — `.cbz→"cbz" .pdf→"pdf" .epub→"epub" .txt→"txt" .md→"md"`,其他 `"..."` → `""`
|
`bookfile.FormatFromExt(name string) string` — `.cbz→"cbz" .pdf→"pdf" .epub→"epub" .txt→"txt" .md→"md"`,其他 `"..."` → `""`
|
||||||
`bookfile.SafeName(s string) string` — 去目录、去控制符、trim、限长 200、保留空格中文
|
`bookfile.SafeName(s string) string` — 去目录、去控制符、trim、限长 200、保留空格中文
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试(bookfile 先行,纯函数)**
|
- [x] **Step 1: 写失败测试(bookfile 先行,纯函数)**
|
||||||
|
|
||||||
`internal/bookfile/bookfile_test.go`:
|
`internal/bookfile/bookfile_test.go`:
|
||||||
|
|
||||||
@@ -1511,7 +1511,7 @@ func TestSafeName(t *testing.T) {
|
|||||||
|
|
||||||
(`"tar.gz"→""`:扩展名是 `.gz` 不在白名单,正确。`"con\ntl.bin"` → base 后去 `\t` 得 `cntl.bin`。)
|
(`"tar.gz"→""`:扩展名是 `.gz` 不在白名单,正确。`"con\ntl.bin"` → base 后去 `\t` 得 `cntl.bin`。)
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 `bookfile.go`**
|
- [x] **Step 2: 跑确认失败;实现 `bookfile.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package bookfile
|
package bookfile
|
||||||
@@ -1560,7 +1560,7 @@ func SafeName(s string) string {
|
|||||||
|
|
||||||
Run: `go test ./internal/bookfile/ -v` → PASS。
|
Run: `go test ./internal/bookfile/ -v` → PASS。
|
||||||
|
|
||||||
- [ ] **Step 3: 写失败测试 users/libraries(API)**
|
- [x] **Step 3: 写失败测试 users/libraries(API)**
|
||||||
|
|
||||||
`internal/api/users_test.go`:
|
`internal/api/users_test.go`:
|
||||||
|
|
||||||
@@ -1732,7 +1732,7 @@ func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writ
|
|||||||
|
|
||||||
(`libraries_test.go` 需 import `bytes`;`httptest.NewRequest("POST", url, buf)` 直接收 `*bytes.Buffer`。)
|
(`libraries_test.go` 需 import `bytes`;`httptest.NewRequest("POST", url, buf)` 直接收 `*bytes.Buffer`。)
|
||||||
|
|
||||||
- [ ] **Step 4: 跑确认失败;实现 users.go / libraries.go / router 接线**
|
- [x] **Step 4: 跑确认失败;实现 users.go / libraries.go / router 接线**
|
||||||
|
|
||||||
先修 `auth_test.go` 的 setupAPI(upload 测试需要,libRoot 校验依赖 BooksDir;auth_test.go 补 import `path/filepath`):
|
先修 `auth_test.go` 的 setupAPI(upload 测试需要,libRoot 校验依赖 BooksDir;auth_test.go 补 import `path/filepath`):
|
||||||
|
|
||||||
@@ -2038,12 +2038,12 @@ func (a *api) uniquePath(root, name string) (string, error) {
|
|||||||
libs.POST("/:id/upload", a.adminOnly(), a.upload)
|
libs.POST("/:id/upload", a.adminOnly(), a.upload)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: 跑测试确认通过**
|
- [x] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/bookfile/ -v`
|
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/bookfile/ -v`
|
||||||
Expected: PASS
|
Expected: PASS
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend && git commit -m "feat(backend): user + library admin API, atomic sanitized upload"
|
git add backend && git commit -m "feat(backend): user + library admin API, atomic sanitized upload"
|
||||||
@@ -2068,7 +2068,7 @@ bookfile.NaturalLess(a, b string) bool
|
|||||||
bookfile.Hash(size, modTS int64) string // sha256("size:mtime") hex 前 16 字符
|
bookfile.Hash(size, modTS int64) string // sha256("size:mtime") hex 前 16 字符
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/bookfile/zip_test.go`。测试内构造 zip 夹具(不落盘,bytes.Buffer + zip.Writer):
|
`internal/bookfile/zip_test.go`。测试内构造 zip 夹具(不落盘,bytes.Buffer + zip.Writer):
|
||||||
|
|
||||||
@@ -2162,12 +2162,12 @@ func TestHash(t *testing.T) {
|
|||||||
|
|
||||||
注意 `{"007","7"}` 与 `{"A","a"}` 两个用例:数字相等时按原始串比较(`"007" < "7"`,字典序),纯 ASCII 大小写按字节序。
|
注意 `{"007","7"}` 与 `{"A","a"}` 两个用例:数字相等时按原始串比较(`"007" < "7"`,字典序),纯 ASCII 大小写按字节序。
|
||||||
|
|
||||||
- [ ] **Step 2: 跑,确认失败**
|
- [x] **Step 2: 跑,确认失败**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/bookfile/ -run 'PageIndex|ReadEntry|Natural|Hash' -v`
|
Run: `cd backend && go test ./internal/bookfile/ -run 'PageIndex|ReadEntry|Natural|Hash' -v`
|
||||||
Expected: 编译失败 undefined
|
Expected: 编译失败 undefined
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 `zip.go` 与 `hash.go`**
|
- [x] **Step 3: 实现 `zip.go` 与 `hash.go`**
|
||||||
|
|
||||||
`internal/bookfile/zip.go`:
|
`internal/bookfile/zip.go`:
|
||||||
|
|
||||||
@@ -2290,7 +2290,7 @@ func Hash(size, modTS int64) string {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 跑确认通过;Commit**
|
- [x] **Step 4: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
||||||
|
|
||||||
@@ -2315,7 +2315,7 @@ bookfile.CBZCover(f io.ReaderAt, size int64) (img []byte, ext string, err error)
|
|||||||
bookfile.EPUBCover(f io.ReaderAt, size int64) (img []byte, ext string, err error) // 无封面 → err
|
bookfile.EPUBCover(f io.ReaderAt, size int64) (img []byte, ext string, err error) // 无封面 → err
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/bookfile/cover_test.go`(内存构造真 epub:container.xml + OPF + cover.png):
|
`internal/bookfile/cover_test.go`(内存构造真 epub:container.xml + OPF + cover.png):
|
||||||
|
|
||||||
@@ -2398,7 +2398,7 @@ func TestEPUBNoCover(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 `cover.go`**
|
- [x] **Step 2: 跑确认失败;实现 `cover.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package bookfile
|
package bookfile
|
||||||
@@ -2530,7 +2530,7 @@ func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) {
|
|||||||
|
|
||||||
(cover_test 中 `_ = url.PathUnescape` 那行不写,url 只在实现里 import。)
|
(cover_test 中 `_ = url.PathUnescape` 那行不写,url 只在实现里 import。)
|
||||||
|
|
||||||
- [ ] **Step 3: 跑确认通过;Commit**
|
- [x] **Step 3: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
||||||
|
|
||||||
@@ -2561,7 +2561,7 @@ bookfile.SweepStale(cacheDir string, live map[string]bool) (int, error) // 删
|
|||||||
|
|
||||||
衍生文件永不算旧即永不原地改:更新的书 hash 变 → 新 key 目录;旧 key 目录由清扫删除。
|
衍生文件永不算旧即永不原地改:更新的书 hash 变 → 新 key 目录;旧 key 目录由清扫删除。
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/bookfile/cache_test.go`:
|
`internal/bookfile/cache_test.go`:
|
||||||
|
|
||||||
@@ -2621,7 +2621,7 @@ func TestPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 `cache.go`**
|
- [x] **Step 2: 跑确认失败;实现 `cache.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package bookfile
|
package bookfile
|
||||||
@@ -2687,7 +2687,7 @@ func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑确认通过;Commit**
|
- [x] **Step 3: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
|
||||||
|
|
||||||
@@ -2718,7 +2718,7 @@ NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scan
|
|||||||
|
|
||||||
行为规则(spec §3.1/§6.1):锁内执行;单本失败 → `state=error` 继续;删行不删进度(path 键控);扫描尾清扫缓存。**不删磁盘文件**(删文件只有 API DELETE)。
|
行为规则(spec §3.1/§6.1):锁内执行;单本失败 → `state=error` 继续;删行不删进度(path 键控);扫描尾清扫缓存。**不删磁盘文件**(删文件只有 API DELETE)。
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试 `scanner_test.go`(集成,依赖 PG;Redis 可为空走 no-op)**
|
- [x] **Step 1: 写失败测试 `scanner_test.go`(集成,依赖 PG;Redis 可为空走 no-op)**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package scanner
|
package scanner
|
||||||
@@ -2864,11 +2864,11 @@ func TestScanBrokenCBZStateError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败**
|
- [x] **Step 2: 跑确认失败**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... go test ./internal/scanner/ -v` → 编译失败
|
Run: `cd backend && DATABASE_URL=... go test ./internal/scanner/ -v` → 编译失败
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 scanner**
|
- [x] **Step 3: 实现 scanner**
|
||||||
|
|
||||||
`internal/scanner/scanner.go`:
|
`internal/scanner/scanner.go`:
|
||||||
|
|
||||||
@@ -3126,7 +3126,7 @@ func (s *Scanner) sweepCache(ctx context.Context) {
|
|||||||
|
|
||||||
坏 cbz 的判定规则已合并在上面 `add`/`update` 终版中:`PageIndex` 失败(含 `ErrNotZip`、`ErrUnsafeZip`,Task 6 已定义)→ `state=error`;cover 提取失败只 log。
|
坏 cbz 的判定规则已合并在上面 `add`/`update` 终版中:`PageIndex` 失败(含 `ErrNotZip`、`ErrUnsafeZip`,Task 6 已定义)→ `state=error`;cover 提取失败只 log。
|
||||||
|
|
||||||
- [ ] **Step 4: 接线 API**
|
- [x] **Step 4: 接线 API**
|
||||||
|
|
||||||
`NewRouter` 加第 4 参 `sc *scanner.Scanner`,存进 `api` struct(`sc *scanner.Scanner`);`internal/api/libraries.go` 的 `scanLibrary` 整函数替换:
|
`NewRouter` 加第 4 参 `sc *scanner.Scanner`,存进 `api` struct(`sc *scanner.Scanner`);`internal/api/libraries.go` 的 `scanLibrary` 整函数替换:
|
||||||
|
|
||||||
@@ -3153,7 +3153,7 @@ func (a *api) scanLibrary(c *gin.Context) {
|
|||||||
|
|
||||||
(Task 10 会把 setupAPI 整体替换为 4 返回值终版,这里先保编译。)`api` struct 增加字段 `sc *scanner.Scanner`。
|
(Task 10 会把 setupAPI 整体替换为 4 返回值终版,这里先保编译。)`api` struct 增加字段 `sc *scanner.Scanner`。
|
||||||
|
|
||||||
- [ ] **Step 5: 跑全部后端测试确认通过;Commit**
|
- [x] **Step 5: 跑全部后端测试确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test -p 1 ./... -v`
|
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test -p 1 ./... -v`
|
||||||
Expected: 全 PASS
|
Expected: 全 PASS
|
||||||
@@ -3217,7 +3217,7 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
|
|||||||
|
|
||||||
既有 call site 全部改为按需要接收:`_, _, h, _ := setupAPI(t)`(共 5 处:auth_test 2、users_test 2、libraries_test 1);`TestMemberCannotWriteUsers` 里那行无效的 `tok, _ := auth.Sign(...)` 删除,`auth_test.go` 的 `booklib/internal/auth` import 随之删除。setupAPI 需补 import:`path/filepath`、`booklib/internal/scanner`。
|
既有 call site 全部改为按需要接收:`_, _, h, _ := setupAPI(t)`(共 5 处:auth_test 2、users_test 2、libraries_test 1);`TestMemberCannotWriteUsers` 里那行无效的 `tok, _ := auth.Sign(...)` 删除,`auth_test.go` 的 `booklib/internal/auth` import 随之删除。setupAPI 需补 import:`path/filepath`、`booklib/internal/scanner`。
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/api/books_test.go`(fixture 助手 `newLibrary/writeCBZ/scanNow/loginAs/adminToken` 定义在这里,Task 11/12 的测试文件复用):
|
`internal/api/books_test.go`(fixture 助手 `newLibrary/writeCBZ/scanNow/loginAs/adminToken` 定义在这里,Task 11/12 的测试文件复用):
|
||||||
|
|
||||||
@@ -3372,7 +3372,7 @@ func loginAs(t *testing.T, h http.Handler, user, pass string) string {
|
|||||||
|
|
||||||
`adminToken` 终版(users_test.go 中):`func adminToken(t *testing.T, h http.Handler) string { return loginAs(t, h, "alice", "pw12345") }`。
|
`adminToken` 终版(users_test.go 中):`func adminToken(t *testing.T, h http.Handler) string { return loginAs(t, h, "alice", "pw12345") }`。
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 books.go + 路由**
|
- [x] **Step 2: 跑确认失败;实现 books.go + 路由**
|
||||||
|
|
||||||
`internal/api/books.go`:
|
`internal/api/books.go`:
|
||||||
|
|
||||||
@@ -3521,7 +3521,7 @@ func (a *api) deleteBook(c *gin.Context) {
|
|||||||
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑确认通过;Commit**
|
- [x] **Step 3: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
|
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
|
||||||
|
|
||||||
@@ -3551,7 +3551,7 @@ GET /api/books/:id/pages/:n → 第 n 页(0-based),落盘缓存后吐;immutable
|
|||||||
(*api) pageIndex(c, b store.Book, root string) ([]string, error) // redis pagesidx 缓存 7d
|
(*api) pageIndex(c, b store.Book, root string) ([]string, error) // redis pagesidx 缓存 7d
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/api/content_test.go`(fixture 复用 books_test 的 `newLibrary/writeCBZ/scanNow/loginAs/adminToken`):
|
`internal/api/content_test.go`(fixture 复用 books_test 的 `newLibrary/writeCBZ/scanNow/loginAs/adminToken`):
|
||||||
|
|
||||||
@@ -3659,7 +3659,7 @@ func TestFileRangeETag(t *testing.T) {
|
|||||||
|
|
||||||
(`content_test.go` import 需含 `net/http/httptest`、`encoding/json`、`strings`、`os`、`path/filepath`、`testing`、`net/http`。)
|
(`content_test.go` import 需含 `net/http/httptest`、`encoding/json`、`strings`、`os`、`path/filepath`、`testing`、`net/http`。)
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 content.go**
|
- [x] **Step 2: 跑确认失败;实现 content.go**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package api
|
package api
|
||||||
@@ -3853,7 +3853,7 @@ router 追加:
|
|||||||
p.GET("/books/:id/pages/:n", a.page)
|
p.GET("/books/:id/pages/:n", a.page)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑确认通过;Commit**
|
- [x] **Step 3: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./internal/api/ -v` → PASS
|
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./internal/api/ -v` → PASS
|
||||||
|
|
||||||
@@ -3874,7 +3874,7 @@ git add backend && git commit -m "feat(backend): cover/file/pages endpoints —
|
|||||||
- Consumes: Task 2/10
|
- Consumes: Task 2/10
|
||||||
- Produces:`PUT /api/books/:id/progress`、`GET /api/progress`
|
- Produces:`PUT /api/books/:id/progress`、`GET /api/progress`
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败测试**
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
`internal/api/progress_test.go`:
|
`internal/api/progress_test.go`:
|
||||||
|
|
||||||
@@ -3959,7 +3959,7 @@ func TestProgressRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
(progress_test 不用 context,import 列表不要 `context`。)
|
(progress_test 不用 context,import 列表不要 `context`。)
|
||||||
|
|
||||||
- [ ] **Step 2: 跑确认失败;实现 progress.go**
|
- [x] **Step 2: 跑确认失败;实现 progress.go**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package api
|
package api
|
||||||
@@ -4028,7 +4028,7 @@ router 追加:
|
|||||||
p.GET("/progress", a.listProgress)
|
p.GET("/progress", a.listProgress)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑确认通过;Commit**
|
- [x] **Step 3: 跑确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
|
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
|
||||||
|
|
||||||
@@ -4049,7 +4049,7 @@ git add backend && git commit -m "feat(backend): reading progress upsert + listi
|
|||||||
- Consumes: 全部
|
- Consumes: 全部
|
||||||
- Produces: 可执行 `/server`,env 驱动;`NewRouter` 最终签名不变。
|
- Produces: 可执行 `/server`,env 驱动;`NewRouter` 最终签名不变。
|
||||||
|
|
||||||
- [ ] **Step 1: config 单测(先失败)**
|
- [x] **Step 1: config 单测(先失败)**
|
||||||
|
|
||||||
`internal/config/config_test.go`:
|
`internal/config/config_test.go`:
|
||||||
|
|
||||||
@@ -4083,7 +4083,7 @@ func TestLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 实现 main.go**
|
- [x] **Step 2: 实现 main.go**
|
||||||
|
|
||||||
`cmd/server/main.go`:
|
`cmd/server/main.go`:
|
||||||
|
|
||||||
@@ -4149,7 +4149,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: PG 不可用 → 503(spec §9 降级)**
|
- [x] **Step 3: PG 不可用 → 503(spec §9 降级)**
|
||||||
|
|
||||||
`internal/api/api.go` 追加(`import` 补 `errors`、`io`、`log`、`net`、`syscall`、`github.com/jackc/pgx/v5/pgxpool`):
|
`internal/api/api.go` 追加(`import` 补 `errors`、`io`、`log`、`net`、`syscall`、`github.com/jackc/pgx/v5/pgxpool`):
|
||||||
|
|
||||||
@@ -4168,7 +4168,7 @@ func dbErr(c *gin.Context, e error) {
|
|||||||
|
|
||||||
把所有 handler 中 `"internal", "db error"` 的 500 分支替换为 `dbErr(c, <该处的 error 变量>)`:auth.go(login 1 处)、users.go(listUsers/createUser/deleteUser 各 1 处)、libraries.go(listLibraries)、books.go(listBooks/deleteBook)、content.go 无 DB 500 分支、progress.go(putProgress/listProgress)。替换后 `go test -p 1 ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。
|
把所有 handler 中 `"internal", "db error"` 的 500 分支替换为 `dbErr(c, <该处的 error 变量>)`:auth.go(login 1 处)、users.go(listUsers/createUser/deleteUser 各 1 处)、libraries.go(listLibraries)、books.go(listBooks/deleteBook)、content.go 无 DB 500 分支、progress.go(putProgress/listProgress)。替换后 `go test -p 1 ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。
|
||||||
|
|
||||||
- [ ] **Step 4: 全量回归 + 本地起服烟测**
|
- [x] **Step 4: 全量回归 + 本地起服烟测**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test -p 1 ./...
|
cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test -p 1 ./...
|
||||||
@@ -4187,7 +4187,7 @@ kill %1
|
|||||||
|
|
||||||
Expected: 全 PASS + 末行输出含 `"title":"a"`。
|
Expected: 全 PASS + 末行输出含 `"title":"a"`。
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend && git commit -m "feat(backend): main wiring, graceful shutdown, local smoke green"
|
git add backend && git commit -m "feat(backend): main wiring, graceful shutdown, local smoke green"
|
||||||
@@ -4206,7 +4206,7 @@ git add backend && git commit -m "feat(backend): main wiring, graceful shutdown,
|
|||||||
- Consumes: Task 13 的可运行服务
|
- Consumes: Task 13 的可运行服务
|
||||||
- Produces: `docker compose up -d --scale api=2` 起 web/api×2/pg/redis;`scripts/smoke.sh` 一条命令验收整个后端
|
- Produces: `docker compose up -d --scale api=2` 起 web/api×2/pg/redis;`scripts/smoke.sh` 一条命令验收整个后端
|
||||||
|
|
||||||
- [ ] **Step 1: 部署文件**
|
- [x] **Step 1: 部署文件**
|
||||||
|
|
||||||
`deploy/Dockerfile.api`:
|
`deploy/Dockerfile.api`:
|
||||||
|
|
||||||
@@ -4309,7 +4309,7 @@ ADMIN_PASSWORD=change-me-min-8
|
|||||||
SCAN_INTERVAL_SEC=60
|
SCAN_INTERVAL_SEC=60
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 冒烟脚本 `scripts/smoke.sh`**
|
- [x] **Step 2: 冒烟脚本 `scripts/smoke.sh`**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -4370,7 +4370,7 @@ curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete"
|
|||||||
say "ALL SMOKE TESTS PASSED"
|
say "ALL SMOKE TESTS PASSED"
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 跑通**
|
- [x] **Step 3: 跑通**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env && sed -i '' -E "s/change-me-openssl-rand-hex-32/$(openssl rand -hex 32)/; s/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD=smokeadmin1/" .env
|
cp .env.example .env && sed -i '' -E "s/change-me-openssl-rand-hex-32/$(openssl rand -hex 32)/; s/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD=smokeadmin1/" .env
|
||||||
@@ -4382,7 +4382,7 @@ docker compose down -v && rm -f library_upload_note.txt
|
|||||||
|
|
||||||
Expected: `ALL SMOKE TESTS PASSED`。(Linux 上 `sed -i` 去掉 `''` 参数。)
|
Expected: `ALL SMOKE TESTS PASSED`。(Linux 上 `sed -i` 去掉 `''` 参数。)
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add docker-compose.yml .env.example deploy scripts && git commit -m "feat(deploy): compose w/ nginx api-prefix ingress, scaled stateless api, e2e smoke"
|
git add docker-compose.yml .env.example deploy scripts && git commit -m "feat(deploy): compose w/ nginx api-prefix ingress, scaled stateless api, e2e smoke"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
# 个人书库 / 漫画库 — 设计文档
|
# 个人书库 / 漫画库 — 设计文档
|
||||||
|
|
||||||
日期:2026-09-04
|
日期:2026-09-04
|
||||||
状态:待评审
|
状态:已实现(Plan 1 backend+deploy、Plan 2 frontend 均已合并至 master)
|
||||||
|
|
||||||
## 1. 概述
|
## 1. 概述
|
||||||
|
|
||||||
|
|||||||
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