feat: 统一管理端导航为后端下发菜单,移除本地权限过滤
This commit is contained in:
@@ -11,7 +11,8 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} 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";
|
import React from "react";
|
||||||
|
|
||||||
const DRAW_ROUTE_LABELS: Record<string, string> = {
|
const DRAW_ROUTE_LABELS: Record<string, string> = {
|
||||||
@@ -36,6 +37,8 @@ type BreadcrumbCrumb = {
|
|||||||
export function AdminBreadcrumb() {
|
export function AdminBreadcrumb() {
|
||||||
const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]);
|
const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const navItems = profile?.navigation ?? [];
|
||||||
|
|
||||||
// Split the current path into segments.
|
// Split the current path into segments.
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
@@ -52,7 +55,7 @@ export function AdminBreadcrumb() {
|
|||||||
if (pathname !== ADMIN_BASE) {
|
if (pathname !== ADMIN_BASE) {
|
||||||
const businessSegment = segments[1];
|
const businessSegment = segments[1];
|
||||||
if (businessSegment) {
|
if (businessSegment) {
|
||||||
const navItem = adminShellNavItems.find((item) => {
|
const navItem = navItems.find((item) => {
|
||||||
return item.segment === businessSegment || item.href.includes(businessSegment);
|
return item.segment === businessSegment || item.href.includes(businessSegment);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +73,12 @@ export function AdminBreadcrumb() {
|
|||||||
href: navItem.href,
|
href: navItem.href,
|
||||||
isCurrent: pathname === navItem.href || segments.length === 2,
|
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) {
|
if (segments.length > 2) {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons";
|
import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons";
|
||||||
import { adminNavItemVisible } from "@/lib/admin-nav-visibility";
|
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||||
import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string }): boolean {
|
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 { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const visibleNav = useMemo(
|
const visibleNav = useMemo(() => profile?.navigation ?? [], [profile?.navigation]);
|
||||||
() =>
|
|
||||||
adminShellNavItems.filter((item) =>
|
|
||||||
adminNavItemVisible(item, profile?.permissions),
|
|
||||||
),
|
|
||||||
[profile?.permissions],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<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 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 = {
|
export type AdminNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
segment:
|
segment: AdminNavSegment;
|
||||||
| "dashboard"
|
|
||||||
| "players"
|
|
||||||
| "draws"
|
|
||||||
| "config"
|
|
||||||
| "tickets"
|
|
||||||
| "wallet"
|
|
||||||
| "risk"
|
|
||||||
| "settings"
|
|
||||||
| "settlement"
|
|
||||||
| "jackpot"
|
|
||||||
| "reports"
|
|
||||||
| "reconcile"
|
|
||||||
| "audit"
|
|
||||||
| "admin_users";
|
|
||||||
activeMatchPrefix?: string;
|
activeMatchPrefix?: string;
|
||||||
/** Show the nav item when the user has any of these permission slugs. */
|
|
||||||
requiredAny?: readonly string[];
|
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")}
|
aria-label={t("subnavLabel")}
|
||||||
className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3"
|
className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3"
|
||||||
>
|
>
|
||||||
{tabs.map((t) => {
|
{tabs.map((tab) => {
|
||||||
const allowed = adminHasAnyPermission(perms, [...t.requiredAny]);
|
const allowed = adminHasAnyPermission(perms, [...tab.requiredAny]);
|
||||||
const active = pathname === t.href || pathname.startsWith(`${t.href}/`);
|
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||||
const className = cn(
|
const className = cn(
|
||||||
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
@@ -42,14 +42,14 @@ export function WalletSubnav(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return (
|
return (
|
||||||
<span key={t.href} className={className} title={t("noPermission")}>
|
<span key={tab.href} className={className} title={t("noPermission")}>
|
||||||
{t(t.label)}
|
{t(tab.label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link key={t.href} href={t.href} className={className}>
|
<Link key={tab.href} href={tab.href} className={className}>
|
||||||
{t(t.label)}
|
{t(tab.label)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
|
import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||||
|
|
||||||
const KEY = "lottery_admin_profile";
|
const KEY = "lottery_admin_profile";
|
||||||
|
|
||||||
@@ -20,12 +21,24 @@ export function readProfile(): AdminProfile | null {
|
|||||||
const permissions = Array.isArray(v.permissions)
|
const permissions = Array.isArray(v.permissions)
|
||||||
? v.permissions.filter((s): s is string => typeof s === "string")
|
? 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 {
|
return {
|
||||||
id: v.id,
|
id: v.id,
|
||||||
username: v.username,
|
username: v.username,
|
||||||
nickname: v.nickname,
|
nickname: v.nickname,
|
||||||
email: typeof v.email === "string" || v.email === null ? v.email : null,
|
email: typeof v.email === "string" || v.email === null ? v.email : null,
|
||||||
permissions,
|
permissions,
|
||||||
|
navigation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||||
|
|
||||||
/** `GET /api/v1/admin/auth/captcha` 成功信封内的 `data` */
|
/** `GET /api/v1/admin/auth/captcha` 成功信封内的 `data` */
|
||||||
export type AdminAuthCaptchaResponse = {
|
export type AdminAuthCaptchaResponse = {
|
||||||
captcha_key: string;
|
captcha_key: string;
|
||||||
@@ -20,6 +22,8 @@ export type AdminProfile = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
/** 与 Laravel `admin_permissions.slug` 一致(如 `prd.*`);超管为全量列表 */
|
/** 与 Laravel `admin_permissions.slug` 一致(如 `prd.*`);超管为全量列表 */
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
/** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */
|
||||||
|
navigation?: AdminNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||||
|
|
||||||
export type AdminUserPermissionRow = {
|
export type AdminUserPermissionRow = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -27,6 +29,7 @@ export type AdminPermissionCatalogData = {
|
|||||||
label: string;
|
label: string;
|
||||||
permissions: { id: number; slug: string; name: string }[];
|
permissions: { id: number; slug: string; name: string }[];
|
||||||
}[];
|
}[];
|
||||||
|
navigation?: AdminNavItem[];
|
||||||
roles: {
|
roles: {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user