Files
lotteryFront/src/features/player/entry-gate.tsx
kang 587a6ad66c feat: 增强国际化支持与安全头配置
- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能
- 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入
- 更新 Providers 组件以引入 i18n 配置
- 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能
- 优化 HallWalletStrip 组件的网络状态管理逻辑
- 更新多个组件以支持国际化文本
2026-05-13 17:53:56 +08:00

468 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { isAxiosError } from "axios";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
ChevronRight,
Globe,
Loader2,
ShieldCheck,
} from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayerMe, getPlayerPing } from "@/api/player";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors";
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2000;
type EntryStepId = "token" | "account" | "hall";
type EntryStepStatus = "pending" | "in-progress" | "done" | "error";
type EntryStep = {
id: EntryStepId;
status: EntryStepStatus;
};
type Phase = "loading" | "success" | "failed";
type FailureRow = {
code?: string;
/** `entry` 命名空间下的 key例如 `errors.noTokenDetail` */
detailKey?: string;
/** 服务端或动态错误兜底 */
fallbackMessage?: string;
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function initialSteps(): EntryStep[] {
return [
{ id: "token", status: "pending" },
{ id: "account", status: "pending" },
{ id: "hall", status: "pending" },
];
}
function shouldRetryEntryRequest(error: unknown): boolean {
if (error instanceof LotteryApiBizError) {
return false;
}
if (isAxiosError(error)) {
if (error.code === "ECONNABORTED") {
return true;
}
if (!error.response) {
return true;
}
if (error.response.status >= 500) {
return true;
}
}
return false;
}
export function EntryGate() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("entry");
const { t: tc } = useTranslation("common");
const tokenFromUrl = searchParams.get("token") ?? "";
const { bearerToken, setBearerToken, setProfile, clearBearerToken } =
usePlayerSessionStore();
const [phase, setPhase] = useState<Phase>("loading");
const [progress, setProgress] = useState(0);
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const effectiveToken = tokenFromUrl || bearerToken;
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
}, []);
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
const doneCount = currentSteps.filter((s) => s.status === "done").length;
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
}, []);
useEffect(() => {
setProgress(calculateProgress(steps));
}, [steps, calculateProgress]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
}, []);
const doEntry = useCallback(async () => {
if (!effectiveToken) {
setPhase("failed");
setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]);
return;
}
if (tokenFromUrl) {
setBearerToken(tokenFromUrl);
}
setSteps((prev) =>
prev.map((s) => (s.id === "token" ? { ...s, status: "in-progress" } : s)),
);
await sleep(500);
let lastError: unknown = null;
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
updateStep("token", "done");
updateStep("account", "done");
setSteps((prev) =>
prev.map((s) => (s.id === "hall" ? { ...s, status: "in-progress" } : s)),
);
await Promise.all([getPlayerPing(), sleep(300)]);
updateStep("hall", "done");
setProfile(me);
setPhase("success");
await sleep(600);
router.replace("/hall");
return;
} catch (err) {
lastError = err;
if (err instanceof LotteryApiBizError) {
updateStep("token", "error");
setPhase("failed");
const details: FailureRow[] = [];
if (typeof err.code === "number") {
const keyByCode: Partial<Record<number, string>> = {
401: "errors.http401",
403: "errors.http403",
404: "errors.http404",
};
const dk = keyByCode[err.code];
details.push({
code: String(err.code),
...(dk ? { detailKey: dk } : {}),
fallbackMessage:
typeof err.message === "string" ? err.message : undefined,
});
}
const rows =
details.length > 0
? details
: [
{
fallbackMessage: err.message ?? t("errors.unknown"),
},
];
setFailureDetails(rows);
clearBearerToken();
return;
}
if (!shouldRetryEntryRequest(err)) {
updateStep("token", "error");
setPhase("failed");
setFailureDetails([{ code: "NETWORK_ERROR", detailKey: "errors.networkDetail" }]);
return;
}
if (attempt < RETRY_ATTEMPTS) {
await sleep(RETRY_DELAY_MS);
}
}
}
updateStep("token", "error");
setPhase("failed");
setFailureDetails([
{ code: "MAX_RETRIES", detailKey: "errors.maxRetriesDetail" },
{
fallbackMessage:
lastError instanceof Error ? lastError.message : t("errors.tryLater"),
},
]);
}, [
effectiveToken,
tokenFromUrl,
setBearerToken,
setProfile,
clearBearerToken,
router,
updateStep,
t,
]);
useEffect(() => {
const tmr = window.setTimeout(() => {
void doEntry();
}, 300);
return () => window.clearTimeout(tmr);
}, [doEntry]);
return (
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative h-[45vh] min-h-[320px] bg-red-600">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image
src="/entry/image1.png"
alt={t("header.backgroundAlt")}
fill
className="object-cover object-center"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-red-600/20 to-red-600/80" />
</div>
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
<LanguageSwitcher variant="header" showFlag={false} />
</div>
</div>
<div className="flex flex-1 flex-col px-4 py-6">
{phase === "loading" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded bg-red-600 text-white">
<Globe className="size-5" aria-hidden />
</div>
<span className="font-medium text-gray-800">{t("loading.title")}</span>
</div>
<div className="mb-6">
<div className="mb-2 flex justify-between text-xs text-gray-500">
<span>{t("loading.progress")}</span>
<span className="font-medium text-red-600">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-red-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-4">
{steps.map((step) => (
<div key={step.id} className="flex items-start gap-3">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
step.status === "done" &&
"border-green-500 bg-green-500 text-white",
step.status === "in-progress" &&
"border-blue-600 bg-blue-600 text-white",
step.status === "pending" &&
"border-gray-300 bg-gray-100 text-gray-400",
step.status === "error" && "border-red-500 bg-red-500 text-white",
)}
>
{step.status === "done" ? (
<CheckCircle2 className="size-4" aria-hidden />
) : null}
{step.status === "in-progress" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{step.status === "pending" ? (
<div className="size-2 rounded-full bg-gray-400" aria-hidden />
) : null}
{step.status === "error" ? (
<AlertCircle className="size-4" aria-hidden />
) : null}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span
className={cn(
"font-medium",
step.status === "done" && "text-green-600",
step.status === "in-progress" && "text-blue-600",
step.status === "pending" && "text-gray-600",
step.status === "error" && "text-red-600",
)}
>
{t(`steps.${step.id}.title`)}
</span>
<EntryStatusBadge status={step.status} />
</div>
<p className="text-xs text-gray-500">
{t(`steps.${step.id}.description`)}
</p>
</div>
</div>
))}
</div>
</div>
) : null}
{phase === "failed" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-red-600">{t("failure.title")}</h2>
<p className="text-sm text-gray-600">{t("failure.subtitle")}</p>
</div>
{failureDetails.length > 0 ? (
<div className="mb-6 overflow-hidden rounded-lg border border-red-200 bg-red-50">
<div className="border-b border-red-200 bg-red-100 px-4 py-2">
<span className="text-sm font-medium text-red-800">
{t("failure.detailsTitle")}
</span>
</div>
<table className="w-full text-sm">
<thead className="bg-red-100/50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.no")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.check")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.reason")}
</th>
</tr>
</thead>
<tbody>
{failureDetails.map((detail, idx) => (
<tr key={`${detail.code}-${idx}`} className="border-t border-red-100">
<td className="px-3 py-2 text-gray-600">{idx + 1}</td>
<td className="px-3 py-2 text-gray-800">
{detail.code ?? tc("errors.general")}
</td>
<td className="px-3 py-2 text-gray-600">
{detail.detailKey
? t(detail.detailKey)
: (detail.fallbackMessage ?? t("errors.unknown"))}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<Button
onClick={handleRetry}
className="w-full gap-2 bg-red-600 text-white hover:bg-red-700"
size="lg"
type="button"
>
<Loader2 className="size-4" aria-hidden />
{t("failure.reenter")}
</Button>
</div>
) : null}
{phase === "success" ? (
<div className="mx-auto w-full max-w-md text-center">
<div className="mb-6">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckCircle2 className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-green-600">
{t("success.title")}
</h2>
<p className="text-sm text-gray-600">{t("success.subtitle")}</p>
</div>
<div className="mb-6 space-y-3">
{steps.map((step) => (
<div key={step.id} className="flex items-center gap-3">
<div className="flex size-6 items-center justify-center rounded-full bg-green-500 text-white">
<CheckCircle2 className="size-4" aria-hidden />
</div>
<span className="flex-1 text-left font-medium text-gray-700">
{t(`steps.${step.id}.title`)}
</span>
<span className="text-xs text-green-600">{t("success.doneLabel")}</span>
<CheckCircle2 className="size-4 text-green-500" aria-hidden />
</div>
))}
</div>
<Button
onClick={() => router.push("/hall")}
className="w-full gap-2 bg-blue-600 text-white hover:bg-blue-700"
size="lg"
type="button"
>
{t("success.continue")}
<ChevronRight className="size-4" aria-hidden />
</Button>
</div>
) : null}
</div>
<div className="flex items-center justify-center gap-2 py-4 text-xs text-gray-500">
<ShieldCheck className="size-4 text-red-500" aria-hidden />
<span>{t("footer.secure")}</span>
</div>
</div>
);
}
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common");
if (status === "done") {
return (
<span className="flex items-center gap-1 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
{t("status.done")}
<CheckCircle2 className="size-3" aria-hidden />
</span>
);
}
if (status === "in-progress") {
return (
<span className="flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{t("status.inProgress")}
<Loader2 className="size-3 animate-spin" aria-hidden />
</span>
);
}
if (status === "pending") {
return (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
{t("status.pending")}
</span>
);
}
return (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
{t("status.failed")}
</span>
);
}