diff --git a/.env.example b/.env.example index e98635c..d1b1413 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ LOTTERY_API_UPSTREAM=http://127.0.0.1:8000 # 可选:入口授权失败时返回主站(build 前设置,会打进 JS) # NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173 +# 可选:代理玩家登录时隐式携带的站点编号(不配则后端用 LOTTERY_DEFAULT_SITE_CODE) +# NEXT_PUBLIC_PLAYER_SITE_CODE=default_site + # Reverb:本地全栈联调时取消注释,并 php artisan reverb:start;不配则走轮询 # NEXT_PUBLIC_REVERB_APP_KEY= # NEXT_PUBLIC_REVERB_HOST=127.0.0.1 diff --git a/src/api/player-auth.ts b/src/api/player-auth.ts new file mode 100644 index 0000000..8220c5e --- /dev/null +++ b/src/api/player-auth.ts @@ -0,0 +1,26 @@ +import { lotteryRequest } from "@/lib/lottery-http"; + +export type PlayerAuthLoginPayload = { + site_code?: string; + username: string; + password: string; +}; + +export type PlayerAuthLoginData = { + access_token: string; + expires_in: number; + token_type: string; + player: { + id: number; + site_code: string; + username: string | null; + nickname: string | null; + funding_mode: string; + auth_source: string; + }; +}; + +/** `POST /api/v1/player/auth/login`(公开) */ +export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise { + return lotteryRequest.post(`/player/auth/login`, body); +} diff --git a/src/app/(player)/login/page.tsx b/src/app/(player)/login/page.tsx new file mode 100644 index 0000000..0235512 --- /dev/null +++ b/src/app/(player)/login/page.tsx @@ -0,0 +1,5 @@ +import { PlayerLoginScreen } from "@/features/player/player-login-screen"; + +export default function PlayerLoginPage() { + return ; +} diff --git a/src/features/hall/hall-bet-errors.ts b/src/features/hall/hall-bet-errors.ts index 806152e..469385f 100644 --- a/src/features/hall/hall-bet-errors.ts +++ b/src/features/hall/hall-bet-errors.ts @@ -28,7 +28,7 @@ export function mapTicketBetError( "玩法参数不完整(如单双大小需选择位数与维度)。", ); case 2006: - return msg("hall.ticketError.2006", "当前期号不可下注。"); + return msg("hall.ticketError.2006", "期号无效或已切换,请刷新大厅后重试。"); case 2007: return msg("hall.ticketError.2007", "该玩法暂不支持或缺少赔率配置。"); case 2008: diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx index 418c464..e20f3f5 100644 --- a/src/features/hall/hall-bet-preview-dialog.tsx +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -75,7 +75,8 @@ function SubmittingPanel() { alt="" width={150} height={119} - className="mx-auto h-[118px] w-[150px] object-contain" + style={{ width: "auto", height: "auto" }} + className="mx-auto max-h-[118px] w-[150px] object-contain" priority aria-hidden /> diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index d9ebe0b..d65cdda 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -978,6 +978,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setPreviewData(null); clearPlaceTraceId(); } + if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { + void reloadDraw(); + } toast.error(mapTicketBetError(code, msg, t)); } finally { setPreviewLoading(false); @@ -1009,10 +1012,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const traceId = placeTraceIdRef.current ?? newPlaceTraceId(); placeTraceIdRef.current = traceId; + const drawIdForBet = + previewData.draw.draw_id.trim() || display.draw_no.trim(); + if (drawIdForBet === "") { + toast.error(t("hall.notBettable")); + return; + } + setPlaceLoading(true); try { const data = await postTicketPlace({ - draw_id: display.draw_no, + draw_id: drawIdForBet, currency_code: currencyCode, client_trace_id: traceId, lines, @@ -1067,6 +1077,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setPreviewData(null); clearPlaceTraceId(); } + if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { + void reloadDraw(); + } toast.error(mapTicketBetError(code, msg, t)); } finally { setPlaceLoading(false); diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index ec8e9d0..d8c0f62 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -84,6 +84,8 @@ export function HallWalletStrip() { }, [mode, refresh]); const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0); + const isCreditPlayer = + balance?.credit_line_mode === true || balance?.funding_mode === "credit"; const mainMinor = balance?.main_balance === null || balance?.main_balance === undefined ? null @@ -108,7 +110,11 @@ export function HallWalletStrip() {
-

