Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991de0ef26 | ||
|
|
45eefd6741 |
@@ -1,125 +1,34 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
@github.com/XingfenD/AGENTS.md:content/basic_agents_md/AGENTS.md
|
## Safety Rules
|
||||||
|
- Dev branch naming: `{feat|fix|docs|chore}/{branch-name}` (e.g. `feat/file-tag-done`, `fix/tree-render`).
|
||||||
|
- Before `git commit`: run `git branch --show-current`. If on `master`, do NOT commit — ask user for a branch name (suggest one based on the changes), create it, commit there.
|
||||||
|
- Every user-visible change gets a changelog entry: WebUI changes (files under `frontend/`) → `docs/CHANGELOG_web.md`, everything else → `docs/CHANGELOG.md`; never duplicate an entry across both. Higher versions on top.
|
||||||
|
- CHANGELOG entry format: same entry has English line then Chinese line on consecutive lines (no blank line between them); different entries are separated by a blank line.
|
||||||
|
- `docs/README.md` and `docs/README_zh.md` stay content-equivalent; update them in the same change.
|
||||||
|
|
||||||
## Docker deployment (dev & release)
|
## Docker deployment (dev & release)
|
||||||
|
- dev = `deploy/docker-compose.dev.yml`: source bind-mounted (`../backend:/app`, `../frontend:/app` with an anonymous volume on `/app/node_modules`), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (delve on 2345), web runs `npm run dev` (hot reload, no rebuild needed); infra ports exposed to host (postgres 5432, redis 6379); startup gated on healthchecks.
|
||||||
### Deployment mode
|
- release = `deploy/docker-compose.yml`: multi-stage `*.prod` Dockerfiles (`target: runner`) with compiled binaries, no source mounts; infra ports stay on the internal network only.
|
||||||
|
- Persisted state lives only in named volumes `booklib_{postgres,redis}_data` — same names in both files, so dev and release see the same data. Only `down -v` wipes them.
|
||||||
Mode differences (dev = `deploy/docker-compose.dev.yml`, release = `deploy/docker-compose.yml`):
|
- Books on disk: host `deploy/api/storage` is mounted as `/data/books` (`BOOKS_DIR`); uploads land there and the scanner picks them up.
|
||||||
|
- Rendered configs `deploy/nginx/nginx.conf`, `deploy/nginx/conf.d/default.conf`, `deploy/redis/redis.conf` (mounted `:ro`) come from their `*.tpl` via `deploy/prepare.sh` — edit the template, rerun `prepare.sh`, restart; stale files in a container mean you forgot to rerun. Host logs under `deploy/logs/`.
|
||||||
- dev: source dirs bind-mounted into containers (`../backend:/app`, `../frontend:/app`, `../collab*`), `target: dev` images, workers run `go mod download && go run ./cmd/...`, hot reload without rebuild; also exposes infra ports to the host (postgres 5432, redis 6379, ES 9200, rabbit 5672) and a delve debugger on 2345, and gates startup on healthchecks.
|
- Tear down with `down`, not `rm`, or the web image's anonymous `node_modules` volume gets orphaned.
|
||||||
- release: multi-stage `*.prod` Dockerfiles (`target: runner`) with compiled binaries, no source mounts — Go services only get config files mounted; infra ports stay on the internal network only.
|
|
||||||
|
|
||||||
### Volume mounts
|
|
||||||
|
|
||||||
- Named volumes (both modes, shared): `{$project_name}_{postgres,redis,consul,rabbitmq,minio,elasticsearch}_data` persist infra state to Docker volumes — identical names across the two compose files, so dev and release see the same data. Only `down -v` (i.e. `rebuild`/`clear`) wipes them.
|
|
||||||
- Config/log bind mounts (both modes): rendered outputs `deploy/nginx/nginx.conf`, `deploy/nginx/conf.d/default.conf`, `deploy/redis/redis.conf`, `deploy/rabbitmq/rabbitmq.conf` (`:ro`) plus `deploy/logs/{nginx,rabbitmq}` are mounted from the host — these come from `prepare.sh`, so stale files in the container mean you forgot to rerun it.
|
|
||||||
- Source mounts (dev only): `../backend:/app` (which run `go mod download && go run ./cmd/...`), `../frontend:/app` with an anonymous volume on `/app/node_modules` (so the image's deps survive the mount — delete containers with `down`, not `rm`, to avoid orphaning it).
|
|
||||||
- The File Storage mounts: if the backend stores uploaded files directly instead of in an object storage, mount `deploy/{$service_name}/storage` as the storage path into the container.
|
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node app, served via nginx in release), `deploy` (compose stacks, rendered configs, host logs). Actual layout is visible via `ls`; only rules not derivable from it are listed here.
|
||||||
|
|
||||||
Basically, the project consists of the directories `docs`, `backend`, `frontend`, `deploy`, plus optional `collab*` services:
|
### backend
|
||||||
|
- `cmd/`: each subdir is exactly one `main` package; `main.go` only bootstraps (load config, wire deps, serve, graceful shutdown) — no business logic, and nothing outside `cmd` imports `cmd`.
|
||||||
|
- `cmd/webui/api` is the single source of truth for the endpoint contract; the frontend aligns with it.
|
||||||
|
- Handlers stay thin (bind/validate, call `internal`, map errors) — never touch the DB or implement domain rules; SQL and business rules live behind `internal` boundaries.
|
||||||
|
- Business code defaults to `internal/`; `pkg/` is opt-in public surface shared with other repos — keep it small, never leak `internal` types through it.
|
||||||
|
|
||||||
```plaintext
|
### frontend
|
||||||
.
|
- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app.
|
||||||
├── docs # project documentation (see Documents layout)
|
- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
|
||||||
├── backend # golang services (see backend layout)
|
- Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
|
||||||
├── frontend # node web app, built and served via nginx (see frontend layout)
|
|
||||||
├── collab* # optional collaboration services, same layout as backend
|
|
||||||
├── deploy # compose stacks, rendered configs, host-side logs (see deploy layout)
|
|
||||||
├── .gitignore # repo-wide ignores only; module-specific ignores live inside each module
|
|
||||||
├── AGENTS.md # agent guidance for this repo
|
|
||||||
└── LICENSE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documents layout
|
|
||||||
|
|
||||||
The documents' layout is expected to be like below:
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
docs
|
|
||||||
├── CHANGELOG.md # general (non-WebUI) changes; highest version on top
|
|
||||||
├── CHANGELOG_web.md # WebUI changes only; highest version on top
|
|
||||||
├── README.md # primary (English) entry doc
|
|
||||||
└── README_zh.md # Chinese mirror of README.md — keep in sync
|
|
||||||
```
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Every user-visible change gets a changelog entry: WebUI changes → `CHANGELOG_web.md`, everything else → `CHANGELOG.md`; never duplicate an entry across both.
|
|
||||||
- `README.md` and `README_zh.md` stay content-equivalent; update them in the same change.
|
|
||||||
- Documentation lives only under `docs/` — no stray `*.md` at the repo root besides `AGENTS.md` and `LICENSE`.
|
|
||||||
|
|
||||||
### backend (golang)
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
backend
|
|
||||||
├── cmd # executable entry points, one dir per binary
|
|
||||||
├── internal # private packages: importable only within this module
|
|
||||||
├── pkg # public packages: intentionally shared with other repos
|
|
||||||
└── .gitignore # .gitignore inside backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- `cmd`: each subdirectory holds exactly one `main` package; `main.go` only bootstraps — load config, wire dependencies, start the server/CLI, handle graceful shutdown. No business logic here, and nothing outside `cmd` imports `cmd`.
|
|
||||||
- `internal`: default home for all business code (domain, services, storage, config). If code is not meant to be imported by other repos, it goes here, not in `pkg`.
|
|
||||||
- `pkg`: opt-in public API surface — keep it small and stable, and never leak `internal` types through its APIs.
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
cmd
|
|
||||||
├── cli # command-line binary
|
|
||||||
│ ├── commands # one file/package per subcommand: flag parsing + dispatch into internal
|
|
||||||
│ └── main.go # arg parsing and subcommand dispatch only
|
|
||||||
└── webui # HTTP server binary
|
|
||||||
├── api # route registration + request/response DTOs — the endpoint contract, no logic
|
|
||||||
├── constant # webui-only constants (routes, keys, limits)
|
|
||||||
├── handlers # thin HTTP handlers: bind/validate input, call internal services, map errors
|
|
||||||
├── main.go # boot the HTTP server
|
|
||||||
├── static # assets served as-is by webui
|
|
||||||
└── templates # server-side HTML templates rendered by webui
|
|
||||||
```
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Handlers never touch the database or implement domain rules — they delegate to `internal`; SQL and business rules live behind `internal` boundaries.
|
|
||||||
- `api` is the single source of truth for the endpoint contract; the frontend aligns with it.
|
|
||||||
- Code shared between `cli` and `webui` belongs in `internal` (or `pkg`), never imported from one command by the other.
|
|
||||||
|
|
||||||
### frontend (node)
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
frontend
|
|
||||||
├── src # all hand-written app source: pages, components, state, API client
|
|
||||||
├── public # static assets copied into the build output as-is
|
|
||||||
├── package.json # dev/build/lint/test scripts, runnable unchanged inside the container
|
|
||||||
└── .gitignore # node_modules/ and build output (e.g. dist/) never committed
|
|
||||||
```
|
|
||||||
|
|
||||||
Constraints (framework-agnostic — any stack that keeps the layout above):
|
|
||||||
- The app talks to the backend only over the HTTP APIs defined in `backend/cmd/webui/api` — no direct access to infra (DB, ES, rabbit) from the frontend.
|
|
||||||
- Build output is disposable and git-ignored; committed sources live only in `src`/`public`; nginx serves the built assets in release.
|
|
||||||
- Dev runs from the source mount (`../frontend:/app`) with `node_modules` provided by the image's anonymous volume — install new deps inside the container and commit the lockfile.
|
|
||||||
|
|
||||||
### deploy
|
### deploy
|
||||||
|
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
|
||||||
```plaintext
|
|
||||||
deploy
|
|
||||||
├── docker-compose.yml # release stack: multi-stage *.prod images, infra internal-only
|
|
||||||
├── docker-compose.dev.yml # dev stack: source mounts, target: dev, exposed ports, delve 2345
|
|
||||||
├── prepare.sh # renders the config files below; rerun after changing templates
|
|
||||||
├── nginx
|
|
||||||
│ ├── nginx.conf # main config, mounted :ro
|
|
||||||
│ └── conf.d/default.conf # site config, mounted :ro
|
|
||||||
├── redis/redis.conf # mounted :ro
|
|
||||||
├── rabbitmq/rabbitmq.conf # mounted :ro
|
|
||||||
├── logs # host-side dirs bind-mounted into containers ({nginx,rabbitmq})
|
|
||||||
├── {$service_name}/storage # file-storage path for services not using object storage
|
|
||||||
└── .gitignore # ignores actual/rendered configs and logs/; tracks only *.example files
|
|
||||||
```
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Only config examples (e.g. `*.conf.example`) are tracked by git; actual configs (rendered by `prepare.sh`) and log files are git-ignored.
|
|
||||||
- Everything under `deploy/` that containers mount (`*.conf` and `prepare.sh` outputs) is config, not app code: edit it here, rerun `prepare.sh`, restart — never edit configs inside a running container.
|
|
||||||
- Infra ports are exposed to the host only in `docker-compose.dev.yml`; release keeps them internal-network-only.
|
|
||||||
- Persisted state lives only in the named `{$project_name}_*` volumes; bind-mounts are reserved for source, config, logs, and file storage.
|
|
||||||
- Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root.
|
- Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, na
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := filepath.Join(booksDir, name)
|
root := filepath.Join(booksDir, name)
|
||||||
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
|
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name})
|
||||||
if w.Code != 201 {
|
if w.Code != 201 {
|
||||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,29 +47,31 @@ func (h *H) ListLibraries(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定
|
||||||
func (h *H) CreateLibrary(c *gin.Context) {
|
func (h *H) CreateLibrary(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
RootPath string `json:"root_path"`
|
|
||||||
}
|
}
|
||||||
if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" {
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "name and root_path required")
|
err(c, http.StatusBadRequest, "bad_request", "name required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(req.RootPath) {
|
safe := bookfile.SafeName(req.Name)
|
||||||
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
|
if safe == "" || safe == ".." {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
|
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
||||||
|
id, e := h.st.CreateLibrary(c, req.Name, root)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if isUnique(e) {
|
if isUnique(e) {
|
||||||
err(c, http.StatusConflict, "exists", "root_path taken")
|
err(c, http.StatusConflict, "exists", "name taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
|
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
|
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
|
||||||
|
|||||||
@@ -14,23 +14,35 @@ import (
|
|||||||
func TestLibraryCreateListUpload(t *testing.T) {
|
func TestLibraryCreateListUpload(t *testing.T) {
|
||||||
_, _, h, booksDir := setupAPI(t)
|
_, _, h, booksDir := setupAPI(t)
|
||||||
tok := adminToken(t, h)
|
tok := adminToken(t, h)
|
||||||
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内
|
root := filepath.Join(booksDir, "comics") // 服务端自动拼接 BooksDir/<清洗后的库名>
|
||||||
os.MkdirAll(root, 0o755)
|
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
|
||||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
|
|
||||||
if w.Code != 201 {
|
if w.Code != 201 {
|
||||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||||
}
|
}
|
||||||
var lib map[string]any
|
var lib map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &lib)
|
json.Unmarshal(w.Body.Bytes(), &lib)
|
||||||
|
if lib["root_path"] != root {
|
||||||
|
t.Fatalf("root_path want %q got %v", root, lib["root_path"])
|
||||||
|
}
|
||||||
libID := itoa(lib["id"])
|
libID := itoa(lib["id"])
|
||||||
w = do(h, "GET", "/api/libraries", tok, nil)
|
w = do(h, "GET", "/api/libraries", tok, nil)
|
||||||
if !strings.Contains(w.Body.String(), `"comics"`) {
|
if !strings.Contains(w.Body.String(), `"comics"`) {
|
||||||
t.Fatalf("list: %s", w.Body)
|
t.Fatalf("list: %s", w.Body)
|
||||||
}
|
}
|
||||||
// 相对路径 root 必须 400(前缀校验的根)
|
// 恶意库名必须清洗,root 仍在 booksDir 内
|
||||||
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"})
|
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "../../etc/passwd"})
|
||||||
|
if w.Code != 201 || !strings.Contains(w.Body.String(), filepath.Join(booksDir, "passwd")) {
|
||||||
|
t.Fatalf("traversal name want sanitized 201 got %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
// "." / ".." 清洗后非法 → 400
|
||||||
|
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": ".."})
|
||||||
if w.Code != 400 {
|
if w.Code != 400 {
|
||||||
t.Fatalf("relative root want 400 got %d", w.Code)
|
t.Fatalf("'..' want 400 got %d", w.Code)
|
||||||
|
}
|
||||||
|
// 重名(同 root)→ 409
|
||||||
|
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
|
||||||
|
if w.Code != 409 {
|
||||||
|
t.Fatalf("dup want 409 got %d", w.Code)
|
||||||
}
|
}
|
||||||
// 上传:白名单 + 防穿越 + 原子落盘
|
// 上传:白名单 + 防穿越 + 原子落盘
|
||||||
body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
|
body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
|
||||||
|
|||||||
+9
-3
@@ -1,10 +1,16 @@
|
|||||||
# Changelog
|
# Changelog / 更新日志
|
||||||
|
|
||||||
All non-WebUI user-visible changes. Newest version on top.
|
All notable non-WebUI user-visible changes should be documented in this file, newest version on top.
|
||||||
|
本文件记录所有非 WebUI 的重要用户可见变更,最新版本在最上方。
|
||||||
|
|
||||||
|
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
|
||||||
|
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed / 变更
|
||||||
|
|
||||||
|
- 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.
|
- 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`.
|
- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,16 @@
|
|||||||
# Changelog (WebUI)
|
# Changelog (WebUI) / 更新日志
|
||||||
|
|
||||||
All WebUI user-visible changes. Newest version on top.
|
All notable WebUI user-visible changes should be documented in this file, newest version on top.
|
||||||
|
本文件记录所有 WebUI 的重要用户可见变更,最新版本在最上方。
|
||||||
|
|
||||||
|
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
|
||||||
|
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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._
|
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ POST /api/users ★ {username,password,role}
|
|||||||
DELETE /api/users/{id} ★
|
DELETE /api/users/{id} ★
|
||||||
|
|
||||||
GET /api/libraries
|
GET /api/libraries
|
||||||
POST /api/libraries ★ {name,root_path}
|
POST /api/libraries ★ {name} → root_path 由服务端生成(BooksDir/清洗后的库名)
|
||||||
POST /api/libraries/{id}/scan ★ → 202 异步
|
POST /api/libraries/{id}/scan ★ → 202 异步
|
||||||
POST /api/libraries/{id}/upload multipart ★ → 202
|
POST /api/libraries/{id}/upload multipart ★ → 202
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ export const api = {
|
|||||||
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
|
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
||||||
createLibrary: (name: string, root_path: string) =>
|
createLibrary: (name: string) => apiFetch<Library>("/libraries", { method: "POST", body: { name } }),
|
||||||
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
|
|
||||||
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
|
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
|
||||||
uploadBook: (id: number, file: File) => {
|
uploadBook: (id: number, file: File) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|||||||
@@ -9,15 +9,13 @@ export default function AdminLibraries() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [root, setRoot] = useState("");
|
|
||||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () => api.createLibrary(name.trim(), root.trim()),
|
mutationFn: () => api.createLibrary(name.trim()),
|
||||||
onSuccess: (lib) => {
|
onSuccess: (lib) => {
|
||||||
toast("ok", `库「${lib.name}」已创建`);
|
toast("ok", `库「${lib.name}」已创建`);
|
||||||
setName("");
|
setName("");
|
||||||
setRoot("");
|
|
||||||
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
||||||
},
|
},
|
||||||
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
||||||
@@ -54,14 +52,13 @@ export default function AdminLibraries() {
|
|||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
||||||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||||||
<input className={input} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
<input
|
<input
|
||||||
className={input + " flex-1"}
|
className={input + " flex-1"}
|
||||||
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)"
|
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
|
||||||
value={root}
|
value={name}
|
||||||
onChange={(e) => setRoot(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}>
|
<button className={btnPrimary} disabled={create.isPending || !name.trim()}>
|
||||||
{create.isPending ? "创建中…" : "建库"}
|
{create.isPending ? "创建中…" : "建库"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user