feat: 优化语言切换下拉交互并支持玩法规则多语言内容
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
import "@/i18n";
|
||||
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -28,7 +29,6 @@ export function LanguageSwitcher({
|
||||
const { i18n, t } = useTranslation("common");
|
||||
const active = normalizeLanguage(i18n.language) as AppLanguage;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
@@ -40,22 +40,6 @@ export function LanguageSwitcher({
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
async function handleSelect(code: AppLanguage): Promise<void> {
|
||||
await i18n.changeLanguage(code);
|
||||
setIsOpen(false);
|
||||
@@ -91,72 +75,74 @@ export function LanguageSwitcher({
|
||||
menuAlign ?? (variant === "header" || variant === "default" ? "start" : "end");
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative inline-block", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn("inline-flex", className)}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
||||
styles.button,
|
||||
)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Language (${currentLabel})`}
|
||||
>
|
||||
<Globe className="size-4" aria-hidden />
|
||||
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
|
||||
{showLabel ? <span>{currentLabel}</span> : null}
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
||||
styles.button,
|
||||
"z-[200] w-auto min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900",
|
||||
styles.dropdown,
|
||||
)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Language (${currentLabel})`}
|
||||
>
|
||||
<Globe className="size-4" aria-hidden />
|
||||
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
|
||||
{showLabel ? <span>{currentLabel}</span> : null}
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-[100] mt-1 min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] rounded-lg py-1 text-gray-900 shadow-md",
|
||||
align === "start" ? "left-0" : "right-0",
|
||||
styles.dropdown,
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => void handleSelect(option.code)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||
active === option.code ? styles.activeItem : styles.item,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={active === option.code}
|
||||
>
|
||||
<span className="text-lg">{option.flag}</span>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs opacity-60">{option.short}</span>
|
||||
</div>
|
||||
{active === option.code ? (
|
||||
<svg
|
||||
className="ml-auto size-4 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<title>{option.label}</title>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-[min(280px,50dvh)] overflow-y-auto" role="listbox">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => void handleSelect(option.code)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
active === option.code ? styles.activeItem : styles.item,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={active === option.code}
|
||||
>
|
||||
<span className="text-lg">{option.flag}</span>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs opacity-60">{option.short}</span>
|
||||
</div>
|
||||
{active === option.code ? (
|
||||
<svg
|
||||
className="ml-auto size-4 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,6 +152,11 @@ export function LanguageSwitcherMinimal({
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<LanguageSwitcher variant="minimal" className={className} showFlag={false} />
|
||||
<LanguageSwitcher
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
className={className}
|
||||
showFlag={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ export function PlayerPanel({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className={cn(playerPageHeader, "sticky top-0 z-50 bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3")}>
|
||||
<header
|
||||
className={cn(
|
||||
playerPageHeader,
|
||||
"sticky top-0 z-50 overflow-visible bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 justify-start">
|
||||
<Link
|
||||
href={backHref}
|
||||
@@ -67,6 +72,7 @@ export function PlayerPanel({
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<LanguageSwitcher
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
showFlag={false}
|
||||
className={cn(
|
||||
playerHeaderControl,
|
||||
|
||||
@@ -26,8 +26,8 @@ export function HallScreen() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<section className={cn("overflow-hidden bg-white text-slate-900", playerPageInset)}>
|
||||
<header className="mb-2 flex min-h-9 items-center gap-2">
|
||||
<section className={cn("bg-white text-slate-900", playerPageInset)}>
|
||||
<header className="relative z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
@@ -41,6 +41,7 @@ export function HallScreen() {
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<LanguageSwitcher
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
showFlag={false}
|
||||
className={cn(
|
||||
playerHeaderControl,
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Loader2 } from "lucide-react";
|
||||
|
||||
import { getPublicSettings } from "@/api";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function PlayRulesScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const { t, i18n } = useTranslation("player");
|
||||
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -17,9 +18,9 @@ export function PlayRulesScreen() {
|
||||
async function loadRules() {
|
||||
try {
|
||||
const res = await getPublicSettings("frontend");
|
||||
const ruleItem = res.items.find((item) => item.key === "frontend.play_rules_html");
|
||||
if (ruleItem && typeof ruleItem.value === "string" && ruleItem.value.trim() !== "") {
|
||||
setHtmlContent(ruleItem.value);
|
||||
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
||||
if (html) {
|
||||
setHtmlContent(html);
|
||||
} else {
|
||||
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.empty", { defaultValue: "暂无玩法规则说明" })}</div>`);
|
||||
}
|
||||
@@ -30,7 +31,7 @@ export function PlayRulesScreen() {
|
||||
}
|
||||
}
|
||||
void loadRules();
|
||||
}, [t]);
|
||||
}, [i18n.language, t]);
|
||||
|
||||
return (
|
||||
<PlayerPanel
|
||||
|
||||
38
src/lib/play-rules-html.ts
Normal file
38
src/lib/play-rules-html.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { normalizeLanguage, type AppLanguage } from "@/i18n/language";
|
||||
|
||||
const KEY_LEGACY = "frontend.play_rules_html";
|
||||
const KEY_ZH = "frontend.play_rules_html_zh";
|
||||
const KEY_EN = "frontend.play_rules_html_en";
|
||||
const KEY_NE = "frontend.play_rules_html_ne";
|
||||
|
||||
type SettingItem = { key: string; value: unknown };
|
||||
|
||||
function asNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** 从 public settings 列表中按玩家语言选取玩法规则 HTML */
|
||||
export function resolvePlayRulesHtml(
|
||||
items: SettingItem[],
|
||||
language: string | undefined,
|
||||
): string | null {
|
||||
const kv = new Map(items.map((item) => [item.key, item.value]));
|
||||
const legacy = asNonEmptyString(kv.get(KEY_LEGACY));
|
||||
const zh = asNonEmptyString(kv.get(KEY_ZH));
|
||||
const en = asNonEmptyString(kv.get(KEY_EN));
|
||||
const ne = asNonEmptyString(kv.get(KEY_NE));
|
||||
|
||||
const lang: AppLanguage = normalizeLanguage(language);
|
||||
|
||||
if (lang === "zh") {
|
||||
return zh ?? legacy;
|
||||
}
|
||||
if (lang === "ne") {
|
||||
return ne ?? en ?? zh ?? legacy;
|
||||
}
|
||||
return en ?? zh ?? legacy;
|
||||
}
|
||||
Reference in New Issue
Block a user