refactor(layout, i18n, admin): 优化布局结构与多语言支持

调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
This commit is contained in:
2026-05-30 17:46:27 +08:00
parent 36117144dc
commit a550c418e5
64 changed files with 3405 additions and 1378 deletions

View File

@@ -1,8 +1,10 @@
"use client";
import Link from "next/link";
import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
import {
Bar,
BarChart,
@@ -18,6 +20,7 @@ import {
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
ChartLegend,
@@ -87,18 +90,137 @@ function settlementBarColor(status: string): string {
}
}
type DashboardKpiAccent = "primary" | "destructive" | "muted";
function kpiAccentClass(accent: DashboardKpiAccent): string {
switch (accent) {
case "destructive":
return "bg-destructive/10 text-destructive";
case "muted":
return "bg-muted text-muted-foreground";
default:
return "bg-primary/10 text-primary";
}
}
/** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({
label,
value,
hint,
icon,
accent = "primary",
sparklineValues,
deltaLabel,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<div className="flex min-w-0 items-start gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg",
kpiAccentClass(accent),
)}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
{value}
</p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
</div>
</div>
{sparklineValues && sparklineValues.length >= 2 ? (
<div className="mt-3 flex justify-end">
<MiniSparkline
values={sparklineValues}
strokeClass={
accent === "destructive"
? "stroke-destructive"
: accent === "muted"
? "stroke-muted-foreground"
: "stroke-primary"
}
/>
</div>
) : null}
{hint ? (
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
) : null}
</div>
);
}
function MiniSparkline({
values,
strokeClass,
}: {
values: number[];
strokeClass: string;
}): ReactElement | null {
if (values.length < 2) {
return null;
}
const width = 88;
const height = 32;
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = Math.max(max - min, 1);
const points = values
.map((v, i) => {
const x = (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-8 w-[5.5rem] shrink-0"
aria-hidden
>
<polyline
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className={strokeClass}
/>
</svg>
);
}
export function StatCard({
label,
value,
hint,
icon,
accent = "primary",
href,
sparklineValues,
deltaLabel,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: "primary" | "destructive" | "muted";
/** 整张卡片可点击跳转 */
href?: string;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
const accentClass =
accent === "destructive"
@@ -107,9 +229,15 @@ export function StatCard({
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground";
return (
<Card className="border-border/80 py-0 shadow-sm">
<CardContent className="flex gap-4 p-5">
const card = (
<Card
className={cn(
"flex h-full flex-col border-border/80 py-0 shadow-sm transition-colors",
href &&
"group-hover/stat border-primary/30 bg-muted/15 shadow-md",
)}
>
<CardContent className="flex flex-1 items-start gap-4 p-5">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
@@ -118,14 +246,292 @@ export function StatCard({
>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{value}
</p>
{deltaLabel ? (
<p className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</p>
) : null}
<div
className={cn(
"mt-auto min-h-10 pt-2 text-xs leading-snug",
hint
? href
? "font-medium text-primary group-hover/stat:underline"
: "text-muted-foreground"
: "text-transparent",
)}
>
{hint ?? "\u00a0"}
</div>
</div>
{sparklineValues ? (
<MiniSparkline
values={sparklineValues}
strokeClass={
accent === "destructive"
? "stroke-destructive"
: accent === "muted"
? "stroke-muted-foreground"
: "stroke-primary"
}
/>
) : href ? (
<ChevronRightIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 transition group-hover/stat:text-primary"
aria-hidden
/>
) : null}
</CardContent>
</Card>
);
const shellClass = "flex h-full min-h-0 rounded-2xl";
if (!href) {
return <div className={shellClass}>{card}</div>;
}
return (
<Link
href={href}
className={cn(
shellClass,
"group/stat outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{card}
</Link>
);
}
type DashboardPanelAccent = "primary" | "destructive" | "warning" | "muted";
function panelAccentClass(accent: DashboardPanelAccent): string {
switch (accent) {
case "destructive":
return "bg-destructive/10 text-destructive";
case "warning":
return "bg-amber-500/15 text-amber-700 dark:text-amber-400";
case "muted":
return "bg-muted text-muted-foreground";
default:
return "bg-primary/10 text-primary";
}
}
/** 仪表盘 KPI整卡可点主指标 + 可选底部可视化 */
export function DashboardPanelCard({
href,
icon,
title,
value,
subtitle,
actionLabel,
accent = "primary",
loading = false,
highlight = false,
children,
}: {
href: string;
title: string;
value: ReactNode;
subtitle?: ReactNode;
actionLabel: string;
icon: ReactNode;
accent?: DashboardPanelAccent;
loading?: boolean;
/** 有异常/待办时强调边框 */
highlight?: boolean;
children?: ReactNode;
}): ReactElement {
const hasFooter = children != null;
return (
<Link
href={href}
aria-label={`${title}${actionLabel}`}
className="group/panel flex h-full min-w-0 w-full rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card
className={cn(
"admin-list-card flex h-full min-w-0 w-full flex-col py-0 transition-all duration-200",
"hover:border-primary/30 hover:shadow-md",
highlight && "border-amber-400/50 ring-1 ring-amber-400/25 dark:border-amber-500/40",
)}
>
<div className="flex flex-1 flex-col p-4">
<div className="flex items-start justify-between gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[1.125rem]",
panelAccentClass(accent),
)}
>
{icon}
</div>
<span
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors group-hover/panel:bg-primary/10 group-hover/panel:text-primary"
title={actionLabel}
>
<ArrowRightIcon
className="size-4 transition-transform group-hover/panel:translate-x-0.5"
aria-hidden
/>
</span>
</div>
<div className="mt-3 min-w-0">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-24 rounded-md" />
) : (
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{value}
</p>
)}
{subtitle && !loading ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{subtitle}
</p>
) : null}
</div>
{hasFooter ? (
<div
className={cn(
"mt-4 flex min-h-[3.25rem] items-center justify-center",
loading && "items-stretch",
)}
>
{loading ? (
<Skeleton className="h-[3.25rem] w-full rounded-lg" />
) : (
<div className="w-full border-t border-dashed border-border/60 pt-3">
{children}
</div>
)}
</div>
) : null}
</div>
</Card>
</Link>
);
}
/** 异常转账 KPI 底部:待办提示或正常态 */
export function AbnormalTransferPanelFooter({
total,
walletPermission = true,
}: {
total: number | null;
walletPermission?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
if (!walletPermission) {
return (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] leading-snug text-muted-foreground ring-1 ring-border/50">
{t("warnings.walletPermission")}
</p>
);
}
if (total == null) {
return (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
{t("states.noData", { ns: "common" })}
</p>
);
}
if (total > 0) {
return (
<div className="flex items-start gap-2.5 rounded-lg bg-amber-500/10 px-3 py-2.5 ring-1 ring-amber-500/20">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
<div className="min-w-0 text-left">
<p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
{t("abnormalTransferPending", { count: total })}
</p>
<p className="mt-1 text-[11px] leading-snug text-amber-800/90 dark:text-amber-300/90">
{t("abnormalTransferAction")}
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/8 px-3 py-2.5 ring-1 ring-emerald-500/15">
<CheckCircle2 className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">
{t("abnormalTransferAllClear")}
</p>
</div>
);
}
/** 派彩 KPI 底部:投注/中奖/奖池拆分,有派彩时再附饼图 */
export function PayoutPanelSnapshot({
finance,
formatMoney,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const hasPayout = win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
}
const cells = [
{ key: "bet", label: t("currentDrawBetTotal"), amount: bet, emphasize: bet > 0 },
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
] as const;
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
{cells.map((cell) => (
<div
key={cell.key}
className={cn(
"rounded-lg px-1.5 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
<p
className={cn(
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
>
{formatMoney(cell.amount, currency)}
</p>
</div>
))}
</div>
{hasPayout ? (
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
) : (
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
{t("noPayoutYet")}
</p>
)}
</div>
);
}
export function CapUsageBar({
@@ -134,12 +540,15 @@ export function CapUsageBar({
usagePct,
formatMoney,
currency,
compact = false,
}: {
locked: number;
cap: number;
usagePct: number;
formatMoney: MoneyFormatter;
currency: string | null;
/** 嵌入 DashboardPanelCard 时隐藏底部说明、缩小图表 */
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const pct = Math.min(100, Math.max(0, usagePct));
@@ -150,6 +559,24 @@ export function CapUsageBar({
);
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) {
return (
<div
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("riskCapUsage")}
>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, backgroundColor: fill }}
/>
</div>
);
}
return (
<div className="space-y-4">
<ChartContainer
@@ -178,7 +605,9 @@ export function CapUsageBar({
const { cx, cy } = viewBox as { cx: number; cy: number };
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan className="fill-foreground text-2xl font-bold">{pct.toFixed(1)}%</tspan>
<tspan className="fill-foreground text-2xl font-bold">
{pct.toFixed(1)}%
</tspan>
</text>
);
}}
@@ -240,7 +669,7 @@ export function FinanceStructureChart({
<YAxis type="category" dataKey="segment" hide width={0} />
<ChartTooltip
content={
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
<ChartTooltipContent formatter={(value) => formatMoney(Number(value), currency)} />
}
/>
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
@@ -259,9 +688,11 @@ export function FinanceStructureChart({
export function PayoutCompositionChart({
finance,
formatMoney,
compact = false,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
@@ -279,7 +710,7 @@ export function PayoutCompositionChart({
);
if (total <= 0) {
return <DashboardChartEmpty message={t("noPayoutYet")} />;
return <DashboardChartEmpty message={t("noPayoutYet")} compact={compact} />;
}
const pieData = [
@@ -288,7 +719,13 @@ export function PayoutCompositionChart({
];
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
<ChartContainer
config={chartConfig}
className={cn(
"mx-auto aspect-square w-full",
compact ? "h-[72px] max-w-[88px]" : "h-[220px] max-w-[280px]",
)}
>
<PieChart>
<ChartTooltip
content={
@@ -310,13 +747,21 @@ export function PayoutCompositionChart({
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
{compact ? null : (
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
)}
</PieChart>
</ChartContainer>
);
}
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
export function HotUsageBars({
rows,
compact = false,
}: {
rows: AdminRiskPoolRow[];
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
@@ -337,7 +782,9 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
return <DashboardChartEmpty message={t("noPoolData")} />;
}
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
const chartHeight = compact
? Math.min(220, Math.max(120, rows.length * 22 + 36))
: Math.min(420, Math.max(160, rows.length * 32 + 48));
return (
<ChartContainer
@@ -445,7 +892,13 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
);
}
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
export function ResultBatchProgress({
draw,
compact = false,
}: {
draw: AdminDashboardDrawPanel;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const { total, pending_review, published } = draw.result_batch_counts;
const other = Math.max(0, total - pending_review - published);
@@ -462,6 +915,43 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
const chartData = [{ row: "batches", pending: pending_review, published, other }];
const statCells = (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-amber-500/8 px-2 py-2 ring-1 ring-amber-500/15">
<p
className={cn(
"font-bold tabular-nums text-amber-700 dark:text-amber-400",
compact ? "text-lg" : "text-2xl",
)}
>
{pending_review}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
<p
className={cn(
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
compact ? "text-lg" : "text-2xl",
)}
>
{published}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
{total}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchTotal")}</p>
</div>
</div>
);
if (compact) {
return statCells;
}
return (
<div className="space-y-4">
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
@@ -478,20 +968,7 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
</BarChart>
</ChartContainer>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums text-emerald-600">{published}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums">{total}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchTotal")}</p>
</div>
</div>
{statCells}
</div>
);
}