Files
lotteryAdmin/src/modules/tickets/player-tickets-console.tsx
kang a4e7a2d228 feat(dashboard, i18n): 增强玩家身份信息展示并完善多语言支持
更新仪表盘相关组件,采用新的玩家身份信息字段(Player Identity Columns),提升数据展示的清晰度与可读性。
优化奖池记录(Jackpot Records)中的玩家信息展示方式,便于快速识别玩家身份。
改进结算明细(Settlement Details)页面的玩家身份展示,提升数据追踪与核对效率。
更新玩家注单(Player Tickets)与钱包交易(Wallet Transactions)相关界面,统一使用新的玩家身份信息展示逻辑。
在英文、尼泊尔语与中文语言包中新增玩家相关术语翻译,增强多语言支持。
提升系统整体用户体验,确保各模块中的玩家信息展示更加一致、直观。
2026-06-01 17:25:22 +08:00

435 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { getAdminTicketItems } from "@/api/admin-tickets";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
import { ChevronDown } from "lucide-react";
/** 与玩家端、注项表 status 字段对齐(不含无效的 success */
const TICKET_STATUS_OPTIONS = [
"pending_confirm",
"partial_pending_confirm",
"placed",
"pending_draw",
"partial_failed",
"failed",
"pending_payout",
"settled_win",
"settled_lose",
"refunded",
] as const;
type TicketFilters = {
siteCode: string;
playerQuery: string;
drawNo: string;
numberKeyword: string;
startDate: string;
endDate: string;
statuses: string[];
};
const emptyTicketFilters: TicketFilters = {
siteCode: "",
playerQuery: "",
drawNo: "",
numberKeyword: "",
startDate: "",
endDate: "",
statuses: [],
};
type TicketTranslateFn = (
key: string,
options?: { count?: number },
) => string;
function ticketStatusText(value: string, t: TicketTranslateFn): string {
const key = `statusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
if (statuses.length === 0) {
return t("statusOptions.all");
}
if (statuses.length === 1) {
return ticketStatusText(statuses[0], t);
}
return t("statusSelectedCount", { count: statuses.length });
}
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const playCodeLabel = useAdminPlayCodeLabel();
const exportLabels = useExportLabels("tickets");
const formatTs = useAdminDateTimeFormatter();
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
const [data, setData] = useState<AdminTicketItemsData | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const playerQuery = applied.playerQuery.trim();
const playerId = Number(playerQuery);
const query =
playerQuery === ""
? {}
: Number.isInteger(playerId) && playerId > 0 && String(playerId) === playerQuery
? { player_id: playerId }
: { player_account: playerQuery };
const d = await getAdminTicketItems({
page,
per_page: perPage,
...query,
site_code: applied.siteCode.trim() || undefined,
draw_no: applied.drawNo.trim() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
start_date: applied.startDate || undefined,
end_date: applied.endDate || undefined,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [applied, page, perPage, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const runSearch = () => {
setErr(null);
setApplied({
...draft,
siteCode: draft.siteCode.trim(),
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
numberKeyword: draft.numberKeyword.trim(),
});
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTicketFilters);
setApplied(emptyTicketFilters);
setErr(null);
setPage(1);
};
const toggleStatus = (status: string, checked: boolean) => {
setDraft((current) => ({
...current,
statuses: checked
? [...current.statuses, status]
: current.statuses.filter((item) => item !== status),
}));
};
return (
<Card className="admin-list-card w-full max-w-none">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
{canChooseSite ? (
<div className="admin-list-field">
<Label className="sm:shrink-0">{t("filterSite")}</Label>
<Select
value={draft.siteCode || "__all__"}
onValueChange={(v) =>
setDraft((current) => ({
...current,
siteCode: v === "__all__" ? "" : (v ?? ""),
}))
}
>
<SelectTrigger className="w-full sm:w-[12rem]">
<SelectValue>
{(v) => {
const value = String(v ?? "__all__");
if (value === "__all__") {
return t("filterAllSites");
}
const site = siteOptions.find((item) => item.code === value);
return site ? `${site.code}${site.name}` : value;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{siteOptions.map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.code} {site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-player" className="sm:shrink-0">
{t("playerId")}
</Label>
<Input
id="pt-player"
className="font-mono"
placeholder={t("playerIdPlaceholder")}
value={draft.playerQuery}
onChange={(e) =>
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
}
/>
</div>
<div className="admin-list-field">
<Label htmlFor="pt-draw" className="sm:shrink-0">
{t("drawNoOptional")}
</Label>
<Input
id="pt-draw"
className="font-mono text-sm sm:w-44"
placeholder={t("drawNoPlaceholder")}
value={draft.drawNo}
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
/>
</div>
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-number" className="sm:shrink-0">
{t("numberKeyword")}
</Label>
<Input
id="pt-number"
className="font-mono text-sm"
placeholder={t("numberKeywordPlaceholder")}
value={draft.numberKeyword}
onChange={(e) =>
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
}
/>
</div>
<div className="admin-list-field">
<Label htmlFor="pt-date-range" className="sm:shrink-0">
{t("placedDateRange")}
</Label>
<div className="min-w-0 w-full sm:w-56">
<AdminDateRangeField
id="pt-date-range"
from={draft.startDate}
to={draft.endDate}
onRangeChange={(range) =>
setDraft((current) => ({
...current,
startDate: range.from,
endDate: range.to,
}))
}
/>
</div>
</div>
<div className="admin-list-field">
<Label htmlFor="pt-status" className="sm:shrink-0">
{t("statusFilterLabel")}
</Label>
<DropdownMenu>
<DropdownMenuTrigger
id="pt-status"
title={t("statusHint")}
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:w-44"
>
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{TICKET_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={draft.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
>
{ticketStatusText(status, t)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="admin-list-actions">
<AdminTableExportButton
tableId="tickets-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("query")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
</div>
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
<p className="text-muted-foreground text-sm">
{applied.playerQuery ? (
<>
{t("playerId")}<span className="font-mono">{applied.playerQuery}</span>
</>
) : (
<span>{t("allTickets")}</span>
)}
{applied.drawNo ? (
<>
{" · "}
{t("drawNo")}<span className="font-mono">{applied.drawNo}</span>
</>
) : null}
</p>
) : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{loading ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
<>
<div className="admin-table-shell">
<Table id="tickets-table">
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead>
<TableHead className="text-center">{t("betAmount")}</TableHead>
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("failReason")}</TableHead>
<TableHead className="text-center">{t("winAmount")}</TableHead>
<TableHead>{t("placedAt")}</TableHead>
<TableHead>{t("updatedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={15} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => {
const winLabel = row.jackpot_win_amount > 0
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
: row.win_amount_formatted;
return (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<AdminPlayerIdentityCells row={row} />
<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>
<TableCell className="text-center tabular-nums text-xs">
{row.total_bet_amount_formatted}
</TableCell>
<TableCell className="text-center tabular-nums text-xs">
{row.actual_deduct_amount_formatted}
</TableCell>
<TableCell className="text-xs">
<AdminStatusBadge status={row.status}>
{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 tabular-nums text-xs">{winLabel}</TableCell>
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="player-tickets-per-page"
total={data.total}
page={data.page}
lastPage={Math.max(1, data.last_page)}
perPage={data.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>
</Card>
);
}