Files
lotteryAdmin/src/modules/agents/agents-players-panel.tsx

1311 lines
52 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, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAgentNodeProfile } from "@/api/admin-agents";
import {
getSettlementBills,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import {
deleteAdminPlayer,
getAdminPlayer,
getAdminPlayers,
postAdminPlayer,
putAdminPlayer,
} from "@/api/admin-player";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
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 { formatPlayerCreditAmount, 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { resolveRoleStatusTone } 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 creditAdjustModeLabel(
mode: "increase" | "decrease",
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
return mode === "increase"
? t("playersPanel.creditIncrease", { defaultValue: "增加授信" })
: t("playersPanel.creditDecrease", { defaultValue: "减少授信" });
}
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;
/** 外部触发创建直属玩家的计数器 */
createRequestKey?: number;
};
export function AgentsPlayersPanel({
siteCode,
agentNodeId,
allowCreatePlayer,
embedded = false,
createRequestKey = 0,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter();
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 { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profileAllowsCreate =
allowCreatePlayer === undefined
? boundAgent?.can_create_player !== false
: allowCreatePlayer === true;
const canCreatePlayer =
isSuperAdmin ||
(profileAllowsCreate &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
const canManagePlayerRows = canCreatePlayer;
const effectiveAgentId = useMemo(() => {
if (agentNodeId !== null) {
return agentNodeId;
}
return boundAgent?.id ?? null;
}, [agentNodeId, boundAgent?.id]);
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 [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 [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 [editCreditBase, setEditCreditBase] = useState(0);
const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase");
const [editCreditDelta, setEditCreditDelta] = useState("");
const [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false);
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
const [billingLoading, setBillingLoading] = useState(false);
const [billingBusy, setBillingBusy] = useState(false);
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const lastCreateRequestKeyRef = useRef(createRequestKey);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
setItems([]);
setTotal(0);
setLastPage(1);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await getAdminPlayers({
page,
per_page: perPage,
site_code: siteCode.trim(),
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
});
setItems(data.items);
setTotal(data.meta.total);
setLastPage(Math.max(1, data.meta.last_page));
} catch {
setItems([]);
setTotal(0);
setLastPage(1);
} finally {
setLoading(false);
}
}, [effectiveAgentId, page, perPage, siteCode]);
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;
}
if (password.trim().length < 8) {
toast.error(
t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 8 位" }),
);
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 (
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;
}
setSaving(true);
try {
await postAdminPlayer({
site_code: siteCode.trim(),
username: username.trim(),
password: password,
nickname: nickname.trim() || null,
...(isSuperAdmin && effectiveAgentId ? { 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))
.catch(() => setParentAvailableCredit(null));
} else {
setParentAvailableCredit(null);
}
}
useEffect(() => {
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
return;
}
lastCreateRequestKeyRef.current = createRequestKey;
if (canCreatePlayer) {
openCreateDialog();
}
}, [canCreatePlayer, createRequestKey]);
const applyEditForm = (row: AdminPlayerRow): void => {
const form = fillEditFormFromPlayer(row);
setEditUsername(form.username);
setEditNickname(form.nickname);
setEditDefaultCurrency(form.currency);
setEditStatus(form.status);
setEditCreditBase(form.creditLimit);
setEditCreditAdjustMode("increase");
setEditCreditDelta("");
setEditRebateRate(form.rebateRate);
setEditRiskTags(form.riskTags);
};
const openEditPlayer = (row: AdminPlayerRow): void => {
setEditingPlayer(row);
applyEditForm(row);
setEditDialogOpen(true);
setEditDetailLoading(true);
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 ?? "")) {
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 creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
if (!Number.isNaN(creditDelta) && creditDelta > 0) {
const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta;
const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta);
if (nextCredit !== (editingPlayer.credit_limit ?? 0)) {
body.credit_limit = nextCredit;
}
}
const prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate);
const nextRebate = nextPercent === null ? null : nextPercent;
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"));
}
}
const selectedBill = useMemo(
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
[billingBills, selectedBillId],
);
const projectedCreditLimit = useMemo(() => {
const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
if (Number.isNaN(delta) || delta <= 0) {
return editCreditBase;
}
return Math.max(0, editCreditBase + (editCreditAdjustMode === "increase" ? delta : -delta));
}, [editCreditAdjustMode, editCreditBase, editCreditDelta]);
function resetBillingForm(): void {
setPayAmount("");
setPayMethod("");
setPayProof("");
setBadDebtReason("");
}
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
setBillingDialogOpen(true);
setBillingPlayer(row);
setBillingBills([]);
setSelectedBillId(null);
resetBillingForm();
setBillingLoading(true);
try {
const data = await getSettlementBills({
bill_type: "player",
keyword: row.site_player_id,
per_page: 20,
});
const items = (data.items ?? []).filter(
(bill) =>
bill.bill_type === "player" &&
bill.owner_id === row.id &&
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
);
setBillingBills(items);
const first = items[0] ?? null;
setSelectedBillId(first?.id ?? null);
setPayAmount(first ? String(first.unpaid_amount ?? 0) : "");
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
);
} finally {
setBillingLoading(false);
}
}
async function handleConfirmBill(): Promise<void> {
if (selectedBill === null) return;
setBillingBusy(true);
try {
await postSettlementBillConfirm(selectedBill.id);
toast.success(
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
);
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
);
} finally {
setBillingBusy(false);
}
}
async function handlePayBill(): Promise<void> {
if (selectedBill === null) return;
const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0));
if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillPayment(selectedBill.id, {
amount,
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
});
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
await load();
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
);
} finally {
setBillingBusy(false);
}
}
async function handleWriteOffBill(): Promise<void> {
if (selectedBill === null) return;
const reason = badDebtReason.trim();
if (!reason) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
reason,
});
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
await load();
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
);
} finally {
setBillingBusy(false);
}
}
function parseBillingAmount(raw: string): number | null {
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return null;
}
return value;
}
function requestConfirmBillAction(): void {
if (selectedBill === null) return;
requestConfirm({
title: t("playersPanel.confirmBillTitle", { defaultValue: "确认账单?" }),
description: t("playersPanel.confirmBillDescription", {
defaultValue: "确认后账单会进入待收付状态,请确认金额与玩家无误。",
}),
confirmLabel: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
confirmVariant: "default",
onConfirm: handleConfirmBill,
});
}
function requestPayBillAction(): void {
if (selectedBill === null) return;
const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0));
if (amount === null || amount <= 0) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" }));
return;
}
if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
return;
}
requestConfirm({
title: t("playersPanel.payBillConfirmTitle", { defaultValue: "确认登记收付?" }),
description: t("playersPanel.payBillConfirmDescription", {
defaultValue: "这会写入收付记录并更新玩家账单金额,请确认金额与凭证无误。",
}),
confirmLabel: t("agents:settlementBills.paid", { defaultValue: "登记收付" }),
confirmVariant: "default",
onConfirm: handlePayBill,
});
}
function requestWriteOffBillAction(): void {
if (selectedBill === null) return;
if (!badDebtReason.trim()) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
requestConfirm({
title: t("playersPanel.writeOffBillConfirmTitle", { defaultValue: "确认核销坏账?" }),
description: t("playersPanel.writeOffBillConfirmDescription", {
defaultValue: "核销会把该玩家账单未结金额归档为坏账记录,请确认已无法收回。",
}),
confirmLabel: t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
confirmVariant: "destructive",
onConfirm: handleWriteOffBill,
});
}
return (
<div className="space-y-4">
<ConfirmDialog />
<div className="flex flex-wrap items-center justify-between gap-3">
{!embedded ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
) : (
<div />
)}
{canCreatePlayer && !embedded ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
</Button>
) : null}
</div>
{loading ? (
<AdminLoadingState minHeight="6rem" />
) : (
<>
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<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>
<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>
<TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
{!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
) : null}
<TableHead className="sticky right-0 z-20 w-14 bg-muted 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 ? 9 : 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>
<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-[11px] 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>
<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>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
{!embedded ? (
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
{playerStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
) : null}
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
busy={confirmBusy}
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 openBillingDialog(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: "至少 8 位" })}
</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">
<div className="space-y-2">
<Label htmlFor="agent-player-credit" className="text-muted-foreground">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-credit"
type="number"
min={0}
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
className="bg-background/50 transition-colors focus:bg-background"
/>
{parentAvailableCredit !== null ? (
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={rebateRate}
placeholder="0"
onChange={(e) => setRebateRate(e.target.value)}
className="bg-background/50 transition-colors focus:bg-background"
/>
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
</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={billingDialogOpen}
onOpenChange={(open) => {
setBillingDialogOpen(open);
if (!open) {
setBillingPlayer(null);
setBillingBills([]);
setSelectedBillId(null);
resetBillingForm();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{billingLoading ? (
<p className="text-sm text-muted-foreground">
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
</p>
) : billingBills.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
</p>
) : (
<>
<div className="space-y-2">
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
<Select
value={selectedBillId ? String(selectedBillId) : ""}
onValueChange={(value) => {
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
setSelectedBillId(next?.id ?? null);
setPayAmount(next ? String(next.unpaid_amount ?? 0) : "");
setPayMethod("");
setPayProof("");
setBadDebtReason("");
}}
>
<SelectTrigger>
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
</SelectTrigger>
<SelectContent>
{billingBills.map((bill) => (
<SelectItem key={bill.id} value={String(bill.id)}>
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${bill.unpaid_amount ?? 0}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedBill ? (
<div className="space-y-4 rounded-xl border border-border/70 p-4">
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
</span>{" "}
{selectedBill.status}
</div>
<div>
<span className="text-muted-foreground">
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
</span>{" "}
{selectedBill.unpaid_amount ?? 0}
</div>
</div>
{selectedBill.status === "pending_confirm" ? (
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
) : null}
{selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
<div className="space-y-3">
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
<Input
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
defaultValue: "例如:现金 / 银行转账",
})}
/>
</div>
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<Input
value={payProof}
onChange={(e) => setPayProof(e.target.value)}
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
defaultValue: "可填写流水号、截图说明或备注",
})}
/>
</div>
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestPayBillAction}>
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
</Button>
<div className="space-y-1 pt-2">
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
/>
</div>
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</div>
) : null}
</div>
) : null}
</>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
</DialogHeader>
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
<div className={editDetailLoading ? "hidden" : "space-y-3"}>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-username">
{t("players:username", { defaultValue: "用户名" })}
</Label>
<Input
id="agent-player-edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-nickname">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-edit-nickname"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-currency">
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
</Label>
<Input
id="agent-player-edit-currency"
value={editDefaultCurrency}
onChange={(e) => setEditDefaultCurrency(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<div className="rounded-xl border border-border/70 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">
{t("playersPanel.currentCredit", { defaultValue: "当前授信" })}
</span>
<span className="font-semibold">
{formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")}
</span>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
<div className="space-y-1">
<Label htmlFor="agent-player-edit-credit-mode">
{t("playersPanel.creditAdjustType", { defaultValue: "调整方式" })}
</Label>
<Select
value={editCreditAdjustMode}
onValueChange={(value) => setEditCreditAdjustMode(value as "increase" | "decrease")}
>
<SelectTrigger id="agent-player-edit-credit-mode">
<SelectValue>
{creditAdjustModeLabel(editCreditAdjustMode, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="increase">
{creditAdjustModeLabel("increase", t)}
</SelectItem>
<SelectItem value="decrease">
{creditAdjustModeLabel("decrease", t)}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="agent-player-edit-credit-delta">
{t("playersPanel.creditAdjustAmount", { defaultValue: "调整额度" })}
</Label>
<Input
id="agent-player-edit-credit-delta"
type="number"
min={0}
value={editCreditDelta}
onChange={(e) => setEditCreditDelta(e.target.value)}
placeholder="0"
/>
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">
{t("playersPanel.creditProjected", {
defaultValue: "调整后授信:{{amount}}",
amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"),
})}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-edit-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={editRebateRate}
onChange={(e) => setEditRebateRate(e.target.value)}
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-risk-tags">
{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: "逗号分隔",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-status">
{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>
<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>
);
}