feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface

This commit is contained in:
2026-09-08 19:14:41 +08:00
parent 273f35a42f
commit dcaf502de6
5 changed files with 133 additions and 280 deletions
+62 -54
View File
@@ -1,9 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, type FormEvent } from "react";
import { useRef, useState, type DragEvent, type FormEvent } from "react";
import { RefreshCw, Upload } from "lucide-react";
import { api } from "../../api/client";
import { TopBar } from "../../components/TopBar";
import { toast } from "../../components/Toaster";
import { btn, btnPrimary, card, input } from "../../components/ui";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminLibraries() {
const qc = useQueryClient();
@@ -30,10 +33,10 @@ export default function AdminLibraries() {
});
async function onFiles(libId: number, list: FileList | null) {
const files = Array.from(list ?? []); // FileList 是活引用,onChange 里 input.value="" 会清空它,必须先快照
const files = Array.from(list ?? []); // FileList 活引用,先快照
if (!files.length) return;
try {
for (const f of files) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
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);
@@ -42,63 +45,68 @@ export default function AdminLibraries() {
}
}
function onDrop(e: DragEvent, libId: number) {
e.preventDefault();
void onFiles(libId, e.dataTransfer.files);
}
function submit(e: FormEvent) {
e.preventDefault();
create.mutate();
}
const libs = libsQ.data ?? [];
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 + " flex-1"}
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button className={btnPrimary} disabled={create.isPending || !name.trim()}>
{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-stone-500">#{l.id}</span>
</div>
<p className="mb-3 text-xs break-all text-stone-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,.zip,.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-stone-600">白名单:cbz/zip/pdf/epub/txt/md</span>
</div>
</div>
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} />
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "建库"}
</Button>
</form>
{libsQ.isPending && (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full" />
))}
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
)}
</div>
)}
<div className="flex flex-col gap-4">
{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>
);
+69 -49
View File
@@ -2,9 +2,16 @@ 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";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminUsers() {
const qc = useQueryClient();
@@ -39,55 +46,68 @@ export default function AdminUsers() {
}
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} type="password" 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-stone-500">
<tr>
<th className="py-1">ID</th>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th />
</tr>
</thead>
<tbody>
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={create.isPending || !u.trim() || p.length < 8}>
{create.isPending ? "创建中…" : "创建"}
</Button>
</form>
{usersQ.isPending ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>用户名</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{(usersQ.data ?? []).map((x) => (
<tr key={x.id} className="border-t border-stone-800">
<td className="py-1 text-stone-500">{x.id}</td>
<td>{x.username}</td>
<td>{x.role}</td>
<td className="text-stone-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>
<TableRow key={x.id}>
<TableCell className="text-muted-foreground">{x.id}</TableCell>
<TableCell>{x.username}</TableCell>
<TableCell>{x.role}</TableCell>
<TableCell className="text-muted-foreground">{x.created_at.slice(0, 19).replace("T", " ")}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}>
删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除用户 {x.username}?</AlertDialogTitle>
<AlertDialogDescription>该用户的阅读进度与书签一并删除。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-primary-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</div>
</TableBody>
</Table>
)}
</div>
);
}