调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
194 lines
5.6 KiB
TypeScript
194 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { format, subDays } from "date-fns";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||
import { LotteryApiBizError } from "@/types/api/errors";
|
||
import type {
|
||
AdminDashboardAnalyticsData,
|
||
DashboardAnalyticsMetric,
|
||
DashboardAnalyticsPeriod,
|
||
} from "@/types/api/admin-dashboard-analytics";
|
||
|
||
export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||
"today",
|
||
"last_7_days",
|
||
"last_30_days",
|
||
"this_month",
|
||
"lifetime",
|
||
"custom",
|
||
];
|
||
|
||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||
|
||
export function formatDashboardMoneyMinor(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(getAdminRequestLocale(), {
|
||
style: "currency",
|
||
currency: code,
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals,
|
||
}).format(major);
|
||
} catch {
|
||
return formatAdminMinorUnits(minor, code, decimals);
|
||
}
|
||
}
|
||
|
||
export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
||
if (minor === 0) {
|
||
return formatDashboardMoneyMinor(0, currencyCode);
|
||
}
|
||
const s = minor > 0 ? "+" : "−";
|
||
return `${s}${formatDashboardMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||
}
|
||
|
||
export function useDashboardAnalytics({
|
||
enabled,
|
||
playOptions,
|
||
}: {
|
||
enabled: boolean;
|
||
playOptions: { code: string; label: string }[];
|
||
}) {
|
||
const { t } = useTranslation(["dashboard", "common"]);
|
||
const playLabel = useAdminPlayCodeLabel();
|
||
|
||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||
const [rankingMetric, setRankingMetric] = useState<DashboardAnalyticsMetric>("bet");
|
||
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: "overview",
|
||
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, 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 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],
|
||
);
|
||
|
||
const topPlayRows = useMemo(() => {
|
||
if (!data) {
|
||
return [];
|
||
}
|
||
const rows = [...data.play_breakdown];
|
||
rows.sort((a, b) => {
|
||
if (rankingMetric === "payout") {
|
||
return b.total_payout_minor - a.total_payout_minor;
|
||
}
|
||
if (rankingMetric === "profit") {
|
||
return b.approx_house_gross_minor - a.approx_house_gross_minor;
|
||
}
|
||
return b.total_bet_minor - a.total_bet_minor;
|
||
});
|
||
return rows.slice(0, 5);
|
||
}, [data, rankingMetric]);
|
||
|
||
const sparklines = useMemo(() => {
|
||
const series = data?.daily_series ?? [];
|
||
return {
|
||
bet: series.map((d) => d.total_bet_minor),
|
||
payout: series.map((d) => d.total_payout_minor),
|
||
profit: series.map((d) => d.approx_house_gross_minor),
|
||
};
|
||
}, [data?.daily_series]);
|
||
|
||
return {
|
||
enabled,
|
||
period,
|
||
setPeriod,
|
||
rankingMetric,
|
||
setRankingMetric,
|
||
playCode,
|
||
setPlayCode,
|
||
customFrom,
|
||
setCustomFrom,
|
||
customTo,
|
||
setCustomTo,
|
||
loading,
|
||
error,
|
||
data,
|
||
currency,
|
||
summary,
|
||
periodRangeLabel,
|
||
playFilterLabel,
|
||
playOptions,
|
||
resolvePlayLabel,
|
||
topPlayRows,
|
||
sparklines,
|
||
formatMoney: formatDashboardMoneyMinor,
|
||
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||
t,
|
||
};
|
||
}
|
||
|
||
export type DashboardAnalyticsState = ReturnType<typeof useDashboardAnalytics>;
|