feat: 统一管理端导航为后端下发菜单,移除本地权限过滤
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user