refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制
This commit is contained in:
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal file
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user