feat: 更新管理员导航,添加管理员权限模块和相关API导出,优化仪表盘链接样式

This commit is contained in:
2026-05-11 17:54:35 +08:00
parent 76e318be8f
commit 5dd7aa1185
9 changed files with 492 additions and 68 deletions

View File

@@ -0,0 +1,301 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
getAdminUserPermissionCatalog,
getAdminUsers,
putAdminUserPermissions,
} from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
export function AdminUsersConsole(): React.ReactElement {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [keyword, setKeyword] = useState("");
const [query, setQuery] = useState("");
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [items, setItems] = useState<AdminUserPermissionRow[]>([]);
const [total, setTotal] = useState(0);
const [lastPage, setLastPage] = useState(1);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const selectedUser = useMemo(
() => items.find((u) => u.id === selectedId) ?? null,
[items, selectedId],
);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [catalogData, listData] = await Promise.all([
getAdminUserPermissionCatalog(),
getAdminUsers({
page,
per_page: perPage,
keyword: query.trim() || undefined,
}),
]);
setCatalog(catalogData);
setItems(listData.items);
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载管理员列表失败";
setErr(msg);
setItems([]);
setTotal(0);
setLastPage(1);
} finally {
setLoading(false);
}
}, [page, perPage, query]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function togglePermission(slug: string, checked: boolean): void {
setDraftPermissions((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((s) => s !== slug);
});
}
async function savePermissions(): Promise<void> {
if (!selectedUser) {
return;
}
setSaving(true);
try {
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
setDraftPermissions([...result.direct_permissions].sort());
setItems((prev) =>
prev.map((row) =>
row.id === result.id
? {
...row,
direct_permissions: result.direct_permissions,
effective_permissions: result.effective_permissions,
}
: row,
),
);
toast.success(`已更新 ${result.username} 的权限`);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存权限失败";
toast.error(msg);
} finally {
setSaving(false);
}
}
return (
<div className="flex w-full max-w-none flex-col gap-6">
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
value={keyword}
placeholder="按用户名 / 昵称 / 邮箱搜索"
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setPage(1);
setQuery(keyword.trim());
}
}}
/>
<Button
type="button"
onClick={() => {
setPage(1);
setQuery(keyword.trim());
}}
>
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
items.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{row.username}</span>
<span className="text-xs text-muted-foreground">{row.email ?? "—"}</span>
</div>
</TableCell>
<TableCell>{row.nickname}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.roles.length === 0 ? (
<span className="text-xs text-muted-foreground"></span>
) : (
row.roles.map((slug) => (
<Badge key={slug} variant="secondary">
{slug}
</Badge>
))
)}
</div>
</TableCell>
<TableCell className="tabular-nums">{row.direct_permissions.length}</TableCell>
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
<TableCell>
<Button
type="button"
size="sm"
variant={selectedId === row.id ? "default" : "outline"}
onClick={() => {
setSelectedId(row.id);
setDraftPermissions([...row.direct_permissions].sort());
}}
>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="admin-users-per-page"
total={total}
page={page}
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
</div>
<Button
type="button"
onClick={() => void savePermissions()}
disabled={!selectedUser || saving}
>
{saving ? "保存中…" : "保存权限"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!selectedUser ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<>
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<span className="ml-1 font-medium">{selectedUser.username}</span>
<span className="ml-2 text-muted-foreground">({selectedUser.nickname})</span>
</div>
<div className="grid gap-3 rounded-md border p-4 sm:grid-cols-2 lg:grid-cols-3">
{(catalog?.permissions ?? []).map((p) => {
const checked = draftPermissions.includes(p.slug);
return (
<label key={p.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none">{p.name}</span>
<span className="text-xs text-muted-foreground">{p.slug}</span>
</span>
</label>
);
})}
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{selectedUser.roles.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
selectedUser.roles.map((slug) => (
<Badge key={slug} variant="outline">
{slug}
</Badge>
))
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}