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

35
src/api/admin-users.ts Normal file
View File

@@ -0,0 +1,35 @@
import { adminRequest } from "@/lib/admin-http";
import { API_V1_PREFIX } from "./paths";
import type {
AdminPermissionCatalogData,
AdminUserPermissionListData,
AdminUserPermissionSyncData,
} from "@/types/api/admin-user";
const A = `${API_V1_PREFIX}/admin`;
export async function getAdminUsers(params?: {
page?: number;
per_page?: number;
keyword?: string;
}): Promise<AdminUserPermissionListData> {
return adminRequest.get<AdminUserPermissionListData>(`${A}/admin-users`, {
params,
});
}
export async function getAdminUserPermissionCatalog(): Promise<AdminPermissionCatalogData> {
return adminRequest.get<AdminPermissionCatalogData>(`${A}/admin-user-permission-catalog`);
}
export async function putAdminUserPermissions(
adminUserId: number,
permissionSlugs: string[],
): Promise<AdminUserPermissionSyncData> {
return adminRequest.put<AdminUserPermissionSyncData>(
`${A}/admin-users/${adminUserId}/permissions`,
{ permission_slugs: permissionSlugs },
);
}

View File

@@ -0,0 +1,16 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminUsersConsole } from "@/modules/admin-users/admin-users-console";
import { adminUsersModuleMeta } from "@/modules/admin-users/meta";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: adminUsersModuleMeta.title,
};
export default function AdminUsersPage() {
return (
<ModuleScaffold className="w-full max-w-none">
<AdminUsersConsole />
</ModuleScaffold>
);
}

View File

@@ -11,6 +11,7 @@ import {
ScrollText, ScrollText,
Settings, Settings,
ShieldAlert, ShieldAlert,
ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
Ticket, Ticket,
Users, Users,
@@ -35,6 +36,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
reports: FileSpreadsheet, reports: FileSpreadsheet,
reconcile: Scale, reconcile: Scale,
audit: ScrollText, audit: ScrollText,
admin_users: ShieldCheck,
settings: Settings, settings: Settings,
}; };

View File

@@ -22,7 +22,8 @@ export type AdminNavItem = {
| "jackpot" | "jackpot"
| "reports" | "reports"
| "reconcile" | "reconcile"
| "audit"; | "audit"
| "admin_users";
activeMatchPrefix?: string; activeMatchPrefix?: string;
/** 拥有任一权限 slug 即显示侧栏项 */ /** 拥有任一权限 slug 即显示侧栏项 */
requiredAny?: readonly string[]; requiredAny?: readonly string[];
@@ -30,32 +31,6 @@ export type AdminNavItem = {
export const adminShellNavItems: AdminNavItem[] = [ export const adminShellNavItems: AdminNavItem[] = [
{ segment: "dashboard", label: "仪表盘", href: "/admin" }, { 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", segment: "draws",
label: "开奖", label: "开奖",
@@ -77,36 +52,6 @@ export const adminShellNavItems: AdminNavItem[] = [
"prd.jackpot.view", "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", segment: "risk",
label: "风控", label: "风控",
@@ -131,14 +76,17 @@ export const adminShellNavItems: AdminNavItem[] = [
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"], requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
}, },
{ {
segment: "reports", segment: "wallet",
label: "报表导出", label: "钱包流水",
href: "/admin/reports", href: "/admin/wallet/transactions",
activeMatchPrefix: "/admin/wallet",
requiredAny: [ requiredAny: [
"prd.report.all", "prd.wallet_reconcile.manage",
"prd.report.risk", "prd.wallet_reconcile.view",
"prd.report.finance", "prd.wallet_reconcile.view_cs",
"prd.report.player", "prd.users.manage",
"prd.users.view_finance",
"prd.users.view_cs",
], ],
}, },
{ {
@@ -151,11 +99,70 @@ export const adminShellNavItems: AdminNavItem[] = [
"prd.wallet_reconcile.view_cs", "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", segment: "audit",
label: "审计日志", label: "审计日志",
href: "/admin/audit-logs", href: "/admin/audit-logs",
requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"], 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" }, { segment: "settings", label: "系统设置", href: "/admin/settings" },
]; ];

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>
);
}

View File

@@ -0,0 +1,5 @@
export const adminUsersModuleMeta = {
segment: "admin_users",
title: "管理员权限",
description: "",
} as const;

View File

@@ -489,7 +489,10 @@ export function DashboardConsole(): ReactElement {
</p> </p>
{drawId != null ? ( {drawId != null ? (
<Link <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}`} href={`/admin/draws/${drawId}`}
> >
@@ -533,7 +536,10 @@ export function DashboardConsole(): ReactElement {
</div> </div>
{drawId != null ? ( {drawId != null ? (
<Link <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`} href={`/admin/risk/draws/${drawId}/occupancy`}
> >
@@ -576,7 +582,10 @@ export function DashboardConsole(): ReactElement {
{drawId != null ? ( {drawId != null ? (
<Link <Link
href={`/admin/risk/draws/${drawId}/hot`} 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> </Link>
@@ -600,7 +609,10 @@ export function DashboardConsole(): ReactElement {
{drawId != null ? ( {drawId != null ? (
<Link <Link
href={`/admin/risk/draws/${drawId}/sold-out`} 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> </Link>

View File

@@ -0,0 +1,40 @@
export type AdminUserPermissionRow = {
id: number;
username: string;
nickname: string;
email: string | null;
status: number;
roles: string[];
direct_permissions: string[];
effective_permissions: string[];
};
export type AdminUserPermissionListData = {
items: AdminUserPermissionRow[];
meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
};
export type AdminPermissionCatalogData = {
permissions: { id: number; slug: string; name: string }[];
roles: {
id: number;
slug: string;
name: string;
permission_slugs: string[];
user_count: number;
}[];
};
export type AdminUserPermissionSyncData = {
id: number;
username: string;
nickname: string;
roles: string[];
direct_permissions: string[];
effective_permissions: string[];
};

View File

@@ -55,3 +55,9 @@ export type {
RiskCapItemRow, RiskCapItemRow,
RiskCapVersionDetail, RiskCapVersionDetail,
} from "./admin-config"; } from "./admin-config";
export type {
AdminPermissionCatalogData,
AdminUserPermissionListData,
AdminUserPermissionRow,
AdminUserPermissionSyncData,
} from "./admin-user";