docs: 新增 AI 代理学习偏好与信用盘术语规范

feat: 优化登录与入口页面加载体验

- 为登录页面添加 Suspense 边界,统一 fallback 样式
- 重构入口守卫逻辑,在布局阶段处理未登录重定向,避免闪烁
- 登录页面支持会话过期提示,并自动清理 URL 参数

feat: 信用盘与钱包盘文案差异化展示

- 底部导航「钱包」标签在信用盘模式下显示为「信用」
- 大厅余额条在信用盘模式下使用「可
This commit is contained in:
2026-06-12 20:48:10 +08:00
parent 20d2befa55
commit 7c283627d3
24 changed files with 347 additions and 92 deletions

View File

@@ -3,3 +3,11 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
## Learned User Preferences
- 玩家端信用盘文案与钱包盘区分:不用「派彩」口径,中奖走账期结算语义;提示语避免生硬重复「信用盘」字样。
## Learned Workspace Facts
- 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付),与钱包 `prize`/派彩文案分离。

View File

@@ -1,5 +1,16 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { PlayerLoginScreen } from "@/features/player/player-login-screen"; import { PlayerLoginScreen } from "@/features/player/player-login-screen";
export default function PlayerLoginPage() { function LoginFallback(): ReactNode {
return <PlayerLoginScreen />; return <div className="min-h-dvh bg-white" aria-hidden />;
}
export default function PlayerLoginPage() {
return (
<Suspense fallback={<LoginFallback />}>
<PlayerLoginScreen />
</Suspense>
);
} }

View File

