diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index 212a892..4cc24e5 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -11,7 +11,8 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav"; +import { ADMIN_BASE } from "@/modules/_config/admin-nav"; +import { useAdminProfile } from "@/stores/admin-session"; import React from "react"; const DRAW_ROUTE_LABELS: Record = { @@ -36,6 +37,8 @@ type BreadcrumbCrumb = { export function AdminBreadcrumb() { const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]); const pathname = usePathname(); + const profile = useAdminProfile(); + const navItems = profile?.navigation ?? []; // Split the current path into segments. const segments = pathname.split("/").filter(Boolean); @@ -52,7 +55,7 @@ export function AdminBreadcrumb() { if (pathname !== ADMIN_BASE) { const businessSegment = segments[1]; if (businessSegment) { - const navItem = adminShellNavItems.find((item) => { + const navItem = navItems.find((item) => { return item.segment === businessSegment || item.href.includes(businessSegment); }); @@ -70,6 +73,12 @@ export function AdminBreadcrumb() { href: navItem.href, isCurrent: pathname === navItem.href || segments.length === 2, }); + } else { + breadcrumbs.push({ + label: titleCase(businessSegment), + href: `${ADMIN_BASE}/${businessSegment}`, + isCurrent: segments.length === 2, + }); } if (segments.length > 2) { diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 2e20306..a917d5f 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -20,8 +20,7 @@ import { SidebarSeparator, } from "@/components/ui/sidebar"; import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons"; -import { adminNavItemVisible } from "@/lib/admin-nav-visibility"; -import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav"; +import { ADMIN_BASE } from "@/modules/_config/admin-nav"; import { useAdminProfile } from "@/stores/admin-session"; function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string }): boolean { @@ -37,13 +36,7 @@ export function AdminAppSidebar() { const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]); const pathname = usePathname(); const profile = useAdminProfile(); - const visibleNav = useMemo( - () => - adminShellNavItems.filter((item) => - adminNavItemVisible(item, profile?.permissions), - ), - [profile?.permissions], - ); + const visibleNav = useMemo(() => profile?.navigation ?? [], [profile?.navigation]); return ( diff --git a/src/lib/admin-nav-visibility.ts b/src/lib/admin-nav-visibility.ts deleted file mode 100644 index 4724be2..0000000 --- a/src/lib/admin-nav-visibility.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AdminNavItem } from "@/modules/_config/admin-nav"; - -/** 已登录且拥有 `requiredAny` 中任一 slug 则显示;未配置 `requiredAny` 则始终显示。 */ -export function adminNavItemVisible( - item: AdminNavItem, - permissionSlugs: readonly string[] | null | undefined, -): boolean { - const req = item.requiredAny; - if (req === undefined || req.length === 0) { - return true; - } - const set = permissionSlugs ?? []; - if (set.length === 0) { - return false; - } - return req.some((slug) => set.includes(slug)); -} diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 832106a..2db1351 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -1,152 +1,25 @@ -/** - * Single source of truth for admin navigation and routes. - * - * `requiredAny` matches `admin.permissions` from the login response (Laravel `prd.*`). - * When omitted, the item is visible to any signed-in admin. - */ export const ADMIN_BASE = "/admin" as const; +export type AdminNavSegment = + | "dashboard" + | "players" + | "draws" + | "config" + | "tickets" + | "wallet" + | "risk" + | "settings" + | "settlement" + | "jackpot" + | "reports" + | "reconcile" + | "audit" + | "admin_users"; + export type AdminNavItem = { label: string; href: string; - segment: - | "dashboard" - | "players" - | "draws" - | "config" - | "tickets" - | "wallet" - | "risk" - | "settings" - | "settlement" - | "jackpot" - | "reports" - | "reconcile" - | "audit" - | "admin_users"; + segment: AdminNavSegment; activeMatchPrefix?: string; - /** Show the nav item when the user has any of these permission slugs. */ requiredAny?: readonly string[]; }; - -export const adminShellNavItems: AdminNavItem[] = [ - { segment: "dashboard", label: "Dashboard", href: "/admin" }, - { - segment: "admin_users", - label: "Admin Users", - href: "/admin/admin-users", - requiredAny: ["prd.admin_user.manage"], - }, - { - segment: "players", - label: "Players", - href: "/admin/players", - requiredAny: [ - "prd.users.manage", - "prd.users.view_finance", - "prd.users.view_cs", - ], - }, - { - segment: "wallet", - label: "Wallet", - 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: "draws", - label: "Draws", - href: "/admin/draws", - requiredAny: ["prd.draw_result.manage", "prd.draw_result.view"], - }, - { - segment: "config", - label: "Configuration", - href: "/admin/config", - requiredAny: [ - "prd.play_switch.manage", - "prd.odds.manage", - "prd.risk_cap.manage", - "prd.risk_cap.view", - "prd.rebate.manage", - "prd.rebate.view", - "prd.jackpot.manage", - "prd.jackpot.view", - ], - }, - { - segment: "risk", - label: "Risk", - href: "/admin/risk", - requiredAny: ["prd.draw_result.view", "prd.draw_result.manage"], - }, - { - segment: "settlement", - label: "Settlement", - href: "/admin/settlement-batches", - requiredAny: [ - "prd.payout.manage", - "prd.payout.review", - "prd.payout.view", - ], - }, - { - segment: "jackpot", - label: "Jackpot", - href: "/admin/jackpot/pools", - activeMatchPrefix: "/admin/jackpot", - requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"], - }, - { - segment: "reconcile", - label: "Reconcile", - href: "/admin/reconcile", - requiredAny: [ - "prd.wallet_reconcile.manage", - "prd.wallet_reconcile.view", - "prd.wallet_reconcile.view_cs", - ], - }, - { - segment: "tickets", - label: "Tickets", - 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: "reports", - label: "Reports", - href: "/admin/reports", - requiredAny: [ - "prd.report.all", - "prd.report.risk", - "prd.report.finance", - "prd.report.player", - ], - }, - { - segment: "audit", - label: "Audit Logs", - href: "/admin/audit-logs", - requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"], - }, - { segment: "settings", label: "Settings", href: "/admin/settings" }, -]; diff --git a/src/modules/wallet/wallet-subnav.tsx b/src/modules/wallet/wallet-subnav.tsx index 7b42a87..59cb04a 100644 --- a/src/modules/wallet/wallet-subnav.tsx +++ b/src/modules/wallet/wallet-subnav.tsx @@ -30,9 +30,9 @@ export function WalletSubnav(): React.ReactElement { aria-label={t("subnavLabel")} className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3" > - {tabs.map((t) => { - const allowed = adminHasAnyPermission(perms, [...t.requiredAny]); - const active = pathname === t.href || pathname.startsWith(`${t.href}/`); + {tabs.map((tab) => { + const allowed = adminHasAnyPermission(perms, [...tab.requiredAny]); + const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`); const className = cn( "rounded-lg px-3 py-1.5 text-sm font-medium transition-colors", active @@ -42,14 +42,14 @@ export function WalletSubnav(): React.ReactElement { ); if (!allowed) { return ( - - {t(t.label)} + + {t(tab.label)} ); } return ( - - {t(t.label)} + + {t(tab.label)} ); })} diff --git a/src/stores/admin-profile.ts b/src/stores/admin-profile.ts index aeb645c..1a70759 100644 --- a/src/stores/admin-profile.ts +++ b/src/stores/admin-profile.ts @@ -1,4 +1,5 @@ import type { AdminProfile } from "@/types/api/admin-auth"; +import type { AdminNavItem } from "@/modules/_config/admin-nav"; const KEY = "lottery_admin_profile"; @@ -20,12 +21,24 @@ export function readProfile(): AdminProfile | null { const permissions = Array.isArray(v.permissions) ? v.permissions.filter((s): s is string => typeof s === "string") : []; + const navigation = Array.isArray(v.navigation) + ? v.navigation.filter((item): item is AdminNavItem => { + return ( + item !== null && + typeof item === "object" && + typeof item.segment === "string" && + typeof item.label === "string" && + typeof item.href === "string" + ); + }) + : []; return { id: v.id, username: v.username, nickname: v.nickname, email: typeof v.email === "string" || v.email === null ? v.email : null, permissions, + navigation, }; } } catch { diff --git a/src/types/api/admin-auth.ts b/src/types/api/admin-auth.ts index ba3e85f..30e3e2b 100644 --- a/src/types/api/admin-auth.ts +++ b/src/types/api/admin-auth.ts @@ -1,3 +1,5 @@ +import type { AdminNavItem } from "@/modules/_config/admin-nav"; + /** `GET /api/v1/admin/auth/captcha` 成功信封内的 `data` */ export type AdminAuthCaptchaResponse = { captcha_key: string; @@ -20,6 +22,8 @@ export type AdminProfile = { email: string | null; /** 与 Laravel `admin_permissions.slug` 一致(如 `prd.*`);超管为全量列表 */ permissions?: string[]; + /** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */ + navigation?: AdminNavItem[]; }; /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ diff --git a/src/types/api/admin-user.ts b/src/types/api/admin-user.ts index 697dfe4..a44e95c 100644 --- a/src/types/api/admin-user.ts +++ b/src/types/api/admin-user.ts @@ -1,3 +1,5 @@ +import type { AdminNavItem } from "@/modules/_config/admin-nav"; + export type AdminUserPermissionRow = { id: number; username: string; @@ -27,6 +29,7 @@ export type AdminPermissionCatalogData = { label: string; permissions: { id: number; slug: string; name: string }[]; }[]; + navigation?: AdminNavItem[]; roles: { id: number; slug: string;