refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制

This commit is contained in:
2026-05-25 14:31:24 +08:00
parent 7d01e5c47e
commit ddedef824e
101 changed files with 3033 additions and 641 deletions

View File

@@ -2,24 +2,28 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
ClipboardList,
Diamond,
FileSearch,
Gift,
RefreshCw,
ScrollText,
Shield,
Ticket,
TrendingUp,
Wallet,
} from "lucide-react";
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 { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,9 +36,10 @@ import {
ResultBatchProgress,
SettlementStatusChart,
SoldOutRing,
StatCard,
} from "@/modules/dashboard/dashboard-visuals";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { normalizeAdminLanguage } from "@/i18n";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -69,14 +74,6 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
}
}
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatMoneyMinor(0, currencyCode);
}
const s = minor > 0 ? "+" : "";
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -109,18 +106,24 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
}
export function DashboardConsole(): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const { t, i18n } = useTranslation(["dashboard", "common"]);
useAdminCurrencyCatalog();
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
useAdminPlayTypeCatalog();
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
const [pendingReview, setPendingReview] = useState<number | null>(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
@@ -128,6 +131,26 @@ export function DashboardConsole(): ReactElement {
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
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(() => {
void loadPlayOptions();
}, [loadPlayOptions]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -136,8 +159,8 @@ export function DashboardConsole(): ReactElement {
setLoading(true);
}
setError(null);
setNotice(null);
setFinance(null);
setCapabilities(null);
setDrawPanel(null);
setPendingReview(null);
setDrawId(null);
@@ -155,6 +178,7 @@ export function DashboardConsole(): ReactElement {
setDrawId(d.resolved_draw.id);
}
setCapabilities(d.capabilities);
if (d.finance != null) {
setFinance(d.finance);
}
@@ -169,15 +193,6 @@ export function DashboardConsole(): ReactElement {
setSoldOutBuckets(d.risk.sold_out_buckets);
}
setAbnormalTransferTotal(d.abnormal_transfer_total);
const noticeParts: string[] = d.warnings.map((w) => w.message);
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
noticeParts.push(t("warnings.drawPermission"));
}
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
noticeParts.push(t("warnings.walletPermission"));
}
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
@@ -196,6 +211,7 @@ export function DashboardConsole(): ReactElement {
}, [load]);
const currency = finance?.currency_code ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
@@ -218,16 +234,6 @@ export function DashboardConsole(): ReactElement {
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
];
const kpiSkeleton = (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
<Skeleton className="h-20 w-full" />
</div>
))}
</div>
);
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -242,7 +248,7 @@ export function DashboardConsole(): ReactElement {
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
{t("refresh")}
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
</div>
@@ -254,69 +260,48 @@ export function DashboardConsole(): ReactElement {
</Alert>
) : null}
{notice && !error ? (
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
{!loading && capabilities && !capabilities.draw_finance_risk ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{notice}</AlertDescription>
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
) : null}
{loading ? (
kpiSkeleton
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label={t("todayBetTotal")}
value={finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
hint={hall?.draw_no ? t("drawNoHint", { drawNo: hall.draw_no }) : undefined}
icon={<Wallet className="size-5" aria-hidden />}
/>
<StatCard
label={t("currentPayout")}
value={finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
hint={
finance
? t("orderAndTicket", {
orders: finance.order_count.toLocaleString("zh-CN"),
tickets: finance.ticket_item_count.toLocaleString("zh-CN"),
})
: undefined
}
icon={<Gift className="size-5" aria-hidden />}
accent="destructive"
/>
<StatCard
label={t("currentProfit")}
value={finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
hint={finance && finance.total_bet_minor > 0
? t("marginRate", {
rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1),
})
: undefined}
icon={<TrendingUp className="size-5" aria-hidden />}
/>
<StatCard
label={t("currentDraw")}
value={<span className="font-mono text-primary">{hall?.draw_no ?? "—"}</span>}
hint={
<span className="inline-flex flex-wrap items-center gap-2">
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
)}
/>
{hallStatusLabel}
</span>
</span>
}
icon={<Ticket className="size-5" aria-hidden />}
accent="muted"
/>
{!loading && hall ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<Ticket className="size-5 text-primary" aria-hidden />
<div>
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
</div>
<span className="text-sm text-muted-foreground">
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
</span>
<span className="inline-flex items-center gap-1.5 text-sm">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
)}
/>
{hallStatusLabel}
</span>
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
>
{t("drawFinanceDetails")}
</Link>
) : null}
</div>
)}
) : null}
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<Card className="border-border/80 shadow-sm xl:col-span-1">