feat: 增加管理端多语言与多模块界面国际化支持

This commit is contained in:
2026-05-19 09:11:55 +08:00
parent 49a4caf01e
commit 1b1dfc92ab
110 changed files with 4053 additions and 1308 deletions

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
ClipboardList,
@@ -30,7 +31,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "特别";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
type SoldOutBuckets = {
d4: number;
@@ -66,7 +67,7 @@ function formatSignedMoneyMinor(minor: number, currencyCode: string | null): str
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
/** 与后端 {@see AdminDashboardSnapshotBuilder::soldOutBucketKey} 维度对齐 */
/** Aligned with the bucket dimensions used by AdminDashboardSnapshotBuilder::soldOutBucketKey. */
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -74,7 +75,7 @@ function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const hasLetter = /[A-Za-z]/.test(raw);
if (hasLetter && digitLen < 3) {
return "特别";
return "special";
}
if (digitLen >= 4) {
return "4D";
@@ -86,7 +87,7 @@ function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
return "2D";
}
if (hasLetter) {
return "特别";
return "special";
}
return "other";
}
@@ -99,6 +100,7 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
}
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
const { t } = useTranslation("dashboard");
const v = Math.min(100, Math.max(0, pct));
const r = 76;
const arcLen = Math.PI * r;
@@ -126,13 +128,14 @@ function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1 text-center">
<p className="text-lg font-bold tabular-nums text-[#1a365d]">{v.toFixed(2)}%</p>
<p className="text-[11px] leading-tight text-muted-foreground"></p>
<p className="text-[11px] leading-tight text-muted-foreground">{t("capUsage")}</p>
</div>
</div>
);
}
function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
const { t } = useTranslation("dashboard");
const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0));
return (
@@ -142,10 +145,10 @@ function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
className="pointer-events-none absolute bottom-6 left-0 top-2 w-6 rotate-180 text-[10px] leading-tight text-muted-foreground [writing-mode:vertical-rl]"
aria-hidden
>
{t("capUsage")}
</span>
{rows.length === 0 ? (
<p className="w-full pb-6 text-center text-sm text-muted-foreground"></p>
<p className="w-full pb-6 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>
) : (
rows.map((row) => {
const u = row.usage_ratio ?? 0;
@@ -170,25 +173,26 @@ function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
})
)}
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground"></p>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">{t("numbersByUsage")}</p>
</div>
);
}
function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
const { t } = useTranslation("dashboard");
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
{ key: "d4", label: "4D", color: "oklch(0.32 0.08 260)" },
{ key: "d3", label: "3D", color: "oklch(0.48 0.12 250)" },
{ key: "d2", label: "2D", color: "oklch(0.78 0.14 95)" },
{ key: "special", label: "特别号", color: "oklch(0.55 0.22 25)" },
{ key: "other", label: "其他", color: "oklch(0.62 0.16 145)" },
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.32 0.08 260)" },
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.48 0.12 250)" },
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.78 0.14 95)" },
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.55 0.22 25)" },
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.62 0.16 145)" },
];
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
if (total === 0) {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<p></p>
<p>{t("noSoldOutNumbers")}</p>
</div>
);
}
@@ -227,7 +231,7 @@ function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
/>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<p className="text-2xl font-bold tabular-nums text-[#1a365d]">{total}</p>
<p className="text-[11px] text-muted-foreground"></p>
<p className="text-[11px] text-muted-foreground">{t("soldOutTotal")}</p>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2 text-sm">
@@ -246,6 +250,7 @@ function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
}
export function DashboardConsole(): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -304,21 +309,21 @@ export function DashboardConsole(): ReactElement {
const noticeParts: string[] = d.warnings.map((w) => w.message);
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
noticeParts.push("当前账号无开奖查看/管理权限,财务与风控数据未返回。");
noticeParts.push(t("warnings.drawPermission"));
}
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
noticeParts.push("当前账号无钱包对账查看权限,异常转账计数未返回。");
noticeParts.push(t("warnings.walletPermission"));
}
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查 API 与登录状态。";
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
}, [t]);
useEffect(() => {
const t = window.setTimeout(() => {
@@ -335,28 +340,27 @@ export function DashboardConsole(): ReactElement {
const hallStatusLabel = hall?.status ?? "—";
const isOpenLike =
hallStatusLabel.toLowerCase().includes("open") ||
hallStatusLabel.includes("开售") ||
hallStatusLabel.includes("开放");
hallStatusLabel.toLowerCase().includes("sale");
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: "创建期计划", icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: "开售 / 期号", icon: <Ticket className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-5" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: "开奖结果",
label: t("quickLinks.results"),
icon: <FileSearch className="size-5" />,
},
{ href: "/admin/tickets", label: "注单管理", icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: "钱包流水", icon: <Wallet className="size-5" /> },
{ href: "/admin/reports", label: "报表中心", icon: <FileSpreadsheet className="size-5" /> },
{ href: "/admin/audit-logs", label: "审计日志", icon: <ScrollText className="size-5" /> },
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-5" /> },
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <FileSpreadsheet className="size-5" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
];
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]"></h1>
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]">{t("title")}</h1>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{todayLabel}</span>
@@ -369,26 +373,26 @@ export function DashboardConsole(): ReactElement {
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
{t("refresh")}
</Button>
</div>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle></AlertTitle>
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{notice && !error ? (
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
<AlertTitle></AlertTitle>
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{notice}</AlertDescription>
</Alert>
) : null}
{/* Row 1 — 核心财务 KPI */}
{/* Row 1 - Core finance KPI */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
@@ -407,13 +411,13 @@ export function DashboardConsole(): ReactElement {
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("todayBetTotal")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
{t("currentDrawFinanceSummary")}
</p>
</div>
</div>
@@ -424,13 +428,13 @@ export function DashboardConsole(): ReactElement {
<Gift className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentPayout")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
+ Jackpot
{t("payoutSummary")}
</p>
</div>
</div>
@@ -441,13 +445,13 @@ export function DashboardConsole(): ReactElement {
<TrendingUp className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentProfit")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
{t("profitFormula")}
</p>
</div>
</div>
@@ -456,7 +460,7 @@ export function DashboardConsole(): ReactElement {
)}
</div>
{/* Row 2 — 期号 / 投注 / 风险表 */}
{/* Row 2 - Draw / betting / risk */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
@@ -472,10 +476,10 @@ export function DashboardConsole(): ReactElement {
<Ticket className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentDraw")}</p>
<p className="mt-1 font-mono text-2xl font-bold text-[#c41e3a]">{hall?.draw_no ?? "—"}</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span> {hall?.sequence_no ?? "—"} </span>
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
<span className="hidden sm:inline">·</span>
<span className="inline-flex items-center gap-1.5">
<span
@@ -495,7 +499,7 @@ export function DashboardConsole(): ReactElement {
)}
href={`/admin/draws/${drawId}`}
>
{t("drawDetails")}
</Link>
) : null}
</div>
@@ -507,12 +511,12 @@ export function DashboardConsole(): ReactElement {
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("ticketCount")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{" "}
{t("relatedBetAmount")}{" "}
<span className="font-medium text-foreground">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</span>
@@ -526,10 +530,12 @@ export function DashboardConsole(): ReactElement {
<Shield className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1 text-center sm:text-left">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("riskCapUsage")}</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
{formatMoneyMinor(riskLocked, currency)} / {" "}
{formatMoneyMinor(riskCap, currency)}
{t("lockedAndCap", {
locked: formatMoneyMinor(riskLocked, currency),
cap: formatMoneyMinor(riskCap, currency),
})}
</p>
<div className="mt-2">
<RiskSemiGauge pct={usagePct} />
@@ -542,7 +548,7 @@ export function DashboardConsole(): ReactElement {
)}
href={`/admin/risk/draws/${drawId}/occupancy`}
>
{t("occupancyDetails")}
</Link>
) : null}
</div>
@@ -552,30 +558,35 @@ export function DashboardConsole(): ReactElement {
)}
</div>
{/* Row 3 — 图表 */}
{/* Row 3 - Charts */}
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"> Top 10</CardTitle>
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("hotNumbersTop10")}</CardTitle>
</div>
<div className="flex items-center gap-3">
<div role="tablist" aria-label="玩法维度" className="flex gap-1 border-b border-transparent">
{(["4D", "3D", "2D", "特别"] as const).map((tab) => (
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1 border-b border-transparent">
{([
{ value: "4D", label: t("tabs.4d") },
{ value: "3D", label: t("tabs.3d") },
{ value: "2D", label: t("tabs.2d") },
{ value: "special", label: t("tabs.special") },
] as const).map((tab) => (
<button
key={tab}
key={tab.value}
type="button"
role="tab"
aria-selected={hotTab === tab}
aria-selected={hotTab === tab.value}
className={cn(
"-mb-px border-b-2 px-2.5 py-1 text-sm font-medium transition-colors",
hotTab === tab
hotTab === tab.value
? "border-[#c41e3a] text-[#c41e3a]"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setHotTab(tab)}
onClick={() => setHotTab(tab.value)}
>
{tab}
{tab.label}
</button>
))}
</div>
@@ -587,7 +598,7 @@ export function DashboardConsole(): ReactElement {
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
)}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</div>
@@ -604,7 +615,7 @@ export function DashboardConsole(): ReactElement {
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"></CardTitle>
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("soldOutDistribution")}</CardTitle>
</div>
{drawId != null ? (
<Link
@@ -614,7 +625,7 @@ export function DashboardConsole(): ReactElement {
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
)}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</CardHeader>
@@ -624,13 +635,13 @@ export function DashboardConsole(): ReactElement {
) : soldOutBuckets ? (
<SoldOutDonut buckets={soldOutBuckets} />
) : (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
{/* Row 4 — 待办 */}
{/* Row 4 - To-do */}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
@@ -638,7 +649,7 @@ export function DashboardConsole(): ReactElement {
<ClipboardList className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("pendingReviewResults")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{pendingReview ?? "—"}
</p>
@@ -652,7 +663,7 @@ export function DashboardConsole(): ReactElement {
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
{t("actions.reviewNow", { ns: "common" })}
</Link>
) : null}
</div>
@@ -662,7 +673,7 @@ export function DashboardConsole(): ReactElement {
<AlertTriangle className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("abnormalTransferOrders")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{abnormalTransferTotal ?? "—"}
</p>
@@ -675,12 +686,12 @@ export function DashboardConsole(): ReactElement {
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
{t("viewTransferOrders")}
</Link>
</div>
</div>
{/* Row 5 — 快捷入口 */}
{/* Row 5 - Quick links */}
<Card className="border-slate-200/90 shadow-sm">
<CardContent className="flex flex-wrap justify-center gap-3 py-6 sm:gap-6">
{quickLinks.map((q) => (

View File

@@ -1,5 +1,5 @@
export const dashboardModuleMeta = {
segment: "dashboard",
title: "仪表盘",
title: "Dashboard",
description: "",
} as const;