feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates
Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminPlayers, postAdminPlayer } from "@/api/admin-player";
|
||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||
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 { AdminNoResourceState, 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";
|
||||
@@ -19,6 +29,13 @@ import {
|
||||
} 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,
|
||||
@@ -28,31 +45,119 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } 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 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: string;
|
||||
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 != null ? String(row.credit_limit) : "",
|
||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
||||
riskTags: (row.risk_tags ?? []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
type AgentsPlayersPanelProps = {
|
||||
siteCode: string;
|
||||
/** 筛选直属玩家时的代理节点;null 表示当前登录代理或不过滤 */
|
||||
agentNodeId: number | null;
|
||||
/** 当前代理 profile 是否允许创建玩家;未传时沿用登录代理能力 */
|
||||
allowCreatePlayer?: boolean;
|
||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
siteCode,
|
||||
agentNodeId,
|
||||
allowCreatePlayer,
|
||||
embedded = false,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
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 ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
(profileAllowsCreate &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
const canManagePlayerRows = canCreatePlayer;
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
@@ -72,7 +177,22 @@ export function AgentsPlayersPanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
||||
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 [editCreditLimit, setEditCreditLimit] = useState("");
|
||||
const [editRebateRate, setEditRebateRate] = useState("");
|
||||
const [editRiskTags, setEditRiskTags] = useState("");
|
||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
@@ -108,8 +228,12 @@ export function AgentsPlayersPanel({
|
||||
}, [load]);
|
||||
|
||||
async function savePlayer(): Promise<void> {
|
||||
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
|
||||
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
|
||||
if (siteCode.trim() === "") {
|
||||
toast.error(t("players:siteCodeRequired", { defaultValue: "请填写主站编号" }));
|
||||
return;
|
||||
}
|
||||
if (username.trim() === "" || password.trim() === "") {
|
||||
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,16 +241,30 @@ export function AgentsPlayersPanel({
|
||||
try {
|
||||
await postAdminPlayer({
|
||||
site_code: siteCode.trim(),
|
||||
site_player_id: sitePlayerId.trim(),
|
||||
username: username.trim() || null,
|
||||
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
|
||||
username: username.trim(),
|
||||
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) }
|
||||
: {}),
|
||||
});
|
||||
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
|
||||
toast.success(
|
||||
t("playersPanel.createSuccessNative", {
|
||||
name: username.trim(),
|
||||
defaultValue: "玩家 {{name}} 已创建,请使用彩票端登录",
|
||||
}),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setSitePlayerId("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setNickname("");
|
||||
setCreditLimit("");
|
||||
setRebateRate("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
|
||||
@@ -135,56 +273,285 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog(): void {
|
||||
setDialogOpen(true);
|
||||
if (effectiveAgentId !== null) {
|
||||
void getAgentNodeProfile(effectiveAgentId)
|
||||
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
||||
.catch(() => setParentAvailableCredit(null));
|
||||
} else {
|
||||
setParentAvailableCredit(null);
|
||||
}
|
||||
}
|
||||
|
||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||
const form = fillEditFormFromPlayer(row);
|
||||
setEditUsername(form.username);
|
||||
setEditNickname(form.nickname);
|
||||
setEditDefaultCurrency(form.currency);
|
||||
setEditStatus(form.status);
|
||||
setEditCreditLimit(form.creditLimit);
|
||||
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 nextCredit =
|
||||
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
|
||||
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
||||
body.credit_limit = Math.max(0, nextCredit);
|
||||
}
|
||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||
const nextPercent = parsePercentUi(editRebateRate);
|
||||
const nextRebate = nextPercent === null ? null : percentUiToRatio(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) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canCreatePlayer ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.creditListHint", {
|
||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{canCreatePlayer ? (
|
||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="6rem" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<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>
|
||||
<TableHead>{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}</TableHead>
|
||||
<TableHead>{t("players:username", { defaultValue: "用户名" })}</TableHead>
|
||||
<TableHead>{t("players:nickname", { defaultValue: "昵称" })}</TableHead>
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
<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: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-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
items.map((row) => (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
const rebate = resolvePlayerRebateRate(row);
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<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>
|
||||
<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 ? `${ratioToPercentUi(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)}>
|
||||
{row.status === 0
|
||||
? t("players:statusNormal", { defaultValue: "正常" })
|
||||
: String(row.status)}
|
||||
{playerStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
busy={confirmBusy}
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("players:viewDetail", { defaultValue: "查看详情" }),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.id),
|
||||
},
|
||||
...(canManagePlayerRows
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: t("players:edit", { defaultValue: "编辑" }),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditPlayer(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("players:delete", { defaultValue: "删除" }),
|
||||
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"
|
||||
@@ -208,28 +575,42 @@ export function AgentsPlayersPanel({
|
||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-site-id">
|
||||
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
|
||||
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID(可选)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-site-id"
|
||||
value={sitePlayerId}
|
||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{t("players:username", { defaultValue: "用户名" })}
|
||||
{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">
|
||||
@@ -242,6 +623,41 @@ export function AgentsPlayersPanel({
|
||||
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>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
@@ -252,6 +668,126 @@ export function AgentsPlayersPanel({
|
||||
</DialogFooter>
|
||||
</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>
|
||||
<Input
|
||||
id="agent-player-edit-credit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editCreditLimit}
|
||||
onChange={(e) => setEditCreditLimit(e.target.value)}
|
||||
/>
|
||||
</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.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user