feat: 新增玩家管理模块,完善钱包转账单操作能力
1. 重构玩家模块导航与元信息,将原玩家查询改为玩家列表 2. 新增完整的玩家CRUD API与前端管理页面,支持搜索、新建、编辑、删除玩家 3. 为转账单新增冲正与人工处理操作,补充状态显示与对应枚举 4. 优化用户列表表格空值展示与样式细节 5. 调整钱包子导航,移除旧的玩家钱包入口
This commit is contained in:
41
src/api/admin-player.ts
Normal file
41
src/api/admin-player.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { adminRequest } from "@/lib/admin-http";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminPlayerListData,
|
||||||
|
AdminPlayerRow,
|
||||||
|
AdminPlayerCreatePayload,
|
||||||
|
AdminPlayerUpdatePayload,
|
||||||
|
AdminPlayerDeleteResult,
|
||||||
|
} from "@/types/api/admin-player";
|
||||||
|
|
||||||
|
const A = `${API_V1_PREFIX}/admin`;
|
||||||
|
|
||||||
|
export async function getAdminPlayers(params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: number;
|
||||||
|
}): Promise<AdminPlayerListData> {
|
||||||
|
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminPlayer(playerId: number): Promise<AdminPlayerRow> {
|
||||||
|
return adminRequest.get<AdminPlayerRow>(`${A}/players/${playerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminPlayer(body: AdminPlayerCreatePayload): Promise<AdminPlayerRow> {
|
||||||
|
return adminRequest.post<AdminPlayerRow>(`${A}/players`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putAdminPlayer(
|
||||||
|
playerId: number,
|
||||||
|
body: AdminPlayerUpdatePayload,
|
||||||
|
): Promise<AdminPlayerRow> {
|
||||||
|
return adminRequest.put<AdminPlayerRow>(`${A}/players/${playerId}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminPlayer(playerId: number): Promise<AdminPlayerDeleteResult> {
|
||||||
|
return adminRequest.delete<AdminPlayerDeleteResult>(`${A}/players/${playerId}`);
|
||||||
|
}
|
||||||
@@ -64,3 +64,28 @@ export async function getAdminPlayerWallets(
|
|||||||
`${A}/players/${playerId}/wallets`,
|
`${A}/players/${playerId}/wallets`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TransferOrderActionResult = {
|
||||||
|
transfer_no: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function reverseTransferOrder(
|
||||||
|
transferNo: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<TransferOrderActionResult> {
|
||||||
|
return adminRequest.post<TransferOrderActionResult>(
|
||||||
|
`${A}/wallet/transfer-orders/${transferNo}/reverse`,
|
||||||
|
remark ? { remark } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function manuallyProcessTransferOrder(
|
||||||
|
transferNo: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<TransferOrderActionResult> {
|
||||||
|
return adminRequest.post<TransferOrderActionResult>(
|
||||||
|
`${A}/wallet/transfer-orders/${transferNo}/manually-process`,
|
||||||
|
remark ? { remark } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ export const adminShellNavItems: AdminNavItem[] = [
|
|||||||
href: "/admin/admin-users",
|
href: "/admin/admin-users",
|
||||||
requiredAny: ["prd.admin_user.manage"],
|
requiredAny: ["prd.admin_user.manage"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
segment: "players",
|
||||||
|
label: "玩家列表",
|
||||||
|
href: "/admin/players",
|
||||||
|
requiredAny: [
|
||||||
|
"prd.users.manage",
|
||||||
|
"prd.users.view_finance",
|
||||||
|
"prd.users.view_cs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: "wallet",
|
||||||
|
label: "钱包流水",
|
||||||
|
href: "/admin/wallet/transactions",
|
||||||
|
activeMatchPrefix: "/admin/wallet",
|
||||||
|
requiredAny: [
|
||||||
|
"prd.wallet_reconcile.manage",
|
||||||
|
"prd.wallet_reconcile.view",
|
||||||
|
"prd.wallet_reconcile.view_cs",
|
||||||
|
"prd.users.manage",
|
||||||
|
"prd.users.view_finance",
|
||||||
|
"prd.users.view_cs",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
segment: "draws",
|
segment: "draws",
|
||||||
label: "开奖",
|
label: "开奖",
|
||||||
@@ -80,20 +104,6 @@ export const adminShellNavItems: AdminNavItem[] = [
|
|||||||
activeMatchPrefix: "/admin/jackpot",
|
activeMatchPrefix: "/admin/jackpot",
|
||||||
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
|
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
segment: "wallet",
|
|
||||||
label: "钱包流水",
|
|
||||||
href: "/admin/wallet/transactions",
|
|
||||||
activeMatchPrefix: "/admin/wallet",
|
|
||||||
requiredAny: [
|
|
||||||
"prd.wallet_reconcile.manage",
|
|
||||||
"prd.wallet_reconcile.view",
|
|
||||||
"prd.wallet_reconcile.view_cs",
|
|
||||||
"prd.users.manage",
|
|
||||||
"prd.users.view_finance",
|
|
||||||
"prd.users.view_cs",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
segment: "reconcile",
|
segment: "reconcile",
|
||||||
label: "对账",
|
label: "对账",
|
||||||
@@ -120,16 +130,6 @@ export const adminShellNavItems: AdminNavItem[] = [
|
|||||||
"prd.report.player",
|
"prd.report.player",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
segment: "players",
|
|
||||||
label: "玩家查询",
|
|
||||||
href: "/admin/players",
|
|
||||||
requiredAny: [
|
|
||||||
"prd.users.manage",
|
|
||||||
"prd.users.view_finance",
|
|
||||||
"prd.users.view_cs",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
segment: "reports",
|
segment: "reports",
|
||||||
label: "报表导出",
|
label: "报表导出",
|
||||||
|
|||||||
@@ -432,10 +432,10 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{row.username}</span>
|
<span className="font-medium">{row.username}</span>
|
||||||
<span className="text-xs text-muted-foreground">{row.email ?? "—"}</span>
|
<span className="text-xs text-muted-foreground">{row.email ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.nickname}</TableCell>
|
<TableCell>{row.nickname ?? ""}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.status === 0 ? (
|
{row.status === 0 ? (
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const playersModuleMeta = {
|
export const playersModuleMeta = {
|
||||||
segment: "players",
|
segment: "players",
|
||||||
title: "玩家",
|
title: "玩家列表",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,18 +1,541 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { PlayerWalletPanel } from "@/modules/wallet/wallet-console";
|
import {
|
||||||
|
deleteAdminPlayer,
|
||||||
|
getAdminPlayers,
|
||||||
|
postAdminPlayer,
|
||||||
|
putAdminPlayer,
|
||||||
|
} from "@/api/admin-player";
|
||||||
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||||
|
|
||||||
|
function playerStatusLabel(status: number): string {
|
||||||
|
if (status === 0) return "正常";
|
||||||
|
if (status === 1) return "冻结";
|
||||||
|
if (status === 2) return "封禁";
|
||||||
|
return String(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerStatusVariant(
|
||||||
|
status: number,
|
||||||
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
if (status === 0) return "secondary";
|
||||||
|
if (status === 1) return "outline";
|
||||||
|
if (status === 2) return "destructive";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinorUnits(minor: number, currencyCode: string): string {
|
||||||
|
const major = minor / 100;
|
||||||
|
return `${major.toFixed(2)} ${currencyCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYER_STATUS_OPTIONS = [
|
||||||
|
{ value: 0, label: "正常" },
|
||||||
|
{ value: 1, label: "冻结" },
|
||||||
|
{ value: 2, label: "封禁" },
|
||||||
|
];
|
||||||
|
|
||||||
export function PlayersConsole(): React.ReactElement {
|
export function PlayersConsole(): React.ReactElement {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(25);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [lastPage, setLastPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
|
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
|
||||||
|
const [accountSaving, setAccountSaving] = useState(false);
|
||||||
|
const [editingAccountId, setEditingAccountId] = useState<number | null>(null);
|
||||||
|
const [formSiteCode, setFormSiteCode] = useState("");
|
||||||
|
const [formSitePlayerId, setFormSitePlayerId] = useState("");
|
||||||
|
const [formUsername, setFormUsername] = useState("");
|
||||||
|
const [formNickname, setFormNickname] = useState("");
|
||||||
|
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||||
|
const [formStatus, setFormStatus] = useState(0);
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
|
||||||
|
const editingPlayer = useMemo(
|
||||||
|
() => items.find((p) => p.id === editingAccountId) ?? null,
|
||||||
|
[items, editingAccountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const data = await getAdminPlayers({
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
keyword: query.trim() || undefined,
|
||||||
|
});
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.meta.total);
|
||||||
|
setLastPage(Math.max(1, data.meta.last_page));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof LotteryApiBizError ? e.message : "加载玩家列表失败";
|
||||||
|
setErr(msg);
|
||||||
|
setItems([]);
|
||||||
|
setTotal(0);
|
||||||
|
setLastPage(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, perPage, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function openCreateAccount(): void {
|
||||||
|
setAccountMode("create");
|
||||||
|
setEditingAccountId(null);
|
||||||
|
setFormSiteCode("");
|
||||||
|
setFormSitePlayerId("");
|
||||||
|
setFormUsername("");
|
||||||
|
setFormNickname("");
|
||||||
|
setFormDefaultCurrency("NPR");
|
||||||
|
setFormStatus(0);
|
||||||
|
setAccountOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAccount(row: AdminPlayerRow): void {
|
||||||
|
setAccountMode("edit");
|
||||||
|
setEditingAccountId(row.id);
|
||||||
|
setFormSiteCode(row.site_code);
|
||||||
|
setFormSitePlayerId(row.site_player_id);
|
||||||
|
setFormUsername(row.username ?? "");
|
||||||
|
setFormNickname(row.nickname ?? "");
|
||||||
|
setFormDefaultCurrency(row.default_currency);
|
||||||
|
setFormStatus(row.status);
|
||||||
|
setAccountOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccountDialogOpenChange(open: boolean): void {
|
||||||
|
setAccountOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingAccountId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAccount(): Promise<void> {
|
||||||
|
if (accountMode === "create") {
|
||||||
|
if (formSiteCode.trim() === "") {
|
||||||
|
toast.error("请填写主站编号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formSitePlayerId.trim() === "") {
|
||||||
|
toast.error("请填写主站玩家 ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAccountSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await postAdminPlayer({
|
||||||
|
site_code: formSiteCode.trim(),
|
||||||
|
site_player_id: formSitePlayerId.trim(),
|
||||||
|
username: formUsername.trim() || null,
|
||||||
|
nickname: formNickname.trim() || null,
|
||||||
|
default_currency: formDefaultCurrency,
|
||||||
|
status: formStatus,
|
||||||
|
});
|
||||||
|
setItems((prev) => [created, ...prev]);
|
||||||
|
setTotal((t) => t + 1);
|
||||||
|
toast.success(`已创建玩家 ${created.username ?? created.site_player_id}`);
|
||||||
|
handleAccountDialogOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof LotteryApiBizError ? e.message : "创建玩家失败";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setAccountSaving(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const id = editingAccountId;
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const body: Parameters<typeof putAdminPlayer>[1] = {};
|
||||||
|
if (formUsername.trim() !== "") {
|
||||||
|
body.username = formUsername.trim();
|
||||||
|
}
|
||||||
|
if (formNickname !== editingPlayer?.nickname) {
|
||||||
|
body.nickname = formNickname.trim() || null;
|
||||||
|
}
|
||||||
|
if (formStatus !== editingPlayer?.status) {
|
||||||
|
body.status = formStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(body).length === 0) {
|
||||||
|
toast.success("没有变更");
|
||||||
|
handleAccountDialogOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccountSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await putAdminPlayer(id, body);
|
||||||
|
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
|
||||||
|
toast.success(`已更新 ${updated.username ?? updated.site_player_id}`);
|
||||||
|
handleAccountDialogOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof LotteryApiBizError ? e.message : "更新玩家失败";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setAccountSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(): Promise<void> {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await deleteAdminPlayer(deleteTarget.id);
|
||||||
|
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||||
|
setTotal((t) => Math.max(0, t - 1));
|
||||||
|
toast.success(`已删除玩家 ${deleteTarget.username ?? deleteTarget.site_player_id}`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof LotteryApiBizError ? e.message : "删除失败";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||||
<CardTitle>玩家查询</CardTitle>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<CardTitle>玩家列表</CardTitle>
|
||||||
|
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||||
|
新建玩家
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full max-w-lg gap-2">
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
placeholder="按玩家 ID / 用户名 / 昵称搜索"
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
setPage(1);
|
||||||
|
setQuery(keyword.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setQuery(keyword.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">ID</TableHead>
|
||||||
|
<TableHead>主站</TableHead>
|
||||||
|
<TableHead>主站玩家ID</TableHead>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>昵称</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">币种</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap text-right">余额</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap text-right">可用</TableHead>
|
||||||
|
<TableHead className="w-20 whitespace-nowrap">状态</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">最后登录</TableHead>
|
||||||
|
<TableHead className="min-w-[10rem]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="text-muted-foreground">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs">{row.site_code}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.username ?? "—"}</TableCell>
|
||||||
|
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||||
|
<TableCell>{row.default_currency}</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs">
|
||||||
|
{row.wallets.length > 0
|
||||||
|
? formatMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs">
|
||||||
|
{row.wallets.length > 0
|
||||||
|
? formatMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={playerStatusVariant(row.status)} className="font-normal">
|
||||||
|
{playerStatusLabel(row.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{row.last_login_at
|
||||||
|
? new Date(row.last_login_at).toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||||
|
}
|
||||||
|
onClick={() => openEditAccount(row)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<AdminListPaginationFooter
|
||||||
|
selectId="admin-players-per-page"
|
||||||
|
total={total}
|
||||||
|
page={page}
|
||||||
|
lastPage={lastPage}
|
||||||
|
perPage={perPage}
|
||||||
|
loading={loading}
|
||||||
|
onPerPageChange={(n) => {
|
||||||
|
setPerPage(n);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<PlayerWalletPanel />
|
|
||||||
|
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
|
||||||
|
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{accountMode === "create" ? "新建玩家" : "编辑玩家"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{accountMode === "create"
|
||||||
|
? "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。"
|
||||||
|
: "编辑玩家信息。"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{accountMode === "create" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-site-code">主站编号</Label>
|
||||||
|
<Input
|
||||||
|
id="player-site-code"
|
||||||
|
value={formSiteCode}
|
||||||
|
placeholder="例如 main_site"
|
||||||
|
onChange={(e) => setFormSiteCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-site-id">主站玩家 ID</Label>
|
||||||
|
<Input
|
||||||
|
id="player-site-id"
|
||||||
|
value={formSitePlayerId}
|
||||||
|
placeholder="主站返回的唯一标识"
|
||||||
|
onChange={(e) => setFormSitePlayerId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-username">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="player-username"
|
||||||
|
value={formUsername}
|
||||||
|
disabled={accountMode === "edit"}
|
||||||
|
placeholder={accountMode === "create" ? "选填" : ""}
|
||||||
|
onChange={(e) => setFormUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-nickname">昵称</Label>
|
||||||
|
<Input
|
||||||
|
id="player-nickname"
|
||||||
|
value={formNickname}
|
||||||
|
placeholder="选填"
|
||||||
|
onChange={(e) => setFormNickname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{accountMode === "create" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-currency">默认币种</Label>
|
||||||
|
<Input
|
||||||
|
id="player-currency"
|
||||||
|
value={formDefaultCurrency}
|
||||||
|
placeholder="NPR"
|
||||||
|
onChange={(e) => setFormDefaultCurrency(e.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-status">状态</Label>
|
||||||
|
<Select
|
||||||
|
value={String(formStatus)}
|
||||||
|
onValueChange={(v) => setFormStatus(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="player-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{accountMode === "edit" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-edit-status">状态</Label>
|
||||||
|
<Select
|
||||||
|
value={String(formStatus)}
|
||||||
|
onValueChange={(v) => setFormStatus(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="player-edit-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAccountDialogOpenChange(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={accountSaving}
|
||||||
|
onClick={() => void submitAccount()}
|
||||||
|
>
|
||||||
|
{accountSaving ? "保存中…" : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<DialogContent showCloseButton className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除玩家{" "}
|
||||||
|
{deleteTarget ? (
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{deleteTarget.username ?? deleteTarget.site_player_id}
|
||||||
|
</span>
|
||||||
|
) : null}{" "}
|
||||||
|
吗?此操作不可恢复。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
|
||||||
|
{deleteBusy ? "删除中…" : "删除"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
getAdminPlayerWallets,
|
getAdminPlayerWallets,
|
||||||
getAdminTransferOrders,
|
getAdminTransferOrders,
|
||||||
getAdminWalletTransactions,
|
getAdminWalletTransactions,
|
||||||
|
reverseTransferOrder,
|
||||||
|
manuallyProcessTransferOrder,
|
||||||
} from "@/api/admin-wallet";
|
} from "@/api/admin-wallet";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -97,9 +99,31 @@ function statusBadgeVariant(
|
|||||||
if (status === "success" || status === "posted") return "secondary";
|
if (status === "success" || status === "posted") return "secondary";
|
||||||
if (status === "failed") return "destructive";
|
if (status === "failed") return "destructive";
|
||||||
if (status === "pending_reconcile") return "outline";
|
if (status === "pending_reconcile") return "outline";
|
||||||
|
if (status === "reversed" || status === "manually_processed") return "outline";
|
||||||
return "default";
|
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 = {
|
type TransferFilters = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playerAccount: 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 }[] = [
|
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: "posted", label: "已记账" },
|
{ value: "posted", label: "已记账" },
|
||||||
{ value: "pending_reconcile", label: "待对账" },
|
{ value: "pending_reconcile", label: "待对账" },
|
||||||
|
{ value: "reversed", label: "已冲正" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
|
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
|
||||||
@@ -168,6 +193,8 @@ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: "success", label: "成功" },
|
{ value: "success", label: "成功" },
|
||||||
{ value: "failed", label: "失败" },
|
{ value: "failed", label: "失败" },
|
||||||
{ value: "pending_reconcile", label: "待对账" },
|
{ value: "pending_reconcile", label: "待对账" },
|
||||||
|
{ value: "reversed", label: "已冲正" },
|
||||||
|
{ value: "manually_processed", label: "已人工处理" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
|
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
|
||||||
@@ -191,6 +218,34 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
const [perPage, setPerPage] = useState(20);
|
const [perPage, setPerPage] = useState(20);
|
||||||
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
|
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
|
||||||
const [applied, setApplied] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -405,7 +460,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
{formatMinorUnits(row.amount, row.currency_code)}
|
{formatMinorUnits(row.amount, row.currency_code)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
|
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
|
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
|
||||||
{row.fail_reason?.trim() ? row.fail_reason : "—"}
|
{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">
|
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
||||||
{formatTs(row.finished_at)}
|
{formatTs(row.finished_at)}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -666,13 +746,12 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
<TableHead className="min-w-0 whitespace-normal leading-tight">
|
<TableHead className="min-w-0 whitespace-normal leading-tight">
|
||||||
完成时间
|
完成时间
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-24">操作</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-muted-foreground">
|
<TableCell colSpan={8} className="text-muted-foreground">
|
||||||
无数据
|
无数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -697,7 +776,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
{row.amount} ({row.direction === 1 ? "入" : "出"})
|
{row.amount} ({row.direction === 1 ? "入" : "出"})
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
|
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
||||||
{formatTs(row.created_at)}
|
{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">
|
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
||||||
{formatTs(row.updated_at)}
|
{formatTs(row.updated_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">—</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,16 +13,9 @@ const RECONCILE_PERMS = [
|
|||||||
"prd.wallet_reconcile.view_cs",
|
"prd.wallet_reconcile.view_cs",
|
||||||
] as const;
|
] 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[] }[] = [
|
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
|
||||||
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
|
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
|
||||||
{ href: "/admin/wallet/transfer-orders", 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 {
|
export function WalletSubnav(): React.ReactElement {
|
||||||
|
|||||||
50
src/types/api/admin-player.ts
Normal file
50
src/types/api/admin-player.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export type AdminPlayerWalletRow = {
|
||||||
|
wallet_type: string;
|
||||||
|
currency_code: string;
|
||||||
|
balance: number;
|
||||||
|
frozen_balance: number;
|
||||||
|
available_balance: number;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerRow = {
|
||||||
|
id: number;
|
||||||
|
site_code: string;
|
||||||
|
site_player_id: string;
|
||||||
|
username: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
default_currency: string;
|
||||||
|
status: number;
|
||||||
|
last_login_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
wallets: AdminPlayerWalletRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerListData = {
|
||||||
|
items: AdminPlayerRow[];
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
last_page: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerCreatePayload = {
|
||||||
|
site_code: string;
|
||||||
|
site_player_id: string;
|
||||||
|
username?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
default_currency?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerUpdatePayload = {
|
||||||
|
username?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerDeleteResult = {
|
||||||
|
deleted: boolean;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user