refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user