diff --git a/src/api/admin-users.ts b/src/api/admin-users.ts new file mode 100644 index 0000000..37068b7 --- /dev/null +++ b/src/api/admin-users.ts @@ -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 { + return adminRequest.get(`${A}/admin-users`, { + params, + }); +} + +export async function getAdminUserPermissionCatalog(): Promise { + return adminRequest.get(`${A}/admin-user-permission-catalog`); +} + +export async function putAdminUserPermissions( + adminUserId: number, + permissionSlugs: string[], +): Promise { + return adminRequest.put( + `${A}/admin-users/${adminUserId}/permissions`, + { permission_slugs: permissionSlugs }, + ); +} diff --git a/src/app/admin/(shell)/admin-users/page.tsx b/src/app/admin/(shell)/admin-users/page.tsx new file mode 100644 index 0000000..634b189 --- /dev/null +++ b/src/app/admin/(shell)/admin-users/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index e34adcb..ad06537 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -11,6 +11,7 @@ import { ScrollText, Settings, ShieldAlert, + ShieldCheck, SlidersHorizontal, Ticket, Users, @@ -35,6 +36,7 @@ export const adminNavIconBySegment: Record reports: FileSpreadsheet, reconcile: Scale, audit: ScrollText, + admin_users: ShieldCheck, settings: Settings, }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index a5d9b8b..dd48b63 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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" }, ]; diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx new file mode 100644 index 0000000..de86152 --- /dev/null +++ b/src/modules/admin-users/admin-users-console.tsx @@ -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(null); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [lastPage, setLastPage] = useState(1); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + const [draftPermissions, setDraftPermissions] = useState([]); + 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 { + 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 ( +
+ + +
+ 管理员用户列表 +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setPage(1); + setQuery(keyword.trim()); + } + }} + /> + + +
+
+ + {err ?

{err}

: null} + {loading && items.length === 0 ? ( +

加载中…

+ ) : null} +
+ + + + ID + 账号 + 昵称 + 角色 + 直接权限 + 有效权限 + 操作 + + + + {items.length === 0 ? ( + + + 暂无数据 + + + ) : ( + items.map((row) => ( + + {row.id} + +
+ {row.username} + {row.email ?? "—"} +
+
+ {row.nickname} + +
+ {row.roles.length === 0 ? ( + + ) : ( + row.roles.map((slug) => ( + + {slug} + + )) + )} +
+
+ {row.direct_permissions.length} + {row.effective_permissions.length} + + + +
+ )) + )} +
+
+
+ { + setPerPage(n); + setPage(1); + }} + onPageChange={setPage} + /> +
+
+ + + +
+ 权限分配 +
+ +
+ + {!selectedUser ? ( +

请先在上方列表选择一个管理员。

+ ) : ( + <> +
+ 当前用户: + {selectedUser.username} + ({selectedUser.nickname}) +
+ +
+ {(catalog?.permissions ?? []).map((p) => { + const checked = draftPermissions.includes(p.slug); + return ( + + ); + })} +
+
+ +
+ {selectedUser.roles.length === 0 ? ( + 无角色 + ) : ( + selectedUser.roles.map((slug) => ( + + {slug} + + )) + )} +
+
+ + )} +
+
+
+ ); +} diff --git a/src/modules/admin-users/meta.ts b/src/modules/admin-users/meta.ts new file mode 100644 index 0000000..94e3b83 --- /dev/null +++ b/src/modules/admin-users/meta.ts @@ -0,0 +1,5 @@ +export const adminUsersModuleMeta = { + segment: "admin_users", + title: "管理员权限", + description: "", +} as const; diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index 2272e69..c6df38d 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -489,7 +489,10 @@ export function DashboardConsole(): ReactElement {

{drawId != null ? ( 期号详情 @@ -533,7 +536,10 @@ export function DashboardConsole(): ReactElement { {drawId != null ? ( 占用明细 @@ -576,7 +582,10 @@ export function DashboardConsole(): ReactElement { {drawId != null ? ( 查看全部 @@ -600,7 +609,10 @@ export function DashboardConsole(): ReactElement { {drawId != null ? ( 查看全部 diff --git a/src/types/api/admin-user.ts b/src/types/api/admin-user.ts new file mode 100644 index 0000000..4891804 --- /dev/null +++ b/src/types/api/admin-user.ts @@ -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[]; +}; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index e994a4f..a0e0ba8 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -55,3 +55,9 @@ export type { RiskCapItemRow, RiskCapVersionDetail, } from "./admin-config"; +export type { + AdminPermissionCatalogData, + AdminUserPermissionListData, + AdminUserPermissionRow, + AdminUserPermissionSyncData, +} from "./admin-user";