{t("wallet.balance")}

+

+ {balance?.credit_line_mode + ? t("wallet.creditAvailable", { defaultValue: "可用信用" }) + : t("wallet.balance")} +

{loading ? ( ) : ( @@ -120,27 +126,29 @@ export function HallWalletStrip() {
-
- - -
+ {isCreditPlayer ? null : ( +
+ + +
+ )} ); } diff --git a/src/features/orders/group-ticket-items.ts b/src/features/orders/group-ticket-items.ts index a123383..ea47baa 100644 --- a/src/features/orders/group-ticket-items.ts +++ b/src/features/orders/group-ticket-items.ts @@ -36,16 +36,48 @@ function resolveGroupStatus(items: TicketItemListRow[]): string { return orderStatus; } - const itemStatuses = new Set(items.map((row) => row.status)); - if ( - itemStatuses.has("failed") && - (itemStatuses.has("pending_draw") || - itemStatuses.has("placed") || - itemStatuses.has("pending_confirm")) - ) { + const hasStatus = (status: string): boolean => items.some((row) => row.status === status); + const hasAny = (statuses: string[]): boolean => statuses.some((status) => hasStatus(status)); + const allMatch = (statuses: string[]): boolean => + items.length > 0 && items.every((row) => statuses.includes(row.status)); + const hasWinningItem = items.some( + (row) => row.status === "settled_win" && row.win_amount + row.jackpot_win_amount > 0, + ); + + if (hasStatus("failed") && hasAny(["pending_draw", "placed", "pending_confirm"])) { return "partial_failed"; } + if (hasAny(["pending_confirm", "partial_pending_confirm"])) { + return hasAny(["failed", "pending_draw", "placed", "pending_payout", "settled_win", "settled_lose"]) + ? "partial_pending_confirm" + : "pending_confirm"; + } + + if (hasAny(["pending_draw", "placed"])) { + return "pending_draw"; + } + + if (hasStatus("pending_payout")) { + return "pending_payout"; + } + + if (hasWinningItem) { + return "settled_win"; + } + + if (allMatch(["failed"])) { + return "failed"; + } + + if (allMatch(["refunded"])) { + return "refunded"; + } + + if (allMatch(["settled_lose", "settled_win"])) { + return "settled_lose"; + } + return items[0]?.status ?? "unknown"; } diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index 037eb9e..c12a214 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -141,6 +141,10 @@ export function EntryGate() { if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) { return; } + if (typeof window !== "undefined" && !isInIframe()) { + router.replace("/login"); + return; + } setPhase("failed"); setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]); return; diff --git a/src/features/player/player-login-screen.tsx b/src/features/player/player-login-screen.tsx new file mode 100644 index 0000000..12c4c2e --- /dev/null +++ b/src/features/player/player-login-screen.tsx @@ -0,0 +1,115 @@ +"use client"; + +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 { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { getPlayerMe } from "@/api/player"; +import { postPlayerAuthLogin } from "@/api/player-auth"; +import { getPublicCurrencies } from "@/api/currency"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { LotteryApiBizError } from "@/types/api/errors"; + +const DEPLOY_SITE_CODE = process.env.NEXT_PUBLIC_PLAYER_SITE_CODE?.trim() ?? ""; + +export function PlayerLoginScreen(): React.ReactElement { + const { t } = useTranslation("entry"); + const router = useRouter(); + const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken); + const setProfile = usePlayerSessionStore((s) => s.setProfile); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent): Promise { + event.preventDefault(); + if (!username.trim() || !password) { + toast.error(t("login.missingFields", { defaultValue: "请填写账号和密码" })); + return; + } + + setLoading(true); + try { + const data = await postPlayerAuthLogin({ + ...(DEPLOY_SITE_CODE !== "" ? { site_code: DEPLOY_SITE_CODE } : {}), + username: username.trim(), + password, + }); + setBearerToken(data.access_token); + const me = await getPlayerMe(); + setProfile(me); + try { + const currencies = await getPublicCurrencies(); + usePlayerSessionStore.getState().setCurrencies(currencies.items); + } catch { + // 不阻断登录 + } + router.replace("/hall"); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed", { defaultValue: "登录失败" })); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +
+ +
+
+ +
+

+ {t("login.title", { defaultValue: "代理玩家登录" })} +

+

+ {t("login.hint", { defaultValue: "使用代理为您开通的账号登录。主站用户请从主站进入。" })} +

+ +
void handleSubmit(e)}> +
+ + setUsername(e.target.value)} + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ +
+ +

+ + {t("login.backEntry", { defaultValue: "返回入口" })} + +

+
+
+ ); +} diff --git a/src/features/wallet/wallet-logs-block.tsx b/src/features/wallet/wallet-logs-block.tsx index f8d0c3d..f55ee9a 100644 --- a/src/features/wallet/wallet-logs-block.tsx +++ b/src/features/wallet/wallet-logs-block.tsx @@ -20,11 +20,38 @@ export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [ { value: "reversal", labelKey: "wallet.flow.reversal" }, ]; +/** 信用盘:无主站划转;文案强调占用/释放信用 */ +export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [ + { value: "", labelKey: "wallet.creditFlow.all" }, + { value: "bet", labelKey: "wallet.creditFlow.bet" }, + { value: "prize", labelKey: "wallet.creditFlow.prize" }, + { value: "refund", labelKey: "wallet.creditFlow.refund" }, + { value: "reversal", labelKey: "wallet.creditFlow.reversal" }, +]; + +const FLOW_LABEL_FALLBACKS: Record = { + "wallet.flow.all": "全部", + "wallet.flow.transfer_in": "转入", + "wallet.flow.transfer_out": "转出", + "wallet.flow.bet": "下注", + "wallet.flow.prize": "派奖", + "wallet.flow.refund": "退款", + "wallet.flow.reversal": "冲正", + "wallet.creditFlow.all": "全部", + "wallet.creditFlow.bet": "下注占用", + "wallet.creditFlow.prize": "派奖释放", + "wallet.creditFlow.refund": "退款返还", + "wallet.creditFlow.reversal": "冲正回退", +}; + export function logTypeLabel( type: string, t?: (key: string, options?: { defaultValue?: string }) => string, + creditMode = false, ): string { - return t?.(`wallet.flow.${type}`, { defaultValue: type }) ?? type; + const prefix = creditMode ? "wallet.creditFlow." : "wallet.flow."; + const key = `${prefix}${type}`; + return t?.(key, { defaultValue: FLOW_LABEL_FALLBACKS[key] ?? type }) ?? type; } function txnStatusLabel( @@ -46,6 +73,8 @@ type WalletLogsBlockProps = { currency: string; /** 独立流水页可隐藏标题或改文案 */ title?: string; + /** 信用盘:专用筛选项与列表文案 */ + creditMode?: boolean; }; /** 类型筛选 + 列表(待对账见顶栏通知铃铛) */ @@ -60,23 +89,37 @@ export function WalletLogsBlock({ onFilterChange, currency, title, + creditMode = false, }: WalletLogsBlockProps) { const { t } = useTranslation("player"); - const resolvedTitle = title ?? t("wallet.flowsTitle"); - const filters = useMemo( - () => - WALLET_FLOW_FILTERS.map((f) => ({ - ...f, - label: t(f.labelKey), - })), - [t], - ); + const resolvedTitle = + title + ?? (creditMode + ? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" }) + : t("wallet.flowsTitle", { defaultValue: "钱包流水" })); + const channelHint = creditMode + ? t("wallet.playerChannel.credit", { + defaultValue: "信用盘玩家(占用与释放额度流水)", + }) + : t("wallet.playerChannel.wallet", { + defaultValue: "主站钱包玩家(划转与钱包余额流水)", + }); + const filters = useMemo(() => { + const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS; + return source.map((f) => ({ + ...f, + label: t(f.labelKey, { defaultValue: FLOW_LABEL_FALLBACKS[f.labelKey] ?? f.labelKey }), + })); + }, [creditMode, t]); return ( <>
-

{resolvedTitle}

+
+

{resolvedTitle}

+

{channelHint}

+
{filters.map((f) => (
- {t("wallet.balanceAfter")} + + {creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")} + {formatMinorAsCurrency(item.balance_after, ccy)} diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index 4769870..151d80e 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getWalletLogs } from "@/api/wallet"; +import { getWalletBalance, getWalletLogs } from "@/api/wallet"; import { Button } from "@/components/ui/button"; import { PlayerPanel } from "@/components/layout/player-panel"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; @@ -24,6 +24,7 @@ export function WalletLogsScreen() { const [logsLoading, setLogsLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [creditMode, setCreditMode] = useState(false); const loadMoreRef = useRef(null); const fetchPassRef = useRef(true); @@ -39,6 +40,10 @@ export function WalletLogsScreen() { setLogsLoading(true); } try { + const balance = await getWalletBalance({ currency }); + setCreditMode( + balance.credit_line_mode === true || balance.funding_mode === "credit", + ); const nextLogs = await getWalletLogs({ page: targetPage, size: WALLET_LOGS_PAGE_SIZE, @@ -129,6 +134,7 @@ export function WalletLogsScreen() { filter={filter} onFilterChange={setFilter} currency={currency} + creditMode={creditMode} title={t("wallet.typeFilter")} />
diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index 1ffd16a..2765850 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -112,6 +112,12 @@ export function WalletScreen() { const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; + const isCreditPlayer = + balance?.credit_line_mode === true || balance?.funding_mode === "credit"; + const displayMinor = isCreditPlayer + ? Number(balance?.available_balance ?? 0) + : Number(balance?.balance ?? 0); + const loadMore = useCallback(() => { if (!logs || !hasMore || loadingMore) return; @@ -172,50 +178,69 @@ export function WalletScreen() {
-

{t("wallet.balance")}

+

+ {isCreditPlayer + ? t("wallet.creditAvailable", { defaultValue: "可用信用" }) + : t("wallet.balance")} +

{loading ? ( ) : (

- {formatMinorAsCurrency(balance?.balance ?? 0, currency)} + {formatMinorAsCurrency(displayMinor, currency)}

)}

- {t("wallet.available", { - amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency), - })} + {isCreditPlayer + ? t("wallet.creditSummary", { + defaultValue: "授信 {{limit}} · 已用 {{used}}", + limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency), + used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency), + }) + : t("wallet.available", { + amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency), + })}

-
- - -
+ {isCreditPlayer ? ( +

+ {t("wallet.creditNoTransferHint", { + defaultValue: "信用盘账号由代理授信,无需主站转入转出;额度调整请联系代理。", + })} +

+ ) : ( +
+ + +
+ )} | null = null; +let loggedRemoteReverbSkip = false; + +function isLocalDevHostname(hostname: string): boolean { + return ( + hostname === "localhost" + || hostname === "127.0.0.1" + || hostname.endsWith(".local") + ); +} + +function isLocalReverbHost(host: string): boolean { + const h = host.trim().toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h.startsWith("127."); +} + +/** + * 本地 Next + 本地 Laravel,但 Reverb 指向线上时,WS 会把「线上期号」盖到界面上, + * 下注仍走本地 API → 期号在库中不存在(业务码 2006)。代理账号常走 /login 久留大厅,更易踩中。 + */ +function shouldSkipReverbForLocalApiMismatch(): boolean { + if (typeof window === "undefined") { + return false; + } + if (!isLocalDevHostname(window.location.hostname)) { + return false; + } + const reverbHost = process.env.NEXT_PUBLIC_REVERB_HOST?.trim() ?? ""; + if (reverbHost === "" || isLocalReverbHost(reverbHost)) { + return false; + } + if (!loggedRemoteReverbSkip) { + loggedRemoteReverbSkip = true; + console.warn( + "[lottery-echo] 本地开发已跳过远程 Reverb:请把 NEXT_PUBLIC_REVERB_* 指到本机," + + "或设 NEXT_PUBLIC_REVERB_ENABLED=false,以免大厅期号与 LOTTERY_API_UPSTREAM 不一致。", + ); + } + return true; +} + +/** + * 是否配置了可用的 Reverb(用于区分「故意 HTTP 轮询」与「WS 断线降级」)。 + */ +export function isLotteryReverbConfigured(): boolean { + if (process.env.NEXT_PUBLIC_REVERB_ENABLED === "false") { + return false; + } + const key = process.env.NEXT_PUBLIC_REVERB_APP_KEY?.trim() ?? ""; + const host = process.env.NEXT_PUBLIC_REVERB_HOST?.trim() ?? ""; + if (key === "" || host === "") { + return false; + } + if (shouldSkipReverbForLocalApiMismatch()) { + return false; + } + return true; +} /** * NEXT_PUBLIC_REVERB_* 须与当前 Reverb 服务一致,并 `php artisan reverb:start`。 @@ -24,6 +81,10 @@ export function getLotteryEcho(): Echo<"reverb"> | null { return null; } + if (shouldSkipReverbForLocalApiMismatch()) { + return null; + } + const key = process.env.NEXT_PUBLIC_REVERB_APP_KEY; const host = process.env.NEXT_PUBLIC_REVERB_HOST; if (!key?.length || !host?.length) { diff --git a/src/types/api/player-me.ts b/src/types/api/player-me.ts index 7397842..2585feb 100644 --- a/src/types/api/player-me.ts +++ b/src/types/api/player-me.ts @@ -3,6 +3,8 @@ export type PlayerMeData = { id: number; site_code: string; site_player_id: string; + auth_source?: string; + funding_mode?: string; username: string | null; nickname: string | null; default_currency: string; diff --git a/src/types/api/wallet-balance.ts b/src/types/api/wallet-balance.ts index 641ed81..0c71a63 100644 --- a/src/types/api/wallet-balance.ts +++ b/src/types/api/wallet-balance.ts @@ -8,4 +8,9 @@ export type WalletBalanceData = { currency_code: string; wallet_type: string; frozen_balance: string | number; + credit_line_mode?: boolean; + funding_mode?: string; + auth_source?: string; + credit_limit?: string | number; + used_credit?: string | number; }; diff --git a/src/types/api/wallet-logs.ts b/src/types/api/wallet-logs.ts index 9143e13..7370c67 100644 --- a/src/types/api/wallet-logs.ts +++ b/src/types/api/wallet-logs.ts @@ -3,6 +3,10 @@ export type WalletLogItem = { log_id: string; type: string; biz_type: string; + /** wallet_txn | credit_ledger */ + ledger_source?: string; + funding_mode?: string; + auth_source?: string | null; /** 正数为加款,负数为扣款(最小货币单位) */ amount: number; amount_abs: number; @@ -35,6 +39,10 @@ export type WalletLogsData = { total: number; page: number; per_page: number; + /** 本页数据来源:钱包流水表 / 信用流水表 */ + ledger_source?: string; + funding_mode?: string; + auth_source?: string | null; pending_reconcile: WalletPendingTransfer[]; };