"use client"; import { Eye, Pencil, Plus, ReceiptText, Search, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getAgentNodeProfile } from "@/api/admin-agents"; import { deleteAdminPlayer, getAdminPlayer, getAdminPlayers, postAdminPlayer, putAdminPlayer, } from "@/api/admin-player"; import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper"; import { PlayerCreditLimitField } from "@/components/admin/player-credit-limit-field"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { playerBalanceCells } from "@/lib/admin-player-display"; import { formatAdminMinorUnits } from "@/lib/money"; import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps"; import { NATIVE_PLAYER_PASSWORD_MIN_LENGTH, validateNativePlayerPassword, validateNativePlayerUsername, } from "@/lib/admin-input-validation"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_USERS_MANAGE } from "@/lib/admin-prd"; import { isSiteAdminOperator } from "@/lib/admin-session-variants"; import { resolvePlayerSettlementHref } from "@/modules/settlement/settlement-player-deeplink"; import { settlementCenterListHref } from "@/modules/settlement/settlement-center-nav"; import { useAgentManagementSiteStore } from "@/stores/agent-management-site"; import { resolvePlayerStatusTone } from "@/lib/admin-status-tone"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayerRow } from "@/types/api/admin-player"; const PLAYER_STATUS_OPTIONS = [ { value: 0, labelKey: "players:statusNormal" as const }, { value: 1, labelKey: "players:statusFrozen" as const }, { value: 2, labelKey: "players:statusBanned" as const }, ]; function playerStatusLabel( status: number, t: (key: string, opts?: { defaultValue?: string }) => string, ): string { const hit = PLAYER_STATUS_OPTIONS.find((opt) => opt.value === status); if (hit) { return t(hit.labelKey, { defaultValue: status === 0 ? "正常" : status === 1 ? "冻结" : "封禁", }); } return String(status); } function resolvePlayerRebateRate(row: AdminPlayerRow): number | null { if (row.rebate_rate != null) { return row.rebate_rate; } const defaultProfile = row.rebate_profiles?.find((p) => p.game_type === "*"); if (defaultProfile && !defaultProfile.inherit_from_agent) { return defaultProfile.rebate_rate; } return null; } function parseRiskTagsInput(text: string): string[] { return Array.from( new Set( text .split(/[,,\s]+/) .map((tag) => tag.trim()) .filter((tag) => tag.length > 0), ), ); } function fillEditFormFromPlayer(row: AdminPlayerRow): { username: string; nickname: string; currency: string; status: number; creditLimit: number; rebateRate: string; riskTags: string; } { const rebate = resolvePlayerRebateRate(row); return { username: row.username ?? "", nickname: row.nickname ?? "", currency: row.default_currency ?? "", status: row.status, creditLimit: row.credit_limit ?? 0, rebateRate: rebate != null ? percentValueToUi(rebate) : "", riskTags: (row.risk_tags ?? []).join(", "), }; } type AgentsPlayersPanelProps = { siteCode: string; /** 筛选直属玩家时的代理节点;null 表示当前登录代理或不过滤 */ agentNodeId: number | null; /** 当前代理 profile 是否允许创建玩家;未传时沿用登录代理能力 */ allowCreatePlayer?: boolean; /** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */ embedded?: boolean; }; export function AgentsPlayersPanel({ siteCode, agentNodeId, allowCreatePlayer, embedded = false, }: AgentsPlayersPanelProps): React.ReactElement { const { t } = useTranslation(["agents", "players", "common"]); const router = useRouter(); const formatDt = useAdminDateTimeFormatter(); const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId); const createPlayerLabel = embedded ? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" }) : t("playersPanel.create", { defaultValue: "创建玩家" }); const viewPlayerLabel = t("players:viewDetail", { defaultValue: "查看玩家详情" }); const editPlayerLabel = t("players:editPlayer", { defaultValue: "编辑玩家" }); const deletePlayerLabel = t("players:deletePlayer", { defaultValue: "删除玩家" }); const profile = useAdminProfile(); const boundAgent = profile?.agent ?? null; const isSuperAdmin = profile?.is_super_admin === true; const isSiteAdmin = isSiteAdminOperator(profile); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction(); const effectiveAgentId = useMemo(() => { if (agentNodeId !== null) { return agentNodeId; } return boundAgent?.id ?? null; }, [agentNodeId, boundAgent?.id]); const profileAllowsCreate = allowCreatePlayer === undefined ? boundAgent?.can_create_player !== false : allowCreatePlayer === true; const canAttachAgentNodeOnCreate = effectiveAgentId !== null && (isSuperAdmin || isSiteAdmin || (boundAgent !== null && effectiveAgentId === boundAgent.id)); const canCreatePlayer = canAttachAgentNodeOnCreate && profileAllowsCreate && adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]); const canManagePlayerRows = canCreatePlayer; const [settlementNavBusy, setSettlementNavBusy] = useState(false); const [keyword, setKeyword] = useState(""); const [appliedKeyword, setAppliedKeyword] = useState(""); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(20); const [items, setItems] = useState>["items"]>([]); const [total, setTotal] = useState(0); const [lastPage, setLastPage] = useState(1); const [loading, setLoading] = useState(true); const [listErr, setListErr] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [nickname, setNickname] = useState(""); const [creditLimit, setCreditLimit] = useState(""); const [rebateRate, setRebateRate] = useState(""); const [parentAvailableCredit, setParentAvailableCredit] = useState(null); const [agentRebateLimitPercent, setAgentRebateLimitPercent] = useState(null); const maxPlayerRebatePercent = useMemo(() => { if (agentRebateLimitPercent === null || !Number.isFinite(agentRebateLimitPercent)) { return AGENT_PERCENT_HARD_MAX; } return Math.min(AGENT_PERCENT_HARD_MAX, Math.max(0, agentRebateLimitPercent)); }, [agentRebateLimitPercent]); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editSaving, setEditSaving] = useState(false); const [editingPlayer, setEditingPlayer] = useState>["items"][number] | null>(null); const [editUsername, setEditUsername] = useState(""); const [editNickname, setEditNickname] = useState(""); const [editDefaultCurrency, setEditDefaultCurrency] = useState(""); const [editStatus, setEditStatus] = useState(0); const [editCreditLimit, setEditCreditLimit] = useState(""); const [editRebateRate, setEditRebateRate] = useState(""); const [editRiskTags, setEditRiskTags] = useState(""); const [editDetailLoading, setEditDetailLoading] = useState(false); const load = useCallback(async () => { if (siteCode.trim() === "") { setItems([]); setTotal(0); setLastPage(1); setLoading(false); return; } setLoading(true); setListErr(null); try { const data = await getAdminPlayers({ page, per_page: perPage, site_code: siteCode.trim(), ...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}), ...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}), }); setItems(data.items); setTotal(data.meta.total); setLastPage(Math.max(1, data.meta.last_page)); } catch (e) { setItems([]); setTotal(0); setLastPage(1); setListErr(e instanceof LotteryApiBizError ? e.message : t("common:loadFailed")); } finally { setLoading(false); } }, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]); useAsyncEffect(() => { void load(); }, [load]); async function savePlayer(): Promise { if (siteCode.trim() === "") { toast.error(t("players:siteCodeRequired", { defaultValue: "请填写主站编号" })); return; } if (username.trim() === "" || password.trim() === "") { toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" })); return; } const usernameIssue = validateNativePlayerUsername(username); if (usernameIssue === "invalid_charset") { toast.error( t("playersPanel.usernameInvalidCharset", { defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符", }), ); return; } const passwordIssue = validateNativePlayerPassword(password); if (passwordIssue === "too_short") { toast.error( t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 6 位" }), ); return; } const parsedCreditLimit = creditLimit.trim() === "" ? 0 : Number.parseInt(creditLimit, 10); if ( Number.isNaN(parsedCreditLimit) || parsedCreditLimit < 0 || !Number.isInteger(parsedCreditLimit) ) { toast.error( t("playersPanel.creditLimitInvalid", { defaultValue: "授信额度必须为不小于 0 的整数" }), ); return; } if (effectiveAgentId !== null && parentAvailableCredit === null) { toast.error( t("playersPanel.creditCapLoading", { defaultValue: "代理可下发额度加载中,请稍后再保存", }), ); return; } if ( parentAvailableCredit !== null && parsedCreditLimit > parentAvailableCredit ) { toast.error( t("playersPanel.creditLimitExceeded", { defaultValue: "授信额度不能超过当前代理可下发额度", }), ); return; } const parsedRebateRate = rebateRate.trim() === "" ? null : parsePercentUi(rebateRate); if (rebateRate.trim() !== "" && (parsedRebateRate === null || parsedRebateRate < 0 || parsedRebateRate > 100)) { toast.error( t("playersPanel.rebateRateInvalid", { defaultValue: "回水比例须在 0–100% 之间", }), ); return; } if (parsedRebateRate !== null && parsedRebateRate > maxPlayerRebatePercent) { toast.error( t("playersPanel.rebateRateExceedsAgent", { defaultValue: "回水比例不能超过代理回水上限 {{max}}%", max: maxPlayerRebatePercent, }), ); return; } setSaving(true); try { await postAdminPlayer({ site_code: siteCode.trim(), username: username.trim(), password: password, nickname: nickname.trim() || null, ...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}), credit_limit: parsedCreditLimit, ...(parsedRebateRate !== null ? { rebate_rate: parsedRebateRate } : {}), }); toast.success( t("playersPanel.createSuccessNative", { name: username.trim(), defaultValue: "玩家 {{name}} 已创建,请使用彩票端登录", }), ); setDialogOpen(false); setUsername(""); setPassword(""); setNickname(""); setCreditLimit(""); setRebateRate(""); await load(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed")); } finally { setSaving(false); } } function openCreateDialog(): void { setDialogOpen(true); setUsername(""); setPassword(""); setNickname(""); setCreditLimit(""); setRebateRate(""); if (effectiveAgentId !== null) { void getAgentNodeProfile(effectiveAgentId) .then((p) => { setParentAvailableCredit(p.available_credit ?? null); const rebate = Number(p.rebate_limit); setAgentRebateLimitPercent(Number.isFinite(rebate) ? rebate : null); }) .catch(() => { setParentAvailableCredit(null); setAgentRebateLimitPercent(null); }); } else { setParentAvailableCredit(null); setAgentRebateLimitPercent(null); } } const applyEditForm = (row: AdminPlayerRow): void => { const form = fillEditFormFromPlayer(row); setEditUsername(form.username); setEditNickname(form.nickname); setEditDefaultCurrency(form.currency); setEditStatus(form.status); setEditCreditLimit(String(form.creditLimit)); setEditRebateRate(form.rebateRate); setEditRiskTags(form.riskTags); }; const openEditPlayer = (row: AdminPlayerRow): void => { setEditingPlayer(row); applyEditForm(row); setEditDialogOpen(true); setEditDetailLoading(true); if (effectiveAgentId !== null) { void getAgentNodeProfile(effectiveAgentId) .then((p) => { setParentAvailableCredit(p.available_credit ?? null); const rebate = Number(p.rebate_limit); setAgentRebateLimitPercent(Number.isFinite(rebate) ? rebate : null); }) .catch(() => { setParentAvailableCredit(null); setAgentRebateLimitPercent(null); }); } void getAdminPlayer(row.id) .then((full) => { setEditingPlayer(full); applyEditForm(full); }) .catch(() => { toast.error(t("players:loadFailed", { defaultValue: "加载玩家详情失败" })); }) .finally(() => { setEditDetailLoading(false); }); }; function handleEditDialogOpenChange(open: boolean): void { setEditDialogOpen(open); if (!open) { setEditingPlayer(null); } } async function saveEditedPlayer(): Promise { if (!editingPlayer) { return; } const body: Parameters[1] = {}; if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) { const usernameIssue = validateNativePlayerUsername(editUsername); if (usernameIssue === "invalid_charset") { toast.error( t("playersPanel.usernameInvalidCharset", { defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符", }), ); return; } body.username = editUsername.trim(); } if (editNickname.trim() !== (editingPlayer.nickname ?? "")) { body.nickname = editNickname.trim() || null; } const nextCurrency = editDefaultCurrency.trim().toUpperCase(); if (nextCurrency !== editingPlayer.default_currency) { body.default_currency = nextCurrency; } if (editStatus !== editingPlayer.status) { body.status = editStatus; } const baselineCredit = editingPlayer.credit_limit ?? 0; const parsedCreditLimit = editCreditLimit.trim() === "" ? baselineCredit : Number.parseInt(editCreditLimit, 10); if ( Number.isNaN(parsedCreditLimit) || parsedCreditLimit < 0 || !Number.isInteger(parsedCreditLimit) ) { toast.error( t("playersPanel.creditLimitInvalid", { defaultValue: "授信额度必须为不小于 0 的整数" }), ); return; } if ( parsedCreditLimit !== baselineCredit && effectiveAgentId !== null && parentAvailableCredit === null ) { toast.error( t("playersPanel.creditCapLoading", { defaultValue: "代理可下发额度加载中,请稍后再保存", }), ); return; } if ( parentAvailableCredit !== null && parsedCreditLimit > baselineCredit + parentAvailableCredit ) { toast.error( t("playersPanel.creditLimitExceeded", { defaultValue: "授信额度不能超过当前代理可下发额度", }), ); return; } if (parsedCreditLimit !== baselineCredit) { body.credit_limit = parsedCreditLimit; } const prevRebate = resolvePlayerRebateRate(editingPlayer); const nextPercent = parsePercentUi(editRebateRate); const nextRebate = nextPercent === null ? null : nextPercent; if ( nextRebate !== null && (nextRebate < 0 || nextRebate > AGENT_PERCENT_HARD_MAX) ) { toast.error( t("playersPanel.rebateRateInvalid", { defaultValue: "回水比例须在 0–100% 之间", }), ); return; } if (nextRebate !== null && nextRebate > maxPlayerRebatePercent) { toast.error( t("playersPanel.rebateRateExceedsAgent", { defaultValue: "回水比例不能超过代理回水上限 {{max}}%", max: maxPlayerRebatePercent, }), ); return; } if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) { body.rebate_rate = nextRebate; } const nextRiskTags = parseRiskTagsInput(editRiskTags); const prevRiskTags = editingPlayer.risk_tags ?? []; if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) { body.risk_tags = nextRiskTags; } if (Object.keys(body).length === 0) { toast.success(t("players:noChanges", { defaultValue: "没有变更" })); handleEditDialogOpenChange(false); return; } setEditSaving(true); try { const updated = await putAdminPlayer(editingPlayer.id, body); setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row))); toast.success( t("players:updateSuccess", { name: updated.username ?? updated.site_player_id, defaultValue: "已更新 {{name}}", }), ); handleEditDialogOpenChange(false); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : t("players:updateFailed", { defaultValue: "更新玩家失败" }), ); } finally { setEditSaving(false); } } async function confirmDeletePlayer(row: Awaited>["items"][number]): Promise { try { await deleteAdminPlayer(row.id); setItems((prev) => prev.filter((item) => item.id !== row.id)); setTotal((current) => Math.max(0, current - 1)); toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id })); } catch (e) { if (e instanceof LotteryApiBizError) { const needsSettlement = e.message.includes("已占用信用额度"); toast.error( needsSettlement ? t("playersPanel.deleteBlockedByCreditHint", { defaultValue: "该玩家仍有已占用信用额度,请先到结算中心结清或核销相关账单后再删除。", }) : e.message, ); return; } toast.error(t("deleteFailed")); } } async function goToPlayerSettlement(row: AdminPlayerRow): Promise { setSettlementNavBusy(true); try { const href = await resolvePlayerSettlementHref(row, adminSiteId); if (href) { router.push(href); return; } router.push(settlementCenterListHref(adminSiteId)); toast.info( t("playersPanel.noPendingBillsGoCenter", { defaultValue: "该玩家暂无待处理账单,已打开结算中心。", }), ); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }), ); } finally { setSettlementNavBusy(false); } } return (
setKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { setAppliedKeyword(keyword); setPage(1); } }} placeholder={t("playersPanel.searchPh", { defaultValue: "搜索玩家账号 / 标识" })} className="pl-9" />
{canCreatePlayer ? ( ) : null}
{loading ? ( ) : ( <> {listErr ? (

{listErr}

) : null}
{t("common:table.id", { defaultValue: "ID" })} {t("playersPanel.playerRef", { defaultValue: "玩家标识" })} {t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })} {!embedded ? ( <> {t("players:riskTags", { defaultValue: "风控标签" })} {t("players:fundingMode", { defaultValue: "资金模式" })} {t("players:currency", { defaultValue: "币种" })} ) : null} {t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })} {t("players:rebateRate", { defaultValue: "回水" })} {!embedded ? ( {t("players:lastLogin", { defaultValue: "最后登录" })} ) : null} {t("players:status", { defaultValue: "状态" })} {t("common:table.actions", { defaultValue: "操作" })} {items.length === 0 ? ( ) : ( items.map((row) => { const balances = playerBalanceCells(row, formatAdminMinorUnits); const rebate = resolvePlayerRebateRate(row); const riskTags = row.risk_tags ?? []; return ( #{row.id} {row.site_player_id} {row.username ?? "—"} / {row.nickname ?? "—"} {!embedded ? ( <> {riskTags.length > 0 ? (
{riskTags.map((tag) => ( {tag} ))}
) : ( )}
{row.default_currency} ) : null} {balances.balance} / {balances.available} {rebate != null ? `${percentValueToUi(rebate)}%` : "—"} {!embedded ? ( {row.last_login_at ? formatDt(row.last_login_at) : "—"} ) : null} {playerStatusLabel(row.status, t)} e.stopPropagation()} > void goToPlayerSettlement(row), }, ] : []), ...(canManagePlayerRows ? [ { key: "edit", label: editPlayerLabel, icon: Pencil, onClick: () => openEditPlayer(row), }, { key: "delete", label: deletePlayerLabel, icon: Trash2, destructive: true, onClick: () => requestConfirm({ title: t("players:confirmDelete", { defaultValue: "确认删除", }), description: t("players:confirmDeleteDesc", { name: row.username ?? row.site_player_id, defaultValue: "确定要删除玩家 {{name}} 吗?此操作不可恢复。", }), confirmVariant: "destructive", onConfirm: () => void confirmDeletePlayer(row), }), }, ] : []), ]} />
); }) )}
{ setPerPage(value); setPage(1); }} onPageChange={setPage} /> )} {createPlayerLabel}
setUsername(e.target.value)} autoComplete="off" className="bg-background/50 transition-colors focus:bg-background" />
setPassword(e.target.value)} autoComplete="new-password" className="bg-background/50 transition-colors focus:bg-background" />

