feat: 新增玩家管理模块,完善钱包转账单操作能力

1. 重构玩家模块导航与元信息,将原玩家查询改为玩家列表
2. 新增完整的玩家CRUD API与前端管理页面,支持搜索、新建、编辑、删除玩家
3. 为转账单新增冲正与人工处理操作,补充状态显示与对应枚举
4. 优化用户列表表格空值展示与样式细节
5. 调整钱包子导航,移除旧的玩家钱包入口
This commit is contained in:
2026-05-14 10:42:43 +08:00
parent c4d566fc48
commit 2dfffd1fd1
9 changed files with 755 additions and 45 deletions

View File

@@ -8,6 +8,8 @@ import {
getAdminPlayerWallets,
getAdminTransferOrders,
getAdminWalletTransactions,
reverseTransferOrder,
manuallyProcessTransferOrder,
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -97,9 +99,31 @@ function statusBadgeVariant(
if (status === "success" || status === "posted") return "secondary";
if (status === "failed") return "destructive";
if (status === "pending_reconcile") return "outline";
if (status === "reversed" || status === "manually_processed") return "outline";
return "default";
}
function statusLabel(status: string): string {
switch (status) {
case "processing":
return "处理中";
case "success":
return "成功";
case "failed":
return "失败";
case "pending_reconcile":
return "待对账";
case "reversed":
return "已冲正";
case "manually_processed":
return "已人工处理";
case "posted":
return "已记账";
default:
return status;
}
}
type TransferFilters = {
playerId: string;
playerAccount: string;
@@ -160,6 +184,7 @@ const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "已记账" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
@@ -168,6 +193,8 @@ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
{ value: "manually_processed", label: "已人工处理" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
@@ -191,6 +218,34 @@ export function TransferOrdersPanel(): React.ReactElement {
const [perPage, setPerPage] = useState(20);
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
const doAction = async (
transferNo: string,
fn: () => Promise<unknown>,
successMsg: string,
) => {
setActionLoading((prev) => new Set(prev).add(transferNo));
try {
await fn();
toast.success(successMsg);
void load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
} finally {
setActionLoading((prev) => {
const next = new Set(prev);
next.delete(transferNo);
return next;
});
}
};
const handleReverse = (transferNo: string) =>
doAction(transferNo, () => reverseTransferOrder(transferNo), "冲正成功");
const handleManuallyProcess = (transferNo: string) =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), "人工处理成功");
const load = useCallback(async () => {
setLoading(true);
@@ -405,7 +460,7 @@ export function TransferOrdersPanel(): React.ReactElement {
{formatMinorUnits(row.amount, row.currency_code)}
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
@@ -416,7 +471,32 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.finished_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground"></TableCell>
<TableCell>
{row.status === "pending_reconcile" ? (
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleReverse(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "冲正"}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleManuallyProcess(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "人工处理"}
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))
)}
@@ -666,13 +746,12 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
<TableCell colSpan={8} className="text-muted-foreground">
</TableCell>
</TableRow>
@@ -697,7 +776,7 @@ export function WalletTxnsPanel(): React.ReactElement {
{row.amount} ({row.direction === 1 ? "入" : "出"})
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
@@ -705,7 +784,6 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.updated_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground"></TableCell>
</TableRow>
))
)}

View File

@@ -13,16 +13,9 @@ const RECONCILE_PERMS = [
"prd.wallet_reconcile.view_cs",
] as const;
const USER_PERMS = [
"prd.users.manage",
"prd.users.view_finance",
"prd.users.view_cs",
] as const;
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "转账单", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/player", label: "玩家钱包", requiredAny: USER_PERMS },
];
export function WalletSubnav(): React.ReactElement {