From 1bd7688ee6c9f39c38e0f8c7cc39695057e204e8 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Mon, 7 Sep 2026 16:18:58 +0800 Subject: [PATCH] feat(web): admin users CRUD + libraries create/scan/upload --- web/src/App.tsx | 24 ++++++- web/src/pages/admin/Libraries.tsx | 107 ++++++++++++++++++++++++++++++ web/src/pages/admin/Users.tsx | 93 ++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/admin/Libraries.tsx create mode 100644 web/src/pages/admin/Users.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index d6d8846..b7224f8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,12 @@ import { Navigate, Route, Routes } from "react-router-dom"; -import { AuthProvider, RequireAuth } from "./auth/AuthContext"; +import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Toaster } from "./components/Toaster"; import Login from "./pages/Login"; import Reader from "./pages/Reader"; import Shelf from "./pages/Shelf"; +import AdminLibraries from "./pages/admin/Libraries"; +import AdminUsers from "./pages/admin/Users"; export default function App() { return ( @@ -28,6 +30,26 @@ export default function App() { } /> + + + + + + } + /> + + + + + + } + /> } /> diff --git a/web/src/pages/admin/Libraries.tsx b/web/src/pages/admin/Libraries.tsx new file mode 100644 index 0000000..712168a --- /dev/null +++ b/web/src/pages/admin/Libraries.tsx @@ -0,0 +1,107 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState, type FormEvent } from "react"; +import { api } from "../../api/client"; +import { TopBar } from "../../components/TopBar"; +import { toast } from "../../components/Toaster"; +import { btn, btnPrimary, card, input } from "../../components/ui"; + +export default function AdminLibraries() { + const qc = useQueryClient(); + const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); + const [name, setName] = useState(""); + const [root, setRoot] = useState(""); + const fileRefs = useRef>({}); + + const create = useMutation({ + mutationFn: () => api.createLibrary(name.trim(), root.trim()), + onSuccess: (lib) => { + toast("ok", `库「${lib.name}」已创建`); + setName(""); + setRoot(""); + void qc.invalidateQueries({ queryKey: ["libraries"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"), + }); + const scan = useMutation({ + mutationFn: (id: number) => api.scanLibrary(id), + onSuccess: () => { + toast("ok", "扫描已触发"); + setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), + }); + + async function onFiles(libId: number, list: FileList | null) { + if (!list?.length) return; + try { + for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错 + toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`); + void qc.invalidateQueries({ queryKey: ["books"] }); + setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000); + } catch (e) { + toast("err", e instanceof Error ? e.message : "上传失败"); + } + } + + function submit(e: FormEvent) { + e.preventDefault(); + create.mutate(); + } + + return ( +
+ +
+

库管理

+
+ setName(e.target.value)} /> + setRoot(e.target.value)} + /> + +
+
+ {(libsQ.data ?? []).map((l) => ( +
+
+ {l.name} + #{l.id} +
+

{l.root_path}

+
+ + { + fileRefs.current[l.id] = el; + }} + type="file" + multiple + accept=".cbz,.pdf,.epub,.txt,.md" + className="hidden" + onChange={(e) => { + void onFiles(l.id, e.target.files); + e.target.value = ""; + }} + /> + + 白名单:cbz/pdf/epub/txt/md +
+
+ ))} + {(libsQ.data ?? []).length === 0 && !libsQ.isPending && ( +

还没有库,用上方表单创建。

+ )} +
+
+
+ ); +} diff --git a/web/src/pages/admin/Users.tsx b/web/src/pages/admin/Users.tsx new file mode 100644 index 0000000..c29a5ac --- /dev/null +++ b/web/src/pages/admin/Users.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { api } from "../../api/client"; +import { useAuth } from "../../auth/AuthContext"; +import { TopBar } from "../../components/TopBar"; +import { toast } from "../../components/Toaster"; +import { btn, btnPrimary, card, input } from "../../components/ui"; + +export default function AdminUsers() { + const qc = useQueryClient(); + const { user: me } = useAuth(); + const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers }); + const [u, setU] = useState(""); + const [p, setP] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); + + const create = useMutation({ + mutationFn: () => api.createUser(u.trim(), p, role), + onSuccess: () => { + toast("ok", "用户已创建"); + setU(""); + setP(""); + void qc.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"), + }); + const del = useMutation({ + mutationFn: (id: number) => api.deleteUser(id), + onSuccess: () => { + toast("ok", "用户已删除(其阅读进度一并删除)"); + void qc.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), + }); + + function submit(e: FormEvent) { + e.preventDefault(); + create.mutate(); + } + + return ( +
+ +
+

用户管理

+
+ setU(e.target.value)} /> + setP(e.target.value)} /> + + +
+ + + + + + + + + + + {(usersQ.data ?? []).map((x) => ( + + + + + + + + ))} + +
ID用户名角色创建时间 +
{x.id}{x.username}{x.role}{x.created_at.slice(0, 19).replace("T", " ")} + +
+
+
+ ); +}