feat: 统一管理端导航为后端下发菜单,移除本地权限过滤

This commit is contained in:
2026-05-19 09:34:52 +08:00
parent 1b1dfc92ab
commit d625c59393
8 changed files with 57 additions and 179 deletions

View File

@@ -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<string, string> = {
@@ -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) {

View File

@@ -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 (
<Sidebar collapsible="icon" variant="inset">

View File

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

View File

@@ -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" },
];

View File

@@ -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 (
<span key={t.href} className={className} title={t("noPermission")}>
{t(t.label)}
<span key={tab.href} className={className} title={t("noPermission")}>
{t(tab.label)}
</span>
);
}
return (
<Link key={t.href} href={t.href} className={className}>
{t(t.label)}
<Link key={tab.href} href={tab.href} className={className}>
{t(tab.label)}
</Link>
);
})}

View File

@@ -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 {

View File

@@ -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` */

View File

@@ -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;