Compare commits
51
Commits
443f4acfa9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef57b194f4 | ||
|
|
395be9688d | ||
|
|
28d8587e71 | ||
|
|
4767ee7fec | ||
|
|
8831a9b277 | ||
|
|
272d0bc672 | ||
|
|
1004daf821 | ||
|
|
74e5e9d5fe | ||
|
|
0d7cd5f322 | ||
|
|
174c498757 | ||
|
|
26aafb3f53 | ||
|
|
cea2414371 | ||
|
|
1f9d7e7572 | ||
|
|
996f492083 | ||
|
|
6aefe62a9f | ||
|
|
8283d66db6 | ||
|
|
655b77c2b7 | ||
|
|
64cc293878 | ||
|
|
72cf13c8c3 | ||
|
|
154ef11c90 | ||
|
|
043b7fc480 | ||
|
|
cc18822c97 | ||
|
|
2fccf3f2b3 | ||
|
|
caacaf9c30 | ||
|
|
5ba522753a | ||
|
|
8f4c34c949 | ||
|
|
0bf8129f6e | ||
|
|
f5c33f538e | ||
|
|
cd02a88b59 | ||
|
|
4ba55fcf2a | ||
|
|
f2f06a516b | ||
|
|
9c6339d35e | ||
|
|
217adf5914 | ||
|
|
ba28fe9037 | ||
|
|
b65ea93984 | ||
|
|
a3755bcfab | ||
|
|
38957b4c2c | ||
|
|
ca419410b4 | ||
|
|
147339083b | ||
|
|
a427aa1c4a | ||
|
|
29167d3cea | ||
|
|
09a02fa0d7 | ||
|
|
24c5d42531 | ||
|
|
42a39ceef8 | ||
|
|
b3a207a5d2 | ||
|
|
8305af9d5c | ||
|
|
85c61d9f24 | ||
|
|
cc1470f6e4 | ||
|
|
7baadedeb4 | ||
|
|
b9b7022212 | ||
|
|
fbd5cd243d |
@@ -0,0 +1,41 @@
|
|||||||
|
name: e2e
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Render configs
|
||||||
|
working-directory: deploy
|
||||||
|
run: ./prepare.sh
|
||||||
|
- name: Start dev stack
|
||||||
|
working-directory: deploy
|
||||||
|
env:
|
||||||
|
JWT_SECRET: ci-e2e-only-secret
|
||||||
|
ADMIN_USER: admin
|
||||||
|
ADMIN_PASSWORD: ci-e2e-password
|
||||||
|
run: docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
- name: Wait for web
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -fsS http://localhost:5173/ >/dev/null 2>&1; then exit 0; fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "web not up in 450s"; docker compose -f deploy/docker-compose.dev.yml logs --tail 50; exit 1
|
||||||
|
- run: cd frontend && npm ci
|
||||||
|
- run: cd frontend && npx playwright install --with-deps chromium
|
||||||
|
- name: Run e2e
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
E2E_ADMIN_USER: admin
|
||||||
|
E2E_ADMIN_PASSWORD: ci-e2e-password
|
||||||
|
run: npx playwright test
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: frontend/playwright-report
|
||||||
@@ -3,3 +3,6 @@
|
|||||||
tasks/
|
tasks/
|
||||||
.playwright*/
|
.playwright*/
|
||||||
frontend/tsconfig.tsbuildinfo
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
dist-stats/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node
|
|||||||
- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app.
|
- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app.
|
||||||
- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
|
- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
|
||||||
- Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
|
- Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
|
||||||
|
- TypeScript runs side-by-side (`frontend/package.json`): `typescript` = TS 6.0.2 JS API for tooling (typescript-eslint doesn't support TS 7 yet), `@typescript/native` = native 7.0.2 providing `tsc`. Do not re-alias `typescript` to 7.x until typescript-eslint ships TS 7 support. Plain `npm install` needs `--legacy-peer-deps` (also pinned in `frontend/.npmrc`); `npm ci` is unaffected.
|
||||||
|
|
||||||
### deploy
|
### deploy
|
||||||
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
|
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import (
|
|||||||
|
|
||||||
"booklib/cmd/webui/handlers"
|
"booklib/cmd/webui/handlers"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/ports"
|
||||||
"booklib/internal/scanner"
|
|
||||||
"booklib/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
|
// NewRouter 只依赖 port 接口:main.go 传具体实现(*store.Store 满足 5 个 store
|
||||||
|
// 接口,*redispkg.R 满足 RateLimiter),测试传 portsfake。
|
||||||
|
func NewRouter(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore,
|
||||||
|
progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter,
|
||||||
|
sc ports.Scanner, med ports.Media, up ports.UploadSessions) *gin.Engine {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
h := handlers.New(cfg, st, rdb, sc)
|
h := handlers.New(cfg, users, libs, books, progress, bookmarks, rl, sc, med, up)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
|
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
|
||||||
panic(e)
|
panic(e)
|
||||||
@@ -27,17 +29,17 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
|
|||||||
p := g.Group("", h.AuthMw())
|
p := g.Group("", h.AuthMw())
|
||||||
p.GET("/auth/me", h.Me)
|
p.GET("/auth/me", h.Me)
|
||||||
|
|
||||||
users := p.Group("/users", h.AdminOnly())
|
usersGrp := p.Group("/users", h.AdminOnly())
|
||||||
users.GET("", h.ListUsers)
|
usersGrp.GET("", h.ListUsers)
|
||||||
users.POST("", h.CreateUser)
|
usersGrp.POST("", h.CreateUser)
|
||||||
users.DELETE("/:id", h.DeleteUser)
|
usersGrp.DELETE("/:id", h.DeleteUser)
|
||||||
|
|
||||||
libs := p.Group("/libraries")
|
libsGrp := p.Group("/libraries")
|
||||||
libs.GET("", h.ListLibraries)
|
libsGrp.GET("", h.ListLibraries)
|
||||||
libs.POST("", h.AdminOnly(), h.CreateLibrary)
|
libsGrp.POST("", h.AdminOnly(), h.CreateLibrary)
|
||||||
libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
|
libsGrp.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
|
||||||
libs.POST("/:id/upload", h.AdminOnly(), h.Upload)
|
libsGrp.POST("/:id/upload", h.AdminOnly(), h.Upload)
|
||||||
libs.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit)
|
libsGrp.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit)
|
||||||
|
|
||||||
uploads := p.Group("/uploads", h.AdminOnly())
|
uploads := p.Group("/uploads", h.AdminOnly())
|
||||||
uploads.GET("/:uid", h.UploadStatus)
|
uploads.GET("/:uid", h.UploadStatus)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/redispkg"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *config.Config {
|
func testCfg() *config.Config {
|
||||||
@@ -16,7 +15,7 @@ func testCfg() *config.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthz(t *testing.T) {
|
func TestHealthz(t *testing.T) {
|
||||||
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
|
r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
@@ -24,3 +23,56 @@ func TestHealthz(t *testing.T) {
|
|||||||
t.Fatalf("healthz = %d, want 200", w.Code)
|
t.Fatalf("healthz = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRouterContract 钉死全量路由表(Task 27):增删改任何路由都会使此测试失败,
|
||||||
|
// 必须显式更新——路由即对外契约。
|
||||||
|
func TestRouterContract(t *testing.T) {
|
||||||
|
r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
want := map[string]bool{
|
||||||
|
"GET /api/healthz": true,
|
||||||
|
"POST /api/auth/login": true,
|
||||||
|
"GET /api/auth/me": true,
|
||||||
|
"GET /api/users": true,
|
||||||
|
"POST /api/users": true,
|
||||||
|
"DELETE /api/users/:id": true,
|
||||||
|
"GET /api/libraries": true,
|
||||||
|
"POST /api/libraries": true,
|
||||||
|
"POST /api/libraries/:id/scan": true,
|
||||||
|
"POST /api/libraries/:id/upload": true,
|
||||||
|
"POST /api/libraries/:id/upload/init": true,
|
||||||
|
"GET /api/uploads/:uid": true,
|
||||||
|
"POST /api/uploads/:uid/complete": true,
|
||||||
|
"PUT /api/uploads/:uid/parts/:index": true,
|
||||||
|
"GET /api/books": true,
|
||||||
|
"GET /api/books/:id": true,
|
||||||
|
"DELETE /api/books/:id": true,
|
||||||
|
"GET /api/books/:id/cover": true,
|
||||||
|
"GET /api/books/:id/file": true,
|
||||||
|
"GET /api/books/:id/pages": true,
|
||||||
|
"GET /api/books/:id/pages/:n": true,
|
||||||
|
"PUT /api/books/:id/progress": true,
|
||||||
|
"GET /api/progress": true,
|
||||||
|
"GET /api/books/:id/bookmarks": true,
|
||||||
|
"POST /api/books/:id/bookmarks": true,
|
||||||
|
"PATCH /api/bookmarks/:id": true,
|
||||||
|
"DELETE /api/bookmarks/:id": true,
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, rt := range r.Routes() {
|
||||||
|
key := rt.Method + " " + rt.Path
|
||||||
|
if got[key] {
|
||||||
|
t.Errorf("duplicate route %s", key)
|
||||||
|
}
|
||||||
|
got[key] = true
|
||||||
|
}
|
||||||
|
for k := range want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Errorf("missing route %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range got {
|
||||||
|
if !want[k] {
|
||||||
|
t.Errorf("unexpected route %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ func (h *H) Login(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "username and password required")
|
err(c, http.StatusBadRequest, "bad_request", "username and password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
|
if n := h.rl.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
|
||||||
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
|
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, qerr := h.st.GetUserByName(c, req.Username)
|
u, qerr := h.users.GetUserByName(c, req.Username)
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
if !errors.Is(qerr, pgx.ErrNoRows) {
|
if !errors.Is(qerr, pgx.ErrNoRows) {
|
||||||
dbErr(c, qerr)
|
dbErr(c, qerr)
|
||||||
@@ -49,7 +49,7 @@ func (h *H) Login(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *H) Me(c *gin.Context) {
|
func (h *H) Me(c *gin.Context) {
|
||||||
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503.
|
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503.
|
||||||
u, qerr := h.st.GetUserByID(c, uid(c))
|
u, qerr := h.users.GetUserByID(c, uid(c))
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
if errors.Is(qerr, pgx.ErrNoRows) {
|
if errors.Is(qerr, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import (
|
|||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/db"
|
"booklib/internal/db"
|
||||||
|
"booklib/internal/media"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
"booklib/internal/scanner"
|
"booklib/internal/scanner"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
|
"booklib/internal/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *config.Config {
|
func testCfg() *config.Config {
|
||||||
@@ -57,8 +59,10 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
|
|||||||
cfg.BooksDir = booksDir
|
cfg.BooksDir = booksDir
|
||||||
cfg.CacheDir = t.TempDir()
|
cfg.CacheDir = t.TempDir()
|
||||||
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
||||||
sc := scanner.New(st, cfg, rdb)
|
med := media.New(cfg, rdb)
|
||||||
r := api.NewRouter(cfg, st, rdb, sc)
|
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
|
||||||
|
sc := scanner.New(st, cfg, rdb, up)
|
||||||
|
r := api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up)
|
||||||
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
||||||
if opt, e := redis.ParseURL(u); e == nil {
|
if opt, e := redis.ParseURL(u); e == nil {
|
||||||
rc := redis.NewClient(opt)
|
rc := redis.NewClient(opt)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (h *H) ListBookmarks(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, e := h.st.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
|
rows, e := h.bookmarks.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -65,7 +65,7 @@ func (h *H) CreateBookmark(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, e := h.st.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note)
|
id, e := h.bookmarks.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -101,7 +101,7 @@ func (h *H) PatchBookmark(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, e := h.st.UpdateBookmarkNote(c, uid(c), id, req.Note)
|
updated, e := h.bookmarks.UpdateBookmarkNote(c, uid(c), id, req.Note)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -118,7 +118,7 @@ func (h *H) DeleteBookmark(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deleted, e := h.st.DeleteBookmark(c, uid(c), id)
|
deleted, e := h.bookmarks.DeleteBookmark(c, uid(c), id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -18,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||||
b, e := h.st.GetBook(c, id)
|
b, e := h.books.GetBook(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such book")
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
||||||
@@ -31,41 +30,23 @@ func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) {
|
func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) {
|
||||||
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, ok := idParam(c)
|
||||||
if e != nil {
|
if !ok {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
|
||||||
return store.Book{}, false
|
return store.Book{}, false
|
||||||
}
|
}
|
||||||
return h.getBookRow(c, id)
|
return h.getBookRow(c, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)。
|
||||||
l, e := h.st.GetLibrary(c, id)
|
// bookfile.Contains 带 EvalSymlinks,比裸 filepath.Rel 更能拦住软链逃逸。
|
||||||
if e != nil {
|
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
|
||||||
return store.Library{}, false
|
|
||||||
}
|
|
||||||
dbErr(c, e)
|
|
||||||
return store.Library{}, false
|
|
||||||
}
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
|
|
||||||
func absBookPath(root string, b store.Book) (string, error) {
|
func absBookPath(root string, b store.Book) (string, error) {
|
||||||
abs := filepath.Join(root, filepath.FromSlash(b.Path))
|
abs := filepath.Join(root, filepath.FromSlash(b.Path))
|
||||||
if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) {
|
if filepath.Clean(abs) != abs || !bookfile.Contains(root, abs) {
|
||||||
return "", os.ErrPermission
|
return "", os.ErrPermission
|
||||||
}
|
}
|
||||||
return abs, nil
|
return abs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPrefixDir(p, dir string) bool {
|
|
||||||
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p))
|
|
||||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
|
||||||
}
|
|
||||||
|
|
||||||
func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
||||||
h := bookfile.Hash(b.FileSize, b.ModTS)
|
h := bookfile.Hash(b.FileSize, b.ModTS)
|
||||||
j := gin.H{
|
j := gin.H{
|
||||||
@@ -89,7 +70,7 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
|||||||
|
|
||||||
func (h *H) ListBooks(c *gin.Context) {
|
func (h *H) ListBooks(c *gin.Context) {
|
||||||
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
||||||
views, e := h.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
views, e := h.books.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -106,12 +87,12 @@ func (h *H) GetBook(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
p, e := h.progress.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
||||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lib, e := h.st.GetLibrary(c, b.LibraryID)
|
lib, e := h.libs.GetLibrary(c, b.LibraryID)
|
||||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -124,7 +105,7 @@ func (h *H) DeleteBook(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lib, ok := h.getLibRow(c, b.LibraryID)
|
lib, ok := h.getLib(c, b.LibraryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,7 +125,7 @@ func (h *H) DeleteBook(c *gin.Context) {
|
|||||||
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||||
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
|
||||||
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
|
||||||
if e := h.st.DeleteBook(c, b.ID); e != nil {
|
if e := h.books.DeleteBook(c, b.ID); e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"log"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -20,7 +18,7 @@ import (
|
|||||||
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
|
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
|
||||||
|
|
||||||
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
||||||
lib, ok := h.getLibRow(c, b.LibraryID)
|
lib, ok := h.getLib(c, b.LibraryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -31,6 +29,26 @@ func (h *H) immutable(c *gin.Context) {
|
|||||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkPath 纵深防御:path 越界 → 403,与原契约一致。
|
||||||
|
func checkPath(c *gin.Context, root string, b store.Book) (string, bool) {
|
||||||
|
abs, perr := absBookPath(root, b)
|
||||||
|
if perr != nil {
|
||||||
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return abs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapContentErr 把 media/bookfile 的文件级失败映射回原契约状态码:
|
||||||
|
// 文件不在盘上 → 404,其余(坏包等)由调用方决定 422/500。
|
||||||
|
func mapContentErr(c *gin.Context, e error) bool {
|
||||||
|
if errors.Is(e, fs.ErrNotExist) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "file missing on disk")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *H) ServeCover(c *gin.Context) {
|
func (h *H) ServeCover(c *gin.Context) {
|
||||||
b, ok := h.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -48,36 +66,30 @@ func (h *H) ServeCover(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
|
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
|
||||||
if root, ok := h.bookRoot(c, b); ok {
|
if root, ok := h.bookRoot(c, b); ok {
|
||||||
if f, size, ok := h.openBook(c, b, root); ok {
|
if _, ok := checkPath(c, root, b); ok {
|
||||||
defer f.Close()
|
e := h.med.EnsureCover(c, b.ID, b.Format, b.FileSize, b.ModTS, root, b.Path)
|
||||||
var img []byte
|
switch {
|
||||||
var ext string
|
case e == nil:
|
||||||
var e error
|
if entries, re := os.ReadDir(dir); re == nil {
|
||||||
if b.Format == "cbz" {
|
for _, en := range entries {
|
||||||
img, ext, e = bookfile.CBZCover(f, size)
|
if !strings.Contains(en.Name(), ".tmp") {
|
||||||
|
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case mapContentErr(c, e):
|
||||||
|
return // 404 已回复
|
||||||
|
}
|
||||||
|
// 抽取失败(坏包)→ 落到占位 SVG,与原契约一致
|
||||||
} else {
|
} else {
|
||||||
img, ext, e = bookfile.EPUBCover(f, size)
|
return // 403 已回复
|
||||||
}
|
}
|
||||||
if e == nil {
|
|
||||||
dst := filepath.Join(dir, "cover"+ext)
|
|
||||||
if e := os.MkdirAll(dir, 0o755); e == nil {
|
|
||||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
|
|
||||||
// B11: check all write errors; clean tmp only on failure.
|
|
||||||
if we := os.WriteFile(tmp, img, 0o644); we != nil {
|
|
||||||
log.Printf("serve: write cover tmp: %v", we)
|
|
||||||
os.Remove(tmp)
|
|
||||||
} else if re := os.Rename(tmp, dst); re != nil {
|
|
||||||
log.Printf("serve: rename cover: %v", re)
|
|
||||||
os.Remove(tmp)
|
|
||||||
} else {
|
} else {
|
||||||
http.ServeFile(c.Writer, c.Request, dst)
|
return // 404/503 已回复
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if c.Writer.Written() {
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||||
@@ -92,55 +104,32 @@ func (h *H) ServeFile(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
abs, perr := absBookPath(root, b)
|
abs, ok := checkPath(c, root, b)
|
||||||
if perr != nil {
|
if !ok {
|
||||||
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
|
if _, e := os.Stat(abs); e != nil {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "file missing on disk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("ETag", `"`+h.med.CacheBuster(b.FileSize, b.ModTS)+`"`)
|
||||||
c.Header("Cache-Control", "private, must-revalidate")
|
c.Header("Cache-Control", "private, must-revalidate")
|
||||||
http.ServeFile(c.Writer, c.Request, abs)
|
http.ServeFile(c.Writer, c.Request, abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
|
// pageIndex 走 media(redis 缓存 + 索引提取);路径校验仍在 handler,保住 403 契约。
|
||||||
abs, perr := absBookPath(root, b)
|
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, bool) {
|
||||||
if perr != nil {
|
if _, ok := checkPath(c, root, b); !ok {
|
||||||
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
return nil, false
|
||||||
return nil, 0, false
|
|
||||||
}
|
}
|
||||||
f, perr := os.Open(abs)
|
idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path)
|
||||||
if perr != nil {
|
|
||||||
err(c, http.StatusNotFound, "not_found", "file missing on disk")
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
st, perr := f.Stat()
|
|
||||||
if perr != nil {
|
|
||||||
f.Close()
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "stat")
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
return f, st.Size(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
|
|
||||||
hash := bookfile.Hash(b.FileSize, b.ModTS)
|
|
||||||
key := fmt.Sprintf("pagesidx2:%d:%s", b.ID, hash) // 前缀换代 = 索引逻辑变更时一次性作废旧缓存
|
|
||||||
if v, ok := h.rdb.Get(c, key); ok && v != "" {
|
|
||||||
return strings.Split(v, "\n"), nil
|
|
||||||
}
|
|
||||||
f, size, ok := h.openBook(c, b, root)
|
|
||||||
if !ok {
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
idx, e := bookfile.PageIndex(f, size)
|
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e
|
if !mapContentErr(c, e) {
|
||||||
|
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
||||||
}
|
}
|
||||||
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
|
return nil, false
|
||||||
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
|
||||||
}
|
}
|
||||||
return idx, nil
|
return idx, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) PagesCount(c *gin.Context) {
|
func (h *H) PagesCount(c *gin.Context) {
|
||||||
@@ -156,56 +145,11 @@ func (h *H) PagesCount(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx, e := h.pageIndex(c, b, root)
|
idx, ok := h.pageIndex(c, b, root)
|
||||||
if e != nil {
|
if !ok {
|
||||||
if c.Writer.Written() {
|
|
||||||
return // openBook 已写 403/404,不再叠加 422
|
|
||||||
}
|
|
||||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": chaptersOf(idx)})
|
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(idx)})
|
||||||
}
|
|
||||||
|
|
||||||
type cbzChapter struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Start int `json:"start"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// chaptersOf 页索引已自然序排列,按父目录分组:每个新目录开一章,标题取目录名;扁平包或只有一组返回 nil
|
|
||||||
func chaptersOf(idx []string) []cbzChapter {
|
|
||||||
type grp struct {
|
|
||||||
dir string
|
|
||||||
start int
|
|
||||||
}
|
|
||||||
var grps []grp
|
|
||||||
last := "\x00"
|
|
||||||
for i, n := range idx {
|
|
||||||
d := path.Dir(n)
|
|
||||||
if d == last {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
last = d
|
|
||||||
if d == "." { // 根目录散页不开章
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
grps = append(grps, grp{d, i})
|
|
||||||
}
|
|
||||||
if len(grps) < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
titles := make(map[string]int)
|
|
||||||
out := make([]cbzChapter, len(grps))
|
|
||||||
for i, g := range grps {
|
|
||||||
out[i] = cbzChapter{Title: path.Base(g.dir), Start: g.start}
|
|
||||||
titles[out[i].Title]++
|
|
||||||
}
|
|
||||||
for i, g := range grps { // 同名目录(不同父级)撞车 → 用全路径消歧
|
|
||||||
if titles[out[i].Title] > 1 {
|
|
||||||
out[i].Title = g.dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) Page(c *gin.Context) {
|
func (h *H) Page(c *gin.Context) {
|
||||||
@@ -226,48 +170,23 @@ func (h *H) Page(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx, e := h.pageIndex(c, b, root)
|
idx, ok := h.pageIndex(c, b, root)
|
||||||
if e != nil {
|
if !ok {
|
||||||
if c.Writer.Written() {
|
|
||||||
return // openBook 已写 403/404,不再叠加 422
|
|
||||||
}
|
|
||||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n >= len(idx) {
|
if n >= len(idx) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such page")
|
err(c, http.StatusNotFound, "not_found", "no such page")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(idx[n]))
|
// miss → 解压落盘(media 内部唯一 tmp 名 + rename 原子,并发重做同页幂等)
|
||||||
dir := bookfile.PagesDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
dst, e := h.med.EnsurePage(c, b.ID, b.FileSize, b.ModTS, root, b.Path, n, idx)
|
||||||
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
|
if e != nil {
|
||||||
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
|
if mapContentErr(c, e) {
|
||||||
f, size, ok := h.openBook(c, b, root)
|
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
data, e := bookfile.ReadEntry(f, size, idx[n])
|
|
||||||
if e != nil {
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "extract page")
|
err(c, http.StatusInternalServerError, "internal", "extract page")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e := os.MkdirAll(dir, 0o755); e != nil {
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "cache dir")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
|
|
||||||
if e := os.WriteFile(tmp, data, 0o644); e != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "write cache")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if e := os.Rename(tmp, dst); e != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "rename cache")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.immutable(c)
|
h.immutable(c)
|
||||||
http.ServeFile(c.Writer, c.Request, dst)
|
http.ServeFile(c.Writer, c.Request, dst)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -15,26 +16,45 @@ import (
|
|||||||
|
|
||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/ports"
|
||||||
"booklib/internal/scanner"
|
|
||||||
"booklib/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// H 只依赖 ports 里的小口径接口(Task 25):具体实现由 main.go 装配,
|
||||||
|
// 测试可注入手写 fake(portsfake),无需 PG/Redis。
|
||||||
type H struct {
|
type H struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
st *store.Store
|
users ports.UserStore
|
||||||
rdb *redispkg.R
|
libs ports.LibraryStore
|
||||||
sc *scanner.Scanner
|
books ports.BookStore
|
||||||
|
progress ports.ProgressStore
|
||||||
|
bookmarks ports.BookmarkStore
|
||||||
|
rl ports.RateLimiter
|
||||||
|
sc ports.Scanner
|
||||||
|
med ports.Media
|
||||||
|
up ports.UploadSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *H {
|
func New(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore,
|
||||||
return &H{cfg: cfg, st: st, rdb: rdb, sc: sc}
|
progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter,
|
||||||
|
sc ports.Scanner, med ports.Media, up ports.UploadSessions) *H {
|
||||||
|
return &H{cfg: cfg, users: users, libs: libs, books: books, progress: progress,
|
||||||
|
bookmarks: bookmarks, rl: rl, sc: sc, med: med, up: up}
|
||||||
}
|
}
|
||||||
|
|
||||||
func err(c *gin.Context, status int, code, msg string) {
|
func err(c *gin.Context, status int, code, msg string) {
|
||||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
|
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idParam 解析 :id 路径参数,失败已回复 400。
|
||||||
|
func idParam(c *gin.Context) (int64, bool) {
|
||||||
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
// dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500
|
// dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500
|
||||||
// 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代;
|
// 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代;
|
||||||
// PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503
|
// PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"booklib/internal/bookfile"
|
"booklib/internal/bookfile"
|
||||||
"booklib/internal/media"
|
"booklib/internal/media"
|
||||||
|
"booklib/internal/ports"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ import (
|
|||||||
func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
||||||
root := filepath.Clean(lib.RootPath)
|
root := filepath.Clean(lib.RootPath)
|
||||||
books := filepath.Clean(h.cfg.BooksDir)
|
books := filepath.Clean(h.cfg.BooksDir)
|
||||||
if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
|
if !filepath.IsAbs(root) || !bookfile.Contains(books, root) {
|
||||||
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
|
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -34,8 +35,22 @@ func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
|||||||
return root, true
|
return root, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getLib 查库行,404/503/500 已回复(原 getLibrary/getLibRow 合一,Task 25)
|
||||||
|
func (h *H) getLib(c *gin.Context, id int64) (store.Library, bool) {
|
||||||
|
l, e := h.libs.GetLibrary(c, id)
|
||||||
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *H) ListLibraries(c *gin.Context) {
|
func (h *H) ListLibraries(c *gin.Context) {
|
||||||
libs, e := h.st.ListLibraries(c)
|
libs, e := h.libs.ListLibraries(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -68,9 +83,9 @@ func (h *H) CreateLibrary(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
||||||
id, e := h.st.CreateLibrary(c, req.Name, root)
|
id, e := h.libs.CreateLibrary(c, req.Name, root)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if isUnique(e) {
|
if ports.IsUniqueViolation(e) {
|
||||||
err(c, http.StatusConflict, "exists", "name taken")
|
err(c, http.StatusConflict, "exists", "name taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,26 +95,12 @@ func (h *H) CreateLibrary(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
|
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
|
|
||||||
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if e != nil {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
|
||||||
return store.Library{}, false
|
|
||||||
}
|
|
||||||
lib, e := h.st.GetLibrary(c, id)
|
|
||||||
if e != nil {
|
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
|
||||||
return store.Library{}, false
|
|
||||||
}
|
|
||||||
dbErr(c, e)
|
|
||||||
return store.Library{}, false
|
|
||||||
}
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *H) ScanLibrary(c *gin.Context) {
|
func (h *H) ScanLibrary(c *gin.Context) {
|
||||||
lib, ok := h.getLibrary(c)
|
id, ok := idParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, ok := h.getLib(c, id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -111,7 +112,11 @@ func (h *H) ScanLibrary(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) Upload(c *gin.Context) {
|
func (h *H) Upload(c *gin.Context) {
|
||||||
lib, ok := h.getLibrary(c)
|
id, ok := idParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, ok := h.getLib(c, id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,12 +141,12 @@ func (h *H) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// B12: retry on O_EXCL collision — concurrent uploads with the same name
|
// B12: retry on O_EXCL collision — concurrent uploads with the same name
|
||||||
// can both get the same candidate from uniquePath (stat-then-create race).
|
// can both get the same candidate from UniquePath (stat-then-create race).
|
||||||
var dst, tmp string
|
var dst, tmp string
|
||||||
var out *os.File
|
var out *os.File
|
||||||
for attempt := 0; attempt < 5; attempt++ {
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
var e error
|
var e error
|
||||||
dst, e = h.uniquePath(root, name)
|
dst, e = h.up.UniquePath(root, name)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusForbidden, "forbidden", e.Error())
|
err(c, http.StatusForbidden, "forbidden", e.Error())
|
||||||
return
|
return
|
||||||
@@ -155,7 +160,7 @@ func (h *H) Upload(c *gin.Context) {
|
|||||||
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// O_EXCL collision — retry with fresh uniquePath.
|
// O_EXCL collision — retry with fresh UniquePath.
|
||||||
}
|
}
|
||||||
if out == nil {
|
if out == nil {
|
||||||
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
|
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
|
||||||
@@ -190,28 +195,3 @@ func (h *H) Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
|
|
||||||
func (h *H) uniquePath(root, name string) (string, error) {
|
|
||||||
ext := filepath.Ext(name)
|
|
||||||
base := strings.TrimSuffix(name, ext)
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
cand := base + ext
|
|
||||||
if i > 0 {
|
|
||||||
cand = base + " (" + strconv.Itoa(i) + ")" + ext
|
|
||||||
}
|
|
||||||
p := filepath.Join(root, cand)
|
|
||||||
if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) ||
|
|
||||||
!strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) {
|
|
||||||
return "", os.ErrInvalid
|
|
||||||
}
|
|
||||||
if _, e := os.Stat(p); os.IsNotExist(e) {
|
|
||||||
return p, nil
|
|
||||||
} else if e != nil {
|
|
||||||
return "", e
|
|
||||||
}
|
|
||||||
if i > 999 {
|
|
||||||
return "", os.ErrExist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/ports"
|
||||||
|
"booklib/internal/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- libraries (Task 27, portsfake) ----------
|
||||||
|
|
||||||
|
func TestUnit_ListLibraries(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.seedLib(t, "comics")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/libraries", atok, nil)
|
||||||
|
httpOK(t, w, 200, "list libraries")
|
||||||
|
var libs []map[string]any
|
||||||
|
if err := jsonUnmarshal(w, &libs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(libs) != 1 || libs[0]["name"] != "comics" {
|
||||||
|
t.Fatalf("unexpected libs %v", libs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateLibrary_ReservedName(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
// 保留名清单以 media/reserved.go 为准(B8);大小写不敏感。
|
||||||
|
// 契约(与重构前一致): code="bad_request", message="reserved_name"
|
||||||
|
for _, name := range []string{"cache", ".uploads", ".trash", "CACHE", "Cache"} {
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
|
||||||
|
if w.Code != 400 || errCode(t, w) != "bad_request" || !strings.Contains(w.Body.String(), "reserved_name") {
|
||||||
|
t.Fatalf("name=%q want 400/bad_request/reserved_name got %d %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateLibrary_BadName(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
for _, name := range []string{"", "..", "///"} {
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
|
||||||
|
httpOK(t, w, 400, "bad name "+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateLibrary_Duplicate(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.seedLib(t, "dup")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "dup"})
|
||||||
|
httpOK(t, w, 409, "duplicate library")
|
||||||
|
if code := errCode(t, w); code != "exists" {
|
||||||
|
t.Fatalf("error code want exists got %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateLibrary_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "new lib"})
|
||||||
|
httpOK(t, w, 201, "create library")
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
if body["name"] != "new lib" {
|
||||||
|
t.Fatalf("unexpected body %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_ScanLibrary_NotFound(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries/999/scan", atok, nil)
|
||||||
|
httpOK(t, w, 404, "scan missing library")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_ScanLibrary_Accepted(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
libID, _ := e.seedLib(t, "scannable")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", atok, nil)
|
||||||
|
httpOK(t, w, 202, "scan accepted")
|
||||||
|
if !e.sc.WaitForScan(1, time.Second) {
|
||||||
|
t.Fatalf("ScanLibraryByID never called; seen=%v", e.sc.Seen())
|
||||||
|
}
|
||||||
|
if seen := e.sc.Seen(); seen[0] != libID {
|
||||||
|
t.Fatalf("scanned lib %d want %d", seen[0], libID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_ScanLibrary_MemberForbidden(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
libID, _ := e.seedLib(t, "nope")
|
||||||
|
mtok := e.token(t, "member", 2)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", mtok, nil)
|
||||||
|
httpOK(t, w, 403, "member scan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- uploads: sentinel → status mapping ----------
|
||||||
|
|
||||||
|
func TestUnit_UploadInit_MapsSentinels(t *testing.T) {
|
||||||
|
libIDPath := func(e *testEnv) string {
|
||||||
|
id, _ := e.seedLib(t, "up")
|
||||||
|
return "/api/libraries/" + strconv.FormatInt(id, 10) + "/upload/init"
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
initErr error
|
||||||
|
wantCode int
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"too large", ports.ErrTooLarge, 413, "too_large"},
|
||||||
|
{"bad name", upload.ErrBadName, 400, "bad_request"},
|
||||||
|
{"bad format", upload.ErrBadFormat, 400, "bad_format"},
|
||||||
|
{"bad size", upload.ErrBadSize, 400, "bad_request"},
|
||||||
|
{"bad chunk", upload.ErrBadChunk, 400, "bad_request"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.up.InitErr = tc.initErr
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, libIDPath(e), atok,
|
||||||
|
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
|
||||||
|
httpOK(t, w, tc.wantCode, "init "+tc.name)
|
||||||
|
if code := errCode(t, w); code != tc.wantErr {
|
||||||
|
t.Fatalf("error code want %q got %q", tc.wantErr, code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadInit_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
id, _ := e.seedLib(t, "up")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok,
|
||||||
|
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
|
||||||
|
httpOK(t, w, 200, "init ok")
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
if body["uploadId"] != e.up.UID {
|
||||||
|
t.Fatalf("uploadId want %q got %v", e.up.UID, body["uploadId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadInit_BadBody(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
id, _ := e.seedLib(t, "up")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok, nil)
|
||||||
|
httpOK(t, w, 400, "init without json body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadStatus_NotFound(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.up.StatusErr = ports.ErrNotFound
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
|
||||||
|
httpOK(t, w, 404, "status missing upload")
|
||||||
|
if code := errCode(t, w); code != "not_found" {
|
||||||
|
t.Fatalf("error code want not_found got %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadStatus_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.up.Received = []int64{0, 2}
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
|
||||||
|
httpOK(t, w, 200, "status ok")
|
||||||
|
var body struct {
|
||||||
|
Received []int64 `json:"received"`
|
||||||
|
}
|
||||||
|
if err := jsonUnmarshal(w, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(body.Received) != 2 || body.Received[1] != 2 {
|
||||||
|
t.Fatalf("received want [0 2] got %v", body.Received)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadPart_BadIndex(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/abc", atok, nil)
|
||||||
|
httpOK(t, w, 400, "part bad index")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadPart_TooBig(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.up.PutErr = upload.ErrPartTooBig
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/0", atok, nil)
|
||||||
|
httpOK(t, w, 413, "part too big")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadComplete_Incomplete(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.seedLib(t, "up") // fake LibraryID=1 与 seed 的第一条对齐
|
||||||
|
e.up.CompleteErr = ports.ErrIncomplete
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
|
||||||
|
httpOK(t, w, 400, "complete incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadComplete_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.seedLib(t, "up")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
|
||||||
|
httpOK(t, w, 202, "complete ok")
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
if body["path"] != e.up.RelPath {
|
||||||
|
t.Fatalf("path want %q got %v", e.up.RelPath, body["path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_UploadComplete_LibGone(t *testing.T) {
|
||||||
|
e := newTestEnv(t) // LibraryID=1 但库里没有 id=1 → GetLibrary ErrNoRows → 404? dbErr → 500
|
||||||
|
e.up.LibID = 42
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
|
||||||
|
// dbErr 把 pgx.ErrNoRows 归为 500(非连接类),契约与真库一致
|
||||||
|
if w.Code != 500 {
|
||||||
|
t.Fatalf("lib gone want 500 got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ func (h *H) PutProgress(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
if e := h.progress.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func (h *H) PutProgress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) ListProgress(c *gin.Context) {
|
func (h *H) ListProgress(c *gin.Context) {
|
||||||
rows, e := h.st.ListProgress(c, uid(c))
|
rows, e := h.progress.ListProgress(c, uid(c))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"booklib/cmd/webui/api"
|
||||||
|
"booklib/internal/auth"
|
||||||
|
"booklib/internal/config"
|
||||||
|
"booklib/internal/ports/portsfake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testEnv 是纯 fake 装配(Task 27):无 PG/Redis。请求走 api.NewRouter 组装的
|
||||||
|
// 真实 engine,因此认证/鉴权中间件、路由匹配与 gin 的 header 落盘行为都被覆盖;
|
||||||
|
// 集成测试(auth_test.go 的 setupAPI)另走真库,两层互补。
|
||||||
|
type testEnv struct {
|
||||||
|
cfg *config.Config
|
||||||
|
router *gin.Engine
|
||||||
|
users *portsfake.Users
|
||||||
|
libs *portsfake.Libraries
|
||||||
|
books *portsfake.Books
|
||||||
|
progress *portsfake.Progress
|
||||||
|
bookmarks *portsfake.Bookmarks
|
||||||
|
rl *portsfake.RateLimiter
|
||||||
|
sc *portsfake.Scanner
|
||||||
|
med *portsfake.Media
|
||||||
|
up *portsfake.Uploads
|
||||||
|
booksDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
resolved, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), UploadMaxMB: 1,
|
||||||
|
ScanInterval: time.Minute, BooksDir: resolved, CacheDir: t.TempDir(),
|
||||||
|
TrustedProxies: []string{"172.16.0.0/12"}}
|
||||||
|
users := portsfake.NewUsers()
|
||||||
|
libs := portsfake.NewLibraries()
|
||||||
|
books := portsfake.NewBooks()
|
||||||
|
progress := portsfake.NewProgress(libs, books)
|
||||||
|
e := &testEnv{
|
||||||
|
cfg: cfg, users: users, libs: libs, books: books,
|
||||||
|
progress: progress, bookmarks: portsfake.NewBookmarks(),
|
||||||
|
rl: portsfake.NewRateLimiter(), sc: portsfake.NewScanner(),
|
||||||
|
med: portsfake.NewMedia(), up: portsfake.NewUploads(),
|
||||||
|
booksDir: resolved,
|
||||||
|
}
|
||||||
|
e.router = api.NewRouter(cfg, users, libs, books, progress, e.bookmarks,
|
||||||
|
e.rl, e.sc, e.med, e.up)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// token 签发一个带角色的 JWT(uid 固定 1,与 fake 里 seed 的用户对应)。
|
||||||
|
func (e *testEnv) token(t *testing.T, role string, uid int64) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := auth.Sign(e.cfg.JWTSecret, uid, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// do 以 Bearer token 走完整 engine;body 非 nil 时按 JSON 发送。
|
||||||
|
func (e *testEnv) do(t *testing.T, method, path, tok string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(b)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
if tok != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
e.router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonBody 解析响应体为 map。
|
||||||
|
func jsonBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal %q: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonUnmarshal 解析响应体到任意目标(slice/struct)。
|
||||||
|
func jsonUnmarshal(w *httptest.ResponseRecorder, dst any) error {
|
||||||
|
return json.Unmarshal(w.Body.Bytes(), dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errCode 取 error.code 字段(错误响应契约的核心)。
|
||||||
|
func errCode(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
e, _ := body["error"].(map[string]any)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatalf("no error object in %q", w.Body.String())
|
||||||
|
}
|
||||||
|
return fmt.Sprint(e["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedLib 建一个 root 落在 BooksDir 内的库(fake 行 + 真实目录),返回 id 与 root。
|
||||||
|
func (e *testEnv) seedLib(t *testing.T, name string) (int64, string) {
|
||||||
|
t.Helper()
|
||||||
|
root := filepath.Join(e.booksDir, name)
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e.libs.Seed(name, root), root
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpOK 断言状态码,失败时带上响应体便于定位。
|
||||||
|
func httpOK(t *testing.T, w *httptest.ResponseRecorder, want int, what string) {
|
||||||
|
t.Helper()
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s: want %d got %d body=%q", what, want, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +1,29 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"booklib/internal/bookfile"
|
"booklib/internal/ports"
|
||||||
|
"booklib/internal/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 分片上传:init(指纹→确定性 uploadId,天然支持续传)→ PUT parts → complete 拼接原子落盘。
|
// 分片上传:HTTP 层只做参数绑定与错误映射,域逻辑(指纹续传/分片落盘/拼接/清扫)
|
||||||
// 会话即 <BooksDir>/.uploads/<uid>/(meta.json + parts/N),无独立状态存储;24h 未 complete opportunistic 清扫。
|
// 全部在 internal/upload;会话清扫由 scanner ticker 接管(B16),不在请求路径。
|
||||||
|
|
||||||
const (
|
// maxChunkBytes 与 upload 包内常量同值,作为 PutPart 的防御性读取上限。
|
||||||
maxChunkBytes = 32 << 20
|
const maxChunkBytes = 32 << 20
|
||||||
defaultChunk = 8 << 20
|
|
||||||
uploadSessTTL = 24 * time.Hour
|
|
||||||
uploadSessionIn = ".uploads"
|
|
||||||
)
|
|
||||||
|
|
||||||
type uploadMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
ChunkSize int64 `json:"chunkSize"`
|
|
||||||
LibraryID int64 `json:"libraryId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func validUploadID(s string) bool {
|
|
||||||
if len(s) != 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range s {
|
|
||||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadIDFor(libID int64, name string, size, chunk int64) string {
|
|
||||||
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%d", libID, name, size, chunk)))
|
|
||||||
return hex.EncodeToString(h[:16])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *H) uploadDir(uid string) string {
|
|
||||||
return filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn, uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func chunkRange(m uploadMeta, i int64) (int64, int64) {
|
|
||||||
lo := i * m.ChunkSize
|
|
||||||
hi := min(lo+m.ChunkSize, m.Size)
|
|
||||||
return lo, hi
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *H) numParts(m uploadMeta) int64 {
|
|
||||||
return (m.Size + m.ChunkSize - 1) / m.ChunkSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweepUploads 删除过期会话目录;尽力而为,失败不影响主流程
|
|
||||||
func (h *H) sweepUploads() {
|
|
||||||
base := filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn)
|
|
||||||
es, e := os.ReadDir(base)
|
|
||||||
if e != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, en := range es {
|
|
||||||
if fi, e := en.Info(); e == nil && time.Since(fi.ModTime()) > uploadSessTTL {
|
|
||||||
os.RemoveAll(filepath.Join(base, en.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *H) UploadInit(c *gin.Context) {
|
func (h *H) UploadInit(c *gin.Context) {
|
||||||
lib, ok := h.getLibrary(c)
|
id, ok := idParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, ok := h.getLib(c, id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,154 +39,47 @@ func (h *H) UploadInit(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "name, size required")
|
err(c, http.StatusBadRequest, "bad_request", "name, size required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := bookfile.SafeName(req.Name)
|
uid, e := h.up.Init(c, lib.ID, req.Name, req.Size, req.ChunkSize)
|
||||||
if name == "" {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bookfile.FormatFromExt(name) == "" {
|
|
||||||
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Size <= 0 {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad size")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Size > h.cfg.UploadMaxMB<<20 {
|
|
||||||
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.ChunkSize == 0 {
|
|
||||||
req.ChunkSize = defaultChunk
|
|
||||||
}
|
|
||||||
if req.ChunkSize > maxChunkBytes {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uid := uploadIDFor(lib.ID, name, req.Size, req.ChunkSize)
|
|
||||||
dir := h.uploadDir(uid)
|
|
||||||
meta := uploadMeta{Name: name, Size: req.Size, ChunkSize: req.ChunkSize, LibraryID: lib.ID}
|
|
||||||
if b, e := os.ReadFile(filepath.Join(dir, "meta.json")); e == nil {
|
|
||||||
var old uploadMeta
|
|
||||||
if json.Unmarshal(b, &old) == nil && old == meta { // 同指纹 → 复用会话(续传)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
os.RemoveAll(dir) // 指纹撞上但内容不同 → 从头再来
|
|
||||||
}
|
|
||||||
h.sweepUploads()
|
|
||||||
if e := os.MkdirAll(filepath.Join(dir, "parts"), 0o755); e != nil {
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "create session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(meta)
|
|
||||||
if e := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); e != nil {
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "write meta")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadMeta 校验 uid 与路径,404/400 已回复
|
|
||||||
func (h *H) loadMeta(c *gin.Context) (uploadMeta, string, bool) {
|
|
||||||
uid := c.Param("uid")
|
|
||||||
if !validUploadID(uid) {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
|
|
||||||
return uploadMeta{}, "", false
|
|
||||||
}
|
|
||||||
dir := h.uploadDir(uid)
|
|
||||||
var m uploadMeta
|
|
||||||
b, e := os.ReadFile(filepath.Join(dir, "meta.json"))
|
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such upload")
|
h.mapUploadErr(c, e)
|
||||||
return uploadMeta{}, "", false
|
return
|
||||||
}
|
}
|
||||||
if json.Unmarshal(b, &m) != nil {
|
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
|
||||||
err(c, http.StatusInternalServerError, "internal", "corrupt session")
|
|
||||||
return uploadMeta{}, "", false
|
|
||||||
}
|
|
||||||
return m, dir, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) UploadStatus(c *gin.Context) {
|
func (h *H) UploadStatus(c *gin.Context) {
|
||||||
_, dir, ok := h.loadMeta(c)
|
recv, e := h.up.Status(c, c.Param("uid"))
|
||||||
if !ok {
|
if e != nil {
|
||||||
|
h.mapUploadErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recv := []int64{}
|
|
||||||
es, e := os.ReadDir(filepath.Join(dir, "parts"))
|
|
||||||
if e == nil {
|
|
||||||
for _, en := range es {
|
|
||||||
if i, e := strconv.ParseInt(en.Name(), 10, 64); e == nil {
|
|
||||||
recv = append(recv, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(recv, func(i, j int) bool { return recv[i] < recv[j] })
|
|
||||||
c.JSON(http.StatusOK, gin.H{"received": recv})
|
c.JSON(http.StatusOK, gin.H{"received": recv})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) UploadPart(c *gin.Context) {
|
func (h *H) UploadPart(c *gin.Context) {
|
||||||
m, dir, ok := h.loadMeta(c)
|
uid := c.Param("uid")
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
|
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
|
||||||
if e != nil || idx < 0 || idx >= h.numParts(m) {
|
if e != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad part index")
|
err(c, http.StatusBadRequest, "bad_request", "bad part index")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lo, hi := chunkRange(m, idx)
|
// maxSize 防御性上限:分片声明大小由会话 meta 决定,这里再垫一层 32MB 全局上限
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, hi-lo)
|
e = h.up.PutPart(c, uid, idx, c.Request.Body, maxChunkBytes)
|
||||||
// B4: write to .tmp then rename — prevents truncated parts from being
|
|
||||||
// reported as "received" by UploadStatus if the process crashes mid-write.
|
|
||||||
p := filepath.Join(dir, "parts", strconv.FormatInt(idx, 10))
|
|
||||||
tmp := p + ".tmp"
|
|
||||||
f, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "create part")
|
h.mapUploadErr(c, e)
|
||||||
return
|
|
||||||
}
|
|
||||||
n, e := io.Copy(f, c.Request.Body)
|
|
||||||
f.Close()
|
|
||||||
if e != nil || n != hi-lo {
|
|
||||||
os.Remove(tmp)
|
|
||||||
var mbe *http.MaxBytesError
|
|
||||||
code, msg := "too_large", "part size mismatch"
|
|
||||||
if errors.As(e, &mbe) {
|
|
||||||
msg = "part exceeds declared size"
|
|
||||||
}
|
|
||||||
err(c, http.StatusRequestEntityTooLarge, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if e := os.Rename(tmp, p); e != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "rename part")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) UploadComplete(c *gin.Context) {
|
func (h *H) UploadComplete(c *gin.Context) {
|
||||||
m, dir, ok := h.loadMeta(c)
|
uid := c.Param("uid")
|
||||||
if !ok {
|
libID, e := h.up.LibraryID(c, uid)
|
||||||
|
if e != nil {
|
||||||
|
h.mapUploadErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var total int64
|
lib, e := h.libs.GetLibrary(c, libID)
|
||||||
for i := int64(0); i < h.numParts(m); i++ {
|
|
||||||
lo, hi := chunkRange(m, i)
|
|
||||||
fi, e := os.Stat(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
|
||||||
if e != nil || fi.Size() != hi-lo {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
total += fi.Size()
|
|
||||||
}
|
|
||||||
if total != m.Size {
|
|
||||||
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lib, e := h.st.GetLibrary(c, m.LibraryID)
|
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -255,39 +88,53 @@ func (h *H) UploadComplete(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst, e := h.uniquePath(root, m.Name)
|
rel, e := h.up.Complete(c, uid, root)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
h.mapUploadErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": rel})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapUploadErr 把 upload 包的 sentinel 错误映射为原契约的 status/code/message。
|
||||||
|
func (h *H) mapUploadErr(c *gin.Context, e error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(e, ports.ErrTooLarge):
|
||||||
|
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
|
||||||
|
case errors.Is(e, upload.ErrBadName):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
||||||
|
case errors.Is(e, upload.ErrBadFormat):
|
||||||
|
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
|
||||||
|
case errors.Is(e, upload.ErrBadSize):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad size")
|
||||||
|
case errors.Is(e, upload.ErrBadChunk):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
|
||||||
|
case errors.Is(e, upload.ErrBadUploadID):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
|
||||||
|
case errors.Is(e, upload.ErrBadIndex):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad part index")
|
||||||
|
case errors.Is(e, upload.ErrPartTooBig):
|
||||||
|
err(c, http.StatusRequestEntityTooLarge, "too_large", "part exceeds declared size")
|
||||||
|
case errors.Is(e, upload.ErrPartSizeMismatch):
|
||||||
|
err(c, http.StatusRequestEntityTooLarge, "too_large", "part size mismatch")
|
||||||
|
case errors.Is(e, ports.ErrNotFound):
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such upload")
|
||||||
|
case errors.Is(e, upload.ErrCorrupt):
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "corrupt session")
|
||||||
|
case errors.Is(e, ports.ErrIncomplete):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
|
||||||
|
case errors.Is(e, ports.ErrSizeMismatch):
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
|
||||||
|
case errors.Is(e, os.ErrInvalid), errors.Is(e, os.ErrExist):
|
||||||
err(c, http.StatusForbidden, "forbidden", e.Error())
|
err(c, http.StatusForbidden, "forbidden", e.Error())
|
||||||
|
case errors.Is(e, os.ErrPermission), errors.Is(e, os.ErrClosed):
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "io error")
|
||||||
|
default:
|
||||||
|
var oe *upload.OpError
|
||||||
|
if errors.As(e, &oe) {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", oe.Op)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmp := filepath.Join(dir, "assembled")
|
err(c, http.StatusInternalServerError, "internal", "upload failed")
|
||||||
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
||||||
if e != nil {
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
for i := int64(0); i < h.numParts(m); i++ {
|
|
||||||
pf, e := os.Open(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
|
||||||
if e != nil {
|
|
||||||
out.Close()
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "open part")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, e := io.Copy(out, pf); e != nil {
|
|
||||||
pf.Close()
|
|
||||||
out.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "assemble")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pf.Close()
|
|
||||||
}
|
|
||||||
out.Close()
|
|
||||||
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
|
|
||||||
os.Remove(tmp)
|
|
||||||
err(c, http.StatusInternalServerError, "internal", "rename")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
|
"booklib/internal/ports"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isUnique is a convenience alias for store.IsUniqueViolation.
|
|
||||||
func isUnique(e error) bool { return store.IsUniqueViolation(e) }
|
|
||||||
|
|
||||||
func (h *H) ListUsers(c *gin.Context) {
|
func (h *H) ListUsers(c *gin.Context) {
|
||||||
users, e := h.st.ListUsers(c)
|
users, e := h.users.ListUsers(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -49,9 +46,9 @@ func (h *H) CreateUser(c *gin.Context) {
|
|||||||
err(c, http.StatusInternalServerError, "internal", "hash")
|
err(c, http.StatusInternalServerError, "internal", "hash")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, e := h.st.CreateUser(c, req.Username, hp, req.Role)
|
id, e := h.users.CreateUser(c, req.Username, hp, req.Role)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if isUnique(e) {
|
if ports.IsUniqueViolation(e) {
|
||||||
err(c, http.StatusConflict, "exists", "username taken")
|
err(c, http.StatusConflict, "exists", "username taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,9 +59,8 @@ func (h *H) CreateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) DeleteUser(c *gin.Context) {
|
func (h *H) DeleteUser(c *gin.Context) {
|
||||||
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, ok := idParam(c)
|
||||||
if e != nil {
|
if !ok {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if id == uid(c) {
|
if id == uid(c) {
|
||||||
@@ -72,7 +68,7 @@ func (h *H) DeleteUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// B5: transactional last-admin check eliminates TOCTOU race.
|
// B5: transactional last-admin check eliminates TOCTOU race.
|
||||||
if e := h.st.DeleteUser(c, id); e != nil {
|
if e := h.users.DeleteUser(c, id); e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such user")
|
err(c, http.StatusNotFound, "not_found", "no such user")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"booklib/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- users: admin-only CRUD branches (Task 27, portsfake) ----------
|
||||||
|
|
||||||
|
func TestUnit_ListUsers(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
hash, _ := auth.HashPassword("password1234")
|
||||||
|
e.users.Seed("admin", hash, "admin")
|
||||||
|
e.users.Seed("member1", hash, "member")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
|
||||||
|
w := e.do(t, http.MethodGet, "/api/users", atok, nil)
|
||||||
|
httpOK(t, w, 200, "list users")
|
||||||
|
var users []map[string]any
|
||||||
|
if err := jsonUnmarshal(w, &users); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users) != 2 {
|
||||||
|
t.Fatalf("want 2 users got %d", len(users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_ListUsers_MemberForbidden(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
mtok := e.token(t, "member", 2)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/users", mtok, nil)
|
||||||
|
httpOK(t, w, 403, "member list users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateUser_RoleValidation(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
for _, role := range []string{"superadmin", "", "Member"} {
|
||||||
|
w := e.do(t, http.MethodPost, "/api/users", atok,
|
||||||
|
map[string]string{"Username": "u", "Password": "password1234", "Role": role})
|
||||||
|
httpOK(t, w, 400, "create user role="+role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateUser_ShortPassword(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/users", atok,
|
||||||
|
map[string]string{"Username": "u", "Password": "short", "Role": "member"})
|
||||||
|
httpOK(t, w, 400, "short password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateUser_DuplicateName(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
hash, _ := auth.HashPassword("password1234")
|
||||||
|
e.users.Seed("existing", hash, "member")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
|
||||||
|
w := e.do(t, http.MethodPost, "/api/users", atok,
|
||||||
|
map[string]string{"Username": "existing", "Password": "password1234", "Role": "member"})
|
||||||
|
httpOK(t, w, 409, "duplicate name")
|
||||||
|
if code := errCode(t, w); code != "exists" {
|
||||||
|
t.Fatalf("error code want 'exists' got %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_CreateUser_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodPost, "/api/users", atok,
|
||||||
|
map[string]string{"Username": "newbie", "Password": "password1234", "Role": "member"})
|
||||||
|
httpOK(t, w, 201, "create user")
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
if body["username"] != "newbie" || body["role"] != "member" {
|
||||||
|
t.Fatalf("unexpected body %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_DeleteUser_BadID(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodDelete, "/api/users/notanum", atok, nil)
|
||||||
|
httpOK(t, w, 400, "bad id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_DeleteUser_Self(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
uid := e.users.Seed("self", "hash", "admin")
|
||||||
|
atok := e.token(t, "admin", uid) // 自己删自己
|
||||||
|
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
|
||||||
|
httpOK(t, w, 400, "delete self")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_DeleteUser_LastAdmin(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
uid := e.users.Seed("lastadmin", "hash", "admin")
|
||||||
|
atok := e.token(t, "admin", 999) // 另一个(不存在的)操作者
|
||||||
|
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
|
||||||
|
httpOK(t, w, 400, "last admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_DeleteUser_NotFound(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.users.Seed("other", "hash", "admin")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
w := e.do(t, http.MethodDelete, "/api/users/99999", atok, nil)
|
||||||
|
httpOK(t, w, 404, "missing user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_DeleteUser_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.users.Seed("admin", "hash", "admin") // 保住 last-admin 保护不触发
|
||||||
|
target := e.users.Seed("todelete", "hash", "member")
|
||||||
|
atok := e.token(t, "admin", 1)
|
||||||
|
|
||||||
|
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(target, 10), atok, nil)
|
||||||
|
httpOK(t, w, 204, "delete user")
|
||||||
|
if _, err := e.users.GetUserByID(context.Background(), target); !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
t.Fatal("user should be gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_Me_OK(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
uid := e.users.Seed("me", "hash", "member")
|
||||||
|
mtok := e.token(t, "member", uid)
|
||||||
|
|
||||||
|
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
|
||||||
|
httpOK(t, w, 200, "me")
|
||||||
|
body := jsonBody(t, w)
|
||||||
|
if body["username"] != "me" {
|
||||||
|
t.Fatalf("username want 'me' got %v", body["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_Me_UserGone(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
mtok := e.token(t, "member", 99999)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
|
||||||
|
httpOK(t, w, 401, "me after user gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnit_Me_NoToken(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
w := e.do(t, http.MethodGet, "/api/auth/me", "", nil)
|
||||||
|
httpOK(t, w, 401, "me without token")
|
||||||
|
}
|
||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
"booklib/cmd/webui/api"
|
"booklib/cmd/webui/api"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/db"
|
"booklib/internal/db"
|
||||||
|
"booklib/internal/media"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
"booklib/internal/scanner"
|
"booklib/internal/scanner"
|
||||||
"booklib/internal/seed"
|
"booklib/internal/seed"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
|
"booklib/internal/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -39,11 +41,14 @@ func main() {
|
|||||||
log.Fatalf("seed: %v", err)
|
log.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
rdb := redispkg.New(cfg.RedisURL)
|
rdb := redispkg.New(cfg.RedisURL)
|
||||||
sc := scanner.New(st, cfg, rdb)
|
med := media.New(cfg, rdb)
|
||||||
|
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
|
||||||
|
sc := scanner.New(st, cfg, rdb, up) // B16: sweep rides the scan ticker
|
||||||
go sc.Run(ctx)
|
go sc.Run(ctx)
|
||||||
|
|
||||||
serveErr := make(chan error, 1)
|
serveErr := make(chan error, 1)
|
||||||
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc),
|
// *store.Store 同时满足 5 个 store 接口;*redispkg.R 满足 RateLimiter。
|
||||||
|
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up),
|
||||||
ReadHeaderTimeout: 10 * time.Second}
|
ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("listening on %s", cfg.Addr)
|
log.Printf("listening on %s", cfg.Addr)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"booklib/internal/bookfile"
|
"booklib/internal/bookfile"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
|
"booklib/internal/ports"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ type M struct {
|
|||||||
rdb *redispkg.R
|
rdb *redispkg.R
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compile-time proof that *M satisfies the consumer-side interface.
|
||||||
|
var _ ports.Media = (*M)(nil)
|
||||||
|
|
||||||
func New(cfg *config.Config, rdb *redispkg.R) *M {
|
func New(cfg *config.Config, rdb *redispkg.R) *M {
|
||||||
return &M{cfg: cfg, rdb: rdb}
|
return &M{cfg: cfg, rdb: rdb}
|
||||||
}
|
}
|
||||||
@@ -54,7 +57,7 @@ func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
|
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
|
||||||
return writeAtomic(dir, "cover"+ext, img)
|
return WriteAtomic(dir, "cover"+ext, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageIndex returns the page list for a CBZ, using redis cache when available.
|
// PageIndex returns the page list for a CBZ, using redis cache when available.
|
||||||
@@ -80,7 +83,7 @@ func (m *M) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ChaptersOf derives chapters from a page index by grouping pages by parent directory.
|
// ChaptersOf derives chapters from a page index by grouping pages by parent directory.
|
||||||
func (m *M) ChaptersOf(idx []string) []Chapter {
|
func (m *M) ChaptersOf(idx []string) []ports.Chapter {
|
||||||
type grp struct {
|
type grp struct {
|
||||||
dir string
|
dir string
|
||||||
start int
|
start int
|
||||||
@@ -102,9 +105,9 @@ func (m *M) ChaptersOf(idx []string) []Chapter {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
titles := make(map[string]int)
|
titles := make(map[string]int)
|
||||||
out := make([]Chapter, len(grps))
|
out := make([]ports.Chapter, len(grps))
|
||||||
for i, g := range grps {
|
for i, g := range grps {
|
||||||
out[i] = Chapter{Title: path.Base(g.dir), Start: g.start}
|
out[i] = ports.Chapter{Title: path.Base(g.dir), Start: g.start}
|
||||||
titles[out[i].Title]++
|
titles[out[i].Title]++
|
||||||
}
|
}
|
||||||
for i, g := range grps {
|
for i, g := range grps {
|
||||||
@@ -115,14 +118,8 @@ func (m *M) ChaptersOf(idx []string) []Chapter {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chapter represents a CBZ chapter.
|
|
||||||
type Chapter struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Start int `json:"start"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsurePage extracts a single page to the cache. Returns the cache file path.
|
// EnsurePage extracts a single page to the cache. Returns the cache file path.
|
||||||
func (m *M) EnsurePage(bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) {
|
func (m *M) EnsurePage(_ context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) {
|
||||||
if n >= len(idx) {
|
if n >= len(idx) {
|
||||||
return "", fmt.Errorf("page %d out of range", n)
|
return "", fmt.Errorf("page %d out of range", n)
|
||||||
}
|
}
|
||||||
@@ -141,26 +138,26 @@ func (m *M) EnsurePage(bookID int64, size, modTS int64, root, rel string, n int,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeAtomic(dir, strconv.Itoa(n)+ext, data); err != nil {
|
if err := WriteAtomic(dir, strconv.Itoa(n)+ext, data); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return dst, nil
|
return dst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on failure.
|
// WriteAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on
|
||||||
func writeAtomic(dir, name string, data []byte) error {
|
// failure, so a crash mid-write never leaves a readable half-written file.
|
||||||
|
// It does not log — callers own the context (which book, which page).
|
||||||
|
func WriteAtomic(dir, name string, data []byte) error {
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
|
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
|
||||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
log.Printf("media: write %s: %v", name, err)
|
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dst := filepath.Join(dir, name)
|
dst := filepath.Join(dir, name)
|
||||||
if err := os.Rename(tmp, dst); err != nil {
|
if err := os.Rename(tmp, dst); err != nil {
|
||||||
log.Printf("media: rename %s: %v", name, err)
|
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ type Scanner interface {
|
|||||||
|
|
||||||
type UploadSessions interface {
|
type UploadSessions interface {
|
||||||
Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error)
|
Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error)
|
||||||
|
// LibraryID returns the target library recorded in the session, so the
|
||||||
|
// handler can resolve+validate the library root before Complete.
|
||||||
|
LibraryID(ctx context.Context, uploadID string) (int64, error)
|
||||||
Status(ctx context.Context, uploadID string) ([]int64, error)
|
Status(ctx context.Context, uploadID string) ([]int64, error)
|
||||||
PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error
|
PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error
|
||||||
Complete(ctx context.Context, uploadID string, root string) (string, error)
|
Complete(ctx context.Context, uploadID string, root string) (string, error)
|
||||||
@@ -86,7 +89,12 @@ type UploadSessions interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Media interface {
|
type Media interface {
|
||||||
|
// EnsureCover extracts+caches the cover if not already cached. Returns nil
|
||||||
|
// for formats without cover support.
|
||||||
EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error
|
EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error
|
||||||
|
// EnsurePage extracts page n (from a pre-fetched idx) to the cache and
|
||||||
|
// returns the cache file path.
|
||||||
|
EnsurePage(ctx context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error)
|
||||||
ChaptersOf(idx []string) []Chapter
|
ChaptersOf(idx []string) []Chapter
|
||||||
PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
|
PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
|
||||||
CacheBuster(size, modTS int64) string
|
CacheBuster(size, modTS int64) string
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package portsfake
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- RateLimiter ----------
|
||||||
|
|
||||||
|
// RateLimiter counts IncrWindow calls per key and returns a programmable
|
||||||
|
// window value. Default: always allow (return 1).
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls map[string]int
|
||||||
|
Result int // value returned by IncrWindow; 0 means "call count"
|
||||||
|
Hook func(key string, n int) int // optional override
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter() *RateLimiter { return &RateLimiter{calls: map[string]int{}} }
|
||||||
|
|
||||||
|
func (r *RateLimiter) IncrWindow(_ context.Context, key string, _ time.Duration) int {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.calls[key]++
|
||||||
|
n := r.calls[key]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if r.Hook != nil {
|
||||||
|
return r.Hook(key, n)
|
||||||
|
}
|
||||||
|
if r.Result > 0 {
|
||||||
|
return r.Result
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls reports how many times IncrWindow was invoked for key.
|
||||||
|
func (r *RateLimiter) Calls(key string) int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.calls[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Scanner ----------
|
||||||
|
|
||||||
|
// Scanner records ScanLibraryByID calls. The handler invokes it in a goroutine,
|
||||||
|
// so reads go through the mutex; WaitForScan blocks until at least n calls
|
||||||
|
// landed (or the timeout expires) to keep tests deterministic.
|
||||||
|
type Scanner struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScanner() *Scanner { return &Scanner{} }
|
||||||
|
|
||||||
|
func (s *Scanner) ScanLibraryByID(_ context.Context, id int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.seen = append(s.seen, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen returns the library ids scanned so far, in call order.
|
||||||
|
func (s *Scanner) Seen() []int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]int64, len(s.seen))
|
||||||
|
copy(out, s.seen)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForScan blocks until len(Seen()) >= n or timeout elapses; reports success.
|
||||||
|
func (s *Scanner) WaitForScan(n int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if len(s.Seen()) >= n {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
return len(s.Seen()) >= n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Media ----------
|
||||||
|
|
||||||
|
// Media is a programmable stand-in for the media service. Defaults answer
|
||||||
|
// successfully with the configured fixture data; individual hooks let a test
|
||||||
|
// force one error branch without touching the others.
|
||||||
|
type Media struct {
|
||||||
|
Pages []string // PageIndex result
|
||||||
|
Chapters []ports.Chapter // ChaptersOf result
|
||||||
|
CoverErr error // EnsureCover result
|
||||||
|
IndexErr error // PageIndex result
|
||||||
|
PagePath string // EnsurePage result path (test writes the file first)
|
||||||
|
PageErr error // EnsurePage result
|
||||||
|
|
||||||
|
EnsureCoverCalls int
|
||||||
|
EnsurePageCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMedia() *Media { return &Media{} }
|
||||||
|
|
||||||
|
func (m *Media) EnsureCover(_ context.Context, _ int64, _ string, _, _ int64, _, _ string) error {
|
||||||
|
m.EnsureCoverCalls++
|
||||||
|
return m.CoverErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) EnsurePage(_ context.Context, _ int64, _, _ int64, _, _ string, _ int, _ []string) (string, error) {
|
||||||
|
m.EnsurePageCalls++
|
||||||
|
if m.PageErr != nil {
|
||||||
|
return "", m.PageErr
|
||||||
|
}
|
||||||
|
return m.PagePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) ChaptersOf(_ []string) []ports.Chapter { return m.Chapters }
|
||||||
|
|
||||||
|
func (m *Media) PageIndex(_ context.Context, _ int64, _, _ int64, _, _ string) ([]string, error) {
|
||||||
|
if m.IndexErr != nil {
|
||||||
|
return nil, m.IndexErr
|
||||||
|
}
|
||||||
|
return m.Pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) CacheBuster(size, modTS int64) string { return fmt.Sprintf("%d-%d", size, modTS) }
|
||||||
|
|
||||||
|
// ---------- UploadSessions ----------
|
||||||
|
|
||||||
|
// Uploads is a programmable stand-in for the chunked-upload subsystem.
|
||||||
|
// Defaults simulate a happy session (uid "fakeuid…", LibraryID 1);
|
||||||
|
// hooks force error branches.
|
||||||
|
type Uploads struct {
|
||||||
|
UID string // returned by Init
|
||||||
|
LibID int64 // returned by LibraryID
|
||||||
|
Received []int64 // returned by Status
|
||||||
|
RelPath string // returned by Complete
|
||||||
|
InitErr error
|
||||||
|
LibErr error
|
||||||
|
StatusErr error
|
||||||
|
PutErr error
|
||||||
|
CompleteErr error
|
||||||
|
|
||||||
|
PutCalls []int64 // part indices passed to PutPart
|
||||||
|
SweepCalls int
|
||||||
|
UniqueCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUploads() *Uploads {
|
||||||
|
return &Uploads{UID: "0123456789abcdef0123456789abcdef", LibID: 1, RelPath: "book.cbz"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) Init(_ context.Context, _ int64, _ string, _, _ int64) (string, error) {
|
||||||
|
if u.InitErr != nil {
|
||||||
|
return "", u.InitErr
|
||||||
|
}
|
||||||
|
return u.UID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) LibraryID(_ context.Context, _ string) (int64, error) {
|
||||||
|
if u.LibErr != nil {
|
||||||
|
return 0, u.LibErr
|
||||||
|
}
|
||||||
|
return u.LibID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) Status(_ context.Context, _ string) ([]int64, error) {
|
||||||
|
if u.StatusErr != nil {
|
||||||
|
return nil, u.StatusErr
|
||||||
|
}
|
||||||
|
return u.Received, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) PutPart(_ context.Context, _ string, index int64, body io.Reader, _ int64) error {
|
||||||
|
u.PutCalls = append(u.PutCalls, index)
|
||||||
|
if body != nil { // drain so callers using pipes don't block
|
||||||
|
io.Copy(io.Discard, body)
|
||||||
|
}
|
||||||
|
return u.PutErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) Complete(_ context.Context, _, _ string) (string, error) {
|
||||||
|
if u.CompleteErr != nil {
|
||||||
|
return "", u.CompleteErr
|
||||||
|
}
|
||||||
|
return u.RelPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) Sweep(_ context.Context) error {
|
||||||
|
u.SweepCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Uploads) UniquePath(root, name string) (string, error) {
|
||||||
|
u.UniqueCalls++
|
||||||
|
return root + "/" + name, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// Package portsfake provides hand-written, in-memory implementations of every
|
||||||
|
// interface in internal/ports. They let handler tests run without PG or Redis,
|
||||||
|
// while reproducing the real store's error semantics exactly (pgx.ErrNoRows for
|
||||||
|
// missing rows, store.ErrLastAdmin / store.ErrUniqueViolation for the guarded
|
||||||
|
// paths), so the branches under test behave as they do against the database.
|
||||||
|
//
|
||||||
|
// Fakes are safe for single-goroutine test use only; they are not locked.
|
||||||
|
package portsfake
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"booklib/internal/ports"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uniqueViolation 复刻真 PG 的唯一约束冲突:store.IsUniqueViolation 只认
|
||||||
|
// SQLSTATE 23505 的 *pgconn.PgError(生产链路里 pgx 原样透出),fake 必须同形。
|
||||||
|
func uniqueViolation(constraint string) error {
|
||||||
|
return &pgconn.PgError{Code: "23505", ConstraintName: constraint}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time proof that the fakes satisfy the consumer-side interfaces.
|
||||||
|
var (
|
||||||
|
_ ports.UserStore = (*Users)(nil)
|
||||||
|
_ ports.LibraryStore = (*Libraries)(nil)
|
||||||
|
_ ports.BookStore = (*Books)(nil)
|
||||||
|
_ ports.ProgressStore = (*Progress)(nil)
|
||||||
|
_ ports.BookmarkStore = (*Bookmarks)(nil)
|
||||||
|
_ ports.RateLimiter = (*RateLimiter)(nil)
|
||||||
|
_ ports.Scanner = (*Scanner)(nil)
|
||||||
|
_ ports.Media = (*Media)(nil)
|
||||||
|
_ ports.UploadSessions = (*Uploads)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// now is frozen per-fake-set so CreatedAt comparisons are stable within a test.
|
||||||
|
func now() time.Time { return time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
// ---------- UserStore ----------
|
||||||
|
|
||||||
|
type Users struct {
|
||||||
|
m map[int64]store.User
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsers() *Users { return &Users{m: map[int64]store.User{}, next: 1} }
|
||||||
|
|
||||||
|
// Seed adds a user and returns its id — test convenience, not part of the port.
|
||||||
|
func (u *Users) Seed(username, hash, role string) int64 {
|
||||||
|
id := u.next
|
||||||
|
u.next++
|
||||||
|
u.m[id] = store.User{ID: id, Username: username, PasswordHash: hash, Role: role, CreatedAt: now()}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Users) CountUsers(_ context.Context) (int, error) { return len(u.m), nil }
|
||||||
|
|
||||||
|
func (u *Users) CreateUser(_ context.Context, username, hash, role string) (int64, error) {
|
||||||
|
for _, v := range u.m {
|
||||||
|
if v.Username == username {
|
||||||
|
return 0, uniqueViolation("users_username_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u.Seed(username, hash, role), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Users) GetUserByName(_ context.Context, username string) (store.User, error) {
|
||||||
|
for _, v := range u.m {
|
||||||
|
if v.Username == username {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Users) GetUserByID(_ context.Context, id int64) (store.User, error) {
|
||||||
|
if v, ok := u.m[id]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return store.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Users) ListUsers(_ context.Context) ([]store.User, error) {
|
||||||
|
out := make([]store.User, 0, len(u.m))
|
||||||
|
for _, v := range u.m {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Users) DeleteUser(_ context.Context, id int64) error {
|
||||||
|
v, ok := u.m[id]
|
||||||
|
if !ok {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
if v.Role == "admin" {
|
||||||
|
n := 0
|
||||||
|
for _, x := range u.m {
|
||||||
|
if x.Role == "admin" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return store.ErrLastAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(u.m, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- LibraryStore ----------
|
||||||
|
|
||||||
|
type Libraries struct {
|
||||||
|
m map[int64]store.Library
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLibraries() *Libraries { return &Libraries{m: map[int64]store.Library{}, next: 1} }
|
||||||
|
|
||||||
|
// Seed adds a library and returns its id — test convenience.
|
||||||
|
func (l *Libraries) Seed(name, root string) int64 {
|
||||||
|
id := l.next
|
||||||
|
l.next++
|
||||||
|
l.m[id] = store.Library{ID: id, Name: name, RootPath: root, CreatedAt: now()}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Libraries) CreateLibrary(_ context.Context, name, root string) (int64, error) {
|
||||||
|
for _, v := range l.m {
|
||||||
|
if v.Name == name {
|
||||||
|
return 0, uniqueViolation("libraries_name_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.Seed(name, root), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Libraries) ListLibraries(_ context.Context) ([]store.Library, error) {
|
||||||
|
out := make([]store.Library, 0, len(l.m))
|
||||||
|
for _, v := range l.m {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Libraries) GetLibrary(_ context.Context, id int64) (store.Library, error) {
|
||||||
|
if v, ok := l.m[id]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return store.Library{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- BookStore ----------
|
||||||
|
|
||||||
|
type Books struct {
|
||||||
|
m map[int64]store.Book
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBooks() *Books { return &Books{m: map[int64]store.Book{}, next: 1} }
|
||||||
|
|
||||||
|
// Seed adds a book and returns its id — test convenience.
|
||||||
|
func (b *Books) Seed(libID int64, path, title, format string, size, modTS int64, pageCount int) int64 {
|
||||||
|
id := b.next
|
||||||
|
b.next++
|
||||||
|
b.m[id] = store.Book{ID: id, LibraryID: libID, Path: path, Title: title, Format: format,
|
||||||
|
FileSize: size, ModTS: modTS, PageCount: pageCount, State: "ok", AddedAt: now()}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) InsertBook(_ context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) {
|
||||||
|
return b.Seed(libID, path, title, format, size, modTS, pageCount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) GetBook(_ context.Context, id int64) (store.Book, error) {
|
||||||
|
if v, ok := b.m[id]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return store.Book{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) ListBookMeta(_ context.Context, libID int64) (map[string]store.BookMeta, error) {
|
||||||
|
out := map[string]store.BookMeta{}
|
||||||
|
for _, v := range b.m {
|
||||||
|
if v.LibraryID == libID {
|
||||||
|
out[v.Path] = store.BookMeta{ID: v.ID, Size: v.FileSize, ModTS: v.ModTS, Format: v.Format}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) UpdateBookFile(_ context.Context, id, size, modTS int64, pageCount int) error {
|
||||||
|
v, ok := b.m[id]
|
||||||
|
if !ok {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
v.FileSize, v.ModTS, v.PageCount = size, modTS, pageCount
|
||||||
|
b.m[id] = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) DeleteBookByPath(_ context.Context, libID int64, path string) error {
|
||||||
|
for id, v := range b.m {
|
||||||
|
if v.LibraryID == libID && v.Path == path {
|
||||||
|
delete(b.m, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) DeleteBook(_ context.Context, id int64) error {
|
||||||
|
if _, ok := b.m[id]; !ok {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
delete(b.m, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) SetBookState(_ context.Context, id int64, state, msg string) error {
|
||||||
|
v, ok := b.m[id]
|
||||||
|
if !ok {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
v.State, v.ErrMsg = state, msg
|
||||||
|
b.m[id] = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBooks applies the same q/prefix filter as the SQL view (q matches title or
|
||||||
|
// path, prefix matches path prefix), then joins progress + library name.
|
||||||
|
func (b *Books) ListBooks(_ context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error) {
|
||||||
|
out := []store.BookView{}
|
||||||
|
q = strings.ToLower(q)
|
||||||
|
for _, v := range b.m {
|
||||||
|
if libID != 0 && v.LibraryID != libID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prefix != "" && !strings.HasPrefix(v.Path, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q != "" && !strings.Contains(strings.ToLower(v.Title), q) && !strings.Contains(strings.ToLower(v.Path), q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, store.BookView{Book: v})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Book.ID < out[j].Book.ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Books) BookHashes(_ context.Context) (map[int64][2]int64, error) {
|
||||||
|
out := map[int64][2]int64{}
|
||||||
|
for _, v := range b.m {
|
||||||
|
out[v.ID] = [2]int64{v.FileSize, v.ModTS}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ProgressStore ----------
|
||||||
|
|
||||||
|
type progressKey struct {
|
||||||
|
userID, libID int64
|
||||||
|
bookPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Progress struct {
|
||||||
|
m map[progressKey]store.Progress
|
||||||
|
libs *Libraries // for LibraryName join; may be nil
|
||||||
|
books *Books // for Title join; may be nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgress(libs *Libraries, books *Books) *Progress {
|
||||||
|
return &Progress{m: map[progressKey]store.Progress{}, libs: libs, books: books}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) UpsertProgress(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error {
|
||||||
|
k := progressKey{userID, libID, bookPath}
|
||||||
|
old := p.m[k]
|
||||||
|
p.m[k] = store.Progress{LibraryID: libID, BookPath: bookPath, Locator: locator,
|
||||||
|
Percent: percent, UpdatedAt: now(), LibraryName: old.LibraryName, Title: old.Title}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) GetProgress(_ context.Context, userID, libID int64, bookPath string) (store.Progress, error) {
|
||||||
|
if v, ok := p.m[progressKey{userID, libID, bookPath}]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return store.Progress{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) ListProgress(_ context.Context, userID int64) ([]store.Progress, error) {
|
||||||
|
out := []store.Progress{}
|
||||||
|
for k, v := range p.m {
|
||||||
|
if k.userID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.libs != nil {
|
||||||
|
if l, e := p.libs.GetLibrary(context.Background(), v.LibraryID); e == nil {
|
||||||
|
v.LibraryName = l.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.books != nil {
|
||||||
|
for _, b := range p.books.m {
|
||||||
|
if b.LibraryID == v.LibraryID && b.Path == v.BookPath {
|
||||||
|
v.Title = b.Title
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].BookPath < out[j].BookPath })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- BookmarkStore ----------
|
||||||
|
|
||||||
|
// bookmark pairs a row with its owner id. The real store carries the owner in a
|
||||||
|
// users-scoped join; the fake keeps it alongside so ListBookmarks/Patch/Delete
|
||||||
|
// can honour owner-scoped 404 semantics without a global index.
|
||||||
|
type bookmark struct {
|
||||||
|
row store.Bookmark
|
||||||
|
userID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bookmarks struct {
|
||||||
|
m map[int64]bookmark
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBookmarks() *Bookmarks { return &Bookmarks{m: map[int64]bookmark{}, next: 1} }
|
||||||
|
|
||||||
|
func (bm *Bookmarks) InsertBookmark(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
|
||||||
|
id := bm.next
|
||||||
|
bm.next++
|
||||||
|
bm.m[id] = bookmark{
|
||||||
|
row: store.Bookmark{ID: id, LibraryID: libID, BookPath: bookPath,
|
||||||
|
Locator: locator, Percent: percent, Note: note, CreatedAt: now()},
|
||||||
|
userID: userID,
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *Bookmarks) ListBookmarks(_ context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error) {
|
||||||
|
out := []store.Bookmark{}
|
||||||
|
for _, v := range bm.m {
|
||||||
|
if v.userID == userID && v.row.LibraryID == libID && v.row.BookPath == bookPath {
|
||||||
|
out = append(out, v.row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Percent != out[j].Percent {
|
||||||
|
return out[i].Percent < out[j].Percent
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *Bookmarks) UpdateBookmarkNote(_ context.Context, userID, id int64, note string) (bool, error) {
|
||||||
|
v, ok := bm.m[id]
|
||||||
|
if !ok || v.userID != userID { // owner-scoped: foreign id is a 404, not a 403
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
v.row.Note = note
|
||||||
|
bm.m[id] = v
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *Bookmarks) DeleteBookmark(_ context.Context, userID, id int64) (bool, error) {
|
||||||
|
v, ok := bm.m[id]
|
||||||
|
if !ok || v.userID != userID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
delete(bm.m, id)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -15,21 +14,28 @@ import (
|
|||||||
|
|
||||||
"booklib/internal/bookfile"
|
"booklib/internal/bookfile"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
|
"booklib/internal/media"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sweeper 是 scanner 每轮顺手调用的清理钩子;upload.U 满足它(B16)。
|
||||||
|
type Sweeper interface {
|
||||||
|
Sweep(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
st *store.Store
|
st *store.Store
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
rdb *redispkg.R
|
rdb *redispkg.R
|
||||||
|
sweepers []Sweeper
|
||||||
// B9-②: per-library single-flight — concurrent scan triggers for the same
|
// B9-②: per-library single-flight — concurrent scan triggers for the same
|
||||||
// library are merged into one execution, even without redis.
|
// library are merged into one execution, even without redis.
|
||||||
flights sync.Map // map[int64]*sync.WaitGroup
|
flights sync.Map // map[int64]*sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner {
|
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R, sweepers ...Sweeper) *Scanner {
|
||||||
return &Scanner{st: st, cfg: cfg, rdb: rdb}
|
return &Scanner{st: st, cfg: cfg, rdb: rdb, sweepers: sweepers}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) Run(ctx context.Context) {
|
func (s *Scanner) Run(ctx context.Context) {
|
||||||
@@ -40,6 +46,11 @@ func (s *Scanner) Run(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
|
for _, sw := range s.sweepers { // B16: 上传会话清扫随扫描周期跑
|
||||||
|
if err := sw.Sweep(ctx); err != nil {
|
||||||
|
log.Printf("scan: sweep: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
libs, err := s.st.ListLibraries(ctx)
|
libs, err := s.st.ListLibraries(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("scan: list libraries: %v", err)
|
log.Printf("scan: list libraries: %v", err)
|
||||||
@@ -86,7 +97,7 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
|
|||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath))
|
root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath))
|
||||||
if err != nil || !inside(s.cfg.BooksDir, root) {
|
if err != nil || !bookfile.Contains(s.cfg.BooksDir, root) {
|
||||||
log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath)
|
log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,9 +116,9 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
|
|||||||
delete(dbMeta, rel)
|
delete(dbMeta, rel)
|
||||||
switch {
|
switch {
|
||||||
case !exists:
|
case !exists:
|
||||||
s.add(ctx, lib.ID, root, rel, ds)
|
s.ingest(ctx, lib.ID, 0, root, rel, ds, true)
|
||||||
case old.Size != ds.size || old.ModTS != ds.modTS:
|
case old.Size != ds.size || old.ModTS != ds.modTS:
|
||||||
s.update(ctx, lib.ID, old.ID, root, rel, ds)
|
s.ingest(ctx, lib.ID, old.ID, root, rel, ds, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for rel := range dbMeta { // 只剩被删的文件
|
for rel := range dbMeta { // 只剩被删的文件
|
||||||
@@ -120,11 +131,6 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
|
|||||||
|
|
||||||
type diskStat struct{ size, modTS int64 }
|
type diskStat struct{ size, modTS int64 }
|
||||||
|
|
||||||
func inside(booksDir, root string) bool {
|
|
||||||
b := filepath.Clean(booksDir)
|
|
||||||
return root == b || strings.HasPrefix(root, b+string(os.PathSeparator))
|
|
||||||
}
|
|
||||||
|
|
||||||
func walk(root string) (map[string]diskStat, error) {
|
func walk(root string) (map[string]diskStat, error) {
|
||||||
out := map[string]diskStat{}
|
out := map[string]diskStat{}
|
||||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
@@ -157,7 +163,9 @@ func titleOf(rel string) string {
|
|||||||
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " "))
|
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds diskStat) {
|
// ingest 是 add/update 的合一实现(Task 24):isNew 决定走 Insert 还是 UpdateBookFile,
|
||||||
|
// 之后的错误处理与封面生成完全共享。bookID 仅在 isNew=false 时有意义。
|
||||||
|
func (s *Scanner) ingest(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat, isNew bool) {
|
||||||
format := bookfile.FormatFromExt(filepath.Base(rel))
|
format := bookfile.FormatFromExt(filepath.Base(rel))
|
||||||
pageCount := 0
|
pageCount := 0
|
||||||
var idxErr error
|
var idxErr error
|
||||||
@@ -169,40 +177,21 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis
|
|||||||
idxErr = errors.New("no images in archive")
|
idxErr = errors.New("no images in archive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isNew {
|
||||||
id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount)
|
id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("scan: insert %s: %v", rel, err)
|
log.Printf("scan: insert %s: %v", rel, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if idxErr != nil {
|
bookID = id
|
||||||
// B10: log SetBookState errors instead of discarding.
|
} else if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
|
||||||
if e := s.st.SetBookState(ctx, id, "error", idxErr.Error()); e != nil {
|
|
||||||
log.Printf("scan: SetBookState error for %s: %v", rel, e)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cover(ctx, id, root, rel, format, ds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat) {
|
|
||||||
format := bookfile.FormatFromExt(filepath.Base(rel))
|
|
||||||
pageCount := 0
|
|
||||||
var idxErr error
|
|
||||||
if format == "cbz" {
|
|
||||||
idx, err := s.zipIndex(root, rel)
|
|
||||||
pageCount = len(idx)
|
|
||||||
idxErr = err
|
|
||||||
if idxErr == nil && pageCount == 0 {
|
|
||||||
idxErr = errors.New("no images in archive")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
|
|
||||||
log.Printf("scan: update %s: %v", rel, err)
|
log.Printf("scan: update %s: %v", rel, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if idxErr != nil {
|
if idxErr != nil {
|
||||||
|
// B10: log SetBookState errors instead of discarding.
|
||||||
if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil {
|
if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil {
|
||||||
log.Printf("scan: SetBookState error for %s: %v", rel, e)
|
log.Printf("scan: SetBookState %s: %v", rel, e)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -210,66 +199,41 @@ func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
|
func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
|
||||||
f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel)))
|
f, size, err := bookfile.OpenReaderAt(root, rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
st, err := f.Stat()
|
return bookfile.PageIndex(f, size)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return bookfile.PageIndex(f, st.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cover writes the cover image to the cache dir. B11: all write errors are
|
// cover writes the cover image to the cache dir via media.WriteAtomic.
|
||||||
// checked and logged; orphan .tmp files are cleaned only on failure path.
|
// B11: write failures are logged; orphan .tmp files are cleaned only on failure.
|
||||||
func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
|
func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
|
||||||
var img []byte
|
var fn func(io.ReaderAt, int64) ([]byte, string, error)
|
||||||
var ext string
|
|
||||||
var err error
|
|
||||||
switch format {
|
switch format {
|
||||||
case "cbz":
|
case "cbz":
|
||||||
img, ext, err = s.readCover(root, rel, bookfile.CBZCover)
|
fn = bookfile.CBZCover
|
||||||
case "epub":
|
case "epub":
|
||||||
img, ext, err = s.readCover(root, rel, bookfile.EPUBCover)
|
fn = bookfile.EPUBCover
|
||||||
default:
|
default:
|
||||||
return // pdf/txt/md 用占位 SVG,不落盘
|
return // pdf/txt/md 用占位 SVG,不落盘
|
||||||
}
|
}
|
||||||
|
f, size, err := bookfile.OpenReaderAt(root, rel)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: cover %s: %v", rel, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, ext, err := fn(f, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("scan: cover %s: %v", rel, err)
|
log.Printf("scan: cover %s: %v", rel, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS)))
|
dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS)))
|
||||||
if e := os.MkdirAll(dir, 0o755); e != nil {
|
if e := media.WriteAtomic(dir, "cover"+ext, img); e != nil {
|
||||||
log.Printf("scan: coverdir %s: %v", rel, e)
|
log.Printf("scan: cover write %s: %v", rel, e)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
tmp := filepath.Join(dir, "cover"+ext+".tmp")
|
|
||||||
dst := filepath.Join(dir, "cover"+ext)
|
|
||||||
if e := os.WriteFile(tmp, img, 0o644); e != nil {
|
|
||||||
log.Printf("scan: write cover tmp %s: %v", rel, e)
|
|
||||||
os.Remove(tmp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if e := os.Rename(tmp, dst); e != nil {
|
|
||||||
log.Printf("scan: rename cover %s: %v", rel, e)
|
|
||||||
os.Remove(tmp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) readCover(root, rel string, fn func(io.ReaderAt, int64) ([]byte, string, error)) ([]byte, string, error) {
|
|
||||||
f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
st, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return fn(f, st.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) sweepCache(ctx context.Context) {
|
func (s *Scanner) sweepCache(ctx context.Context) {
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package seed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
|
|
||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
@@ -28,8 +25,7 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil {
|
if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil {
|
||||||
var pgErr *pgconn.PgError
|
if store.IsUniqueViolation(err) {
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
||||||
log.Printf("seed admin %q may already exist: %v", user, err)
|
log.Printf("seed admin %q may already exist: %v", user, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
// Package upload owns the resumable chunked-upload subsystem and the shared
|
||||||
|
// unique-path placement helper used by both chunked and single-file uploads.
|
||||||
|
//
|
||||||
|
// A session lives at <BooksDir>/.uploads/<uid>/ (meta.json + parts/N). The uid
|
||||||
|
// is a fingerprint of (libID, name, size, chunkSize), so re-initialising the
|
||||||
|
// same file resumes the existing session instead of restarting it. Expired
|
||||||
|
// sessions are swept by the scanner ticker (B16), not on the request path.
|
||||||
|
//
|
||||||
|
// This package is HTTP-free: it returns sentinel errors that the handlers map
|
||||||
|
// onto status/code/message tuples. The prior client-facing contract is
|
||||||
|
// preserved exactly.
|
||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxChunkBytes = 32 << 20
|
||||||
|
defaultChunk = 8 << 20
|
||||||
|
uploadSessTTL = 24 * time.Hour
|
||||||
|
uploadSessionIn = ".uploads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors returned to handlers for status/code mapping. The four shared
|
||||||
|
// with ports (ErrTooLarge/ErrNotFound/ErrIncomplete/ErrSizeMismatch) live in the
|
||||||
|
// ports package so the interface contract and the implementation agree.
|
||||||
|
var (
|
||||||
|
ErrBadName = errors.New("bad name")
|
||||||
|
ErrBadFormat = errors.New("bad format")
|
||||||
|
ErrBadSize = errors.New("bad size")
|
||||||
|
ErrBadChunk = errors.New("bad chunk size")
|
||||||
|
ErrBadUploadID = errors.New("bad upload id")
|
||||||
|
ErrBadIndex = errors.New("bad part index")
|
||||||
|
ErrPartTooBig = errors.New("part exceeds declared size")
|
||||||
|
ErrPartSizeMismatch = errors.New("part size mismatch")
|
||||||
|
ErrCorrupt = errors.New("corrupt session")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpError wraps an internal filesystem/IO failure with the short operation label
|
||||||
|
// that the client-facing 500 message uses, preserving the prior contract strings
|
||||||
|
// ("create session", "write meta", "create part", ...).
|
||||||
|
type OpError struct {
|
||||||
|
Op string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
|
||||||
|
func (e *OpError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func opErr(op string, err error) error { return &OpError{Op: op, Err: err} }
|
||||||
|
|
||||||
|
type uploadMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ChunkSize int64 `json:"chunkSize"`
|
||||||
|
LibraryID int64 `json:"libraryId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// U is the upload subsystem. It is stateless beyond the filesystem session dir.
|
||||||
|
type U struct {
|
||||||
|
booksDir string
|
||||||
|
uploadMaxMB int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the upload subsystem. booksDir is the storage root (sessions live
|
||||||
|
// under booksDir/.uploads); uploadMaxMB caps the total declared file size.
|
||||||
|
func New(booksDir string, uploadMaxMB int64) *U {
|
||||||
|
return &U{booksDir: booksDir, uploadMaxMB: uploadMaxMB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time proof that *U satisfies the consumer-side interface.
|
||||||
|
var _ ports.UploadSessions = (*U)(nil)
|
||||||
|
|
||||||
|
// ---------- pure helpers ----------
|
||||||
|
|
||||||
|
func validUploadID(s string) bool {
|
||||||
|
if len(s) != 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadIDFor(libID int64, name string, size, chunk int64) string {
|
||||||
|
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%d", libID, name, size, chunk)))
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *U) uploadDir(uid string) string {
|
||||||
|
return filepath.Join(filepath.Clean(u.booksDir), uploadSessionIn, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkRange(m uploadMeta, i int64) (int64, int64) {
|
||||||
|
lo := i * m.ChunkSize
|
||||||
|
hi := min(lo+m.ChunkSize, m.Size)
|
||||||
|
return lo, hi
|
||||||
|
}
|
||||||
|
|
||||||
|
func numParts(m uploadMeta) int64 {
|
||||||
|
return (m.Size + m.ChunkSize - 1) / m.ChunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- session meta ----------
|
||||||
|
|
||||||
|
// loadMeta validates uid + reads meta.json. Returns ErrBadUploadID,
|
||||||
|
// ports.ErrNotFound, or ErrCorrupt on failure.
|
||||||
|
func (u *U) loadMeta(uid string) (uploadMeta, string, error) {
|
||||||
|
if !validUploadID(uid) {
|
||||||
|
return uploadMeta{}, "", ErrBadUploadID
|
||||||
|
}
|
||||||
|
dir := u.uploadDir(uid)
|
||||||
|
b, e := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||||
|
if e != nil {
|
||||||
|
return uploadMeta{}, "", ports.ErrNotFound
|
||||||
|
}
|
||||||
|
var m uploadMeta
|
||||||
|
if json.Unmarshal(b, &m) != nil {
|
||||||
|
return uploadMeta{}, "", ErrCorrupt
|
||||||
|
}
|
||||||
|
return m, dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryID returns the target library id recorded in the session, so the
|
||||||
|
// handler can resolve+validate the library root before calling Complete.
|
||||||
|
func (u *U) LibraryID(ctx context.Context, uid string) (int64, error) {
|
||||||
|
m, _, e := u.loadMeta(uid)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
return m.LibraryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- public API (satisfies ports.UploadSessions) ----------
|
||||||
|
|
||||||
|
// Init validates the declared upload, then creates or resumes a session. The
|
||||||
|
// returned uid is deterministic for a given (libID, safeName, size, chunkSize),
|
||||||
|
// so a re-init of the same file resumes; a fingerprint collision with different
|
||||||
|
// content restarts the session.
|
||||||
|
func (u *U) Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error) {
|
||||||
|
safe := bookfile.SafeName(name)
|
||||||
|
if safe == "" {
|
||||||
|
return "", ErrBadName
|
||||||
|
}
|
||||||
|
if bookfile.FormatFromExt(safe) == "" {
|
||||||
|
return "", ErrBadFormat
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
return "", ErrBadSize
|
||||||
|
}
|
||||||
|
if size > u.uploadMaxMB<<20 {
|
||||||
|
return "", ports.ErrTooLarge
|
||||||
|
}
|
||||||
|
if chunkSize == 0 {
|
||||||
|
chunkSize = defaultChunk
|
||||||
|
}
|
||||||
|
if chunkSize > maxChunkBytes {
|
||||||
|
return "", ErrBadChunk
|
||||||
|
}
|
||||||
|
uid := uploadIDFor(libID, safe, size, chunkSize)
|
||||||
|
dir := u.uploadDir(uid)
|
||||||
|
meta := uploadMeta{Name: safe, Size: size, ChunkSize: chunkSize, LibraryID: libID}
|
||||||
|
if b, e := os.ReadFile(filepath.Join(dir, "meta.json")); e == nil {
|
||||||
|
var old uploadMeta
|
||||||
|
if json.Unmarshal(b, &old) == nil && old == meta { // same fingerprint → resume
|
||||||
|
return uid, nil
|
||||||
|
}
|
||||||
|
os.RemoveAll(dir) // fingerprint collided but content differs → restart
|
||||||
|
}
|
||||||
|
if e := os.MkdirAll(filepath.Join(dir, "parts"), 0o755); e != nil {
|
||||||
|
return "", opErr("create session", e)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(meta)
|
||||||
|
if e := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); e != nil {
|
||||||
|
return "", opErr("write meta", e)
|
||||||
|
}
|
||||||
|
return uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the sorted indices of parts already received.
|
||||||
|
func (u *U) Status(ctx context.Context, uid string) ([]int64, error) {
|
||||||
|
_, dir, e := u.loadMeta(uid)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
recv := []int64{}
|
||||||
|
es, e := os.ReadDir(filepath.Join(dir, "parts"))
|
||||||
|
if e == nil {
|
||||||
|
for _, en := range es {
|
||||||
|
if i, e := strconv.ParseInt(en.Name(), 10, 64); e == nil {
|
||||||
|
recv = append(recv, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(recv, func(i, j int) bool { return recv[i] < recv[j] })
|
||||||
|
return recv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutPart writes one part via tmp+rename (B4: a truncated part is never reported
|
||||||
|
// as received). body is read up to the declared part size; reading past it yields
|
||||||
|
// ErrPartTooBig, a short read yields ErrPartSizeMismatch. maxSize, when > 0, is a
|
||||||
|
// defensive ceiling on bytes read.
|
||||||
|
func (u *U) PutPart(ctx context.Context, uid string, index int64, body io.Reader, maxSize int64) error {
|
||||||
|
m, dir, e := u.loadMeta(uid)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if index < 0 || index >= numParts(m) {
|
||||||
|
return ErrBadIndex
|
||||||
|
}
|
||||||
|
lo, hi := chunkRange(m, index)
|
||||||
|
want := hi - lo
|
||||||
|
limit := want + 1
|
||||||
|
if maxSize > 0 && maxSize+1 < limit {
|
||||||
|
limit = maxSize + 1
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, "parts", strconv.FormatInt(index, 10))
|
||||||
|
tmp := p + ".tmp"
|
||||||
|
f, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if e != nil {
|
||||||
|
return opErr("create part", e)
|
||||||
|
}
|
||||||
|
n, copyErr := io.Copy(f, io.LimitReader(body, limit))
|
||||||
|
f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return ErrPartSizeMismatch
|
||||||
|
}
|
||||||
|
if n > want {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return ErrPartTooBig
|
||||||
|
}
|
||||||
|
if n != want {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return ErrPartSizeMismatch
|
||||||
|
}
|
||||||
|
if e := os.Rename(tmp, p); e != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return opErr("rename part", e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete verifies every part is present and correctly sized, assembles them
|
||||||
|
// into a tmp file, then atomically renames it into root under a unique name.
|
||||||
|
// It returns the path relative to root. The session dir is removed on success.
|
||||||
|
func (u *U) Complete(ctx context.Context, uid, root string) (string, error) {
|
||||||
|
m, dir, e := u.loadMeta(uid)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for i := int64(0); i < numParts(m); i++ {
|
||||||
|
lo, hi := chunkRange(m, i)
|
||||||
|
fi, e := os.Stat(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
||||||
|
if e != nil || fi.Size() != hi-lo {
|
||||||
|
return "", ports.ErrIncomplete
|
||||||
|
}
|
||||||
|
total += fi.Size()
|
||||||
|
}
|
||||||
|
if total != m.Size {
|
||||||
|
return "", ports.ErrSizeMismatch
|
||||||
|
}
|
||||||
|
dst, e := u.UniquePath(root, m.Name)
|
||||||
|
if e != nil {
|
||||||
|
return "", e // os.ErrInvalid / os.ErrExist → handler maps to 403
|
||||||
|
}
|
||||||
|
tmp := filepath.Join(dir, "assembled")
|
||||||
|
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if e != nil {
|
||||||
|
return "", opErr("create tmp", e)
|
||||||
|
}
|
||||||
|
for i := int64(0); i < numParts(m); i++ {
|
||||||
|
pf, e := os.Open(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
||||||
|
if e != nil {
|
||||||
|
out.Close()
|
||||||
|
return "", opErr("open part", e)
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(out, pf)
|
||||||
|
pf.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", opErr("assemble", copyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
if e := os.Rename(tmp, dst); e != nil { // atomic placement; scanner picks it up
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", opErr("rename", e)
|
||||||
|
}
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return strings.TrimPrefix(dst, root+string(os.PathSeparator)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep removes session dirs untouched for longer than the TTL. Best-effort:
|
||||||
|
// a missing/unreadable base dir is not an error. Run from the scanner ticker
|
||||||
|
// (B16) instead of the request path.
|
||||||
|
func (u *U) Sweep(ctx context.Context) error {
|
||||||
|
base := filepath.Join(filepath.Clean(u.booksDir), uploadSessionIn)
|
||||||
|
es, e := os.ReadDir(base)
|
||||||
|
if e != nil {
|
||||||
|
return nil // no sessions yet
|
||||||
|
}
|
||||||
|
for _, en := range es {
|
||||||
|
if fi, e := en.Info(); e == nil && time.Since(fi.ModTime()) > uploadSessTTL {
|
||||||
|
os.RemoveAll(filepath.Join(base, en.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniquePath returns a path under root for name that does not yet exist,
|
||||||
|
// appending " (n)" on collision. The cleaned name must stay inside root.
|
||||||
|
// Shared by single-file and chunked upload completion.
|
||||||
|
func (u *U) UniquePath(root, name string) (string, error) {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
base := strings.TrimSuffix(name, ext)
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
cand := base + ext
|
||||||
|
if i > 0 {
|
||||||
|
cand = base + " (" + strconv.Itoa(i) + ")" + ext
|
||||||
|
}
|
||||||
|
p := filepath.Join(root, cand)
|
||||||
|
if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) ||
|
||||||
|
!strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) {
|
||||||
|
return "", os.ErrInvalid
|
||||||
|
}
|
||||||
|
if _, e := os.Stat(p); os.IsNotExist(e) {
|
||||||
|
return p, nil
|
||||||
|
} else if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if i > 999 {
|
||||||
|
return "", os.ErrExist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newU(t *testing.T) *U {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
resolved, err := filepath.EvalSymlinks(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return New(resolved, 1) // 1MB cap, mirrors handler test cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustInit(t *testing.T, u *U, libID int64, name string, size, chunk int64) string {
|
||||||
|
t.Helper()
|
||||||
|
uid, err := u.Init(context.Background(), libID, name, size, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init(%q): %v", name, err)
|
||||||
|
}
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadIDForDeterministic(t *testing.T) {
|
||||||
|
a := uploadIDFor(1, "x.cbz", 100, 50)
|
||||||
|
b := uploadIDFor(1, "x.cbz", 100, 50)
|
||||||
|
c := uploadIDFor(2, "x.cbz", 100, 50)
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("same fingerprint must yield same uid: %s vs %s", a, b)
|
||||||
|
}
|
||||||
|
if a == c {
|
||||||
|
t.Fatal("different libID must yield different uid")
|
||||||
|
}
|
||||||
|
if !validUploadID(a) || validUploadID("zzz") || validUploadID(strings.Repeat("a", 31)) {
|
||||||
|
t.Fatal("validUploadID broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitValidation(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := u.Init(ctx, 1, "", 100, 0); !errors.Is(err, ErrBadName) {
|
||||||
|
t.Fatalf("empty name: want ErrBadName got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.Init(ctx, 1, "virus.exe", 100, 0); !errors.Is(err, ErrBadFormat) {
|
||||||
|
t.Fatalf("bad ext: want ErrBadFormat got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.Init(ctx, 1, "ok.cbz", 0, 0); !errors.Is(err, ErrBadSize) {
|
||||||
|
t.Fatalf("zero size: want ErrBadSize got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.Init(ctx, 1, "big.cbz", 2<<20, 0); !errors.Is(err, ports.ErrTooLarge) {
|
||||||
|
t.Fatalf("oversize: want ErrTooLarge got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.Init(ctx, 1, "ok.cbz", 100, 40<<20); !errors.Is(err, ErrBadChunk) {
|
||||||
|
t.Fatalf("huge chunk: want ErrBadChunk got %v", err)
|
||||||
|
}
|
||||||
|
// 默认 chunk 生效且 uid 稳定
|
||||||
|
uid := mustInit(t, u, 1, "ok.cbz", 100, 0)
|
||||||
|
if uid != uploadIDFor(1, "ok.cbz", 100, defaultChunk) {
|
||||||
|
t.Fatal("chunkSize=0 must default to defaultChunk in fingerprint")
|
||||||
|
}
|
||||||
|
// SafeName 清洗:path 形式取 base
|
||||||
|
if uid2 := mustInit(t, u, 1, `C:\dir\book.cbz`, 100, 0); uid2 != uploadIDFor(1, "book.cbz", 100, defaultChunk) {
|
||||||
|
t.Fatalf("windows path must be sanitized to base name, got uid %s", uid2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitResumeAndRestart(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := mustInit(t, u, 1, "r.cbz", 900000, 400000)
|
||||||
|
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("x"), 400000)), 0); err != nil {
|
||||||
|
t.Fatalf("PutPart: %v", err)
|
||||||
|
}
|
||||||
|
// 同指纹 re-init → 复用会话,分片保留
|
||||||
|
uid2, err := u.Init(ctx, 1, "r.cbz", 900000, 400000)
|
||||||
|
if err != nil || uid2 != uid {
|
||||||
|
t.Fatalf("resume: want same uid, got %s err=%v", uid2, err)
|
||||||
|
}
|
||||||
|
recv, err := u.Status(ctx, uid)
|
||||||
|
if err != nil || len(recv) != 1 || recv[0] != 0 {
|
||||||
|
t.Fatalf("resume must keep parts: %v err=%v", recv, err)
|
||||||
|
}
|
||||||
|
// 同指纹位但内容不同(size 变)→ 新会话,旧目录被换掉是安全的(uid 不同)
|
||||||
|
uid3 := mustInit(t, u, 1, "r.cbz", 800000, 400000)
|
||||||
|
if uid3 == uid {
|
||||||
|
t.Fatal("different size must yield different uid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutPartErrors(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := mustInit(t, u, 1, "p.cbz", 1000, 400)
|
||||||
|
if err := u.PutPart(ctx, uid, 9, bytes.NewReader(bytes.Repeat([]byte("y"), 400)), 0); !errors.Is(err, ErrBadIndex) {
|
||||||
|
t.Fatalf("index oob: want ErrBadIndex got %v", err)
|
||||||
|
}
|
||||||
|
if err := u.PutPart(ctx, uid, -1, bytes.NewReader(nil), 0); !errors.Is(err, ErrBadIndex) {
|
||||||
|
t.Fatalf("negative index: want ErrBadIndex got %v", err)
|
||||||
|
}
|
||||||
|
// 超期望体积 → ErrPartTooBig
|
||||||
|
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("y"), 500)), 0); !errors.Is(err, ErrPartTooBig) {
|
||||||
|
t.Fatalf("oversize part: want ErrPartTooBig got %v", err)
|
||||||
|
}
|
||||||
|
// 短读 → ErrPartSizeMismatch
|
||||||
|
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("y"), 300)), 0); !errors.Is(err, ErrPartSizeMismatch) {
|
||||||
|
t.Fatalf("short part: want ErrPartSizeMismatch got %v", err)
|
||||||
|
}
|
||||||
|
// 失败不留 parts/B4: 截断分片不被 Status 报告为已接收
|
||||||
|
recv, err := u.Status(ctx, uid)
|
||||||
|
if err != nil || len(recv) != 0 {
|
||||||
|
t.Fatalf("failed parts must not be received: %v err=%v", recv, err)
|
||||||
|
}
|
||||||
|
// 未知/非法 uid
|
||||||
|
if _, err := u.Status(ctx, "deadbeefdeadbeefdeadbeefdeadbeef"); !errors.Is(err, ports.ErrNotFound) {
|
||||||
|
t.Fatalf("unknown uid: want ErrNotFound got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.Status(ctx, "zzz"); !errors.Is(err, ErrBadUploadID) {
|
||||||
|
t.Fatalf("bad uid: want ErrBadUploadID got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteHappyPathAndCleanup(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := filepath.Join(u.booksDir, "lib")
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := bytes.Repeat([]byte("調教開關第二季!"), 300) // ~6.3KB multibyte
|
||||||
|
size := int64(len(content))
|
||||||
|
const chunk = int64(2000)
|
||||||
|
uid := mustInit(t, u, 7, "調教開關:第二季.zip", size, chunk)
|
||||||
|
n := int((size + chunk - 1) / chunk)
|
||||||
|
// 乱序上传:索引顺序 1,2,...,n-1,0 —— 覆盖所有分片且非递增
|
||||||
|
for step := 1; step <= n; step++ {
|
||||||
|
idx := int64(step % n)
|
||||||
|
lo := idx * chunk
|
||||||
|
hi := min(lo+chunk, size)
|
||||||
|
if err := u.PutPart(ctx, uid, idx, bytes.NewReader(content[lo:hi]), 0); err != nil {
|
||||||
|
t.Fatalf("PutPart %d: %v", idx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 缺片 → ErrIncomplete(先删一片验证)
|
||||||
|
part0 := filepath.Join(u.uploadDir(uid), "parts", "0")
|
||||||
|
saved, _ := os.ReadFile(part0)
|
||||||
|
os.Remove(part0)
|
||||||
|
if _, err := u.Complete(ctx, uid, root); !errors.Is(err, ports.ErrIncomplete) {
|
||||||
|
t.Fatalf("missing part: want ErrIncomplete got %v", err)
|
||||||
|
}
|
||||||
|
os.WriteFile(part0, saved, 0o644)
|
||||||
|
|
||||||
|
rel, err := u.Complete(ctx, uid, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete: %v", err)
|
||||||
|
}
|
||||||
|
if rel != "調教開關:第二季.zip" {
|
||||||
|
t.Fatalf("rel path: %q", rel)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, rel))
|
||||||
|
if err != nil || !bytes.Equal(got, content) {
|
||||||
|
t.Fatalf("assembled content wrong: err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(u.uploadDir(uid)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("session dir must be removed after complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteUniquePathCollision(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := filepath.Join(u.booksDir, "lib")
|
||||||
|
os.MkdirAll(root, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(root, "dup.cbz"), []byte("existing"), 0o644)
|
||||||
|
uid := mustInit(t, u, 1, "dup.cbz", 4, 4)
|
||||||
|
if err := u.PutPart(ctx, uid, 0, bytes.NewReader([]byte("new!")), 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rel, err := u.Complete(ctx, uid, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete: %v", err)
|
||||||
|
}
|
||||||
|
if rel != "dup (1).cbz" {
|
||||||
|
t.Fatalf("collision must add suffix, got %q", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniquePathTraversalRejected(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
root := filepath.Join(u.booksDir, "lib")
|
||||||
|
if _, err := u.UniquePath(root, "../escape.cbz"); !errors.Is(err, os.ErrInvalid) {
|
||||||
|
t.Fatalf("traversal: want os.ErrInvalid got %v", err)
|
||||||
|
}
|
||||||
|
p, err := u.UniquePath(root, "ok.cbz")
|
||||||
|
if err != nil || p != filepath.Join(root, "ok.cbz") {
|
||||||
|
t.Fatalf("valid name: %q %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweepRemovesOnlyExpired(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
fresh := mustInit(t, u, 1, "fresh.cbz", 10, 10)
|
||||||
|
stale := mustInit(t, u, 1, "stale.cbz", 10, 10)
|
||||||
|
// 把 stale 会话 mtime 拨到 TTL 之前
|
||||||
|
old := time.Now().Add(-uploadSessTTL - time.Hour)
|
||||||
|
dir := u.uploadDir(stale)
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, "meta.json"), old, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chtimes(dir, old, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := u.Sweep(ctx); err != nil {
|
||||||
|
t.Fatalf("Sweep: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("stale session must be swept")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(u.uploadDir(fresh)); err != nil {
|
||||||
|
t.Fatalf("fresh session must survive: %v", err)
|
||||||
|
}
|
||||||
|
// 空目录/不存在 base 都不报错
|
||||||
|
os.RemoveAll(filepath.Join(u.booksDir, uploadSessionIn))
|
||||||
|
if err := u.Sweep(ctx); err != nil {
|
||||||
|
t.Fatalf("Sweep on missing base: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryID(t *testing.T) {
|
||||||
|
u := newU(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := mustInit(t, u, 42, "lib.cbz", 10, 10)
|
||||||
|
id, err := u.LibraryID(ctx, uid)
|
||||||
|
if err != nil || id != 42 {
|
||||||
|
t.Fatalf("LibraryID: %d %v", id, err)
|
||||||
|
}
|
||||||
|
if _, err := u.LibraryID(ctx, "deadbeefdeadbeefdeadbeefdeadbeef"); !errors.Is(err, ports.ErrNotFound) {
|
||||||
|
t.Fatalf("unknown uid: want ErrNotFound got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-36
@@ -8,6 +8,49 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added / 新增
|
||||||
|
|
||||||
|
- API: resumable chunked upload protocol for large files — `POST /api/libraries/:id/upload/init` (fingerprint-derived deterministic `uploadId`, rejects totals over `UPLOAD_MAX_MB` with `413 too_large`), `PUT /api/uploads/:uid/parts/:index` (parts ≤ 32MB), `GET /api/uploads/:uid` (received parts, for resume), `POST /api/uploads/:uid/complete` (assemble + atomic land, same path contract as single-POST upload). Sessions persist under `BOOKS_DIR/.uploads/` with a periodic sweep. `UPLOAD_MAX_MB` is now wired through both compose stacks/`.env`; `.env.example` sets 2048 and drops `NGINX_CLIENT_MAX_BODY_SIZE` to 32m (nginx only ever sees one chunk).
|
||||||
|
- API:新增大文件可续传分片上传协议——`POST /api/libraries/:id/upload/init`(按指纹派生确定性 `uploadId`,总量超 `UPLOAD_MAX_MB` 返回 `413 too_large`)、`PUT /api/uploads/:uid/parts/:index`(单片 ≤32MB)、`GET /api/uploads/:uid`(查询已传分片以续传)、`POST /api/uploads/:uid/complete`(拼接后原子落盘,返回与单发上传一致的 `path`)。会话存于 `BOOKS_DIR/.uploads/`,定期清理。`UPLOAD_MAX_MB` 已接入两份 compose/`.env`;`.env.example` 调至 2048 并将 `NGINX_CLIENT_MAX_BODY_SIZE` 降为 32m(nginx 只见单个分片)。
|
||||||
|
|
||||||
|
- API: per-user bookmarks — `GET/POST /api/books/:id/bookmarks` (locator+percent snapshot with optional ≤500-char note; list ordered by percent) and `PATCH/DELETE /api/bookmarks/:id`; not-yours uniformly 404. New `bookmarks` table keyed like progress, cleaned up with the user (no cascade on book delete, same precedent).
|
||||||
|
- API:新增按用户隔离的书签——`GET/POST /api/books/:id/bookmarks`(存当前 locator+percent,备注可选、≤500 字,列表按进度升序)与 `PATCH/DELETE /api/bookmarks/:id`;不属于自己的一律 404。新 `bookmarks` 表与进度同款定位键,随用户删除而清(删书不级联,沿用既有先例)。
|
||||||
|
|
||||||
|
- API: CBZ page indexing now skips macOS packaging junk (`__MACOSX/…` and `._*` AppleDouble files), which used to land in the page list as ~163-byte black "pages"; `GET /api/books/:id/pages` additionally returns `chapters:[{title,start}]` derived from the archive's folder structure (e.g. 第1話…), so per-folder comics expose their real organization.
|
||||||
|
- API:CBZ 页索引现会跳过 macOS 打包垃圾(`__MACOSX/…` 与 `._*` 资源叉文件),此前它们以 ~163 字节黑页混入页列表;`GET /api/books/:id/pages` 新增 `chapters:[{title,start}]`,按压缩包内目录结构(如 第1話…)给出真实章节。
|
||||||
|
|
||||||
|
- Tests: router contract test (`TestRouterContract`) pins the full route table — any added, removed or renamed route fails the test until the expectation is updated deliberately.
|
||||||
|
- 测试:新增路由契约测试(`TestRouterContract`),锁定全量路由表——任何路由的增删改名都会使该测试失败,必须显式更新预期。
|
||||||
|
|
||||||
|
- Tests: hand-written in-memory fakes (`internal/ports/portsfake`) cover all port interfaces, enabling handler unit tests with no PG or Redis. Error semantics mirror the real store (`pgx.ErrNoRows`, `store.ErrLastAdmin`, `*pgconn.PgError{Code:23505}`), so the fakes exercise the same 404/409/400 branches as the database.
|
||||||
|
- 测试:新增手写内存 fake(`internal/ports/portsfake`),覆盖全部 port 接口,使 handler 单测无需 PG/Redis 即可运行。错误语义与真实 store 一致(`pgx.ErrNoRows`、`store.ErrLastAdmin`、`*pgconn.PgError{Code:23505}`),因此 fake 走到的是与真库相同的 404/409/400 分支。
|
||||||
|
|
||||||
|
- CI workflow (`.github/workflows/ci.yml`) compatible with both GitHub Actions and Gitea Actions.
|
||||||
|
- CI 工作流(`.github/workflows/ci.yml`),兼容 GitHub Actions 和 Gitea Actions。
|
||||||
|
|
||||||
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Backend restructured around hexagonal ports: HTTP handlers now depend only on small consumer-side interfaces (`internal/ports`) instead of concrete `*store.Store` / `*redispkg.R` / `*scanner.Scanner`. Domain logic moved out of handlers into `internal/media` (cover/page extraction, page index cache, atomic cache writes) and `internal/upload` (chunked session lifecycle). `cmd/webui/main.go` is the composition root; `api.NewRouter` accepts pure interfaces.
|
||||||
|
- 后端按六边形端口重构:HTTP handler 现在只依赖 `internal/ports` 中的小口径消费端接口,不再直接持有 `*store.Store` / `*redispkg.R` / `*scanner.Scanner` 等具体类型。域逻辑从 handler 下沉到 `internal/media`(封面/页抽取、页索引缓存、缓存原子写)与 `internal/upload`(分片会话生命周期)。`cmd/webui/main.go` 作为装配根,`api.NewRouter` 只收接口。
|
||||||
|
|
||||||
|
- Upload session sweep moved off the request path onto the scanner's ticker cycle (B16), so `POST /upload/init` no longer pays for a directory walk.
|
||||||
|
- 上传会话清理从请求路径移到扫描器的定时周期(B16),`POST /upload/init` 不再顺带付出一次目录遍历的开销。
|
||||||
|
|
||||||
|
- Add ordered migration system with `schema_migrations` tracking and pg advisory lock for safe multi-replica schema evolution. Existing databases are auto-baselined. To change the schema, add a new `NNNN_description.sql` file under `backend/internal/db/migrations/`; never modify an already-applied file. No down migrations — rollback via backup, fix-forward.
|
||||||
|
- 新增有序迁移系统,通过 `schema_migrations` 表和 pg advisory lock 实现安全的多副本 schema 演进,已有数据库自动基线化。修改 schema 时在 `backend/internal/db/migrations/` 下新增 `NNNN_description.sql`,已应用的文件不可修改。不支持 down 迁移——回滚靠备份,fix-forward。
|
||||||
|
|
||||||
|
- Scanner: an image-list-less archive (`.zip`/`.cbz` with no page images — video packs, document dumps) is now recorded as `state=error` ("no images in archive") instead of registering as an empty CBZ with a blank reader.
|
||||||
|
- 扫描器:不含任何图片条目的 `.zip`/`.cbz`(视频包、文档包)现记录为 `state=error`("no images in archive"),不再注册成空 CBZ 留下一个白板阅读器。
|
||||||
|
|
||||||
|
- API: upload over `UPLOAD_MAX_MB` now returns `413 too_large` with the limit in the message; previously the size abort was misreported as `400 bad_request "multipart field 'file' required"`.
|
||||||
|
- API:超过 `UPLOAD_MAX_MB` 的上传现在返回 `413 too_large` 并在消息中带上限额;此前体积超限被误报为 `400 bad_request "multipart field 'file' required"`。
|
||||||
|
|
||||||
|
- API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/<sanitized name>` (no client-supplied paths, validated at creation).
|
||||||
|
- API:`POST /api/libraries` 只需 `{name}`;`root_path` 由服务端生成为 `BOOKS_DIR/<清洗后的库名>`(不再接受客户端指定路径,创建时即校验)。
|
||||||
|
|
||||||
|
- Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked.
|
||||||
|
- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
|
||||||
|
|
||||||
### Fixed / 修复
|
### Fixed / 修复
|
||||||
|
|
||||||
- Rate limiter `IncrWindow` uses atomic Lua script for INCR+EXPIRE, preventing permanent IP lockout on EXPIRE failure (B1).
|
- Rate limiter `IncrWindow` uses atomic Lua script for INCR+EXPIRE, preventing permanent IP lockout on EXPIRE failure (B1).
|
||||||
@@ -36,45 +79,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
- 上传处理器在 `O_EXCL` 冲突时重试,处理并发同名上传(B12)。
|
- 上传处理器在 `O_EXCL` 冲突时重试,处理并发同名上传(B12)。
|
||||||
- Bookmark methods check `err` before `RowsAffected` to avoid invalid reads on query failure (B13).
|
- Bookmark methods check `err` before `RowsAffected` to avoid invalid reads on query failure (B13).
|
||||||
- 书签方法先检查 `err` 再读 `RowsAffected`,避免查询失败时的无效读取(B13)。
|
- 书签方法先检查 `err` 再读 `RowsAffected`,避免查询失败时的无效读取(B13)。
|
||||||
|
|
||||||
### Changed / 变更
|
|
||||||
|
|
||||||
- Add ordered migration system with `schema_migrations` tracking and pg advisory lock for safe multi-replica schema evolution. Existing databases are auto-baselined. To change the schema, add a new `NNNN_description.sql` file under `backend/internal/db/migrations/`; never modify an already-applied file. No down migrations — rollback via backup, fix-forward.
|
|
||||||
- 新增有序迁移系统,通过 `schema_migrations` 表和 pg advisory lock 实现安全的多副本 schema 演进,已有数据库自动基线化。修改 schema 时在 `backend/internal/db/migrations/` 下新增 `NNNN_description.sql`,已应用的文件不可修改。不支持 down 迁移——回滚靠备份,fix-forward。
|
|
||||||
|
|
||||||
### Fixed / 修复
|
|
||||||
|
|
||||||
- Serve goroutine `log.Fatalf` replaced with channel-based shutdown to preserve graceful teardown (B14).
|
- Serve goroutine `log.Fatalf` replaced with channel-based shutdown to preserve graceful teardown (B14).
|
||||||
- 服务 goroutine 中的 `log.Fatalf` 改为 channel 通知方式,确保优雅关停流程不被绕过(B14)。
|
- 服务 goroutine 中的 `log.Fatalf` 改为 channel 通知方式,确保优雅关停流程不被绕过(B14)。
|
||||||
- `DATABASE_URL` is now validated at startup (required, parseable); empty `REDIS_URL` logs a clear "redis disabled" message (B15).
|
- `DATABASE_URL` is now validated at startup (required, parseable); empty `REDIS_URL` logs a clear "redis disabled" message (B15).
|
||||||
- `DATABASE_URL` 在启动时校验(必填、可解析);空 `REDIS_URL` 记录明确的 "redis disabled" 日志(B15)。
|
- `DATABASE_URL` 在启动时校验(必填、可解析);空 `REDIS_URL` 记录明确的 "redis disabled" 日志(B15)。
|
||||||
- `scripts/smoke.sh` aligned with current API contract, removed ignored `root_path` field (B17).
|
- `scripts/smoke.sh` aligned with current API contract, removed ignored `root_path` field (B17).
|
||||||
- `scripts/smoke.sh` 对齐当前 API 契约,移除被忽略的 `root_path` 字段(B17)。
|
- `scripts/smoke.sh` 对齐当前 API 契约,移除被忽略的 `root_path` 字段(B17)。
|
||||||
|
|
||||||
### Added / 新增
|
|
||||||
|
|
||||||
- CI workflow (`.github/workflows/ci.yml`) compatible with both GitHub Actions and Gitea Actions.
|
|
||||||
- CI 工作流(`.github/workflows/ci.yml`),兼容 GitHub Actions 和 Gitea Actions。
|
|
||||||
|
|
||||||
### Added / 新增
|
|
||||||
|
|
||||||
- API: per-user bookmarks — `GET/POST /api/books/:id/bookmarks` (locator+percent snapshot with optional ≤500-char note; list ordered by percent) and `PATCH/DELETE /api/bookmarks/:id`; not-yours uniformly 404. New `bookmarks` table keyed like progress, cleaned up with the user (no cascade on book delete, same precedent).
|
|
||||||
- API:新增按用户隔离的书签——`GET/POST /api/books/:id/bookmarks`(存当前 locator+percent,备注可选、≤500 字,列表按进度升序)与 `PATCH/DELETE /api/bookmarks/:id`;不属于自己的一律 404。新 `bookmarks` 表与进度同款定位键,随用户删除而清(删书不级联,沿用既有先例)。
|
|
||||||
|
|
||||||
- API: CBZ page indexing now skips macOS packaging junk (`__MACOSX/…` and `._*` AppleDouble files), which used to land in the page list as ~163-byte black "pages"; `GET /api/books/:id/pages` additionally returns `chapters:[{title,start}]` derived from the archive's folder structure (e.g. 第1話…), so per-folder comics expose their real organization.
|
|
||||||
- API:CBZ 页索引现会跳过 macOS 打包垃圾(`__MACOSX/…` 与 `._*` 资源叉文件),此前它们以 ~163 字节黑页混入页列表;`GET /api/books/:id/pages` 新增 `chapters:[{title,start}]`,按压缩包内目录结构(如 第1話…)给出真实章节。
|
|
||||||
|
|
||||||
- API: resumable chunked upload protocol for large files — `POST /api/libraries/:id/upload/init` (fingerprint-derived deterministic `uploadId`, rejects totals over `UPLOAD_MAX_MB` with `413 too_large`), `PUT /api/uploads/:uid/parts/:index` (parts ≤ 32MB), `GET /api/uploads/:uid` (received parts, for resume), `POST /api/uploads/:uid/complete` (assemble + atomic land, same path contract as single-POST upload). Sessions persist under `BOOKS_DIR/.uploads/` with 24h opportunistic sweep. `UPLOAD_MAX_MB` is now wired through both compose stacks/`.env`; `.env.example` sets 2048 and drops `NGINX_CLIENT_MAX_BODY_SIZE` to 32m (nginx only ever sees one chunk).
|
|
||||||
- API: 新增大文件可续传分片上传协议——`POST /api/libraries/:id/upload/init`(按指纹派生确定性 `uploadId`,总量超 `UPLOAD_MAX_MB` 返回 `413 too_large`)、`PUT /api/uploads/:uid/parts/:index`(单片 ≤32MB)、`GET /api/uploads/:uid`(查询已传分片以续传)、`POST /api/uploads/:uid/complete`(拼接后原子落盘,返回与单发上传一致的 `path`)。会话存于 `BOOKS_DIR/.uploads/`,超 24h 顺手清理。`UPLOAD_MAX_MB` 已接入两份 compose/`.env`;`.env.example` 调至 2048 并将 `NGINX_CLIENT_MAX_BODY_SIZE` 降为 32m(nginx 只见单个分片)。
|
|
||||||
|
|
||||||
### Changed / 变更
|
|
||||||
|
|
||||||
- Scanner: an image-list-less archive (`.zip`/`.cbz` with no page images — video packs, document dumps) is now recorded as `state=error` ("no images in archive") instead of registering as an empty CBZ with a blank reader.
|
|
||||||
- 扫描器:不含任何图片条目的 `.zip`/`.cbz`(视频包、文档包)现记录为 `state=error`("no images in archive"),不再注册成空 CBZ 留下一个白板阅读器。
|
|
||||||
|
|
||||||
- API: upload over `UPLOAD_MAX_MB` now returns `413 too_large` with the limit in the message; previously the size abort was misreported as `400 bad_request "multipart field 'file' required"`.
|
|
||||||
- API:超过 `UPLOAD_MAX_MB` 的上传现在返回 `413 too_large` 并在消息中带上限额;此前体积超限被误报为 `400 bad_request "multipart field 'file' required"`。
|
|
||||||
- API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/<sanitized name>` (no client-supplied paths, validated at creation).
|
|
||||||
- API:`POST /api/libraries` 只需 `{name}`;`root_path` 由服务端生成为 `BOOKS_DIR/<清洗后的库名>`(不再接受客户端指定路径,创建时即校验)。
|
|
||||||
- Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked.
|
|
||||||
- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
|
|
||||||
|
|||||||
+38
-7
@@ -8,8 +8,40 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
|
||||||
|
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
|
||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
|
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
|
||||||
|
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
|
||||||
|
|
||||||
|
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
|
||||||
|
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
|
||||||
|
|
||||||
|
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
|
||||||
|
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support.
|
||||||
|
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card.
|
||||||
|
- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
|
||||||
|
|
||||||
|
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
|
||||||
|
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
|
||||||
|
|
||||||
|
- E2E test suite (Playwright + axe-core) covering login→shelf→CBZ reading→bookmark→logout and an admin create-library/scan smoke, run against the dev compose stack via `npm run e2e` (manual pre-release gate); critical/serious a11y violations fail the run.
|
||||||
|
- 新增 e2e 测试套件(Playwright + axe-core):覆盖 登录→书架→CBZ 阅读→书签→退出 主流程与 admin 建库/扫描冒烟,针对 dev compose 栈以 `npm run e2e` 手动门禁运行;critical/serious 级 a11y 违规会使测试失败。
|
||||||
|
|
||||||
|
- Developer tooling: ESLint (flat config, typescript-eslint + react-hooks + jsx-a11y) and Prettier are now part of `npm run check` and CI; the whole codebase passes with zero eslint errors.
|
||||||
|
- 开发工具链:ESLint(flat config,typescript-eslint + react-hooks + jsx-a11y)与 Prettier 纳入 `npm run check` 和 CI;全库 0 eslint error。
|
||||||
|
|
||||||
|
- Lint toolchain note: TypeScript runs side-by-side (`typescript` = TS 6.0.2 JS API for tooling, `@typescript/native` = native 7.0.2 for `tsc`) because typescript-eslint does not support TS 7 yet; do not alias `typescript` to 7.x until typescript-eslint ships TS 7 support.
|
||||||
|
- 工具链备注:TypeScript 双轨并存(`typescript` = TS 6.0.2 JS API 供 lint 工具链,`@typescript/native` = native 7.0.2 提供 `tsc`),因 typescript-eslint 暂不支持 TS 7;待其支持前勿把 `typescript` 别名改回 7.x。
|
||||||
|
|
||||||
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
|
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
|
||||||
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
||||||
|
|
||||||
@@ -51,6 +83,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
|
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
|
||||||
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
|
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
|
||||||
|
|
||||||
|
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
|
||||||
|
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
|
||||||
|
|
||||||
### Removed / 移除
|
### Removed / 移除
|
||||||
|
|
||||||
- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading.
|
- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading.
|
||||||
@@ -58,6 +93,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Fixed / 修复
|
### Fixed / 修复
|
||||||
|
|
||||||
|
- Accessibility fixes: login screen muted text meets WCAG AA contrast in light theme (muted-foreground token darkened); the CBZ reading pane is now keyboard-scrollable (scroll container is focusable).
|
||||||
|
- 无障碍修复:登录页弱化文本在亮色主题下达到 WCAG AA 对比度(muted-foreground 色值加深);CBZ 阅读面板现在可用键盘滚动(滚动容器可聚焦)。
|
||||||
|
|
||||||
- CBZ reader no longer snaps to the top (or visibly bounces) when scrolling or dragging the scrollbar near the end of a long chapter: re-estimating every unmeasured page from each newly measured page made the scroll geometry oscillate on books with mixed page orientations (and the correction was computed from a stale anchor), so the estimator now learns once from the first measured page and then stays put; after a geometry change the viewport is re-anchored absolutely (keep the current page under the same screen spot) instead of by a delta, and while the scrollbar is being dragged the reader doesn't write scrollTop at all, leaving the browser in sole control.
|
- CBZ reader no longer snaps to the top (or visibly bounces) when scrolling or dragging the scrollbar near the end of a long chapter: re-estimating every unmeasured page from each newly measured page made the scroll geometry oscillate on books with mixed page orientations (and the correction was computed from a stale anchor), so the estimator now learns once from the first measured page and then stays put; after a geometry change the viewport is re-anchored absolutely (keep the current page under the same screen spot) instead of by a delta, and while the scrollbar is being dragged the reader doesn't write scrollTop at all, leaving the browser in sole control.
|
||||||
- CBZ 阅读器在长章节末尾滚动或拖滚动条时不再出现"到底后被重置到上方"的跳变:此前每量完一页就用它重估所有未量页的高度,横竖版混排的书里估高来回翻转、滚动几何剧烈呼吸,且补偿量基于已过期的 anchor 计算;现改为估高只从首个实测页学习一次、之后保持稳定,几何变化后按"当前页钉回原屏幕位置"绝对重锚(不再用增量补偿),拖拽滚动条期间阅读器完全不写 scrollTop,避免与浏览器抢滚动控制权。
|
- CBZ 阅读器在长章节末尾滚动或拖滚动条时不再出现"到底后被重置到上方"的跳变:此前每量完一页就用它重估所有未量页的高度,横竖版混排的书里估高来回翻转、滚动几何剧烈呼吸,且补偿量基于已过期的 anchor 计算;现改为估高只从首个实测页学习一次、之后保持稳定,几何变化后按"当前页钉回原屏幕位置"绝对重锚(不再用增量补偿),拖拽滚动条期间阅读器完全不写 scrollTop,避免与浏览器抢滚动控制权。
|
||||||
|
|
||||||
@@ -73,10 +111,3 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
- GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。
|
- GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。
|
||||||
- Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter.
|
- Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter.
|
||||||
- 阅读大 txt 不再卡死浏览器:正文按章节切分(识别「第X章」/Chapter X/序章等标题,无标记的按段落切成小节),每次只渲染一章,并提供章节目录选择与上一章/下一章导航;「第X卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。
|
- 阅读大 txt 不再卡死浏览器:正文按章节切分(识别「第X章」/Chapter X/序章等标题,无标记的按段落切成小节),每次只渲染一章,并提供章节目录选择与上一章/下一章导航;「第X卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。
|
||||||
|
|
||||||
### Changed / 变更
|
|
||||||
|
|
||||||
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
|
|
||||||
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
|
|
||||||
|
|
||||||
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
|
|
||||||
|
|||||||
+17
-1
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
|
|||||||
|
|
||||||
`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9).
|
`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9).
|
||||||
|
|
||||||
Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build).
|
Frontend gate: `cd frontend && npm run check` (tsc + eslint + prettier + vitest + vite build). E2E is a separate manual pre-release gate against the dev stack: `cd frontend && npm run e2e` (requires `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`, or source `deploy/.env`; one-off browser install inside the web container: `npx playwright install --with-deps chromium`). Bundle report: `npm run analyze` → `dist-stats/stats.html`.
|
||||||
|
|
||||||
## Old-volume migration (one-off, upgrading from the previous deploy layout)
|
## Old-volume migration (one-off, upgrading from the previous deploy layout)
|
||||||
|
|
||||||
@@ -78,6 +78,22 @@ Schema changes go through the ordered migration system in `backend/internal/db/m
|
|||||||
|
|
||||||
Local gate before each batch merge: `go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...` with dev PG+Redis running.
|
Local gate before each batch merge: `go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...` with dev PG+Redis running.
|
||||||
|
|
||||||
|
## Backend structure
|
||||||
|
|
||||||
|
The backend is organized around consumer-side port interfaces (hexagonal style):
|
||||||
|
|
||||||
|
- `cmd/webui` — binary entry point and composition root: `main.go` builds concrete implementations (`store.Store`, `redispkg.R`, `scanner.Scanner`, `media.M`, `upload.U`) and hands them to `api.NewRouter`, which only accepts the port interfaces.
|
||||||
|
- `cmd/webui/handlers` — HTTP layer: request binding, auth/authz, error → status mapping. No SQL, no archive/file logic.
|
||||||
|
- `internal/ports` — the small interfaces handlers depend on (`UserStore`, `LibraryStore`, `BookStore`, `ProgressStore`, `BookmarkStore`, `RateLimiter`, `Scanner`, `Media`, `UploadSessions`) plus shared sentinel errors. Interfaces live on the consumer side, implementations satisfy them.
|
||||||
|
- `internal/ports/portsfake` — hand-written in-memory fakes for every port, with error semantics mirroring the real store (`pgx.ErrNoRows`, `ErrLastAdmin`, PgError 23505). Handler unit tests run against these with no PG/Redis.
|
||||||
|
- `internal/media` — cover/page extraction, page-index cache (Redis-backed), atomic cache writes.
|
||||||
|
- `internal/upload` — chunked upload session lifecycle (init/part/status/complete/sweep).
|
||||||
|
- `internal/store` — all SQL, one place.
|
||||||
|
- `internal/scanner` — library walk, ingest (add/update/delete in one pass), sweep riding the scan ticker.
|
||||||
|
- `internal/bookfile` — shared file utilities (`SafeName`, `Contains`, `Hash`, `FormatFromExt`, cache dir layout).
|
||||||
|
|
||||||
|
Testing is two-tiered: integration tests hit a real PG+Redis via the full router (`handlers/*_test.go` with `setupAPI`), unit tests hit the same router with `portsfake` injected (`handlers/*_unit_test.go`). The route table itself is pinned by `TestRouterContract` in `cmd/webui/api`.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- Workflow: `.github/workflows/ci.yml` (standard GitHub Actions syntax, Gitea Actions compatible).
|
- Workflow: `.github/workflows/ci.yml` (standard GitHub Actions syntax, Gitea Actions compatible).
|
||||||
|
|||||||
+17
-1
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
|
|||||||
|
|
||||||
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
||||||
|
|
||||||
前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。
|
前端门槛:`cd frontend && npm run check`(tsc + eslint + prettier + vitest + vite build)。e2e 是独立的手动发布门禁,针对 dev 栈运行:`cd frontend && npm run e2e`(需 `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`,或 source `deploy/.env`;浏览器一次性安装于 web 容器内:`npx playwright install --with-deps chromium`)。体积报告:`npm run analyze` → `dist-stats/stats.html`。
|
||||||
|
|
||||||
## 旧卷迁移(一次性,升级自上一版部署)
|
## 旧卷迁移(一次性,升级自上一版部署)
|
||||||
|
|
||||||
@@ -78,6 +78,22 @@ Schema 变更通过 `backend/internal/db/migrations/` 中的有序迁移系统
|
|||||||
|
|
||||||
每批合入前的本地门禁:`go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...`(需启动 dev PG+Redis)。
|
每批合入前的本地门禁:`go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...`(需启动 dev PG+Redis)。
|
||||||
|
|
||||||
|
## 后端结构
|
||||||
|
|
||||||
|
后端按消费端接口组织(六边形风格):
|
||||||
|
|
||||||
|
- `cmd/webui` —— 二进制入口与装配根:`main.go` 构造具体实现(`store.Store`、`redispkg.R`、`scanner.Scanner`、`media.M`、`upload.U`)并交给 `api.NewRouter`,后者只接受 port 接口。
|
||||||
|
- `cmd/webui/handlers` —— HTTP 层:参数绑定、认证/鉴权、错误→状态码映射。没有 SQL,没有压缩包/文件逻辑。
|
||||||
|
- `internal/ports` —— handlers 依赖的小口径接口(`UserStore`、`LibraryStore`、`BookStore`、`ProgressStore`、`BookmarkStore`、`RateLimiter`、`Scanner`、`Media`、`UploadSessions`)与共享 sentinel 错误。接口定义在消费端,实现方来满足它们。
|
||||||
|
- `internal/ports/portsfake` —— 全部 port 的手写内存 fake,错误语义与真实 store 一致(`pgx.ErrNoRows`、`ErrLastAdmin`、PgError 23505)。handler 单测无需 PG/Redis。
|
||||||
|
- `internal/media` —— 封面/页抽取、页索引缓存(Redis)、缓存原子写。
|
||||||
|
- `internal/upload` —— 分片上传会话生命周期(init/part/status/complete/sweep)。
|
||||||
|
- `internal/store` —— 所有 SQL,集中一处。
|
||||||
|
- `internal/scanner` —— 书库遍历、ingest(增/改/删一趟完成)、会话清扫搭扫描 ticker 顺风车。
|
||||||
|
- `internal/bookfile` —— 共享文件工具(`SafeName`、`Contains`、`Hash`、`FormatFromExt`、缓存目录布局)。
|
||||||
|
|
||||||
|
测试分两层:集成测试走真实 PG+Redis、过完整 router(`handlers/*_test.go` 的 `setupAPI`);单测注入 `portsfake`、过同一个 router(`handlers/*_unit_test.go`)。路由表本身由 `cmd/webui/api` 的 `TestRouterContract` 钉死。
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- 工作流:`.github/workflows/ci.yml`(标准 GitHub Actions 语法,兼容 Gitea Actions)。
|
- 工作流:`.github/workflows/ci.yml`(标准 GitHub Actions 语法,兼容 Gitea Actions)。
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 前端 bundle 审视(2026-09) / Frontend bundle review
|
||||||
|
|
||||||
|
> 由 `cd frontend && npm run analyze` 生成 `dist-stats/stats.html` 后人工审视得出;本期只记录结论,不实施优化(spec ④ S4)。
|
||||||
|
> Generated from `dist-stats/stats.html` (`npm run analyze`); findings only — no optimization in this batch (spec ④ S4).
|
||||||
|
|
||||||
|
## 现状 / Current state
|
||||||
|
|
||||||
|
实测基线(`frontend/dist/`,rollup/rolldown 构建产物):总计约 2.6 MB(assets 2,609,869 B;`du -sh` 显示 2.6M,含 PWA precache 全部条目)。每 chunk 构成如下。**注意两套口径**:chunk 级 `stat` 为 minified 实测产物大小(与 `ls dist/assets` 一致);`备注` 列中的依赖级数字为**源码级(未压缩,rollup `renderedLength`)口径**,仅用于依赖之间的相对占比,**不可与 stat/minified 字节数相加或比对**。gzip/brotli 来自 visualizer 输出;总注:gzip 列为 visualizer 容器口径,与宿主机 `gzip -c | wc -c` 在多个文件上存在 ≤2% 的小数位差异(如 index 145.3 vs 143.8 kB、TextReader 27.0 vs 26.7 kB),属统计口径差异。
|
||||||
|
|
||||||
|
Measured baseline (`frontend/dist/`): ~2.6 MB total (assets 2,609,869 B; `du -sh` 2.6M). Composition per chunk below. **Two calibers apply**: chunk-level `stat` is the measured minified artifact size (matches `ls dist/assets`); dependency-level figures in the `notes` column are **source-level (uncompressed rollup `renderedLength`)** and are only for relative shares among dependencies — **they must not be summed with or compared against stat/minified byte counts**. gzip/brotli come from the visualizer output; note: the gzip column uses the visualizer's in-container figure, which differs from host-side `gzip -c | wc -c` by ≤2% on several files (e.g. index 145.3 vs 143.8 kB, TextReader 27.0 vs 26.7 kB) — a statistics-caliber difference.
|
||||||
|
|
||||||
|
| chunk | 入口/来源 | stat | gzip | brotli | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `index-CGr3zft8.js` | 主入口(`src/main.tsx`,shelf/login/admin + react-dom/router/query/radix) | 462.6 kB | 145.3 kB | 122.3 kB | 依赖级(源码级口径,相对占比):react-dom 459.8 + react-router 94.9 + tailwind-merge 56 + radix-ui 各子包共 ~131 + lucide-react(34 个图标) 17.3(gzip 10.1)+ (src) 83.9 kB |
|
||||||
|
| `pdf.worker-C0DQFlrB.js` | `PdfReader.tsx` worker(`pdfjs-dist/build/pdf.worker.mjs`,Vite worker 语法独立产物) | 1,189.3 kB | 367.0 kB | ~ | 独立文件,不进 index 主包 |
|
||||||
|
| `PdfReader-Bv6vuw8i.js` | `pages/Reader.tsx` → `lazy(() => import(...))` | 434.4 kB | 130.5 kB | 137.9 kB | pdfjs-dist 主库 764.3 kB(源码级口径,本 chunk 最大依赖;brotli 135.4 kB) |
|
||||||
|
| `src-BNcXNGhn.js` | 共享 chunk:EpubReader 的 `await import("epubjs")` 链 | 345.4 kB | 103.6 kB | 135.5 kB | 依赖级(源码级口径):epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB + marks-pane/path-webpack/lodash 等 |
|
||||||
|
| `TextReader-CmvYmFNB.js` | `lazy()`(txt 与 md 共用) | 79.3 kB | 27.0 kB | 30.1 kB | 依赖级(源码级口径):marked 53.7 + dompurify 59.7 kB(gzip 14.2/14.5 kB) |
|
||||||
|
| `CbzReader-BEFlX6jt.js` | `lazy()` | 12.6 kB | 5.1 kB | 6.2 kB | 仅 (src) 21.1 kB(源码级口径)里的渲染逻辑,jszip 共享 chunk 已含 |
|
||||||
|
| `useProgress-BHPrULZV.js` | `lib/useProgress`(多 reader 共享) | 7.0 kB | 2.8 kB | 4.7 kB | |
|
||||||
|
| `EpubReader-BIpid_pd.js` | `lazy()` | 3.1 kB | 1.5 kB | 1.5 kB | 仅薄壳;epubjs 全链在 `src-BNcXNGhn.js` |
|
||||||
|
| `workbox-window.prod.es5-Bd17z0YL.js` | vite-plugin-pwa(registerSW) | 5.7 kB | 2.2 kB | 2.3 kB | |
|
||||||
|
| `rolldown-runtime-Dd_uD5pT.js` | 运行时 | 1.1 kB | ~0.9 kB | 0.8 kB | index.html 里 `<link rel="modulepreload">` |
|
||||||
|
| `index-C4Rgqjhl.css` | Tailwind v4 产物 | 69.4 kB | 12.1 kB | ~ | |
|
||||||
|
|
||||||
|
- 五个阅读器(cbz/txt/md/pdf/epub)均已通过 `pages/Reader.tsx` 的 `lazy()` 按需加载:是(实测确认——`dist/assets` 有 Cbz/Pdf/Text/Epub 四个独立 reader chunk;txt/md 共用 TextReader 一个 chunk;pdf 另有独立 pdf.worker 文件)。
|
||||||
|
Yes (verified): `dist/assets` contains separate chunks for Cbz/Pdf/Text/Epub readers; txt and md share the TextReader chunk; pdf additionally has a separate pdf.worker file.
|
||||||
|
- PWA precache(`workbox.globPatterns`)当前包含哪些大文件:generateSW 报告 "precache 15 entries (2549.92 KiB)",实测把全部 11 个 JS/CSS 都打进 precache,15 条 = 11 JS/CSS + index.html + icon.svg×2(icon.svg 在清单中出现两次)+ manifest.webmanifest;其中最大的五个:`pdf.worker` 1,189.3 kB、`index` 主包 462.6 kB、`PdfReader` 434.4 kB、`src-BNcXNGhn.js`(epubjs 链)345.4 kB、`TextReader` 79.3 kB。`maximumFileSizeToCacheInBytes` 是 generateSW 构建配置项(`vite.config.ts` 未设置,走默认 2 MiB),非 `sw.js` 内字段——默认值下 1.19 MB 的 pdf.worker 未被排除、仍在 precache 内。
|
||||||
|
The precache (reported as "15 entries, 2549.92 KiB") includes all 11 JS/CSS files; the 15 entries = 11 JS/CSS + index.html + icon.svg×2 (icon.svg appears twice in the manifest) + manifest.webmanifest; the five largest are pdf.worker 1,189.3 kB, index 462.6 kB, PdfReader 434.4 kB, src-BNcXNGhn.js (epubjs chain) 345.4 kB, TextReader 79.3 kB. `maximumFileSizeToCacheInBytes` is a generateSW build-time option (not set in `vite.config.ts`, so the default 2 MiB applies), not a field inside `sw.js` — under the default, the 1.19 MB pdf.worker is not excluded and stays in the precache.
|
||||||
|
|
||||||
|
## 可优化点 / Optimization backlog
|
||||||
|
|
||||||
|
| # | 问题 | 证据(chunk/体积) | 建议动作 | 归属 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | PWA precache 把全站 JS 全量缓存:pdf.worker(1.19 MB)+PdfReader(434 kB)+epubjs 链(345 kB)+TextReader(79 kB) 均在首装/首启 precache 清单,未打开对应阅读器的用户也要下载这 ~2.05 MB | sw.js precache 15 entries(11 JS/CSS + html + icon.svg×2 + webmanifest,2,549.92 KiB);globPatterns `**/*.{js,css,html,svg,woff2}` 未排除 | 首装 precache 仅保留 index/css/主包,reader chunk 改 runtime caching 或自定义 precache 过滤 | 后续 spec |
|
||||||
|
| 2 | epubjs 依赖链以单体共享 chunk `src-BNcXNGhn.js`(345.4 kB) 存在,体积大且被 precache 全量缓存(见 #1):虽然拆分本身是正确的——实测该 chunk 仅被 `EpubReader-*.js` 动态 import 引用,index 主包无静态引用、无 modulepreload,首屏不加载——但打开 epub 书需一次性下载 345.4 kB(gzip 103.6 kB),且单 chunk 无法按 epubjs/jszip 细分 | `src-BNcXNGhn.js` 345.4 kB:epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB(依赖级,源码级口径);`grep import` 确认仅 EpubReader 动态引用 | 与 #1 的 offline 策略一并处理:reader chunk 改 runtime caching 后,此 chunk 仅在用户真正打开 epub 时下载;或评估 `advancedChunks` 按 epubjs/jszip 拆小 chunk 提升缓存粒度 | 后续 spec |
|
||||||
|
| 3 | react-dom 459.8 kB(gzip 87.1 kB)是 index 主包内最大的单一依赖(依赖级,源码级口径),无 vendor 拆分;brotli 后 71.4 kB 仍是首屏最大单块 | index chunk rendered 明细:react-dom 459.8 kB / react-router 94.9 kB / query-core 64.2 kB | 评估 `build.rollupOptions.output.manualChunks`(或 vite advancedChunks)拆 vendor,提升缓存命中(业务代码改动不会使 react-dom 失效) | 后续 spec |
|
||||||
|
| 4 | `lucide-react` 34 个按需 import 的图标最终以完整组件体形式进 index(17.3 kB stat / gzip 10.1 kB——压缩比 ~59%、节省 ~41%,低于典型 JS 的 ~70% 节省水平,可能与 treeshake 不足有关,需进一步确认);且每个 reader chunk 各自带少量 lucide 模块(CbzReader 1.0 kB、PdfReader 0.7 kB、useProgress 2.8 kB)导致重复 | index 内 lucide-react rendered=17.3 kB(gzip 10.1 kB) mods=34;各 reader chunk 再零散重复 | 核查 treeshake 行为(rolldown 下 `lucide-react` named import 是否保留死代码);必要时统一走本仓库 `components/icons.tsx` 自绘 SVG | 后续 spec |
|
||||||
|
| 5 | radix-ui 单包多模块:`radix-ui` 包内 Select/Menu/Dialog/DropdownMenu/AlertDialog 等 ~29 个子模块共 ~131 kB(其中 react-select 43.0 kB、react-menu 22.1 kB;另有 radix 的定位依赖 floating-ui 46.5 kB,独立包)进 index 主包 | index chunk 内 @radix-ui/* 合计 rendered ≈ 131 kB(gzip ≈ 30 kB,依赖级口径) | 非首屏必需的 Select/DropdownMenu 所在页面(如 Shelf 筛选、admin 页)可评估下沉到对应 route chunk;仅在使用处再 import | 后续 spec |
|
||||||
|
| 6 | marked+dompurify 只被 TextReader(md 格式) 用,却进了 txt 也复用的 TextReader chunk——纯 txt 用户也下载这两个库(依赖级合计 ~113 kB,源码级口径) | `TextReader-CmvYmFNB.js` 79.3 kB 内 marked 53.7 + dompurify 59.7 kB(依赖级,源码级口径) | md 渲染再拆一层:TextReader 内对 md 分支用 `await import("marked")`/`await import("dompurify")`,txt 分支零依赖 | 后续 spec |
|
||||||
|
|
||||||
|
## 结论 / Verdict
|
||||||
|
|
||||||
|
当前总体体积(首屏 gzip 约 145 kB + CSS 12 kB)对个人书库场景可以接受,五阅读器 lazy 拆分也确实生效(epubjs 链仅被 EpubReader 动态 import,首屏不加载),pdf.worker 已是独立文件不进主包。最优先的两项是 #1(precache 全量缓存 2.05 MB reader 代码,与 spec 的离线策略直接冲突)和 #2(epubjs 链 345 kB 单体 chunk 被 precache 全量缓存,与 #1 同根),建议在下一个 spec 批次(S5 之后的优化轮)落地;#3–#6 属于收尾打磨,可与 #1 同期或更晚处理。
|
||||||
|
|
||||||
|
Total size is acceptable for a personal library (first-load gzip ≈145 kB + 12 kB CSS), and all five readers are properly split via `lazy()` (the epubjs chain is only dynamically imported by EpubReader and never loads on first paint), with pdf.worker already a standalone file. The two top priorities are #1 (precache ships ~2.05 MB of reader code users may never open — conflicts with the offline spec) and #2 (the 345 kB monolithic epubjs-chain chunk is fully precached, same root cause as #1); both belong to the next optimization round after S5. Items #3–#6 are polish that can land together with #1 or later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 实测数据来源 / Measurement provenance
|
||||||
|
|
||||||
|
- `npm run analyze`(容器内 vite build + rollup-plugin-visualizer 7.1.1,gzip/brotli 选项开启)→ `frontend/dist-stats/stats.html`;chunk/依赖级 stat、gzip、brotli 数值用 node 脚本从 stats.html 内嵌 `nodeParts`/`nodeMetas` JSON 提取(`nodeParts[uid] = {renderedLength, gzipLength, brotliLength}`,`nodeMetas[uid].moduleParts` 给出模块归属 chunk)。
|
||||||
|
- 产物文件级数值:`ls -la frontend/dist/assets/`(与 stats.html 的 chunk 级 stat/minified 大小一致;依赖级 `renderedLength` 是另一套源码级口径,见上)。
|
||||||
|
- gzip 交叉核对:宿主机 `gzip -c <file> | wc -c`(index 143,763 / PdfReader 128,873 / src-BNcXNGhn 102,696 / TextReader 26,677 / pdf.worker 366,988 / CbzReader 5,061 / EpubReader 1,500 / useProgress 2,778 / index.css 12,113 字节——与 visualizer 值存在 ≤2% 小数位差异,见现状节总注)。
|
||||||
|
- precache:`grep url:"..." frontend/dist/sw.js` 列出 15 条目(11 个 JS/CSS + index.html + icon.svg×2 + manifest.webmanifest)+ 容器内 build 输出 "precache 15 entries (2549.92 KiB)"。
|
||||||
|
- dist 总计:`du -sh frontend/dist/` = 2.6M;assets 目录 `du -b` 求和 2,609,869 字节。
|
||||||
|
- `Reader.tsx` lazy() 现状与 `dist/assets` 中四 reader chunk + pdf.worker 独立文件直接核对。
|
||||||
|
|
||||||
|
Provenance: values come from (a) the JSON embedded in `dist-stats/stats.html` produced by `npm run analyze` (rollup-plugin-visualizer 7.1.1 with gzip+brotli), extracted with a node script over `nodeParts`/`nodeMetas`; (b) `ls -la frontend/dist/assets/`; (c) host-side `gzip -c | wc -c` cross-checks; (d) `sw.js` precache URL list and the build log line "precache 15 entries (2549.92 KiB)"; (e) `du -sh frontend/dist` (2.6M) and per-file `du -b` sum (2,609,869 B); (f) direct comparison of `Reader.tsx` `lazy()` sources with the four reader chunks and the standalone pdf.worker file in `dist/assets`.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
|||||||
|
# 前端工程质量(lint/format + 组件测试 + e2e/a11y + bundle 分析)设计 / Frontend engineering quality design
|
||||||
|
|
||||||
|
日期 2026-09-15。分支 `docs/frontend-quality-spec`(设计);实施分支另建(建议 `feat/frontend-quality`)。状态:已获用户批准(会话内确认)。
|
||||||
|
|
||||||
|
本 spec 是项目优化四个子项目中的 **④**(其余:② 阅读器改版、③ 功能增强;① 后端健壮性已合入 master,见 `2026-09-14-backend-hardening-design.md`)。④ 与 ② 存在排序耦合:本 spec 先落地 lint/测试/e2e 基建,② 改阅读器时即有规范与回归网可用;阅读器组件测试明确留给 ②。
|
||||||
|
|
||||||
|
## 目标 / Goal
|
||||||
|
|
||||||
|
1. 引入 ESLint + Prettier,全库一次到位 0 error,进入 `npm run check` 与 CI 门禁。
|
||||||
|
2. 建立组件测试基建(testing-library + jsdom),公共组件有少量样板测试;阅读器组件不在本期。
|
||||||
|
3. 建立 Playwright e2e 基建,覆盖登录/书架/阅读/书签主流程与 admin 冒烟,含关键页 axe a11y 扫描;发布前手动门禁。
|
||||||
|
4. 接入 bundle 分析工具并产出书面审视结论;不做实际优化。
|
||||||
|
|
||||||
|
非目标见文末「范围外」。
|
||||||
|
|
||||||
|
## 决策记录 / Decisions
|
||||||
|
|
||||||
|
- 五个方向(lint/format、组件测试、e2e、a11y、bundle 分析)**全部纳入**本期,单 spec、四批次依序落地(方案 A)。用户选定。否决:B(全部基建一次接通再集中修,中间态长、难定位)、C(拆两份 spec 后置 e2e/a11y/bundle,会削弱 ② 的回归安全网)。
|
||||||
|
- lint 工具选 **ESLint 9 flat config + Prettier**(独立格式化),否决 Biome(a11y 规则覆盖弱于 jsx-a11y)与 Biome+ESLint 混合(双工具维护成本)。用户选定。
|
||||||
|
- lint 严格度:**全库 0 error 一次到位**,不做 baseline 渐进。过噪规则显式降 warn 或关闭,且必须在 config 内注释理由。用户选定。
|
||||||
|
- 组件测试深度:**基建 + 少量样板**(2-3 个文件,验证基建可用),阅读器组件测试留给 ② 随新 chrome 一起写。用户选定。
|
||||||
|
- e2e 运行方式:**针对 dev compose 栈**(默认 `http://localhost:5173`),`npm run e2e` 为发布前手动门禁,不进 `npm run check`;CI 加独立 job,`workflow_dispatch` 触发。用户选定。否决:进 check(日常摩擦大)、mock 后端(测不到集成层、与真实后端漂移)。
|
||||||
|
- a11y 分层:**静态 jsx-a11y(批次 1 随 eslint 接通)+ e2e 关键页 axe 扫描(批次 3)**;不做组件测试层 axe 断言(与 e2e 扫描重叠)。用户选定。
|
||||||
|
- bundle 分析:**只接工具 + 出书面结论**(`docs/bundle-review.md`),优化动作不在本期。用户选定。
|
||||||
|
- 所有新依赖在 dev 容器内安装并提交 lockfile(AGENTS.md 约束);Playwright 浏览器二进制仅 dev 容器手动安装(文档化),不进 prod 镜像。
|
||||||
|
|
||||||
|
## S0 批次总则 / Batches
|
||||||
|
|
||||||
|
四批次依序实施、依序提交,每批独立可回滚:
|
||||||
|
|
||||||
|
- B1 lint/format → B2 组件测试基建 → B3 e2e + axe → B4 bundle 分析。
|
||||||
|
- 每批验收底线:`npm run check` 保持绿 + 该批新增命令可跑通。
|
||||||
|
- B1 最先,使后续批次新写的测试/e2e 代码天然符合 lint/format 规范。
|
||||||
|
- changelog:纯 `frontend/` 改动记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新版本在上);`docs/bundle-review.md` 与 README 改动随 B4 同批,不重复记入 `docs/CHANGELOG.md`。
|
||||||
|
|
||||||
|
## S1 批次 1:ESLint + Prettier / Lint & format
|
||||||
|
|
||||||
|
- 依赖(dev):`eslint`、`typescript-eslint`、`eslint-plugin-react-hooks`、`eslint-plugin-jsx-a11y`、`eslint-config-prettier`、`prettier`(`prettier-plugin-tailwindcss` 可选,接入与否在实施时以噪音程度定,接则 class 自动排序)。
|
||||||
|
- 配置:`frontend/eslint.config.js`(flat config),基线 = `typescript-eslint` recommended + react-hooks recommended + jsx-a11y recommended + `eslint-config-prettier` 收尾关闭格式冲突规则。按目录 override:`e2e/`(B3 产物)用 node 环境 globals、放宽 e2e 惯例规则;`test/`、`*.test.tsx` 允许测试专用 globals。
|
||||||
|
- Prettier:`frontend/.prettierrc`(对齐现有代码风格:2 空格、单引号——以实施时对存量代码 diff 最小化为准)+ `.prettierignore`(`dist/`、`node_modules/`、`package-lock.json`、`dist-stats/`)。
|
||||||
|
- scripts(`frontend/package.json`):`lint`(eslint .)、`lint:fix`、`format`(prettier --write .)、`format:check`;`check` 改为 `tsc --noEmit && eslint . && prettier --check . && vitest run && vite build`。
|
||||||
|
- 存量修复:全库 eslint error 清零;prettier 首次全量 format 单独成一个 commit(纯格式、无逻辑改动),便于 review 与回滚。
|
||||||
|
- 降 warn/关闭的规则须在 `eslint.config.js` 内逐条注释理由(例如 jsx-a11y `click-events-have-key-events` 对阅读器中央点击区可先 warn,② 改版时收严)。
|
||||||
|
- CI:frontend job 已跑 `npm run check`,lint 自动覆盖,无需改 workflow。
|
||||||
|
|
||||||
|
验收:`npm run check` 绿;`eslint .` 0 error;`prettier --check .` 0 diff。
|
||||||
|
|
||||||
|
## S2 批次 2:组件测试基建 / Component testing
|
||||||
|
|
||||||
|
- 依赖(dev):`jsdom`、`@testing-library/react`、`@testing-library/user-event`、`@testing-library/jest-dom`。
|
||||||
|
- 环境分层:存量 `frontend/test/*.test.ts` 纯逻辑测试保持 node 环境不动;组件测试用 `*.test.tsx` 后缀,vitest `environmentMatchGlobs`(或等价的项目级配置)将 `**/*.test.tsx` 路由到 jsdom;`vitest run` 一次全跑,不加新 script。
|
||||||
|
- setup:`frontend/test/setup.ts` —— 注册 jest-dom matchers、自动 cleanup、补 jsdom 缺口 polyfill(`ResizeObserver`、`Element.scrollIntoView`、pointer events),radix 组件在 jsdom 下的已知坑一次配好。
|
||||||
|
- 放置约定:组件测试与组件同目录 colocate(如 `src/components/ui/button.test.tsx`);纯逻辑测试维持 `frontend/test/`。约定写入 B1 的 eslint override 与本 spec 附录(S6)。
|
||||||
|
- 样板测试(3 个,目的是验证基建而非追覆盖率):
|
||||||
|
1. `src/components/ui/button.test.tsx`:渲染、variant class、点击回调;
|
||||||
|
2. `src/components/ui/dialog.test.tsx`:radix Dialog 在 jsdom 下开合、焦点管理(验证 polyfill 方案成立——② 的 chrome 迁移依赖 Sheet/Popover 等同类原语);
|
||||||
|
3. `src/components/theme-toggle.test.tsx`:真实业务组件,验证 localStorage/主题上下文可测。
|
||||||
|
- 阅读器组件(`src/readers/{Cbz,Epub,Pdf,Text}Reader.tsx`)本期不写组件测试;spec 显式约定 ② 改版时随新 chrome 补齐并作为行为回归网。
|
||||||
|
|
||||||
|
验收:`npm run check` 绿;3 个样板测试通过;存量 9 个逻辑测试不受影响。
|
||||||
|
|
||||||
|
## S3 批次 3:e2e + a11y 运行时扫描 / e2e & axe
|
||||||
|
|
||||||
|
- 依赖(dev):`@playwright/test`、`@axe-core/playwright`;浏览器仅装 chromium(dev 容器内 `npx playwright install --with-deps chromium`,命令写入 README 与本 spec)。
|
||||||
|
- 结构:`frontend/e2e/*.spec.ts` + `frontend/playwright.config.ts`;`baseURL` 取环境变量 `E2E_BASE_URL`,默认 `http://localhost:5173`(dev compose 栈 web 端口)。**不由 Playwright 管理 server 生命周期**——前提是手动起好 dev 栈(`deploy/docker-compose.dev.yml`),连不上直接失败并给出提示。
|
||||||
|
- 测试数据:全部走 HTTP API 准备(注册/登录、上传 `e2e/fixtures/` 下的小 CBZ 文件),不直接碰 PG/Redis;登录态用 Playwright `storageState` 复用,避免每条用例重复登录。
|
||||||
|
- 用例(2 个 spec,不追全量):
|
||||||
|
1. `e2e/auth-shelf.spec.ts`:登录 → 书架可见 → 进入 CBZ 书 → 翻页 → 加入书签 → 退出登录;
|
||||||
|
2. `e2e/admin-smoke.spec.ts`:admin 登录 → 建库 → 触发扫描 → 扫描入口/状态可见(冒烟级;扫描状态 API 属 ③,此处只验证现有行为)。
|
||||||
|
- axe 扫描:在关键页面(登录页、书架页、阅读器 chrome 展开态)插入 `@axe-core/playwright` 扫描步骤。门禁分级:
|
||||||
|
- critical/serious 违规 = 测试红;moderate/minor 仅输出报告不阻断;
|
||||||
|
- 存量 critical/serious 违规本批内修掉(多为属性级小修,记 `CHANGELOG_web.md`);确属将被 ② 重写的旧阅读器 chrome 的问题,允许在测试内逐条显式 allowlist 豁免,每条注释指向 ②。
|
||||||
|
- scripts:`e2e`(playwright test)、`e2e:ui`(playwright test --ui,开发调试用)。**不进 `npm run check`**。
|
||||||
|
- CI:`ci.yml` 新增独立 `e2e` job,仅 `workflow_dispatch` 触发,job 内自起 PG/Redis/api/web(service 容器 + build),runner 未就绪期间形同文档,就绪后零改动可用。
|
||||||
|
- eslint:`e2e/` 目录 override(node globals、测试文件规则)。
|
||||||
|
|
||||||
|
验收:dev 栈起后 `npm run e2e` 绿(2 spec + 关键页 axe 无未豁免的 critical/serious)。
|
||||||
|
|
||||||
|
## S4 批次 4:bundle 分析 / Bundle analysis
|
||||||
|
|
||||||
|
- 依赖(dev):`rollup-plugin-visualizer`;接入方式为 `vite.config.ts` 内按条件启用(如 `ANALYZE=1` 或 `--mode analyze`),产物输出到 `frontend/dist-stats/`(加入 `.gitignore` 与 `.prettierignore`,不进 dist 发布产物)。
|
||||||
|
- script:`analyze`。
|
||||||
|
- 审视结论文档:`docs/bundle-review.md`(英中双语,随批 commit)。内容:各大依赖(pdfjs-dist、epubjs、marked、dompurify、react-query、radix-ui、lucide-react 等)的体积占比、当前是否已懒加载/分包、可优化点清单;每个优化项标注建议归属(② 或后续 spec),**本期不动代码**。
|
||||||
|
- README:`docs/README.md` 与 `docs/README_zh.md` 同批补充新增命令(lint/format/analyze/e2e/e2e:ui)与本地门禁清单更新,保持双语内容等价(AGENTS.md 约束)。
|
||||||
|
|
||||||
|
验收:`npm run analyze` 可出报告;`docs/bundle-review.md` 提交;README 双语等价。
|
||||||
|
|
||||||
|
## S5 整体验收 / Definition of Done
|
||||||
|
|
||||||
|
1. `npm run check` 绿 = tsc + eslint 全库 0 error + prettier check + vitest(9 存量逻辑测试 + 3 新组件样板)+ build;
|
||||||
|
2. dev 栈起后 `npm run e2e` 绿;
|
||||||
|
3. `npm run analyze` 可出报告,`docs/bundle-review.md` 已提交;
|
||||||
|
4. `docs/CHANGELOG_web.md` 双语条目齐全;`docs/README.md` / `README_zh.md` 等价更新;
|
||||||
|
5. CI:frontend job 无需改动即覆盖 lint;新增 e2e job(workflow_dispatch)就绪;
|
||||||
|
6. lockfile 已提交;prod 镜像不新增任何本期依赖。
|
||||||
|
|
||||||
|
## S6 范围外 / Out of scope
|
||||||
|
|
||||||
|
- 阅读器组件的任何测试与改动(→ ② 阅读器改版);
|
||||||
|
- bundle 体积的实际优化动作(仅出结论清单);
|
||||||
|
- moderate/minor 级 a11y 修复(仅输出报告);组件测试层 axe 断言;
|
||||||
|
- Playwright 浏览器进 prod 镜像;e2e 进 `npm run check`;
|
||||||
|
- 后端任何改动(含扫描状态 API → ③);
|
||||||
|
- `components/ui.ts`(deprecated)与 `components/icons.tsx` 的清理(→ ②,本期 lint 仅按现状规则放行或豁免,不做删除)。
|
||||||
|
|
||||||
|
## 附录:测试放置与命名约定 / Appendix: test conventions
|
||||||
|
|
||||||
|
- 纯逻辑测试:`frontend/test/<module>.test.ts`(现状不变,node 环境);
|
||||||
|
- 组件测试:与被测组件同目录 `<name>.test.tsx`(jsdom 环境);
|
||||||
|
- e2e:`frontend/e2e/<flow>.spec.ts`,fixture 放 `frontend/e2e/fixtures/`;
|
||||||
|
- 测试命名:`describe` 用被测单元名,`it` 用行为描述句。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 阅读器改版(chrome 迁移 + 阅读新特性)设计 / Reader revamp design
|
||||||
|
|
||||||
|
日期 2026-09-16。分支 `docs/reader-revamp-spec`(设计);实施分支另建(建议 `feat/reader-revamp`)。状态:已获用户批准(会话内分节确认)。
|
||||||
|
|
||||||
|
本 spec 是项目优化四个子项目中的 **②**(① 后端健壮性、④ 前端工程质量已合入 master;③ 功能增强独立 spec)。边界来源:`2026-09-08-modern-redesign-shadcn-design.md` 的「Phase 2」节;基建依赖 ④ 已交付(eslint/组件测试/e2e/axe 回归网)。
|
||||||
|
|
||||||
|
## 目标 / Goal
|
||||||
|
|
||||||
|
1. 四阅读器(CBZ/Text/EPUB/PDF)chrome(头栏/工具条/目录/书签面板/主题卡)迁入 shadcn token + radix 原语,删除全部 `rd-*` 组件类与裸色类,删除 deprecated 的 `components/ui.ts` 与 `components/icons.tsx`。
|
||||||
|
2. 「纸/米/夜」阅读主题重映射为语义 token,并实现与全局 light/dark 的「默认联动 + 手动覆盖」规则。
|
||||||
|
3. 五个新特性:CBZ 横向翻页模式(长卷/单页/双页 + RTL)、txt/md 书内搜索、统一阅读设置面板(底部抽屉扩展)、阅读统计(localStorage + 可替换接口,书架顶部统计卡)、EPUB 排版设置(epubjs themes)。
|
||||||
|
4. 回收 ④ 留下的收严清单:eslint reader 豁免 override(4 条 jsx-a11y warn)删除回 error、axe `WAIVERS`(reader-chrome color-contrast)清空重扫、TxtView/CbzReader 的行级 disable 重估。
|
||||||
|
5. 顺带修复 ④ 终审遗留的 tsc 盲区(`e2e/`、`*.config.ts` 纳入类型检查)。
|
||||||
|
|
||||||
|
非目标见文末「范围外」。
|
||||||
|
|
||||||
|
## 决策记录 / Decisions
|
||||||
|
|
||||||
|
- 范围 = **chrome 迁移 + 新特性**(用户选定,否决「纯迁移」与「迁移为主+逐个勾选」)。五特性由用户从候选清单多选圈定;快捷键速查面板落 backlog。
|
||||||
|
- 实施策略 = **基建先行 + 按阅读器纵切**(方案 1,用户选定):B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。否决:两段式(迁移批过大、特性二次触碰)、特性先行(旧壳上写特性必返工)。
|
||||||
|
- 主题联动 = **默认联动 + 手动覆盖**(用户选定):`themeMode:"auto"` 时全局 dark→夜、light→纸;手动选卡记住手动值;设置面板提供「跟随全局」重置。否决:完全独立、完全同步。
|
||||||
|
- 统一设置面板形态 = **A 底部抽屉扩展**(用户经视觉伴侣选定):现有沉浸底部 sheet 长大,常驻行 + 按格式条件渲染的「更多设置」展开区。否决:右侧 Sheet、工具条 Popover。
|
||||||
|
- 书内搜索交互 = **导航抽屉第三 tab**(用户选定),否决顶部 overlay 搜索栏。
|
||||||
|
- 阅读统计 = **localStorage 记录 + `StatsStore` 可替换接口**(用户选定,③ 未来可换服务端实现);展示 = **书架顶部可折叠统计卡**(用户选定),书卡不加元素。
|
||||||
|
- CBZ 翻页 = **长卷/单页/双页 + RTL 独立开关**(用户选定);双页为手动配对,**不做自动跨页检测**;RTL 仅对页模式生效。
|
||||||
|
- EPUB 排版 = **字号/行距/边距 + 主题映射**(用户选定),不引入自定义字体族/字体文件。
|
||||||
|
- tsc 盲区修复**纳入 ②**(用户选定)作为 B0 的一个任务。
|
||||||
|
- **无 localStorage 兼容要求**(用户明确裁定):偏好统一收进单一 `reader-prefs` 新结构,旧独立键 `cbz-continuous`/`cbz-prefetch` 直接废弃(不读不写不迁移),老用户偏好重置为新默认值。服务端进度/书签 locator 格式本期不变(page/spread 与 `{page}` 天然兼容)。
|
||||||
|
- 行为保留约束(继承 Phase 2 边界):翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报的**行为逻辑**原样保留(只换壳 + 明确圈定的新特性);④ 期间的行为等价修复(渲染期重置等)不回退。
|
||||||
|
|
||||||
|
## S0 批次总则 / Batches
|
||||||
|
|
||||||
|
六批依序实施、依序提交:B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。
|
||||||
|
|
||||||
|
- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(既有 spec 不回归)+ 该批新增测试通过。
|
||||||
|
- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新在上);每批随批记。
|
||||||
|
- 新依赖(如有)容器内安装并提交 lockfile;本期预期**零新运行时依赖**(radix/shadcn/lucide 已在),仅可能新增 `@types/node`(dev)。
|
||||||
|
|
||||||
|
## S1 B0 横切基建 / Cross-cutting foundation
|
||||||
|
|
||||||
|
**阅读主题 token 与联动**
|
||||||
|
- 保留 `data-rd={paper|sepia|night}` 驱动机制;`--rd-bg/--rd-fg/--rd-link` 扩展为完整 token 组(补 `--rd-muted/--rd-accent/--rd-border`),供迁移后 chrome 使用。阅读面用 `--rd-*` token,抽屉/弹层容器用 shadcn 语义 token。
|
||||||
|
- `themeMode: "auto" | "paper" | "sepia" | "night"`,默认 `auto`(全局 dark→night、light→paper,实时跟随);手动选主题卡即切换为手动值;设置面板「跟随全局」按钮重置回 auto。
|
||||||
|
|
||||||
|
**readerPrefs 统一结构(无兼容负担)**
|
||||||
|
- 单一 localStorage 键 `reader-prefs`,新结构:`{ themeMode, text:{sizeIdx,lineIdx,marginIdx}, cbz:{mode:"strip"|"page"|"spread", rtl:boolean, continuous:boolean, prefetch:0|1|2|3}, epub:{sizeIdx,lineIdx,marginIdx} }`;宽容解析(缺字段/坏值回默认)。旧 `cbz-continuous`/`cbz-prefetch` 键废弃。
|
||||||
|
- `useReaderPrefs` 重构为该结构的读写 hook(现有消费点随各阅读器批次迁移)。
|
||||||
|
|
||||||
|
**统一设置抽屉(形态 A)**
|
||||||
|
- 新增 shadcn 原语文件 `ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`(基于已装的 `radix-ui` 包,零新 npm 依赖,模式对齐现有 `ui/*.tsx`)。
|
||||||
|
- `components/reader-settings.tsx`:底部 sheet(radix Sheet 原语 + shadcn token),常驻行 = 进度滑条(shadcn Slider)+ 主题卡(含「跟随」态)+ A−/A+;「更多设置」展开区按格式条件渲染:CBZ = 翻页模式/方向/连读/预读;Text = 行距/边距;EPUB = 字号/行距/边距;PDF = 无。
|
||||||
|
- 替代旧 `rd-sheet/rd-btn/rd-range` 与 `rd-slider.tsx`(组件在 B5 删除)。
|
||||||
|
|
||||||
|
**统计记录层**
|
||||||
|
- `lib/readingStats.ts`:`StatsStore` 接口(`record(seconds)` / `weekly()` / `streak()`)+ localStorage 实现(键 `reading-stats`,按日桶 `{ "YYYY-MM-DD": seconds }`)。
|
||||||
|
- 记录时机:Reader 页可见期间 30s 心跳 + 卸载 flush(`visibilitychange`/`pagehide`)。纯函数(日桶聚合/周合计/连续天数)独立导出供单测。
|
||||||
|
|
||||||
|
**tsc 盲区修复**
|
||||||
|
- `frontend/tsconfig.json` include 扩为 `["src", "test", "e2e", "*.config.ts"]` + dev 依赖 `@types/node`;存量 e2e/config 代码先过一遍 `tsc --noEmit` 清零。
|
||||||
|
|
||||||
|
## S2 B1 CBZ(迁移 + 翻页模式 + RTL)
|
||||||
|
|
||||||
|
**chrome 迁移**
|
||||||
|
- `pages/Reader.tsx` 头栏:`stone-950`/`btn`/`btnGhost`/`formatBadge` → shadcn token + `ui/button`(ghost)+ `ui/badge`;`IconArrowLeft` → lucide `ArrowLeft`。
|
||||||
|
- `components/reader-nav.tsx` → radix Sheet(side="left")+ shadcn Tabs 样式;`Bookmarks.tsx` 内容结构不变、`rd-btn` → `ui/button`。
|
||||||
|
- 底部工具条 → S1 的 `reader-settings`。
|
||||||
|
|
||||||
|
**翻页模式 + RTL**
|
||||||
|
- `strip`(长卷)= 现有渲染路径原样保留:章锁/连读/预读/滚动定位/章末卡片零改动。
|
||||||
|
- `page`/`spread` 新渲染路径:按页索引显示当前页;spread 手动配对 `[2i, 2i+1]`;翻页 = 索引步进,复用 `PageImg` 解码缓存预载相邻页;章锁在页模式 = 章末页后显示「本章完」卡片页,点击进下一章(锁章止步);`continuous` 在页模式 = 越过章末卡片自动进下一章;预读三模式通用。
|
||||||
|
- RTL 独立开关:右缘点击 = 上一页、键盘 ←/→ 映射翻转、spread 右页为先;仅 page/spread 生效。
|
||||||
|
- 点击区约定沿用:两侧翻页、中央收放 chrome。模式/方向切换保持当前页;书签 `{page}` locator 与进度上报(页索引→percent)兼容不变。
|
||||||
|
- 双页配对与 RTL 方向映射抽纯函数进 `lib/`(单测)。
|
||||||
|
|
||||||
|
**测试**:配对/方向纯函数单测;模式切换组件测试;e2e 扩展单页模式翻页 + RTL 冒烟。CbzReader 迁移后其 eslint warn 豁免与 axe WAIVERS 应自然消除(B5 验证)。
|
||||||
|
|
||||||
|
## S3 B2 Text(迁移 + 搜索 + 排版)
|
||||||
|
|
||||||
|
**chrome 迁移**:阅读面保留 `rd-surface`/`data-rd` token 机制(用 S1 扩展后变量);顶栏/章节选择器(含卷分组)/底部栏迁 shadcn + radix;主题卡/A−A+ 收进 `reader-settings`;书签抽屉复用 B1 的 `reader-nav`。分章/分卷/编码回退/进度恢复逻辑原样保留;TxtView 的 set-state disable(④ 遗留)迁移时重估:能以渲染期重置修复则修,否则保留注释并登记 B5 清单。
|
||||||
|
|
||||||
|
**书内搜索(导航抽屉第三 tab,仅 Text)**
|
||||||
|
- 输入 250ms 防抖;对内存章节文本线性扫描(大小写不敏感),全局结果上限 500;结果行 = 章节标题 + snippet(命中 ±~20 字符,`<mark>` 高亮)。
|
||||||
|
- 点击结果 → 复用章节跳转定位到章、滚动至命中处短暂高亮;不产生书签;locator 格式不变。
|
||||||
|
- 扫描/snippet/高亮切分抽纯函数进 `lib/search.ts`(单测)。
|
||||||
|
|
||||||
|
**排版设置**:行距五档(1.2/1.4/1.6/1.8/2.0)+ 边距三档(窄/中/宽 → max-width),进 `reader-settings`,持久化 `reader-prefs.text`。
|
||||||
|
|
||||||
|
**测试**:`lib/search.ts` 单测;搜索 tab 组件测试;新增 `e2e/fixtures/e2e-sample.txt` + e2e 搜索冒烟(输入→结果→跳转断言)。
|
||||||
|
|
||||||
|
## S4 B3 EPUB(迁移 + 排版设置)
|
||||||
|
|
||||||
|
- chrome 迁移:顶栏/「更多阅读选项」dropdown → shadcn + radix DropdownMenu/Sheet;书签抽屉复用 `reader-nav`;CFI 进度/书签原样保留。
|
||||||
|
- 排版:epubjs `rendition.themes` API——字号(复用 `FONT_SIZES`)/行距/边距 register 为 overrides;「纸/米/夜」映射 EPUB 背景/文字色,按 `themeMode`(含 auto 联动)`themes.select`;持久化 `reader-prefs.epub`。
|
||||||
|
- **测试限制(如实记录)**:epubjs 渲染在 iframe 内,组件测试不可行;headless 稳定性差,本期不加 EPUB e2e/fixture。验收走手工清单:开书 → 改字号/行距/边距/主题各一次 → 视觉生效 → 进度恢复。
|
||||||
|
|
||||||
|
## S5 B4 PDF(迁移)+ 书架统计卡
|
||||||
|
|
||||||
|
- PDF:pdfjs 渲染/页码定位/书签/进度原样保留;顶栏/工具条 chrome → shadcn + radix;pdf.worker 配置不动;无新增排版设置。
|
||||||
|
- 统计卡:`Shelf.tsx` 筛选栏上方可折叠卡(折叠态记忆 localStorage)——「本周阅读 X 小时 Y 分 · 连续 N 天」,展开显示近 7 日按日柱状图(纯 div 高度百分比,不引图表库);数据来自 `StatsStore.weekly()/streak()`;书卡不加元素。
|
||||||
|
- 测试:统计纯函数单测(B0 已列);统计卡组件测试用 fake `StatsStore` 注入;e2e 仅断言卡片存在(不断言时间敏感数值)。
|
||||||
|
|
||||||
|
## S6 B5 清理与收严 / Cleanup & tightening
|
||||||
|
|
||||||
|
- 删除 `components/ui.ts`(使用点先迁 `ui/button`/`ui/badge`)与 `components/icons.tsx`(→ lucide)。
|
||||||
|
- `index.css` 删除全部 `rd-*` 组件类(rd-btn/rd-sheet/rd-range/rd-row/rd-highlight/rd-divider 等)与阅读器裸色类(stone-* 等);`--rd-*` token 变量与 `data-rd` 机制保留。验收:`grep -rn 'rd-' src/ --include='*.tsx'` 零命中(`--rd-*`/`data-rd` 除外)。
|
||||||
|
- 收严清单回收:① `eslint.config.js` 删除 reader 豁免 override(4 条 jsx-a11y warn 回 error),迁移后代码必须真达标;② `e2e/helpers/axe.ts` 的 `WAIVERS` 清空,reader 相关页重扫零 critical/serious;③ TxtView/CbzReader 行级 disable 重估(修复或留带理由注释,AuthContext 的 disable 与阅读器无关、按 ④ 结论保留)。
|
||||||
|
- 测试收口:设置抽屉组件测试(改字号→断言正文 style);e2e 补「设置面板冒烟」。
|
||||||
|
- changelog:每批用户可见条目已在各批记;B5 补 Changed 条目(旧样式类删除/主题联动)。
|
||||||
|
|
||||||
|
## S7 验收 / Definition of Done
|
||||||
|
|
||||||
|
1. `npm run check` 绿(含 tsc 新覆盖 e2e/config);`npm run e2e` 绿(扩展后全部 spec)。
|
||||||
|
2. 手工走查清单:四阅读器 × 三阅读主题(+auto 联动)× 375/768/1280 三宽 × light/dark;翻页/书签/进度恢复/沉浸收放行为与迁移前一致(新特性除外)。
|
||||||
|
3. `ui.ts`/`icons.tsx` 已删;rd-* 组件类 grep 零命中;eslint reader override 与 axe WAIVERS 已移除。
|
||||||
|
4. changelog(双语)与 lockfile 齐;prod 镜像无新依赖。
|
||||||
|
|
||||||
|
## S8 范围外 / Out of scope
|
||||||
|
|
||||||
|
- EPUB 书内搜索;CBZ 自动跨页检测;快捷键速查面板(backlog);
|
||||||
|
- 阅读统计服务端化(③ 候选,本期仅留 `StatsStore` 接口缝);
|
||||||
|
- PDF 排版设置;自定义字体族/字体文件;i18n;
|
||||||
|
- 后端任何改动(API/迁移/契约零变化);
|
||||||
|
- 书卡元素变更;书架布局改版;
|
||||||
|
- localStorage 旧偏好兼容(用户裁定不需要)。
|
||||||
|
|
||||||
|
## 附录:backlog(本期未选,记录备查)
|
||||||
|
|
||||||
|
- 快捷键速查面板(阅读页 `?` 弹出);
|
||||||
|
- CBZ 自动跨页检测(宽>高单显、相邻竖图配对);
|
||||||
|
- EPUB 书内搜索(epubjs search API);
|
||||||
|
- 阅读统计服务端化 + 跨设备(③ 联动);
|
||||||
|
- ④ 遗留:`--legacy-peer-deps` 已落 `.npmrc`(闭合);vitest `src/**/*.test.ts`(非 tsx)静默缝隙——B0 顺手在 vitest.config 注释或 include 收紧。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dist
|
||||||
|
dist-stats
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -5,5 +5,11 @@
|
|||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
|
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { creds } from "./helpers/api";
|
||||||
|
|
||||||
|
test("admin:建库 → 触发扫描(冒烟)", async ({ page }) => {
|
||||||
|
const { user, pass } = creds();
|
||||||
|
const libName = `e2e-admin-${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill("#login-user", user);
|
||||||
|
await page.fill("#login-pass", pass);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
await page.goto("/admin/libraries");
|
||||||
|
await page.getByPlaceholder(/库名/).fill(libName);
|
||||||
|
await page.getByRole("button", { name: "建库" }).click();
|
||||||
|
await expect(page.getByText(`库「${libName}」已创建`)).toBeVisible();
|
||||||
|
// name cell 加 exact:路径 cell(服务端目录 /data/books/<库名>)的名称也含库名,子串匹配会 strict 双命中。
|
||||||
|
await expect(page.getByRole("cell", { name: libName, exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const row = page.getByRole("row", { name: new RegExp(libName) });
|
||||||
|
await row.getByRole("button", { name: /扫描/ }).click();
|
||||||
|
await expect(page.getByText("扫描已触发")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
import { adminApiRetry, creds, ensureSampleBook } from "./helpers/api";
|
||||||
|
import { scanA11y } from "./helpers/axe";
|
||||||
|
|
||||||
|
let admin: APIRequestContext;
|
||||||
|
let bookId: number;
|
||||||
|
let title: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
admin = await adminApiRetry();
|
||||||
|
bookId = await ensureSampleBook(admin, "e2e");
|
||||||
|
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
|
||||||
|
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await admin?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ page }) => {
|
||||||
|
const { user, pass } = creds();
|
||||||
|
|
||||||
|
// 登录页 + axe
|
||||||
|
await page.goto("/login");
|
||||||
|
await scanA11y(page, "login");
|
||||||
|
await page.fill("#login-user", user);
|
||||||
|
await page.fill("#login-pass", pass);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
// 书架 + axe;样本卡可见并点进阅读器。
|
||||||
|
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
||||||
|
const card = page.locator(".group > button", { hasText: title });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
|
||||||
|
await scanA11y(page, "shelf");
|
||||||
|
await card.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
||||||
|
|
||||||
|
// 阅读器(chrome 默认展开)+ axe
|
||||||
|
const reader = page.getByLabel("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
|
||||||
|
await expect(reader).toBeVisible();
|
||||||
|
await expect(page.getByText("1/3")).toBeVisible();
|
||||||
|
await scanA11y(page, "reader-chrome");
|
||||||
|
|
||||||
|
// 右缘点击翻屏 → 页码前进。
|
||||||
|
// 单次 scrollBy 一屏未必把视口中心推过第 1 页底(页高 > 一屏),故按实况重试点击直到 2/3。
|
||||||
|
const box = await reader.boundingBox();
|
||||||
|
if (!box) throw new Error("阅读器容器无 boundingBox");
|
||||||
|
await expect(async () => {
|
||||||
|
await page.mouse.click(box.x + box.width * 0.9, box.y + box.height / 2);
|
||||||
|
await expect(page.getByText("2/3")).toBeVisible();
|
||||||
|
}).toPass({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// 页模式 + RTL 冒烟:开设置面板 → 单页 → 右缘翻页 → 右开本(RTL) 方向翻转 → 回长卷。
|
||||||
|
await page.getByRole("button", { name: "更多阅读设置" }).click();
|
||||||
|
await page.getByRole("button", { name: "单页" }).click();
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible(); // 模式切换保持当前页:此时在第 2 页
|
||||||
|
// 回到第 1 页:左缘点击
|
||||||
|
const box2 = await reader.boundingBox();
|
||||||
|
if (!box2) throw new Error("阅读器容器无 boundingBox");
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.1, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
|
||||||
|
// 右缘点击 → 第 2 页
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^2\/3 · /)).toBeVisible();
|
||||||
|
// RTL:右缘点击变后退
|
||||||
|
await page.getByRole("button", { name: "右开本(RTL)" }).click();
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
|
||||||
|
// 回长卷
|
||||||
|
await page.getByRole("button", { name: "长卷", exact: true }).click();
|
||||||
|
await expect(page.getByText("1/3")).toBeVisible();
|
||||||
|
|
||||||
|
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
|
||||||
|
await page.getByRole("button", { name: "导航", exact: true }).click();
|
||||||
|
const nav = page.locator("#reader-nav");
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
await nav.getByRole("button", { name: "加书签" }).click();
|
||||||
|
await expect(page.getByText("已加书签")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "关闭" }).click();
|
||||||
|
|
||||||
|
// 回书架 → 退出登录
|
||||||
|
await page.getByRole("link", { name: "书架" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
|
||||||
|
await page.getByText("退出登录").click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
第一章 样本
|
||||||
|
|
||||||
|
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
|
||||||
|
第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离,
|
||||||
|
因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及
|
||||||
|
当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用
|
||||||
|
明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按
|
||||||
|
章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是
|
||||||
|
搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云
|
||||||
|
收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合
|
||||||
|
端到端测试用的文本样本,昭然有效,勿改内容。
|
||||||
|
|
||||||
|
第二章 落雨
|
||||||
|
|
||||||
|
雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的
|
||||||
|
命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器
|
||||||
|
当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜
|
||||||
|
面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距,
|
||||||
|
本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读
|
||||||
|
主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中
|
||||||
|
列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行
|
||||||
|
至结尾,此章同样只包含一次标记词,合计两处命中。
|
||||||
|
|
||||||
|
第三章 风止
|
||||||
|
|
||||||
|
wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。
|
||||||
|
结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过
|
||||||
|
无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3,
|
||||||
|
即第二章标记已存。样本至此结束。
|
||||||
Binary file not shown.
@@ -0,0 +1,91 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { request, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
|
||||||
|
export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url));
|
||||||
|
export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url));
|
||||||
|
|
||||||
|
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
|
||||||
|
export function creds(): { user: string; pass: string } {
|
||||||
|
const user = process.env.E2E_ADMIN_USER ?? process.env.ADMIN_USER;
|
||||||
|
const pass = process.env.E2E_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD;
|
||||||
|
if (!user || !pass) {
|
||||||
|
throw new Error(
|
||||||
|
"缺少管理员凭据:export E2E_ADMIN_USER/E2E_ADMIN_PASSWORD," +
|
||||||
|
"或 `set -a; source ../deploy/.env; set +a` 后重跑",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { user, pass };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminApi(): Promise<APIRequestContext> {
|
||||||
|
const { user, pass } = creds();
|
||||||
|
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: user, password: pass }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`admin 登录失败:HTTP ${res.status}(dev 栈起了吗?凭据对吗?)`);
|
||||||
|
const { token } = (await res.json()) as { token: string };
|
||||||
|
return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** adminApi 登录带 429 退避重试:三 spec 并发 worker 的登录总数可能顶到后端 1min/5 次限流门,等窗口即可;仅消耗等待预算,不改用例语义 */
|
||||||
|
export async function adminApiRetry(): Promise<APIRequestContext> {
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
try {
|
||||||
|
return await adminApi();
|
||||||
|
} catch (e) {
|
||||||
|
if (i > 20 || !(e instanceof Error) || !e.message.includes("HTTP 429")) throw e;
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise<number> {
|
||||||
|
const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[];
|
||||||
|
const hit = libs.find((l) => l.name === name);
|
||||||
|
if (hit) return hit.id;
|
||||||
|
const created = (await (await ctx.post("/api/libraries", { data: { name } })).json()) as { id: number };
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number; title: string } | null> {
|
||||||
|
const res = await ctx.get(`/api/books?q=${encodeURIComponent(q)}`);
|
||||||
|
const books = (await res.json()) as { id: number; title: string }[];
|
||||||
|
return books.find((b) => b.title.includes(q)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
|
||||||
|
export async function ensureBook(
|
||||||
|
ctx: APIRequestContext,
|
||||||
|
libName: string,
|
||||||
|
fileName: string,
|
||||||
|
absPath: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const title = fileName.replace(/\.[a-z]+$/i, "");
|
||||||
|
const existing = await findBook(ctx, title);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const libId = await ensureLibrary(ctx, libName);
|
||||||
|
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
|
||||||
|
multipart: {
|
||||||
|
file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`);
|
||||||
|
const scan = await ctx.post(`/api/libraries/${libId}/scan`);
|
||||||
|
if (!scan.ok()) throw new Error(`触发扫描失败:HTTP ${scan.status()}`);
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
for (;;) {
|
||||||
|
const hit = await findBook(ctx, title);
|
||||||
|
if (hit) return hit.id;
|
||||||
|
if (Date.now() > deadline) throw new Error(`扫描后 30s 内未找到书「${title}」`);
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */
|
||||||
|
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
||||||
|
return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
interface Waiver {
|
||||||
|
page: string;
|
||||||
|
rule: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* critical/serious 违规的逐条豁免清单(④ 基建):
|
||||||
|
* ② 阅读器改版后旧 chrome 重写完毕,本期清空;新增豁免必须写明 reason 并控制在一次性条件。
|
||||||
|
*/
|
||||||
|
const WAIVERS: Waiver[] = [];
|
||||||
|
|
||||||
|
export async function scanA11y(page: Page, name: string): Promise<void> {
|
||||||
|
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Promise.race([
|
||||||
|
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
||||||
|
new Promise((r) => setTimeout(r, 2000)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[axe:${name}]`,
|
||||||
|
JSON.stringify(
|
||||||
|
results.violations.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
nodes: v.nodes.map((n) => n.target.join(" ")),
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocking = results.violations.filter(
|
||||||
|
(v) =>
|
||||||
|
(v.impact === "critical" || v.impact === "serious") && !WAIVERS.some((w) => w.page === name && w.rule === v.id),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
blocking.map((v) => ({ id: v.id, impact: v.impact })),
|
||||||
|
`${name} 存在未豁免的 critical/serious a11y 违规`,
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
import { adminApiRetry, creds, ensureBook, FIXTURE_TXT } from "./helpers/api";
|
||||||
|
import { scanA11y } from "./helpers/axe";
|
||||||
|
|
||||||
|
let admin: APIRequestContext;
|
||||||
|
let bookId: number;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
admin = await adminApiRetry();
|
||||||
|
// 文件名避免与 CBZ 样本书同名:fixture 用 e2e-sample-txt.txt(title=e2e-sample-txt)
|
||||||
|
bookId = await ensureBook(admin, "e2e", "e2e-sample-txt.txt", FIXTURE_TXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await admin?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("登录 → txt 阅读 → 全书搜索 → 跳转", async ({ page }) => {
|
||||||
|
const { user, pass } = creds();
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill("#login-user", user);
|
||||||
|
await page.fill("#login-pass", pass);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
const card = page.locator(".group > button", { hasText: "e2e-sample-txt" });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await card.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
||||||
|
|
||||||
|
// 导航抽屉 → 搜索 tab
|
||||||
|
await page.getByRole("button", { name: "导航", exact: true }).click();
|
||||||
|
const nav = page.locator("#reader-nav");
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
await nav.getByRole("tab", { name: "搜索" }).click();
|
||||||
|
await nav.getByRole("textbox", { name: "搜索全书" }).fill("zebra-xc42");
|
||||||
|
// 大小写不敏感:第一章 2 处 + 第二章 1 处 = 3 行
|
||||||
|
await expect(nav.getByRole("listitem")).toHaveCount(3, { timeout: 10_000 });
|
||||||
|
expect(await nav.locator("mark").count()).toBe(3); // <mark> 高亮
|
||||||
|
|
||||||
|
// 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 2/3,抽屉关闭
|
||||||
|
await nav.getByRole("button").filter({ hasText: "第二章" }).first().click();
|
||||||
|
await expect(page.getByText("2/3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(nav).not.toBeVisible();
|
||||||
|
|
||||||
|
await scanA11y(page, "reader-chrome");
|
||||||
|
|
||||||
|
// 设置面板冒烟:字号/行距即时生效 + 主题卡联动阅读面(data-rd 受控)
|
||||||
|
const pre = page.locator(".rd-surface pre");
|
||||||
|
await expect(pre).toHaveCSS("font-size", "20px");
|
||||||
|
await page.getByRole("button", { name: "更多阅读设置" }).click();
|
||||||
|
await page.getByRole("button", { name: "加大字号" }).click();
|
||||||
|
await expect(pre).toHaveCSS("font-size", "22px");
|
||||||
|
await page.getByRole("button", { name: "1.8", exact: true }).click();
|
||||||
|
await expect(pre).toHaveAttribute("style", /line-height: 1\.8/);
|
||||||
|
await page.getByRole("button", { name: "夜色主题" }).click();
|
||||||
|
await expect(page.locator(".rd-surface").first()).toHaveAttribute("data-rd", "night");
|
||||||
|
|
||||||
|
// 退出
|
||||||
|
await page.getByRole("link", { name: "书架" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
|
||||||
|
await page.getByText("退出登录").click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist/**", "dist-stats/**", "node_modules/**", "playwright-report/**", "test-results/**"] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
// react-hooks 插件的 flat 导出名随版本而异(recommended-latest / configs.flat.recommended),
|
||||||
|
// 以安装版本实际导出为准,用 npx eslint --print-config src/main.tsx 验证规则已生效
|
||||||
|
reactHooks.configs.flat["recommended-latest"],
|
||||||
|
jsxA11y.flatConfigs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["e2e/**/*.ts"],
|
||||||
|
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
|
||||||
|
languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } },
|
||||||
|
rules: { "@typescript-eslint/no-explicit-any": "off" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.{ts,tsx}", "test/**/*.ts"],
|
||||||
|
// 测试代码允许 any 以贴合测试惯例(mock 数据、运行时探测)
|
||||||
|
rules: { "@typescript-eslint/no-explicit-any": "off" },
|
||||||
|
},
|
||||||
|
prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则
|
||||||
|
);
|
||||||
Generated
+2815
-30
File diff suppressed because it is too large
Load Diff
+26
-2
@@ -6,9 +6,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:ui": "playwright test --ui",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"analyze": "ANALYZE=1 vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "tsc --noEmit && vitest run && vite build"
|
"check": "tsc --noEmit && eslint . && prettier --check . && vitest run && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -32,12 +39,29 @@
|
|||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/dom": "^10.4.2",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@typescript/native": "npm:typescript@^7.0.2",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"eslint": "^10.10.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"rollup-plugin-visualizer": "^7.1.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.2.2",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "e2e",
|
||||||
|
timeout: 60_000,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:5173",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
+16
-2
@@ -25,8 +25,22 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Shelf />} />
|
<Route path="/" element={<Shelf />} />
|
||||||
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
|
<Route
|
||||||
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminUsers />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/libraries"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminLibraries />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/book/:id"
|
path="/book/:id"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const mem = new Map<string, string>();
|
|||||||
const hasLS = typeof globalThis.localStorage?.setItem === "function";
|
const hasLS = typeof globalThis.localStorage?.setItem === "function";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
|
return hasLS ? localStorage.getItem(TOKEN_KEY) : (mem.get(TOKEN_KEY) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(t: string | null): void {
|
export function setToken(t: string | null): void {
|
||||||
@@ -181,8 +181,7 @@ export const api = {
|
|||||||
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
|
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
|
||||||
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
|
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
|
||||||
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
|
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
|
||||||
patchBookmark: (id: number, note: string) =>
|
patchBookmark: (id: number, note: string) => apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
|
||||||
apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
|
|
||||||
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
|
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
|
||||||
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && meQ.isError) {
|
if (token && meQ.isError) {
|
||||||
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
|
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- 对「me 查询失败」异步结果的清理反应,与持久化 token 需同帧回收;改渲染期重置/事件驱动会改变鉴权失败时序,保守保留
|
||||||
setTok(null);
|
setTok(null);
|
||||||
}
|
}
|
||||||
}, [token, meQ.isError]);
|
}, [token, meQ.isError]);
|
||||||
@@ -77,7 +78,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||||||
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
|
if (!ready || !user) return <div className="text-muted-foreground grid h-full place-items-center">加载中…</div>;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "./Toaster";
|
import { toast } from "./Toaster";
|
||||||
|
|
||||||
type Locator = Record<string, unknown>;
|
type Locator = Record<string, unknown>;
|
||||||
@@ -55,8 +57,8 @@ export function BookmarksTab({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<input
|
<Input
|
||||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
|
className="min-w-0 flex-1"
|
||||||
placeholder="备注(可空)"
|
placeholder="备注(可空)"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={note}
|
value={note}
|
||||||
@@ -65,26 +67,28 @@ export function BookmarksTab({
|
|||||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex shrink-0 items-center gap-1"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
aria-label="加书签"
|
aria-label="加书签"
|
||||||
disabled={add.isPending}
|
disabled={add.isPending}
|
||||||
onClick={() => add.mutate()}
|
onClick={() => add.mutate()}
|
||||||
>
|
>
|
||||||
<Plus size={14} /> 加
|
<Plus size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
{q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
|
||||||
{!q.isPending && items.length === 0 && (
|
{!q.isPending && items.length === 0 && (
|
||||||
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
|
||||||
)}
|
)}
|
||||||
{items.map((b) => (
|
{items.map((b) => (
|
||||||
<li key={b.id}>
|
<li key={b.id}>
|
||||||
{editId === b.id ? (
|
{editId === b.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<Input
|
||||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
|
className="min-w-0 flex-1"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={editVal}
|
value={editVal}
|
||||||
onChange={(e) => setEditVal(e.target.value)}
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
@@ -92,30 +96,40 @@ export function BookmarksTab({
|
|||||||
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex shrink-0 items-center"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
aria-label="保存备注"
|
aria-label="保存备注"
|
||||||
disabled={patch.isPending}
|
disabled={patch.isPending}
|
||||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="取消编辑"
|
||||||
|
onClick={() => setEditId(null)}
|
||||||
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="group flex items-center">
|
<div className="group flex items-center">
|
||||||
<button
|
<button
|
||||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
title={b.note || undefined}
|
title={b.note || undefined}
|
||||||
onClick={() => seek(b.locator)}
|
onClick={() => seek(b.locator)}
|
||||||
>
|
>
|
||||||
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
||||||
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="改备注"
|
aria-label="改备注"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditId(b.id);
|
setEditId(b.id);
|
||||||
@@ -123,14 +137,16 @@ export function BookmarksTab({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="删除书签"
|
aria-label="删除书签"
|
||||||
onClick={() => del.mutate(b.id)}
|
onClick={() => del.mutate(b.id)}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
import { btn, formatBadge } from "./ui";
|
import { FormatBadge } from "./format-badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import { TriangleAlert } from "lucide-react";
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
||||||
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
|
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({
|
||||||
|
key: "",
|
||||||
|
src: "",
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
const key = (url ?? "") + "#" + nonce;
|
const key = (url ?? "") + "#" + nonce;
|
||||||
|
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
|
||||||
|
// 替代原先 effect 内的同步 setState(级联渲染),行为等价——加载中同样呈现 src="" 的占位
|
||||||
|
const [prevKey, setPrevKey] = useState(key);
|
||||||
|
if (prevKey !== key) {
|
||||||
|
setPrevKey(key);
|
||||||
|
setState({ key, src: "", failed: false });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
let dead = false;
|
let dead = false;
|
||||||
const k = url + "#" + nonce;
|
const k = url + "#" + nonce;
|
||||||
setState({ key: k, src: "", failed: false });
|
|
||||||
fetchObjectUrl(url)
|
fetchObjectUrl(url)
|
||||||
.then((src) => !dead && setState({ key: k, src, failed: false }))
|
.then((src) => !dead && setState({ key: k, src, failed: false }))
|
||||||
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
|
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
|
||||||
@@ -30,16 +41,21 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
|
|||||||
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
||||||
if (!book.cover_url || failed) {
|
if (!book.cover_url || failed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}>
|
||||||
className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}
|
<span className="select-none font-serif text-4xl text-muted-foreground">
|
||||||
>
|
{Array.from(book.title)[0] ?? "?"}
|
||||||
<span className="select-none font-serif text-4xl text-muted-foreground">{Array.from(book.title)[0] ?? "?"}</span>
|
</span>
|
||||||
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute right-1.5 bottom-1.5" />
|
||||||
{failed && (
|
{failed && (
|
||||||
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 gap-1"
|
||||||
|
onClick={retry}
|
||||||
|
>
|
||||||
<TriangleAlert className="size-4" />
|
<TriangleAlert className="size-4" />
|
||||||
封面重试
|
封面重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
|
|||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||||||
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
|
<p className="mb-4 max-w-md text-sm break-all text-muted-foreground">{String(this.state.err)}</p>
|
||||||
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
|
<button
|
||||||
|
className="rounded-md bg-secondary px-3 py-1.5 text-sm"
|
||||||
|
onClick={() => this.setState({ err: null })}
|
||||||
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function Toaster() {
|
|||||||
t.kind === "ok" ? "border-border" : "border-destructive/40"
|
t.kind === "ok" ? "border-border" : "border-destructive/40"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />}
|
{t.kind === "ok" ? (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-accent" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
{t.text}
|
{t.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
|||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import { useAuth } from "@/auth/AuthContext";
|
import { useAuth } from "@/auth/AuthContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -12,7 +19,11 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
function Brand() {
|
function Brand() {
|
||||||
return (
|
return (
|
||||||
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
|
<Link
|
||||||
|
to="/"
|
||||||
|
aria-label="BookLib 首页"
|
||||||
|
className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||||
|
>
|
||||||
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
|
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
|
||||||
<BookMarked className="size-4" />
|
<BookMarked className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
@@ -57,7 +68,10 @@ export function AppShell() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
{user?.username}
|
||||||
|
{isAdmin ? " · admin" : ""}
|
||||||
|
</DropdownMenuLabel>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -88,14 +102,25 @@ export function AppShell() {
|
|||||||
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
|
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
|
||||||
<Brand />
|
<Brand />
|
||||||
<nav aria-label="主导航" className="flex flex-col gap-1">
|
<nav aria-label="主导航" className="flex flex-col gap-1">
|
||||||
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
|
<NavItem
|
||||||
|
to="/"
|
||||||
|
icon={<BookOpen className="size-4 shrink-0" />}
|
||||||
|
label="书架"
|
||||||
|
active={loc.pathname === "/" && !libParam}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
{libs.length > 0 && (
|
{libs.length > 0 && (
|
||||||
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
|
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
|
||||||
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
|
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{libs.map((l) => (
|
{libs.map((l) => (
|
||||||
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} />
|
<NavItem
|
||||||
|
key={l.id}
|
||||||
|
to={`/?lib=${l.id}`}
|
||||||
|
icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />}
|
||||||
|
label={l.name}
|
||||||
|
active={loc.pathname === "/" && libParam === String(l.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +145,10 @@ export function AppShell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* 移动端底 Tab */}
|
{/* 移动端底 Tab */}
|
||||||
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden">
|
<nav
|
||||||
|
aria-label="底部导航"
|
||||||
|
className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"
|
||||||
|
>
|
||||||
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
|
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +158,16 @@ export function AppShell() {
|
|||||||
|
|
||||||
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
|
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}>
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground",
|
||||||
|
isActive && "text-accent-strong",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Format } from "@/api/types";
|
||||||
|
|
||||||
|
const CLASSES: Record<Format, string> = {
|
||||||
|
cbz: "border-transparent bg-orange-500/15 text-orange-900 ring-0 dark:text-orange-300",
|
||||||
|
pdf: "border-transparent bg-rose-500/15 text-rose-900 ring-0 dark:text-rose-300",
|
||||||
|
epub: "border-transparent bg-sky-500/15 text-sky-900 ring-0 dark:text-sky-300",
|
||||||
|
txt: "border-transparent bg-emerald-500/15 text-emerald-900 ring-0 dark:text-emerald-300",
|
||||||
|
md: "border-transparent bg-teal-500/15 text-teal-900 ring-0 dark:text-teal-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
|
||||||
|
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] font-semibold uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
{fmt}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
|
|
||||||
export function RdSlider({
|
|
||||||
label,
|
|
||||||
ariaLabel,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
ariaLabel: string;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-2.5 pb-1.5 pt-2">
|
|
||||||
<div className="mb-1 text-sm">{label}</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={1}
|
|
||||||
value={value}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="rd-range"
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
|
|
||||||
{Array.from({ length: max - min + 1 }, (_, i) => (
|
|
||||||
<span key={i}>{min + i}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,51 @@
|
|||||||
import { Bookmark, List, X } from "lucide-react";
|
import { Bookmark, List, Search } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export type NavTab = "toc" | "bm";
|
export type NavTab = "toc" | "bm" | "search";
|
||||||
|
|
||||||
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
|
/** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
|
||||||
export function ReaderNav({
|
export function ReaderNav({
|
||||||
tab,
|
tab,
|
||||||
onTab,
|
onTab,
|
||||||
onClose,
|
onClose,
|
||||||
toc,
|
toc,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
search,
|
||||||
}: {
|
}: {
|
||||||
tab: NavTab;
|
tab: NavTab;
|
||||||
onTab?: (t: NavTab) => void;
|
onTab?: (t: NavTab) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
toc?: ReactNode;
|
toc?: ReactNode;
|
||||||
bookmarks: ReactNode;
|
bookmarks: ReactNode;
|
||||||
|
search?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
|
||||||
|
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
|
||||||
|
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
|
||||||
|
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
|
||||||
|
];
|
||||||
|
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
|
||||||
return (
|
return (
|
||||||
<>
|
<Sheet open onOpenChange={(o) => !o && onClose()}>
|
||||||
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
|
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
|
||||||
<nav
|
<SheetHeader className="sr-only">
|
||||||
id="reader-nav"
|
<SheetTitle>导航</SheetTitle>
|
||||||
aria-label="导航"
|
</SheetHeader>
|
||||||
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
|
<div id="reader-nav" className="flex h-full flex-col">
|
||||||
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
<Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
|
||||||
>
|
<TabsList className="m-2 shrink-0 self-start">
|
||||||
<div
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
role={toc != null ? "tablist" : undefined}
|
<TabsTrigger key={id} value={id} className="gap-1.5">
|
||||||
aria-label="导航选项卡"
|
<Icon size={14} /> {label}
|
||||||
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
|
</TabsTrigger>
|
||||||
>
|
))}
|
||||||
{toc != null ? (
|
</TabsList>
|
||||||
<>
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
|
||||||
<button
|
</Tabs>
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === "toc"}
|
|
||||||
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
|
|
||||||
onClick={() => onTab?.("toc")}
|
|
||||||
>
|
|
||||||
<List size={14} /> 目录
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === "bm"}
|
|
||||||
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
|
|
||||||
onClick={() => onTab?.("bm")}
|
|
||||||
>
|
|
||||||
<Bookmark size={14} /> 书签
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
|
|
||||||
<Bookmark size={14} /> 书签
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
|
</SheetContent>
|
||||||
</nav>
|
</Sheet>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
import { ReaderSettings } from "@/components/reader-settings";
|
||||||
|
import { ThemeProvider } from "@/components/theme";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSettings(props = {}) {
|
||||||
|
return render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ReaderSettings
|
||||||
|
slider={{ value: 2, max: 10, onChange: () => {}, ariaLabel: "页码跳转" }}
|
||||||
|
position="3/12 · 25%"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReaderSettings", () => {
|
||||||
|
it("常驻行渲染:滑条 aria-label 与位置文本", () => {
|
||||||
|
renderSettings();
|
||||||
|
expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3/12 · 25%")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("主题卡点击 → localStorage 的 themeMode 变手动值", () => {
|
||||||
|
renderSettings();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "夜色主题" }));
|
||||||
|
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
|
||||||
|
expect(stored.themeMode).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("「跟随全局」按钮存在且点击回 auto", () => {
|
||||||
|
renderSettings();
|
||||||
|
const follow = screen.getByRole("button", { name: "跟随全局" });
|
||||||
|
expect(follow).toBeInTheDocument();
|
||||||
|
expect(follow).toHaveAttribute("aria-pressed", "true");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "米色主题" }));
|
||||||
|
expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "跟随全局" }));
|
||||||
|
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
|
||||||
|
expect(stored.themeMode).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showFont=false 时无 A−/A+", () => {
|
||||||
|
renderSettings({ showFont: false, onFont: () => {} });
|
||||||
|
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未传 onFont 时 A−/A+ 隐藏(showFont 也无字号钮)", () => {
|
||||||
|
renderSettings({ showFont: true });
|
||||||
|
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
|
||||||
|
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
|
||||||
|
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByTestId("extra")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByTestId("extra")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => {
|
||||||
|
let got = 0;
|
||||||
|
renderSettings({ showFont: true, onFont: (d: number) => (got = d) });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
|
||||||
|
expect(got).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { useGlobalDark } from "@/components/theme";
|
||||||
|
import {
|
||||||
|
RD_THEMES,
|
||||||
|
RD_THEME_LABEL,
|
||||||
|
FONT_SIZES,
|
||||||
|
resolveRdTheme,
|
||||||
|
useReaderPrefs,
|
||||||
|
type RdTheme,
|
||||||
|
type ReaderPrefs,
|
||||||
|
} from "@/lib/readerPrefs";
|
||||||
|
|
||||||
|
export interface ReaderSettingsProps {
|
||||||
|
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数;EPUB 无全局分数故可省 */
|
||||||
|
slider?: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
|
||||||
|
/** 位置文本,如 "3/12 · 25%" */
|
||||||
|
position?: string;
|
||||||
|
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
|
||||||
|
left?: ReactNode;
|
||||||
|
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
|
||||||
|
showFont?: boolean;
|
||||||
|
/** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */
|
||||||
|
onFont?: (d: number) => void;
|
||||||
|
/** 当前字号档位(用于 A−/A+ 边界禁用);不传按文本框位 */
|
||||||
|
fontIdx?: number;
|
||||||
|
/** 受控主题:传入时用调用方状态,保证阅读面 data-rd 与设置栏同步(不传则组件内自持) */
|
||||||
|
themeMode?: ReaderPrefs["themeMode"];
|
||||||
|
onThemeMode?: (m: ReaderPrefs["themeMode"]) => void;
|
||||||
|
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
|
||||||
|
extra?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */
|
||||||
|
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||||
|
|
||||||
|
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
|
||||||
|
export function ReaderSettings({
|
||||||
|
slider,
|
||||||
|
position,
|
||||||
|
left,
|
||||||
|
showFont = false,
|
||||||
|
onFont,
|
||||||
|
fontIdx,
|
||||||
|
themeMode,
|
||||||
|
onThemeMode,
|
||||||
|
extra,
|
||||||
|
}: ReaderSettingsProps) {
|
||||||
|
const internal = useReaderPrefs();
|
||||||
|
// 主题受控:调用方给状态时用调用方(阅读面 data-rd 联动);否则退回组件内自持(独立使用/测试)
|
||||||
|
const mode = themeMode ?? internal.prefs.themeMode;
|
||||||
|
const setMode = onThemeMode ?? internal.setThemeMode;
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(mode, globalDark);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border-t bg-background/95 text-foreground backdrop-blur pt-1.5 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||||
|
data-rd={rd}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 px-2.5">
|
||||||
|
{left && <div className="flex items-center gap-1.5">{left}</div>}
|
||||||
|
{position && (
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground" aria-live="polite">
|
||||||
|
{position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 whitespace-nowrap" role="group" aria-label="阅读主题">
|
||||||
|
{RD_THEMES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
||||||
|
aria-pressed={mode === t}
|
||||||
|
onClick={() => setMode(t)}
|
||||||
|
className="h-5 w-5 shrink-0 rounded-full transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: SWATCH[t],
|
||||||
|
boxShadow:
|
||||||
|
rd === t
|
||||||
|
? "0 0 0 2px var(--rd-accent)"
|
||||||
|
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-pressed={mode === "auto"}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => setMode("auto")}
|
||||||
|
>
|
||||||
|
跟随全局
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
|
{showFont && onFont && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="减小字号"
|
||||||
|
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === 0}
|
||||||
|
onClick={() => onFont(-1)}
|
||||||
|
>
|
||||||
|
A−
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="加大字号"
|
||||||
|
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === FONT_SIZES.length - 1}
|
||||||
|
onClick={() => onFont(1)}
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{extra && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="更多阅读设置"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{slider && (
|
||||||
|
<Slider
|
||||||
|
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
|
||||||
|
value={[slider.value]}
|
||||||
|
max={slider.max}
|
||||||
|
onValueChange={([v]) => slider.onChange(v)}
|
||||||
|
aria-label={slider.ariaLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置行通用骨架:label + 右侧控件组。 */
|
||||||
|
export function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="flex items-center gap-1">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
|
import type { StatsStore } from "@/lib/readingStats";
|
||||||
|
|
||||||
|
function fakeStore(): StatsStore {
|
||||||
|
const today = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
const y = new Date(today);
|
||||||
|
y.setDate(y.getDate() - 1);
|
||||||
|
const buckets: Record<string, number> = { [key(today)]: 900, [key(y)]: 300 };
|
||||||
|
return {
|
||||||
|
record() {},
|
||||||
|
weekly() {
|
||||||
|
const perDay = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
const k = key(d);
|
||||||
|
return { day: k, seconds: buckets[k] ?? 0 };
|
||||||
|
});
|
||||||
|
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
|
||||||
|
},
|
||||||
|
streak() {
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("ReadingStatsCard", () => {
|
||||||
|
it("本周文案含时长与连续天数", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
expect(screen.getByText(/本周阅读/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/20 分钟/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/连续 2 天/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
|
||||||
|
const btn = screen.getByRole("button", { name: "折叠统计" });
|
||||||
|
expect(screen.queryAllByRole("img").length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开统计" }));
|
||||||
|
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("注入的 store 无连续记录时不显示连续段", () => {
|
||||||
|
const s = fakeStore();
|
||||||
|
render(<ReadingStatsCard store={{ ...s, streak: () => 0 }} />);
|
||||||
|
expect(screen.queryByText(/连续/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("7 根柱(含 0 值日)", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" });
|
||||||
|
expect(chart.children.length).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
|
||||||
|
|
||||||
|
const FOLD_KEY = "stats-card-folded";
|
||||||
|
|
||||||
|
function fmtDur(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.round((s % 3600) / 60);
|
||||||
|
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
|
||||||
|
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
|
||||||
|
const stats = useMemo(() => store ?? createLocalStats(), [store]);
|
||||||
|
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
|
||||||
|
const { perDay, total } = stats.weekly();
|
||||||
|
const streak = stats.streak();
|
||||||
|
const max = Math.max(1, ...perDay.map((d) => d.seconds));
|
||||||
|
return (
|
||||||
|
<section aria-label="阅读统计" className="bg-card text-card-foreground px-4 py-3 text-sm rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="text-muted-foreground size-4" />
|
||||||
|
<span>
|
||||||
|
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
|
||||||
|
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="ml-auto"
|
||||||
|
aria-label={folded ? "展开统计" : "折叠统计"}
|
||||||
|
onClick={() => {
|
||||||
|
const v = !folded;
|
||||||
|
setFolded(v);
|
||||||
|
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!folded && (
|
||||||
|
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
|
||||||
|
{perDay.map((d) => (
|
||||||
|
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="bg-primary/70 w-full rounded-sm"
|
||||||
|
style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ThemeProvider } from "./theme";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
|
describe("ThemeToggle", () => {
|
||||||
|
it("switches theme via dropdown, persists to localStorage, toggles dark class", async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "切换主题" }));
|
||||||
|
await userEvent.click(await screen.findByText("深色"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(localStorage.getItem("ui.theme")).toBe("dark"));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Check, Monitor, Moon, Sun } from "lucide-react";
|
import { Check, Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { useTheme, type Theme } from "./theme";
|
import { useTheme, type Theme } from "./theme";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
|
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
|
||||||
@@ -15,7 +20,13 @@ export function ThemeToggle() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label="切换主题">
|
<Button variant="ghost" size="icon" aria-label="切换主题">
|
||||||
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
|
{theme === "dark" ? (
|
||||||
|
<Moon className="size-4" />
|
||||||
|
) : theme === "light" ? (
|
||||||
|
<Sun className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="size-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|||||||
@@ -45,3 +45,19 @@ export function useTheme() {
|
|||||||
if (!v) throw new Error("useTheme outside ThemeProvider");
|
if (!v) throw new Error("useTheme outside ThemeProvider");
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MQ = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */
|
||||||
|
export function useGlobalDark(): boolean {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
const mq = matchMedia(MQ);
|
||||||
|
const on = () => setSysDark(mq.matches);
|
||||||
|
mq.addEventListener("change", on);
|
||||||
|
return () => mq.removeEventListener("change", on);
|
||||||
|
}, [theme]);
|
||||||
|
return theme === "dark" || (theme === "system" && sysDark);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Format } from "../api/types";
|
|
||||||
|
|
||||||
export const btn =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
|
|
||||||
export const btnGhost =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
|
||||||
|
|
||||||
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
|
|
||||||
export const FORMAT_BADGE: Record<Format, string> = {
|
|
||||||
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
|
|
||||||
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
|
|
||||||
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
|
|
||||||
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
|
|
||||||
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatBadge(fmt: Format, extra = ""): string {
|
|
||||||
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,34 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function AlertDialog({
|
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
...props
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
...props
|
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
...props
|
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogContent({
|
function AlertDialogContent({
|
||||||
@@ -49,7 +36,7 @@ function AlertDialogContent({
|
|||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
size?: "default" | "sm"
|
size?: "default" | "sm";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
@@ -59,60 +46,51 @@ function AlertDialogContent({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogHeader({
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogFooter({
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTitle({
|
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogDescription({
|
function AlertDialogDescription({
|
||||||
@@ -125,23 +103,20 @@ function AlertDialogDescription({
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogMedia({
|
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-media"
|
data-slot="alert-dialog-media"
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogAction({
|
function AlertDialogAction({
|
||||||
@@ -153,13 +128,9 @@ function AlertDialogAction({
|
|||||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
return (
|
return (
|
||||||
<Button variant={variant} size={size} asChild>
|
<Button variant={variant} size={size} asChild>
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
|
||||||
data-slot="alert-dialog-action"
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogCancel({
|
function AlertDialogCancel({
|
||||||
@@ -171,13 +142,9 @@ function AlertDialogCancel({
|
|||||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
return (
|
return (
|
||||||
<Button variant={variant} size={size} asChild>
|
<Button variant={variant} size={size} asChild>
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
|
||||||
data-slot="alert-dialog-cancel"
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -193,4 +160,4 @@ export {
|
|||||||
AlertDialogPortal,
|
AlertDialogPortal,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
@@ -9,12 +9,10 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
outline:
|
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
||||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
},
|
},
|
||||||
@@ -22,26 +20,20 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
const Comp = asChild ? Slot.Root : "span";
|
||||||
const Comp = asChild ? Slot.Root : "span"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
data-slot="badge"
|
);
|
||||||
data-variant={variant}
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders label, exposes variant/size data attrs, fires onClick", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<Button onClick={onClick}>保存</Button>);
|
||||||
|
const btn = screen.getByRole("button", { name: "保存" });
|
||||||
|
expect(btn).toHaveAttribute("data-variant", "default");
|
||||||
|
expect(btn).toHaveAttribute("data-size", "default");
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies non-default variant/size and blocks clicks when disabled", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button variant="outline" size="sm" disabled onClick={onClick}>
|
||||||
|
x
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button");
|
||||||
|
expect(btn).toHaveAttribute("data-variant", "outline");
|
||||||
|
expect(btn).toHaveAttribute("data-size", "sm");
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
@@ -13,10 +13,8 @@ const buttonVariants = cva(
|
|||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
@@ -34,8 +32,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -45,9 +43,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -57,7 +55,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
|
||||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -20,72 +17,39 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||||
data-slot="card-footer"
|
);
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog";
|
||||||
|
|
||||||
|
describe("Dialog", () => {
|
||||||
|
it("opens via trigger, renders title, closes via Escape (radix under jsdom)", async () => {
|
||||||
|
render(
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>打开对话框</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>测试标题</DialogTitle>
|
||||||
|
<p>正文内容</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "打开对话框" }));
|
||||||
|
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("测试标题")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正文内容")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,37 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
...props
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
...props
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
...props
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
...props
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -51,7 +40,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
@@ -60,7 +49,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -76,7 +65,7 @@ function DialogContent({
|
|||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -86,7 +75,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
@@ -95,15 +84,12 @@ function DialogFooter({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -113,33 +99,27 @@ function DialogFooter({
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -153,4 +133,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -42,20 +29,16 @@ function DropdownMenuContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@@ -64,8 +47,8 @@ function DropdownMenuItem({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -74,11 +57,11 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -92,7 +75,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -104,18 +87,11 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -128,7 +104,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -139,7 +115,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -147,54 +123,40 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
...props
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -203,7 +165,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -211,14 +173,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -230,11 +192,11 @@ function DropdownMenuSubContent({
|
|||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -253,4 +215,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
function Label({
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
|
|
||||||
function Select({
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
...props
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
...props
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
...props
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -27,7 +21,7 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -35,7 +29,7 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -44,7 +38,7 @@ function SelectTrigger({
|
|||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -62,7 +56,7 @@ function SelectContent({
|
|||||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
align={align}
|
align={align}
|
||||||
@@ -73,7 +67,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -81,78 +75,59 @@ function SelectContent({
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
data-slot="select-item-indicator"
|
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -162,15 +137,12 @@ function SelectScrollDownButton({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -184,4 +156,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@@ -17,11 +17,11 @@ function Separator({
|
|||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDE_CLASSES = {
|
||||||
|
top: "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal data-slot="sheet-portal">
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-2 bg-background p-4 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||||
|
SIDE_CLASSES[side],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && side !== "bottom" && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
aria-label="关闭"
|
||||||
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetPortal,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
};
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-accent", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="skeleton"
|
|
||||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Slider as SliderPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const thumbs = React.useMemo(
|
||||||
|
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||||
|
[value, defaultValue, min, max],
|
||||||
|
);
|
||||||
|
// radix 的可访问 slider 角色在 Thumb 上,aria-label 提到 Root 传不进去——改下沉到每个 Thumb。
|
||||||
|
const { "aria-label": ariaLabel, ...rootProps } = props;
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rootProps}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className="relative w-full grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className="absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=horizontal]:top-0 data-[orientation=vertical]:w-full"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{thumbs.map((_, i) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
key={i}
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
aria-label={Array.isArray(ariaLabel) ? ariaLabel[i] : ariaLabel}
|
||||||
|
className="block size-4 shrink-0 rounded-full border border-primary/50 bg-background shadow transition-[color,box-shadow] hover:ring-4 hover:ring-ring/30 focus-visible:ring-4 focus-visible:ring-ring/50 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@@ -1,54 +1,32 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||||
data-slot="table-container"
|
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
className="relative w-full overflow-x-auto"
|
|
||||||
>
|
|
||||||
<table
|
|
||||||
data-slot="table"
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
return (
|
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||||
<thead
|
|
||||||
data-slot="table-header"
|
|
||||||
className={cn("[&_tr]:border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
return (
|
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
<tbody
|
|
||||||
data-slot="table-body"
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
return (
|
return (
|
||||||
<tfoot
|
<tfoot
|
||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
@@ -57,11 +35,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
@@ -70,11 +48,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
@@ -83,33 +61,17 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"caption">) {
|
|
||||||
return (
|
return (
|
||||||
<caption
|
<caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||||
data-slot="table-caption"
|
);
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableCaption,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn("inline-flex w-fit items-center justify-center rounded-lg bg-muted p-1-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-foreground text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
delayDuration = 0,
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Provider
|
|
||||||
data-slot="tooltip-provider"
|
|
||||||
delayDuration={delayDuration}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
...props
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
...props
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@@ -40,7 +27,7 @@ function TooltipContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -48,7 +35,7 @@ function TooltipContent({
|
|||||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
+92
-50
@@ -16,7 +16,7 @@
|
|||||||
--secondary: oklch(0.95 0.003 80);
|
--secondary: oklch(0.95 0.003 80);
|
||||||
--secondary-foreground: oklch(0.32 0.01 70);
|
--secondary-foreground: oklch(0.32 0.01 70);
|
||||||
--muted: oklch(0.95 0.003 80);
|
--muted: oklch(0.95 0.003 80);
|
||||||
--muted-foreground: oklch(0.55 0.008 70);
|
--muted-foreground: oklch(0.52 0.008 70);
|
||||||
--accent: oklch(0.68 0.16 46);
|
--accent: oklch(0.68 0.16 46);
|
||||||
--accent-strong: oklch(0.55 0.17 46);
|
--accent-strong: oklch(0.55 0.17 46);
|
||||||
--accent-foreground: oklch(0.985 0.002 80);
|
--accent-foreground: oklch(0.985 0.002 80);
|
||||||
@@ -103,15 +103,44 @@ body {
|
|||||||
|
|
||||||
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
|
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
|
||||||
@keyframes fx-rise {
|
@keyframes fx-rise {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from {
|
||||||
to { opacity: 1; transform: none; }
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fx-rise {
|
||||||
|
animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
|
||||||
|
|
||||||
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
|
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
|
||||||
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
|
[data-rd="paper"] {
|
||||||
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; }
|
--rd-bg: #faf8f4;
|
||||||
[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; }
|
--rd-fg: #292524;
|
||||||
|
--rd-link: #9a3412;
|
||||||
|
--rd-muted: #8a8378;
|
||||||
|
--rd-accent: #b45309;
|
||||||
|
--rd-border: #e4ded2;
|
||||||
|
}
|
||||||
|
[data-rd="sepia"] {
|
||||||
|
--rd-bg: #f0e6d2;
|
||||||
|
--rd-fg: #433a29;
|
||||||
|
--rd-link: #92400e;
|
||||||
|
--rd-muted: #857a63;
|
||||||
|
--rd-accent: #92400e;
|
||||||
|
--rd-border: #ddd0b4;
|
||||||
|
}
|
||||||
|
[data-rd="night"] {
|
||||||
|
--rd-bg: #171512;
|
||||||
|
--rd-fg: #c6c0b6;
|
||||||
|
--rd-link: #e0a458;
|
||||||
|
--rd-muted: #8b8579;
|
||||||
|
--rd-accent: #e0a458;
|
||||||
|
--rd-border: #322e27;
|
||||||
|
}
|
||||||
.rd-surface {
|
.rd-surface {
|
||||||
background: var(--rd-bg);
|
background: var(--rd-bg);
|
||||||
color: var(--rd-fg);
|
color: var(--rd-fg);
|
||||||
@@ -120,51 +149,64 @@ body {
|
|||||||
.rd-surface ::selection {
|
.rd-surface ::selection {
|
||||||
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
||||||
}
|
}
|
||||||
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
|
|
||||||
.rd-btn {
|
|
||||||
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
|
|
||||||
background: color-mix(in oklab, currentColor 8%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); }
|
|
||||||
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; }
|
|
||||||
/* 目录侧边栏行 */
|
|
||||||
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
|
|
||||||
.rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); }
|
|
||||||
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
|
|
||||||
/* Radix 菜单项高亮(指针/键盘) */
|
|
||||||
.rd-highlight { outline: none; }
|
|
||||||
.rd-highlight[data-highlighted] { background: color-mix(in oklab, currentColor 16%, transparent); }
|
|
||||||
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
|
|
||||||
|
|
||||||
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
|
||||||
.rd-range { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full; background: color-mix(in oklab, currentColor 18%, transparent); }
|
|
||||||
.rd-range::-webkit-slider-thumb {
|
|
||||||
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10; background: var(--rd-link, #f59e0b);
|
|
||||||
}
|
|
||||||
.rd-range::-moz-range-thumb { @apply h-4 w-4 rounded-full border-0; background: var(--rd-link, #f59e0b); }
|
|
||||||
|
|
||||||
/* 沉浸态底部工具抽屉的滑入动效 */
|
|
||||||
@keyframes rd-rise { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } }
|
|
||||||
.rd-sheet { animation: rd-rise 0.2s ease-out; }
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.rd-sheet { animation: none; }
|
.fx-rise {
|
||||||
.rd-rise-anim { transition: none !important; }
|
animation: none;
|
||||||
.fx-rise { animation: none; }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* markdown 正文(仅 .md-body 作用域) */
|
/* markdown 正文(仅 .md-body 作用域) */
|
||||||
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
|
.md-body h1 {
|
||||||
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
|
@apply mt-8 mb-3 text-2xl font-semibold;
|
||||||
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; }
|
}
|
||||||
.md-body p { @apply my-3; }
|
.md-body h2 {
|
||||||
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
|
@apply mt-6 mb-2 text-xl font-semibold;
|
||||||
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
|
}
|
||||||
.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; }
|
.md-body h3 {
|
||||||
.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); }
|
@apply mt-4 mb-2 text-base font-semibold;
|
||||||
.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); }
|
}
|
||||||
.md-body pre code { @apply bg-transparent p-0; }
|
.md-body p {
|
||||||
.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; }
|
@apply my-3;
|
||||||
.md-body img { @apply my-3 max-w-full rounded; }
|
}
|
||||||
.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); }
|
.md-body ul {
|
||||||
.md-body table { @apply my-3 w-full border-collapse text-sm; }
|
@apply my-3 list-disc space-y-1 pl-6;
|
||||||
.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); }
|
}
|
||||||
|
.md-body ol {
|
||||||
|
@apply my-3 list-decimal space-y-1 pl-6;
|
||||||
|
}
|
||||||
|
.md-body blockquote {
|
||||||
|
@apply my-3 border-l-2 pl-4;
|
||||||
|
border-color: color-mix(in oklab, currentColor 30%, transparent);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.md-body code {
|
||||||
|
@apply rounded px-1 py-0.5 text-[0.85em];
|
||||||
|
background: color-mix(in oklab, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
.md-body pre {
|
||||||
|
@apply my-3 overflow-x-auto rounded-lg p-3;
|
||||||
|
background: color-mix(in oklab, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
.md-body pre code {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
.md-body a {
|
||||||
|
color: var(--rd-link, #f59e0b);
|
||||||
|
@apply underline underline-offset-2;
|
||||||
|
}
|
||||||
|
.md-body img {
|
||||||
|
@apply my-3 max-w-full rounded;
|
||||||
|
}
|
||||||
|
.md-body hr {
|
||||||
|
@apply my-6;
|
||||||
|
border-color: color-mix(in oklab, currentColor 15%, transparent);
|
||||||
|
}
|
||||||
|
.md-body table {
|
||||||
|
@apply my-3 w-full border-collapse text-sm;
|
||||||
|
}
|
||||||
|
.md-body th,
|
||||||
|
.md-body td {
|
||||||
|
@apply border px-2 py-1 text-left;
|
||||||
|
border-color: color-mix(in oklab, currentColor 22%, transparent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type PageMode = "page" | "spread";
|
||||||
|
|
||||||
|
export function viewCount(count: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? count : Math.ceil(count / 2);
|
||||||
|
}
|
||||||
|
export function pageIndexToView(i: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? i : Math.floor(i / 2);
|
||||||
|
}
|
||||||
|
export function viewToPageIndex(v: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? v : v * 2;
|
||||||
|
}
|
||||||
|
/** 一个 spread 视图要渲染的页,按显示顺序(LTR=[左,右];RTL=[右,左] 即先读页在前)。 */
|
||||||
|
export function spreadPages(first: number, count: number, rtl: boolean): number[] {
|
||||||
|
const pages = [first, ...(first + 1 < count ? [first + 1] : [])];
|
||||||
|
return rtl ? [...pages].reverse() : pages;
|
||||||
|
}
|
||||||
|
/** 翻一屏/页;rtl 时方向翻转;越界钳制。 */
|
||||||
|
export function turnView(v: number, d: number, rtl: boolean, views: number): number {
|
||||||
|
const step = rtl ? -d : d;
|
||||||
|
return Math.max(0, Math.min(views - 1, v + step));
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
export type ProgressSend = (
|
export type ProgressSend = (locator: Record<string, unknown>, percent: number, keepalive: boolean) => Promise<unknown>;
|
||||||
locator: Record<string, unknown>,
|
|
||||||
percent: number,
|
|
||||||
keepalive: boolean,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
|
|
||||||
export interface ProgressSaver {
|
export interface ProgressSaver {
|
||||||
report: (locator: Record<string, unknown>, percent: number) => void;
|
report: (locator: Record<string, unknown>, percent: number) => void;
|
||||||
@@ -41,11 +37,14 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
|
|||||||
lastAt = t;
|
lastAt = t;
|
||||||
doSend(false);
|
doSend(false);
|
||||||
} else {
|
} else {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
lastAt = Date.now();
|
lastAt = Date.now();
|
||||||
doSend(false);
|
doSend(false);
|
||||||
}, intervalMs - (t - lastAt));
|
},
|
||||||
|
intervalMs - (t - lastAt),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flush() {
|
flush() {
|
||||||
|
|||||||
@@ -1,35 +1,108 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
export const RD_THEMES = ["paper", "sepia", "night"] as const;
|
export const RD_THEMES = ["paper", "sepia", "night"] as const;
|
||||||
export type RdTheme = (typeof RD_THEMES)[number];
|
export type RdTheme = (typeof RD_THEMES)[number];
|
||||||
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
||||||
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
||||||
|
export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0];
|
||||||
|
export const MARGINS = ["narrow", "medium", "wide"] as const;
|
||||||
|
export type Margin = (typeof MARGINS)[number];
|
||||||
|
export const MARGIN_LABEL: Record<Margin, string> = { narrow: "窄", medium: "中", wide: "宽" };
|
||||||
|
/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */
|
||||||
|
export const MARGIN_MAXW: Record<Margin, string> = { narrow: "42em", medium: "34em", wide: "26em" };
|
||||||
|
export const CBZ_MODES = ["strip", "page", "spread"] as const;
|
||||||
|
export type CbzMode = (typeof CBZ_MODES)[number];
|
||||||
|
export const CBZ_MODE_LABEL: Record<CbzMode, string> = { strip: "长卷", page: "单页", spread: "双页" };
|
||||||
|
|
||||||
const KEY = "reader-prefs";
|
const KEY = "reader-prefs";
|
||||||
|
|
||||||
export interface ReaderPrefs {
|
export interface ReaderPrefs {
|
||||||
theme: RdTheme;
|
themeMode: "auto" | RdTheme;
|
||||||
sizeIdx: number;
|
text: { sizeIdx: number; lineIdx: number; margin: Margin };
|
||||||
|
cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number };
|
||||||
|
epub: { sizeIdx: number; lineIdx: number; marginIdx: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFS: ReaderPrefs = {
|
||||||
|
themeMode: "auto",
|
||||||
|
text: { sizeIdx: 2, lineIdx: 2, margin: "medium" },
|
||||||
|
cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 },
|
||||||
|
epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function idx(v: unknown, len: number, def: number): number {
|
||||||
|
return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */
|
||||||
|
export function parsePrefs(raw: string | null): ReaderPrefs {
|
||||||
|
let o: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw ?? "");
|
||||||
|
if (parsed && typeof parsed === "object") o = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
/* 首次使用或坏值 */
|
||||||
|
}
|
||||||
|
const t = (o.text ?? {}) as Record<string, unknown>;
|
||||||
|
const c = (o.cbz ?? {}) as Record<string, unknown>;
|
||||||
|
const e = (o.epub ?? {}) as Record<string, unknown>;
|
||||||
|
const mode = o.themeMode;
|
||||||
|
return {
|
||||||
|
themeMode:
|
||||||
|
mode === "auto" || RD_THEMES.includes(mode as RdTheme)
|
||||||
|
? (mode as ReaderPrefs["themeMode"])
|
||||||
|
: DEFAULT_PREFS.themeMode,
|
||||||
|
text: {
|
||||||
|
sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx),
|
||||||
|
lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx),
|
||||||
|
margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin,
|
||||||
|
},
|
||||||
|
cbz: {
|
||||||
|
mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode,
|
||||||
|
rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl,
|
||||||
|
continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous,
|
||||||
|
prefetch:
|
||||||
|
Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3
|
||||||
|
? (c.prefetch as number)
|
||||||
|
: DEFAULT_PREFS.cbz.prefetch,
|
||||||
|
},
|
||||||
|
epub: {
|
||||||
|
sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx),
|
||||||
|
lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx),
|
||||||
|
marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */
|
||||||
|
export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme {
|
||||||
|
if (mode !== "auto") return mode;
|
||||||
|
return globalDark ? "night" : "paper";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReaderPrefs() {
|
export function useReaderPrefs() {
|
||||||
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
|
const [prefs, setPrefs] = useState<ReaderPrefs>(() => parsePrefs(localStorage.getItem(KEY)));
|
||||||
try {
|
|
||||||
const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs;
|
|
||||||
if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length)
|
|
||||||
return p;
|
|
||||||
} catch {
|
|
||||||
/* 首次使用或存了坏值 */
|
|
||||||
}
|
|
||||||
return { theme: "sepia", sizeIdx: 2 };
|
|
||||||
});
|
|
||||||
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
||||||
|
const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []);
|
||||||
return {
|
return {
|
||||||
prefs,
|
prefs,
|
||||||
fontSize: FONT_SIZES[prefs.sizeIdx],
|
update,
|
||||||
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
|
fontSize: FONT_SIZES[prefs.text.sizeIdx],
|
||||||
bump: (d: number) =>
|
lineHeight: LINE_HEIGHTS[prefs.text.lineIdx],
|
||||||
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
|
marginMaxW: MARGIN_MAXW[prefs.text.margin],
|
||||||
|
epubFontSize: FONT_SIZES[prefs.epub.sizeIdx],
|
||||||
|
setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })),
|
||||||
|
bumpText: (d: number) =>
|
||||||
|
update((p) => ({
|
||||||
|
...p,
|
||||||
|
text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) },
|
||||||
|
})),
|
||||||
|
bumpEpub: (d: number) =>
|
||||||
|
update((p) => ({
|
||||||
|
...p,
|
||||||
|
epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) },
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReaderPrefsApi = ReturnType<typeof useReaderPrefs>;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface DayBucket {
|
||||||
|
day: string;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
export interface StatsStore {
|
||||||
|
record(seconds: number): void;
|
||||||
|
weekly(): { perDay: DayBucket[]; total: number };
|
||||||
|
streak(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "reading-stats";
|
||||||
|
export type Buckets = Record<string, number>;
|
||||||
|
|
||||||
|
export function dayKey(d: Date): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
}
|
||||||
|
export function recordInto(b: Buckets, day: string, seconds: number): Buckets {
|
||||||
|
return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) };
|
||||||
|
}
|
||||||
|
export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } {
|
||||||
|
const perDay: DayBucket[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const day = dayKey(d);
|
||||||
|
perDay.push({ day, seconds: b[day] ?? 0 });
|
||||||
|
}
|
||||||
|
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
|
||||||
|
}
|
||||||
|
export function streakOf(b: Buckets, today: Date): number {
|
||||||
|
let n = 0;
|
||||||
|
const d = new Date(today);
|
||||||
|
if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断
|
||||||
|
while (b[dayKey(d)] > 0) {
|
||||||
|
n++;
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): Buckets {
|
||||||
|
try {
|
||||||
|
const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
||||||
|
return o && typeof o === "object" ? (o as Buckets) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */
|
||||||
|
export function createLocalStats(): StatsStore {
|
||||||
|
return {
|
||||||
|
record(seconds) {
|
||||||
|
if (seconds <= 0) return;
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds)));
|
||||||
|
},
|
||||||
|
weekly() {
|
||||||
|
return weeklySum(load(), new Date());
|
||||||
|
},
|
||||||
|
streak() {
|
||||||
|
return streakOf(load(), new Date());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { chapterText, type TxtChapter } from "./chapters";
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
ch: number;
|
||||||
|
/** 命中在章内文本的起点 */
|
||||||
|
pos: number;
|
||||||
|
snippet: string;
|
||||||
|
matchStart: number; // snippet 内
|
||||||
|
matchLen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CTX = 20; // snippet 前后文
|
||||||
|
|
||||||
|
/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */
|
||||||
|
export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
const bodies = chapters.map((_, ci) => chapterText(text, chapters, ci).toLowerCase());
|
||||||
|
const hits: SearchHit[] = [];
|
||||||
|
for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) {
|
||||||
|
const body = bodies[ci];
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const at = body.indexOf(needle, from);
|
||||||
|
if (at < 0 || hits.length >= limit) break;
|
||||||
|
const raw = chapterText(text, chapters, ci);
|
||||||
|
const s = Math.max(0, at - CTX);
|
||||||
|
const e = Math.min(raw.length, at + needle.length + CTX);
|
||||||
|
hits.push({
|
||||||
|
ch: ci,
|
||||||
|
pos: at,
|
||||||
|
snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""),
|
||||||
|
matchStart: at - s + (s > 0 ? 1 : 0),
|
||||||
|
matchLen: needle.length,
|
||||||
|
});
|
||||||
|
from = at + needle.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitHighlight(snippet: string, matchStart: number, matchLen: number) {
|
||||||
|
return {
|
||||||
|
before: snippet.slice(0, matchStart),
|
||||||
|
match: snippet.slice(matchStart, matchStart + matchLen),
|
||||||
|
after: snippet.slice(matchStart + matchLen),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ export type GroupMode = "flat" | "dir";
|
|||||||
export function sortBooks(books: Book[], key: SortKey): Book[] {
|
export function sortBooks(books: Book[], key: SortKey): Book[] {
|
||||||
const a = [...books];
|
const a = [...books];
|
||||||
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
|
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
|
||||||
if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
if (key === "progress")
|
||||||
|
return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
||||||
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createLocalStats, type StatsStore } from "./readingStats";
|
||||||
|
|
||||||
|
const TICK = 30; // 秒:可见期间心跳粒度
|
||||||
|
|
||||||
|
/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */
|
||||||
|
export function useReadingSession(store: StatsStore = createLocalStats()) {
|
||||||
|
const acc = useRef(0);
|
||||||
|
const last = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
last.current = Date.now(); // 渲染期不取时钟,effect 内初始化起算点
|
||||||
|
const flush = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000;
|
||||||
|
last.current = now;
|
||||||
|
if (acc.current >= 1) {
|
||||||
|
store.record(acc.current);
|
||||||
|
acc.current = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const t = setInterval(flush, TICK * 1000);
|
||||||
|
const onVis = () => flush();
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
globalThis.addEventListener?.("pagehide", onVis);
|
||||||
|
return () => {
|
||||||
|
clearInterval(t);
|
||||||
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
|
globalThis.removeEventListener?.("pagehide", onVis);
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
}, [store]);
|
||||||
|
}
|
||||||
@@ -47,11 +47,24 @@ export default function Login() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-user">用户名</Label>
|
<Label htmlFor="login-user">用户名</Label>
|
||||||
<Input id="login-user" value={u} onChange={(e) => setU(e.target.value)} autoFocus autoComplete="username" />
|
<Input
|
||||||
|
id="login-user"
|
||||||
|
value={u}
|
||||||
|
onChange={(e) => setU(e.target.value)}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- 登录页整页即表单,首字段自动聚焦是通行 UX;规则无按场景豁免选项,保留行为
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-pass">密码</Label>
|
<Label htmlFor="login-pass">密码</Label>
|
||||||
<Input id="login-pass" type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" />
|
<Input
|
||||||
|
id="login-pass"
|
||||||
|
type="password"
|
||||||
|
value={p}
|
||||||
|
onChange={(e) => setP(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={busy || !u || !p}>
|
<Button type="submit" className="w-full" disabled={busy || !u || !p}>
|
||||||
{busy ? "登录中…" : "登录"}
|
{busy ? "登录中…" : "登录"}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Format } from "../api/types";
|
import type { Format } from "../api/types";
|
||||||
import { IconArrowLeft } from "../components/icons";
|
import { FormatBadge } from "../components/format-badge";
|
||||||
import { btn, btnGhost, formatBadge } from "../components/ui";
|
import { Button } from "../components/ui/button";
|
||||||
import type { ReaderProps } from "../lib/useProgress";
|
import type { ReaderProps } from "../lib/useProgress";
|
||||||
|
import { createLocalStats } from "../lib/readingStats";
|
||||||
|
import { useReadingSession } from "../lib/useReadingSession";
|
||||||
|
|
||||||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||||
cbz: lazy(() => import("../readers/CbzReader")),
|
cbz: lazy(() => import("../readers/CbzReader")),
|
||||||
@@ -26,6 +29,8 @@ export default function Reader() {
|
|||||||
|
|
||||||
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||||||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
|
const stats = useMemo(() => createLocalStats(), []);
|
||||||
|
useReadingSession(stats);
|
||||||
|
|
||||||
const book = bookQ.data;
|
const book = bookQ.data;
|
||||||
const body = (() => {
|
const body = (() => {
|
||||||
@@ -51,19 +56,21 @@ export default function Reader() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-stone-800/80 bg-stone-950/80 px-4 text-sm backdrop-blur transition-all duration-300 " +
|
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-border bg-background/90 px-4 text-sm text-foreground backdrop-blur transition-all duration-300 " +
|
||||||
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
|
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link to="/" className={btnGhost + " shrink-0"}>
|
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
||||||
<IconArrowLeft className="size-4" />
|
<Link to="/">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
书架
|
书架
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||||||
{book && (
|
{book && (
|
||||||
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
|
<span className="hidden shrink-0 items-center gap-2 text-xs text-muted-foreground sm:flex">
|
||||||
{book.library && <span>{book.library}</span>}
|
{book.library && <span>{book.library}</span>}
|
||||||
<span className={formatBadge(book.format)}>{book.format}</span>
|
<FormatBadge fmt={book.format} />
|
||||||
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -77,13 +84,13 @@ export default function Reader() {
|
|||||||
|
|
||||||
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3">{text}</p>
|
<p className="mb-3">{text}</p>
|
||||||
{retry && (
|
{retry && (
|
||||||
<button className={btn} onClick={retry}>
|
<Button variant="secondary" onClick={retry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+143
-29
@@ -1,39 +1,79 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react";
|
import {
|
||||||
|
ArrowDownUp,
|
||||||
|
Ellipsis,
|
||||||
|
FolderOpen,
|
||||||
|
LibraryBig,
|
||||||
|
RefreshCw,
|
||||||
|
ScanLine,
|
||||||
|
Search,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import type { Book } from "@/api/types";
|
import type { Book } from "@/api/types";
|
||||||
import { useAuth } from "@/auth/AuthContext";
|
import { useAuth } from "@/auth/AuthContext";
|
||||||
import { Cover } from "@/components/Cover";
|
import { Cover } from "@/components/Cover";
|
||||||
import { toast } from "@/components/Toaster";
|
import { toast } from "@/components/Toaster";
|
||||||
import { UploadButton } from "@/components/upload-button";
|
import { UploadButton } from "@/components/upload-button";
|
||||||
import { formatBadge } from "@/components/ui";
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
|
import { FormatBadge } from "@/components/format-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf";
|
import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf";
|
||||||
import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta";
|
import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta";
|
||||||
|
|
||||||
const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]";
|
const GRID =
|
||||||
|
"grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]";
|
||||||
|
|
||||||
function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) {
|
function BookCard({
|
||||||
|
book,
|
||||||
|
index,
|
||||||
|
onOpen,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
book: Book;
|
||||||
|
index: number;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const pct = Math.round(book.percent * 100);
|
const pct = Math.round(book.percent * 100);
|
||||||
return (
|
return (
|
||||||
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
|
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
|
||||||
<button onClick={onOpen} className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
||||||
<Cover book={book} className="size-full" />
|
<Cover book={book} className="size-full" />
|
||||||
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />
|
||||||
{book.state === "error" && (
|
{book.state === "error" && (
|
||||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground" title={book.error}>
|
<span
|
||||||
|
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
|
||||||
|
title={book.error}
|
||||||
|
>
|
||||||
<TriangleAlert className="size-3" /> 损坏
|
<TriangleAlert className="size-3" /> 损坏
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -45,13 +85,20 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
|
|||||||
</span>
|
</span>
|
||||||
<span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
|
<span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
|
||||||
<span className="block truncate text-[11px] text-muted-foreground">
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
|
{book.path.includes("/")
|
||||||
|
? book.path.slice(0, book.path.lastIndexOf("/"))
|
||||||
|
: `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
|
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 bg-background/80 backdrop-blur"
|
||||||
|
aria-label={`《${book.title}》操作`}
|
||||||
|
>
|
||||||
<Ellipsis className="size-4" />
|
<Ellipsis className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -95,22 +142,34 @@ export default function Shelf() {
|
|||||||
const [qRaw, setQRaw] = useState(serverQ);
|
const [qRaw, setQRaw] = useState(serverQ);
|
||||||
const [confirmDel, setConfirmDel] = useState<Book | null>(null);
|
const [confirmDel, setConfirmDel] = useState<Book | null>(null);
|
||||||
|
|
||||||
useEffect(() => setQRaw(serverQ), [serverQ]);
|
// URL 是筛选词的事实来源:回退/前进/程序化改参时把输入框拉回 URL 值。
|
||||||
|
// 用 React 官方推荐的「渲染期按下发值重置本地 state」替代原先 effect 内的同步 setState
|
||||||
|
const [prevServerQ, setPrevServerQ] = useState(serverQ);
|
||||||
|
if (prevServerQ !== serverQ) {
|
||||||
|
setPrevServerQ(serverQ);
|
||||||
|
setQRaw(serverQ);
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (qRaw.trim() === serverQ) return;
|
if (qRaw.trim() === serverQ) return;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setSp((prev) => {
|
setSp(
|
||||||
|
(prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
if (qRaw.trim()) next.set("q", qRaw.trim());
|
if (qRaw.trim()) next.set("q", qRaw.trim());
|
||||||
else next.delete("q");
|
else next.delete("q");
|
||||||
return next;
|
return next;
|
||||||
}, { replace: true });
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [qRaw, serverQ, sp, setSp]);
|
}, [qRaw, serverQ, sp, setSp]);
|
||||||
|
|
||||||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) });
|
const booksQ = useQuery({
|
||||||
|
queryKey: ["books", lib, serverQ],
|
||||||
|
queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }),
|
||||||
|
});
|
||||||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
|
const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
|
||||||
|
|
||||||
@@ -145,14 +204,21 @@ export default function Shelf() {
|
|||||||
else next.delete(k);
|
else next.delete(k);
|
||||||
setSp(next, { replace: true });
|
setSp(next, { replace: true });
|
||||||
};
|
};
|
||||||
const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库";
|
const libName = lib === LIB0 ? "全部" : (libsQ.data?.find((l) => l.id === lib)?.name ?? "书库");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
|
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
|
||||||
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
|
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
|
||||||
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
|
<Input
|
||||||
|
className="pl-8"
|
||||||
|
type="search"
|
||||||
|
aria-label="搜索标题"
|
||||||
|
placeholder="搜索标题…"
|
||||||
|
value={qRaw}
|
||||||
|
onChange={(e) => setQRaw(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -164,7 +230,11 @@ export default function Shelf() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>排序</DropdownMenuLabel>
|
<DropdownMenuLabel>排序</DropdownMenuLabel>
|
||||||
{SORTS.map((k) => (
|
{SORTS.map((k) => (
|
||||||
<DropdownMenuItem key={k} onSelect={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""}>
|
<DropdownMenuItem
|
||||||
|
key={k}
|
||||||
|
onSelect={() => setParam("sort", k === "added" ? "" : k)}
|
||||||
|
className={k === sort ? "font-medium text-accent-strong" : ""}
|
||||||
|
>
|
||||||
{SORT_LABEL[k]}
|
{SORT_LABEL[k]}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -172,7 +242,14 @@ export default function Shelf() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5">
|
<div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5">
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<Button key={g.v} size="sm" variant={group === g.v ? "secondary" : "ghost"} aria-pressed={group === g.v} className="px-2.5" onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}>
|
<Button
|
||||||
|
key={g.v}
|
||||||
|
size="sm"
|
||||||
|
variant={group === g.v ? "secondary" : "ghost"}
|
||||||
|
aria-pressed={group === g.v}
|
||||||
|
className="px-2.5"
|
||||||
|
onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}
|
||||||
|
>
|
||||||
{g.label}
|
{g.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -180,19 +257,32 @@ export default function Shelf() {
|
|||||||
{isAdmin && lib > LIB0 && (
|
{isAdmin && lib > LIB0 && (
|
||||||
<>
|
<>
|
||||||
<UploadButton libId={lib} variant="outline" label="上传文件" />
|
<UploadButton libId={lib} variant="outline" label="上传文件" />
|
||||||
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={scan.isPending}
|
||||||
|
onClick={() => scan.mutate(lib)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
<ScanLine className="size-4" />
|
<ScanLine className="size-4" />
|
||||||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="刷新书目"
|
||||||
|
onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}
|
||||||
|
>
|
||||||
<RefreshCw className="size-4" />
|
<RefreshCw className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
||||||
|
|
||||||
|
<ReadingStatsCard />
|
||||||
|
|
||||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
||||||
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
|
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
|
||||||
<LibraryBig className="size-4 shrink-0" />
|
<LibraryBig className="size-4 shrink-0" />
|
||||||
@@ -203,23 +293,37 @@ export default function Shelf() {
|
|||||||
{continueRows.length > 0 && !searching && (
|
{continueRows.length > 0 && !searching && (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
|
<h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- Tailwind preflight 移除了 ul 的 list 样式,Safari/VoiceOver 因此丢列表语义;role="list" 是官方推荐的补偿写法,非冗余 */}
|
||||||
<ul className="flex gap-3 overflow-x-auto pb-2" role="list">
|
<ul className="flex gap-3 overflow-x-auto pb-2" role="list">
|
||||||
{continueRows.map((r, i) => {
|
{continueRows.map((r, i) => {
|
||||||
const b = booksByKey.get(`${r.library_id}/${r.path}`);
|
const b = booksByKey.get(`${r.library_id}/${r.path}`);
|
||||||
if (!b)
|
if (!b)
|
||||||
return (
|
return (
|
||||||
<li key={`${r.library_id}/${r.path}`} className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground" style={{ animationDelay: `${i * 40}ms` }}>
|
<li
|
||||||
|
key={`${r.library_id}/${r.path}`}
|
||||||
|
className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground"
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
<p className="truncate">{r.title || "已删除的书"}</p>
|
<p className="truncate">{r.title || "已删除的书"}</p>
|
||||||
<p className="mt-1 text-[10px]">进度已保留</p>
|
<p className="mt-1 text-[10px]">进度已保留</p>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
const pct = Math.round(r.percent * 100);
|
const pct = Math.round(r.percent * 100);
|
||||||
return (
|
return (
|
||||||
<li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" style={{ animationDelay: `${i * 40}ms` }}>
|
<li
|
||||||
<button onClick={() => nav(`/book/${b.id}`)} className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent">
|
key={`${r.library_id}/${r.path}`}
|
||||||
|
className="fx-rise shrink-0"
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => nav(`/book/${b.id}`)}
|
||||||
|
className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
<Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" />
|
<Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium group-hover:text-accent-strong">{b.title}</span>
|
<span className="block truncate text-sm font-medium group-hover:text-accent-strong">
|
||||||
|
{b.title}
|
||||||
|
</span>
|
||||||
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
||||||
<span>{r.library}</span>
|
<span>{r.library}</span>
|
||||||
<span className="tabular-nums">{pct}%</span>
|
<span className="tabular-nums">{pct}%</span>
|
||||||
@@ -261,8 +365,12 @@ export default function Shelf() {
|
|||||||
{!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && (
|
{!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && (
|
||||||
<div className="fx-rise flex flex-col items-center py-20 text-center">
|
<div className="fx-rise flex flex-col items-center py-20 text-center">
|
||||||
<FolderOpen className="size-10 text-muted-foreground/60" />
|
<FolderOpen className="size-10 text-muted-foreground/60" />
|
||||||
<h3 className="mt-3 text-sm font-medium">{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}</h3>
|
<h3 className="mt-3 text-sm font-medium">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}</p>
|
{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -271,7 +379,13 @@ export default function Shelf() {
|
|||||||
<SectionHead label={s.label} count={s.books.length} />
|
<SectionHead label={s.label} count={s.books.length} />
|
||||||
<div className={GRID}>
|
<div className={GRID}>
|
||||||
{s.books.map((b, i) => (
|
{s.books.map((b, i) => (
|
||||||
<BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} />
|
<BookCard
|
||||||
|
key={b.id}
|
||||||
|
book={b}
|
||||||
|
index={i}
|
||||||
|
onOpen={() => nav(`/book/${b.id}`)}
|
||||||
|
onDelete={() => setConfirmDel(b)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user