feat(web): admin users CRUD + libraries create/scan/upload
This commit is contained in:
+23
-1
@@ -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() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireAdmin>
|
||||
<AdminUsers />
|
||||
</RequireAdmin>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/libraries"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireAdmin>
|
||||
<AdminLibraries />
|
||||
</RequireAdmin>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { TopBar } from "../../components/TopBar";
|
||||
import { toast } from "../../components/Toaster";
|
||||
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||||
|
||||
export default function AdminLibraries() {
|
||||
const qc = useQueryClient();
|
||||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createLibrary(name.trim(), root.trim()),
|
||||
onSuccess: (lib) => {
|
||||
toast("ok", `库「${lib.name}」已创建`);
|
||||
setName("");
|
||||
setRoot("");
|
||||
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
||||
},
|
||||
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
||||
});
|
||||
const scan = useMutation({
|
||||
mutationFn: (id: number) => api.scanLibrary(id),
|
||||
onSuccess: () => {
|
||||
toast("ok", "扫描已触发");
|
||||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000);
|
||||
},
|
||||
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||||
});
|
||||
|
||||
async function onFiles(libId: number, list: FileList | null) {
|
||||
if (!list?.length) return;
|
||||
try {
|
||||
for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
|
||||
toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`);
|
||||
void qc.invalidateQueries({ queryKey: ["books"] });
|
||||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
||||
} catch (e) {
|
||||
toast("err", e instanceof Error ? e.message : "上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
create.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TopBar />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
||||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||||
<input className={input} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
className={input + " flex-1"}
|
||||
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)"
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
/>
|
||||
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}>
|
||||
{create.isPending ? "创建中…" : "建库"}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex max-w-2xl flex-col gap-4">
|
||||
{(libsQ.data ?? []).map((l) => (
|
||||
<div key={l.id} className={`${card} p-4`}>
|
||||
<div className="mb-1 flex items-baseline gap-3">
|
||||
<span className="font-medium">{l.name}</span>
|
||||
<span className="text-xs text-zinc-500">#{l.id}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||||
{scan.isPending ? "扫描中…" : "扫描"}
|
||||
</button>
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileRefs.current[l.id] = el;
|
||||
}}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".cbz,.pdf,.epub,.txt,.md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void onFiles(l.id, e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
||||
上传文件
|
||||
</button>
|
||||
<span className="text-xs text-zinc-600">白名单:cbz/pdf/epub/txt/md</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
||||
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { TopBar } from "../../components/TopBar";
|
||||
import { toast } from "../../components/Toaster";
|
||||
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||||
|
||||
export default function AdminUsers() {
|
||||
const qc = useQueryClient();
|
||||
const { user: me } = useAuth();
|
||||
const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers });
|
||||
const [u, setU] = useState("");
|
||||
const [p, setP] = useState("");
|
||||
const [role, setRole] = useState<"member" | "admin">("member");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createUser(u.trim(), p, role),
|
||||
onSuccess: () => {
|
||||
toast("ok", "用户已创建");
|
||||
setU("");
|
||||
setP("");
|
||||
void qc.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
toast("ok", "用户已删除(其阅读进度一并删除)");
|
||||
void qc.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||||
});
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
create.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TopBar />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h1 className="mb-4 text-lg font-semibold">用户管理</h1>
|
||||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||||
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
|
||||
<input className={input} placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
|
||||
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
|
||||
{create.isPending ? "创建中…" : "创建"}
|
||||
</button>
|
||||
</form>
|
||||
<table className="w-full max-w-2xl text-sm">
|
||||
<thead className="text-left text-zinc-500">
|
||||
<tr>
|
||||
<th className="py-1">ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQ.data ?? []).map((x) => (
|
||||
<tr key={x.id} className="border-t border-zinc-800">
|
||||
<td className="py-1 text-zinc-500">{x.id}</td>
|
||||
<td>{x.username}</td>
|
||||
<td>{x.role}</td>
|
||||
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
className={btn + " text-red-300"}
|
||||
disabled={x.id === me?.id}
|
||||
title={x.id === me?.id ? "不能删除自己" : undefined}
|
||||
onClick={() => {
|
||||
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user