feat(dashboard, i18n): 增强玩家身份信息展示并完善多语言支持

更新仪表盘相关组件,采用新的玩家身份信息字段(Player Identity Columns),提升数据展示的清晰度与可读性。
优化奖池记录(Jackpot Records)中的玩家信息展示方式,便于快速识别玩家身份。
改进结算明细(Settlement Details)页面的玩家身份展示,提升数据追踪与核对效率。
更新玩家注单(Player Tickets)与钱包交易(Wallet Transactions)相关界面,统一使用新的玩家身份信息展示逻辑。
在英文、尼泊尔语与中文语言包中新增玩家相关术语翻译,增强多语言支持。
提升系统整体用户体验,确保各模块中的玩家信息展示更加一致、直观。
This commit is contained in:
2026-06-01 17:25:22 +08:00
parent 2716591164
commit a4e7a2d228
18 changed files with 310 additions and 98 deletions

View File

@@ -41,8 +41,8 @@ import {
CapUsageBar,
FinanceStructureChart,
HotUsageBars,
PayoutPanelSnapshot,
ResultBatchQueueSummary,
PlatformLifetimePayoutSnapshot,
DashboardPanelCard,
SettlementStatusChart,
} from "@/modules/dashboard/dashboard-visuals";
@@ -55,6 +55,8 @@ import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardDrawPanel,
AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue,
} from "@/types/api/admin-dashboard";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
@@ -151,6 +153,10 @@ export function DashboardConsole(): ReactElement {
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
null,
);
const [lifetimeFinance, setLifetimeFinance] = useState<AdminDashboardLifetimeFinance | null>(
null,
);
const [platformRisk, setPlatformRisk] = useState<AdminDashboardPlatformRisk | null>(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
@@ -190,6 +196,8 @@ export function DashboardConsole(): ReactElement {
setCapabilities(null);
setDrawPanel(null);
setResultBatchQueue(null);
setLifetimeFinance(null);
setPlatformRisk(null);
setDrawId(null);
setRiskLocked(0);
setRiskCap(0);
@@ -208,9 +216,11 @@ export function DashboardConsole(): ReactElement {
if (d.finance != null) {
setFinance(d.finance);
}
setResultBatchQueue(d.result_batch_queue);
setLifetimeFinance(d.lifetime_finance);
setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
setResultBatchQueue(d.result_batch_queue);
}
if (d.risk != null) {
setRiskLocked(d.risk.locked_amount);
@@ -235,14 +245,16 @@ export function DashboardConsole(): ReactElement {
return () => window.clearTimeout(timer);
}, [load]);
const currency = finance?.currency_code ?? null;
const currency =
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
const platformLocked = platformRisk?.locked_amount ?? 0;
const platformCap = platformRisk?.cap_amount ?? 0;
const platformUsagePct = platformRisk?.usage_percent ?? 0;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
const pendingReviewTotal = resultBatchQueue?.pending_review_total ?? 0;
const currentDrawPending = drawPanel?.result_batch_counts.pending_review ?? 0;
const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
const showAnalytics = canFinance;
@@ -322,11 +334,7 @@ export function DashboardConsole(): ReactElement {
loading={loading}
>
{resultBatchQueue != null ? (
<ResultBatchQueueSummary
queue={resultBatchQueue}
currentDrawPending={currentDrawPending}
compact
/>
<ResultBatchQueueSummary queue={resultBatchQueue} compact />
) : null}
</DashboardPanelCard>
@@ -348,53 +356,62 @@ export function DashboardConsole(): ReactElement {
</DashboardPanelCard>
<DashboardPanelCard
href={drawScopedHref(drawId, "/risk/occupancy", "/admin/risk")}
href="/admin/risk"
title={t("riskCapUsage")}
value={`${usagePct.toFixed(1)}%`}
subtitle={t("lockedAndCap", {
locked: formatMoneyMinor(riskLocked, currency),
cap: formatMoneyMinor(riskCap, currency),
value={`${platformUsagePct.toFixed(1)}%`}
subtitle={t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})}
actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />}
accent={
usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"
platformUsagePct >= 90
? "destructive"
: platformUsagePct >= 70
? "primary"
: "muted"
}
loading={loading}
>
<CapUsageBar
locked={riskLocked}
cap={riskCap}
usagePct={usagePct}
formatMoney={formatMoneyMinor}
currency={currency}
compact
/>
{platformRisk != null ? (
<CapUsageBar
locked={platformLocked}
cap={platformCap}
usagePct={platformUsagePct}
formatMoney={formatMoneyMinor}
currency={currency}
compact
/>
) : null}
</DashboardPanelCard>
<DashboardPanelCard
href={drawScopedHref(drawId, "/finance")}
href="/admin/reports"
title={t("payoutComposition")}
value={
finance
? formatMoneyMinor(finance.total_payout_minor, currency)
lifetimeFinance
? formatMoneyMinor(lifetimeFinance.total_payout_minor, currency)
: "—"
}
subtitle={
finance
? t("orderAndTicket", {
orders: finance.order_count,
tickets: finance.ticket_item_count,
lifetimeFinance
? t("platformOrderAndTicket", {
orders: lifetimeFinance.order_count,
tickets: lifetimeFinance.ticket_item_count,
})
: t("states.noData", { ns: "common" })
}
actionLabel={t("detailsShort")}
actionLabel={t("actions.viewAll", { ns: "common" })}
icon={<Wallet className="size-5" aria-hidden />}
accent="primary"
loading={loading}
>
{finance ? (
<PayoutPanelSnapshot finance={finance} formatMoney={formatMoneyMinor} />
{lifetimeFinance ? (
<PlatformLifetimePayoutSnapshot
finance={lifetimeFinance}
formatMoney={formatMoneyMinor}
/>
) : null}
</DashboardPanelCard>
</div>

View File

@@ -41,6 +41,7 @@ import {
} from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type {
AdminDashboardDrawPanel,
@@ -976,15 +977,13 @@ export function ResultBatchProgress({
export function ResultBatchQueueSummary({
queue,
currentDrawPending,
compact = false,
}: {
queue: AdminDashboardResultBatchQueue;
currentDrawPending: number;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const { pending_review_total, pending_draw_count } = queue;
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
return (
<div className="grid grid-cols-3 gap-2 text-center">
@@ -999,27 +998,89 @@ export function ResultBatchQueueSummary({
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg bg-sky-500/8 px-2 py-2 ring-1 ring-sky-500/15">
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
<p
className={cn(
"font-bold tabular-nums text-sky-800 dark:text-sky-300",
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
compact ? "text-lg" : "text-2xl",
)}
>
{pending_draw_count}
{published_total}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPendingDraws")}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
{currentDrawPending}
{batch_total}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{pending_draw_count > 0
? t("batchPendingDrawsCount", { count: pending_draw_count })
: t("batchTotal")}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchCurrentDrawPending")}</p>
</div>
</div>
);
}
export function PlatformLifetimePayoutSnapshot({
finance,
formatMoney,
}: {
finance: AdminDashboardLifetimeFinance;
formatMoney: MoneyFormatter;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_minor;
const jackpot = finance.total_jackpot_minor;
const hasPayout = win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
}
const cells = [
{ key: "bet", label: t("platformBetTotal"), amount: bet, emphasize: bet > 0 },
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
] as const;
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
{cells.map((cell) => (
<div
key={cell.key}
className={cn(
"rounded-lg px-1.5 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
<p
className={cn(
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
>
{formatMoney(cell.amount, currency)}
</p>
</div>
))}
</div>
{!hasPayout ? (
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
{t("platformNoPayoutYet")}
</p>
) : null}
</div>
);
}
export function SettlementStatusChart({
finance,
}: {

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
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 { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
@@ -289,7 +290,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableHead className="w-16 whitespace-nowrap">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("drawNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("ticketNo")}</TableHead>
<TableHead className="max-w-[10rem] whitespace-nowrap">{t("player")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap text-right">{t("contributionAmount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("time")}</TableHead>
</TableRow>
@@ -297,7 +298,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableBody>
{(contribs?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -307,9 +308,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs" title={r.player_username ?? undefined}>
{r.player_username ?? "—"}
</TableCell>
<AdminPlayerIdentityCells row={r} />
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
</TableCell>

View File

@@ -14,6 +14,7 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -336,7 +337,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("player")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-center">{t("regularPayout")}</TableHead>
<TableHead className="text-center">{t("jackpot")}</TableHead>
@@ -347,9 +348,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs">
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
</TableCell>
<AdminPlayerIdentityCells row={r} />
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}

View File

@@ -8,6 +8,7 @@ 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";
@@ -354,7 +355,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
@@ -371,7 +372,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-muted-foreground">
<TableCell colSpan={15} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -380,22 +381,10 @@ export function PlayerTicketsConsole(): React.ReactElement {
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>
<TableCell className="text-xs">
<div className="flex flex-col leading-tight">
<span className="font-medium">
{row.nickname ?? row.username ?? "—"}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{row.site_code && row.site_player_id
? `${row.site_code} / ${row.site_player_id}`
: row.site_player_id ?? `#${row.player_id}`}
</span>
</div>
</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>

View File

@@ -15,6 +15,7 @@ import {
} from "@/api/admin-wallet";
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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -542,7 +543,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead className="w-14">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
@@ -555,7 +556,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell colSpan={12} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -568,13 +569,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<TableCell className="text-xs">
#{row.player_id}
<br />
<span className="text-muted-foreground">
{row.site_player_id ?? row.username ?? "—"}
</span>
</TableCell>
<AdminPlayerIdentityCells row={row} />
<TableCell>{row.direction}</TableCell>
<TableCell className="tabular-nums">
{formatAdminMinorUnits(row.amount, row.currency_code)}
@@ -852,7 +847,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
@@ -863,7 +858,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -876,13 +871,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<TableCell className="min-w-0 text-xs">
#{row.player_id}
<br />
<span className="text-muted-foreground">
{row.site_player_id ?? row.username ?? "—"}
</span>
</TableCell>
<AdminPlayerIdentityCells row={row} />
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? t("in") : t("out")})