docs: 新增 AI 代理学习偏好与信用盘术语规范
feat: 优化登录与入口页面加载体验 - 为登录页面添加 Suspense 边界,统一 fallback 样式 - 重构入口守卫逻辑,在布局阶段处理未登录重定向,避免闪烁 - 登录页面支持会话过期提示,并自动清理 URL 参数 feat: 信用盘与钱包盘文案差异化展示 - 底部导航「钱包」标签在信用盘模式下显示为「信用」 - 大厅余额条在信用盘模式下使用「可
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getPublicCurrencies } from "@/api/currency";
|
||||
@@ -116,6 +116,27 @@ export function EntryGate() {
|
||||
!tokenFromUrl &&
|
||||
!(bearerToken ?? "").trim();
|
||||
|
||||
const [gateReady, setGateReady] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
if (!isInIframe()) {
|
||||
if (sessionExpired) {
|
||||
router.replace("/login?session=expired");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
|
||||
if (!hasToken) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setGateReady(true);
|
||||
}, [bearerToken, router, sessionExpired, tokenFromUrl]);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
|
||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
|
||||
sessionExpired
|
||||
@@ -300,6 +321,10 @@ export function EntryGate() {
|
||||
return () => window.clearTimeout(tmr);
|
||||
}, [sessionExpired, effectiveToken, tokenFromUrl]);
|
||||
|
||||
if (!gateReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className={cn("relative h-[45vh] min-h-[320px]", phase === "success" ? "bg-white" : "bg-red-600")}>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -20,20 +19,42 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const DEPLOY_SITE_CODE = process.env.NEXT_PUBLIC_PLAYER_SITE_CODE?.trim() ?? "";
|
||||
|
||||
function stripSearchParamFromBrowserUrl(name: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has(name)) return;
|
||||
url.searchParams.delete(name);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
export function PlayerLoginScreen(): React.ReactElement {
|
||||
const { t } = useTranslation("entry");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
||||
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
||||
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
||||
const sessionExpiredHandled = useRef(false);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionExpiredHandled.current) return;
|
||||
if (searchParams.get("session") !== "expired") return;
|
||||
|
||||
sessionExpiredHandled.current = true;
|
||||
clearBearerToken();
|
||||
toast.error(t("errors.sessionExpired"));
|
||||
stripSearchParamFromBrowserUrl("session");
|
||||
}, [clearBearerToken, searchParams, t]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error(t("login.missingFields", { defaultValue: "请填写账号和密码" }));
|
||||
toast.error(t("login.missingFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +76,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
}
|
||||
router.replace("/hall");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed", { defaultValue: "登录失败" }));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,8 +84,10 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative h-[32vh] min-h-[220px] bg-red-600">
|
||||
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority />
|
||||
<div className="relative h-[45vh] min-h-[320px] shrink-0 overflow-hidden bg-red-600">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority />
|
||||
</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>
|
||||
@@ -72,15 +95,15 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
|
||||
<div className="mx-auto w-full max-w-md flex-1 px-4 py-8">
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
{t("login.title", { defaultValue: "代理玩家登录" })}
|
||||
{t("login.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("login.hint", { defaultValue: "使用代理为您开通的账号登录。主站用户请从主站进入。" })}
|
||||
{t("login.hint")}
|
||||
</p>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="login-user">{t("login.username", { defaultValue: "登录账号" })}</Label>
|
||||
<Label htmlFor="login-user">{t("login.username")}</Label>
|
||||
<Input
|
||||
id="login-user"
|
||||
value={username}
|
||||
@@ -89,7 +112,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="login-pass">{t("login.password", { defaultValue: "密码" })}</Label>
|
||||
<Label htmlFor="login-pass">{t("login.password")}</Label>
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
@@ -100,15 +123,9 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{t("login.submit", { defaultValue: "登录" })}
|
||||
{t("login.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Link href="/" className="text-red-600 underline">
|
||||
{t("login.backEntry", { defaultValue: "返回入口" })}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user