feat: 统一管理端多语言、配置与票据/结算页面重构
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user