调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
375 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|