feat(admin): 完善玩家管理与角色配置
This commit is contained in:
@@ -38,6 +38,16 @@ export async function putAdminPlayer(
|
||||
return adminRequest.put<AdminPlayerRow>(`${A}/players/${playerId}`, body);
|
||||
}
|
||||
|
||||
export async function putAdminPlayerPassword(
|
||||
playerId: number,
|
||||
body: { password: string; password_confirmation: string },
|
||||
): Promise<{ password_reset: boolean; player_id: number }> {
|
||||
return adminRequest.put<{ password_reset: boolean; player_id: number }>(
|
||||
`${A}/players/${playerId}/password`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdminPlayer(playerId: number): Promise<AdminPlayerDeleteResult> {
|
||||
return adminRequest.delete<AdminPlayerDeleteResult>(`${A}/players/${playerId}`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function getAdminUsers(params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
keyword?: string;
|
||||
role_slug?: string;
|
||||
}): Promise<AdminUserPermissionListData> {
|
||||
return adminRequest.get<AdminUserPermissionListData>(`${A}/admin-users`, {
|
||||
params,
|
||||
|
||||
@@ -7,11 +7,18 @@ import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("adminUsers", "title");
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
type AdminUsersPageProps = {
|
||||
searchParams: Promise<{ role_slug?: string | string[] }>;
|
||||
};
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }: AdminUsersPageProps) {
|
||||
const params = await searchParams;
|
||||
const roleSlug = Array.isArray(params.role_slug) ? params.role_slug[0] : params.role_slug;
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<AdminPermissionGate requiredAny={[PRD_ADMIN_USER_MANAGE]}>
|
||||
<AdminUsersConsole />
|
||||
<AdminUsersConsole roleSlug={roleSlug?.trim() ?? ""} />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
|
||||
@@ -5,5 +5,5 @@ export default function AdminSegmentLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className="flex min-h-full flex-1 flex-col">{children}</div>;
|
||||
return <div className="admin-scrollbars flex min-h-full flex-1 flex-col">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: rgb(255 255 255 / 14%);
|
||||
--sidebar-ring: rgb(255 255 255 / 36%);
|
||||
--scrollbar-track: rgb(15 31 61 / 6%);
|
||||
--scrollbar-thumb: #b7c9e2;
|
||||
--scrollbar-thumb-hover: #8da9cf;
|
||||
--scrollbar-thumb-active: #6f91bf;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -117,6 +121,10 @@
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: rgb(255 255 255 / 14%);
|
||||
--sidebar-ring: rgb(255 255 255 / 36%);
|
||||
--scrollbar-track: rgb(255 255 255 / 6%);
|
||||
--scrollbar-thumb: #405675;
|
||||
--scrollbar-thumb-hover: #587398;
|
||||
--scrollbar-thumb-active: #6c8bb6;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -131,6 +139,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 后管滚动条:原生滚动区域与 ScrollArea 共用同一套语义颜色 */
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
) {
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar-track {
|
||||
border-radius: 999px;
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar-thumb {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--scrollbar-thumb);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar-thumb:active {
|
||||
background-color: var(--scrollbar-thumb-active);
|
||||
}
|
||||
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
)::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"] {
|
||||
--scrollbar-track: rgb(255 255 255 / 6%);
|
||||
--scrollbar-thumb: rgb(255 255 255 / 28%);
|
||||
--scrollbar-thumb-hover: rgb(255 255 255 / 42%);
|
||||
--scrollbar-thumb-active: rgb(255 255 255 / 56%);
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-scrollbar"]:hover > [data-slot="scroll-area-thumb"] {
|
||||
background-color: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-scrollbar"]:active > [data-slot="scroll-area-thumb"] {
|
||||
background-color: var(--scrollbar-thumb-active);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:where(
|
||||
html:has(.admin-scrollbars),
|
||||
body:has(.admin-scrollbars),
|
||||
.admin-scrollbars,
|
||||
.admin-scrollbars *
|
||||
) {
|
||||
scrollbar-color: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.admin-list-card {
|
||||
@apply border-border/80 bg-card shadow-sm;
|
||||
@@ -282,4 +388,3 @@
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
67
src/components/admin/admin-field-label.tsx
Normal file
67
src/components/admin/admin-field-label.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type AdminFieldLabelProps = {
|
||||
htmlFor: string;
|
||||
children: ReactNode;
|
||||
helpText?: string;
|
||||
helpAriaLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminFieldLabel({
|
||||
htmlFor,
|
||||
children,
|
||||
helpText,
|
||||
helpAriaLabel,
|
||||
className,
|
||||
}: AdminFieldLabelProps): React.ReactElement {
|
||||
if (!helpText) {
|
||||
return (
|
||||
<Label htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Label htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label={helpAriaLabel}
|
||||
data-field-help={htmlFor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-80 whitespace-normal text-left leading-relaxed"
|
||||
>
|
||||
<p>{helpText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminFieldLabel } from "@/components/admin/admin-field-label";
|
||||
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { isNumericStepperOutOfRange } from "@/lib/agent-profile-caps";
|
||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||
|
||||
@@ -15,6 +15,8 @@ export type PlayerCreditLimitFieldProps = {
|
||||
parentAvailableCredit: number | null;
|
||||
/** 编辑已有玩家时传入当前授信,用于计算可上调上限 */
|
||||
baselineCreditLimit?: number;
|
||||
helpText?: string;
|
||||
helpAriaLabel?: string;
|
||||
};
|
||||
|
||||
export function PlayerCreditLimitField({
|
||||
@@ -23,6 +25,8 @@ export function PlayerCreditLimitField({
|
||||
onValueChange,
|
||||
parentAvailableCredit,
|
||||
baselineCreditLimit = 0,
|
||||
helpText,
|
||||
helpAriaLabel,
|
||||
}: PlayerCreditLimitFieldProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents"]);
|
||||
|
||||
@@ -36,9 +40,14 @@ export function PlayerCreditLimitField({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id} className="text-muted-foreground">
|
||||
<AdminFieldLabel
|
||||
htmlFor={id}
|
||||
className="text-muted-foreground"
|
||||
helpText={helpText}
|
||||
helpAriaLabel={helpAriaLabel}
|
||||
>
|
||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
</AdminFieldLabel>
|
||||
<AdminNumericStepper
|
||||
id={id}
|
||||
value={value}
|
||||
|
||||
@@ -46,7 +46,7 @@ function ScrollBar({
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
className="relative flex-1 rounded-full bg-[var(--scrollbar-thumb)] transition-colors"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"accountDialog": {
|
||||
"createDescription": "Assign at least one default-site role. Login usernames may contain letters, numbers, dots, underscores, and hyphens only, and are stored in lowercase.",
|
||||
"createDescriptionPlatform": "Assign at least one platform role. Agent accounts can only be created or bound in Agent Management. Login usernames may contain letters, numbers, dots, underscores, and hyphens only, and are stored in lowercase.",
|
||||
"createTitle": "Create admin",
|
||||
"editDescription": "Login username cannot be changed. Leave password empty to keep it unchanged.",
|
||||
"editDescriptionPlatform": "This form only edits platform accounts. Agent accounts can only be created or bound in Agent Management. Login username cannot be changed; leave password empty to keep it unchanged.",
|
||||
"editTitle": "Edit account",
|
||||
"emailOptional": "Email (optional)",
|
||||
"emailPlaceholder": "Leave empty if not needed",
|
||||
@@ -14,6 +16,7 @@
|
||||
"passwordPlaceholderCreate": "at least 6 characters",
|
||||
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
|
||||
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
|
||||
"rolesDescriptionPlatform": "Only platform roles can be selected here. Create or bind agent accounts in Agent Management.",
|
||||
"rolesRequired": "Roles (at least one)",
|
||||
"site": "Bound site",
|
||||
"sitePlaceholder": "Select which site this account can access",
|
||||
@@ -203,7 +206,9 @@
|
||||
"slug": "Role Code",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
"users": "Users"
|
||||
"users": "Linked accounts",
|
||||
"platformUsers": "Platform {{count}}",
|
||||
"agentUsers": "Agent {{count}}"
|
||||
},
|
||||
"roleType": {
|
||||
"custom": "Custom",
|
||||
@@ -217,6 +222,10 @@
|
||||
"saveRoleSuccess": "Updated roles for {{name}}",
|
||||
"saving": "Saving…",
|
||||
"searchPlaceholder": "Search by username / nickname / email",
|
||||
"roleFilter": {
|
||||
"active": "Filtering platform accounts by role “{{role}}”",
|
||||
"clear": "Clear role filter"
|
||||
},
|
||||
"siteRequired": "Select a site",
|
||||
"status": {
|
||||
"disabled": "Disabled",
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"d2": "2D Global",
|
||||
"d3": "3D Global",
|
||||
"d4": "4D Global",
|
||||
"jackpot": "Jackpot",
|
||||
"attribute": "Attribute Plays",
|
||||
"position": "Position Plays"
|
||||
},
|
||||
"batchPartialEnabled": "{{enabledCount}}/{{total}} enabled (not all on — turn on to enable all)",
|
||||
@@ -312,6 +312,7 @@
|
||||
"batchSwitchDisable": "Disable",
|
||||
"batchSwitchEnable": "Enable",
|
||||
"batchSwitchesDesc": "Only updates the current draft. The player betting table refreshes after save and publish.",
|
||||
"batchSwitchSavedLocal": "The batch switch was written to the current draft. Save and publish the draft to apply it.",
|
||||
"batchSwitchesTitle": "Batch switches",
|
||||
"categories": {
|
||||
"attribute": "Attribute",
|
||||
|
||||
@@ -30,10 +30,39 @@
|
||||
"createUsernameRequired": "Enter login username",
|
||||
"createSitePlayerIdOptional": "Optional; auto-generated if empty",
|
||||
"createSuccessNative": "Player {{name}} created — sign in on the lottery site",
|
||||
"fieldHelpAria": "View help for {{field}}",
|
||||
"helpFundingWallet": "The player enters through main-site SSO and bets with funds transferred into the lottery wallet. The player ID must exactly match the main site.",
|
||||
"helpFundingCredit": "The player signs in directly with lottery credentials and bets against credit granted by the assigned agent instead of a main-site wallet.",
|
||||
"helpSiteCode": "The integration site code for this player. It determines SSO, wallet APIs, data scope, and available agents. It is a code, not necessarily a number.",
|
||||
"helpAgentNode": "Assigns the player to an agent line for access and reporting. For credit players it also controls grantable credit and settlement. Choose the site root if there is no agent split.",
|
||||
"helpSitePlayerIdWallet": "The permanent unique identifier supplied by the main site. Wallet players require it, and it must exactly match the value sent by main-site SSO.",
|
||||
"helpSitePlayerIdCredit": "The player's internal lottery identifier. Credit players may leave it empty and the system will generate one on save.",
|
||||
"helpUsernameWallet": "Wallet players sign in through main-site SSO. This is optional profile data and is not a lottery login account.",
|
||||
"helpUsernameCredit": "The account a credit player uses to sign in on the lottery site. It must be unique within the site.",
|
||||
"helpPassword": "The credit player's initial lottery password. It must contain at least 6 characters; wallet players do not need one.",
|
||||
"helpCreditLimit": "The maximum credit the agent lets this player use. Bets consume the limit, which cannot exceed the assigned agent's currently grantable credit.",
|
||||
"helpNickname": "A display name used in the admin and player interfaces. It is not used for login or identity matching and may be left empty.",
|
||||
"helpDefaultCurrency": "The currency used for this player's wallet, credit, and bets. Changing it does not convert any existing amounts.",
|
||||
"helpStatus": "Normal players can sign in and bet. Frozen and banned players are both blocked from normal access.",
|
||||
"editCreditLimit": "Credit limit",
|
||||
"editDetailLoading": "Loading player details…",
|
||||
"usernameInvalidCharset": "Username may only contain letters, digits, dots, underscores, and hyphens",
|
||||
"passwordMinLength": "Initial password must be at least 6 characters",
|
||||
"resetPassword": "Reset login password",
|
||||
"resetPasswordTitle": "Reset player login password",
|
||||
"resetPasswordDescription": "Set a new lottery login password for player {{name}}.",
|
||||
"resetPasswordNew": "New password",
|
||||
"resetPasswordConfirm": "Confirm new password",
|
||||
"resetPasswordPlaceholder": "At least 6 characters",
|
||||
"resetPasswordConfirmPlaceholder": "Enter the new password again",
|
||||
"resetPasswordSessionNotice": "After saving, all current lottery sessions for this player will expire immediately. The player must sign in again with the new password.",
|
||||
"resetPasswordRequired": "Enter a new password",
|
||||
"resetPasswordMinLength": "The new password must be at least 6 characters",
|
||||
"resetPasswordMismatch": "The new passwords do not match",
|
||||
"resetPasswordSubmit": "Reset password",
|
||||
"resetPasswordSaving": "Resetting…",
|
||||
"resetPasswordSuccess": "Login password reset for player {{name}}",
|
||||
"resetPasswordFailed": "Failed to reset password",
|
||||
"creditLimitInvalid": "Credit limit must be a non-negative integer",
|
||||
"creditCapLoading": "Agent grantable credit is still loading — try again shortly",
|
||||
"creditLimitExceeded": "Credit limit cannot exceed the agent's grantable amount",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"accountDialog": {
|
||||
"createDescription": "कम्तीमा एउटा पूर्वनिर्धारित साइट भूमिका तोक्नुपर्छ। लगइन नाममा अक्षर, अंक, डट, अन्डरस्कोर र हाइफन मात्र प्रयोग गर्न सकिन्छ र सेभ भएपछि साना अक्षरमा राखिन्छ।",
|
||||
"createDescriptionPlatform": "प्लेटफर्म खाताका लागि कम्तीमा एउटा प्लेटफर्म भूमिका छान्नुहोस्। एजेन्ट खाता एजेन्ट व्यवस्थापनमा मात्र सिर्जना वा बाँध्न सकिन्छ।",
|
||||
"createTitle": "प्रशासक सिर्जना",
|
||||
"editDescription": "लगइन नाम परिवर्तन गर्न मिल्दैन। पासवर्ड खाली छोडेमा परिवर्तन हुँदैन।",
|
||||
"editDescriptionPlatform": "यहाँ प्लेटफर्म खाता मात्र सम्पादन हुन्छ। एजेन्ट खाता एजेन्ट व्यवस्थापनमा मात्र सिर्जना वा बाँध्न सकिन्छ।",
|
||||
"editTitle": "खाता सम्पादन",
|
||||
"emailOptional": "इमेल (वैकल्पिक)",
|
||||
"emailPlaceholder": "नचाहिए खाली छोड्नुहोस्",
|
||||
@@ -14,6 +16,7 @@
|
||||
"passwordPlaceholderCreate": "कम्तीमा 6 वर्ण",
|
||||
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
|
||||
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
|
||||
"rolesDescriptionPlatform": "यहाँ प्लेटफर्म भूमिका मात्र छान्न सकिन्छ। एजेन्ट खाता एजेन्ट व्यवस्थापनमा सिर्जना वा बाँध्नुहोस्।",
|
||||
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
|
||||
"site": "बाँधिएको साइट",
|
||||
"sitePlaceholder": "यो खाताले पहुँच पाउने साइट छान्नुहोस्",
|
||||
@@ -191,7 +194,9 @@
|
||||
"slug": "भूमिका कोड",
|
||||
"status": "स्थिति",
|
||||
"type": "प्रकार",
|
||||
"users": "सम्बन्धित प्रयोगकर्ता"
|
||||
"users": "सम्बन्धित खाता",
|
||||
"platformUsers": "प्लेटफर्म {{count}}",
|
||||
"agentUsers": "एजेन्ट {{count}}"
|
||||
},
|
||||
"roleType": {
|
||||
"custom": "अनुकूलित",
|
||||
@@ -205,6 +210,10 @@
|
||||
"saveRoleSuccess": "{{name}} को भूमिका अपडेट भयो",
|
||||
"saving": "सेभ हुँदैछ…",
|
||||
"searchPlaceholder": "प्रयोगकर्ता नाम / उपनाम / इमेलबाट खोज्नुहोस्",
|
||||
"roleFilter": {
|
||||
"active": "“{{role}}” भूमिकाअनुसार प्लेटफर्म खाता फिल्टर गरिएको छ",
|
||||
"clear": "भूमिका फिल्टर हटाउनुहोस्"
|
||||
},
|
||||
"siteRequired": "साइट छान्नुहोस्",
|
||||
"status": {
|
||||
"disabled": "निष्क्रिय",
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"d2": "2D ग्लोबल",
|
||||
"d3": "3D ग्लोबल",
|
||||
"d4": "4D ग्लोबल",
|
||||
"jackpot": "ज्याकपट",
|
||||
"attribute": "विशेषता खेलहरू",
|
||||
"position": "स्थिति खेलहरू"
|
||||
},
|
||||
"batchPartialEnabled": "{{enabledCount}}/{{total}} सक्रिय (सबै खुला छैन — अन गर्दा सबै सक्रिय हुन्छ)",
|
||||
@@ -312,6 +312,7 @@
|
||||
"batchSwitchDisable": "निष्क्रिय",
|
||||
"batchSwitchEnable": "सक्रिय",
|
||||
"batchSwitchesDesc": "यसले हालको ड्राफ्ट मात्र अपडेट गर्छ। सेभ र प्रकाशित गरेपछि खेलाडीको बेटिङ तालिका रिफ्रेस हुन्छ।",
|
||||
"batchSwitchSavedLocal": "समूह स्विच हालको ड्राफ्टमा लेखियो। लागू गर्न ड्राफ्ट सेभ गरी प्रकाशित गर्नुहोस्।",
|
||||
"batchSwitchesTitle": "समूह स्विचहरू",
|
||||
"categories": {
|
||||
"attribute": "विशेषता",
|
||||
|
||||
@@ -30,10 +30,39 @@
|
||||
"createUsernameRequired": "लगइन प्रयोगकर्ता नाम लेख्नुहोस्",
|
||||
"createSitePlayerIdOptional": "वैकल्पिक; खाली छोड्दा स्वतः सिर्जना हुन्छ",
|
||||
"createSuccessNative": "खेलाडी {{name}} सिर्जना भयो — लटरी साइटमा लगइन गर्नुहोस्",
|
||||
"fieldHelpAria": "{{field}} को व्याख्या हेर्नुहोस्",
|
||||
"helpFundingWallet": "खेलाडी मुख्य साइटबाट SSO मार्फत लटरीमा प्रवेश गर्छ र लटरी वालेटमा सारिएको रकमबाट बाजी लगाउँछ। खेलाडी ID मुख्य साइटसँग ठ्याक्कै मिल्नुपर्छ।",
|
||||
"helpFundingCredit": "खेलाडी लटरीको खाता र पासवर्डले सीधै लगइन गर्छ र मुख्य साइट वालेटको सट्टा सम्बन्धित एजेन्टले दिएको क्रेडिट प्रयोग गर्छ।",
|
||||
"helpSiteCode": "यो खेलाडीको इन्टिग्रेसन साइट कोड हो। यसले SSO, वालेट API, डेटा स्कोप र उपलब्ध एजेन्ट निर्धारण गर्छ। यो कोड हो, नम्बर हुनैपर्छ भन्ने छैन।",
|
||||
"helpAgentNode": "खेलाडीलाई कुन एजेन्ट लाइनमा राख्ने भन्ने तय गर्छ र पहुँच तथा रिपोर्टमा असर गर्छ। क्रेडिट खेलाडीका लागि यसले दिन मिल्ने क्रेडिट र सेटलमेन्ट पनि निर्धारण गर्छ।",
|
||||
"helpSitePlayerIdWallet": "मुख्य साइटले दिएको स्थायी अद्वितीय चिन्ह। वालेट खेलाडीका लागि अनिवार्य छ र मुख्य साइट SSO ले पठाएको मानसँग ठ्याक्कै मिल्नुपर्छ।",
|
||||
"helpSitePlayerIdCredit": "लटरीभित्रको खेलाडीको अद्वितीय चिन्ह। क्रेडिट खेलाडीले खाली छोडेमा सुरक्षित गर्दा प्रणालीले स्वतः सिर्जना गर्छ।",
|
||||
"helpUsernameWallet": "वालेट खेलाडी मुख्य साइट SSO बाट लगइन गर्छ। यो वैकल्पिक प्रोफाइल विवरण हो, लटरी लगइन खाता होइन।",
|
||||
"helpUsernameCredit": "क्रेडिट खेलाडीले लटरी साइटमा लगइन गर्दा प्रयोग गर्ने खाता। एउटै साइटभित्र दोहोरिन मिल्दैन।",
|
||||
"helpPassword": "क्रेडिट खेलाडीको प्रारम्भिक लटरी पासवर्ड। कम्तीमा ६ वर्ण हुनुपर्छ; वालेट खेलाडीलाई आवश्यक पर्दैन।",
|
||||
"helpCreditLimit": "एजेन्टले खेलाडीलाई प्रयोग गर्न दिएको अधिकतम क्रेडिट। बाजीले यो सीमा प्रयोग गर्छ र यो एजेन्टको हाल दिन सकिने क्रेडिटभन्दा बढी हुन सक्दैन।",
|
||||
"helpNickname": "ब्याकअफिस र खेलाडी इन्टरफेसमा देखिने नाम। लगइन वा पहिचान मिलाउन प्रयोग हुँदैन र खाली छोड्न सकिन्छ।",
|
||||
"helpDefaultCurrency": "खेलाडीको वालेट, क्रेडिट र बाजीमा प्रयोग हुने मुद्रा। यसलाई परिवर्तन गर्दा पुरानो रकम स्वतः सटही हुँदैन।",
|
||||
"helpStatus": "सामान्य खेलाडीले लगइन र बाजी गर्न सक्छ। फ्रिज वा प्रतिबन्धित खेलाडीको सामान्य पहुँच रोकिन्छ।",
|
||||
"editCreditLimit": "क्रेडिट सीमा",
|
||||
"editDetailLoading": "खेलाडी विवरण लोड हुँदैछ…",
|
||||
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट, अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
|
||||
"passwordMinLength": "प्रारम्भिक पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"resetPassword": "लगइन पासवर्ड रिसेट",
|
||||
"resetPasswordTitle": "खेलाडी लगइन पासवर्ड रिसेट",
|
||||
"resetPasswordDescription": "खेलाडी {{name}} का लागि नयाँ लटरी लगइन पासवर्ड सेट गर्नुहोस्।",
|
||||
"resetPasswordNew": "नयाँ पासवर्ड",
|
||||
"resetPasswordConfirm": "नयाँ पासवर्ड पुष्टि",
|
||||
"resetPasswordPlaceholder": "कम्तीमा ६ वर्ण",
|
||||
"resetPasswordConfirmPlaceholder": "नयाँ पासवर्ड फेरि लेख्नुहोस्",
|
||||
"resetPasswordSessionNotice": "सुरक्षित गरेपछि यस खेलाडीका सबै हालका लटरी सत्र तुरुन्तै समाप्त हुन्छन्। नयाँ पासवर्डले फेरि लगइन गर्नुपर्छ।",
|
||||
"resetPasswordRequired": "नयाँ पासवर्ड लेख्नुहोस्",
|
||||
"resetPasswordMinLength": "नयाँ पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"resetPasswordMismatch": "नयाँ पासवर्डहरू मिलेनन्",
|
||||
"resetPasswordSubmit": "पासवर्ड रिसेट",
|
||||
"resetPasswordSaving": "रिसेट हुँदै…",
|
||||
"resetPasswordSuccess": "खेलाडी {{name}} को लगइन पासवर्ड रिसेट भयो",
|
||||
"resetPasswordFailed": "पासवर्ड रिसेट असफल भयो",
|
||||
"creditLimitInvalid": "क्रेडिट सीमा नऋणात्मक पूर्णांक हुनुपर्छ",
|
||||
"creditCapLoading": "एजेन्ट क्रेडिट लोड हुँदैछ — केही बेरपछि पुनः प्रयास गर्नुहोस्",
|
||||
"creditLimitExceeded": "क्रेडिट सीमा एजेन्टको दिन सकिने रकमभन्दा बढी हुन सक्दैन",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"listTitle": "平台账号列表",
|
||||
"createAdmin": "新建平台账号",
|
||||
"searchPlaceholder": "按用户名 / 昵称 / 邮箱搜索",
|
||||
"roleFilter": {
|
||||
"active": "当前按角色“{{role}}”筛选平台账号",
|
||||
"clear": "清除角色筛选"
|
||||
},
|
||||
"loadFailed": "加载管理员列表失败",
|
||||
"roleLoadFailed": "加载角色列表失败",
|
||||
"nicknameRequired": "请填写昵称",
|
||||
@@ -67,7 +71,9 @@
|
||||
"slug": "角色编码",
|
||||
"type": "类型",
|
||||
"status": "状态",
|
||||
"users": "关联用户",
|
||||
"users": "关联账号",
|
||||
"platformUsers": "平台 {{count}}",
|
||||
"agentUsers": "代理 {{count}}",
|
||||
"permissions": "权限数",
|
||||
"enabledAreas": "开放模块",
|
||||
"actions": "操作"
|
||||
@@ -128,6 +134,8 @@
|
||||
"accountDialog": {
|
||||
"createTitle": "新建管理员",
|
||||
"editTitle": "编辑账号",
|
||||
"createDescriptionPlatform": "须为平台账号指定至少一个平台角色。代理账号只能在“代理管理”中创建或绑定。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。",
|
||||
"editDescriptionPlatform": "这里只编辑平台账号。代理账号只能在“代理管理”中创建或绑定。登录账号不可修改,留空密码表示不修改。",
|
||||
"createDescription": "须为账号指定至少一个默认站点角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。",
|
||||
"editDescription": "登录账号不可修改。留空密码表示不修改。",
|
||||
"username": "登录账号",
|
||||
@@ -144,6 +152,7 @@
|
||||
"sitePlaceholder": "选择该账号可访问的数据站点",
|
||||
"rolesRequired": "角色(至少一项)",
|
||||
"rolesDescription": "创建后可在「分配角色」中按站点继续调整角色绑定。",
|
||||
"rolesDescriptionPlatform": "这里只能选择平台角色;代理账号只能在“代理管理”中创建或绑定。",
|
||||
"noRoles": "暂无角色数据,请等待列表加载完成后重试。"
|
||||
},
|
||||
"delete": {
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
"big-small": "Big / Small",
|
||||
"position": "位置类玩法",
|
||||
"box": "包号类玩法",
|
||||
"jackpot": "奖池"
|
||||
"attribute": "属性类玩法"
|
||||
},
|
||||
"validation": {
|
||||
"minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额",
|
||||
@@ -417,6 +417,7 @@
|
||||
"readOnlyHint": "当前限额与规则为只读,请先创建草稿。",
|
||||
"batchSwitchesTitle": "批量开关",
|
||||
"batchSwitchesDesc": "这里只会修改当前草稿;保存并发布后,玩家下注表会按新配置刷新。",
|
||||
"batchSwitchSavedLocal": "批量开关已写入当前草稿,仍需保存草稿并发布。",
|
||||
"readOnlyDraftHint": "当前版本为只读,请先创建草稿。",
|
||||
"batchEnabledCount": "{{enabledCount}}/{{total}} 已开启",
|
||||
"noPlayTypes": "暂无玩法",
|
||||
|
||||
@@ -90,10 +90,39 @@
|
||||
"createUsernameRequired": "请填写登录账号",
|
||||
"createSitePlayerIdOptional": "选填,留空将自动生成",
|
||||
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端登录",
|
||||
"fieldHelpAria": "查看“{{field}}”说明",
|
||||
"helpFundingWallet": "玩家从主站通过 SSO 进入彩票;投注资金使用主站转入的彩票钱包。必须填写与主站一致的玩家 ID。",
|
||||
"helpFundingCredit": "玩家直接使用彩票端账号密码登录;投注占用归属代理下发的授信额度,不使用主站钱包。",
|
||||
"helpSiteCode": "玩家所属的接入站点代码,决定 SSO、钱包接口、数据范围和可选代理。它是站点代码,不一定是数字。",
|
||||
"helpAgentNode": "决定玩家归入哪条代理线,影响管理权限和报表归属;信用盘还影响可下发授信与账期结算。没有细分代理时选站点根节点。",
|
||||
"helpSitePlayerIdWallet": "主站为玩家提供的永久唯一标识。钱包盘必须填写,并且要和主站 SSO 传来的值完全一致。",
|
||||
"helpSitePlayerIdCredit": "彩票端为玩家保留的站内唯一标识。信用盘可以留空,保存时系统会自动生成。",
|
||||
"helpUsernameWallet": "钱包盘通过主站 SSO 登录;这里是选填资料,不作为彩票端登录账号。",
|
||||
"helpUsernameCredit": "信用盘玩家在彩票端登录时使用的账号;同一站点内不能重复。",
|
||||
"helpPassword": "信用盘玩家首次登录使用的密码,至少 6 位;钱包盘不需要彩票端密码。",
|
||||
"helpCreditLimit": "代理允许玩家使用的最大信用额度。下注会占用额度,不能超过归属代理当前可下发的额度。",
|
||||
"helpNickname": "用于后台和玩家端显示的名称,不参与登录和身份匹配,可以留空。",
|
||||
"helpDefaultCurrency": "这个玩家的钱包、信用和投注金额所使用的币种。修改它不会自动兑换已有金额。",
|
||||
"helpStatus": "正常状态可以登录和下注;冻结与封禁都会阻止玩家正常登录和使用。",
|
||||
"editCreditLimit": "授信额度",
|
||||
"editDetailLoading": "加载玩家详情…",
|
||||
"usernameInvalidCharset": "登录账号只能使用字母、数字、点(.)、下划线和连字符",
|
||||
"passwordMinLength": "初始密码至少 6 位",
|
||||
"resetPassword": "重置登录密码",
|
||||
"resetPasswordTitle": "重置玩家登录密码",
|
||||
"resetPasswordDescription": "为玩家 {{name}} 设置新的彩票端登录密码。",
|
||||
"resetPasswordNew": "新密码",
|
||||
"resetPasswordConfirm": "确认新密码",
|
||||
"resetPasswordPlaceholder": "至少 6 位",
|
||||
"resetPasswordConfirmPlaceholder": "再次输入新密码",
|
||||
"resetPasswordSessionNotice": "保存后,该玩家当前所有彩票端会话会立即失效,需要使用新密码重新登录。",
|
||||
"resetPasswordRequired": "请填写新密码",
|
||||
"resetPasswordMinLength": "新密码至少 6 位",
|
||||
"resetPasswordMismatch": "两次输入的新密码不一致",
|
||||
"resetPasswordSubmit": "确认重置",
|
||||
"resetPasswordSaving": "正在重置…",
|
||||
"resetPasswordSuccess": "玩家 {{name}} 的登录密码已重置",
|
||||
"resetPasswordFailed": "重置密码失败",
|
||||
"creditLimitInvalid": "授信额度必须为不小于 0 的整数",
|
||||
"creditCapLoading": "代理可下发额度加载中,请稍后再保存",
|
||||
"creditLimitExceeded": "授信额度不能超过当前代理可下发额度",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
@@ -334,7 +335,34 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
{role.status === 1 ? t("status.enabled") : t("status.disabled")}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1 text-xs tabular-nums">
|
||||
{role.platform_user_count > 0 ? (
|
||||
<Link
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
href={`/admin/admin-users?role_slug=${encodeURIComponent(role.slug)}`}
|
||||
>
|
||||
{t("roleTable.platformUsers", { count: role.platform_user_count })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("roleTable.platformUsers", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
{role.agent_user_count > 0 ? (
|
||||
<Link
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
href="/admin/agents"
|
||||
>
|
||||
{t("roleTable.agentUsers", { count: role.agent_user_count })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("roleTable.agentUsers", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{countEnabledAreas(role.permission_slugs, catalog, isSuperAdmin)}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
@@ -65,7 +66,11 @@ import type {
|
||||
} from "@/types/api/index";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
type AdminUsersConsoleProps = {
|
||||
roleSlug?: string;
|
||||
};
|
||||
|
||||
export function AdminUsersConsole({ roleSlug = "" }: AdminUsersConsoleProps): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
@@ -124,6 +129,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
() => new Map((catalog?.roles ?? []).map((role) => [role.slug, role.name])),
|
||||
[catalog],
|
||||
);
|
||||
const assignableRoles = catalog?.assignable_roles ?? [];
|
||||
const router = useRouter();
|
||||
|
||||
const defaultSiteId = useMemo(() => siteOptions[0]?.id ?? null, [siteOptions]);
|
||||
const roleEditSiteLabel = useMemo(() => {
|
||||
@@ -168,6 +175,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
role_slug: roleSlug || undefined,
|
||||
}),
|
||||
]);
|
||||
setCatalog(catalogData);
|
||||
@@ -183,11 +191,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, tRef]);
|
||||
}, [page, perPage, query, roleSlug, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query]);
|
||||
}, [page, perPage, query, roleSlug]);
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
@@ -446,6 +454,23 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{roleSlug ? (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
<span>
|
||||
{t("roleFilter.active", {
|
||||
role: roleNameBySlug.get(roleSlug) ?? roleSlug,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => router.replace("/admin/admin-users")}
|
||||
>
|
||||
{t("roleFilter.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
@@ -598,7 +623,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((role) => {
|
||||
{assignableRoles.map((role) => {
|
||||
const checked = draftRoles.includes(role.slug);
|
||||
return (
|
||||
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
||||
@@ -741,12 +766,12 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
})}
|
||||
</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 ? (
|
||||
{assignableRoles.length === 0 ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
{t("accountDialog.noRoles")}
|
||||
</p>
|
||||
) : (
|
||||
(catalog?.roles ?? []).map((role) => {
|
||||
assignableRoles.map((role) => {
|
||||
const checked = formCreateRoles.includes(role.slug);
|
||||
return (
|
||||
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
||||
|
||||
@@ -86,12 +86,6 @@ type PlayBatchSwitchGroup = {
|
||||
match: (row: PlayConfigItemRow) => boolean;
|
||||
};
|
||||
|
||||
const TRADITIONAL_PLAY_CODES = new Set([
|
||||
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
|
||||
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
|
||||
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
|
||||
]);
|
||||
|
||||
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
|
||||
{
|
||||
key: "d2",
|
||||
@@ -118,8 +112,8 @@ const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
|
||||
match: (row) => row.category === "box",
|
||||
},
|
||||
{
|
||||
key: "jackpot",
|
||||
match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
|
||||
key: "attribute",
|
||||
match: (row) => row.category === "attribute",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -273,7 +267,7 @@ export function PlayConfigDocScreen() {
|
||||
|
||||
const orderedRows = useMemo(
|
||||
() =>
|
||||
draftRows.filter((row) => TRADITIONAL_PLAY_CODES.has(row.play_code)).sort(
|
||||
[...draftRows].sort(
|
||||
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
|
||||
),
|
||||
[draftRows],
|
||||
@@ -342,6 +336,7 @@ export function PlayConfigDocScreen() {
|
||||
setDraftRows((prev) =>
|
||||
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
||||
);
|
||||
toast.success(t("play.batchSwitchSavedLocal", { ns: "config" }));
|
||||
}
|
||||
|
||||
const batchSwitchStates = useMemo(
|
||||
@@ -582,47 +577,59 @@ export function PlayConfigDocScreen() {
|
||||
) : null}
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<label
|
||||
key={group.key}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
group: group.label,
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
<div className="space-y-1.5">
|
||||
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<label
|
||||
key={group.key}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
group: group.label,
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("play.batchSwitchesDesc", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
postAdminPlayerFreeze,
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
putAdminPlayerPassword,
|
||||
} from "@/api/admin-player";
|
||||
import { AdminFieldLabel } from "@/components/admin/admin-field-label";
|
||||
import { PlayerCreditLimitField } from "@/components/admin/player-credit-limit-field";
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
@@ -160,6 +162,10 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [freezeBusyId, setFreezeBusyId] = useState<number | null>(null);
|
||||
const [passwordTarget, setPasswordTarget] = useState<AdminPlayerRow | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetPasswordConfirm, setResetPasswordConfirm] = useState("");
|
||||
const [passwordResetBusy, setPasswordResetBusy] = useState(false);
|
||||
|
||||
const editingPlayer = useMemo(
|
||||
() => items.find((p) => p.id === editingAccountId) ?? null,
|
||||
@@ -177,6 +183,55 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
const editingUsesCredit = editingPlayer != null && isCreditFundingPlayer(editingPlayer);
|
||||
|
||||
function openPasswordReset(player: AdminPlayerRow) {
|
||||
setResetPassword("");
|
||||
setResetPasswordConfirm("");
|
||||
setPasswordTarget(player);
|
||||
}
|
||||
|
||||
function handlePasswordDialogOpenChange(open: boolean) {
|
||||
if (open) return;
|
||||
setPasswordTarget(null);
|
||||
setResetPassword("");
|
||||
setResetPasswordConfirm("");
|
||||
}
|
||||
|
||||
async function submitPasswordReset() {
|
||||
if (passwordTarget === null) return;
|
||||
|
||||
const issue = validateNativePlayerPassword(resetPassword);
|
||||
if (issue === "empty") {
|
||||
toast.error(t("resetPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
if (issue === "too_short") {
|
||||
toast.error(t("resetPasswordMinLength"));
|
||||
return;
|
||||
}
|
||||
if (resetPassword !== resetPasswordConfirm) {
|
||||
toast.error(t("resetPasswordMismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordResetBusy(true);
|
||||
try {
|
||||
await putAdminPlayerPassword(passwordTarget.id, {
|
||||
password: resetPassword,
|
||||
password_confirmation: resetPasswordConfirm,
|
||||
});
|
||||
toast.success(
|
||||
t("resetPasswordSuccess", {
|
||||
name: passwordTarget.username ?? passwordTarget.site_player_id,
|
||||
}),
|
||||
);
|
||||
handlePasswordDialogOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof LotteryApiBizError ? error.message : t("resetPasswordFailed"));
|
||||
} finally {
|
||||
setPasswordResetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showSiteFilter =
|
||||
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
|
||||
|
||||
@@ -828,6 +883,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "reset-password",
|
||||
label: t("resetPassword"),
|
||||
icon: KeyRound,
|
||||
hidden: row.auth_source !== "lottery_native",
|
||||
onClick: () => openPasswordReset(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
@@ -875,7 +937,17 @@ export function PlayersConsole(): React.ReactElement {
|
||||
{accountMode === "create" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-funding-mode">{t("createFundingMode")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-funding-mode"
|
||||
helpText={
|
||||
formFundingMode === "credit"
|
||||
? t("helpFundingCredit")
|
||||
: t("helpFundingWallet")
|
||||
}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("createFundingMode") })}
|
||||
>
|
||||
{t("createFundingMode")}
|
||||
</AdminFieldLabel>
|
||||
<Select
|
||||
value={formFundingMode}
|
||||
onValueChange={(value) => {
|
||||
@@ -901,7 +973,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-site-code"
|
||||
helpText={t("helpSiteCode")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("siteCode") })}
|
||||
>
|
||||
{t("siteCode")}
|
||||
</AdminFieldLabel>
|
||||
{boundAgent && boundAgent.site_code ? (
|
||||
<Input id="player-site-code" value={boundAgent.site_code} disabled readOnly />
|
||||
) : canChooseSite && siteOptions.length > 0 ? (
|
||||
@@ -943,7 +1021,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</p>
|
||||
) : canPickAgentOnCreate ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-agent-node">{t("createAgentNode")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-agent-node"
|
||||
helpText={t("helpAgentNode")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("createAgentNode") })}
|
||||
>
|
||||
{t("createAgentNode")}
|
||||
</AdminFieldLabel>
|
||||
<Select
|
||||
value={
|
||||
formAgentNodeId != null && formAgentNodeId > 0
|
||||
@@ -970,7 +1054,17 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-id">{t("sitePlayerIdLabel")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-site-id"
|
||||
helpText={
|
||||
formFundingMode === "credit"
|
||||
? t("helpSitePlayerIdCredit")
|
||||
: t("helpSitePlayerIdWallet")
|
||||
}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("sitePlayerIdLabel") })}
|
||||
>
|
||||
{t("sitePlayerIdLabel")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-site-id"
|
||||
value={formSitePlayerId}
|
||||
@@ -985,7 +1079,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
{formFundingMode === "credit" ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-username">{t("username")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-username"
|
||||
helpText={t("helpUsernameCredit")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("username") })}
|
||||
>
|
||||
{t("username")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-username"
|
||||
value={formUsername}
|
||||
@@ -995,7 +1095,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-password">{t("createLoginPassword")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-password"
|
||||
helpText={t("helpPassword")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("createLoginPassword") })}
|
||||
>
|
||||
{t("createLoginPassword")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-password"
|
||||
type="password"
|
||||
@@ -1010,11 +1116,19 @@ export function PlayersConsole(): React.ReactElement {
|
||||
value={formCreditLimit}
|
||||
onValueChange={setFormCreditLimit}
|
||||
parentAvailableCredit={parentAvailableCredit}
|
||||
helpText={t("helpCreditLimit")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("creditLimit") })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-username">{t("username")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-username"
|
||||
helpText={t("helpUsernameWallet")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("username") })}
|
||||
>
|
||||
{t("username")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-username"
|
||||
value={formUsername}
|
||||
@@ -1037,7 +1151,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-nickname">{t("nickname")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-nickname"
|
||||
helpText={t("helpNickname")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("nickname") })}
|
||||
>
|
||||
{t("nickname")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-nickname"
|
||||
value={formNickname}
|
||||
@@ -1048,7 +1168,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
{accountMode === "create" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-currency">{t("defaultCurrency")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-currency"
|
||||
helpText={t("helpDefaultCurrency")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("defaultCurrency") })}
|
||||
>
|
||||
{t("defaultCurrency")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id="player-currency"
|
||||
value={formDefaultCurrency}
|
||||
@@ -1057,7 +1183,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-status">{t("status")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor="player-status"
|
||||
helpText={t("helpStatus")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("status") })}
|
||||
>
|
||||
{t("status")}
|
||||
</AdminFieldLabel>
|
||||
<Select
|
||||
value={String(formStatus)}
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
@@ -1123,6 +1255,8 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onValueChange={setFormCreditLimit}
|
||||
parentAvailableCredit={parentAvailableCredit}
|
||||
baselineCreditLimit={editingPlayer?.credit_limit ?? 0}
|
||||
helpText={t("helpCreditLimit")}
|
||||
helpAriaLabel={t("fieldHelpAria", { field: t("creditLimit") })}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
@@ -1166,6 +1300,70 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={passwordTarget !== null}
|
||||
onOpenChange={handlePasswordDialogOpenChange}
|
||||
>
|
||||
<DialogContent showCloseButton className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("resetPasswordTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{passwordTarget
|
||||
? t("resetPasswordDescription", {
|
||||
name: passwordTarget.username ?? passwordTarget.site_player_id,
|
||||
})
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-reset-password">{t("resetPasswordNew")}</Label>
|
||||
<Input
|
||||
id="player-reset-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={resetPassword}
|
||||
placeholder={t("resetPasswordPlaceholder")}
|
||||
onChange={(event) => setResetPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-reset-password-confirm">
|
||||
{t("resetPasswordConfirm")}
|
||||
</Label>
|
||||
<Input
|
||||
id="player-reset-password-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={resetPasswordConfirm}
|
||||
placeholder={t("resetPasswordConfirmPlaceholder")}
|
||||
onChange={(event) => setResetPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("resetPasswordSessionNotice")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={passwordResetBusy}
|
||||
onClick={() => handlePasswordDialogOpenChange(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={passwordResetBusy}
|
||||
onClick={() => void submitPasswordReset()}
|
||||
>
|
||||
{passwordResetBusy ? t("resetPasswordSaving") : t("resetPasswordSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent showCloseButton className="max-w-sm">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -40,6 +40,7 @@ export type AdminPermissionCatalogData = {
|
||||
}[];
|
||||
navigation?: AdminNavItem[];
|
||||
roles: AdminRoleRow[];
|
||||
assignable_roles: AdminRoleRow[];
|
||||
};
|
||||
|
||||
export type AdminRoleRow = {
|
||||
@@ -56,6 +57,8 @@ export type AdminRoleRow = {
|
||||
is_read_only_template?: boolean;
|
||||
permission_slugs: string[];
|
||||
user_count: number;
|
||||
platform_user_count: number;
|
||||
agent_user_count: number;
|
||||
};
|
||||
|
||||
export type AdminRoleListData = {
|
||||
|
||||
Reference in New Issue
Block a user