refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components

This commit is contained in:
2026-06-11 18:02:02 +08:00
parent 44ad51698f
commit 1eb6702c51
54 changed files with 1888 additions and 1103 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -57,7 +57,7 @@ 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, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
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";
@@ -136,7 +136,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
currency: row.default_currency ?? "",
status: row.status,
creditLimit: row.credit_limit ?? 0,
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
rebateRate: rebate != null ? percentValueToUi(rebate) : "",
riskTags: (row.risk_tags ?? []).join(", "),
};
}
@@ -149,6 +149,8 @@ type AgentsPlayersPanelProps = {
allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean;
/** 外部触发创建直属玩家的计数器 */
createRequestKey?: number;
};
export function AgentsPlayersPanel({
@@ -156,6 +158,7 @@ export function AgentsPlayersPanel({
agentNodeId,
allowCreatePlayer,
embedded = false,
createRequestKey = 0,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter();
@@ -226,6 +229,7 @@ export function AgentsPlayersPanel({
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const lastCreateRequestKeyRef = useRef(createRequestKey);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
@@ -269,6 +273,46 @@ export function AgentsPlayersPanel({
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 {
@@ -278,10 +322,9 @@ export function AgentsPlayersPanel({
password: password,
nickname: nickname.trim() || null,
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
credit_limit:
creditLimit.trim() === "" ? 0 : Math.max(0, Number.parseInt(creditLimit, 10) || 0),
...(rebateRate.trim() !== ""
? { rebate_rate: percentUiToRatio(rebateRate) }
credit_limit: parsedCreditLimit,
...(parsedRebateRate !== null
? { rebate_rate: parsedRebateRate }
: {}),
});
toast.success(
@@ -306,6 +349,11 @@ export function AgentsPlayersPanel({
function openCreateDialog(): void {
setDialogOpen(true);
setUsername("");
setPassword("");
setNickname("");
setCreditLimit("");
setRebateRate("");
if (effectiveAgentId !== null) {
void getAgentNodeProfile(effectiveAgentId)
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
@@ -315,6 +363,16 @@ 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);
@@ -382,7 +440,7 @@ export function AgentsPlayersPanel({
}
const prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate);
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
const nextRebate = nextPercent === null ? null : nextPercent;
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
body.rebate_rate = nextRebate;
}
@@ -648,13 +706,9 @@ export function AgentsPlayersPanel({
})}
</p>
) : (
<p className="text-sm text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
<div />
)}
{canCreatePlayer ? (
{canCreatePlayer && !embedded ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
@@ -693,7 +747,7 @@ export function AgentsPlayersPanel({
{!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
) : null}
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
<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>
@@ -750,7 +804,7 @@ export function AgentsPlayersPanel({
: undefined
}
>
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
@@ -763,7 +817,7 @@ export function AgentsPlayersPanel({
</TableCell>
) : null}
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
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
@@ -844,83 +898,105 @@ export function AgentsPlayersPanel({
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{createPlayerLabel}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input value={siteCode} readOnly disabled />
<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>
<div className="space-y-2">
<Label htmlFor="agent-player-username">
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
</Label>
<Input
id="agent-player-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-password">
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="agent-player-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-nickname">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-credit"
type="number"
min={0}
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
/>
{parentAvailableCredit !== null ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={rebateRate}
placeholder="0.5"
onChange={(e) => setRebateRate(e.target.value)}
/>
</div>
<DialogFooter>
<DialogFooter className="mt-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
@@ -1174,10 +1250,10 @@ export function AgentsPlayersPanel({
step="0.01"
value={editRebateRate}
onChange={(e) => setEditRebateRate(e.target.value)}
placeholder="0.5"
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
</p>
</div>
<div className="space-y-2">