@@ -4,11 +4,7 @@ import { Suspense } from "react";
import { EntryGate } from "@/features/player/entry-gate"; import { EntryGate } from "@/features/player/entry-gate";
function EntryFallback(): ReactNode { function EntryFallback(): ReactNode {
return ( return <div className="min-h-dvh bg-white" aria-hidden />;
<div className="flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-red-800 to-red-950 px-4 text-sm text-white/90">
<p>Loading...</p>
</div>
);
} }
export default function EntryPage() { export default function EntryPage() {

View File

@@ -7,7 +7,9 @@ import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { playerViewportFixedBarClass } from "@/lib/player-viewport"; import { playerViewportFixedBarClass } from "@/lib/player-viewport";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
const tabs = [ const tabs = [
{ {
@@ -34,7 +36,9 @@ const tabs = [
{ {
href: "/wallet", href: "/wallet",
labelKey: "nav.wallet", labelKey: "nav.wallet",
creditLabelKey: "nav.credit",
labelDefault: "钱包", labelDefault: "钱包",
creditLabelDefault: "信用",
icon: Wallet, icon: Wallet,
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
}, },
@@ -46,6 +50,7 @@ const tabs = [
export function PlayerBottomNav() { export function PlayerBottomNav() {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
return ( return (
<nav <nav
@@ -56,9 +61,13 @@ export function PlayerBottomNav() {
aria-label={t("nav.aria")} aria-label={t("nav.aria")}
> >
<div className="grid h-14 w-full grid-cols-4 grid-rows-1"> <div className="grid h-14 w-full grid-cols-4 grid-rows-1">
{tabs.map(({ href, labelKey, labelDefault, icon: Icon, match }) => { {tabs.map(({ href, labelKey, labelDefault, icon: Icon, match, ...tab }) => {
const active = match(pathname); const active = match(pathname);
const label = t(labelKey, { defaultValue: labelDefault }); const creditLabelKey = "creditLabelKey" in tab ? tab.creditLabelKey : undefined;
const creditLabelDefault = "creditLabelDefault" in tab ? tab.creditLabelDefault : undefined;
const label = creditMode && creditLabelKey
? t(creditLabelKey, { defaultValue: creditLabelDefault ?? labelDefault })
: t(labelKey, { defaultValue: labelDefault });
return ( return (
<Link <Link
key={href} key={href}

View File

@@ -15,6 +15,7 @@ import {
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useApiQuery } from "@/hooks/use-api-query"; import { useApiQuery } from "@/hooks/use-api-query";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
@@ -60,15 +61,21 @@ export function HallWalletStrip() {
}, [isDegraded]); }, [isDegraded]);
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0); const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
const isCreditPlayer = const isCreditPlayer = isCreditFundingPlayer(balance);
balance?.credit_line_mode === true || balance?.funding_mode === "credit";
const mainMinor = const mainMinor =
balance?.main_balance === null || balance?.main_balance === undefined balance?.main_balance === null || balance?.main_balance === undefined
? null ? null
: Number(balance.main_balance); : Number(balance.main_balance);
return ( return (
<section className="mb-3 space-y-2.5" aria-label={t("wallet.balance")}> <section
className="mb-3 space-y-2.5"
aria-label={
isCreditPlayer
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
: t("wallet.balance")
}
>
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-3 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]", "relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-3 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
@@ -87,7 +94,7 @@ export function HallWalletStrip() {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90"> <p className="text-sm font-semibold text-white/90">
{balance?.credit_line_mode {isCreditPlayer
? t("wallet.creditAvailable", { defaultValue: "可用信用" }) ? t("wallet.creditAvailable", { defaultValue: "可用信用" })
: t("wallet.balance")} : t("wallet.balance")}
</p> </p>

View File

@@ -5,6 +5,7 @@ export function ticketStatusDisplay(
winMinor: number, winMinor: number,
jackpotMinor: number, jackpotMinor: number,
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string, t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
creditMode = false,
): { label: string; dotClass: string; ring?: boolean } { ): { label: string; dotClass: string; ring?: boolean } {
const total = winMinor + jackpotMinor; const total = winMinor + jackpotMinor;
if (status === "partial_failed") { if (status === "partial_failed") {
@@ -32,10 +33,20 @@ export function ticketStatusDisplay(
}; };
} }
if (status === "pending_payout") { if (status === "pending_payout") {
return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" }; return {
label: creditMode
? t?.("ticketStatus.credit.pending_payout", { defaultValue: "已中奖待结算" }) ?? status
: t?.("ticketStatus.pending_payout") ?? status,
dotClass: "bg-amber-500",
};
} }
if (status === "settled_win" && total > 0) { if (status === "settled_win" && total > 0) {
return { label: t?.("ticketStatus.settled_win") ?? status, dotClass: "bg-emerald-500" }; return {
label: creditMode
? t?.("ticketStatus.credit.settled_win", { defaultValue: "已中奖" }) ?? status
: t?.("ticketStatus.settled_win") ?? status,
dotClass: "bg-emerald-500",
};
} }
if (status === "settled_lose" || (status === "settled_win" && total <= 0)) { if (status === "settled_lose" || (status === "settled_win" && total <= 0)) {
return { return {

View File

@@ -22,8 +22,10 @@ import { orderGroupPath } from "@/features/orders/group-ticket-items";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatPlayerInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { norm4d } from "@/lib/norm-4d"; import { norm4d } from "@/lib/norm-4d";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TicketItemDetailPayload } from "@/types/api/ticket-items"; import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
@@ -79,6 +81,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const [data, setData] = useState<TicketItemDetailPayload | null>(null); const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -184,7 +187,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
} }
const cur = data.currency_code ?? activeCurrency; const cur = data.currency_code ?? activeCurrency;
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t); const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t, creditMode);
const orderStatus = data.order_status ?? null; const orderStatus = data.order_status ?? null;
const isPartialFailedOrder = orderStatus === "partial_failed"; const isPartialFailedOrder = orderStatus === "partial_failed";
const isLineFailed = data.status === "failed" || data.status === "refunded"; const isLineFailed = data.status === "failed" || data.status === "refunded";
@@ -370,7 +373,9 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
) : null} ) : null}
</p> </p>
<p className="mt-1 font-mono font-semibold text-emerald-900"> <p className="mt-1 font-mono font-semibold text-emerald-900">
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })} {t(creditMode ? "orders.creditWinTotal" : "orders.payoutTotal", {
amount: formatMinorAsCurrency(totalWin, cur),
})}
</p> </p>
</div> </div>
) : hasSettlement ? ( ) : hasSettlement ? (

View File

@@ -16,8 +16,10 @@ import {
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
type TicketOrderGroupScreenProps = { type TicketOrderGroupScreenProps = {
groupKey: string; groupKey: string;
@@ -26,6 +28,7 @@ type TicketOrderGroupScreenProps = {
export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps) { export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const decodedKey = decodeURIComponent(groupKey); const decodedKey = decodeURIComponent(groupKey);
@@ -61,6 +64,7 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
group.win_amount, group.win_amount,
group.jackpot_win_amount, group.jackpot_win_amount,
t, t,
creditMode,
); );
const totalWin = group.win_amount + group.jackpot_win_amount; const totalWin = group.win_amount + group.jackpot_win_amount;
return ( return (
@@ -116,6 +120,7 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
row.win_amount, row.win_amount,
row.jackpot_win_amount, row.jackpot_win_amount,
t, t,
creditMode,
); );
const lineWin = row.win_amount + row.jackpot_win_amount; const lineWin = row.win_amount + row.jackpot_win_amount;
return ( return (

View File

@@ -26,6 +26,7 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { import {
formatSchedulePickerYmd, formatSchedulePickerYmd,
getTimeZoneShortLabel, getTimeZoneShortLabel,
@@ -33,6 +34,7 @@ import {
scheduleTodayYmd, scheduleTodayYmd,
} from "@/lib/player-datetime"; } from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TicketItemListRow } from "@/types/api/ticket-items"; import type { TicketItemListRow } from "@/types/api/ticket-items";
@@ -55,6 +57,7 @@ export function TicketOrdersListScreen() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
const statusFilter = useMemo( const statusFilter = useMemo(
@@ -427,6 +430,7 @@ export function TicketOrdersListScreen() {
group.win_amount, group.win_amount,
group.jackpot_win_amount, group.jackpot_win_amount,
t, t,
creditMode,
); );
const totalWin = group.win_amount + group.jackpot_win_amount; const totalWin = group.win_amount + group.jackpot_win_amount;
return ( return (

View File

@@ -12,7 +12,7 @@ import {
} from "lucide-react"; } 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 { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getPublicCurrencies } from "@/api/currency"; import { getPublicCurrencies } from "@/api/currency";
@@ -116,6 +116,27 @@ export function EntryGate() {
!tokenFromUrl && !tokenFromUrl &&
!(bearerToken ?? "").trim(); !(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 [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() => const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
sessionExpired sessionExpired
@@ -300,6 +321,10 @@ export function EntryGate() {
return () => window.clearTimeout(tmr); return () => window.clearTimeout(tmr);
}, [sessionExpired, effectiveToken, tokenFromUrl]); }, [sessionExpired, effectiveToken, tokenFromUrl]);
if (!gateReady) {
return null;
}
return ( return (
<div className="relative flex min-h-dvh flex-col bg-white"> <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")}> <div className={cn("relative h-[45vh] min-h-[320px]", phase === "success" ? "bg-white" : "bg-red-600")}>

View File

@@ -2,9 +2,8 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; 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() ?? ""; 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 { export function PlayerLoginScreen(): React.ReactElement {
const { t } = useTranslation("entry"); const { t } = useTranslation("entry");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken); const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
const setProfile = usePlayerSessionStore((s) => s.setProfile); const setProfile = usePlayerSessionStore((s) => s.setProfile);
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
const sessionExpiredHandled = useRef(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); 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> { async function handleSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault(); event.preventDefault();
if (!username.trim() || !password) { if (!username.trim() || !password) {
toast.error(t("login.missingFields", { defaultValue: "请填写账号和密码" })); toast.error(t("login.missingFields"));
return; return;
} }
@@ -55,7 +76,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", { defaultValue: "登录失败" })); toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -63,8 +84,10 @@ export function PlayerLoginScreen(): React.ReactElement {
return ( return (
<div className="relative flex min-h-dvh flex-col bg-white"> <div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative h-[32vh] min-h-[220px] bg-red-600"> <div className="relative h-[45vh] min-h-[320px] shrink-0 overflow-hidden bg-red-600">
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority /> <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"> <div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
<LanguageSwitcher variant="header" showFlag={false} /> <LanguageSwitcher variant="header" showFlag={false} />
</div> </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"> <div className="mx-auto w-full max-w-md flex-1 px-4 py-8">
<h1 className="text-xl font-bold text-gray-900"> <h1 className="text-xl font-bold text-gray-900">
{t("login.title", { defaultValue: "代理玩家登录" })} {t("login.title")}
</h1> </h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{t("login.hint", { defaultValue: "使用代理为您开通的账号登录。主站用户请从主站进入。" })} {t("login.hint")}
</p> </p>
<form className="mt-6 space-y-4" onSubmit={(e) => void handleSubmit(e)}> <form className="mt-6 space-y-4" onSubmit={(e) => void handleSubmit(e)}>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="login-user">{t("login.username", { defaultValue: "登录账号" })}</Label> <Label htmlFor="login-user">{t("login.username")}</Label>
<Input <Input
id="login-user" id="login-user"
value={username} value={username}
@@ -89,7 +112,7 @@ export function PlayerLoginScreen(): React.ReactElement {
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="login-pass">{t("login.password", { defaultValue: "密码" })}</Label> <Label htmlFor="login-pass">{t("login.password")}</Label>
<Input <Input
id="login-pass" id="login-pass"
type="password" type="password"
@@ -100,15 +123,9 @@ export function PlayerLoginScreen(): React.ReactElement {
</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", { defaultValue: "登录" })} {t("login.submit")}
</Button> </Button>
</form> </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>
</div> </div>
); );

View File

@@ -22,9 +22,11 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth"; import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatPlayerInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { norm4d } from "@/lib/norm-4d"; import { norm4d } from "@/lib/norm-4d";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { DrawResultDetailPayload } from "@/types/api/draw-results"; import type { DrawResultDetailPayload } from "@/types/api/draw-results";
type DrawResultDetailScreenProps = { type DrawResultDetailScreenProps = {
@@ -35,6 +37,7 @@ type DrawResultDetailScreenProps = {
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const [data, setData] = useState<DrawResultDetailPayload | null>(null); const [data, setData] = useState<DrawResultDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -241,7 +244,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
{showMyPayout && myTotals ? ( {showMyPayout && myTotals ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)]"> <div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)]">
<p className="font-bold text-emerald-900"> <p className="font-bold text-emerald-900">
{t("results.myPayout")} {t(creditMode ? "results.creditMyWin" : "results.myPayout")}
</p> </p>
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80"> <p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80">
{t("results.regular", { {t("results.regular", {
@@ -262,7 +265,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
{showHitOnly ? ( {showHitOnly ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950"> <div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950">
{t("results.hitPending")} {t(creditMode ? "results.creditHitPending" : "results.hitPending")}
</div> </div>
) : null} ) : null}

View File

@@ -24,9 +24,8 @@ export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [ export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [
{ value: "", labelKey: "wallet.creditFlow.all" }, { value: "", labelKey: "wallet.creditFlow.all" },
{ value: "bet", labelKey: "wallet.creditFlow.bet" }, { value: "bet", labelKey: "wallet.creditFlow.bet" },
{ value: "prize", labelKey: "wallet.creditFlow.prize" }, { value: "credit_release", labelKey: "wallet.creditFlow.credit_release" },
{ value: "refund", labelKey: "wallet.creditFlow.refund" }, { value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
{ value: "reversal", labelKey: "wallet.creditFlow.reversal" },
]; ];
const FLOW_LABEL_FALLBACKS: Record<string, string> = { const FLOW_LABEL_FALLBACKS: Record<string, string> = {
@@ -38,10 +37,12 @@ const FLOW_LABEL_FALLBACKS: Record<string, string> = {
"wallet.flow.refund": "退款", "wallet.flow.refund": "退款",
"wallet.flow.reversal": "冲正", "wallet.flow.reversal": "冲正",
"wallet.creditFlow.all": "全部", "wallet.creditFlow.all": "全部",
"wallet.creditFlow.bet": "下注占用", "wallet.creditFlow.bet": "占用额度",
"wallet.creditFlow.prize": "派奖释放", "wallet.creditFlow.credit_release": "释额",
"wallet.creditFlow.refund": "退款返还", "wallet.creditFlow.bill_settlement": "账期收付",
"wallet.creditFlow.reversal": "冲正回退", "wallet.creditFlow.win_credit": "中奖释额",
"wallet.creditFlow.refund": "账期确认释额",
"wallet.creditFlow.reversal": "退本释额",
}; };
export function logTypeLabel( export function logTypeLabel(
@@ -97,13 +98,6 @@ export function WalletLogsBlock({
?? (creditMode ?? (creditMode
? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" }) ? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" })
: t("wallet.flowsTitle", { defaultValue: "钱包流水" })); : t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
const channelHint = creditMode
? t("wallet.playerChannel.credit", {
defaultValue: "信用盘玩家(占用与释放额度流水)",
})
: t("wallet.playerChannel.wallet", {
defaultValue: "主站钱包玩家(划转与钱包余额流水)",
});
const filters = useMemo(() => { const filters = useMemo(() => {
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS; const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
return source.map((f) => ({ return source.map((f) => ({
@@ -118,7 +112,6 @@ export function WalletLogsBlock({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2> <h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
<p className="mt-0.5 text-[11px] font-medium text-slate-500">{channelHint}</p>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{filters.map((f) => ( {filters.map((f) => (
@@ -152,7 +145,9 @@ export function WalletLogsBlock({
<ul className="space-y-2"> <ul className="space-y-2">
{logs.items.length === 0 ? ( {logs.items.length === 0 ? (
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground"> <li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
{t("wallet.emptyLogs")} {t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
})}
</li> </li>
) : ( ) : (
logs.items.map((row) => ( logs.items.map((row) => (
@@ -219,7 +214,7 @@ export function LogRow({
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={isIn ? "size-2.5 rounded-full bg-emerald-500" : "size-2.5 rounded-full bg-[#0b3f96]"} aria-hidden /> <span className={isIn ? "size-2.5 rounded-full bg-emerald-500" : "size-2.5 rounded-full bg-[#e5002c]"} aria-hidden />
<p className="truncate text-base font-black leading-tight text-[#101a33]"> <p className="truncate text-base font-black leading-tight text-[#101a33]">
{logTypeLabel(item.type, t, creditMode)} {logTypeLabel(item.type, t, creditMode)}
</p> </p>
@@ -235,7 +230,7 @@ export function LogRow({
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-2 text-right"> <div className="flex shrink-0 flex-col items-end gap-2 text-right">
<p className={isIn ? "text-lg font-black tabular-nums text-emerald-600" : "text-lg font-black tabular-nums text-[#0b3f96]"}> <p className={isIn ? "text-lg font-black tabular-nums text-emerald-600" : "text-lg font-black tabular-nums text-[#e5002c]"}>
{isIn ? "+" : ""} {isIn ? "+" : ""}
{formatMinorAsCurrency(item.amount_abs, ccy)} {formatMinorAsCurrency(item.amount_abs, ccy)}
</p> </p>
@@ -246,12 +241,24 @@ export function LogRow({
</div> </div>
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs"> <div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
<span className="font-semibold text-slate-500"> {creditMode && item.affects_available_credit === false ? (
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")} <span className="font-medium text-slate-500">
</span> {t("wallet.creditPaymentRecordOnly", {
<span className="font-mono font-black tabular-nums text-[#32518d]"> defaultValue: "账期收付记账,不计入可用信用",
{formatMinorAsCurrency(item.balance_after, ccy)} })}
</span> </span>
) : (
<>
<span className="font-semibold text-slate-500">
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
</span>
<span className="font-mono font-black tabular-nums text-[#32518d]">
{item.balance_after != null
? formatMinorAsCurrency(item.balance_after, ccy)
: "—"}
</span>
</>
)}
</div> </div>
</li> </li>
); );

View File

@@ -10,6 +10,7 @@ import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile"; import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { formatWalletClientError } from "@/lib/wallet-api-error"; import { formatWalletClientError } from "@/lib/wallet-api-error";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs"; import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
@@ -106,9 +107,17 @@ export function WalletLogsScreen() {
return ( return (
<PlayerPanel <PlayerPanel
title={t("wallet.logsTitle")} title={
creditMode
? t("wallet.creditLogsTitle", { defaultValue: "信用流水" })
: t("wallet.logsTitle")
}
backHref="/wallet" backHref="/wallet"
backLabel={t("wallet.title")} backLabel={
creditMode
? t("wallet.creditTitle", { defaultValue: "信用" })
: t("wallet.title")
}
> >
<div className="space-y-3"> <div className="space-y-3">
{error ? ( {error ? (

View File

@@ -16,6 +16,7 @@ import {
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile"; import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { formatWalletClientError } from "@/lib/wallet-api-error"; import { formatWalletClientError } from "@/lib/wallet-api-error";
@@ -117,8 +118,7 @@ export function WalletScreen() {
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const isCreditPlayer = const isCreditPlayer = isCreditFundingPlayer(balance);
balance?.credit_line_mode === true || balance?.funding_mode === "credit";
const displayMinor = isCreditPlayer const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0) ? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0); : Number(balance?.balance ?? 0);
@@ -155,7 +155,9 @@ export function WalletScreen() {
}, [hasMore, loadMore, loading, loadingMore, logsLoading]); }, [hasMore, loadMore, loading, loadingMore, logsLoading]);
return ( return (
<PlayerPanel title={t("wallet.title")}> <PlayerPanel
title={isCreditPlayer ? t("wallet.creditTitle", { defaultValue: "信用" }) : t("wallet.title")}
>
<div className="space-y-3"> <div className="space-y-3">
{error ? ( {error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700"> <div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
@@ -213,7 +215,8 @@ export function WalletScreen() {
{isCreditPlayer ? ( {isCreditPlayer ? (
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85"> <p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
{t("wallet.creditNoTransferHint", { {t("wallet.creditNoTransferHint", {
defaultValue: "信用盘账号由代理授信,无需主站转入转出;额度调整请联系代理。", defaultValue:
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
})} })}
</p> </p>
) : ( ) : (

View File

@@ -49,6 +49,16 @@
"footer": { "footer": {
"secure": "Secure. Trusted. Authorized access." "secure": "Secure. Trusted. Authorized access."
}, },
"login": {
"title": "Agent player login",
"hint": "Sign in with the account your agent created for you. Main-site users should enter from the main site.",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"missingFields": "Please enter username and password",
"failed": "Sign-in failed",
"backEntry": "Back to entry"
},
"errors": { "errors": {
"noToken": "No authorization token found", "noToken": "No authorization token found",
"noTokenDetail": "Please return to the main site and try again.", "noTokenDetail": "Please return to the main site and try again.",

View File

@@ -24,7 +24,8 @@
"results": "Results", "results": "Results",
"orders": "My Bets", "orders": "My Bets",
"rules": "Rules", "rules": "Rules",
"wallet": "Wallet" "wallet": "Wallet",
"credit": "Credit"
}, },
"notifications": { "notifications": {
"title": "Pending reconciliation", "title": "Pending reconciliation",
@@ -346,10 +347,12 @@
"wallet": { "wallet": {
"title": "Wallet", "title": "Wallet",
"subtitle": "Balance and transfer records", "subtitle": "Balance and transfer records",
"creditTitle": "Credit",
"creditSubtitle": "Credit usage and billing-period activity",
"balance": "Wallet Balance", "balance": "Wallet Balance",
"creditAvailable": "Available credit", "creditAvailable": "Available credit",
"creditSummary": "Limit {{limit}} · Used {{used}}", "creditSummary": "Limit {{limit}} · Used {{used}}",
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Contact your agent to change your limit.", "creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.",
"available": "Available {{amount}}", "available": "Available {{amount}}",
"transferIn": "Transfer In", "transferIn": "Transfer In",
"transferOut": "Transfer Out", "transferOut": "Transfer Out",
@@ -358,6 +361,8 @@
"logs": "Logs", "logs": "Logs",
"logsTitle": "Wallet Logs", "logsTitle": "Wallet Logs",
"logsSubtitle": "Transfer and betting records", "logsSubtitle": "Transfer and betting records",
"creditLogsTitle": "Credit activity",
"creditLogsSubtitle": "Holds, releases, and billing-period payments",
"typeFilter": "Type Filter", "typeFilter": "Type Filter",
"transferInTitle": "Transfer In", "transferInTitle": "Transfer In",
"transferOutTitle": "Transfer Out", "transferOutTitle": "Transfer Out",
@@ -392,12 +397,14 @@
"flowsTitle": "Wallet logs", "flowsTitle": "Wallet logs",
"creditFlowsTitle": "Credit activity", "creditFlowsTitle": "Credit activity",
"playerChannel": { "playerChannel": {
"credit": "Credit-line player (agent credit ledger)", "credit": "Credit line: credit holds, releases, and billing payments (not wallet payouts)",
"wallet": "Main-site wallet player (transfers & wallet ledger)" "wallet": "Main-site wallet player (transfers & wallet ledger)"
}, },
"creditAvailableAfter": "Available credit after", "creditAvailableAfter": "Available credit after",
"creditPaymentRecordOnly": "Billing payment record only; not counted in available credit",
"totalRecords": "{{total}} records", "totalRecords": "{{total}} records",
"emptyLogs": "No wallet logs", "emptyLogs": "No wallet logs",
"emptyCreditLogs": "No credit activity",
"noMoreLogs": "No more transactions", "noMoreLogs": "No more transactions",
"balanceAfter": "Balance after", "balanceAfter": "Balance after",
"wsBalanceUpdated": "Balance {{change}} ({{reason}})", "wsBalanceUpdated": "Balance {{change}} ({{reason}})",
@@ -420,10 +427,12 @@
}, },
"creditFlow": { "creditFlow": {
"all": "All", "all": "All",
"bet": "Bet hold", "bet": "Credit hold",
"prize": "Payout", "credit_release": "Credit release",
"refund": "Credit release", "bill_settlement": "Period payment",
"reversal": "Reversal" "win_credit": "Win release",
"refund": "Period confirm release",
"reversal": "Refund release"
}, },
"txnStatus": { "txnStatus": {
"posted": "Success", "posted": "Success",
@@ -498,6 +507,7 @@
"winAmount": "Win amount {{amount}}", "winAmount": "Win amount {{amount}}",
"jackpotAmount": "Jackpot {{amount}}", "jackpotAmount": "Jackpot {{amount}}",
"payoutTotal": "Payout total {{amount}}", "payoutTotal": "Payout total {{amount}}",
"creditWinTotal": "Win total {{amount}}",
"matchLose": "Match result: not won", "matchLose": "Match result: not won",
"matchResult": "Match result", "matchResult": "Match result",
"drawPendingMatch": "Draw numbers are not published yet. Winning status cannot be determined.", "drawPendingMatch": "Draw numbers are not published yet. Winning status cannot be determined.",
@@ -528,9 +538,11 @@
"next": "Next ", "next": "Next ",
"drawTime": "Draw time: {{time}}", "drawTime": "Draw time: {{time}}",
"myPayout": "My payout for this issue", "myPayout": "My payout for this issue",
"creditMyWin": "My wins for this issue",
"regular": "Regular: {{amount}}", "regular": "Regular: {{amount}}",
"jackpot": "Jackpot: {{amount}}", "jackpot": "Jackpot: {{amount}}",
"hitPending": "Your ticket has hit a result cell in this issue. The amount summary will show after payout is completed.", "hitPending": "Your ticket has hit a result cell in this issue. The amount summary will show after payout is completed.",
"creditHitPending": "Your ticket matched this issue. Amounts will show after settlement; profit and loss are handled in billing periods.",
"hitHint": "If you win, numbers matched by your tickets are highlighted in gold.", "hitHint": "If you win, numbers matched by your tickets are highlighted in gold.",
"viewMyWinning": "View my winning status", "viewMyWinning": "View my winning status",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
@@ -645,6 +657,10 @@
"partial_failed": "Partially failed", "partial_failed": "Partially failed",
"pending_payout": "Won, pending payout", "pending_payout": "Won, pending payout",
"settled_win": "Paid", "settled_win": "Paid",
"credit": {
"pending_payout": "Won, pending settlement",
"settled_win": "Won"
},
"settled_lose": "Not won", "settled_lose": "Not won",
"failed": "Failed", "failed": "Failed",
"refunded": "Refunded", "refunded": "Refunded",

View File

@@ -49,6 +49,16 @@
"footer": { "footer": {
"secure": "सुरक्षित। विश्वसनीय। अधिकृत पहुँच।" "secure": "सुरक्षित। विश्वसनीय। अधिकृत पहुँच।"
}, },
"login": {
"title": "एजेन्ट खेलाडी लगइन",
"hint": "एजेन्टले दिएको खाताबाट लगइन गर्नुहोस्। मुख्य साइट प्रयोगकर्ताहरू मुख्य साइटबाट प्रवेश गर्नुहोस्।",
"username": "प्रयोगकर्ता नाम",
"password": "पासवर्ड",
"submit": "लगइन",
"missingFields": "कृपया प्रयोगकर्ता नाम र पासवर्ड भर्नुहोस्",
"failed": "लगइन असफल",
"backEntry": "प्रवेशमा फर्कनुहोस्"
},
"errors": { "errors": {
"noToken": "कुनै प्राधिकरण टोकन फेला परेन", "noToken": "कुनै प्राधिकरण टोकन फेला परेन",
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।", "noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",

View File

@@ -24,7 +24,8 @@
"results": "नतिजा", "results": "नतिजा",
"orders": "मेरा बेट", "orders": "मेरा बेट",
"rules": "नियम", "rules": "नियम",
"wallet": "वालेट" "wallet": "वालेट",
"credit": "क्रेडिट"
}, },
"notifications": { "notifications": {
"title": "मिलान बाँकी सूचना", "title": "मिलान बाँकी सूचना",
@@ -387,6 +388,30 @@
"pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।", "pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
"pendingStatus": "प्रक्रिया हुँदैछ", "pendingStatus": "प्रक्रिया हुँदैछ",
"flowsTitle": "वालेट लग", "flowsTitle": "वालेट लग",
"creditTitle": "क्रेडिट",
"creditSubtitle": "क्रेडिट प्रयोग र बिलिङ अवधि लेनदेन",
"creditAvailable": "उपलब्ध क्रेडिट",
"creditSummary": "सीमा {{limit}} · प्रयोग {{used}}",
"creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
"creditFlowsTitle": "क्रेडिट लेनदेन",
"creditLogsTitle": "क्रेडिट लेनदेन",
"creditLogsSubtitle": "ओगट, मुक्ति र बिलिङ भुक्तानी",
"playerChannel": {
"credit": "क्रेडिट लाइन: ओगट, मुक्ति र बिलिङ भुक्तानी (वालेट भुक्तानी होइन)",
"wallet": "मुख्य साइट वालेट खेलाडी"
},
"creditAvailableAfter": "पछि उपलब्ध क्रेडिट",
"creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन",
"emptyCreditLogs": "क्रेडिट लेनदेन छैन",
"creditFlow": {
"all": "सबै",
"bet": "क्रेडिट ओगट",
"credit_release": "मुक्ति",
"bill_settlement": "अवधि भुक्तानी",
"win_credit": "जित मुक्ति",
"refund": "अवधि पुष्टि मुक्ति",
"reversal": "फिर्ता मुक्ति"
},
"totalRecords": "{{total}} रेकर्ड", "totalRecords": "{{total}} रेकर्ड",
"emptyLogs": "वालेट लग छैन", "emptyLogs": "वालेट लग छैन",
"noMoreLogs": "थप लेनदेन छैन", "noMoreLogs": "थप लेनदेन छैन",
@@ -482,6 +507,7 @@
"winAmount": "जित रकम {{amount}}", "winAmount": "जित रकम {{amount}}",
"jackpotAmount": "Jackpot {{amount}}", "jackpotAmount": "Jackpot {{amount}}",
"payoutTotal": "कुल भुक्तानी {{amount}}", "payoutTotal": "कुल भुक्तानी {{amount}}",
"creditWinTotal": "जित कुल {{amount}}",
"matchLose": "मिलान नतिजा: जितेन", "matchLose": "मिलान नतिजा: जितेन",
"matchResult": "मिलान नतिजा", "matchResult": "मिलान नतिजा",
"drawPendingMatch": "यस इश्यूका ड्र नम्बर प्रकाशित भएका छैनन्। जित-नजित अझै निर्धारण गर्न मिल्दैन।", "drawPendingMatch": "यस इश्यूका ड्र नम्बर प्रकाशित भएका छैनन्। जित-नजित अझै निर्धारण गर्न मिल्दैन।",
@@ -512,9 +538,11 @@
"next": "अर्को ", "next": "अर्को ",
"drawTime": "ड्र समय: {{time}}", "drawTime": "ड्र समय: {{time}}",
"myPayout": "यस इश्यूमा मेरो भुक्तानी", "myPayout": "यस इश्यूमा मेरो भुक्तानी",
"creditMyWin": "यस इश्यूमा मेरो जित",
"regular": "सामान्य: {{amount}}", "regular": "सामान्य: {{amount}}",
"jackpot": "Jackpot: {{amount}}", "jackpot": "Jackpot: {{amount}}",
"hitPending": "तपाईंको टिकटले यस इश्यूको नतिजा सेल हिट गरेको छ। भुक्तानी पूरा भएपछि रकम देखिनेछ।", "hitPending": "तपाईंको टिकटले यस इश्यूको नतिजा सेल हिट गरेको छ। भुक्तानी पूरा भएपछि रकम देखिनेछ।",
"creditHitPending": "तपाईंको टिकट मिल्यो। मिलान पछि रकम देखिनेछ; नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
"hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन्।", "hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन्।",
"viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्", "viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
@@ -629,6 +657,10 @@
"partial_failed": "आंशिक असफल", "partial_failed": "आंशिक असफल",
"pending_payout": "जितेको, भुक्तानी बाँकी", "pending_payout": "जितेको, भुक्तानी बाँकी",
"settled_win": "भुक्तानी भयो", "settled_win": "भुक्तानी भयो",
"credit": {
"pending_payout": "जितेको, मिलान बाँकी",
"settled_win": "जितेको"
},
"settled_lose": "जितेन", "settled_lose": "जितेन",
"failed": "असफल", "failed": "असफल",
"refunded": "फिर्ता", "refunded": "फिर्ता",

View File

@@ -3,7 +3,7 @@
"backgroundAlt": "页头背景" "backgroundAlt": "页头背景"
}, },
"loading": { "loading": {
"title": "正在进入彩票大厅(前端测试标记)", "title": "正在进入彩票大厅",
"progress": "进度" "progress": "进度"
}, },
"steps": { "steps": {

View File

@@ -24,7 +24,8 @@
"results": "开奖结果", "results": "开奖结果",
"orders": "我的注单", "orders": "我的注单",
"rules": "规则", "rules": "规则",
"wallet": "钱包" "wallet": "钱包",
"credit": "信用"
}, },
"notifications": { "notifications": {
"title": "待对账提醒", "title": "待对账提醒",
@@ -346,10 +347,12 @@
"wallet": { "wallet": {
"title": "钱包", "title": "钱包",
"subtitle": "余额与划转记录", "subtitle": "余额与划转记录",
"creditTitle": "信用",
"creditSubtitle": "额度占用与账期流水",
"balance": "钱包余额", "balance": "钱包余额",
"creditAvailable": "可用信用", "creditAvailable": "可用信用",
"creditSummary": "授信 {{limit}} · 已用 {{used}}", "creditSummary": "授信 {{limit}} · 已用 {{used}}",
"creditNoTransferHint": "信用盘账号由代理授信,无需主站转入转出;额度调整请联系代理。", "creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
"available": "可用 {{amount}}", "available": "可用 {{amount}}",
"transferIn": "转入", "transferIn": "转入",
"transferOut": "转出", "transferOut": "转出",
@@ -358,6 +361,8 @@
"logs": "流水", "logs": "流水",
"logsTitle": "钱包流水", "logsTitle": "钱包流水",
"logsSubtitle": "划转与下注记录", "logsSubtitle": "划转与下注记录",
"creditLogsTitle": "信用流水",
"creditLogsSubtitle": "占用、释额与账期收付",
"typeFilter": "类型筛选", "typeFilter": "类型筛选",
"transferInTitle": "转入资金", "transferInTitle": "转入资金",
"transferOutTitle": "转出资金", "transferOutTitle": "转出资金",
@@ -392,12 +397,14 @@
"flowsTitle": "资金流水", "flowsTitle": "资金流水",
"creditFlowsTitle": "信用流水", "creditFlowsTitle": "信用流水",
"playerChannel": { "playerChannel": {
"credit": "信用盘玩家(代理授信,流水来自信用账本", "credit": "信用盘:展示额度占用、释额与账期收付(非钱包派彩",
"wallet": "主站钱包玩家(划转与钱包余额流水)" "wallet": "主站钱包玩家(划转与钱包余额流水)"
}, },
"creditAvailableAfter": "变更后可用信用", "creditAvailableAfter": "变更后可用信用",
"creditPaymentRecordOnly": "账期收付记账,不计入可用信用",
"totalRecords": "共 {{total}} 条记录", "totalRecords": "共 {{total}} 条记录",
"emptyLogs": "暂无流水", "emptyLogs": "暂无流水",
"emptyCreditLogs": "暂无信用流水",
"noMoreLogs": "没有更多流水", "noMoreLogs": "没有更多流水",
"balanceAfter": "变更后余额", "balanceAfter": "变更后余额",
"wsBalanceUpdated": "余额 {{change}}{{reason}}", "wsBalanceUpdated": "余额 {{change}}{{reason}}",
@@ -420,10 +427,12 @@
}, },
"creditFlow": { "creditFlow": {
"all": "全部", "all": "全部",
"bet": "下注占用", "bet": "占用额度",
"prize": "派彩", "credit_release": "释额",
"refund": "释放额度", "bill_settlement": "账期收付",
"reversal": "冲正" "win_credit": "中奖释额",
"refund": "账期确认释额",
"reversal": "退本释额"
}, },
"txnStatus": { "txnStatus": {
"posted": "成功", "posted": "成功",
@@ -498,6 +507,7 @@
"winAmount": "中奖金额 {{amount}}", "winAmount": "中奖金额 {{amount}}",
"jackpotAmount": "Jackpot {{amount}}", "jackpotAmount": "Jackpot {{amount}}",
"payoutTotal": "派彩合计 {{amount}}", "payoutTotal": "派彩合计 {{amount}}",
"creditWinTotal": "中奖合计 {{amount}}",
"matchLose": "匹配结果:未中奖", "matchLose": "匹配结果:未中奖",
"matchResult": "匹配结果", "matchResult": "匹配结果",
"drawPendingMatch": "本期开奖号码尚未发布,暂不能判断是否中奖。", "drawPendingMatch": "本期开奖号码尚未发布,暂不能判断是否中奖。",
@@ -528,9 +538,11 @@
"next": "下一期 ", "next": "下一期 ",
"drawTime": "开奖时间: {{time}}", "drawTime": "开奖时间: {{time}}",
"myPayout": "本期我的派彩", "myPayout": "本期我的派彩",
"creditMyWin": "本期我的中奖",
"regular": "常规:{{amount}}", "regular": "常规:{{amount}}",
"jackpot": "Jackpot{{amount}}", "jackpot": "Jackpot{{amount}}",
"hitPending": "您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。", "hitPending": "您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。",
"creditHitPending": "您的注单已命中本期开奖号码;结算完成后将显示金额,盈亏在账期统一处理。",
"hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示。", "hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示。",
"viewMyWinning": "查看我的中奖情况", "viewMyWinning": "查看我的中奖情况",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
@@ -645,6 +657,10 @@
"partial_failed": "部分失败", "partial_failed": "部分失败",
"pending_payout": "已中奖待派彩", "pending_payout": "已中奖待派彩",
"settled_win": "已派彩", "settled_win": "已派彩",
"credit": {
"pending_payout": "已中奖待结算",
"settled_win": "已中奖"
},
"settled_lose": "未中奖", "settled_lose": "未中奖",
"failed": "失败", "failed": "失败",
"refunded": "已退款", "refunded": "已退款",

View File

@@ -1,9 +1,11 @@
import axios, { import axios, {
isAxiosError, isAxiosError,
type AxiosError,
type AxiosRequestConfig, type AxiosRequestConfig,
type AxiosResponse, type AxiosResponse,
} from "axios"; } from "axios";
import { isInIframe } from "@/components/iframe-bridge";
import { withPlayerAuthHeader } from "@/lib/lottery-auth"; import { withPlayerAuthHeader } from "@/lib/lottery-auth";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale"; import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
@@ -14,6 +16,7 @@ import {
import { isApiEnvelope } from "@/types/api/envelope"; import { isApiEnvelope } from "@/types/api/envelope";
import { useErrorStore } from "@/stores/error-store"; import { useErrorStore } from "@/stores/error-store";
import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base"; import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base";
import i18n from "@/i18n";
/** /**
* **第一层**`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path如 `/currencies`)。 * **第一层**`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path如 `/currencies`)。
@@ -24,6 +27,34 @@ export const lotteryHttp = axios.create({
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
/** 凭据类 401非 token 失效),不应触发「会话过期」跳转 */
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
return false;
}
return `${config.url ?? ""}`.includes("/player/auth/login");
}
/** 是否应将 401 视为已登录态失效并跳转入口(见 {@link EntryGate} `session=expired` */
function shouldRedirectPlayerSessionExpired(error: AxiosError): boolean {
if (isPlayerAuthLoginRequest(error.config)) {
return false;
}
if (typeof window !== "undefined" && window.location.pathname === "/login") {
return false;
}
const body = error.response?.data;
if (isApiEnvelope(body) && PLAYER_CREDENTIAL_BIZ_CODES.has(body.code)) {
return false;
}
return true;
}
/** 站内接口 401清本地会话并回入口与 {@link EntryGate} `session=expired` 衔接 */ /** 站内接口 401清本地会话并回入口与 {@link EntryGate} `session=expired` 衔接 */
/** 500 错误:更新全局服务器错误状态 */ /** 500 错误:更新全局服务器错误状态 */
lotteryHttp.interceptors.response.use( lotteryHttp.interceptors.response.use(
@@ -32,20 +63,28 @@ lotteryHttp.interceptors.response.use(
if (isAxiosError(error) && typeof window !== "undefined") { if (isAxiosError(error) && typeof window !== "undefined") {
const status = error.response?.status; const status = error.response?.status;
// 401: 会话过期,清除令牌并重定向 // 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
if (status === 401) { if (status === 401) {
usePlayerSessionStore.getState().clearBearerToken(); const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
const onEntry = window.location.pathname === "/"; if (redirectSessionExpired || window.location.pathname === "/login") {
const alreadyExpired = window.location.search.includes("session=expired"); usePlayerSessionStore.getState().clearBearerToken();
if (!onEntry || !alreadyExpired) { }
window.location.replace("/?session=expired"); if (redirectSessionExpired) {
const pathname = window.location.pathname;
const alreadyExpired = window.location.search.includes("session=expired");
const expiredTarget = isInIframe() ? "/?session=expired" : "/login?session=expired";
const onExpiredPage =
(pathname === "/" || pathname === "/login") && alreadyExpired;
if (!onExpiredPage) {
window.location.replace(expiredTarget);
}
} }
} }
// 500/502/503: 服务器错误,更新全局错误状态 // 500/502/503: 服务器错误,更新全局错误状态
if (status === 500 || status === 502 || status === 503) { if (status === 500 || status === 502 || status === 503) {
const setServerError = useErrorStore.getState().setServerError; const setServerError = useErrorStore.getState().setServerError;
let message = "服务器暂时不可用,请稍后重试"; let message = i18n.t("serverError.serverMessage", { ns: "player" });
// 尝试从响应中获取更详细的错误信息 // 尝试从响应中获取更详细的错误信息
const responseData = error.response?.data; const responseData = error.response?.data;

View File

@@ -0,0 +1,10 @@
/** 信用盘玩家(代理授信);与主站钱包资金盘区分。 */
export function isCreditFundingPlayer(
source?: { funding_mode?: string | null; credit_line_mode?: boolean | null } | null,
): boolean {
if (!source) {
return false;
}
return source.credit_line_mode === true || source.funding_mode === "credit";
}

View File

@@ -12,7 +12,9 @@ export type WalletLogItem = {
amount_abs: number; amount_abs: number;
direction: "in" | "out"; direction: "in" | "out";
currency_code: string; currency_code: string;
balance_after: number; balance_after: number | null;
/** 信用盘false 表示账期收付记账,不改变可用信用 */
affects_available_credit?: boolean;
ref_id: string | null; ref_id: string | null;
idempotent_key: string | null; idempotent_key: string | null;
external_ref_no: string | null; external_ref_no: string | null;