{t("playersPanel.passwordHint", { defaultValue: "至少 {{min}} 位", min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH, })}

setNickname(e.target.value)} className="bg-background/50 transition-colors focus:bg-background" />

{agentRebateLimitPercent !== null ? t("playersPanel.rebateAgentCapHint", { defaultValue: "不得超过代理回水上限 {{max}}%", max: maxPlayerRebatePercent, }) : t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}

{t("players:editDialogTitle", { defaultValue: "编辑玩家" })} {editDetailLoading ? : null}
setEditUsername(e.target.value)} autoComplete="off" className="bg-background/50 transition-colors focus:bg-background" />
setEditNickname(e.target.value)} autoComplete="off" className="bg-background/50 transition-colors focus:bg-background" />
setEditDefaultCurrency(e.target.value)} autoComplete="off" className="bg-background/50 transition-colors focus:bg-background" />

{agentRebateLimitPercent !== null ? t("playersPanel.rebateAgentCapHint", { defaultValue: "不得超过代理回水上限 {{max}}%", max: maxPlayerRebatePercent, }) : t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}

setEditRiskTags(e.target.value)} placeholder={t("playersPanel.riskTagsPlaceholder", { defaultValue: "逗号分隔", })} className="bg-background/50 transition-colors focus:bg-background" />
); }