feat: 统一管理端多语言、配置与票据/结算页面重构

This commit is contained in:
2026-05-20 16:27:06 +08:00
parent 37b13278ef
commit 08a11a1589
81 changed files with 2059 additions and 490 deletions

View File

@@ -21,6 +21,23 @@ const DRAW_ROUTE_LABELS: Record<string, string> = {
results: "Results",
};
const NAV_TRANSLATION_KEYS: Record<string, string> = {
dashboard: "dashboard",
admin_users: "admin_users",
admin_roles: "admin_roles",
players: "players",
wallet: "wallet",
draws: "draws",
config: "config",
risk: "risk",
settlement: "settlement",
reconcile: "reconcile",
tickets: "tickets",
reports: "reports",
audit: "audit",
settings: "settings",
};
function titleCase(value: string): string {
return value
.split("-")
@@ -60,22 +77,24 @@ export function AdminBreadcrumb() {
});
if (navItem && navItem.href !== ADMIN_BASE) {
const navLabelMap: Record<string, string> = {
dashboard: t("title", { ns: "dashboard" }),
reports: t("title", { ns: "reports" }),
"audit-logs": t("title", { ns: "audit" }),
};
const translatedNavLabel =
NAV_TRANSLATION_KEYS[navItem.segment] != null
? t(`nav.${NAV_TRANSLATION_KEYS[navItem.segment]}`, {
ns: "common",
defaultValue: navItem.label,
})
: navItem.label;
breadcrumbs.push({
label:
navItem.segment === "draws"
? "Draws"
: navLabelMap[navItem.segment] ?? navItem.label,
label: translatedNavLabel,
href: navItem.href,
isCurrent: pathname === navItem.href || segments.length === 2,
});
} else {
breadcrumbs.push({
label: titleCase(businessSegment),
label: t(`nav.${businessSegment}`, {
ns: "common",
defaultValue: titleCase(businessSegment),
}),
href: `${ADMIN_BASE}/${businessSegment}`,
isCurrent: segments.length === 2,
});
@@ -111,7 +130,7 @@ export function AdminBreadcrumb() {
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
const itemKey = `${crumb.href}-${index}`;
return (
<React.Fragment key={itemKey}>
<BreadcrumbItem>

View File

@@ -20,6 +20,13 @@ import {
getAdminRequestLocale,
type AdminApiLocale,
} from "@/lib/admin-locale";
import { cn } from "@/lib/utils";
const LOCALE_FLAGS: Record<AdminApiLocale, string> = {
zh: "🇨🇳",
en: "🇺🇸",
ne: "🇳🇵",
};
export function AdminLanguageSwitcher() {
const { i18n, t } = useTranslation("common");
@@ -44,32 +51,60 @@ export function AdminLanguageSwitcher() {
);
}
const currentFlag = LOCALE_FLAGS[locale];
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<GlobeIcon className="size-4 stroke-[1.75]" aria-hidden />
<span className="font-medium uppercase">{locale}</span>
<DropdownMenuTrigger className="inline-flex h-8 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2 text-left text-slate-700 shadow-[0_1px_2px_rgba(15,23,42,0.04)] outline-none transition hover:border-slate-300 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-ring">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs">
{currentFlag}
</span>
<GlobeIcon
className="size-4 shrink-0 stroke-[1.75] text-slate-400 sm:hidden"
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuContent
align="end"
className="w-[188px] overflow-hidden rounded-[20px] border border-slate-200 bg-white p-1 shadow-[0_16px_40px_rgba(15,23,42,0.12)]"
>
<DropdownMenuGroup className="space-y-0.5">
<DropdownMenuLabel className="sr-only">
{t("language.title")}
</DropdownMenuLabel>
{ADMIN_API_LOCALES.map((code) => (
<DropdownMenuItem
key={code}
className="gap-2"
onClick={() => void onSelectLocale(code)}
>
{locale === code ? (
<CheckIcon className="size-4 opacity-100" />
) : (
<span className="size-4 shrink-0" aria-hidden />
)}
<span className="flex-1">{ADMIN_LOCALE_LABELS[code]}</span>
<span className="text-xs text-muted-foreground uppercase">{code}</span>
</DropdownMenuItem>
))}
{ADMIN_API_LOCALES.map((code) => {
const active = locale === code;
return (
<DropdownMenuItem
key={code}
className={cn(
"flex min-h-[42px] items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 text-slate-700 outline-none transition",
active
? "border-rose-100 bg-rose-50 text-rose-600"
: "hover:bg-slate-50 focus:bg-slate-50",
)}
onClick={() => void onSelectLocale(code)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-white text-lg shadow-[inset_0_0_0_1px_rgba(148,163,184,0.16)]">
{LOCALE_FLAGS[code]}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[14px] font-semibold leading-5",
active ? "text-rose-600" : "text-slate-800",
)}
>
{ADMIN_LOCALE_LABELS[code]}
</span>
</span>
<span className="flex w-3 shrink-0 justify-end">
{active ? <CheckIcon className="size-3.5 text-rose-500" /> : null}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -16,7 +16,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
return (
<SidebarProvider defaultOpen>
<AdminAppSidebar />
<SidebarInset className="max-md:overflow-x-hidden">
<SidebarInset className="min-w-0 overflow-x-hidden max-md:overflow-x-hidden">
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1.5 h-4" />
@@ -25,7 +25,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
<ShellToolbar />
</div>
</header>
<div className="flex flex-1 flex-col px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
<div className="flex min-w-0 flex-1 flex-col overflow-x-hidden px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
{children}
</div>
</SidebarInset>

View File

@@ -91,7 +91,7 @@ export function AdminTableExportButton({
};
return (
<Button type="button" size="sm" variant="secondary" className="h-8 px-3" onClick={onExport}>
<Button type="button" size="sm" variant="secondary" onClick={onExport}>
<Download className="size-4" />
{label ?? t("actions.exportExcel", { defaultValue: "导出 Excel" })}
</Button>

View File

@@ -9,5 +9,5 @@ type ModuleScaffoldProps = {
/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */
export function ModuleScaffold({ children, className }: ModuleScaffoldProps) {
return <div className={cn("mx-auto w-full max-w-none", className)}>{children}</div>;
return <div className={cn("mx-auto w-full max-w-none min-w-0", className)}>{children}</div>;
}

View File

@@ -1,7 +1,6 @@
"use client";
import {
BellIcon,
ChevronDownIcon,
LogOutIcon,
UserRoundIcon,
@@ -11,8 +10,6 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,47 +19,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import {
useAdminProfile,
useAdminSessionStore,
} from "@/stores/admin-session";
import type { AdminProfile } from "@/types/api/admin-auth";
/** 暂未接入通知中心时的占位未读数(与设计稿一致可改为接口数据) */
const NOTIFICATION_PLACEHOLDER_COUNT = 6;
function initialsFromProfile(profile: AdminProfile | null): string {
if (!profile) {
return "—";
}
const s = (profile.nickname?.trim() || profile.username?.trim() || "").trim();
if (!s) {
return "?";
}
const runes = Array.from(s);
if (runes.length === 1) {
return runes[0].toUpperCase();
}
const cp = runes[0].codePointAt(0);
const isCjk =
cp !== undefined &&
((cp >= 0x4e00 && cp <= 0x9fff) ||
(cp >= 0x3400 && cp <= 0x4dbf) ||
(cp >= 0xf900 && cp <= 0xfaff));
if (isCjk) {
return runes.slice(0, 2).join("");
}
const parts = s.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
const a = parts[0][0];
const b = parts[1][0];
return `${a}${b}`.toUpperCase();
}
return s.slice(0, 2).toUpperCase();
}
export function ShellToolbar() {
const { t } = useTranslation("common");
@@ -84,40 +44,14 @@ export function ShellToolbar() {
return (
<div className="flex items-center gap-2 sm:gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="relative shrink-0 text-primary hover:text-primary"
aria-label={t("toolbar.notifications")}
title={t("toolbar.notifications")}
onClick={() => toast.message(t("toolbar.notificationsComingSoon"))}
>
<BellIcon className="size-5 stroke-[1.75]" />
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-2 ring-background">
{NOTIFICATION_PLACEHOLDER_COUNT > 99
? "99+"
: NOTIFICATION_PLACEHOLDER_COUNT}
</span>
</Button>
<Separator orientation="vertical" className="mx-0.5 h-7" />
<AdminLanguageSwitcher />
<Separator orientation="vertical" className="mx-0.5 h-7" />
<DropdownMenu>
<DropdownMenuTrigger className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg px-1.5 py-1 text-left outline-none hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring sm:max-w-[16rem]">
<Avatar size="sm" className="ring-1 ring-border">
<AvatarFallback className="bg-muted text-xs font-medium text-foreground">
{initialsFromProfile(adminProfile)}
</AvatarFallback>
</Avatar>
<span className="hidden min-w-0 flex-1 truncate text-sm font-semibold leading-tight sm:block">
<DropdownMenuTrigger className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg px-2 py-1.5 text-left outline-none hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring sm:max-w-[16rem]">
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-tight">
{displayName}
</span>
<ChevronDownIcon className="hidden size-4 shrink-0 text-muted-foreground sm:block" />
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuGroup>