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.
This commit is contained in:
2026-06-04 10:15:19 +08:00
parent cbc499e5b2
commit c2eac2fafc
5 changed files with 355 additions and 38 deletions

View File

@@ -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<AgentNodeRow | null>(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: "占成比例须在 0100 之间",
});
}
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: "回水上限须在 01 之间(如 0.005 表示 0.5%",
});
}
if (Number.isNaN(defaultRebate) || defaultRebate < 0 || defaultRebate > 1) {
return t("profile.validation.defaultRebateRange", {
defaultValue: "默认玩家回水须在 01 之间",
});
}
if (rebateLimit > 0 && defaultRebate > rebateLimit) {
return t("profile.validation.defaultExceedsLimit", {
defaultValue: "默认玩家回水不能超过回水上限",
});
}
return null;
};
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
const parentNameMap = useMemo(
() => new Map<number, string>(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}
</AdminPageCard>
<Dialog
open={downlineDialogNode !== null}
onOpenChange={(open) => {
if (!open) {
setDownlineDialogNode(null);
}
}}
>
<DialogContent className="flex max-h-[min(90vh,52rem)] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{downlineDialogNode
? t("downlineDialogTitle", {
name: downlineDialogNode.name,
defaultValue: "{{name}} — 下级代理与玩家",
})
: t("viewDownline", { defaultValue: "查看下级代理和玩家" })}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto pr-1">
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">
{t("downlineAgentsSection", { defaultValue: "下级代理" })}
</h3>
{downlineDialogNode && canCreateChildForNode(downlineDialogNode) ? (
<Button
type="button"
size="sm"
onClick={() => openCreateChildForNode(downlineDialogNode)}
>
<Plus className="size-4" aria-hidden />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</div>
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{downlineChildAgents.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("downlineNoAgents", { defaultValue: "暂无下级代理" })}
</TableCell>
</TableRow>
) : (
downlineChildAgents.map((child) => (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-mono text-xs">{child.username ?? "—"}</TableCell>
<TableCell>{child.children?.length ?? 0}</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)}>
{child.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</section>
{canViewPlayersTab && downlineDialogNode ? (
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("downlinePlayersSection", { defaultValue: "直属玩家" })}
</h3>
<AgentsPlayersPanel
siteCode={activeSiteCode}
agentNodeId={downlineDialogNode.id}
/>
</section>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDownlineDialogNode(null)}>
{t("common:actions.close", { defaultValue: "关闭" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -668,17 +893,24 @@ export function AgentsConsole(): React.ReactElement {
<div className="space-y-2">
<Label htmlFor="agent-password">
{nodeDialogMode === "create"
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
? t("users.password", { defaultValue: "密码" })
: t("resetPassword", { defaultValue: "重置密码" })}
</Label>
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
<p className="text-xs text-muted-foreground">
{t("bindAccountHint", {
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
})}
</p>
) : null}
<Input
id="agent-password"
type="password"
value={nodePassword}
onChange={(e) => setNodePassword(e.target.value)}
placeholder={
nodeDialogMode === "edit"
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
? t("passwordOptionalHint")
: t("passwordPlaceholder")
}
@@ -696,7 +928,12 @@ export function AgentsConsole(): React.ReactElement {
<p className="text-sm font-medium">
{t("profile.section", { defaultValue: "占成与授信" })}
</p>
<div className="grid gap-3 sm:grid-cols-2">
{profileLoading ? (
<p className="text-sm text-muted-foreground">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div className={cn("grid gap-3 sm:grid-cols-2", profileLoading ? "pointer-events-none opacity-50" : "")}>
<div className="space-y-2">
<Label htmlFor="agent-share-rate">
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
@@ -813,7 +1050,11 @@ export function AgentsConsole(): React.ReactElement {
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
<Button
type="button"
disabled={nodeSaving || (nodeDialogMode === "edit" && canManageProfile && profileLoading)}
onClick={() => void saveNode()}
>
{t("common:actions.save", { defaultValue: "保存" })}
</Button>
</DialogFooter>