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

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
Breadcrumb,
BreadcrumbItem,
@@ -11,13 +12,12 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav";
import { CONFIG_ROUTE_LABELS } from "@/modules/config/config-nav-model";
import React from "react";
const DRAW_ROUTE_LABELS: Record<string, string> = {
finance: "期号收支",
review: "审核",
results: "开奖结果",
finance: "Draw Finance",
review: "Review",
results: "Results",
};
function titleCase(value: string): string {
@@ -34,15 +34,16 @@ type BreadcrumbCrumb = {
};
export function AdminBreadcrumb() {
const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]);
const pathname = usePathname();
// 把路径拆分成段
// Split the current path into segments.
const segments = pathname.split("/").filter(Boolean);
// 基础面包屑:首页/仪表盘
// Base breadcrumb: home / dashboard.
const breadcrumbs: BreadcrumbCrumb[] = [
{
label: "首页",
label: t("nav.home", { ns: "common" }),
href: ADMIN_BASE,
isCurrent: pathname === ADMIN_BASE,
},
@@ -56,8 +57,16 @@ 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" }),
};
breadcrumbs.push({
label: navItem.segment === "draws" ? "期号列表" : navItem.label,
label:
navItem.segment === "draws"
? "Draws"
: navLabelMap[navItem.segment] ?? navItem.label,
href: navItem.href,
isCurrent: pathname === navItem.href || segments.length === 2,
});
@@ -67,9 +76,14 @@ export function AdminBreadcrumb() {
const subSegment = segments[2];
let subLabel = "";
if (businessSegment === "config" && subSegment) {
subLabel = CONFIG_ROUTE_LABELS[subSegment] ?? titleCase(subSegment);
subLabel = t(`nav.items.${subSegment}`, { ns: "config", defaultValue: titleCase(subSegment) });
} else {
subLabel = subSegment ? DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment) : "";
subLabel = subSegment
? t(`subnav.${subSegment}`, {
ns: "draws",
defaultValue: DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment),
})
: "";
}
if (subLabel) {
breadcrumbs.push({

View File

@@ -2,8 +2,9 @@
import * as React from "react";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { enUS } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -16,16 +17,18 @@ export function AdminDateField({
label,
value,
onChange,
placeholder = "选择日期",
placeholder,
}: {
id: string;
label: string;
/** `yyyy-MM-dd` 或空 */
/** `yyyy-MM-dd` or empty */
value: string;
onChange: (next: string) => void;
placeholder?: string;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const resolvedPlaceholder = placeholder ?? t("date.placeholder", { ns: "common", defaultValue: "Select date" });
const parsed = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
@@ -35,7 +38,7 @@ export function AdminDateField({
return Number.isNaN(d.getTime()) ? undefined : d;
}, [value]);
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
const summary = parsed ? format(parsed, "yyyy-MM-dd", { locale: enUS }) : resolvedPlaceholder;
return (
<div className="grid gap-1.5">
@@ -62,7 +65,7 @@ export function AdminDateField({
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={zhCN}
locale={enUS}
captionLayout="dropdown"
selected={parsed}
defaultMonth={parsed}
@@ -82,7 +85,7 @@ export function AdminDateField({
setOpen(false);
}}
>
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
</Button>
</div>
</PopoverContent>

View File

@@ -3,8 +3,9 @@
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { enUS } from "date-fns/locale";
import { CalendarRange } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -31,19 +32,19 @@ function summarize(from: string, to: string, placeholder: string): string {
if (!df && !dt) {
return placeholder;
}
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "";
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "";
return `${a} ${b}`;
const a = df ? format(df, "yyyy-MM-dd", { locale: enUS }) : "...";
const b = dt ? format(dt, "yyyy-MM-dd", { locale: enUS }) : "...";
return `${a} - ${b}`;
}
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
/** Range date picker compatible with legacy `yyyy-MM-dd` filter fields. */
export function AdminDateRangeField({
id,
label,
from: fromProp,
to: toProp,
onRangeChange,
placeholder = "选择日期范围",
placeholder,
}: {
id: string;
label?: string;
@@ -52,8 +53,11 @@ export function AdminDateRangeField({
onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const isMobile = useIsMobile();
const resolvedPlaceholder =
placeholder ?? t("date.rangePlaceholder", { ns: "common", defaultValue: "Select date range" });
const selected = React.useMemo((): DateRange | undefined => {
const df = parseYmd(fromProp);
@@ -98,16 +102,20 @@ export function AdminDateRangeField({
>
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">
{summarize(fromProp, toProp, placeholder)}
{summarize(fromProp, toProp, resolvedPlaceholder)}
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{t("date.rangeHint", {
ns: "common",
defaultValue:
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
})}
</p>
<Calendar
mode="range"
locale={zhCN}
locale={enUS}
captionLayout="dropdown"
selected={selected}
defaultMonth={defaultMonth}
@@ -133,7 +141,7 @@ export function AdminDateRangeField({
setOpen(false);
}}
>
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
</Button>
<Button
type="button"
@@ -144,7 +152,7 @@ export function AdminDateRangeField({
setOpen(false);
}}
>
{t("actions.done", { ns: "common", defaultValue: "Done" })}
</Button>
</div>
</PopoverContent>

View File

@@ -37,7 +37,11 @@ export function AdminLanguageSwitcher() {
applyAdminUiLocale(next);
await i18n.changeLanguage(next);
setLocale(next);
toast.success(`${t("language.changed", { defaultValue: "语言已切换" })}: ${ADMIN_LOCALE_LABELS[next]}`);
toast.success(
t("language.changed", {
language: ADMIN_LOCALE_LABELS[next],
}),
);
}
return (
@@ -49,7 +53,7 @@ export function AdminLanguageSwitcher() {
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("language.title", { defaultValue: "界面语言" })}
{t("language.title")}
</DropdownMenuLabel>
{ADMIN_API_LOCALES.map((code) => (
<DropdownMenuItem

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import {
Pagination,
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
/** Server-side pagination page slices with ellipsis markers. */
export function adminListPageSlices(
current: number,
last: number,
@@ -58,15 +59,16 @@ export function AdminPerPagePicker({
perPage,
onChange,
}: {
/** 表单控件 id · 区分同页多块列表 */
/** Form control id used to distinguish multiple lists on one page. */
selectId: string;
perPage: number;
onChange: (next: number) => void;
}) {
const { t } = useTranslation(["common"]);
return (
<div className="flex items-center gap-2">
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
{t("pagination.perPage", { defaultValue: "Per page" })}
</Label>
<Select
modal={false}
@@ -79,7 +81,7 @@ export function AdminPerPagePicker({
}}
>
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
<SelectValue placeholder="请选择" />
<SelectValue placeholder={t("pagination.selectPlaceholder", { defaultValue: "Select" })} />
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
@@ -93,7 +95,7 @@ export function AdminPerPagePicker({
);
}
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination与 Data Table pagination 一节一致) */
/** Table footer: left summary, right per-page picker and pagination. */
export function AdminListPaginationFooter({
selectId,
total,
@@ -113,10 +115,16 @@ export function AdminListPaginationFooter({
onPerPageChange: (nextPerPage: number) => void;
onPageChange: (nextPage: number) => void;
}) {
const { t } = useTranslation(["common"]);
return (
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{total} {page} / {lastPage}
{t("pagination.summary", {
total,
page,
lastPage,
defaultValue: "{{total}} total, page {{page}} / {{lastPage}}",
})}
</p>
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
<AdminPerPagePicker
@@ -132,7 +140,7 @@ export function AdminListPaginationFooter({
<PaginationItem>
<PaginationPrevious
href="#"
text="上一页"
text={t("pagination.previous", { defaultValue: "Previous" })}
aria-disabled={page <= 1 || loading}
className={cn(
(page <= 1 || loading) && "pointer-events-none opacity-50",
@@ -175,7 +183,7 @@ export function AdminListPaginationFooter({
<PaginationItem>
<PaginationNext
href="#"
text="下一页"
text={t("pagination.next", { defaultValue: "Next" })}
aria-disabled={page >= lastPage || loading}
className={cn(
(page >= lastPage || loading) && "pointer-events-none opacity-50",

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { SparklesIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Sidebar,
@@ -33,6 +34,7 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st
}
export function AdminAppSidebar() {
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]);
const pathname = usePathname();
const profile = useAdminProfile();
const visibleNav = useMemo(
@@ -55,7 +57,7 @@ export function AdminAppSidebar() {
<SparklesIcon data-icon="inline-start" aria-hidden />
<div className="flex flex-col items-start gap-0 group-data-[collapsible=icon]:hidden">
<span className="font-semibold tracking-tight text-sidebar-foreground">
{t("app.title", { ns: "common" })}
</span>
<span className="text-[11px] leading-tight text-sidebar-foreground/70">
Lottery Admin
@@ -67,7 +69,7 @@ export function AdminAppSidebar() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleNav.map((item) => {
@@ -75,12 +77,12 @@ export function AdminAppSidebar() {
return (
<SidebarMenuItem key={item.segment}>
<SidebarMenuButton
tooltip={item.label}
tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}
isActive={isActive(pathname, item)}
render={<Link href={item.href} />}
>
<Icon data-icon="inline-start" aria-hidden />
<span>{item.label}</span>
<span>{t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { readToken } from "@/stores/admin-token";
@@ -10,10 +11,11 @@ type ShellAuthGateProps = {
};
/**
* 壳内路由:仅客户端可读 `localStorage` Token无 Token 时跳转登录。
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
* Shell route guard. Reads the auth token from localStorage on the client and
* redirects to the login page when no token is present.
*/
export function ShellAuthGate({ children }: ShellAuthGateProps) {
const { t } = useTranslation("common");
const router = useRouter();
const [allowed, setAllowed] = useState(false);
@@ -31,7 +33,7 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
if (!allowed) {
return (
<div className="flex min-h-[50vh] w-full flex-1 items-center justify-center text-sm text-muted-foreground">
{t("auth.checking", { defaultValue: "Checking sign-in status…" })}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { ShieldCheckIcon, TriangleAlertIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { isAxiosError } from "axios";
@@ -18,6 +19,7 @@ import { useAdminSessionStore } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
export function LoginForm() {
const { t } = useTranslation("auth");
const router = useRouter();
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
@@ -43,7 +45,7 @@ export function LoginForm() {
try {
const data = await getAdminCaptcha();
if (!data) {
toast.error("无法获取验证码,请检查接口或网络");
toast.error(t("captchaLoadFailed"));
setCaptchaKey(null);
setCaptchaSrc(null);
@@ -55,7 +57,7 @@ export function LoginForm() {
} finally {
setLoadingCaptcha(false);
}
}, [apiConfigured]);
}, [apiConfigured, t]);
useEffect(() => {
if (readToken()) {
@@ -73,12 +75,12 @@ export function LoginForm() {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!apiConfigured) {
toast.error("未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL");
toast.error(t("apiBaseMissingToast"));
return;
}
if (!captchaKey || !captchaSrc) {
toast.error("请先刷新验证码");
toast.error(t("captchaRequired"));
void loadCaptcha();
return;
@@ -94,7 +96,9 @@ export function LoginForm() {
});
setBearerToken(result.token);
setAdminProfile(result.admin);
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
toast.success(
t("welcome", { name: result.admin.nickname || result.admin.username }),
);
router.replace("/admin");
router.refresh();
} catch (err) {
@@ -105,11 +109,11 @@ export function LoginForm() {
return;
}
if (isAxiosError(err)) {
toast.error(err.message || "网络请求失败");
toast.error(err.message || t("networkFailed"));
return;
}
toast.error("登录失败");
toast.error(t("loginFailed"));
} finally {
setSubmitting(false);
}
@@ -142,7 +146,7 @@ export function LoginForm() {
</div>
<div className="space-y-1.5">
<CardTitle className="text-balance text-2xl font-semibold tracking-tight">
{authModuleMeta.title}
{t("loginTitle", { defaultValue: authModuleMeta.title })}
</CardTitle>
</div>
</CardHeader>
@@ -151,19 +155,19 @@ export function LoginForm() {
{!apiConfigured ? (
<Alert variant="destructive" className="text-left">
<TriangleAlertIcon />
<AlertTitle> API </AlertTitle>
<AlertTitle>{t("apiMissingTitle")}</AlertTitle>
<AlertDescription>
{" "}
{t("apiMissingDescriptionPrefix")}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
NEXT_PUBLIC_LOTTERY_API_BASE_URL
</code>{" "}
Laravel URL http://127.0.0.1:8000
{t("apiMissingDescriptionSuffix")}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="admin-account" className="text-sm font-medium">
{t("account")}
</Label>
<Input
id="admin-account"
@@ -171,7 +175,7 @@ export function LoginForm() {
autoComplete="username"
value={account}
onChange={(ev) => setAccount(ev.target.value)}
placeholder="登录账号"
placeholder={t("accountPlaceholder")}
required
disabled={submitting}
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
@@ -179,7 +183,7 @@ export function LoginForm() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="admin-password" className="text-sm font-medium">
{t("password")}
</Label>
<Input
id="admin-password"
@@ -188,7 +192,7 @@ export function LoginForm() {
autoComplete="current-password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
placeholder="密码"
placeholder={t("passwordPlaceholder")}
required
disabled={submitting}
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
@@ -196,7 +200,7 @@ export function LoginForm() {
</div>
<div className="mb-8 flex flex-col gap-2">
<Label htmlFor="admin-captcha" className="text-sm font-medium">
{t("captcha")}
</Label>
<div className="flex flex-wrap items-center gap-3">
<Input
@@ -205,7 +209,7 @@ export function LoginForm() {
autoComplete="off"
value={captchaCode}
onChange={(ev) => setCaptchaCode(ev.target.value)}
placeholder="图中字符"
placeholder={t("captchaPlaceholder")}
maxLength={32}
required
disabled={submitting}
@@ -218,7 +222,7 @@ export function LoginForm() {
disabled={
loadingCaptcha || !apiConfigured || submitting
}
aria-label={loadingCaptcha ? "加载验证码中" : "点击刷新验证码"}
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
>
{captchaSrc ? (
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
@@ -231,7 +235,7 @@ export function LoginForm() {
/>
) : (
<span className="px-2 text-xs text-muted-foreground">
{loadingCaptcha ? "加载中…" : "点击获取"}
{loadingCaptcha ? t("captchaLoading") : t("captchaFetch")}
</span>
)}
</button>
@@ -245,7 +249,7 @@ export function LoginForm() {
className="h-11 w-full text-base font-medium shadow-sm"
disabled={submitting || !apiConfigured}
>
{submitting ? "登录中…" : "登录"}
{submitting ? t("submitting") : t("submit")}
</Button>
</CardFooter>
</form>

View File

@@ -7,6 +7,7 @@ import {
UserRoundIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
@@ -64,6 +65,7 @@ function initialsFromProfile(profile: AdminProfile | null): string {
}
export function ShellToolbar() {
const { t } = useTranslation("common");
const router = useRouter();
const adminProfile = useAdminProfile();
const clearSession = useAdminSessionStore((s) => s.clearSession);
@@ -71,11 +73,11 @@ export function ShellToolbar() {
const displayName =
adminProfile?.nickname?.trim() ||
adminProfile?.username?.trim() ||
"管理员";
t("toolbar.defaultAdmin");
function onLogout() {
clearSession();
toast.success("已退出登录");
toast.success(t("toolbar.loggedOut"));
router.replace("/admin/login");
router.refresh();
}
@@ -87,9 +89,9 @@ export function ShellToolbar() {
variant="ghost"
size="icon"
className="relative shrink-0 text-primary hover:text-primary"
aria-label="通知"
title="通知"
onClick={() => toast.message("通知功能开发中")}
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">
@@ -132,7 +134,7 @@ export function ShellToolbar() {
<DropdownMenuGroup>
<DropdownMenuItem disabled className="gap-2">
<UserRoundIcon />
{t("toolbar.accountSettings")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -143,7 +145,7 @@ export function ShellToolbar() {
onClick={onLogout}
>
<LogOutIcon />
退
{t("actions.logout")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>