feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support

Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

View File

@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
import {
DailyTrendChart,
PlayBreakdownChart,
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
);
}
export function DashboardAgentRankingCard({
analytics,
}: {
analytics: DashboardAnalyticsState;
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
rankingMetric,
loading,
topAgentRows,
currency,
formatMoney,
formatSignedMoney,
} = analytics;
if (!enabled) {
return null;
}
const metricValue = (row: (typeof topAgentRows)[number]): number => {
if (rankingMetric === "payout") {
return row.total_payout_minor;
}
if (rankingMetric === "profit") {
return row.approx_house_gross_minor;
}
return row.total_bet_minor;
};
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
const v = metricValue(row);
if (rankingMetric === "profit") {
return formatSignedMoney(v, currency);
}
return formatMoney(v, currency);
};
const barColor = (row: (typeof topAgentRows)[number]): string => {
if (rankingMetric === "bet") {
return DASHBOARD_CHART_COLORS.primary;
}
if (rankingMetric === "payout") {
return DASHBOARD_CHART_COLORS.rose;
}
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
};
return (
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
<p className="text-xs text-muted-foreground">
{t(`analytics.rankingMetrics.${rankingMetric}`)}
</p>
</CardHeader>
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
{loading ? (
<Skeleton className="h-[210px] w-full" />
) : topAgentRows.length > 0 ? (
<div className="space-y-1.5">
{topAgentRows.map((row, idx) => {
const v = metricValue(row);
const pct = (Math.abs(v) / maxAbs) * 100;
const color = barColor(row);
return (
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
#{idx + 1}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
</div>
</div>
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
{formatRowValue(row)}
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(2, pct)}%`,
backgroundColor: color,
opacity: 0.35,
}}
/>
</div>
</div>
);
})}
</div>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
)}
</CardContent>
</Card>
);
}
/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,

View File

@@ -20,14 +20,12 @@ import {
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { getAdminPlayTypes } from "@/api/admin-config";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
DashboardAnalyticsMain,
DashboardAgentRankingCard,
DashboardPlayRankingCard,
} from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { normalizeAdminLanguage } from "@/i18n";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import {
coerceAdminMinor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
const major = safeMinor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
return formatAdminMinorUnits(safeMinor, code, decimals);
}
}
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
const loadPlayOptions = useCallback(async () => {
try {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label:
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
})),
);
} catch {
setPlayOptions([]);
}
}, [i18n.language]);
useEffect(() => {
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["dashboard", "common"]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
setAbnormalTransferTotal(d.abnormal_transfer_total);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [t]);
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void load(false);
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load(false);
}, []);
const currency =
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const platformLocked = platformRisk?.locked_amount ?? 0;
const platformCap = platformRisk?.cap_amount ?? 0;
const platformUsagePct = platformRisk?.usage_percent ?? 0;
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
const rawPlatformUsagePct = platformRisk?.usage_percent;
const platformUsagePct =
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
? Math.min(100, Math.max(0, rawPlatformUsagePct))
: platformCap > 0
? (platformLocked / platformCap) * 100
: 0;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
href="/admin/risk"
title={t("riskCapUsage")}
value={`${platformUsagePct.toFixed(1)}%`}
subtitle={t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})}
subtitle={
platformCap > 0
? t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})
: t("platformCapNotConfigured", {
locked: formatMoneyMinor(platformLocked, currency),
})
}
actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />}
accent={
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
{showAnalytics ? (
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} />
<DashboardAgentRankingCard analytics={analytics} />
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">

View File

@@ -29,6 +29,11 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
coerceAdminMinor,
formatAdminMinorDecimal,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import {
buildBatchProgressConfig,
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
type MoneyFormatter = (minor: number, currency: string | null) => string;
type DashboardFinanceMetricCell = {
key: string;
label: string;
amount: number;
emphasize: boolean;
};
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
function formatDashboardMetricAmount(
minor: number,
currencyCode: string | null,
formatMoney: MoneyFormatter,
): { display: string; title: string } {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
return {
display: formatAdminMinorDecimal(safeMinor, code, decimals),
title: formatMoney(safeMinor, currencyCode),
};
}
function DashboardFinanceMetricCells({
cells,
currency,
formatMoney,
}: {
cells: readonly DashboardFinanceMetricCell[];
currency: string | null;
formatMoney: MoneyFormatter;
}): ReactElement {
return (
<div className="grid grid-cols-3 gap-1.5">
{cells.map((cell) => {
const { display, title } = formatDashboardMetricAmount(
cell.amount,
currency,
formatMoney,
);
return (
<div
key={cell.key}
className={cn(
"min-w-0 rounded-lg px-1 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
{cell.label}
</p>
<p
className={cn(
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
title={title}
>
{display}
</p>
</div>
);
})}
</div>
);
}
function usageBarFill(pct: number): string {
if (pct >= 95) {
return DASHBOARD_CHART_COLORS.rose;
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const hasPayout = win + jackpot > 0;
const bet = coerceAdminMinor(finance.total_bet_minor);
const win = coerceAdminMinor(finance.total_win_payout_minor);
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
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>
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{hasPayout ? (
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
) : (
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
const publishedTotal = coerceAdminMinor(queue.published_total);
const batchTotal = coerceAdminMinor(queue.batch_total);
return (
<div className="grid grid-cols-3 gap-2 text-center">
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
compact ? "text-lg" : "text-2xl",
)}
>
{pending_review_total}
{pendingReviewTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
compact ? "text-lg" : "text-2xl",
)}
>
{published_total}
{publishedTotal}
</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")}>
{batch_total}
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{pending_draw_count > 0
? t("batchPendingDrawsCount", { count: pending_draw_count })
: t("batchTotal")}
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
</p>
</div>
</div>
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
}): 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;
const bet = coerceAdminMinor(finance.total_bet_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
let win = coerceAdminMinor(finance.total_win_minor);
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
if (payout > 0 && win + jackpot === 0) {
win = payout;
}
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
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>
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{!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")}

View File

@@ -1,16 +1,23 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { format, subDays } from "date-fns";
import { useTranslation } from "react-i18next";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import {
coerceAdminMinor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
AdminDashboardAnalyticsAgentRow,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
const major = safeMinor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
return formatAdminMinorUnits(safeMinor, code, decimals);
}
}
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
playOptions: { code: string; label: string }[];
}) {
const { t } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
needsAuthSync
? tRef.current("warnings.apiResourceMissing")
: raw || tRef.current("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, playCode, customFrom, customTo, t]);
}, [enabled, period, playCode, customFrom, customTo]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [enabled, period, playCode, customFrom, customTo]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
return rows.slice(0, 5);
}, [data, rankingMetric]);
const metricAgentValue = useCallback(
(row: AdminDashboardAnalyticsAgentRow): number => {
if (rankingMetric === "payout") {
return row.total_payout_minor;
}
if (rankingMetric === "profit") {
return row.approx_house_gross_minor;
}
return row.total_bet_minor;
},
[rankingMetric],
);
const topAgentRows = useMemo(() => {
if (!data) {
return [];
}
const rows = [...data.agent_breakdown];
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
return rows.slice(0, 5);
}, [data, metricAgentValue]);
const sparklines = useMemo(() => {
const series = data?.daily_series ?? [];
return {
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
playOptions,
resolvePlayLabel,
topPlayRows,
topAgentRows,
sparklines,
formatMoney: formatDashboardMoneyMinor,
formatSignedMoney: formatDashboardSignedMoneyMinor,