feat: 增加多语言
This commit is contained in:
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
GlobeIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
@@ -21,6 +24,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
ADMIN_API_LOCALES,
|
||||||
|
ADMIN_LOCALE_LABELS,
|
||||||
|
applyAdminUiLocale,
|
||||||
|
getAdminRequestLocale,
|
||||||
|
type AdminApiLocale,
|
||||||
|
} from "@/lib/admin-locale";
|
||||||
import {
|
import {
|
||||||
useAdminProfile,
|
useAdminProfile,
|
||||||
useAdminSessionStore,
|
useAdminSessionStore,
|
||||||
@@ -68,6 +78,17 @@ export function ShellToolbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const adminProfile = useAdminProfile();
|
const adminProfile = useAdminProfile();
|
||||||
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
||||||
|
const [locale, setLocale] = useState<AdminApiLocale>(() =>
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? getAdminRequestLocale()
|
||||||
|
: "zh",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLocale(getAdminRequestLocale());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
adminProfile?.nickname?.trim() ||
|
adminProfile?.nickname?.trim() ||
|
||||||
@@ -81,6 +102,13 @@ export function ShellToolbar() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectLocale(next: AdminApiLocale) {
|
||||||
|
applyAdminUiLocale(next);
|
||||||
|
setLocale(next);
|
||||||
|
toast.success(`语言:${ADMIN_LOCALE_LABELS[next]}`);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -102,6 +130,39 @@ export function ShellToolbar() {
|
|||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<GlobeIcon className="size-5 stroke-[1.75]" aria-hidden />
|
||||||
|
<span className="sr-only">语言</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[10rem]">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
界面语言 / Language
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{ADMIN_API_LOCALES.map((code) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={code}
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => 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>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
||||||
|
|
||||||
<DropdownMenu>
|
<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]">
|
<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">
|
<Avatar size="sm" className="ring-1 ring-border">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from "next-themes";
|
|||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { hydrateAdminUiLocale } from "@/lib/admin-locale";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
@@ -14,6 +15,7 @@ type ProvidersProps = {
|
|||||||
|
|
||||||
function AdminSessionHydrator() {
|
function AdminSessionHydrator() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
hydrateAdminUiLocale();
|
||||||
useAdminSessionStore.getState().rehydrate();
|
useAdminSessionStore.getState().rehydrate();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import axios, {
|
|||||||
} from "axios";
|
} from "axios";
|
||||||
|
|
||||||
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
||||||
|
import { withAdminLocaleHeaders } from "@/lib/admin-locale";
|
||||||
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ export async function publicAdminRequest<T>(
|
|||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const res = await adminHttp.request<unknown>(config);
|
const res = await adminHttp.request<unknown>(
|
||||||
|
withAdminLocaleHeaders(config),
|
||||||
|
);
|
||||||
return unwrapResponse<T>(res);
|
return unwrapResponse<T>(res);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isAxiosError(err) && err.response?.data !== undefined) {
|
if (isAxiosError(err) && err.response?.data !== undefined) {
|
||||||
@@ -54,7 +57,7 @@ export async function publicAdminRequest<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
const merged = withAdminAuthHeader(config);
|
const merged = withAdminAuthHeader(withAdminLocaleHeaders(config));
|
||||||
try {
|
try {
|
||||||
const res = await adminHttp.request<unknown>(merged);
|
const res = await adminHttp.request<unknown>(merged);
|
||||||
return unwrapResponse<T>(res);
|
return unwrapResponse<T>(res);
|
||||||
|
|||||||
127
src/lib/admin-locale.ts
Normal file
127
src/lib/admin-locale.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { AxiosHeaders, type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
/** 与 Laravel `NegotiateLotteryLocale`、`lottery.locales.supported`(zh/en/ne)一致 */
|
||||||
|
export type AdminApiLocale = "zh" | "en" | "ne";
|
||||||
|
|
||||||
|
export const ADMIN_API_LOCALES: readonly AdminApiLocale[] = ["zh", "en", "ne"];
|
||||||
|
|
||||||
|
export const ADMIN_LOCALE_LABELS: Record<AdminApiLocale, string> = {
|
||||||
|
zh: "简体中文",
|
||||||
|
en: "English",
|
||||||
|
ne: "नेपाली",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "lottery_admin_ui_locale";
|
||||||
|
|
||||||
|
let overrideLocale: AdminApiLocale | null = null;
|
||||||
|
|
||||||
|
function isApiLocale(value: string): value is AdminApiLocale {
|
||||||
|
return value === "zh" || value === "en" || value === "ne";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖后续 API 请求的 `X-Locale`(传 `null` 取消覆盖,改回 `document.documentElement.lang` / 默认) */
|
||||||
|
export function setAdminRequestLocale(locale: string | null): void {
|
||||||
|
if (locale === null) {
|
||||||
|
overrideLocale = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = locale.trim().toLowerCase().split("-")[0] ?? "";
|
||||||
|
overrideLocale = isApiLocale(p) ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLocale(): AdminApiLocale {
|
||||||
|
if (overrideLocale) {
|
||||||
|
return overrideLocale;
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const tag = document.documentElement.lang.trim().toLowerCase();
|
||||||
|
const primary = tag.split("-")[0] ?? tag;
|
||||||
|
if (isApiLocale(primary)) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前生效的语言(与即将发出的 API 头一致) */
|
||||||
|
export function getAdminRequestLocale(): AdminApiLocale {
|
||||||
|
return requestLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptLanguage(loc: AdminApiLocale): string {
|
||||||
|
if (loc === "zh") {
|
||||||
|
return "zh-CN,zh;q=0.9,en;q=0.8";
|
||||||
|
}
|
||||||
|
if (loc === "ne") {
|
||||||
|
return "ne,ne-NP;q=0.9,en;q=0.8";
|
||||||
|
}
|
||||||
|
return "en-US,en;q=0.9";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 `<html lang>` 对齐的 BCP 47 标签 */
|
||||||
|
export function adminHtmlLang(loc: AdminApiLocale): string {
|
||||||
|
if (loc === "zh") {
|
||||||
|
return "zh-Hans";
|
||||||
|
}
|
||||||
|
if (loc === "ne") {
|
||||||
|
return "ne";
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredUiLocale(): AdminApiLocale | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)?.trim().toLowerCase();
|
||||||
|
if (raw && isApiLocale(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredUiLocale(loc: AdminApiLocale): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换界面语言:写入 localStorage、同步 `document.documentElement.lang`、设置 API `X-Locale` 覆盖。
|
||||||
|
*/
|
||||||
|
export function applyAdminUiLocale(loc: AdminApiLocale): void {
|
||||||
|
setAdminRequestLocale(loc);
|
||||||
|
writeStoredUiLocale(loc);
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.lang = adminHtmlLang(loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动时从 localStorage 恢复(应在客户端尽早调用一次) */
|
||||||
|
export function hydrateAdminUiLocale(): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stored = readStoredUiLocale();
|
||||||
|
if (stored) {
|
||||||
|
setAdminRequestLocale(stored);
|
||||||
|
document.documentElement.lang = adminHtmlLang(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 `admin-http`:`X-Locale` + `Accept-Language` */
|
||||||
|
export function withAdminLocaleHeaders(
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
): AxiosRequestConfig {
|
||||||
|
const loc = requestLocale();
|
||||||
|
const merged: AxiosRequestConfig = { ...config };
|
||||||
|
const headers = AxiosHeaders.concat(
|
||||||
|
merged.headers as Parameters<typeof AxiosHeaders.concat>[0],
|
||||||
|
);
|
||||||
|
headers.set("X-Locale", loc);
|
||||||
|
headers.set("Accept-Language", acceptLanguage(loc));
|
||||||
|
merged.headers = headers;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user