From c2eac2fafcd76287520165087fe86d473608c83e Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 4 Jun 2026 10:15:19 +0800 Subject: [PATCH] feat(i18n, agents): enhance multi-language support with new agent-related translations Added new translations for agent management features in English, Nepali, and Chinese, including terms related to sub-agents, player management, and validation messages. This update improves the user experience by ensuring consistent terminology across different languages in the agents console. --- src/i18n/locales/en/agents.json | 23 +- src/i18n/locales/ne/agents.json | 23 +- src/i18n/locales/zh/agents.json | 23 +- src/lib/agent-settlement-cycle.ts | 13 ++ src/modules/agents/agents-console.tsx | 311 +++++++++++++++++++++++--- 5 files changed, 355 insertions(+), 38 deletions(-) create mode 100644 src/lib/agent-settlement-cycle.ts diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index 46e7507..857b860 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -37,6 +37,11 @@ "loadFailed": "Failed to load agent tree", "siteLabel": "Site", "createChild": "Add child agent", + "viewDownline": "View sub-agents and players", + "downlineDialogTitle": "{{name}} — sub-agents and players", + "downlineAgentsSection": "Sub-agents", + "downlinePlayersSection": "Direct players", + "downlineNoAgents": "No sub-agents yet", "editNode": "Edit node", "deleteNode": "Delete node", "deleteNodeConfirm": "This action cannot be undone. Make sure the node has no children, users, or roles.", @@ -59,6 +64,12 @@ "deleteSuccess": "Deleted agent {{name}}", "saveFailed": "Save failed", "codeRequired": "Code and name are required", + "nameRequired": "Agent name is required", + "usernameRequired": "Login name is required", + "passwordRequired": "Password is required", + "passwordMinLength": "Password must be at least 8 characters", + "bindAccountPasswordRequired": "This agent has no login account yet. Enter an initial password to create one.", + "bindAccountHint": "No login account is bound yet. Saving will create and bind one automatically.", "modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.", "pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.", "summary": { @@ -83,7 +94,17 @@ "canCreateChildAgent": "Allow creating sub-agents", "cycleDaily": "Daily", "cycleWeekly": "Weekly", - "cycleMonthly": "Monthly" + "cycleMonthly": "Monthly", + "loading": "Loading share and credit settings…", + "loadFailed": "Failed to load share and credit settings. Close and try again.", + "loadingBlocked": "Share and credit settings are still loading. Please wait before saving.", + "validation": { + "shareRange": "Share rate must be between 0 and 100", + "creditInvalid": "Credit limit cannot be negative", + "rebateLimitRange": "Rebate ceiling must be between 0 and 1 (e.g. 0.005 = 0.5%)", + "defaultRebateRange": "Default player rebate must be between 0 and 1", + "defaultExceedsLimit": "Default player rebate cannot exceed the rebate ceiling" + } }, "settlementBills": { "title": "Agent bills", diff --git a/src/i18n/locales/ne/agents.json b/src/i18n/locales/ne/agents.json index db35d2e..96c0ed3 100644 --- a/src/i18n/locales/ne/agents.json +++ b/src/i18n/locales/ne/agents.json @@ -37,6 +37,11 @@ "loadFailed": "Failed to load agent tree", "siteLabel": "Site", "createChild": "Add child agent", + "viewDownline": "चाइल्ड एजेन्ट र खेलाडी हेर्नुहोस्", + "downlineDialogTitle": "{{name}} — चाइल्ड एजेन्ट र खेलाडी", + "downlineAgentsSection": "चाइल्ड एजेन्ट", + "downlinePlayersSection": "प्रत्यक्ष खेलाडी", + "downlineNoAgents": "कुनै चाइल्ड एजेन्ट छैन", "editNode": "Edit node", "deleteNode": "Delete node", "deleteNodeConfirm": "This action cannot be undone. Make sure the node has no children, users, or roles.", @@ -59,6 +64,12 @@ "deleteSuccess": "Deleted agent {{name}}", "saveFailed": "Save failed", "codeRequired": "Code and name are required", + "nameRequired": "Agent name is required", + "usernameRequired": "Login name is required", + "passwordRequired": "Password is required", + "passwordMinLength": "Password must be at least 8 characters", + "bindAccountPasswordRequired": "This agent has no login account yet. Enter an initial password.", + "bindAccountHint": "No login account is bound yet. Saving will create one.", "modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।", "pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।", "summary": { @@ -83,7 +94,17 @@ "canCreateChildAgent": "अधीनस्थ एजेन्ट सिर्जना अनुमति", "cycleDaily": "दैनिक", "cycleWeekly": "साप्ताहिक", - "cycleMonthly": "मासिक" + "cycleMonthly": "मासिक", + "loading": "Loading share and credit settings…", + "loadFailed": "Failed to load share and credit settings.", + "loadingBlocked": "Share and credit settings are still loading.", + "validation": { + "shareRange": "Share rate must be between 0 and 100", + "creditInvalid": "Credit limit cannot be negative", + "rebateLimitRange": "Rebate ceiling must be between 0 and 1", + "defaultRebateRange": "Default player rebate must be between 0 and 1", + "defaultExceedsLimit": "Default player rebate cannot exceed the rebate ceiling" + } }, "settlementBills": { "title": "एजेन्ट बिल", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index e9687d9..9d5af25 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -37,6 +37,11 @@ "loadFailed": "加载代理列表失败", "siteLabel": "站点", "createChild": "添加下级代理", + "viewDownline": "查看下级代理和玩家", + "downlineDialogTitle": "{{name}} — 下级代理与玩家", + "downlineAgentsSection": "下级代理", + "downlinePlayersSection": "直属玩家", + "downlineNoAgents": "暂无下级代理", "editNode": "编辑代理", "deleteNode": "删除代理", "deleteNodeConfirm": "删除后不可恢复,请确认该代理无下级、无账号、无角色绑定。", @@ -59,6 +64,12 @@ "deleteSuccess": "已删除代理 {{name}}", "saveFailed": "保存失败", "codeRequired": "请填写代理名称和登录名", + "nameRequired": "请填写代理名称", + "usernameRequired": "请填写登录名", + "passwordRequired": "请填写密码", + "passwordMinLength": "密码至少 8 位", + "bindAccountPasswordRequired": "该代理尚未绑定登录账号,请填写初始密码以创建账号", + "bindAccountHint": "该代理尚无登录账号,保存时将自动创建并绑定。", "modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。", "pageGuide": "这里统一管理代理树、代理角色、代理账号与下放上限。平台账号和平台角色请到各自的平台治理页面维护。", "summary": { @@ -83,7 +94,17 @@ "canCreateChildAgent": "允许创建下级代理", "cycleDaily": "日结", "cycleWeekly": "周结", - "cycleMonthly": "月结" + "cycleMonthly": "月结", + "loading": "正在加载占成与授信…", + "loadFailed": "加载占成与授信失败,请关闭后重试", + "loadingBlocked": "占成与授信尚未加载完成,请稍候再保存", + "validation": { + "shareRange": "占成比例须在 0–100 之间", + "creditInvalid": "授信额度不能为负数", + "rebateLimitRange": "回水上限须在 0–1 之间(如 0.005 表示 0.5%)", + "defaultRebateRange": "默认玩家回水须在 0–1 之间", + "defaultExceedsLimit": "默认玩家回水不能超过回水上限" + } }, "settlementBills": { "title": "代理账单", diff --git a/src/lib/agent-settlement-cycle.ts b/src/lib/agent-settlement-cycle.ts new file mode 100644 index 0000000..f53f706 --- /dev/null +++ b/src/lib/agent-settlement-cycle.ts @@ -0,0 +1,13 @@ +export const AGENT_SETTLEMENT_CYCLES = ["daily", "weekly", "monthly"] as const; + +export type AgentSettlementCycle = (typeof AGENT_SETTLEMENT_CYCLES)[number]; + +export function normalizeAgentSettlementCycle(value: unknown): AgentSettlementCycle { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + + if (AGENT_SETTLEMENT_CYCLES.includes(normalized as AgentSettlementCycle)) { + return normalized as AgentSettlementCycle; + } + + return "weekly"; +} diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx index dc0117f..a566cfb 100644 --- a/src/modules/agents/agents-console.tsx +++ b/src/modules/agents/agents-console.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { Eye, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -61,6 +61,7 @@ import { PRD_USERS_MANAGE, } from "@/lib/admin-prd"; import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; +import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import type { AgentNodeRow } from "@/types/api/admin-agent"; @@ -91,18 +92,21 @@ export function AgentsConsole(): React.ReactElement { const profile = useAdminProfile(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); - const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]); - const canViewAgents = - profile?.is_super_admin === true || - adminHasAnyPermission(profile?.permissions, [...PRD_AGENTS_ACCESS_ANY]); - const canManageProfile = adminHasAnyPermission(profile?.permissions, [ - PRD_AGENT_PROFILE_MANAGE, - PRD_AGENT_MANAGE, - ]); - const canProvision = - profile?.is_super_admin === true || - adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]); const isSuperAdmin = profile?.is_super_admin === true; + const canManageNode = + isSuperAdmin || adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]); + const canViewAgents = + isSuperAdmin || + adminHasAnyPermission(profile?.permissions, [...PRD_AGENTS_ACCESS_ANY]); + const canManageProfile = + isSuperAdmin || + adminHasAnyPermission(profile?.permissions, [ + PRD_AGENT_PROFILE_MANAGE, + PRD_AGENT_MANAGE, + ]); + const canProvision = + isSuperAdmin || + adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]); const canViewSiteList = adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_SITES_ACCESS_ANY]); const canSwitchSite = isSuperAdmin || adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]); @@ -137,10 +141,15 @@ export function AgentsConsole(): React.ReactElement { const [profileExtraRebate, setProfileExtraRebate] = useState(false); const [profileCanCreateChild, setProfileCanCreateChild] = useState(false); const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true); + const [profileLoading, setProfileLoading] = useState(false); + const [profileLoaded, setProfileLoaded] = useState(true); + const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false); + const [downlineDialogNode, setDownlineDialogNode] = useState(null); const boundAgent = profile?.agent ?? null; const canCreateChildAgent = isSuperAdmin || boundAgent?.can_create_child_agent !== false; + const canCreateChildForNode = (_node: AgentNodeRow) => canManageNode && canCreateChildAgent; const canViewPlayersTab = isSuperAdmin || (boundAgent?.can_create_player !== false && @@ -162,12 +171,51 @@ export function AgentsConsole(): React.ReactElement { credit_limit: Number.parseInt(profileCreditLimit, 10) || 0, rebate_limit: Number.parseFloat(profileRebateLimit) || 0, default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0, - settlement_cycle: profileSettlementCycle, + settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle), can_grant_extra_rebate: profileExtraRebate, can_create_child_agent: profileCanCreateChild, can_create_player: profileCanCreatePlayer, }); + const validateProfileFields = (): string | null => { + const shareRate = Number.parseFloat(profileShareRate); + const creditLimit = Number.parseInt(profileCreditLimit, 10); + const rebateLimit = Number.parseFloat(profileRebateLimit); + const defaultRebate = Number.parseFloat(profileDefaultRebate); + + if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) { + return t("profile.validation.shareRange", { + defaultValue: "占成比例须在 0–100 之间", + }); + } + + if (Number.isNaN(creditLimit) || creditLimit < 0) { + return t("profile.validation.creditInvalid", { + defaultValue: "授信额度不能为负数", + }); + } + + if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 1) { + return t("profile.validation.rebateLimitRange", { + defaultValue: "回水上限须在 0–1 之间(如 0.005 表示 0.5%)", + }); + } + + if (Number.isNaN(defaultRebate) || defaultRebate < 0 || defaultRebate > 1) { + return t("profile.validation.defaultRebateRange", { + defaultValue: "默认玩家回水须在 0–1 之间", + }); + } + + if (rebateLimit > 0 && defaultRebate > rebateLimit) { + return t("profile.validation.defaultExceedsLimit", { + defaultValue: "默认玩家回水不能超过回水上限", + }); + } + + return null; + }; + const flatNodes = useMemo(() => flattenTree(tree), [tree]); const parentNameMap = useMemo( () => new Map(flatNodes.map((node) => [node.id, node.name])), @@ -221,6 +269,13 @@ export function AgentsConsole(): React.ReactElement { const start = (currentPage - 1) * perPage; return filteredRows.slice(start, start + perPage); }, [currentPage, filteredRows, perPage]); + const downlineChildAgents = useMemo(() => { + if (downlineDialogNode === null) { + return []; + } + + return flatNodes.filter((node) => node.parent_id === downlineDialogNode.id); + }, [downlineDialogNode, flatNodes]); const loadTree = useCallback(async (siteId?: number | null) => { setLoading(true); @@ -300,6 +355,9 @@ export function AgentsConsole(): React.ReactElement { setNodeUsername(""); setNodePassword(""); resetProfileForm("create"); + setProfileLoading(false); + setProfileLoaded(true); + setEditingNodeNeedsPrimaryAccount(false); setNodeDialogOpen(true); }; @@ -310,23 +368,40 @@ export function AgentsConsole(): React.ReactElement { setNodeName(node.name); setNodeStatus(node.status); setNodeUsername(node.username ?? ""); + setEditingNodeNeedsPrimaryAccount((node.username ?? "").trim() === ""); setNodePassword(""); resetProfileForm("edit"); setNodeDialogOpen(true); - if (canManageProfile) { - void getAgentNodeProfile(node.id) - .then((p) => { - setProfileShareRate(String(p.total_share_rate ?? 0)); - setProfileCreditLimit(String(p.credit_limit ?? 0)); - setProfileRebateLimit(String(p.rebate_limit ?? 0)); - setProfileDefaultRebate(String(p.default_player_rebate ?? 0)); - setProfileSettlementCycle(p.settlement_cycle ?? "weekly"); - setProfileExtraRebate(Boolean(p.can_grant_extra_rebate)); - setProfileCanCreateChild(Boolean(p.can_create_child_agent)); - setProfileCanCreatePlayer(p.can_create_player !== false); - }) - .catch(() => undefined); + + if (!canManageProfile) { + setProfileLoading(false); + setProfileLoaded(true); + return; } + + setProfileLoading(true); + setProfileLoaded(false); + void getAgentNodeProfile(node.id) + .then((p) => { + setProfileShareRate(String(p.total_share_rate ?? 0)); + setProfileCreditLimit(String(p.credit_limit ?? 0)); + setProfileRebateLimit(String(p.rebate_limit ?? 0)); + setProfileDefaultRebate(String(p.default_player_rebate ?? 0)); + setProfileSettlementCycle(normalizeAgentSettlementCycle(p.settlement_cycle)); + setProfileExtraRebate(Boolean(p.can_grant_extra_rebate)); + setProfileCanCreateChild(Boolean(p.can_create_child_agent)); + setProfileCanCreatePlayer(p.can_create_player !== false); + setProfileLoaded(true); + }) + .catch(() => { + toast.error( + t("profile.loadFailed", { defaultValue: "加载占成与授信失败,请关闭后重试" }), + ); + setProfileLoaded(false); + }) + .finally(() => { + setProfileLoading(false); + }); }; const renderRowActions = (node: AgentNodeRow) => { @@ -349,9 +424,15 @@ export function AgentsConsole(): React.ReactElement { key: "create-child", label: t("createChild", { defaultValue: "添加下级代理" }), icon: Plus, - hidden: !canManageNode || !canCreateChildAgent, + hidden: !canCreateChildForNode(node), onClick: () => openCreateChildForNode(node), }, + { + key: "view-downline", + label: t("viewDownline", { defaultValue: "查看下级代理和玩家" }), + icon: Eye, + onClick: () => setDownlineDialogNode(node), + }, { key: "delete", label: t("deleteNode", { defaultValue: "删除代理" }), @@ -384,8 +465,13 @@ export function AgentsConsole(): React.ReactElement { }; const saveNode = async () => { - if (!nodeName.trim() || !nodeUsername.trim()) { - toast.error(t("codeRequired", { defaultValue: "请填写代理名称和登录名" })); + if (!nodeName.trim()) { + toast.error(t("nameRequired", { defaultValue: "请填写代理名称" })); + return; + } + + if (!nodeUsername.trim()) { + toast.error(t("usernameRequired", { defaultValue: "请填写登录名" })); return; } @@ -394,7 +480,38 @@ export function AgentsConsole(): React.ReactElement { return; } if (!nodePassword.trim()) { - toast.error(t("users.password", { defaultValue: "密码" })); + toast.error(t("passwordRequired", { defaultValue: "请填写密码" })); + return; + } + if (nodePassword.trim().length < 8) { + toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" })); + return; + } + } else if (nodePassword.trim() && nodePassword.trim().length < 8) { + toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" })); + return; + } else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) { + toast.error( + t("bindAccountPasswordRequired", { + defaultValue: "该代理尚未绑定登录账号,请填写初始密码以创建账号", + }), + ); + return; + } + + if (canManageProfile) { + if (nodeDialogMode === "edit" && !profileLoaded) { + toast.error( + t("profile.loadingBlocked", { + defaultValue: "占成与授信尚未加载完成,请稍候再保存", + }), + ); + return; + } + + const profileError = validateProfileFields(); + if (profileError !== null) { + toast.error(profileError); return; } } @@ -415,7 +532,9 @@ export function AgentsConsole(): React.ReactElement { await putAgentNode(editingNodeId, { name: nodeName.trim(), username: nodeUsername.trim(), - password: nodePassword.trim() || undefined, + password: editingNodeNeedsPrimaryAccount + ? nodePassword.trim() + : nodePassword.trim() || undefined, status: nodeStatus, }); if (canManageProfile) { @@ -426,6 +545,14 @@ export function AgentsConsole(): React.ReactElement { setNodeDialogOpen(false); await loadTree(adminSiteId); + if (nodeDialogMode === "create" && targetParentId !== null && downlineDialogNode?.id === targetParentId) { + const refreshedParent = flattenTree( + (await getAgentTree(adminSiteId ?? undefined)).tree, + ).find((node) => node.id === targetParentId); + if (refreshedParent) { + setDownlineDialogNode(refreshedParent); + } + } } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed")); } finally { @@ -634,6 +761,104 @@ export function AgentsConsole(): React.ReactElement { ) : null} + { + if (!open) { + setDownlineDialogNode(null); + } + }} + > + + + + {downlineDialogNode + ? t("downlineDialogTitle", { + name: downlineDialogNode.name, + defaultValue: "{{name}} — 下级代理与玩家", + }) + : t("viewDownline", { defaultValue: "查看下级代理和玩家" })} + + + +
+
+
+

+ {t("downlineAgentsSection", { defaultValue: "下级代理" })} +

+ {downlineDialogNode && canCreateChildForNode(downlineDialogNode) ? ( + + ) : null} +
+
+ + + + {t("name", { defaultValue: "名称" })} + {t("code", { defaultValue: "编码" })} + {t("users.username", { defaultValue: "登录名" })} + {t("childrenCount", { defaultValue: "直属下级" })} + {t("status", { defaultValue: "状态" })} + + + + {downlineChildAgents.length === 0 ? ( + + + {t("downlineNoAgents", { defaultValue: "暂无下级代理" })} + + + ) : ( + downlineChildAgents.map((child) => ( + + {child.name} + {child.code} + {child.username ?? "—"} + {child.children?.length ?? 0} + + + {child.status === 1 + ? t("common:status.enabled", { defaultValue: "Enabled" }) + : t("common:status.disabled", { defaultValue: "Disabled" })} + + + + )) + )} + +
+
+
+ + {canViewPlayersTab && downlineDialogNode ? ( +
+

+ {t("downlinePlayersSection", { defaultValue: "直属玩家" })} +

+ +
+ ) : null} +
+ + + + +
+
+ @@ -668,17 +893,24 @@ export function AgentsConsole(): React.ReactElement {
+ {nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? ( +

+ {t("bindAccountHint", { + defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。", + })} +

+ ) : null} setNodePassword(e.target.value)} placeholder={ - nodeDialogMode === "edit" + nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount ? t("passwordOptionalHint") : t("passwordPlaceholder") } @@ -696,7 +928,12 @@ export function AgentsConsole(): React.ReactElement {

{t("profile.section", { defaultValue: "占成与授信" })}

-
+ {profileLoading ? ( +

+ {t("profile.loading", { defaultValue: "正在加载占成与授信…" })} +

+ ) : null} +