feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
2026-06-26 14:39:22 +08:00
parent 7d075ceb62
commit 0d984b00f0
126 changed files with 7423 additions and 7084 deletions

View File

@@ -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>