feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 {
|
||||
getSettlementBills,
|
||||
postSettlementBillBadDebtWriteOff,
|
||||
postSettlementBillConfirm,
|
||||
postSettlementBillPayment,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayer,
|
||||
@@ -57,18 +52,21 @@ 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 { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money";
|
||||
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_SETTLEMENT_AGENT_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||
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";
|
||||
@@ -148,8 +146,6 @@ type AgentsPlayersPanelProps = {
|
||||
allowCreatePlayer?: boolean;
|
||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||
embedded?: boolean;
|
||||
/** 外部触发创建直属玩家的计数器 */
|
||||
createRequestKey?: number;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
@@ -157,10 +153,11 @@ export function AgentsPlayersPanel({
|
||||
agentNodeId,
|
||||
allowCreatePlayer,
|
||||
embedded = false,
|
||||
createRequestKey = 0,
|
||||
}: 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: "创建玩家" });
|
||||
@@ -173,21 +170,6 @@ export function AgentsPlayersPanel({
|
||||
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||
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 canOperateBills =
|
||||
isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
||||
const canFinanceAdjustments = canOperateBills && boundAgent === null;
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
return agentNodeId;
|
||||
@@ -195,6 +177,26 @@ export function AgentsPlayersPanel({
|
||||
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"]>([]);
|
||||
@@ -231,18 +233,6 @@ export function AgentsPlayersPanel({
|
||||
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([]);
|
||||
@@ -259,6 +249,7 @@ export function AgentsPlayersPanel({
|
||||
page,
|
||||
per_page: perPage,
|
||||
site_code: siteCode.trim(),
|
||||
...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}),
|
||||
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
@@ -272,7 +263,7 @@ export function AgentsPlayersPanel({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveAgentId, page, perPage, siteCode]);
|
||||
}, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
@@ -355,9 +346,7 @@ export function AgentsPlayersPanel({
|
||||
username: username.trim(),
|
||||
password: password,
|
||||
nickname: nickname.trim() || null,
|
||||
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null)
|
||||
? { agent_node_id: effectiveAgentId }
|
||||
: {}),
|
||||
...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}),
|
||||
credit_limit: parsedCreditLimit,
|
||||
...(parsedRebateRate !== null
|
||||
? { rebate_rate: parsedRebateRate }
|
||||
@@ -407,16 +396,6 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -601,47 +580,19 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedBill = useMemo(
|
||||
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
|
||||
[billingBills, selectedBillId],
|
||||
);
|
||||
const canOperateSelectedBill =
|
||||
selectedBill !== null &&
|
||||
canOperateBills &&
|
||||
settlementBillOperableByBoundAgent(selectedBill, boundAgent);
|
||||
const billingCurrency = billingPlayer?.default_currency ?? "NPR";
|
||||
|
||||
function resetBillingForm(): void {
|
||||
setPayAmount("");
|
||||
setPayMethod("");
|
||||
setPayProof("");
|
||||
setBadDebtReason("");
|
||||
}
|
||||
|
||||
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
|
||||
setBillingDialogOpen(true);
|
||||
setBillingPlayer(row);
|
||||
setBillingBills([]);
|
||||
setSelectedBillId(null);
|
||||
resetBillingForm();
|
||||
setBillingLoading(true);
|
||||
async function goToPlayerSettlement(row: AdminPlayerRow): Promise<void> {
|
||||
setSettlementNavBusy(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 ? formatAdminMinorDecimal(first.unpaid_amount ?? 0, row.default_currency ?? "NPR") : "",
|
||||
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(
|
||||
@@ -650,172 +601,50 @@ export function AgentsPlayersPanel({
|
||||
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
setSettlementNavBusy(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 fallbackAmount = formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
);
|
||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
||||
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 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 fallbackAmount = formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
);
|
||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
||||
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}
|
||||
<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>
|
||||
) : null}
|
||||
{canCreatePlayer ? (
|
||||
<Button type="button" size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{createPlayerLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -825,41 +654,51 @@ export function AgentsPlayersPanel({
|
||||
{listErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{listErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<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="bg-muted/40 hover:bg-muted/40">
|
||||
<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>
|
||||
<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>
|
||||
{!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>
|
||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
||||
{!embedded ? (
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { 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)]">
|
||||
<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 ? 9 : 10} cellClassName="py-12 text-center" />
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 7 : 10} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
@@ -876,26 +715,30 @@ export function AgentsPlayersPanel({
|
||||
<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>
|
||||
{!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>
|
||||
@@ -911,22 +754,22 @@ export function AgentsPlayersPanel({
|
||||
>
|
||||
{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 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>
|
||||
) : null}
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
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}
|
||||
busy={confirmBusy || settlementNavBusy}
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
@@ -942,7 +785,7 @@ export function AgentsPlayersPanel({
|
||||
defaultValue: "处理账单",
|
||||
}),
|
||||
icon: ReceiptText,
|
||||
onClick: () => void openBillingDialog(row),
|
||||
onClick: () => void goToPlayerSettlement(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -1040,7 +883,10 @@ export function AgentsPlayersPanel({
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
|
||||
{t("playersPanel.passwordHint", {
|
||||
defaultValue: "至少 {{min}} 位",
|
||||
min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1101,157 +947,6 @@ export function AgentsPlayersPanel({
|
||||
</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
|
||||
? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency)
|
||||
: "",
|
||||
);
|
||||
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} · ${formatAdminMinorUnits(bill.unpaid_amount ?? 0, billingCurrency)}`}
|
||||
</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>{" "}
|
||||
{formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canOperateSelectedBill && selectedBill.status === "pending_confirm" ? (
|
||||
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
|
||||
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canOperateSelectedBill && 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)}
|
||||
inputMode="decimal"
|
||||
placeholder={formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{canFinanceAdjustments &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(selectedBill.status) &&
|
||||
Number(selectedBill.unpaid_amount ?? 0) > 0 &&
|
||||
!["adjustment", "reversal", "bad_debt"].includes(selectedBill.bill_type) ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user