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

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

View File

@@ -1,5 +1,5 @@
export const playersModuleMeta = {
segment: "players",
title: "玩家",
title: "玩家列表",
description: "",
} as const;

View File

@@ -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<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 (
<div className="flex w-full max-w-none flex-col gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<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>
<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>
<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>
);
}