feat: 增加角色管理与奖池配置迁移,优化管理端权限与样式
This commit is contained in:
BIN
public/image6.png
Normal file
BIN
public/image6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 369 KiB |
@@ -6,7 +6,9 @@ import type {
|
||||
AdminAuthCaptchaResponse,
|
||||
AdminAuthLoginRequest,
|
||||
AdminAuthLoginResponse,
|
||||
AdminAuthMeResponse,
|
||||
} from "@/types/api/admin-auth";
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
|
||||
import { API_V1_PREFIX } from "@/api/paths";
|
||||
|
||||
@@ -35,3 +37,8 @@ export async function postAdminLogin(
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
/** `GET /api/v1/admin/auth/me`(需 Token) */
|
||||
export async function getAdminMe(): Promise<AdminAuthMeResponse> {
|
||||
return adminRequest.get<AdminAuthMeResponse>(`${API_V1_PREFIX}/admin/auth/me`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { API_V1_PREFIX } from "./paths";
|
||||
|
||||
import type {
|
||||
AdminPermissionCatalogData,
|
||||
AdminRoleCreatePayload,
|
||||
AdminRoleDeleteResult,
|
||||
AdminRoleListData,
|
||||
AdminRoleRow,
|
||||
AdminRoleUpdatePayload,
|
||||
AdminUserCreatePayload,
|
||||
AdminUserDeleteResult,
|
||||
AdminUserPermissionListData,
|
||||
@@ -29,6 +34,10 @@ export async function getAdminUserPermissionCatalog(): Promise<AdminPermissionCa
|
||||
return adminRequest.get<AdminPermissionCatalogData>(`${A}/admin-user-permission-catalog`);
|
||||
}
|
||||
|
||||
export async function getAdminRoles(): Promise<AdminRoleListData> {
|
||||
return adminRequest.get<AdminRoleListData>(`${A}/admin-roles`);
|
||||
}
|
||||
|
||||
export async function getAdminUser(adminUserId: number): Promise<AdminUserPermissionRow> {
|
||||
return adminRequest.get<AdminUserPermissionRow>(`${A}/admin-users/${adminUserId}`);
|
||||
}
|
||||
@@ -48,6 +57,30 @@ export async function deleteAdminUser(adminUserId: number): Promise<AdminUserDel
|
||||
return adminRequest.delete<AdminUserDeleteResult>(`${A}/admin-users/${adminUserId}`);
|
||||
}
|
||||
|
||||
export async function postAdminRole(body: AdminRoleCreatePayload): Promise<AdminRoleRow> {
|
||||
return adminRequest.post<AdminRoleRow>(`${A}/admin-roles`, body);
|
||||
}
|
||||
|
||||
export async function putAdminRole(
|
||||
roleId: number,
|
||||
body: AdminRoleUpdatePayload,
|
||||
): Promise<AdminRoleRow> {
|
||||
return adminRequest.put<AdminRoleRow>(`${A}/admin-roles/${roleId}`, body);
|
||||
}
|
||||
|
||||
export async function deleteAdminRole(roleId: number): Promise<AdminRoleDeleteResult> {
|
||||
return adminRequest.delete<AdminRoleDeleteResult>(`${A}/admin-roles/${roleId}`);
|
||||
}
|
||||
|
||||
export async function putAdminRolePermissions(
|
||||
roleId: number,
|
||||
permissionSlugs: string[],
|
||||
): Promise<AdminRoleRow> {
|
||||
return adminRequest.put<AdminRoleRow>(`${A}/admin-roles/${roleId}/permissions`, {
|
||||
permission_slugs: permissionSlugs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function putAdminUserPermissions(
|
||||
adminUserId: number,
|
||||
permissionSlugs: string[],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { API_V1_PREFIX } from "@/api/paths";
|
||||
export { getDrawCurrent } from "@/api/public-draw";
|
||||
export { getAdminRiskPools } from "@/api/admin-risk";
|
||||
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
||||
export { getAdminCaptcha, getAdminMe, postAdminLogin } from "@/api/admin-auth";
|
||||
export { getAdminPing } from "@/api/admin-ping";
|
||||
export {
|
||||
getAdminPlayerWallets,
|
||||
@@ -27,5 +27,6 @@ export type {
|
||||
AdminAuthCaptchaResponse,
|
||||
AdminAuthLoginRequest,
|
||||
AdminAuthLoginResponse,
|
||||
AdminAuthMeResponse,
|
||||
AdminPingResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
16
src/app/admin/(shell)/admin-roles/page.tsx
Normal file
16
src/app/admin/(shell)/admin-roles/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminRolesConsole } from "@/modules/admin-roles/admin-roles-console";
|
||||
import { adminRolesModuleMeta } from "@/modules/admin-roles/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: adminRolesModuleMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminRolesPage() {
|
||||
return (
|
||||
<ModuleScaffold className="w-full max-w-none">
|
||||
<AdminRolesConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
20
src/app/admin/(shell)/config/jackpot/page.tsx
Normal file
20
src/app/admin/(shell)/config/jackpot/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { JackpotSubNav } from "@/modules/jackpot/jackpot-subnav";
|
||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||
import { jackpotModuleMeta } from "@/modules/jackpot/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `奖池配置 · ${jackpotModuleMeta.title}`,
|
||||
};
|
||||
|
||||
export default function AdminConfigJackpotPage() {
|
||||
return (
|
||||
<div className="w-full max-w-none px-1">
|
||||
<JackpotSubNav />
|
||||
<div className="mx-auto mb-6 max-w-5xl">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{jackpotModuleMeta.title}</h1>
|
||||
</div>
|
||||
<JackpotPoolsConsole />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/admin/(shell)/config/jackpot/records/page.tsx
Normal file
21
src/app/admin/(shell)/config/jackpot/records/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { JackpotSubNav } from "@/modules/jackpot/jackpot-subnav";
|
||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||
import { jackpotModuleMeta } from "@/modules/jackpot/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Jackpot 记录 · ${jackpotModuleMeta.title}`,
|
||||
};
|
||||
|
||||
export default function AdminConfigJackpotRecordsPage() {
|
||||
return (
|
||||
<div className="w-full max-w-none px-1">
|
||||
<JackpotSubNav />
|
||||
<div className="mx-auto mb-6 max-w-5xl">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Jackpot 记录</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">派彩与蓄水流水</p>
|
||||
</div>
|
||||
<JackpotRecordsConsole />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { JackpotSubNav } from "@/modules/jackpot/jackpot-subnav";
|
||||
|
||||
export default function AdminJackpotLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-full max-w-none px-1">
|
||||
<JackpotSubNav />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||
import { jackpotModuleMeta } from "@/modules/jackpot/meta";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `奖池配置 · ${jackpotModuleMeta.title}`,
|
||||
};
|
||||
|
||||
export default function AdminJackpotPoolsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mb-6 max-w-5xl">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{jackpotModuleMeta.title}</h1>
|
||||
</div>
|
||||
<JackpotPoolsConsole />
|
||||
</>
|
||||
);
|
||||
export default function AdminJackpotPoolsRedirectPage() {
|
||||
redirect("/admin/config/jackpot");
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||
import { jackpotModuleMeta } from "@/modules/jackpot/meta";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Jackpot 记录 · ${jackpotModuleMeta.title}`,
|
||||
};
|
||||
|
||||
export default function AdminJackpotRecordsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mb-6 max-w-5xl">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Jackpot 记录</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">派彩与蓄水流水</p>
|
||||
</div>
|
||||
<JackpotRecordsConsole />
|
||||
</>
|
||||
);
|
||||
export default function AdminJackpotRecordsRedirectPage() {
|
||||
redirect("/admin/config/jackpot/records");
|
||||
}
|
||||
|
||||
@@ -49,72 +49,72 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: #f7fbff;
|
||||
--foreground: #0f1f3d;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: #0f1f3d;
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--popover-foreground: #0f1f3d;
|
||||
--primary: #0b55c4;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #eef5ff;
|
||||
--secondary-foreground: #10336e;
|
||||
--muted: #f2f7ff;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: #e8f1ff;
|
||||
--accent-foreground: #0b55c4;
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--border: #d8e6fb;
|
||||
--input: #d8e6fb;
|
||||
--ring: #7aa7ee;
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: #01266c;
|
||||
--sidebar-foreground: #f8fbff;
|
||||
--sidebar-primary: #e60012;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: rgb(255 255 255 / 12%);
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: rgb(255 255 255 / 14%);
|
||||
--sidebar-ring: rgb(255 255 255 / 36%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: #081426;
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--primary: #77a7ff;
|
||||
--primary-foreground: #061328;
|
||||
--secondary: #10233f;
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent: #102a50;
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--border: rgb(148 180 220 / 24%);
|
||||
--input: rgb(148 180 220 / 28%);
|
||||
--ring: #77a7ff;
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar: #01266c;
|
||||
--sidebar-foreground: #f8fbff;
|
||||
--sidebar-primary: #e60012;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: rgb(255 255 255 / 12%);
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: rgb(255 255 255 / 14%);
|
||||
--sidebar-ring: rgb(255 255 255 / 36%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -127,4 +127,4 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
||||
<SidebarProvider defaultOpen>
|
||||
<AdminAppSidebar />
|
||||
<SidebarInset className="max-md:overflow-x-hidden">
|
||||
<header className="sticky top-0 z-30 flex min-h-14 items-center gap-3 border-b border-border bg-background/80 pl-4 pr-4 py-2 backdrop-blur-md">
|
||||
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
||||
<AdminBreadcrumb />
|
||||
@@ -25,7 +25,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
||||
<ShellToolbar />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-6 pt-4 pb-6 md:px-8 md:pt-4 md:pb-8">
|
||||
<div className="flex flex-1 flex-col px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -39,28 +38,38 @@ export function AdminAppSidebar() {
|
||||
const visibleNav = useMemo(() => profile?.navigation ?? [], [profile?.navigation]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader className="border-b border-sidebar-border px-2 py-1.5">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
||||
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
||||
<SidebarMenu className="h-full w-full">
|
||||
<SidebarMenuItem className="h-full">
|
||||
<SidebarMenuButton
|
||||
render={<Link href={ADMIN_BASE} />}
|
||||
className="gap-2 px-0 hover:bg-transparent"
|
||||
className="h-full min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<SparklesIcon data-icon="inline-start" aria-hidden />
|
||||
<div className="flex flex-col items-start gap-0 group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold tracking-tight text-sidebar-foreground">
|
||||
{t("app.title", { ns: "common" })}
|
||||
</span>
|
||||
<span className="text-[11px] leading-tight text-sidebar-foreground/70">
|
||||
Lottery Admin
|
||||
</span>
|
||||
<div className="flex h-12 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
className="h-auto max-h-11 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarContent className="relative overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src="/image6.png"
|
||||
alt=""
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-0 bg-sidebar/20" />
|
||||
</div>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -73,6 +82,7 @@ export function AdminAppSidebar() {
|
||||
tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}
|
||||
isActive={isActive(pathname, item)}
|
||||
render={<Link href={item.href} />}
|
||||
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
|
||||
>
|
||||
<Icon data-icon="inline-start" aria-hidden />
|
||||
<span>{t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}</span>
|
||||
|
||||
@@ -9,5 +9,5 @@ type ModuleScaffoldProps = {
|
||||
|
||||
/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */
|
||||
export function ModuleScaffold({ children, className }: ModuleScaffoldProps) {
|
||||
return <div className={cn("mx-auto max-w-5xl", className)}>{children}</div>;
|
||||
return <div className={cn("mx-auto w-full max-w-none", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
default: "bg-primary text-primary-foreground shadow-sm [a]:hover:bg-primary/90",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border bg-card text-primary hover:bg-accent hover:text-primary aria-expanded:bg-accent aria-expanded:text-primary dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
|
||||
@@ -12,7 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card py-4 text-sm text-card-foreground shadow-[0_6px_18px_rgb(15_48_96_/_5%)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,7 +38,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
"font-heading text-base leading-snug font-semibold text-foreground group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
"flex items-center rounded-b-lg border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"h-8 w-full min-w-0 rounded-md border border-input bg-card px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,11 +8,11 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
className="relative w-full overflow-x-auto rounded-lg border border-border bg-card"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn("w-full caption-bottom border-collapse text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
className={cn("bg-muted/70 [&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
"border-b border-border transition-colors hover:bg-muted/45 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-center align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-9 border-r border-border px-2 text-center align-middle font-semibold whitespace-nowrap text-[#17305f] last:border-r-0 [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 text-center align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
"border-r border-border px-2 py-2 text-center align-middle whitespace-nowrap last:border-r-0 [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"nickname": "Nickname",
|
||||
"status": "Status",
|
||||
"roles": "Roles",
|
||||
"direct": "Direct",
|
||||
"effective": "Effective",
|
||||
"actions": "Actions"
|
||||
},
|
||||
@@ -38,22 +37,19 @@
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"actions": {
|
||||
"permissions": "Permissions",
|
||||
"permissions": "Assign roles",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "Admin permissions",
|
||||
"title": "Assign roles",
|
||||
"rolesTitle": "Roles",
|
||||
"rolesDescription": "Saved as default-site roles and merged with direct permissions as effective permissions.",
|
||||
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management.",
|
||||
"rolePermissionCount": "Contains {{count}} functional permissions",
|
||||
"directTitle": "Direct permissions",
|
||||
"directDescription": "Expand by menu or domain and check specific prd.* items; in most cases role changes are enough.",
|
||||
"selectedRoles": "Selected roles:",
|
||||
"saveRoles": "Save roles",
|
||||
"saveDirect": "Save direct permissions"
|
||||
"saveRoles": "Save roles"
|
||||
},
|
||||
"accountDialog": {
|
||||
"createTitle": "Create admin",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"plays": "Play types and limits",
|
||||
"odds": "Odds",
|
||||
"rebate": "Commission / rebate",
|
||||
"jackpot": "Jackpot pool",
|
||||
"risk-cap": "Payout caps",
|
||||
"wallet": "Wallet thresholds"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"nickname": "उपनाम",
|
||||
"status": "स्थिति",
|
||||
"roles": "भूमिका",
|
||||
"direct": "प्रत्यक्ष",
|
||||
"effective": "प्रभावी",
|
||||
"actions": "कार्य"
|
||||
},
|
||||
@@ -38,22 +37,19 @@
|
||||
"disabled": "निष्क्रिय"
|
||||
},
|
||||
"actions": {
|
||||
"permissions": "अनुमति",
|
||||
"permissions": "भूमिका तोक्नुहोस्",
|
||||
"edit": "सम्पादन",
|
||||
"delete": "मेटाउनुहोस्",
|
||||
"cancel": "रद्द गर्नुहोस्",
|
||||
"save": "सेभ गर्नुहोस्"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "प्रशासक अनुमति",
|
||||
"title": "भूमिका तोक्नुहोस्",
|
||||
"rolesTitle": "भूमिका",
|
||||
"rolesDescription": "पूर्वनिर्धारित साइट भूमिकाको रूपमा सुरक्षित हुन्छ र प्रत्यक्ष अनुमतिसँग जोडिएर प्रभावी अनुमति बन्छ।",
|
||||
"rolesDescription": "यहाँ प्रशासकलाई भूमिका मात्र जोडिन्छ। विस्तृत अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
|
||||
"directTitle": "प्रत्यक्ष अनुमति",
|
||||
"directDescription": "मेनु वा व्यवसाय क्षेत्र अनुसार विस्तार गरी prd.* अनुमति छान्नुहोस्; धेरैजसो अवस्थामा भूमिका बदल्नु पर्याप्त हुन्छ।",
|
||||
"selectedRoles": "हाल छनोट गरिएका भूमिका:",
|
||||
"saveRoles": "भूमिका सेभ गर्नुहोस्",
|
||||
"saveDirect": "प्रत्यक्ष अनुमति सेभ गर्नुहोस्"
|
||||
"saveRoles": "भूमिका सेभ गर्नुहोस्"
|
||||
},
|
||||
"accountDialog": {
|
||||
"createTitle": "प्रशासक सिर्जना",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"plays": "खेल प्रकार र सीमा",
|
||||
"odds": "अड्स",
|
||||
"rebate": "कमिसन / रिबेट",
|
||||
"jackpot": "Jackpot पूल",
|
||||
"risk-cap": "पेमेन्ट क्याप",
|
||||
"wallet": "वालेट थ्रेसहोल्ड"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"createAdmin": "新建管理员",
|
||||
"searchPlaceholder": "按用户名 / 昵称 / 邮箱搜索",
|
||||
"loadFailed": "加载管理员列表失败",
|
||||
"roleLoadFailed": "加载角色列表失败",
|
||||
"nicknameRequired": "请填写昵称",
|
||||
"newPasswordMin": "新密码至少 8 位",
|
||||
"roleRequired": "请至少选择一个角色",
|
||||
@@ -14,6 +15,16 @@
|
||||
"saveAccountFailed": "保存账号失败",
|
||||
"deleteSuccess": "已删除 {{name}}",
|
||||
"deleteFailed": "删除失败",
|
||||
"roleListTitle": "角色管理",
|
||||
"createRole": "新增角色",
|
||||
"roleCreateSuccess": "已创建角色 {{name}}",
|
||||
"roleUpdateSuccess": "已更新角色 {{name}}",
|
||||
"roleSaveFailed": "保存角色失败",
|
||||
"roleDeleteSuccess": "已删除角色 {{name}}",
|
||||
"roleDeleteFailed": "删除角色失败",
|
||||
"rolePermissionSaveSuccess": "角色权限已更新",
|
||||
"rolePermissionSaveFailed": "保存角色权限失败",
|
||||
"roleFormRequired": "角色名称和标识不能为空",
|
||||
"allPermissions": "全部权限",
|
||||
"saveRoleSuccess": "已更新 {{name}} 的角色",
|
||||
"saveRoleFailed": "保存角色失败",
|
||||
@@ -29,7 +40,6 @@
|
||||
"nickname": "昵称",
|
||||
"status": "状态",
|
||||
"roles": "角色",
|
||||
"direct": "直接权限",
|
||||
"effective": "生效权限",
|
||||
"actions": "操作"
|
||||
},
|
||||
@@ -37,23 +47,48 @@
|
||||
"enabled": "启用",
|
||||
"disabled": "禁用"
|
||||
},
|
||||
"roleType": {
|
||||
"system": "系统",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"actions": {
|
||||
"permissions": "权限",
|
||||
"permissions": "分配角色",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
},
|
||||
"roleTable": {
|
||||
"name": "角色",
|
||||
"slug": "标识",
|
||||
"type": "类型",
|
||||
"status": "状态",
|
||||
"users": "关联用户",
|
||||
"permissions": "权限数",
|
||||
"actions": "操作"
|
||||
},
|
||||
"roleActions": {
|
||||
"permissions": "配权限"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "管理员权限",
|
||||
"title": "分配角色",
|
||||
"rolesTitle": "角色",
|
||||
"rolesDescription": "保存至默认站点,与「直接权限」叠加为有效权限。",
|
||||
"rolesDescription": "管理员只绑定角色;具体权限请到「角色管理」里维护。",
|
||||
"rolePermissionCount": "含 {{count}} 项功能权限",
|
||||
"directTitle": "直接权限",
|
||||
"directDescription": "按菜单或业务域展开,勾选具体的 prd.*;多数情况只调角色即可。",
|
||||
"selectedRoles": "当前勾选的角色:",
|
||||
"saveRoles": "保存角色",
|
||||
"saveDirect": "保存直接权限"
|
||||
"saveRoles": "保存角色"
|
||||
},
|
||||
"rolePermissionDialog": {
|
||||
"title": "角色权限"
|
||||
},
|
||||
"roleDialog": {
|
||||
"createTitle": "新增角色",
|
||||
"editTitle": "编辑角色",
|
||||
"description": "角色用于归拢后台功能权限,再分配给管理员账号。",
|
||||
"slug": "角色标识",
|
||||
"name": "角色名称",
|
||||
"descriptionLabel": "角色说明",
|
||||
"status": "状态"
|
||||
},
|
||||
"accountDialog": {
|
||||
"createTitle": "新建管理员",
|
||||
@@ -79,5 +114,9 @@
|
||||
"rowActionTitle": "删除该管理员",
|
||||
"confirmTitle": "确认删除",
|
||||
"confirmDescription": "确定删除管理员 {{name}}?此操作不可撤销。"
|
||||
},
|
||||
"roleDelete": {
|
||||
"confirmTitle": "删除角色",
|
||||
"confirmDescription": "确认删除角色 {{name}}?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"home": "首页",
|
||||
"dashboard": "仪表盘",
|
||||
"admin_users": "管理列表",
|
||||
"admin_roles": "角色管理",
|
||||
"players": "玩家列表",
|
||||
"wallet": "钱包流水",
|
||||
"draws": "期号列表",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"plays": "玩法与限额",
|
||||
"odds": "赔率",
|
||||
"rebate": "佣金 / 回水",
|
||||
"jackpot": "Jackpot 奖池",
|
||||
"risk-cap": "赔付封顶",
|
||||
"wallet": "钱包阈值"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
FileSpreadsheet,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
@@ -30,11 +29,11 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
wallet: Wallet,
|
||||
risk: ShieldAlert,
|
||||
settlement: Landmark,
|
||||
jackpot: CircleDollarSign,
|
||||
reports: FileSpreadsheet,
|
||||
reconcile: Scale,
|
||||
audit: ScrollText,
|
||||
admin_users: ShieldCheck,
|
||||
admin_roles: ShieldCheck,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ export type AdminNavSegment =
|
||||
| "risk"
|
||||
| "settings"
|
||||
| "settlement"
|
||||
| "jackpot"
|
||||
| "reports"
|
||||
| "reconcile"
|
||||
| "audit"
|
||||
| "admin_users";
|
||||
| "admin_users"
|
||||
| "admin_roles";
|
||||
|
||||
export type AdminNavItem = {
|
||||
label: string;
|
||||
|
||||
501
src/modules/admin-roles/admin-roles-console.tsx
Normal file
501
src/modules/admin-roles/admin-roles-console.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAdminRole,
|
||||
getAdminRoles,
|
||||
getAdminUserPermissionCatalog,
|
||||
postAdminRole,
|
||||
putAdminRole,
|
||||
putAdminRolePermissions,
|
||||
} from "@/api/admin-users";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||
const [draftRolePermissions, setDraftRolePermissions] = useState<string[]>([]);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleMode, setRoleMode] = useState<"create" | "edit">("create");
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
const [roleDescription, setRoleDescription] = useState("");
|
||||
const [roleStatus, setRoleStatus] = useState(1);
|
||||
const [roleFormSaving, setRoleFormSaving] = useState(false);
|
||||
|
||||
const [roleDeleteTarget, setRoleDeleteTarget] = useState<AdminRoleRow | null>(null);
|
||||
const [roleDeleteBusy, setRoleDeleteBusy] = useState(false);
|
||||
|
||||
const selectedRole = useMemo(
|
||||
() => roles.find((role) => role.id === selectedRoleId) ?? null,
|
||||
[roles, selectedRoleId],
|
||||
);
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const groups = catalog?.permission_menu_groups;
|
||||
if (groups && groups.length > 0) {
|
||||
return groups;
|
||||
}
|
||||
const flatPermissions = catalog?.permissions ?? [];
|
||||
if (flatPermissions.length > 0) {
|
||||
return [{ key: "all", label: t("allPermissions"), permissions: flatPermissions }];
|
||||
}
|
||||
return [];
|
||||
}, [catalog, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const [catalogData, roleData] = await Promise.all([
|
||||
getAdminUserPermissionCatalog(),
|
||||
getAdminRoles(),
|
||||
]);
|
||||
setCatalog(catalogData);
|
||||
setRoles(roleData.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
|
||||
setErr(msg);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] !== false;
|
||||
}
|
||||
|
||||
function toggleDirectGroup(key: string): void {
|
||||
setDirectMenuExpanded((prev) => {
|
||||
const wasOpen = prev[key] !== false;
|
||||
return { ...prev, [key]: wasOpen ? false : true };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRolePermission(slug: string, checked: boolean): void {
|
||||
setDraftRolePermissions((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((value) => value !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateRole(): void {
|
||||
setRoleMode("create");
|
||||
setEditingRoleId(null);
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRoleDescription("");
|
||||
setRoleStatus(1);
|
||||
setRoleDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditRole(role: AdminRoleRow): void {
|
||||
setRoleMode("edit");
|
||||
setEditingRoleId(role.id);
|
||||
setRoleSlug(role.slug);
|
||||
setRoleName(role.name);
|
||||
setRoleDescription(role.description ?? "");
|
||||
setRoleStatus(role.status);
|
||||
setRoleDialogOpen(true);
|
||||
}
|
||||
|
||||
function openRolePermissionEditor(role: AdminRoleRow): void {
|
||||
setSelectedRoleId(role.id);
|
||||
setDraftRolePermissions([...role.permission_slugs].sort());
|
||||
setDirectMenuExpanded({});
|
||||
setRolePermissionOpen(true);
|
||||
}
|
||||
|
||||
function handleRoleDialogOpenChange(open: boolean): void {
|
||||
setRoleDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingRoleId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRolePermissionDialogOpenChange(open: boolean): void {
|
||||
setRolePermissionOpen(open);
|
||||
if (!open) {
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRolePermissions(): Promise<void> {
|
||||
if (!selectedRole) {
|
||||
return;
|
||||
}
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
const result = await putAdminRolePermissions(selectedRole.id, draftRolePermissions);
|
||||
setDraftRolePermissions([...result.permission_slugs].sort());
|
||||
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === result.id ? result : role)) } : prev,
|
||||
);
|
||||
toast.success(t("rolePermissionSaveSuccess"));
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("rolePermissionSaveFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRole(): Promise<void> {
|
||||
const name = roleName.trim();
|
||||
const slug = roleSlug.trim().toLowerCase();
|
||||
if (name === "" || slug === "") {
|
||||
toast.error(t("roleFormRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setRoleFormSaving(true);
|
||||
try {
|
||||
if (roleMode === "create") {
|
||||
const created = await postAdminRole({
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => [...prev, created]);
|
||||
setCatalog((prev) => (prev ? { ...prev, roles: [...prev.roles, created] } : prev));
|
||||
toast.success(t("roleCreateSuccess", { name: created.name }));
|
||||
} else {
|
||||
if (editingRoleId === null) {
|
||||
return;
|
||||
}
|
||||
const updated = await putAdminRole(editingRoleId, {
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
|
||||
);
|
||||
toast.success(t("roleUpdateSuccess", { name: updated.name }));
|
||||
}
|
||||
handleRoleDialogOpenChange(false);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setRoleFormSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRoleDelete(): Promise<void> {
|
||||
if (!roleDeleteTarget) {
|
||||
return;
|
||||
}
|
||||
setRoleDeleteBusy(true);
|
||||
try {
|
||||
await deleteAdminRole(roleDeleteTarget.id);
|
||||
setRoles((prev) => prev.filter((role) => role.id !== roleDeleteTarget.id));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: prev.roles.filter((role) => role.id !== roleDeleteTarget.id) } : prev,
|
||||
);
|
||||
toast.success(t("roleDeleteSuccess", { name: roleDeleteTarget.name }));
|
||||
setRoleDeleteTarget(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleDeleteFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setRoleDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("roleListTitle")}</CardTitle>
|
||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||
{t("createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead>{t("roleTable.name")}</TableHead>
|
||||
<TableHead>{t("roleTable.slug")}</TableHead>
|
||||
<TableHead>{t("roleTable.type")}</TableHead>
|
||||
<TableHead>{t("roleTable.status")}</TableHead>
|
||||
<TableHead>{t("roleTable.users")}</TableHead>
|
||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
||||
<TableHead>{t("roleTable.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>{role.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{role.slug}</TableCell>
|
||||
<TableCell>
|
||||
{role.is_system ? (
|
||||
<Badge variant="secondary">{t("roleType.system")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("roleType.custom")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.status === 1 ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t("status.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-600/50 text-amber-800 dark:text-amber-400"
|
||||
>
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
|
||||
{t("roleActions.permissions")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
|
||||
{t("actions.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={role.is_system || role.user_count > 0}
|
||||
onClick={() => setRoleDeleteTarget(role)}
|
||||
>
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={rolePermissionOpen} onOpenChange={handleRolePermissionDialogOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[min(86vh,780px)] !max-w-[min(760px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-base">{t("rolePermissionDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedRole ? `${selectedRole.name} · ${selectedRole.slug}` : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/25 px-5 py-4">
|
||||
<div className="space-y-3">
|
||||
{directPermissionGroups.map((group) => {
|
||||
const isOpen = isDirectGroupOpen(group.key);
|
||||
const selectedCount = group.permissions.filter((permission) =>
|
||||
draftRolePermissions.includes(permission.slug),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section key={group.key} className="overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm hover:bg-muted/45"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-base font-semibold leading-none">{group.label}</span>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 tabular-nums text-xs font-medium text-muted-foreground">
|
||||
{selectedCount}/{group.permissions.length}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="divide-y">
|
||||
{group.permissions.map((permission) => (
|
||||
<label
|
||||
key={permission.slug}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/35",
|
||||
draftRolePermissions.includes(permission.slug) && "bg-muted/25",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={draftRolePermissions.includes(permission.slug)}
|
||||
onCheckedChange={(value) =>
|
||||
toggleRolePermission(permission.slug, value === true)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 whitespace-normal break-words font-medium leading-6 text-foreground">
|
||||
{permission.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t bg-background px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={!selectedRole || roleSaving} onClick={() => void saveRolePermissions()}>
|
||||
{roleSaving ? t("saving") : t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
|
||||
<DialogContent showCloseButton className="max-w-lg gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} disabled={roleMode === "edit"} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
|
||||
<Input value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.status")}</div>
|
||||
<select className={selectClassName} value={roleStatus} onChange={(e) => setRoleStatus(Number(e.target.value))}>
|
||||
<option value={1}>{t("status.enabled")}</option>
|
||||
<option value={0}>{t("status.disabled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={roleFormSaving} onClick={() => void submitRole()}>
|
||||
{roleFormSaving ? t("saving") : t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDeleteTarget !== null} onOpenChange={(open) => !open && setRoleDeleteTarget(null)}>
|
||||
<DialogContent showCloseButton className="max-w-md gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roleDelete.confirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{roleDeleteTarget ? t("roleDelete.confirmDescription", { name: roleDeleteTarget.name }) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" disabled={roleDeleteBusy} onClick={() => setRoleDeleteTarget(null)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" disabled={roleDeleteBusy} onClick={() => void confirmRoleDelete()}>
|
||||
{roleDeleteBusy ? t("deleting") : t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/modules/admin-roles/meta.ts
Normal file
5
src/modules/admin-roles/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const adminRolesModuleMeta = {
|
||||
segment: "admin_roles",
|
||||
title: "Roles",
|
||||
description: "",
|
||||
} as const;
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
getAdminUsers,
|
||||
postAdminUser,
|
||||
putAdminUser,
|
||||
putAdminUserPermissions,
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -35,10 +33,10 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
@@ -57,12 +55,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [draftRoles, setDraftRoles] = useState<string[]>([]);
|
||||
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingRoles, setSavingRoles] = useState(false);
|
||||
const [permissionOpen, setPermissionOpen] = useState(false);
|
||||
/** `false` = collapsed; default expanded */
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
|
||||
@@ -83,11 +77,66 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
[items, selectedId],
|
||||
);
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
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 : t("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((value) => value !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRole(slug: string, checked: boolean): void {
|
||||
setDraftRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((value) => value !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
function openPermissionEditor(row: AdminUserPermissionRow): void {
|
||||
setSelectedId(row.id);
|
||||
setDraftRoles([...row.roles].sort());
|
||||
setDraftPermissions([...row.direct_permissions].sort());
|
||||
setDirectMenuExpanded({});
|
||||
setPermissionOpen(true);
|
||||
}
|
||||
|
||||
@@ -129,8 +178,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
const nick = formNickname.trim();
|
||||
if (nick === "") {
|
||||
const nickname = formNickname.trim();
|
||||
if (nickname === "") {
|
||||
toast.error(t("nicknameRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -138,7 +187,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
toast.error(t("newPasswordMin"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountMode === "create" && formCreateRoles.length === 0) {
|
||||
toast.error(t("roleRequired"));
|
||||
return;
|
||||
@@ -147,8 +195,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
if (accountMode === "create") {
|
||||
const u = formUsername.trim();
|
||||
if (u === "") {
|
||||
const username = formUsername.trim();
|
||||
if (username === "") {
|
||||
toast.error(t("usernameRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -157,15 +205,15 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
const created = await postAdminUser({
|
||||
username: u.toLowerCase(),
|
||||
nickname: nick,
|
||||
username: username.toLowerCase(),
|
||||
nickname,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
password: formPassword,
|
||||
status: formStatus,
|
||||
role_slugs: formCreateRoles,
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
setTotal((prev) => prev + 1);
|
||||
toast.success(t("createSuccess", { name: created.username }));
|
||||
handleAccountDialogOpenChange(false);
|
||||
} else {
|
||||
@@ -179,7 +227,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
status: number;
|
||||
password?: string;
|
||||
} = {
|
||||
nickname: nick,
|
||||
nickname,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
status: formStatus,
|
||||
};
|
||||
@@ -199,114 +247,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await deleteAdminUser(deleteTarget.id);
|
||||
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const g = catalog?.permission_menu_groups;
|
||||
if (g && g.length > 0) {
|
||||
return g;
|
||||
}
|
||||
const flat = catalog?.permissions ?? [];
|
||||
if (flat.length > 0) {
|
||||
return [{ key: "all", label: t("allPermissions"), permissions: flat }];
|
||||
}
|
||||
return [];
|
||||
}, [catalog, t]);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] !== false;
|
||||
}
|
||||
|
||||
function toggleDirectGroup(key: string): void {
|
||||
setDirectMenuExpanded((prev) => {
|
||||
const wasOpen = prev[key] !== false;
|
||||
return { ...prev, [key]: wasOpen ? false : true };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
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 : t("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRole(slug: string, checked: boolean): void {
|
||||
setDraftRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRoles(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
@@ -335,33 +275,22 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePermissions(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
|
||||
setDraftPermissions([...result.direct_permissions].sort());
|
||||
setDraftRoles([...result.roles].sort());
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === result.id
|
||||
? {
|
||||
...row,
|
||||
direct_permissions: result.direct_permissions,
|
||||
effective_permissions: result.effective_permissions,
|
||||
roles: result.roles,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
toast.success(t("savePermissionSuccess", { name: result.username }));
|
||||
await deleteAdminUser(deleteTarget.id);
|
||||
setItems((prev) => prev.filter((row) => row.id !== deleteTarget.id));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("savePermissionFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +344,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableHead>{t("table.nickname")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.roles")}</TableHead>
|
||||
<TableHead>{t("table.direct")}</TableHead>
|
||||
<TableHead>{t("table.effective")}</TableHead>
|
||||
<TableHead className="min-w-[11rem]">{t("table.actions")}</TableHead>
|
||||
</TableRow>
|
||||
@@ -423,7 +351,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -444,7 +372,10 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{t("status.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-600/50 text-amber-800 dark:text-amber-400"
|
||||
>
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -462,7 +393,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{row.direct_permissions.length}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -470,18 +400,14 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
openPermissionEditor(row);
|
||||
}}
|
||||
onClick={() => openPermissionEditor(row)}
|
||||
>
|
||||
{t("actions.permissions")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||
}
|
||||
variant={accountOpen && editingAccountId === row.id ? "secondary" : "outline"}
|
||||
onClick={() => openEditAccount(row)}
|
||||
>
|
||||
{t("actions.edit")}
|
||||
@@ -515,8 +441,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
@@ -527,7 +453,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
|
||||
className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
>
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
|
||||
<DialogTitle>{t("permissionDialog.title")}</DialogTitle>
|
||||
@@ -541,143 +467,46 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
<div className="space-y-6 pb-1">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.rolesTitle")}</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.rolesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((r) => {
|
||||
const checked = draftRoles.includes(r.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleRole(r.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
<span className="block text-xs text-muted-foreground/90">
|
||||
{t("permissionDialog.rolePermissionCount", { count: r.permission_slugs.length })}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.directTitle")}</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.directDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.selectedRoles")}
|
||||
{draftRoles.length === 0 ? (
|
||||
<span className="ml-1 text-foreground/80">{t("common.none")}</span>
|
||||
) : (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
|
||||
{draftRoles.map((slug) => (
|
||||
<Badge key={slug} variant="secondary" className="text-xs font-normal">
|
||||
{slug}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{directPermissionGroups.map((group) => {
|
||||
const isOpen = isDirectGroupOpen(group.key);
|
||||
const nSelected = group.permissions.filter((p) =>
|
||||
draftPermissions.includes(p.slug),
|
||||
).length;
|
||||
return (
|
||||
<div key={group.key} className="border-b last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 font-medium">{group.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{nSelected}/{group.permissions.length}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="space-y-2 border-t bg-muted/20 px-3 py-3 sm:grid sm:grid-cols-2 sm:gap-2 sm:gap-y-3">
|
||||
{group.permissions.map((p) => {
|
||||
const checked = draftPermissions.includes(p.slug);
|
||||
return (
|
||||
<label key={p.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("permissionDialog.rolesDescription")}</p>
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((role) => {
|
||||
const checked = draftRoles.includes(role.slug);
|
||||
return (
|
||||
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => toggleRole(role.slug, value === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
data-slot="dialog-footer-actions"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
onClick={() => {
|
||||
handlePermissionDialogOpenChange(false);
|
||||
}}
|
||||
onClick={() => handlePermissionDialogOpenChange(false)}
|
||||
>
|
||||
{t("actions.close", { ns: "common" })}
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || savingRoles}
|
||||
onClick={() => void saveRoles()}
|
||||
>
|
||||
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || saving}
|
||||
onClick={() => void savePermissions()}
|
||||
>
|
||||
{saving ? t("saving") : t("permissionDialog.saveDirect")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || savingRoles}
|
||||
onClick={() => void saveRoles()}
|
||||
>
|
||||
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -741,26 +570,26 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{accountMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("accountDialog.rolesDescription")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("accountDialog.rolesDescription")}</p>
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
|
||||
{(catalog?.roles ?? []).length === 0 ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
{t("accountDialog.noRoles")}
|
||||
</p>
|
||||
) : (
|
||||
(catalog?.roles ?? []).map((r) => {
|
||||
const checked = formCreateRoles.includes(r.slug);
|
||||
(catalog?.roles ?? []).map((role) => {
|
||||
const checked = formCreateRoles.includes(role.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleFormCreateRole(r.slug, v === true)}
|
||||
onCheckedChange={(value) =>
|
||||
toggleFormCreateRole(role.slug, value === true)
|
||||
}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
<span className="block leading-none font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -801,9 +630,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>{t("delete.confirmDescription", { name: deleteTarget.username })}</>
|
||||
) : null}
|
||||
{deleteTarget ? t("delete.confirmDescription", { name: deleteTarget.username }) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
|
||||
@@ -27,6 +27,10 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
|
||||
href: "/admin/config/rebate",
|
||||
key: "rebate",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/jackpot",
|
||||
key: "jackpot",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type PrizeScopeCode,
|
||||
} from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
type CatTab = "all" | "d4" | "d3" | "d2" | "jackpot";
|
||||
type CatTab = "all" | "d4" | "d3" | "d2";
|
||||
|
||||
function oddsMultiplierLabel(oddsValue: number): string {
|
||||
return (oddsValue / 10000).toFixed(4);
|
||||
@@ -56,9 +56,6 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
|
||||
if (tab === "all") {
|
||||
return types;
|
||||
}
|
||||
if (tab === "jackpot") {
|
||||
return types.filter((t) => t.category.toLowerCase().includes("jackpot"));
|
||||
}
|
||||
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
|
||||
return types.filter((t) => t.dimension === dim);
|
||||
}
|
||||
@@ -389,7 +386,6 @@ export function OddsConfigDocScreen() {
|
||||
{ id: "d4", label: "4D" },
|
||||
{ id: "d3", label: "3D" },
|
||||
{ id: "d2", label: "2D" },
|
||||
{ id: "jackpot", label: "Jackpot" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,12 @@ import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import {
|
||||
PRD_DRAW_REOPEN_MANAGE,
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -42,7 +47,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
const isSuperAdmin = profile?.permissions?.includes("prd.admin_user.manage") ?? false;
|
||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -159,17 +168,16 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("drawActionsDesc")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{(canManageDraw || canReopenDraw || canRunSettlement) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
|
||||
onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))}
|
||||
>
|
||||
@@ -178,6 +186,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
|
||||
onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))}
|
||||
>
|
||||
@@ -185,15 +194,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
|
||||
onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))}
|
||||
>
|
||||
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
|
||||
</Button>
|
||||
{isSuperAdmin ? (
|
||||
{canReopenDraw ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={acting !== null || data.status !== "cooldown"}
|
||||
onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))}
|
||||
>
|
||||
@@ -203,13 +215,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={acting !== null || !["settling", "cooldown"].includes(data.status)}
|
||||
size="sm"
|
||||
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
|
||||
onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))}
|
||||
>
|
||||
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,23 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -122,7 +131,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={settling} onClick={() => void runSettlement()}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
|
||||
onClick={() => void runSettlement()}
|
||||
>
|
||||
{settling ? t("processing") : t("runSettlement")}
|
||||
</Button>
|
||||
<Link
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/** 开奖结果发布权限 slug */
|
||||
export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const;
|
||||
export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const;
|
||||
export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const;
|
||||
export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const;
|
||||
|
||||
@@ -27,10 +27,13 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
/** 下拉「不限」;请求时不传 status */
|
||||
@@ -62,6 +65,8 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
|
||||
export function DrawsIndexConsole() {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -138,9 +143,11 @@ export function DrawsIndexConsole() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
|
||||
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
|
||||
{generating ? t("generating") : t("generatePlan")}
|
||||
</Button>
|
||||
{canManageDraw ? (
|
||||
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
|
||||
{generating ? t("generating") : t("generatePlan")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS: { href: string; label: string }[] = [
|
||||
{ href: "/admin/jackpot/pools", label: "subnavPools" },
|
||||
{ href: "/admin/jackpot/records", label: "subnavRecords" },
|
||||
{ href: "/admin/config/jackpot", label: "subnavPools" },
|
||||
{ href: "/admin/config/jackpot/records", label: "subnavRecords" },
|
||||
];
|
||||
|
||||
export function JackpotSubNav() {
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -70,6 +72,8 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -249,9 +253,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createPlayer")}
|
||||
</Button>
|
||||
{canManagePlayers ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createPlayer")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
@@ -348,26 +354,30 @@ export function PlayersConsole(): React.ReactElement {
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => openEditAccount(row)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
{canManagePlayers ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => openEditAccount(row)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -213,6 +213,17 @@ function walletAdminSelectDisplayedLabel(
|
||||
return key ? (t ? t(key) : key) : v;
|
||||
}
|
||||
|
||||
function canReverseTransferOrder(row: { status: string; can_reverse?: boolean }): boolean {
|
||||
return row.can_reverse ?? row.status === "pending_reconcile";
|
||||
}
|
||||
|
||||
function canManuallyProcessTransferOrder(row: {
|
||||
status: string;
|
||||
can_manually_process?: boolean;
|
||||
}): boolean {
|
||||
return row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status);
|
||||
}
|
||||
|
||||
export function TransferOrdersPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
@@ -473,26 +484,30 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.status === "pending_reconcile" ? (
|
||||
{canReverseTransferOrder(row) || canManuallyProcessTransferOrder(row) ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleReverse(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleManuallyProcess(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
|
||||
</Button>
|
||||
{canReverseTransferOrder(row) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleReverse(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManuallyProcessTransferOrder(row) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleManuallyProcess(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getAdminMe } from "@/api/admin-auth";
|
||||
import { setAdminBearerToken } from "@/lib/admin-auth";
|
||||
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||
import { readToken, writeToken } from "@/stores/admin-token";
|
||||
@@ -19,6 +20,7 @@ export type AdminSessionState = {
|
||||
clearSession: () => void;
|
||||
/** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */
|
||||
rehydrate: () => void;
|
||||
refreshAdminProfile: () => Promise<void>;
|
||||
/** @deprecated 使用 {@link clearSession} */
|
||||
clearBearerToken: () => void;
|
||||
};
|
||||
@@ -61,12 +63,28 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||
if (token) {
|
||||
setAdminBearerToken(token);
|
||||
set({ bearerToken: token, adminProfile: profile });
|
||||
void get().refreshAdminProfile();
|
||||
} else {
|
||||
setAdminBearerToken(null);
|
||||
set({ bearerToken: null, adminProfile: null });
|
||||
}
|
||||
},
|
||||
|
||||
refreshAdminProfile: async () => {
|
||||
const token = get().bearerToken ?? readToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getAdminMe();
|
||||
writeProfile(result.admin);
|
||||
set({ adminProfile: result.admin });
|
||||
} catch {
|
||||
// 兼容旧缓存失败时不打断页面,留给后续登录态处理。
|
||||
}
|
||||
},
|
||||
|
||||
clearBearerToken: () => {
|
||||
get().clearSession();
|
||||
},
|
||||
|
||||
@@ -32,3 +32,8 @@ export type AdminAuthLoginResponse = {
|
||||
token_type: string;
|
||||
admin: AdminProfile;
|
||||
};
|
||||
|
||||
/** `GET /api/v1/admin/auth/me` 成功信封内的 `data` */
|
||||
export type AdminAuthMeResponse = {
|
||||
admin: AdminProfile;
|
||||
};
|
||||
|
||||
@@ -30,13 +30,43 @@ export type AdminPermissionCatalogData = {
|
||||
permissions: { id: number; slug: string; name: string }[];
|
||||
}[];
|
||||
navigation?: AdminNavItem[];
|
||||
roles: {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
permission_slugs: string[];
|
||||
user_count: number;
|
||||
}[];
|
||||
roles: AdminRoleRow[];
|
||||
};
|
||||
|
||||
export type AdminRoleRow = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: number;
|
||||
is_system: boolean;
|
||||
sort_order: number;
|
||||
permission_slugs: string[];
|
||||
user_count: number;
|
||||
};
|
||||
|
||||
export type AdminRoleListData = {
|
||||
items: AdminRoleRow[];
|
||||
};
|
||||
|
||||
export type AdminRoleCreatePayload = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: number;
|
||||
permission_slugs?: string[];
|
||||
};
|
||||
|
||||
export type AdminRoleUpdatePayload = {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type AdminRoleDeleteResult = {
|
||||
deleted: boolean;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type AdminUserPermissionSyncData = {
|
||||
|
||||
@@ -12,6 +12,8 @@ export type AdminTransferOrderItem = {
|
||||
amount: number;
|
||||
idempotent_key: string;
|
||||
status: string;
|
||||
can_reverse?: boolean;
|
||||
can_manually_process?: boolean;
|
||||
external_ref_no: string | null;
|
||||
external_request_payload: Record<string, unknown> | null;
|
||||
external_response_payload: Record<string, unknown> | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type {
|
||||
AdminAuthCaptchaResponse,
|
||||
AdminAuthLoginRequest,
|
||||
AdminAuthLoginResponse,
|
||||
AdminAuthMeResponse,
|
||||
AdminProfile,
|
||||
} from "./admin-auth";
|
||||
export type { AdminPingResponse } from "./admin-ping";
|
||||
@@ -56,6 +57,11 @@ export type {
|
||||
RiskCapVersionDetail,
|
||||
} from "./admin-config";
|
||||
export type {
|
||||
AdminRoleCreatePayload,
|
||||
AdminRoleDeleteResult,
|
||||
AdminRoleListData,
|
||||
AdminRoleRow,
|
||||
AdminRoleUpdatePayload,
|
||||
AdminPermissionCatalogData,
|
||||
AdminUserPermissionListData,
|
||||
AdminUserPermissionRow,
|
||||
|
||||
Reference in New Issue
Block a user