diff --git a/public/image6.png b/public/image6.png new file mode 100644 index 0000000..ec3dea8 Binary files /dev/null and b/public/image6.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..2294dc4 Binary files /dev/null and b/public/logo.png differ diff --git a/src/api/admin-auth.ts b/src/api/admin-auth.ts index 084e36b..de1e8e2 100644 --- a/src/api/admin-auth.ts +++ b/src/api/admin-auth.ts @@ -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 { + return adminRequest.get(`${API_V1_PREFIX}/admin/auth/me`); +} diff --git a/src/api/admin-users.ts b/src/api/admin-users.ts index 0ed5812..6fe26bf 100644 --- a/src/api/admin-users.ts +++ b/src/api/admin-users.ts @@ -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(`${A}/admin-user-permission-catalog`); } +export async function getAdminRoles(): Promise { + return adminRequest.get(`${A}/admin-roles`); +} + export async function getAdminUser(adminUserId: number): Promise { return adminRequest.get(`${A}/admin-users/${adminUserId}`); } @@ -48,6 +57,30 @@ export async function deleteAdminUser(adminUserId: number): Promise(`${A}/admin-users/${adminUserId}`); } +export async function postAdminRole(body: AdminRoleCreatePayload): Promise { + return adminRequest.post(`${A}/admin-roles`, body); +} + +export async function putAdminRole( + roleId: number, + body: AdminRoleUpdatePayload, +): Promise { + return adminRequest.put(`${A}/admin-roles/${roleId}`, body); +} + +export async function deleteAdminRole(roleId: number): Promise { + return adminRequest.delete(`${A}/admin-roles/${roleId}`); +} + +export async function putAdminRolePermissions( + roleId: number, + permissionSlugs: string[], +): Promise { + return adminRequest.put(`${A}/admin-roles/${roleId}/permissions`, { + permission_slugs: permissionSlugs, + }); +} + export async function putAdminUserPermissions( adminUserId: number, permissionSlugs: string[], diff --git a/src/api/index.ts b/src/api/index.ts index f23bdee..10693a2 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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"; diff --git a/src/app/admin/(shell)/admin-roles/page.tsx b/src/app/admin/(shell)/admin-roles/page.tsx new file mode 100644 index 0000000..c2981e9 --- /dev/null +++ b/src/app/admin/(shell)/admin-roles/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/admin/(shell)/config/jackpot/page.tsx b/src/app/admin/(shell)/config/jackpot/page.tsx new file mode 100644 index 0000000..96bb1b2 --- /dev/null +++ b/src/app/admin/(shell)/config/jackpot/page.tsx @@ -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 ( +
+ +
+

{jackpotModuleMeta.title}

+
+ +
+ ); +} diff --git a/src/app/admin/(shell)/config/jackpot/records/page.tsx b/src/app/admin/(shell)/config/jackpot/records/page.tsx new file mode 100644 index 0000000..70891a4 --- /dev/null +++ b/src/app/admin/(shell)/config/jackpot/records/page.tsx @@ -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 ( +
+ +
+

Jackpot 记录

+

派彩与蓄水流水

+
+ +
+ ); +} diff --git a/src/app/admin/(shell)/jackpot/layout.tsx b/src/app/admin/(shell)/jackpot/layout.tsx deleted file mode 100644 index 86c86d7..0000000 --- a/src/app/admin/(shell)/jackpot/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { JackpotSubNav } from "@/modules/jackpot/jackpot-subnav"; - -export default function AdminJackpotLayout({ children }: { children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} diff --git a/src/app/admin/(shell)/jackpot/pools/page.tsx b/src/app/admin/(shell)/jackpot/pools/page.tsx index 8c75c95..8b06059 100644 --- a/src/app/admin/(shell)/jackpot/pools/page.tsx +++ b/src/app/admin/(shell)/jackpot/pools/page.tsx @@ -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 ( - <> -
-

{jackpotModuleMeta.title}

-
- - - ); +export default function AdminJackpotPoolsRedirectPage() { + redirect("/admin/config/jackpot"); } diff --git a/src/app/admin/(shell)/jackpot/records/page.tsx b/src/app/admin/(shell)/jackpot/records/page.tsx index 3967290..12b3d64 100644 --- a/src/app/admin/(shell)/jackpot/records/page.tsx +++ b/src/app/admin/(shell)/jackpot/records/page.tsx @@ -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 ( - <> -
-

Jackpot 记录

-

派彩与蓄水流水

-
- - - ); +export default function AdminJackpotRecordsRedirectPage() { + redirect("/admin/config/jackpot/records"); } diff --git a/src/app/globals.css b/src/app/globals.css index f71e3bd..e770230 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } -} \ No newline at end of file +} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 01ba187..bbf4466 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -17,7 +17,7 @@ export function AdminShell({ children }: { children: ReactNode }) { -
+
@@ -25,7 +25,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index a917d5f..50f82bd 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -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 ( - - - - + + + + } - 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" > - -
- - {t("app.title", { ns: "common" })} - - - Lottery Admin - +
+ N lotto
- + +
+ +
+
+
{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })} @@ -73,6 +82,7 @@ export function AdminAppSidebar() { tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })} isActive={isActive(pathname, item)} render={} + 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" > {t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })} diff --git a/src/components/admin/module-scaffold.tsx b/src/components/admin/module-scaffold.tsx index a433dd5..a1c7043 100644 --- a/src/components/admin/module-scaffold.tsx +++ b/src/components/admin/module-scaffold.tsx @@ -9,5 +9,5 @@ type ModuleScaffoldProps = { /** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */ export function ModuleScaffold({ children, className }: ModuleScaffoldProps) { - return
{children}
; + return
{children}
; } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 09df753..6042c42 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 40cac5f..3b10e8d 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -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">) {
) {
) {
) { 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} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index b7ce854..261889b 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -8,11 +8,11 @@ function Table({ className, ...props }: React.ComponentProps<"table">) { return (
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { return ( ) @@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { ) {
) { wallet: Wallet, risk: ShieldAlert, settlement: Landmark, - jackpot: CircleDollarSign, reports: FileSpreadsheet, reconcile: Scale, audit: ScrollText, admin_users: ShieldCheck, + admin_roles: ShieldCheck, settings: Settings, }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 2db1351..5538c5b 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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; diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx new file mode 100644 index 0000000..a761587 --- /dev/null +++ b/src/modules/admin-roles/admin-roles-console.tsx @@ -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(null); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [directMenuExpanded, setDirectMenuExpanded] = useState>({}); + + const [rolePermissionOpen, setRolePermissionOpen] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [draftRolePermissions, setDraftRolePermissions] = useState([]); + const [roleSaving, setRoleSaving] = useState(false); + + const [roleDialogOpen, setRoleDialogOpen] = useState(false); + const [roleMode, setRoleMode] = useState<"create" | "edit">("create"); + const [editingRoleId, setEditingRoleId] = useState(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(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 { + 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 { + 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 { + 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 ( +
+ + +
+ {t("roleListTitle")} + +
+ +
+ + {err ?

{err}

: null} + {loading && roles.length === 0 ? ( +

{t("states.loading", { ns: "common" })}

+ ) : null} +
+ + + + ID + {t("roleTable.name")} + {t("roleTable.slug")} + {t("roleTable.type")} + {t("roleTable.status")} + {t("roleTable.users")} + {t("roleTable.permissions")} + {t("roleTable.actions")} + + + + {roles.length === 0 ? ( + + + {t("states.noData", { ns: "common" })} + + + ) : ( + roles.map((role) => ( + + {role.id} + +
+ {role.name} + {role.description ?? ""} +
+
+ {role.slug} + + {role.is_system ? ( + {t("roleType.system")} + ) : ( + {t("roleType.custom")} + )} + + + {role.status === 1 ? ( + + {t("status.enabled")} + + ) : ( + + {t("status.disabled")} + + )} + + {role.user_count} + {role.permission_slugs.length} + +
+ + + +
+
+
+ )) + )} +
+
+
+
+
+ + + + + {t("rolePermissionDialog.title")} + + {selectedRole ? `${selectedRole.name} · ${selectedRole.slug}` : null} + + +
+
+ {directPermissionGroups.map((group) => { + const isOpen = isDirectGroupOpen(group.key); + const selectedCount = group.permissions.filter((permission) => + draftRolePermissions.includes(permission.slug), + ).length; + + return ( +
+ + {isOpen ? ( +
+ {group.permissions.map((permission) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+
+
+ + +
+
+
+ + + + + + {roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")} + + {t("roleDialog.description")} + +
+
+
{t("roleDialog.slug")}
+ setRoleSlug(e.target.value)} disabled={roleMode === "edit"} /> +
+
+
{t("roleDialog.name")}
+ setRoleName(e.target.value)} /> +
+
+
{t("roleDialog.descriptionLabel")}
+ setRoleDescription(e.target.value)} /> +
+
+
{t("roleDialog.status")}
+ +
+
+
+ + +
+
+
+ + !open && setRoleDeleteTarget(null)}> + + + {t("roleDelete.confirmTitle")} + + {roleDeleteTarget ? t("roleDelete.confirmDescription", { name: roleDeleteTarget.name }) : null} + + +
+ + +
+
+
+
+ ); +} diff --git a/src/modules/admin-roles/meta.ts b/src/modules/admin-roles/meta.ts new file mode 100644 index 0000000..89a8dbd --- /dev/null +++ b/src/modules/admin-roles/meta.ts @@ -0,0 +1,5 @@ +export const adminRolesModuleMeta = { + segment: "admin_roles", + title: "Roles", + description: "", +} as const; diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index ce4d25e..25c6451 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -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(null); const [draftRoles, setDraftRoles] = useState([]); - const [draftPermissions, setDraftPermissions] = useState([]); - const [saving, setSaving] = useState(false); const [savingRoles, setSavingRoles] = useState(false); const [permissionOpen, setPermissionOpen] = useState(false); - /** `false` = collapsed; default expanded */ - const [directMenuExpanded, setDirectMenuExpanded] = useState>({}); 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 { - 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 { - 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 { if (!selectedUser) { return; @@ -335,33 +275,22 @@ export function AdminUsersConsole(): React.ReactElement { } } - async function savePermissions(): Promise { - if (!selectedUser) { + async function confirmDelete(): Promise { + 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 { {t("table.nickname")} {t("table.status")} {t("table.roles")} - {t("table.direct")} {t("table.effective")} {t("table.actions")} @@ -423,7 +351,7 @@ export function AdminUsersConsole(): React.ReactElement { {items.length === 0 ? ( - + {t("states.noData", { ns: "common" })} @@ -444,7 +372,10 @@ export function AdminUsersConsole(): React.ReactElement { {t("status.enabled")} ) : ( - + {t("status.disabled")} )} @@ -462,7 +393,6 @@ export function AdminUsersConsole(): React.ReactElement { )} - {row.direct_permissions.length} {row.effective_permissions.length}
@@ -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")} - {isOpen ? ( -
- {group.permissions.map((p) => { - const checked = draftPermissions.includes(p.slug); - return ( - - ); - })} -
- ) : null} -
- ); - })} - - +
+
+

{t("permissionDialog.rolesDescription")}

+
+ {(catalog?.roles ?? []).map((role) => { + const checked = draftRoles.includes(role.slug); + return ( + + ); + })} +
-
+
-
- - -
+
@@ -741,26 +570,26 @@ export function AdminUsersConsole(): React.ReactElement { {accountMode === "create" ? (
{t("accountDialog.rolesRequired")}
-

- {t("accountDialog.rolesDescription")} -

+

{t("accountDialog.rolesDescription")}

{(catalog?.roles ?? []).length === 0 ? (

{t("accountDialog.noRoles")}

) : ( - (catalog?.roles ?? []).map((r) => { - const checked = formCreateRoles.includes(r.slug); + (catalog?.roles ?? []).map((role) => { + const checked = formCreateRoles.includes(role.slug); return ( -