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

@@ -0,0 +1,386 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { format, subDays } from "date-fns";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
import {
DailyTrendChart,
PeriodCompareStrip,
PlayBreakdownChart,
} from "@/modules/dashboard/dashboard-trend-charts";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
"today",
"last_7_days",
"last_30_days",
"this_month",
"lifetime",
"custom",
];
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
try {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
}
}
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)}`;
}
export function DashboardAnalyticsPanel({
enabled,
playOptions,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
}): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
const [playCode, setPlayCode] = useState<string>("");
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
const load = useCallback(async () => {
if (!enabled) {
setLoading(false);
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const payload = await getAdminDashboardAnalytics({
period,
metric,
play_code: playCode !== "" ? playCode : undefined,
...(period === "custom"
? { date_from: customFrom, date_to: customTo }
: {}),
});
setData(payload);
} catch (e) {
setData(null);
const raw = e instanceof LotteryApiBizError ? e.message : "";
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
const periodRangeLabel = useMemo(() => {
if (!data) {
return null;
}
return data.date_from === data.date_to
? data.date_from
: `${data.date_from}${data.date_to}`;
}, [data]);
const metricLabel = useMemo(
() => t(`analytics.metrics.${metric}`),
[metric, t],
);
const playFilterLabel = useMemo(() => {
if (playCode === "") {
return t("analytics.allPlays");
}
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
}, [playCode, playOptions, t]);
const resolvePlayLabel = useCallback(
(code: string, dimension: number) => {
const base = playLabel(code);
return dimension > 0 ? `${base} · ${dimension}D` : base;
},
[playLabel],
);
if (!enabled) {
return null;
}
return (
<section className="space-y-4">
<Card className="border-border/80 shadow-sm">
<CardHeader className="space-y-4 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base">{t("analytics.title")}</CardTitle>
<Link
href="/admin/reports"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
>
<BarChart3 className="size-3.5" aria-hidden />
{t("viewReports")}
</Link>
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
{PERIOD_OPTIONS.map((p) => (
<button
key={p}
type="button"
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
period === p
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted",
)}
onClick={() => setPeriod(p)}
>
{t(`analytics.periods.${p}`)}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto] lg:items-end">
{period === "custom" ? (
<AdminDateRangeField
id="dashboard-analytics-range"
label={t("analytics.customRange")}
from={customFrom}
to={customTo}
onRangeChange={({ from, to }) => {
setCustomFrom(from);
setCustomTo(to);
}}
/>
) : (
<p className="text-sm text-muted-foreground lg:col-span-1">
{periodRangeLabel
? t("analytics.rangeHint", { range: periodRangeLabel })
: t("analytics.selectPeriod")}
</p>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("analytics.metricLabel")}</Label>
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
<SelectTrigger className="w-full min-w-[140px]">
<SelectValue>{metricLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{METRIC_OPTIONS.map((m) => (
<SelectItem key={m} value={m}>
{t(`analytics.metrics.${m}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
<Select
value={playCode === "" ? "__all__" : playCode}
onValueChange={(v) => setPlayCode(v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-full min-w-[160px]">
<SelectValue>{playFilterLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allPlays")}</SelectItem>
{playOptions.map((p) => (
<SelectItem key={p.code} value={p.code}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{data?.chart_meta.truncated ? (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("analytics.chartTruncated", {
from: data.chart_meta.chart_date_from,
to: data.chart_meta.chart_date_to,
days: data.chart_meta.span_days,
})}
</p>
) : null}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label={t("analytics.summaryBet")}
value={formatMoneyMinor(summary.total_bet_minor, currency)}
hint={t("lifetimeActivityHint", {
draws: summary.draw_count.toLocaleString("zh-CN"),
days: summary.business_day_count.toLocaleString("zh-CN"),
})}
icon={<Wallet className="size-5" aria-hidden />}
/>
<StatCard
label={t("analytics.summaryPayout")}
value={formatMoneyMinor(summary.total_payout_minor, currency)}
hint={
summary.total_bet_minor > 0
? t("payoutRateOfBet", {
rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<Gift className="size-5" aria-hidden />}
accent="destructive"
/>
<StatCard
label={t("analytics.summaryProfit")}
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
hint={
summary.total_bet_minor > 0
? t("marginRate", {
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<TrendingUp className="size-5" aria-hidden />}
/>
</div>
) : null}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<DailyTrendChart
series={data.daily_series}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<div className="max-h-[280px] overflow-y-auto pr-1">
<PlayBreakdownChart
rows={data.play_breakdown}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
playLabel={resolvePlayLabel}
/>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
{data && !loading ? (
<Card className="border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
</CardHeader>
<CardContent>
<PeriodCompareStrip
series={data.daily_series}
formatMoney={formatMoneyMinor}
currency={currency}
/>
</CardContent>
</Card>
) : null}
</section>
);
}