feat: enhance player login process with captcha validation and improve wallet display logic
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
- Added captcha validation to the player login screen, requiring users to input a captcha code for authentication. - Implemented a new API call to fetch captcha images, improving security during login. - Updated wallet display logic to ensure accurate representation of available and total balances based on player funding modes. - Enhanced documentation in AGENTS.md to clarify changes in credit flow and login requirements.
This commit is contained in:
@@ -10,6 +10,8 @@ This version has breaking changes — APIs, conventions, and file structure may
|
|||||||
|
|
||||||
## Learned Workspace Facts
|
## Learned Workspace Facts
|
||||||
|
|
||||||
- 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付),与钱包 `prize`/派彩文案分离。
|
- 彩票端账号密码登录须图形验证码(后端校验 + 登录页);勿在 SSR 渲染验证码文案(hydration 不一致)。
|
||||||
- 信用流水结余:`game_settlement_win`/`settlement_confirm`/`bet_hold_release` 会减 `used_credit` 并倒推结余;`settlement_payout`(账期收付)仅记账,`affects_available_credit: false`。
|
- 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付)、`reversal` 含 `game_settlement_reversal`,与钱包 `prize`/派彩文案分离。
|
||||||
|
- 信用流水结余:`game_settlement_win`/`settlement_confirm`/`bet_hold_release`/`game_settlement_reversal` 会减 `used_credit` 并倒推结余;`settlement_payout`(账期收付)仅记账,`affects_available_credit: false`;分页 `balance_after` 须跳过前置页累计。
|
||||||
|
- 账号密码登录:`lottery-auth.ts` 与 `player-session-store` 须在**模块加载时**从 `sessionStorage` 同步恢复 Bearer(子组件 `useEffect` 早于 `HydratePlayerAuth`,否则刷新 `/hall` 会先 401)。
|
||||||
- Token 进场(`EntryGate`):`entryLifecycleRef`(idle→running→done)防 URL token 剥离或 store 写入后重复触发 `doEntry` 导致成功/失败页闪现;成功后直接 `router.replace('/hall')`。
|
- Token 进场(`EntryGate`):`entryLifecycleRef`(idle→running→done)防 URL token 剥离或 store 写入后重复触发 `doEntry` 导致成功/失败页闪现;成功后直接 `router.replace('/hall')`。
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { lotteryRequest } from "@/lib/lottery-http";
|
import { lotteryRequest } from "@/lib/lottery-http";
|
||||||
|
|
||||||
|
export type PlayerAuthCaptchaResponse = {
|
||||||
|
captcha_key: string;
|
||||||
|
image_base64: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlayerAuthLoginPayload = {
|
export type PlayerAuthLoginPayload = {
|
||||||
site_code?: string;
|
site_code?: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
captcha_key: string;
|
||||||
|
captcha_code: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlayerAuthLoginData = {
|
export type PlayerAuthLoginData = {
|
||||||
@@ -20,6 +27,11 @@ export type PlayerAuthLoginData = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `GET /api/v1/player/auth/captcha`(公开) */
|
||||||
|
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
||||||
|
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`);
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /api/v1/player/auth/login`(公开) */
|
/** `POST /api/v1/player/auth/login`(公开) */
|
||||||
export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise<PlayerAuthLoginData> {
|
export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise<PlayerAuthLoginData> {
|
||||||
return lotteryRequest.post<PlayerAuthLoginData>(`/player/auth/login`, body);
|
return lotteryRequest.post<PlayerAuthLoginData>(`/player/auth/login`, body);
|
||||||
|
|||||||
@@ -62,6 +62,32 @@ export function HallWalletStrip() {
|
|||||||
|
|
||||||
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
||||||
const isCreditPlayer = isCreditFundingPlayer(balance);
|
const isCreditPlayer = isCreditFundingPlayer(balance);
|
||||||
|
const balanceMinor = Number(balance?.balance ?? 0);
|
||||||
|
const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||||
|
const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||||
|
// #region agent log
|
||||||
|
if (typeof window !== "undefined" && balance && !loading) {
|
||||||
|
fetch("http://127.0.0.1:7696/ingest/e56128e6-898b-4d61-b06d-2aefe0923744", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7fd0fc" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: "7fd0fc",
|
||||||
|
runId: "hall-wallet-display-post-fix",
|
||||||
|
hypothesisId: "H3",
|
||||||
|
location: "hall-wallet-strip.tsx:render",
|
||||||
|
message: "hall wallet headline amounts",
|
||||||
|
data: {
|
||||||
|
isCreditPlayer,
|
||||||
|
availableMinor,
|
||||||
|
balanceMinor,
|
||||||
|
headlineMinor,
|
||||||
|
mismatchWithWalletPage: !isCreditPlayer && headlineMinor !== balanceMinor,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
const mainMinor =
|
const mainMinor =
|
||||||
balance?.main_balance === null || balance?.main_balance === undefined
|
balance?.main_balance === null || balance?.main_balance === undefined
|
||||||
? null
|
? null
|
||||||
@@ -102,7 +128,7 @@ export function HallWalletStrip() {
|
|||||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
||||||
{formatMinorAsCurrency(availableMinor, currency)}
|
{formatMinorAsCurrency(headlineMinor, currency)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +143,7 @@ export function HallWalletStrip() {
|
|||||||
triggerLabel={t("wallet.transferIn")}
|
triggerLabel={t("wallet.transferIn")}
|
||||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
lotteryMinor={availableMinor}
|
lotteryMinor={transferInLotteryMinor}
|
||||||
mainMinor={mainMinor}
|
mainMinor={mainMinor}
|
||||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
|||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头;
|
* 模块加载时已在 {@link lottery-auth} / store 初始态同步恢复 Bearer;
|
||||||
* 若有 Token 无 `profile`,补拉 `GET /player/me` 供顶栏展示。
|
* 此处再调 `restoreBearerToken` 兜底,并在有 Token 无 `profile` 时补拉 `GET /player/me`。
|
||||||
*/
|
*/
|
||||||
export function HydratePlayerAuth(): null {
|
export function HydratePlayerAuth(): null {
|
||||||
const restoreBearerToken = usePlayerSessionStore(
|
const restoreBearerToken = usePlayerSessionStore(
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getPlayerMe } from "@/api/player";
|
import { getPlayerMe } from "@/api/player";
|
||||||
import { postPlayerAuthLogin } from "@/api/player-auth";
|
import { getPlayerAuthCaptcha, postPlayerAuthLogin } from "@/api/player-auth";
|
||||||
import { getPublicCurrencies } from "@/api/currency";
|
import { getPublicCurrencies } from "@/api/currency";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
validatePlayerLoginUsername,
|
validatePlayerLoginUsername,
|
||||||
} from "@/lib/player-input-validation";
|
} from "@/lib/player-input-validation";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import "@/i18n";
|
||||||
|
|
||||||
function stripSearchParamFromBrowserUrl(name: string): void {
|
function stripSearchParamFromBrowserUrl(name: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -32,6 +33,10 @@ function stripSearchParamFromBrowserUrl(name: string): void {
|
|||||||
|
|
||||||
export function PlayerLoginScreen(): React.ReactElement {
|
export function PlayerLoginScreen(): React.ReactElement {
|
||||||
const { t } = useTranslation("entry");
|
const { t } = useTranslation("entry");
|
||||||
|
const tRef = useRef(t);
|
||||||
|
useEffect(() => {
|
||||||
|
tRef.current = t;
|
||||||
|
}, [t]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
||||||
@@ -41,8 +46,43 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [captchaCode, setCaptchaCode] = useState("");
|
||||||
|
const [captchaKey, setCaptchaKey] = useState<string | null>(null);
|
||||||
|
const [captchaSrc, setCaptchaSrc] = useState<string | null>(null);
|
||||||
|
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadCaptcha = useCallback(async () => {
|
||||||
|
setLoadingCaptcha(true);
|
||||||
|
try {
|
||||||
|
const data = await getPlayerAuthCaptcha();
|
||||||
|
setCaptchaKey(data.captcha_key);
|
||||||
|
setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`);
|
||||||
|
setCaptchaCode("");
|
||||||
|
} catch {
|
||||||
|
toast.error(tRef.current("login.captchaLoadFailed"));
|
||||||
|
setCaptchaKey(null);
|
||||||
|
setCaptchaSrc(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingCaptcha(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadCaptcha();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionExpiredHandled.current) return;
|
if (sessionExpiredHandled.current) return;
|
||||||
if (searchParams.get("session") !== "expired") return;
|
if (searchParams.get("session") !== "expired") return;
|
||||||
@@ -60,6 +100,17 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!captchaKey || !captchaSrc) {
|
||||||
|
toast.error(t("login.captchaRequired"));
|
||||||
|
void loadCaptcha();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!captchaCode.trim()) {
|
||||||
|
toast.error(t("login.captchaRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const usernameIssue = validatePlayerLoginUsername(username);
|
const usernameIssue = validatePlayerLoginUsername(username);
|
||||||
if (usernameIssue === "invalid_charset") {
|
if (usernameIssue === "invalid_charset") {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -83,6 +134,8 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
const data = await postPlayerAuthLogin({
|
const data = await postPlayerAuthLogin({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password,
|
password,
|
||||||
|
captcha_key: captchaKey,
|
||||||
|
captcha_code: captchaCode.trim(),
|
||||||
});
|
});
|
||||||
setBearerToken(data.access_token);
|
setBearerToken(data.access_token);
|
||||||
const me = await getPlayerMe();
|
const me = await getPlayerMe();
|
||||||
@@ -96,6 +149,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
router.replace("/hall");
|
router.replace("/hall");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
|
||||||
|
void loadCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -128,6 +182,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -138,8 +193,47 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="login-captcha">{t("login.captcha")}</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="login-captcha"
|
||||||
|
name="captcha"
|
||||||
|
autoComplete="off"
|
||||||
|
value={captchaCode}
|
||||||
|
onChange={(e) => setCaptchaCode(e.target.value)}
|
||||||
|
placeholder={t("login.captchaPlaceholder")}
|
||||||
|
maxLength={32}
|
||||||
|
disabled={loading}
|
||||||
|
className="min-w-0 flex-1 sm:max-w-[12rem]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 min-w-[156px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border border-input bg-muted/40 px-2 transition-colors hover:bg-muted/60 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => void loadCaptcha()}
|
||||||
|
disabled={loadingCaptcha || loading}
|
||||||
|
aria-label={loadingCaptcha ? t("login.captchaLoading") : t("login.captchaRefresh")}
|
||||||
|
>
|
||||||
|
{captchaSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- captcha SVG data URL from API
|
||||||
|
<img
|
||||||
|
src={captchaSrc}
|
||||||
|
alt=""
|
||||||
|
width={160}
|
||||||
|
height={48}
|
||||||
|
className="pointer-events-none block"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 text-xs text-muted-foreground">
|
||||||
|
{loadingCaptcha ? t("login.captchaLoading") : t("login.captchaFetch")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={loading}>
|
<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}
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
{t("login.submit")}
|
{t("login.submit")}
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ export function WalletLogsScreen() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const balance = await getWalletBalance({ currency });
|
const balance = await getWalletBalance({ currency });
|
||||||
setCreditMode(
|
setCreditMode(isCreditFundingPlayer(balance));
|
||||||
balance.credit_line_mode === true || balance.funding_mode === "credit",
|
|
||||||
);
|
|
||||||
const nextLogs = await getWalletLogs({
|
const nextLogs = await getWalletLogs({
|
||||||
page: targetPage,
|
page: targetPage,
|
||||||
size: WALLET_LOGS_PAGE_SIZE,
|
size: WALLET_LOGS_PAGE_SIZE,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function syncPreferredLanguage(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!i18n.isInitialized) {
|
if (!i18n.isInitialized) {
|
||||||
void i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: DEFAULT_LANGUAGE,
|
fallbackLng: DEFAULT_LANGUAGE,
|
||||||
supportedLngs: ["en", "ne", "zh"],
|
supportedLngs: ["en", "ne", "zh"],
|
||||||
@@ -81,6 +81,7 @@ if (!i18n.isInitialized) {
|
|||||||
/** 与 SSR 一致:首屏固定默认语言,hydration 后再由 syncPreferredLanguage 切换 */
|
/** 与 SSR 一致:首屏固定默认语言,hydration 后再由 syncPreferredLanguage 切换 */
|
||||||
load: "languageOnly",
|
load: "languageOnly",
|
||||||
lng: DEFAULT_LANGUAGE,
|
lng: DEFAULT_LANGUAGE,
|
||||||
|
initAsync: false,
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
|
|||||||
@@ -57,7 +57,14 @@
|
|||||||
"submit": "Sign in",
|
"submit": "Sign in",
|
||||||
"missingFields": "Please enter username and password",
|
"missingFields": "Please enter username and password",
|
||||||
"failed": "Sign-in failed",
|
"failed": "Sign-in failed",
|
||||||
"backEntry": "Back to entry"
|
"backEntry": "Back to entry",
|
||||||
|
"captcha": "Captcha",
|
||||||
|
"captchaPlaceholder": "Enter captcha",
|
||||||
|
"captchaRequired": "Please load the captcha first",
|
||||||
|
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
||||||
|
"captchaLoading": "Loading…",
|
||||||
|
"captchaRefresh": "Refresh captcha",
|
||||||
|
"captchaFetch": "Tap to load"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "No authorization token found",
|
"noToken": "No authorization token found",
|
||||||
|
|||||||
@@ -57,7 +57,14 @@
|
|||||||
"submit": "लगइन",
|
"submit": "लगइन",
|
||||||
"missingFields": "कृपया प्रयोगकर्ता नाम र पासवर्ड भर्नुहोस्",
|
"missingFields": "कृपया प्रयोगकर्ता नाम र पासवर्ड भर्नुहोस्",
|
||||||
"failed": "लगइन असफल",
|
"failed": "लगइन असफल",
|
||||||
"backEntry": "प्रवेशमा फर्कनुहोस्"
|
"backEntry": "प्रवेशमा फर्कनुहोस्",
|
||||||
|
"captcha": "क्याप्चा",
|
||||||
|
"captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्",
|
||||||
|
"captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्",
|
||||||
|
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
||||||
|
"captchaLoading": "लोड हुँदै…",
|
||||||
|
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
||||||
|
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
||||||
|
|||||||
@@ -57,7 +57,14 @@
|
|||||||
"submit": "登录",
|
"submit": "登录",
|
||||||
"missingFields": "请填写账号和密码",
|
"missingFields": "请填写账号和密码",
|
||||||
"failed": "登录失败",
|
"failed": "登录失败",
|
||||||
"backEntry": "返回入口"
|
"backEntry": "返回入口",
|
||||||
|
"captcha": "验证码",
|
||||||
|
"captchaPlaceholder": "请输入验证码",
|
||||||
|
"captchaRequired": "请先加载验证码",
|
||||||
|
"captchaLoadFailed": "验证码加载失败,请重试",
|
||||||
|
"captchaLoading": "加载中…",
|
||||||
|
"captchaRefresh": "刷新验证码",
|
||||||
|
"captchaFetch": "点击获取"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "未发现授权令牌",
|
"noToken": "未发现授权令牌",
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { AxiosHeaders, type AxiosRequestConfig } from "axios";
|
import { AxiosHeaders, type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
import { readPersistedPlayerBearerToken } from "@/lib/player-session";
|
||||||
|
|
||||||
/** `Bearer ` 后的原始串:`dev:1`、JWT 等 */
|
/** `Bearer ` 后的原始串:`dev:1`、JWT 等 */
|
||||||
let playerBearerPayload: string | null = null;
|
let playerBearerPayload: string | null = null;
|
||||||
|
|
||||||
|
function normalizeBearerPayload(token: string): string {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
return trimmed.startsWith("Bearer ") ? trimmed.slice(7).trim() : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新后须在首个鉴权请求前恢复(子组件 useEffect 早于 HydratePlayerAuth) */
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const persisted = readPersistedPlayerBearerToken();
|
||||||
|
if (persisted?.trim()) {
|
||||||
|
playerBearerPayload = normalizeBearerPayload(persisted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置玩家鉴权 Token(仅作用于彩票 API 请求)。
|
* 设置玩家鉴权 Token(仅作用于彩票 API 请求)。
|
||||||
* 传入 `null` 或 trim 后空串则清除。
|
* 传入 `null` 或 trim 后空串则清除。
|
||||||
@@ -12,8 +27,7 @@ export function setPlayerBearerToken(token: string | null): void {
|
|||||||
playerBearerPayload = null;
|
playerBearerPayload = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = token.trim();
|
playerBearerPayload = normalizeBearerPayload(token);
|
||||||
playerBearerPayload = t.startsWith("Bearer ") ? t.slice(7).trim() : t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlayerBearerTokenPayload(): string | null {
|
export function getPlayerBearerTokenPayload(): string | null {
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
/** 信用盘玩家(代理授信);与主站钱包资金盘区分。 */
|
/** 信用盘玩家(代理授信);与主站钱包资金盘区分。 */
|
||||||
export function isCreditFundingPlayer(
|
export function isCreditFundingPlayer(
|
||||||
source?: { funding_mode?: string | null; credit_line_mode?: boolean | null } | null,
|
source?: {
|
||||||
|
funding_mode?: string | null;
|
||||||
|
credit_line_mode?: boolean | null;
|
||||||
|
uses_credit?: boolean | null;
|
||||||
|
} | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.credit_line_mode === true || source.funding_mode === "credit";
|
if (source.uses_credit === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.funding_mode === "credit") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.funding_mode === "wallet") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.credit_line_mode === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,15 @@ function initialSteps(): PlayerEntryStep[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readInitialBearerToken(): string | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readPersistedPlayerBearerToken()?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export const usePlayerSessionStore = create<PlayerSessionState>((set, get) => ({
|
export const usePlayerSessionStore = create<PlayerSessionState>((set, get) => ({
|
||||||
bearerToken: null,
|
bearerToken: readInitialBearerToken(),
|
||||||
profile: null,
|
profile: null,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
selectedCurrency: null,
|
selectedCurrency: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user