From 2dfffd1fd1f5086eded7b934f84655becbde9137 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 10:42:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=8E=A9=E5=AE=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E8=BD=AC=E8=B4=A6=E5=8D=95=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重构玩家模块导航与元信息,将原玩家查询改为玩家列表 2. 新增完整的玩家CRUD API与前端管理页面,支持搜索、新建、编辑、删除玩家 3. 为转账单新增冲正与人工处理操作,补充状态显示与对应枚举 4. 优化用户列表表格空值展示与样式细节 5. 调整钱包子导航,移除旧的玩家钱包入口 --- src/api/admin-player.ts | 41 ++ src/api/admin-wallet.ts | 25 + src/modules/_config/admin-nav.ts | 48 +- .../admin-users/admin-users-console.tsx | 4 +- src/modules/players/meta.ts | 2 +- src/modules/players/players-console.tsx | 533 +++++++++++++++++- src/modules/wallet/wallet-console.tsx | 90 ++- src/modules/wallet/wallet-subnav.tsx | 7 - src/types/api/admin-player.ts | 50 ++ 9 files changed, 755 insertions(+), 45 deletions(-) create mode 100644 src/api/admin-player.ts create mode 100644 src/types/api/admin-player.ts diff --git a/src/api/admin-player.ts b/src/api/admin-player.ts new file mode 100644 index 0000000..2fc828a --- /dev/null +++ b/src/api/admin-player.ts @@ -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 { + return adminRequest.get(`${A}/players`, { params }); +} + +export async function getAdminPlayer(playerId: number): Promise { + return adminRequest.get(`${A}/players/${playerId}`); +} + +export async function postAdminPlayer(body: AdminPlayerCreatePayload): Promise { + return adminRequest.post(`${A}/players`, body); +} + +export async function putAdminPlayer( + playerId: number, + body: AdminPlayerUpdatePayload, +): Promise { + return adminRequest.put(`${A}/players/${playerId}`, body); +} + +export async function deleteAdminPlayer(playerId: number): Promise { + return adminRequest.delete(`${A}/players/${playerId}`); +} diff --git a/src/api/admin-wallet.ts b/src/api/admin-wallet.ts index ef7d878..df458ee 100644 --- a/src/api/admin-wallet.ts +++ b/src/api/admin-wallet.ts @@ -64,3 +64,28 @@ export async function getAdminPlayerWallets( `${A}/players/${playerId}/wallets`, ); } + +export type TransferOrderActionResult = { + transfer_no: string; + status: string; +}; + +export async function reverseTransferOrder( + transferNo: string, + remark?: string, +): Promise { + return adminRequest.post( + `${A}/wallet/transfer-orders/${transferNo}/reverse`, + remark ? { remark } : {}, + ); +} + +export async function manuallyProcessTransferOrder( + transferNo: string, + remark?: string, +): Promise { + return adminRequest.post( + `${A}/wallet/transfer-orders/${transferNo}/manually-process`, + remark ? { remark } : {}, + ); +} diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 3ad8941..5649067 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -36,6 +36,30 @@ export const adminShellNavItems: AdminNavItem[] = [ href: "/admin/admin-users", 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", label: "开奖", @@ -80,20 +104,6 @@ export const adminShellNavItems: AdminNavItem[] = [ activeMatchPrefix: "/admin/jackpot", 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", label: "对账", @@ -120,16 +130,6 @@ export const adminShellNavItems: AdminNavItem[] = [ "prd.report.player", ], }, - { - segment: "players", - label: "玩家查询", - href: "/admin/players", - requiredAny: [ - "prd.users.manage", - "prd.users.view_finance", - "prd.users.view_cs", - ], - }, { segment: "reports", label: "报表导出", diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index 789cb68..9b98709 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -432,10 +432,10 @@ export function AdminUsersConsole(): React.ReactElement {
{row.username} - {row.email ?? "—"} + {row.email ?? ""}
- {row.nickname} + {row.nickname ?? ""} {row.status === 0 ? ( diff --git a/src/modules/players/meta.ts b/src/modules/players/meta.ts index da0e220..7cb6893 100644 --- a/src/modules/players/meta.ts +++ b/src/modules/players/meta.ts @@ -1,5 +1,5 @@ export const playersModuleMeta = { segment: "players", - title: "玩家", + title: "玩家列表", description: "", } as const; diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index 12e8a33..06260e5 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -1,18 +1,541 @@ "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 { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + const [keyword, setKeyword] = useState(""); + const [query, setQuery] = useState(""); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [lastPage, setLastPage] = useState(1); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const [accountOpen, setAccountOpen] = useState(false); + const [accountMode, setAccountMode] = useState<"create" | "edit">("create"); + const [accountSaving, setAccountSaving] = useState(false); + const [editingAccountId, setEditingAccountId] = useState(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(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 { + 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[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 { + 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 (
- - 玩家查询 + +
+ 玩家列表 + +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setPage(1); + setQuery(keyword.trim()); + } + }} + /> + + +
+ + {err ?

{err}

: null} + {loading && items.length === 0 ? ( +

加载中…

+ ) : null} +
+ + + + ID + 主站 + 主站玩家ID + 用户名 + 昵称 + 币种 + 余额 + 可用 + 状态 + 最后登录 + 操作 + + + + {items.length === 0 && !loading ? ( + + + 暂无数据 + + + ) : ( + items.map((row) => ( + + #{row.id} + + {row.site_code} + + + {row.site_player_id} + + {row.username ?? "—"} + {row.nickname ?? "—"} + {row.default_currency} + + {row.wallets.length > 0 + ? formatMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code) + : "—"} + + + {row.wallets.length > 0 + ? formatMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code) + : "—"} + + + + {playerStatusLabel(row.status)} + + + + {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", + }) + : "—"} + + +
+ + +
+
+
+ )) + )} +
+
+
+ { + setPerPage(n); + setPage(1); + }} + onPageChange={setPage} + /> +
- + + + + + {accountMode === "create" ? "新建玩家" : "编辑玩家"} + + {accountMode === "create" + ? "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。" + : "编辑玩家信息。"} + + +
+ {accountMode === "create" && ( + <> +
+ + setFormSiteCode(e.target.value)} + /> +
+
+ + setFormSitePlayerId(e.target.value)} + /> +
+ + )} +
+ + setFormUsername(e.target.value)} + /> +
+
+ + setFormNickname(e.target.value)} + /> +
+ {accountMode === "create" && ( + <> +
+ + setFormDefaultCurrency(e.target.value.toUpperCase())} + /> +
+
+ + +
+ + )} + {accountMode === "edit" && ( +
+ + +
+ )} +
+
+ + +
+
+
+ + !open && setDeleteTarget(null)}> + + + 确认删除 + + 确定要删除玩家{" "} + {deleteTarget ? ( + + {deleteTarget.username ?? deleteTarget.site_player_id} + + ) : null}{" "} + 吗?此操作不可恢复。 + + +
+ + +
+
+
); } diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index d0ec000..92053e3 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -8,6 +8,8 @@ import { getAdminPlayerWallets, getAdminTransferOrders, getAdminWalletTransactions, + reverseTransferOrder, + manuallyProcessTransferOrder, } from "@/api/admin-wallet"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; @@ -97,9 +99,31 @@ function statusBadgeVariant( if (status === "success" || status === "posted") return "secondary"; if (status === "failed") return "destructive"; if (status === "pending_reconcile") return "outline"; + if (status === "reversed" || status === "manually_processed") return "outline"; return "default"; } +function statusLabel(status: string): string { + switch (status) { + case "processing": + return "处理中"; + case "success": + return "成功"; + case "failed": + return "失败"; + case "pending_reconcile": + return "待对账"; + case "reversed": + return "已冲正"; + case "manually_processed": + return "已人工处理"; + case "posted": + return "已记账"; + default: + return status; + } +} + type TransferFilters = { playerId: string; playerAccount: string; @@ -160,6 +184,7 @@ const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [ const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [ { value: "posted", label: "已记账" }, { value: "pending_reconcile", label: "待对账" }, + { value: "reversed", label: "已冲正" }, ]; /** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */ @@ -168,6 +193,8 @@ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [ { value: "success", label: "成功" }, { value: "failed", label: "失败" }, { value: "pending_reconcile", label: "待对账" }, + { value: "reversed", label: "已冲正" }, + { value: "manually_processed", label: "已人工处理" }, ]; /** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */ @@ -191,6 +218,34 @@ export function TransferOrdersPanel(): React.ReactElement { const [perPage, setPerPage] = useState(20); const [draft, setDraft] = useState(emptyTransferFilters); const [applied, setApplied] = useState(emptyTransferFilters); + const [actionLoading, setActionLoading] = useState>(new Set()); + + const doAction = async ( + transferNo: string, + fn: () => Promise, + successMsg: string, + ) => { + setActionLoading((prev) => new Set(prev).add(transferNo)); + try { + await fn(); + toast.success(successMsg); + void load(); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败"); + } finally { + setActionLoading((prev) => { + const next = new Set(prev); + next.delete(transferNo); + return next; + }); + } + }; + + const handleReverse = (transferNo: string) => + doAction(transferNo, () => reverseTransferOrder(transferNo), "冲正成功"); + + const handleManuallyProcess = (transferNo: string) => + doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), "人工处理成功"); const load = useCallback(async () => { setLoading(true); @@ -405,7 +460,7 @@ export function TransferOrdersPanel(): React.ReactElement { {formatMinorUnits(row.amount, row.currency_code)}
- {row.status} + {statusLabel(row.status)} {row.fail_reason?.trim() ? row.fail_reason : "—"} @@ -416,7 +471,32 @@ export function TransferOrdersPanel(): React.ReactElement { {formatTs(row.finished_at)} - + + {row.status === "pending_reconcile" ? ( +
+ + +
+ ) : ( + + )} +
)) )} @@ -666,13 +746,12 @@ export function WalletTxnsPanel(): React.ReactElement { 完成时间 - 操作 {data.items.length === 0 ? ( - + 无数据 @@ -697,7 +776,7 @@ export function WalletTxnsPanel(): React.ReactElement { {row.amount} ({row.direction === 1 ? "入" : "出"})
- {row.status} + {statusLabel(row.status)} {formatTs(row.created_at)} @@ -705,7 +784,6 @@ export function WalletTxnsPanel(): React.ReactElement { {formatTs(row.updated_at)} - )) )} diff --git a/src/modules/wallet/wallet-subnav.tsx b/src/modules/wallet/wallet-subnav.tsx index 6ce6818..b8bca93 100644 --- a/src/modules/wallet/wallet-subnav.tsx +++ b/src/modules/wallet/wallet-subnav.tsx @@ -13,16 +13,9 @@ const RECONCILE_PERMS = [ "prd.wallet_reconcile.view_cs", ] as const; -const USER_PERMS = [ - "prd.users.manage", - "prd.users.view_finance", - "prd.users.view_cs", -] as const; - const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [ { href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS }, { href: "/admin/wallet/transfer-orders", label: "转账单", requiredAny: RECONCILE_PERMS }, - { href: "/admin/wallet/player", label: "玩家钱包", requiredAny: USER_PERMS }, ]; export function WalletSubnav(): React.ReactElement { diff --git a/src/types/api/admin-player.ts b/src/types/api/admin-player.ts new file mode 100644 index 0000000..34da04a --- /dev/null +++ b/src/types/api/admin-player.ts @@ -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; +};