Files
lotteryAdmin/src/modules/dashboard/dashboard-analytics-panel.tsx
kang a550c418e5 refactor(layout, i18n, admin): 优化布局结构与多语言支持
调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
2026-05-30 17:46:27 +08:00

375 lines
13 KiB
TypeScript

"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
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 { getAdminRequestLocale } from "@/lib/admin-locale";
import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import {
DailyTrendChart,
PlayBreakdownChart,
} from "@/modules/dashboard/dashboard-trend-charts";
import {
DASHBOARD_ANALYTICS_PERIODS,
DASHBOARD_RANKING_METRICS,
useDashboardAnalytics,
type DashboardAnalyticsState,
} from "@/modules/dashboard/use-dashboard-analytics";
function computeDeltaPercent(series: number[]): string | null {
if (series.length < 2) {
return null;
}
const prev = series[series.length - 2];
const last = series[series.length - 1];
if (prev === 0) {
return null;
}
const pct = ((last - prev) / Math.abs(prev)) * 100;
const sign = pct >= 0 ? "▲" : "▼";
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
}
function deltaClassName(series: number[]): string {
if (series.length < 2) {
return "text-muted-foreground";
}
const last = series[series.length - 1];
const prev = series[series.length - 2];
if (last >= prev) {
return "text-emerald-600 dark:text-emerald-400";
}
return "text-destructive";
}
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
period,
setPeriod,
playCode,
setPlayCode,
customFrom,
setCustomFrom,
customTo,
setCustomTo,
loading,
error,
data,
currency,
summary,
periodRangeLabel,
playFilterLabel,
playOptions,
sparklines,
formatMoney,
formatSignedMoney,
} = analytics;
if (!enabled) {
return null;
}
return (
<Card className="admin-list-card min-w-0 overflow-hidden py-0">
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base font-semibold">{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")}>
{DASHBOARD_ANALYTICS_PERIODS.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-3 lg:grid-cols-[1fr_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">
{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.playLabel")}</Label>
<Select
value={playCode === "" ? "__all__" : playCode}
onValueChange={(v) => setPlayCode(!v || 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-4 px-4 py-4">
{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 min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<DashboardKpiCard
label={t("analytics.summaryBet")}
value={formatMoney(summary.total_bet_minor, currency)}
hint={t("lifetimeActivityHint", {
draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
})}
icon={<Wallet className="size-4" aria-hidden />}
sparklineValues={sparklines.bet}
deltaLabel={
computeDeltaPercent(sparklines.bet) ? (
<span className={deltaClassName(sparklines.bet)}>
{computeDeltaPercent(sparklines.bet)}
</span>
) : undefined
}
/>
<DashboardKpiCard
label={t("analytics.summaryPayout")}
value={formatMoney(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-4" aria-hidden />}
accent="destructive"
sparklineValues={sparklines.payout}
deltaLabel={
computeDeltaPercent(sparklines.payout) ? (
<span className={deltaClassName(sparklines.payout)}>
{computeDeltaPercent(sparklines.payout)}
</span>
) : undefined
}
/>
<DashboardKpiCard
label={t("analytics.summaryProfit")}
value={formatSignedMoney(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-4" aria-hidden />}
sparklineValues={sparklines.profit}
deltaLabel={
computeDeltaPercent(sparklines.profit) ? (
<span className={deltaClassName(sparklines.profit)}>
{computeDeltaPercent(sparklines.profit)}
</span>
) : undefined
}
/>
</div>
) : null}
<div className="rounded-xl border border-border/60 bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2.5">
<p className="text-sm font-semibold">{t("analytics.dailyTrend")}</p>
<span className="text-xs text-muted-foreground">{t("analytics.granularityDay")}</span>
</div>
<div className="px-3 py-3">
{loading ? (
<Skeleton className="h-[260px] w-full" />
) : data ? (
<DailyTrendChart
series={data.daily_series}
metric="overview"
formatMoney={formatMoney}
currency={currency}
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
}
export function DashboardPlayRankingCard({
analytics,
}: {
analytics: DashboardAnalyticsState;
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
rankingMetric,
setRankingMetric,
period,
setPeriod,
loading,
data,
currency,
topPlayRows,
resolvePlayLabel,
formatMoney,
} = analytics;
if (!enabled) {
return null;
}
return (
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("analytics.playRanking")}</CardTitle>
<div className="flex flex-wrap gap-1" role="tablist" aria-label={t("analytics.rankingMetricLabel")}>
{DASHBOARD_RANKING_METRICS.map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={rankingMetric === m}
className={cn(
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
rankingMetric === m
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setRankingMetric(m)}
>
{t(`analytics.rankingMetrics.${m}`)}
</button>
))}
</div>
<Select value={period} onValueChange={(v) => setPeriod(v as typeof period)}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{t(`analytics.periods.${period}`)}</SelectValue>
</SelectTrigger>
<SelectContent>
{DASHBOARD_ANALYTICS_PERIODS.filter((p) => p !== "custom").map((p) => (
<SelectItem key={p} value={p}>
{t(`analytics.periods.${p}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
{loading ? (
<Skeleton className="h-[200px] w-full" />
) : data && topPlayRows.length > 0 ? (
<PlayBreakdownChart
rows={topPlayRows}
metric={rankingMetric}
formatMoney={formatMoney}
currency={currency}
playLabel={resolvePlayLabel}
compact
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("analytics.noPlayData")}
</p>
)}
</CardContent>
</Card>
);
}
/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,
playOptions,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
}): ReactNode {
const analytics = useDashboardAnalytics({ enabled, playOptions });
return (
<section className="space-y-4">
<DashboardAnalyticsMain analytics={analytics} />
<DashboardPlayRankingCard analytics={analytics} />
</section>
);
}