feat: 添加管理员钱包相关API和更新模块结构
This commit is contained in:
65
src/api/admin-wallet.ts
Normal file
65
src/api/admin-wallet.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { adminRequest } from "@/lib/admin-http";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminPlayerWalletsData,
|
||||||
|
AdminTransferOrderListData,
|
||||||
|
AdminWalletTxnListData,
|
||||||
|
} from "@/types/api/admin-wallet";
|
||||||
|
|
||||||
|
const A = `${API_V1_PREFIX}/admin`;
|
||||||
|
|
||||||
|
export type TransferOrderListQuery = {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
player_id?: number;
|
||||||
|
/** 模糊:site_player_id / username */
|
||||||
|
player_account?: string;
|
||||||
|
transfer_no?: string;
|
||||||
|
external_ref_no?: string;
|
||||||
|
created_from?: string;
|
||||||
|
created_to?: string;
|
||||||
|
status?: string;
|
||||||
|
abnormal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminTransferOrders(
|
||||||
|
q: TransferOrderListQuery = {},
|
||||||
|
): Promise<AdminTransferOrderListData> {
|
||||||
|
return adminRequest.get<AdminTransferOrderListData>(
|
||||||
|
`${A}/wallet/transfer-orders`,
|
||||||
|
{ params: q },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WalletTransactionListQuery = {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
player_id?: number;
|
||||||
|
player_account?: string;
|
||||||
|
txn_no?: string;
|
||||||
|
external_ref_no?: string;
|
||||||
|
created_from?: string;
|
||||||
|
created_to?: string;
|
||||||
|
biz_type?: string;
|
||||||
|
status?: string;
|
||||||
|
abnormal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminWalletTransactions(
|
||||||
|
q: WalletTransactionListQuery = {},
|
||||||
|
): Promise<AdminWalletTxnListData> {
|
||||||
|
return adminRequest.get<AdminWalletTxnListData>(
|
||||||
|
`${A}/wallet/transactions`,
|
||||||
|
{ params: q },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminPlayerWallets(
|
||||||
|
playerId: number,
|
||||||
|
): Promise<AdminPlayerWalletsData> {
|
||||||
|
return adminRequest.get<AdminPlayerWalletsData>(
|
||||||
|
`${A}/players/${playerId}/wallets`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export { API_V1_PREFIX } from "@/api/paths";
|
export { API_V1_PREFIX } from "@/api/paths";
|
||||||
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
||||||
export { getAdminPing } from "@/api/admin-ping";
|
export { getAdminPing } from "@/api/admin-ping";
|
||||||
|
export {
|
||||||
|
getAdminPlayerWallets,
|
||||||
|
getAdminTransferOrders,
|
||||||
|
getAdminWalletTransactions,
|
||||||
|
} from "@/api/admin-wallet";
|
||||||
export type {
|
export type {
|
||||||
AdminAuthCaptchaResponse,
|
AdminAuthCaptchaResponse,
|
||||||
AdminAuthLoginRequest,
|
AdminAuthLoginRequest,
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminDrawsPage() {
|
export default function AdminDrawsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={drawsModuleMeta.title}
|
|
||||||
description={drawsModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
业务组件请放在{" "}
|
业务组件请放在{" "}
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { getAdminPing } from "@/api";
|
import { getAdminPing } from "@/api";
|
||||||
import { dashboardModuleMeta } from "@/modules/dashboard/meta";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -12,10 +11,7 @@ export default async function AdminDashboardPage() {
|
|||||||
const apiReady = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "";
|
const apiReady = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={dashboardModuleMeta.title}
|
|
||||||
description={dashboardModuleMeta.description}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
|
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminPlayersPage() {
|
export default function AdminPlayersPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={playersModuleMeta.title}
|
|
||||||
description={playersModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
业务组件请放在{" "}
|
业务组件请放在{" "}
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminRiskPage() {
|
export default function AdminRiskPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={riskModuleMeta.title}
|
|
||||||
description={riskModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
业务组件请放在{" "}
|
业务组件请放在{" "}
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={settingsModuleMeta.title}
|
|
||||||
description={settingsModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
业务组件请放在{" "}
|
业务组件请放在{" "}
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminTicketsPage() {
|
export default function AdminTicketsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold>
|
||||||
title={ticketsModuleMeta.title}
|
|
||||||
description={ticketsModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
业务组件请放在{" "}
|
业务组件请放在{" "}
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { walletModuleMeta } from "@/modules/wallet/meta";
|
import { walletModuleMeta } from "@/modules/wallet/meta";
|
||||||
|
import { WalletConsole } from "@/modules/wallet/wallet-console";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -8,17 +9,8 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function AdminWalletPage() {
|
export default function AdminWalletPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold
|
<ModuleScaffold className="w-full max-w-none">
|
||||||
title={walletModuleMeta.title}
|
<WalletConsole />
|
||||||
description={walletModuleMeta.description}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
业务组件请放在{" "}
|
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
|
|
||||||
src/modules/wallet
|
|
||||||
</code>{" "}
|
|
||||||
下。
|
|
||||||
</p>
|
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,11 @@ import type { ReactNode } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ModuleScaffoldProps = {
|
type ModuleScaffoldProps = {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModuleScaffold({
|
/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */
|
||||||
title,
|
export function ModuleScaffold({ children, className }: ModuleScaffoldProps) {
|
||||||
description,
|
return <div className={cn("mx-auto max-w-5xl", className)}>{children}</div>;
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: ModuleScaffoldProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("mx-auto max-w-5xl", className)}>
|
|
||||||
<header className="border-b border-black/10 pb-6 dark:border-white/10">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-600 dark:text-zinc-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{children !== undefined ? <div className="mt-10">{children}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const walletModuleMeta = {
|
export const walletModuleMeta = {
|
||||||
segment: "wallet",
|
segment: "wallet",
|
||||||
title: "钱包",
|
title: "钱包流水与对账",
|
||||||
description: "资金流水、充值提现审核(占位)。",
|
description:""
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
754
src/modules/wallet/wallet-console.tsx
Normal file
754
src/modules/wallet/wallet-console.tsx
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminPlayerWallets,
|
||||||
|
getAdminTransferOrders,
|
||||||
|
getAdminWalletTransactions,
|
||||||
|
} from "@/api/admin-wallet";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import type {
|
||||||
|
AdminPlayerWalletsData,
|
||||||
|
AdminTransferOrderListData,
|
||||||
|
AdminWalletTxnListData,
|
||||||
|
} from "@/types/api/admin-wallet";
|
||||||
|
|
||||||
|
function formatMinorUnits(minor: number, currencyCode: string): string {
|
||||||
|
const major = minor / 100;
|
||||||
|
return `${major.toFixed(2)} ${currencyCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return iso.replace("T", " ").slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
|
||||||
|
function CellMonoId({
|
||||||
|
value,
|
||||||
|
empty = "—",
|
||||||
|
copyHint,
|
||||||
|
}: {
|
||||||
|
value: string | null | undefined;
|
||||||
|
empty?: string;
|
||||||
|
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
|
||||||
|
copyHint?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (value == null || value === "") {
|
||||||
|
return <span className="text-muted-foreground">{empty}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = async (e: React.MouseEvent): Promise<void> => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success(
|
||||||
|
copyHint
|
||||||
|
? `${copyHint}已复制到剪贴板`
|
||||||
|
: "已复制到剪贴板",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败,请检查浏览器权限或手动选择文本");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
|
||||||
|
title={`${value}\n点击复制`}
|
||||||
|
aria-label={copyHint ? `复制${copyHint}` : "复制到剪贴板"}
|
||||||
|
onClick={(e) => void copy(e)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{value}</span>
|
||||||
|
<Copy
|
||||||
|
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(
|
||||||
|
status: string,
|
||||||
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
if (status === "success" || status === "posted") return "secondary";
|
||||||
|
if (status === "failed") return "destructive";
|
||||||
|
if (status === "pending_reconcile") return "outline";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferFilters = {
|
||||||
|
playerId: string;
|
||||||
|
playerAccount: string;
|
||||||
|
transferNo: string;
|
||||||
|
externalRefNo: string;
|
||||||
|
createdFrom: string;
|
||||||
|
createdTo: string;
|
||||||
|
statusCsv: string;
|
||||||
|
abnormalOnly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTransferFilters: TransferFilters = {
|
||||||
|
playerId: "",
|
||||||
|
playerAccount: "",
|
||||||
|
transferNo: "",
|
||||||
|
externalRefNo: "",
|
||||||
|
createdFrom: "",
|
||||||
|
createdTo: "",
|
||||||
|
statusCsv: "",
|
||||||
|
abnormalOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type TxnFilters = {
|
||||||
|
playerId: string;
|
||||||
|
playerAccount: string;
|
||||||
|
txnNo: string;
|
||||||
|
externalRefNo: string;
|
||||||
|
bizType: string;
|
||||||
|
statusCsv: string;
|
||||||
|
createdFrom: string;
|
||||||
|
createdTo: string;
|
||||||
|
abnormalOnly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTxnFilters: TxnFilters = {
|
||||||
|
playerId: "",
|
||||||
|
playerAccount: "",
|
||||||
|
txnNo: "",
|
||||||
|
externalRefNo: "",
|
||||||
|
bizType: "",
|
||||||
|
statusCsv: "",
|
||||||
|
createdFrom: "",
|
||||||
|
createdTo: "",
|
||||||
|
abnormalOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WalletConsole(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="txns" className="w-full">
|
||||||
|
<TabsList variant="line" className="mb-6 w-full justify-start">
|
||||||
|
<TabsTrigger value="txns">钱包流水</TabsTrigger>
|
||||||
|
<TabsTrigger value="orders">转账单</TabsTrigger>
|
||||||
|
<TabsTrigger value="player">玩家钱包查询</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="orders">
|
||||||
|
<TransferOrdersPanel />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="txns">
|
||||||
|
<WalletTxnsPanel />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="player">
|
||||||
|
<PlayerWalletPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransferOrdersPanel(): React.ReactElement {
|
||||||
|
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
|
||||||
|
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const player_id =
|
||||||
|
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
||||||
|
const d = await getAdminTransferOrders({
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
abnormal: applied.abnormalOnly || undefined,
|
||||||
|
player_id:
|
||||||
|
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
||||||
|
? player_id
|
||||||
|
: undefined,
|
||||||
|
player_account: applied.playerAccount.trim() || undefined,
|
||||||
|
transfer_no: applied.transferNo.trim() || undefined,
|
||||||
|
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||||
|
created_from: applied.createdFrom.trim() || undefined,
|
||||||
|
created_to: applied.createdTo.trim() || undefined,
|
||||||
|
status: applied.statusCsv.trim() || undefined,
|
||||||
|
});
|
||||||
|
setData(d);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, applied]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const runSearch = () => {
|
||||||
|
setApplied({ ...draft });
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>转账单</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
主站 ↔ 彩票划转单;检索口径与 §5.11 一致(单号、主站流水号、玩家账号、状态、日期)。与{" "}
|
||||||
|
<code className="rounded bg-muted px-1">player_id</code> 同时填写时以 ID 为准。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-transfer-no">本地单号</Label>
|
||||||
|
<Input
|
||||||
|
id="to-transfer-no"
|
||||||
|
placeholder="模糊"
|
||||||
|
value={draft.transferNo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, transferNo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-ext">主站流水号</Label>
|
||||||
|
<Input
|
||||||
|
id="to-ext"
|
||||||
|
placeholder="模糊"
|
||||||
|
value={draft.externalRefNo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-account">玩家账号</Label>
|
||||||
|
<Input
|
||||||
|
id="to-account"
|
||||||
|
placeholder="site_player_id / 用户名"
|
||||||
|
value={draft.playerAccount}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-player">玩家 ID</Label>
|
||||||
|
<Input
|
||||||
|
id="to-player"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="可选,优先于账号"
|
||||||
|
value={draft.playerId}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-from">开始日期</Label>
|
||||||
|
<Input
|
||||||
|
id="to-from"
|
||||||
|
type="date"
|
||||||
|
value={draft.createdFrom}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-to">结束日期</Label>
|
||||||
|
<Input
|
||||||
|
id="to-to"
|
||||||
|
type="date"
|
||||||
|
value={draft.createdTo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="to-status">状态</Label>
|
||||||
|
<Input
|
||||||
|
id="to-status"
|
||||||
|
placeholder="逗号分隔,如 pending_reconcile,failed"
|
||||||
|
value={draft.statusCsv}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
|
||||||
|
<span className="text-sm font-medium leading-none">选项</span>
|
||||||
|
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.abnormalOnly}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
仅异常单
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
|
刷新当前页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
|
{loading && !data ? (
|
||||||
|
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-0 max-w-[14rem]">本地单号</TableHead>
|
||||||
|
<TableHead className="min-w-0 max-w-[12rem]">主站流水号</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">玩家</TableHead>
|
||||||
|
<TableHead className="w-14">方向</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">金额</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">状态</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">请求时间</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">完成时间</TableHead>
|
||||||
|
<TableHead className="w-24">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-muted-foreground">
|
||||||
|
无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
|
||||||
|
<CellMonoId value={row.transfer_no} copyHint="本地单号" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||||
|
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
#{row.player_id}
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.site_player_id ?? row.username ?? "—"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.direction}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">
|
||||||
|
{formatMinorUnits(row.amount, row.currency_code)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatTs(row.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatTs(row.finished_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">—</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
共 {data.total} 条 · 第 {data.page} /{" "}
|
||||||
|
{Math.max(1, Math.ceil(data.total / data.per_page))} 页
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WalletTxnsPanel(): React.ReactElement {
|
||||||
|
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
|
||||||
|
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const player_id =
|
||||||
|
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
||||||
|
const d = await getAdminWalletTransactions({
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
abnormal: applied.abnormalOnly || undefined,
|
||||||
|
player_id:
|
||||||
|
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
||||||
|
? player_id
|
||||||
|
: undefined,
|
||||||
|
player_account: applied.playerAccount.trim() || undefined,
|
||||||
|
txn_no: applied.txnNo.trim() || undefined,
|
||||||
|
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||||
|
created_from: applied.createdFrom.trim() || undefined,
|
||||||
|
created_to: applied.createdTo.trim() || undefined,
|
||||||
|
biz_type: applied.bizType.trim() || undefined,
|
||||||
|
status: applied.statusCsv.trim() || undefined,
|
||||||
|
});
|
||||||
|
setData(d);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, applied]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const runSearch = () => {
|
||||||
|
setApplied({ ...draft });
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>钱包流水</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
§5.11 列表:流水号、主站流水号、玩家、类型、金额、状态、请求时间、完成时间、操作。「仅异常」对应{" "}
|
||||||
|
<code className="rounded bg-muted px-1">pending_reconcile</code>。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-no">流水号</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-no"
|
||||||
|
placeholder="模糊"
|
||||||
|
value={draft.txnNo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-ext">主站流水号</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-ext"
|
||||||
|
placeholder="模糊"
|
||||||
|
value={draft.externalRefNo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-account">玩家账号</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-account"
|
||||||
|
placeholder="site_player_id / 用户名"
|
||||||
|
value={draft.playerAccount}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-player">玩家 ID</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-player"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="可选,优先于账号"
|
||||||
|
value={draft.playerId}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-biz">类型(业务)</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-biz"
|
||||||
|
placeholder="如 transfer_in"
|
||||||
|
value={draft.bizType}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, bizType: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-status">状态</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-status"
|
||||||
|
placeholder="posted 或逗号分隔"
|
||||||
|
value={draft.statusCsv}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-from">开始日期</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-from"
|
||||||
|
type="date"
|
||||||
|
value={draft.createdFrom}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="tx-to">结束日期</Label>
|
||||||
|
<Input
|
||||||
|
id="tx-to"
|
||||||
|
type="date"
|
||||||
|
value={draft.createdTo}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
|
||||||
|
<span className="text-sm font-medium leading-none">选项</span>
|
||||||
|
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.abnormalOnly}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
仅异常(待对账)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
|
刷新当前页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
|
{loading && !data ? (
|
||||||
|
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-0 max-w-[14rem]">流水号</TableHead>
|
||||||
|
<TableHead className="min-w-0 max-w-[12rem]">主站流水号</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">玩家</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">类型</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">金额</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">状态</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">请求时间</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">完成时间</TableHead>
|
||||||
|
<TableHead className="w-24">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-muted-foreground">
|
||||||
|
无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
|
||||||
|
<CellMonoId value={row.txn_no} copyHint="流水号" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||||
|
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-0 text-xs">
|
||||||
|
#{row.player_id}
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.site_player_id ?? row.username ?? "—"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
||||||
|
<TableCell className="tabular-nums text-xs">
|
||||||
|
{row.amount} ({row.direction === 1 ? "入" : "出"})
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatTs(row.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatTs(row.updated_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">—</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
共 {data.total} 条 · 第 {data.page} /{" "}
|
||||||
|
{Math.max(1, Math.ceil(data.total / data.per_page))} 页
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerWalletPanel(): React.ReactElement {
|
||||||
|
const [playerId, setPlayerId] = useState("");
|
||||||
|
const [result, setResult] = useState<AdminPlayerWalletsData | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const query = useCallback(async () => {
|
||||||
|
const id = Number(playerId.trim());
|
||||||
|
if (Number.isNaN(id) || id < 1) {
|
||||||
|
setErr("请输入有效玩家 ID");
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const d = await getAdminPlayerWallets(id);
|
||||||
|
setResult(d);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof LotteryApiBizError ? e.message : "查询失败");
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [playerId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>玩家钱包查询</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
按本地玩家主键 <code className="rounded bg-muted px-1">players.id</code>{" "}
|
||||||
|
查询各币种余额。完整客服视图见界面文档 §5.12(待迭代)。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="pw-id">玩家 ID</Label>
|
||||||
|
<Input
|
||||||
|
id="pw-id"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="例如 1"
|
||||||
|
value={playerId}
|
||||||
|
onChange={(e) => setPlayerId(e.target.value)}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => void query()} disabled={loading}>
|
||||||
|
{loading ? "查询中…" : "查询"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
|
{result ? (
|
||||||
|
<div className="space-y-3 rounded-lg border p-4 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">站点玩家</span>{" "}
|
||||||
|
{result.player.site_code}:{result.player.site_player_id}
|
||||||
|
</p>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>币种</TableHead>
|
||||||
|
<TableHead>余额(最小单位)</TableHead>
|
||||||
|
<TableHead>可用(推算)</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.wallets.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground">
|
||||||
|
暂无钱包行(从未下过注或未划转也可能无记录)
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
result.wallets.map((w) => (
|
||||||
|
<TableRow key={w.id}>
|
||||||
|
<TableCell>{w.wallet_type}</TableCell>
|
||||||
|
<TableCell>{w.currency_code}</TableCell>
|
||||||
|
<TableCell className="font-mono tabular-nums">{w.balance}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">
|
||||||
|
{formatMinorUnits(w.available_balance, w.currency_code)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/types/api/admin-wallet.ts
Normal file
84
src/types/api/admin-wallet.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/** GET /api/v1/admin/wallet/transfer-orders */
|
||||||
|
export type AdminTransferOrderItem = {
|
||||||
|
id: number;
|
||||||
|
transfer_no: string;
|
||||||
|
player_id: number;
|
||||||
|
site_code: string | null;
|
||||||
|
site_player_id: string | null;
|
||||||
|
username: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
direction: string;
|
||||||
|
currency_code: string;
|
||||||
|
amount: number;
|
||||||
|
idempotent_key: string;
|
||||||
|
status: string;
|
||||||
|
external_ref_no: string | null;
|
||||||
|
external_request_payload: Record<string, unknown> | null;
|
||||||
|
external_response_payload: Record<string, unknown> | null;
|
||||||
|
fail_reason: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminTransferOrderListData = {
|
||||||
|
items: AdminTransferOrderItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/wallet/transactions */
|
||||||
|
export type AdminWalletTxnItem = {
|
||||||
|
id: number;
|
||||||
|
txn_no: string;
|
||||||
|
player_id: number;
|
||||||
|
site_code: string | null;
|
||||||
|
site_player_id: string | null;
|
||||||
|
username: string | null;
|
||||||
|
wallet_id: number;
|
||||||
|
biz_type: string;
|
||||||
|
biz_no: string;
|
||||||
|
direction: number;
|
||||||
|
amount: number;
|
||||||
|
balance_before: number;
|
||||||
|
balance_after: number;
|
||||||
|
status: string;
|
||||||
|
external_ref_no: string | null;
|
||||||
|
idempotent_key: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
/** 界面「完成时间」展示(表 `updated_at`) */
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminWalletTxnListData = {
|
||||||
|
items: AdminWalletTxnItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/players/{player}/wallets */
|
||||||
|
export type AdminPlayerWalletRow = {
|
||||||
|
id: number;
|
||||||
|
wallet_type: string;
|
||||||
|
currency_code: string;
|
||||||
|
balance: number;
|
||||||
|
frozen_balance: number;
|
||||||
|
available_balance: number;
|
||||||
|
status: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPlayerWalletsData = {
|
||||||
|
player: {
|
||||||
|
id: number;
|
||||||
|
site_code: string;
|
||||||
|
site_player_id: string;
|
||||||
|
username: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
default_currency: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
wallets: AdminPlayerWalletRow[];
|
||||||
|
};
|
||||||
@@ -5,3 +5,11 @@ export type {
|
|||||||
AdminProfile,
|
AdminProfile,
|
||||||
} from "./admin-auth";
|
} from "./admin-auth";
|
||||||
export type { AdminPingResponse } from "./admin-ping";
|
export type { AdminPingResponse } from "./admin-ping";
|
||||||
|
export type {
|
||||||
|
AdminPlayerWalletsData,
|
||||||
|
AdminPlayerWalletRow,
|
||||||
|
AdminTransferOrderItem,
|
||||||
|
AdminTransferOrderListData,
|
||||||
|
AdminWalletTxnItem,
|
||||||
|
AdminWalletTxnListData,
|
||||||
|
} from "./admin-wallet";
|
||||||
|
|||||||
Reference in New Issue
Block a user