Files
lotteryAdmin/src/modules/agents/agents-players-panel.tsx
kang bea342a4d7
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
fix(settlement): 操作记录服务端分页、流水提示与坏账幂等键
- 操作记录 Tab 改用 settlement-operations API 服务端筛选分页
- 流水面板展示 truncated 截断提示
- 坏账核销对齐收付/补差的幂等键生成与复用
- 结算中心 URL 同步、代理控制台 lint 与玩家/奖池模块更新
2026-06-26 16:40:46 +08:00

1096 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
const [total, setTotal] = useState(0);
const [lastPage, setLastPage] = useState(1);
const [loading, setLoading] = useState(true);
const [listErr, setListErr] = useState<string | null>(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<number | null>(null);
const [agentRebateLimitPercent, setAgentRebateLimitPercent] = useState<number | null>(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<Awaited<ReturnType<typeof getAdminPlayers>>["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<void> {
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: "回水比例须在 0100% 之间",
}),
);
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<void> {
if (!editingPlayer) {
return;
}
const body: Parameters<typeof putAdminPlayer>[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: "回水比例须在 0100% 之间",
}),
);
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<ReturnType<typeof getAdminPlayers>>["items"][number]): Promise<void> {
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<void> {
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 (
<div className="space-y-4">
<ConfirmDialog />
<div className="admin-list-toolbar">
<div className="admin-list-field min-w-[12rem] flex-1">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setAppliedKeyword(keyword);
setPage(1);
}
}}
placeholder={t("playersPanel.searchPh", { defaultValue: "搜索玩家账号 / 标识" })}
className="pl-9"
/>
</div>
</div>
<div className="admin-list-actions">
<Button
type="button"
size="sm"
onClick={() => {
setAppliedKeyword(keyword);
setPage(1);
}}
>
{t("common:actions.search", { defaultValue: "搜索" })}
</Button>
{canCreatePlayer ? (
<Button type="button" size="sm" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
</Button>
) : null}
</div>
</div>
{loading ? (
<AdminLoadingState minHeight="6rem" />
) : (
<>
{listErr ? (
<p className="mb-3 text-sm text-destructive">{listErr}</p>
) : null}
<div
className={
embedded
? "admin-table-inset overflow-hidden"
: "admin-table-shell overflow-hidden rounded-xl border border-border/70"
}
>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
<TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead>
{!embedded ? (
<>
<TableHead className="whitespace-nowrap">
{t("players:riskTags", { defaultValue: "风控标签" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
</>
) : null}
<TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })}
</TableHead>
{!embedded ? (
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
) : null}
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<AdminTableNoResourceRow colSpan={embedded ? 7 : 10} cellClassName="py-12 text-center" />
) : (
items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits);
const rebate = resolvePlayerRebateRate(row);
const riskTags = row.risk_tags ?? [];
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
<TableCell className="max-w-[8rem] truncate font-mono text-xs" title={row.site_player_id}>
{row.site_player_id}
</TableCell>
<TableCell className="text-sm">
<span className="font-medium">{row.username ?? "—"}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell>
{!embedded ? (
<>
<TableCell className="max-w-[14rem]">
{riskTags.length > 0 ? (
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
{riskTags.map((tag) => (
<span
key={`${row.id}-${tag}`}
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium leading-4 text-amber-900"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
</>
) : null}
<TableCell className="text-right text-xs tabular-nums">
<span>{balances.balance}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{balances.available}</span>
</TableCell>
<TableCell
className="text-right text-xs tabular-nums font-medium"
title={
row.rebate_inherited
? t("playersPanel.rebateInherited", { defaultValue: "继承代理默认回水" })
: undefined
}
>
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
</TableCell>
{!embedded ? (
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
) : null}
<TableCell>
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
{playerStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
busy={confirmBusy || settlementNavBusy}
actions={[
{
key: "detail",
label: viewPlayerLabel,
icon: Eye,
href: adminPlayerDetailPath(row.id),
},
...(row.funding_mode === "credit" || row.uses_credit === true
? [
{
key: "settlement",
label: t("playersPanel.manageSettlement", {
defaultValue: "处理账单",
}),
icon: ReceiptText,
onClick: () => 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),
}),
},
]
: []),
]}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
<AdminListPaginationFooter
selectId="agents-players-per-page"
total={total}
page={page}
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
}}
onPageChange={setPage}
/>
</>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{createPlayerLabel}</DialogTitle>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">{t("playersPanel.siteCode", { defaultValue: "所属线路" })}</Label>
<Input value={siteCode} readOnly disabled className="bg-muted/40 text-muted-foreground opacity-100 font-mono" />
</div>
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="agent-player-username" className="text-muted-foreground">
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
</Label>
<Input
id="agent-player-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-password" className="text-muted-foreground">
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="agent-player-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
className="bg-background/50 transition-colors focus:bg-background"
/>
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.passwordHint", {
defaultValue: "至少 {{min}} 位",
min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
})}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-nickname" className="text-muted-foreground">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
<PlayerCreditLimitField
id="agent-player-credit"
value={creditLimit}
onValueChange={setCreditLimit}
parentAvailableCredit={parentAvailableCredit}
/>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<AdminNumericStepper
id="agent-player-rebate"
value={rebateRate}
onValueChange={setRebateRate}
min={0}
max={maxPlayerRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
<p className="text-[11px] text-muted-foreground/80">
{agentRebateLimitPercent !== null
? t("playersPanel.rebateAgentCapHint", {
defaultValue: "不得超过代理回水上限 {{max}}%",
max: maxPlayerRebatePercent,
})
: t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}
</p>
</div>
</div>
</div>
<DialogFooter className="mt-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={saving} onClick={() => void savePlayer()}>
{t("common:actions.save", { defaultValue: "保存" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
</DialogHeader>
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
<div className={editDetailLoading ? "hidden" : "grid gap-5 py-2"}>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-username" className="text-muted-foreground">
{t("players:username", { defaultValue: "用户名" })}
</Label>
<Input
id="agent-player-edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
autoComplete="off"
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-nickname" className="text-muted-foreground">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-edit-nickname"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
autoComplete="off"
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-currency" className="text-muted-foreground">
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
</Label>
<Input
id="agent-player-edit-currency"
value={editDefaultCurrency}
onChange={(e) => setEditDefaultCurrency(e.target.value)}
autoComplete="off"
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<PlayerCreditLimitField
id="agent-player-edit-credit"
value={editCreditLimit}
onValueChange={setEditCreditLimit}
parentAvailableCredit={parentAvailableCredit}
baselineCreditLimit={editingPlayer?.credit_limit ?? 0}
/>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-rebate" className="text-muted-foreground">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<AdminNumericStepper
id="agent-player-edit-rebate"
value={editRebateRate}
onValueChange={setEditRebateRate}
min={0}
max={maxPlayerRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
<p className="text-[11px] text-muted-foreground/80">
{agentRebateLimitPercent !== null
? t("playersPanel.rebateAgentCapHint", {
defaultValue: "不得超过代理回水上限 {{max}}%",
max: maxPlayerRebatePercent,
})
: t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-risk-tags" className="text-muted-foreground">
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id="agent-player-edit-risk-tags"
value={editRiskTags}
onChange={(e) => setEditRiskTags(e.target.value)}
placeholder={t("playersPanel.riskTagsPlaceholder", {
defaultValue: "逗号分隔",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-status" className="text-muted-foreground">
{t("players:status", { defaultValue: "状态" })}
</Label>
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
<SelectTrigger id="agent-player-edit-status">
<SelectValue placeholder={t("players:status", { defaultValue: "状态" })}>
{playerStatusLabel(editStatus, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{t(opt.labelKey, {
defaultValue: opt.value === 0 ? "正常" : opt.value === 1 ? "冻结" : "封禁",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="mt-2">
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="button"
disabled={editSaving || editDetailLoading}
onClick={() => void saveEditedPlayer()}
>
{t("players:saveChanges", { defaultValue: "保存修改" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}