From 4ace3151e696961cd176edd3b82705a519d4eb8f Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 9 May 2026 15:22:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=92=B1=E5=8C=85=E7=9B=B8=E5=85=B3API=E5=92=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A8=A1=E5=9D=97=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin-wallet.ts | 65 ++ src/api/index.ts | 5 + src/app/admin/(shell)/draws/page.tsx | 5 +- src/app/admin/(shell)/page.tsx | 6 +- src/app/admin/(shell)/players/page.tsx | 5 +- src/app/admin/(shell)/risk/page.tsx | 5 +- src/app/admin/(shell)/settings/page.tsx | 5 +- src/app/admin/(shell)/tickets/page.tsx | 5 +- src/app/admin/(shell)/wallet/page.tsx | 14 +- src/components/admin/module-scaffold.tsx | 24 +- src/modules/wallet/meta.ts | 4 +- src/modules/wallet/wallet-console.tsx | 754 +++++++++++++++++++++++ src/types/api/admin-wallet.ts | 84 +++ src/types/api/index.ts | 8 + 14 files changed, 930 insertions(+), 59 deletions(-) create mode 100644 src/api/admin-wallet.ts create mode 100644 src/modules/wallet/wallet-console.tsx create mode 100644 src/types/api/admin-wallet.ts diff --git a/src/api/admin-wallet.ts b/src/api/admin-wallet.ts new file mode 100644 index 0000000..0ab9fd5 --- /dev/null +++ b/src/api/admin-wallet.ts @@ -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 { + return adminRequest.get( + `${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 { + return adminRequest.get( + `${A}/wallet/transactions`, + { params: q }, + ); +} + +export async function getAdminPlayerWallets( + playerId: number, +): Promise { + return adminRequest.get( + `${A}/players/${playerId}/wallets`, + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index 543007c..6614b09 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,11 @@ export { API_V1_PREFIX } from "@/api/paths"; export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth"; export { getAdminPing } from "@/api/admin-ping"; +export { + getAdminPlayerWallets, + getAdminTransferOrders, + getAdminWalletTransactions, +} from "@/api/admin-wallet"; export type { AdminAuthCaptchaResponse, AdminAuthLoginRequest, diff --git a/src/app/admin/(shell)/draws/page.tsx b/src/app/admin/(shell)/draws/page.tsx index d0f8fb0..557567e 100644 --- a/src/app/admin/(shell)/draws/page.tsx +++ b/src/app/admin/(shell)/draws/page.tsx @@ -8,10 +8,7 @@ export const metadata: Metadata = { export default function AdminDrawsPage() { return ( - +

业务组件请放在{" "} diff --git a/src/app/admin/(shell)/page.tsx b/src/app/admin/(shell)/page.tsx index eac584e..d4ce2f1 100644 --- a/src/app/admin/(shell)/page.tsx +++ b/src/app/admin/(shell)/page.tsx @@ -1,6 +1,5 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { getAdminPing } from "@/api"; -import { dashboardModuleMeta } from "@/modules/dashboard/meta"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -12,10 +11,7 @@ export default async function AdminDashboardPage() { const apiReady = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== ""; return ( - +

diff --git a/src/app/admin/(shell)/players/page.tsx b/src/app/admin/(shell)/players/page.tsx index 3630bad..0b470d4 100644 --- a/src/app/admin/(shell)/players/page.tsx +++ b/src/app/admin/(shell)/players/page.tsx @@ -8,10 +8,7 @@ export const metadata: Metadata = { export default function AdminPlayersPage() { return ( - +

业务组件请放在{" "} diff --git a/src/app/admin/(shell)/risk/page.tsx b/src/app/admin/(shell)/risk/page.tsx index 9878e4d..b205da6 100644 --- a/src/app/admin/(shell)/risk/page.tsx +++ b/src/app/admin/(shell)/risk/page.tsx @@ -8,10 +8,7 @@ export const metadata: Metadata = { export default function AdminRiskPage() { return ( - +

业务组件请放在{" "} diff --git a/src/app/admin/(shell)/settings/page.tsx b/src/app/admin/(shell)/settings/page.tsx index f521912..5639aee 100644 --- a/src/app/admin/(shell)/settings/page.tsx +++ b/src/app/admin/(shell)/settings/page.tsx @@ -8,10 +8,7 @@ export const metadata: Metadata = { export default function AdminSettingsPage() { return ( - +

业务组件请放在{" "} diff --git a/src/app/admin/(shell)/tickets/page.tsx b/src/app/admin/(shell)/tickets/page.tsx index 76b6654..f2bcc7c 100644 --- a/src/app/admin/(shell)/tickets/page.tsx +++ b/src/app/admin/(shell)/tickets/page.tsx @@ -8,10 +8,7 @@ export const metadata: Metadata = { export default function AdminTicketsPage() { return ( - +

业务组件请放在{" "} diff --git a/src/app/admin/(shell)/wallet/page.tsx b/src/app/admin/(shell)/wallet/page.tsx index d3f4cbb..ec3d678 100644 --- a/src/app/admin/(shell)/wallet/page.tsx +++ b/src/app/admin/(shell)/wallet/page.tsx @@ -1,5 +1,6 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { walletModuleMeta } from "@/modules/wallet/meta"; +import { WalletConsole } from "@/modules/wallet/wallet-console"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -8,17 +9,8 @@ export const metadata: Metadata = { export default function AdminWalletPage() { return ( - -

- 业务组件请放在{" "} - - src/modules/wallet - {" "} - 下。 -

+ + ); } diff --git a/src/components/admin/module-scaffold.tsx b/src/components/admin/module-scaffold.tsx index 1ea9da9..a433dd5 100644 --- a/src/components/admin/module-scaffold.tsx +++ b/src/components/admin/module-scaffold.tsx @@ -3,29 +3,11 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; type ModuleScaffoldProps = { - title: string; - description: string; children?: ReactNode; className?: string; }; -export function ModuleScaffold({ - title, - description, - children, - className, -}: ModuleScaffoldProps) { - return ( -
-
-

- {title} -

-

- {description} -

-
- {children !== undefined ?
{children}
: null} -
- ); +/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */ +export function ModuleScaffold({ children, className }: ModuleScaffoldProps) { + return
{children}
; } diff --git a/src/modules/wallet/meta.ts b/src/modules/wallet/meta.ts index e1aca22..1ba10e2 100644 --- a/src/modules/wallet/meta.ts +++ b/src/modules/wallet/meta.ts @@ -1,5 +1,5 @@ export const walletModuleMeta = { segment: "wallet", - title: "钱包", - description: "资金流水、充值提现审核(占位)。", + title: "钱包流水与对账", + description:"" } as const; diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx new file mode 100644 index 0000000..357baad --- /dev/null +++ b/src/modules/wallet/wallet-console.tsx @@ -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 {empty}; + } + + const copy = async (e: React.MouseEvent): Promise => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(value); + toast.success( + copyHint + ? `${copyHint}已复制到剪贴板` + : "已复制到剪贴板", + ); + } catch { + toast.error("复制失败,请检查浏览器权限或手动选择文本"); + } + }; + + return ( + + ); +} + +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 ( + + + 钱包流水 + 转账单 + 玩家钱包查询 + + + + + + + + + + + + ); +} + +function TransferOrdersPanel(): React.ReactElement { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [page, setPage] = useState(1); + const [draft, setDraft] = useState(emptyTransferFilters); + const [applied, setApplied] = useState(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 ( + + + 转账单 + + 主站 ↔ 彩票划转单;检索口径与 §5.11 一致(单号、主站流水号、玩家账号、状态、日期)。与{" "} + player_id 同时填写时以 ID 为准。 + + + +
+
+ + setDraft((d) => ({ ...d, transferNo: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, externalRefNo: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, playerAccount: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, playerId: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, createdFrom: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, createdTo: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, statusCsv: e.target.value }))} + /> +
+
+ 选项 + +
+
+
+ + +
+ + {err ?

{err}

: null} + {loading && !data ? ( +

加载中…

+ ) : null} + + {data ? ( + <> +
+ + + + 本地单号 + 主站流水号 + 玩家 + 方向 + 金额 + 状态 + 请求时间 + 完成时间 + 操作 + + + + {data.items.length === 0 ? ( + + + 无数据 + + + ) : ( + data.items.map((row) => ( + + + + + + + + + #{row.player_id} +
+ + {row.site_player_id ?? row.username ?? "—"} + +
+ {row.direction} + + {formatMinorUnits(row.amount, row.currency_code)} + + + {row.status} + + + {formatTs(row.created_at)} + + + {formatTs(row.finished_at)} + + +
+ )) + )} +
+
+
+
+

+ 共 {data.total} 条 · 第 {data.page} /{" "} + {Math.max(1, Math.ceil(data.total / data.per_page))} 页 +

+
+ + +
+
+ + ) : null} +
+
+ ); +} + +function WalletTxnsPanel(): React.ReactElement { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [page, setPage] = useState(1); + const [draft, setDraft] = useState(emptyTxnFilters); + const [applied, setApplied] = useState(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 ( + + + 钱包流水 + + §5.11 列表:流水号、主站流水号、玩家、类型、金额、状态、请求时间、完成时间、操作。「仅异常」对应{" "} + pending_reconcile。 + + + +
+
+ + setDraft((d) => ({ ...d, txnNo: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, externalRefNo: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, playerAccount: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, playerId: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, bizType: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, statusCsv: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, createdFrom: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, createdTo: e.target.value }))} + /> +
+
+ 选项 + +
+
+
+ + +
+ + {err ?

{err}

: null} + {loading && !data ? ( +

加载中…

+ ) : null} + + {data ? ( + <> +
+ + + + 流水号 + 主站流水号 + 玩家 + 类型 + 金额 + 状态 + 请求时间 + 完成时间 + 操作 + + + + {data.items.length === 0 ? ( + + + 无数据 + + + ) : ( + data.items.map((row) => ( + + + + + + + + + #{row.player_id} +
+ + {row.site_player_id ?? row.username ?? "—"} + +
+ {row.biz_type} + + {row.amount} ({row.direction === 1 ? "入" : "出"}) + + + {row.status} + + + {formatTs(row.created_at)} + + + {formatTs(row.updated_at)} + + +
+ )) + )} +
+
+
+
+

+ 共 {data.total} 条 · 第 {data.page} /{" "} + {Math.max(1, Math.ceil(data.total / data.per_page))} 页 +

+
+ + +
+
+ + ) : null} +
+
+ ); +} + +function PlayerWalletPanel(): React.ReactElement { + const [playerId, setPlayerId] = useState(""); + const [result, setResult] = useState(null); + const [err, setErr] = useState(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 ( + + + 玩家钱包查询 + + 按本地玩家主键 players.id{" "} + 查询各币种余额。完整客服视图见界面文档 §5.12(待迭代)。 + + + +
+
+ + setPlayerId(e.target.value)} + className="w-40" + /> +
+ +
+ {err ?

{err}

: null} + {result ? ( +
+

+ 站点玩家{" "} + {result.player.site_code}:{result.player.site_player_id} +

+ + + + 类型 + 币种 + 余额(最小单位) + 可用(推算) + + + + {result.wallets.length === 0 ? ( + + + 暂无钱包行(从未下过注或未划转也可能无记录) + + + ) : ( + result.wallets.map((w) => ( + + {w.wallet_type} + {w.currency_code} + {w.balance} + + {formatMinorUnits(w.available_balance, w.currency_code)} + + + )) + )} + +
+
+ ) : null} +
+
+ ); +} diff --git a/src/types/api/admin-wallet.ts b/src/types/api/admin-wallet.ts new file mode 100644 index 0000000..6515dc8 --- /dev/null +++ b/src/types/api/admin-wallet.ts @@ -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 | null; + external_response_payload: Record | 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[]; +}; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index e6463ac..7642032 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -5,3 +5,11 @@ export type { AdminProfile, } from "./admin-auth"; export type { AdminPingResponse } from "./admin-ping"; +export type { + AdminPlayerWalletsData, + AdminPlayerWalletRow, + AdminTransferOrderItem, + AdminTransferOrderListData, + AdminWalletTxnItem, + AdminWalletTxnListData, +} from "./admin-wallet";