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:
@@ -1,64 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Link, NavLink } from "react-router-dom";
|
|
||||||
import { useAuth } from "../auth/AuthContext";
|
|
||||||
import { IconBooks, IconFolder, IconLogout, IconUser } from "./icons";
|
|
||||||
import { btnGhost } from "./ui";
|
|
||||||
|
|
||||||
export function TopBar({ right }: { right?: ReactNode }) {
|
|
||||||
const { user, isAdmin, logout } = useAuth();
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-30 flex shrink-0 items-center gap-1 border-b border-white/[0.06] bg-stone-950/80 px-4 py-2.5 backdrop-blur-md sm:px-6">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex shrink-0 items-center gap-2 rounded-lg pr-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
|
|
||||||
>
|
|
||||||
<span className="grid size-7 place-items-center rounded-lg bg-linear-to-br from-amber-500 to-orange-600 text-stone-950 shadow-md shadow-amber-500/25">
|
|
||||||
<IconBooks className="size-4.5" />
|
|
||||||
</span>
|
|
||||||
<span className="font-serif text-lg font-semibold tracking-wide">
|
|
||||||
Book<span className="text-amber-400">Lib</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
{isAdmin && (
|
|
||||||
<nav aria-label="管理" className="ml-2 hidden items-center gap-1 sm:flex">
|
|
||||||
<TabLink to="/admin/users">
|
|
||||||
<IconUser className="size-4" />
|
|
||||||
用户
|
|
||||||
</TabLink>
|
|
||||||
<TabLink to="/admin/libraries">
|
|
||||||
<IconFolder className="size-4" />
|
|
||||||
库管理
|
|
||||||
</TabLink>
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
|
||||||
{right}
|
|
||||||
<span className="flex items-center gap-2 rounded-full bg-white/[0.05] py-1 pl-1 pr-3 text-sm text-stone-300 ring-1 ring-white/[0.07]">
|
|
||||||
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-amber-500/20 text-[11px] font-semibold text-amber-300 ring-1 ring-amber-500/30">
|
|
||||||
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="hidden max-w-28 truncate sm:inline">{user?.username}</span>
|
|
||||||
{isAdmin && <span className="hidden rounded bg-amber-500/15 px-1 text-[10px] font-semibold text-amber-300 md:inline">admin</span>}
|
|
||||||
</span>
|
|
||||||
<button className={btnGhost} onClick={logout} aria-label="退出登录" title="退出登录">
|
|
||||||
<IconLogout className="size-4.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabLink({ to, children }: { to: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
to={to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 ${
|
|
||||||
isActive ? "bg-white/[0.09] text-stone-100 ring-1 ring-white/10" : "text-stone-400 hover:bg-white/[0.06] hover:text-stone-100"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +1,15 @@
|
|||||||
import type { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
// 统一 1.7px 描边的线性图标(Phosphor 风格),尺寸由外部 className 控制
|
|
||||||
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
|
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.7}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...p}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type P = SVGProps<SVGSVGElement>;
|
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
|
||||||
|
|
||||||
export const IconBooks = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10a1.5 1.5 0 0 1 1.5 1.5V18a2 2 0 0 0-2-2H5.5A1.5 1.5 0 0 1 4 14.5v-9Z" />
|
|
||||||
<path d="M13 8h4.5A1.5 1.5 0 0 1 19 9.5v8A1.5 1.5 0 0 1 17.5 19H13" />
|
|
||||||
<path d="M7 4v12M16 12.5v4" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconSearch = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4 4" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconRefresh = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M20 12a8 8 0 1 1-2.34-5.66" />
|
|
||||||
<path d="M20 4v4.5h-4.5" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconScan = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M4 8V5.5A1.5 1.5 0 0 1 5.5 4H8M16 4h2.5A1.5 1.5 0 0 1 20 5.5V8M20 16v2.5a1.5 1.5 0 0 1-1.5 1.5H16M8 20H5.5A1.5 1.5 0 0 1 4 18.5V16" />
|
|
||||||
<path d="M7 12h10" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconX = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="m6 6 12 12M18 6 6 18" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconLogout = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14" />
|
|
||||||
<path d="M10 8 6 12l4 4M6 12h10" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconArrowLeft = (p: P) => (
|
|
||||||
<Svg {...p}>
|
<Svg {...p}>
|
||||||
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
|
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const IconBookOpen = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M12 6.5C10.5 5 8.5 4.5 4 4.5v13c4.5 0 6.5.5 8 2 1.5-1.5 3.5-2 8-2v-13c-4.5 0-6.5.5-8 2Z" />
|
|
||||||
<path d="M12 6.5v13" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconUser = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<circle cx="12" cy="8" r="3.5" />
|
|
||||||
<path d="M5 19.5c1.2-3 3.7-4.5 7-4.5s5.8 1.5 7 4.5" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconLock = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<rect x="5.5" y="10.5" width="13" height="9" rx="1.5" />
|
|
||||||
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5M12 14v2.5" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconCheck = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="m5 12.5 4.5 4.5L19 7" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconAlert = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<circle cx="12" cy="12" r="8.5" />
|
|
||||||
<path d="M12 7.5V13m0 3.5v.01" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconClock = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<circle cx="12" cy="12" r="8.5" />
|
|
||||||
<path d="M12 7.5V12l3 2" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconFolder = (p: P) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M4 6.5A1.5 1.5 0 0 1 5.5 5h3.2a1.5 1.5 0 0 1 1.2.6l.9 1.2a1.5 1.5 0 0 0 1.2.6h6.5A1.5 1.5 0 0 1 20 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5v-11Z" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -4,18 +4,8 @@ export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器
|
|||||||
|
|
||||||
export const btn =
|
export const btn =
|
||||||
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
|
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
export const btnPrimary =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-sm font-medium text-stone-950 shadow-md shadow-amber-500/20 transition-colors hover:bg-amber-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50";
|
|
||||||
export const btnGhost =
|
export const btnGhost =
|
||||||
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
||||||
export const input =
|
|
||||||
"rounded-lg bg-white/[0.04] px-3 py-1.5 text-sm ring-1 ring-white/10 outline-none transition placeholder:text-stone-500 focus:bg-white/[0.06] focus:ring-2 focus:ring-amber-500/60";
|
|
||||||
export const card = "rounded-xl bg-stone-900/70 ring-1 ring-white/[0.07]";
|
|
||||||
|
|
||||||
// 库/筛选分段 pill
|
|
||||||
export const pill =
|
|
||||||
"rounded-full px-3.5 py-1.5 text-sm text-stone-400 ring-1 ring-transparent transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
|
||||||
export const pillActive = "bg-amber-500/15 text-amber-300 ring-amber-500/30 hover:bg-amber-500/20 hover:text-amber-200";
|
|
||||||
|
|
||||||
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
|
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
|
||||||
export const FORMAT_BADGE: Record<Format, string> = {
|
export const FORMAT_BADGE: Record<Format, string> = {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api } from "../../api/client";
|
||||||
import { TopBar } from "../../components/TopBar";
|
|
||||||
import { toast } from "../../components/Toaster";
|
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() {
|
export default function AdminLibraries() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -30,10 +33,10 @@ export default function AdminLibraries() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onFiles(libId: number, list: FileList | null) {
|
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;
|
if (!files.length) return;
|
||||||
try {
|
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 收编`);
|
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
|
||||||
void qc.invalidateQueries({ queryKey: ["books"] });
|
void qc.invalidateQueries({ queryKey: ["books"] });
|
||||||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
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) {
|
function submit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
create.mutate();
|
create.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const libs = libsQ.data ?? [];
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
|
||||||
<TopBar />
|
<h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
<Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
<Button type="submit" disabled={create.isPending || !name.trim()}>
|
||||||
<input
|
{create.isPending ? "创建中…" : "建库"}
|
||||||
className={input + " flex-1"}
|
</Button>
|
||||||
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
|
</form>
|
||||||
value={name}
|
{libsQ.isPending && (
|
||||||
onChange={(e) => setName(e.target.value)}
|
<div className="space-y-3">
|
||||||
/>
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<button className={btnPrimary} disabled={create.isPending || !name.trim()}>
|
<Skeleton key={i} className="h-28 w-full" />
|
||||||
{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>
|
|
||||||
))}
|
))}
|
||||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
|
||||||
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { TopBar } from "../../components/TopBar";
|
|
||||||
import { toast } from "../../components/Toaster";
|
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() {
|
export default function AdminUsers() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -39,55 +46,68 @@ export default function AdminUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
|
||||||
<TopBar />
|
<h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
<h1 className="mb-4 text-lg font-semibold">用户管理</h1>
|
<Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
|
||||||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
<Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
|
||||||
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
|
<Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
|
||||||
<input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
|
<SelectContent>
|
||||||
<option value="member">member</option>
|
<SelectItem value="member">member</SelectItem>
|
||||||
<option value="admin">admin</option>
|
<SelectItem value="admin">admin</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
|
</Select>
|
||||||
{create.isPending ? "创建中…" : "创建"}
|
<Button type="submit" disabled={create.isPending || !u.trim() || p.length < 8}>
|
||||||
</button>
|
{create.isPending ? "创建中…" : "创建"}
|
||||||
</form>
|
</Button>
|
||||||
<table className="w-full max-w-2xl text-sm">
|
</form>
|
||||||
<thead className="text-left text-stone-500">
|
{usersQ.isPending ? (
|
||||||
<tr>
|
<Skeleton className="h-40 w-full" />
|
||||||
<th className="py-1">ID</th>
|
) : (
|
||||||
<th>用户名</th>
|
<Table>
|
||||||
<th>角色</th>
|
<TableHeader>
|
||||||
<th>创建时间</th>
|
<TableRow>
|
||||||
<th />
|
<TableHead className="w-16">ID</TableHead>
|
||||||
</tr>
|
<TableHead>用户名</TableHead>
|
||||||
</thead>
|
<TableHead>角色</TableHead>
|
||||||
<tbody>
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="w-16" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{(usersQ.data ?? []).map((x) => (
|
{(usersQ.data ?? []).map((x) => (
|
||||||
<tr key={x.id} className="border-t border-stone-800">
|
<TableRow key={x.id}>
|
||||||
<td className="py-1 text-stone-500">{x.id}</td>
|
<TableCell className="text-muted-foreground">{x.id}</TableCell>
|
||||||
<td>{x.username}</td>
|
<TableCell>{x.username}</TableCell>
|
||||||
<td>{x.role}</td>
|
<TableCell>{x.role}</TableCell>
|
||||||
<td className="text-stone-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
<TableCell className="text-muted-foreground">{x.created_at.slice(0, 19).replace("T", " ")}</TableCell>
|
||||||
<td className="py-1 text-right">
|
<TableCell className="text-right">
|
||||||
<button
|
<AlertDialog>
|
||||||
className={btn + " text-red-300"}
|
<AlertDialogTrigger asChild>
|
||||||
disabled={x.id === me?.id}
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}>
|
||||||
title={x.id === me?.id ? "不能删除自己" : undefined}
|
删除
|
||||||
onClick={() => {
|
</Button>
|
||||||
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
|
</AlertDialogTrigger>
|
||||||
}}
|
<AlertDialogContent>
|
||||||
>
|
<AlertDialogHeader>
|
||||||
删除
|
<AlertDialogTitle>删除用户 {x.username}?</AlertDialogTitle>
|
||||||
</button>
|
<AlertDialogDescription>该用户的阅读进度与书签一并删除。</AlertDialogDescription>
|
||||||
</td>
|
</AlertDialogHeader>
|
||||||
</tr>
|
<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>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user