feat(admin, i18n): enhance reports, draws, config, and player workflows

This commit is contained in:
2026-06-08 17:41:55 +08:00
parent af982bb9f7
commit 7e65c53732
55 changed files with 1986 additions and 804 deletions

View File

@@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button";
@@ -42,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
import { Eye } from "lucide-react";
function playerStatusLabel(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
@@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<ProfileField label={t("authSource")}>
{playerAuthSourceLabel(player, t)}
</ProfileField>
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
</ProfileField>
<ProfileField label={t("status")}>
<PlayerStatusBadge status={player.status} t={t} />
</ProfileField>
@@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TabsContent value="tickets" className="mt-0">
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
<div className="space-y-1">
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
</div>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
<TableHead>{t("failReason", { ns: "tickets" })}</TableHead>
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("table.actions", { ns: "common" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ticketsLoading && tickets.length === 0 ? (
<AdminTableLoadingRow colSpan={7} />
<AdminTableLoadingRow colSpan={11} />
) : null}
{tickets.map((row) => (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
@@ -442,13 +458,36 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{ticketStatusText(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.jackpot_win_amount_minor > 0
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
: row.win_amount_formatted}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.placed_at ? formatDt(row.placed_at) : "—"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.updated_at ? formatDt(row.updated_at) : "—"}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view-ticket-in-list",
label: t("viewTicketInList", { ns: "tickets" }),
icon: Eye,
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
},
]}
/>
</TableCell>
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>

View File

@@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [
{ value: 2, label: "statusBanned" },
];
function parseRiskTagsInput(text: string): string[] {
return Array.from(
new Set(
text
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
);
}
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const tRef = useTranslationRef(["players", "common"]);
@@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement {
const [formNickname, setFormNickname] = useState("");
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
const [formStatus, setFormStatus] = useState(0);
const [formRiskTags, setFormRiskTags] = useState("");
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
const [createAgentLoading, setCreateAgentLoading] = useState(false);
@@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement {
setFormNickname("");
setFormDefaultCurrency("NPR");
setFormStatus(0);
setFormRiskTags("");
setAccountOpen(true);
}
@@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement {
setFormNickname(row.nickname ?? "");
setFormDefaultCurrency(row.default_currency);
setFormStatus(row.status);
setFormRiskTags((row.risk_tags ?? []).join(", "));
setAccountOpen(true);
}
@@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement {
if (formStatus !== editingPlayer?.status) {
body.status = formStatus;
}
const nextRiskTags = parseRiskTagsInput(formRiskTags);
const prevRiskTags = editingPlayer?.risk_tags ?? [];
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
body.risk_tags = nextRiskTags;
}
if (Object.keys(body).length === 0) {
toast.success(t("noChanges"));
@@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("riskTags", { defaultValue: "风控标签" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
@@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={13} />
<AdminTableLoadingRow colSpan={14} />
) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
<AdminTableNoResourceRow colSpan={14} className="text-muted-foreground" />
) : (
items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits);
const riskTags = row.risk_tags ?? [];
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums">#{row.id}</TableCell>
@@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement {
</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell className="max-w-[16rem]">
{riskTags.length > 0 ? (
<div className="flex flex-nowrap items-center gap-1 overflow-x-auto whitespace-nowrap" title={riskTags.join(", ")}>
{riskTags.map((tag) => (
<span
key={`${row.id}-${tag}`}
className="inline-flex shrink-0 items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>{row.default_currency}</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
@@ -791,24 +828,61 @@ export function PlayersConsole(): React.ReactElement {
</>
)}
{accountMode === "edit" && (
<div className="space-y-1.5">
<Label htmlFor="player-edit-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
>
<SelectTrigger id="player-edit-status">
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<>
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">
{t("fundingMode", { defaultValue: "资金模式" })}
</p>
<p className="mt-1 font-medium">
{editingPlayer ? <PlayerFundingModeBadge row={editingPlayer} /> : "—"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("authSource", { defaultValue: "登录来源" })}
</p>
<p className="mt-1 font-medium">
{editingPlayer?.auth_source === "main_site_sso"
? t("authMainSite", { defaultValue: "主站 SSO" })
: editingPlayer?.auth_source === "lottery_native"
? t("authNative", { defaultValue: "彩票端" })
: editingPlayer?.auth_source ?? "—"}
</p>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-edit-risk-tags">
{t("riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id="player-edit-risk-tags"
value={formRiskTags}
placeholder={t("riskTagsPlaceholder", { defaultValue: "如:高频、大额、需复核;多个标签用逗号分隔" })}
onChange={(e) => setFormRiskTags(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-edit-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
>
<SelectTrigger id="player-edit-status">
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2">