feat(web): table-style 库管理, admin entries under avatar 系统管理, shelf-view upload button
This commit is contained in:
@@ -31,6 +31,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Changed / 变更
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Admin polish: 库管理 is now a table styled like 用户管理 (scan/upload per row, drag-to-row upload kept); both admin pages moved out of the sidebar and bottom tabs into a 系统管理 group under the avatar menu; admins can upload files straight from a library's shelf view via a new 上传文件 button next to 扫描此库.
|
||||||
|
- 后台入口统一:库管理改为用户管理同款表格(行内扫描/上传,仍支持拖文件到行);两个管理页从侧边栏与移动端底 Tab 收进头像菜单的「系统管理」分组;书架进入某个库时,管理员可在「扫描此库」旁直接「上传文件」。
|
||||||
|
|
||||||
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
|
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
|
||||||
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
|
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ export function AppShell() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
|
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>系统管理</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/admin/libraries">
|
||||||
|
<FolderCog /> 库管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/admin/users">
|
||||||
|
<Users /> 用户管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={logout}>
|
<DropdownMenuItem onSelect={logout}>
|
||||||
<LogOut /> 退出登录
|
<LogOut /> 退出登录
|
||||||
@@ -73,12 +89,6 @@ export function AppShell() {
|
|||||||
<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} />
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" active={loc.pathname.startsWith("/admin/libraries")} />
|
|
||||||
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" active={loc.pathname.startsWith("/admin/users")} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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">
|
||||||
@@ -112,8 +122,6 @@ export function AppShell() {
|
|||||||
{/* 移动端底 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="书架" />
|
||||||
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
|
|
||||||
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRef, useState, type ComponentProps } from "react";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "./Toaster";
|
||||||
|
|
||||||
|
export async function uploadToLib(qc: QueryClient, libId: number, list: FileList | File[]): Promise<boolean> {
|
||||||
|
const files = Array.from(list); // FileList 活引用,先快照
|
||||||
|
if (!files.length) return false;
|
||||||
|
try {
|
||||||
|
for (const f of files) await api.uploadBook(libId, f);
|
||||||
|
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["books"] });
|
||||||
|
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast("err", e instanceof Error ? e.message : "上传失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传按钮(隐藏 file input),白名单:cbz/zip/pdf/epub/txt/md. */
|
||||||
|
export function UploadButton({
|
||||||
|
libId,
|
||||||
|
variant,
|
||||||
|
size = "sm",
|
||||||
|
label = "上传",
|
||||||
|
}: {
|
||||||
|
libId: number;
|
||||||
|
variant?: ComponentProps<typeof Button>["variant"];
|
||||||
|
size?: ComponentProps<typeof Button>["size"];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
e.target.value = "";
|
||||||
|
setBusy(true);
|
||||||
|
await uploadToLib(qc, libId, files);
|
||||||
|
setBusy(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant={variant} size={size} disabled={busy} onClick={() => ref.current?.click()}>
|
||||||
|
<Upload /> {busy ? "上传中…" : label}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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 { formatBadge } from "@/components/ui";
|
import { formatBadge } from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
@@ -177,10 +178,13 @@ export default function Shelf() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && lib > LIB0 && (
|
{isAdmin && lib > LIB0 && (
|
||||||
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
|
<>
|
||||||
<ScanLine className="size-4" />
|
<UploadButton libId={lib} variant="outline" label="上传文件" />
|
||||||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
|
||||||
</Button>
|
<ScanLine className="size-4" />
|
||||||
|
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||||
|
</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" />
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRef, useState, type DragEvent, type FormEvent } from "react";
|
import { useState, type DragEvent, type FormEvent } from "react";
|
||||||
import { RefreshCw, Upload } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { toast } from "../../components/Toaster";
|
import { toast } from "../../components/Toaster";
|
||||||
|
import { UploadButton, uploadToLib } from "@/components/upload-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
export default function AdminLibraries() {
|
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 fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () => api.createLibrary(name.trim()),
|
mutationFn: () => api.createLibrary(name.trim()),
|
||||||
@@ -32,22 +32,9 @@ export default function AdminLibraries() {
|
|||||||
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onFiles(libId: number, list: FileList | null) {
|
|
||||||
const files = Array.from(list ?? []); // FileList 活引用,先快照
|
|
||||||
if (!files.length) return;
|
|
||||||
try {
|
|
||||||
for (const f of files) await api.uploadBook(libId, f);
|
|
||||||
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
|
|
||||||
void qc.invalidateQueries({ queryKey: ["books"] });
|
|
||||||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
|
||||||
} catch (e) {
|
|
||||||
toast("err", e instanceof Error ? e.message : "上传失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(e: DragEvent, libId: number) {
|
function onDrop(e: DragEvent, libId: number) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void onFiles(libId, e.dataTransfer.files);
|
void uploadToLib(qc, libId, Array.from(e.dataTransfer.files));
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(e: FormEvent) {
|
function submit(e: FormEvent) {
|
||||||
@@ -65,49 +52,40 @@ export default function AdminLibraries() {
|
|||||||
{create.isPending ? "创建中…" : "建库"}
|
{create.isPending ? "创建中…" : "建库"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{libsQ.isPending && (
|
{libsQ.isPending ? (
|
||||||
<div className="space-y-3">
|
<Skeleton className="h-40 w-full" />
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
) : libs.length === 0 ? (
|
||||||
<Skeleton key={i} className="h-28 w-full" />
|
<p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">ID</TableHead>
|
||||||
|
<TableHead>库名</TableHead>
|
||||||
|
<TableHead>服务端路径</TableHead>
|
||||||
|
<TableHead className="w-44" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{libs.map((l) => (
|
||||||
|
<TableRow key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
|
||||||
|
<TableCell className="text-muted-foreground">{l.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{l.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{l.root_path}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||||||
|
<RefreshCw /> 扫描
|
||||||
|
</Button>
|
||||||
|
<UploadButton libId={l.id} variant="ghost" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-4">
|
<p className="mt-3 text-xs text-muted-foreground">上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行</p>
|
||||||
{libs.map((l) => (
|
|
||||||
<Card key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
|
|
||||||
<CardHeader className="flex-row items-baseline justify-between gap-3 space-y-0 py-3">
|
|
||||||
<CardTitle className="flex items-baseline gap-2 text-base">
|
|
||||||
{l.name}
|
|
||||||
<span className="text-xs font-normal text-muted-foreground">#{l.id}</span>
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground">{l.root_path}</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap items-center gap-2 pt-0">
|
|
||||||
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
|
||||||
<RefreshCw /> {scan.isPending ? "扫描中…" : "扫描"}
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={(el) => {
|
|
||||||
fileRefs.current[l.id] = el;
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
void onFiles(l.id, e.target.files);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button size="sm" onClick={() => fileRefs.current[l.id]?.click()}>
|
|
||||||
<Upload /> 上传文件
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{libs.length === 0 && !libsQ.isPending && <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user