feat: 更新管理员导航,添加管理员权限模块和相关API导出,优化仪表盘链接样式
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Ticket,
|
||||
Users,
|
||||
@@ -35,6 +36,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
reports: FileSpreadsheet,
|
||||
reconcile: Scale,
|
||||
audit: ScrollText,
|
||||
admin_users: ShieldCheck,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ export type AdminNavItem = {
|
||||
| "jackpot"
|
||||
| "reports"
|
||||
| "reconcile"
|
||||
| "audit";
|
||||
| "audit"
|
||||
| "admin_users";
|
||||
activeMatchPrefix?: string;
|
||||
/** 拥有任一权限 slug 即显示侧栏项 */
|
||||
requiredAny?: readonly string[];
|
||||
@@ -30,32 +31,6 @@ export type AdminNavItem = {
|
||||
|
||||
export const adminShellNavItems: AdminNavItem[] = [
|
||||
{ segment: "dashboard", label: "仪表盘", href: "/admin" },
|
||||
{
|
||||
segment: "service_desk",
|
||||
label: "客服 / 财务",
|
||||
href: "/admin/service-desk",
|
||||
requiredAny: [
|
||||
"prd.users.view_cs",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.manage",
|
||||
"prd.wallet_reconcile.view_cs",
|
||||
"prd.wallet_reconcile.view",
|
||||
"prd.wallet_reconcile.manage",
|
||||
"prd.report.finance",
|
||||
"prd.report.player",
|
||||
"prd.draw_result.view",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "players",
|
||||
label: "玩家查询",
|
||||
href: "/admin/players",
|
||||
requiredAny: [
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "draws",
|
||||
label: "开奖",
|
||||
@@ -77,36 +52,6 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
"prd.jackpot.view",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "tickets",
|
||||
label: "玩家注单",
|
||||
href: "/admin/tickets",
|
||||
requiredAny: [
|
||||
"prd.users.view_cs",
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.draw_result.view",
|
||||
"prd.draw_result.manage",
|
||||
"prd.payout.view",
|
||||
"prd.payout.review",
|
||||
"prd.payout.manage",
|
||||
"prd.report.player",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "wallet",
|
||||
label: "钱包流水",
|
||||
href: "/admin/wallet/transactions",
|
||||
activeMatchPrefix: "/admin/wallet",
|
||||
requiredAny: [
|
||||
"prd.wallet_reconcile.manage",
|
||||
"prd.wallet_reconcile.view",
|
||||
"prd.wallet_reconcile.view_cs",
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "risk",
|
||||
label: "风控",
|
||||
@@ -131,14 +76,17 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
|
||||
},
|
||||
{
|
||||
segment: "reports",
|
||||
label: "报表导出",
|
||||
href: "/admin/reports",
|
||||
segment: "wallet",
|
||||
label: "钱包流水",
|
||||
href: "/admin/wallet/transactions",
|
||||
activeMatchPrefix: "/admin/wallet",
|
||||
requiredAny: [
|
||||
"prd.report.all",
|
||||
"prd.report.risk",
|
||||
"prd.report.finance",
|
||||
"prd.report.player",
|
||||
"prd.wallet_reconcile.manage",
|
||||
"prd.wallet_reconcile.view",
|
||||
"prd.wallet_reconcile.view_cs",
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,11 +99,70 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
"prd.wallet_reconcile.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "tickets",
|
||||
label: "玩家注单",
|
||||
href: "/admin/tickets",
|
||||
requiredAny: [
|
||||
"prd.users.view_cs",
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.draw_result.view",
|
||||
"prd.draw_result.manage",
|
||||
"prd.payout.view",
|
||||
"prd.payout.review",
|
||||
"prd.payout.manage",
|
||||
"prd.report.player",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "players",
|
||||
label: "玩家查询",
|
||||
href: "/admin/players",
|
||||
requiredAny: [
|
||||
"prd.users.manage",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "service_desk",
|
||||
label: "客服 / 财务",
|
||||
href: "/admin/service-desk",
|
||||
requiredAny: [
|
||||
"prd.users.view_cs",
|
||||
"prd.users.view_finance",
|
||||
"prd.users.manage",
|
||||
"prd.wallet_reconcile.view_cs",
|
||||
"prd.wallet_reconcile.view",
|
||||
"prd.wallet_reconcile.manage",
|
||||
"prd.report.finance",
|
||||
"prd.report.player",
|
||||
"prd.draw_result.view",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "reports",
|
||||
label: "报表导出",
|
||||
href: "/admin/reports",
|
||||
requiredAny: [
|
||||
"prd.report.all",
|
||||
"prd.report.risk",
|
||||
"prd.report.finance",
|
||||
"prd.report.player",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "audit",
|
||||
label: "审计日志",
|
||||
href: "/admin/audit-logs",
|
||||
requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"],
|
||||
},
|
||||
{
|
||||
segment: "admin_users",
|
||||
label: "管理员权限",
|
||||
href: "/admin/admin-users",
|
||||
requiredAny: ["prd.admin_user.manage"],
|
||||
},
|
||||
{ segment: "settings", label: "系统设置", href: "/admin/settings" },
|
||||
];
|
||||
|
||||
301
src/modules/admin-users/admin-users-console.tsx
Normal file
301
src/modules/admin-users/admin-users-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/modules/admin-users/meta.ts
Normal file
5
src/modules/admin-users/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const adminUsersModuleMeta = {
|
||||
segment: "admin_users",
|
||||
title: "管理员权限",
|
||||
description: "",
|
||||
} as const;
|
||||
@@ -489,7 +489,10 @@ export function DashboardConsole(): ReactElement {
|
||||
</p>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className="mt-2 inline-block text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-2 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/draws/${drawId}`}
|
||||
>
|
||||
期号详情
|
||||
@@ -533,7 +536,10 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className="mt-1 inline-block text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/risk/draws/${drawId}/occupancy`}
|
||||
>
|
||||
占用明细
|
||||
@@ -576,7 +582,10 @@ export function DashboardConsole(): ReactElement {
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/hot`}
|
||||
className="text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
@@ -600,7 +609,10 @@ export function DashboardConsole(): ReactElement {
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/sold-out`}
|
||||
className="text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user