feat(dashboard, i18n): 增强仪表盘视觉效果与多语言支持
在英文、尼泊尔语和中文语言包中新增 “Other statuses” 翻译,提升仪表盘指标展示的清晰度。 在仪表盘中集成新的 StatCard 组件,用于更直观地展示关键指标数据。 更新仪表盘趋势图表,采用 recharts 实现更丰富的数据可视化效果。 重构现有组件与布局,优化整体交互与用户体验,使界面更加直观易用。
This commit is contained in:
@@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
|
||||
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
||||
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
return row.total_bet_minor;
|
||||
case "payout":
|
||||
return row.total_payout_minor;
|
||||
case "profit":
|
||||
return row.approx_house_gross_minor;
|
||||
default:
|
||||
return row.total_bet_minor;
|
||||
}
|
||||
}
|
||||
|
||||
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
@@ -36,6 +33,16 @@ function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardA
|
||||
}
|
||||
}
|
||||
|
||||
function metricBarFill(metric: DashboardAnalyticsMetric, value: number): string {
|
||||
if (metric === "payout") {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
if (metric === "profit") {
|
||||
return value >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({
|
||||
series,
|
||||
metric,
|
||||
@@ -49,103 +56,116 @@ export function DailyTrendChart({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildTrendChartConfig({
|
||||
bet: t("chartLegend.bet"),
|
||||
payout: t("chartLegend.payout"),
|
||||
profit: t("chartLegend.profit"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
series.map((day) => ({
|
||||
date: day.business_date.slice(5),
|
||||
fullDate: day.business_date,
|
||||
bet: day.total_bet_minor,
|
||||
payout: day.total_payout_minor,
|
||||
profit: day.approx_house_gross_minor,
|
||||
profitAbs: Math.abs(day.approx_house_gross_minor),
|
||||
})),
|
||||
[series],
|
||||
);
|
||||
|
||||
if (series.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>;
|
||||
return <DashboardChartEmpty message={t("states.noData", { ns: "common" })} />;
|
||||
}
|
||||
|
||||
const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1);
|
||||
const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1);
|
||||
const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1);
|
||||
const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1;
|
||||
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;
|
||||
|
||||
const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{metric === "overview" ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-primary" />
|
||||
{t("chartLegend.bet")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-rose-500" />
|
||||
{t("chartLegend.payout")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-emerald-500" />
|
||||
{t("chartLegend.profit")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="flex items-end gap-1 overflow-x-auto rounded-md border border-border/60 bg-muted/20 px-2 pb-2 pt-3 sm:gap-1.5"
|
||||
if (metric === "overview") {
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: plotHeight }}
|
||||
>
|
||||
{series.map((day, idx) => {
|
||||
const betH = (day.total_bet_minor / maxBet) * 100;
|
||||
const payoutH = (day.total_payout_minor / maxPayout) * 100;
|
||||
const profitRaw = day.approx_house_gross_minor;
|
||||
const profitH = (Math.abs(profitRaw) / maxProfit) * 100;
|
||||
const showLabel = idx % labelEvery === 0 || idx === series.length - 1;
|
||||
const shortDate = day.business_date.slice(5);
|
||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
||||
return row?.fullDate ?? "";
|
||||
}}
|
||||
formatter={(value, name, item) => {
|
||||
if (name === "profit") {
|
||||
const row = item?.payload as { profit?: number } | undefined;
|
||||
return formatMoney(row?.profit ?? Number(value), currency);
|
||||
}
|
||||
return formatMoney(Number(value), currency);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="bet" fill="var(--color-bet)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="payout" fill="var(--color-payout)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="profitAbs" name="profit" fill="var(--color-profit)" radius={[4, 4, 0, 0]} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.business_date}
|
||||
className="flex min-w-[28px] flex-1 flex-col items-stretch justify-end gap-1 self-stretch"
|
||||
title={`${day.business_date}\n${t("todayBetTotal")}: ${formatMoney(day.total_bet_minor, currency)}\n${t("todayPayout")}: ${formatMoney(day.total_payout_minor, currency)}\n${t("todayProfit")}: ${formatMoney(day.approx_house_gross_minor, currency)}`}
|
||||
>
|
||||
<div className="flex w-full flex-1 items-end justify-center gap-0.5">
|
||||
{metric === "overview" ? (
|
||||
<>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-primary/90 transition-all"
|
||||
style={{ height: `${Math.max(betH, day.total_bet_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-rose-500/90 transition-all"
|
||||
style={{ height: `${Math.max(payoutH, day.total_payout_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-[30%] min-w-[4px] rounded-t-sm transition-all",
|
||||
profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90",
|
||||
)}
|
||||
style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[70%] min-w-[6px] max-w-[20px] rounded-t-md transition-all",
|
||||
metric === "payout" && "bg-rose-500/90",
|
||||
metric === "profit" && (profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"),
|
||||
metric === "bet" && "bg-primary/90",
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
(metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100,
|
||||
metricValue(day, metric) !== 0 ? 6 : 0,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-center text-[10px] tabular-nums text-muted-foreground",
|
||||
!showLabel && "invisible",
|
||||
)}
|
||||
>
|
||||
{shortDate}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const activeKey = metric === "bet" ? "bet" : metric === "payout" ? "payout" : "profitAbs";
|
||||
const dataKey = metric === "profit" ? "profitAbs" : activeKey;
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: plotHeight }}
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
||||
return row?.fullDate ?? "";
|
||||
}}
|
||||
formatter={(value) => formatMoney(Number(value), currency)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={dataKey} radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((row) => (
|
||||
<Cell
|
||||
key={row.fullDate}
|
||||
fill={metricBarFill(metric, row.profit)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,50 +183,93 @@ export function PlayBreakdownChart({
|
||||
playLabel: (code: string, dimension: number) => string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noPlayData")}</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1);
|
||||
const activeMetric = metric === "overview" ? "bet" : metric;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const value = playMetricValue(row, activeMetric);
|
||||
const pct = (Math.abs(value) / max) * 100;
|
||||
const label = playLabel(row.play_code, row.dimension);
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildTrendChartConfig({
|
||||
bet: t("chartLegend.bet"),
|
||||
payout: t("chartLegend.payout"),
|
||||
profit: t("chartLegend.profit"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={`${row.play_code}-${row.dimension}`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium text-foreground">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{formatMoney(value, currency)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
activeMetric === "payout" && "bg-rose-500",
|
||||
activeMetric === "profit" && (value >= 0 ? "bg-emerald-500" : "bg-amber-500"),
|
||||
activeMetric === "bet" && "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{metric === "overview" ? (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">
|
||||
{t("playBreakdownHint", {
|
||||
payout: formatMoney(row.total_payout_minor, currency),
|
||||
profit: formatMoney(row.approx_house_gross_minor, currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((row) => {
|
||||
const value = playMetricValue(row, activeMetric);
|
||||
return {
|
||||
id: `${row.play_code}-${row.dimension}`,
|
||||
label: playLabel(row.play_code, row.dimension),
|
||||
value: Math.abs(value),
|
||||
signed: value,
|
||||
payout: row.total_payout_minor,
|
||||
profit: row.approx_house_gross_minor,
|
||||
fill: metricBarFill(activeMetric, value),
|
||||
};
|
||||
}),
|
||||
[rows, activeMetric, playLabel],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
|
||||
}
|
||||
|
||||
const chartHeight = Math.min(480, Math.max(180, rows.length * 36 + 48));
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 4 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { label?: string } | undefined;
|
||||
return row?.label ?? "";
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const row = item.payload as { signed: number; payout: number; profit: number };
|
||||
if (metric === "overview") {
|
||||
return (
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatMoney(row.signed, currency)} · {t("playBreakdownHint", {
|
||||
payout: formatMoney(row.payout, currency),
|
||||
profit: formatMoney(row.profit, currency),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return formatMoney(row.signed, currency);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="value" radius={4} barSize={14}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.id} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={100}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,34 +286,50 @@ export function PeriodCompareStrip({
|
||||
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
|
||||
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
|
||||
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
|
||||
const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1);
|
||||
const maxAbs = Math.max(Math.abs(totalBet), Math.abs(totalPayout), Math.abs(totalProfit), 1);
|
||||
const payoutRate = totalBet > 0 ? (totalPayout / totalBet) * 100 : 0;
|
||||
const profitRate = totalBet > 0 ? (totalProfit / totalBet) * 100 : 0;
|
||||
|
||||
const items = [
|
||||
{ key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" },
|
||||
{ key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" },
|
||||
const rows = [
|
||||
{
|
||||
key: "bet",
|
||||
label: t("chartLegend.bet"),
|
||||
value: totalBet,
|
||||
pctText: "100%",
|
||||
width: (Math.abs(totalBet) / maxAbs) * 100,
|
||||
fill: "var(--chart-1)",
|
||||
},
|
||||
{
|
||||
key: "payout",
|
||||
label: t("chartLegend.payout"),
|
||||
value: totalPayout,
|
||||
pctText: `${payoutRate.toFixed(1)}%`,
|
||||
width: (Math.abs(totalPayout) / maxAbs) * 100,
|
||||
fill: "var(--chart-5)",
|
||||
},
|
||||
{
|
||||
key: "profit",
|
||||
label: t("chartLegend.profit"),
|
||||
value: totalProfit,
|
||||
className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500",
|
||||
pctText: `${profitRate >= 0 ? "+" : ""}${profitRate.toFixed(1)}%`,
|
||||
width: (Math.abs(totalProfit) / maxAbs) * 100,
|
||||
fill: totalProfit >= 0 ? "var(--chart-2)" : DASHBOARD_CHART_COLORS.warning,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="grid gap-4">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</span>
|
||||
<span className="text-sm font-medium text-foreground">{row.label}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span>
|
||||
</div>
|
||||
<div className="mb-1 text-sm font-semibold tabular-nums">{formatMoney(row.value, currency)}</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", item.className)}
|
||||
style={{ width: `${(Math.abs(item.value) / max) * 100}%` }}
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(2, row.width)}%`, background: row.fill }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user