feat: enhance wallet functionality and improve error handling
- Updated .env.example to include optional player site code configuration. - Modified error messages in hall-bet-errors for better clarity. - Improved the betting grid logic to handle invalid draw IDs and reload draws when necessary. - Adjusted wallet components to differentiate between credit and main wallet balances, including new translations for credit-related terms. - Enhanced wallet logs to support credit activity tracking and improved UI for displaying credit information.
This commit is contained in:
@@ -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
|
||||
|
||||
26
src/api/player-auth.ts
Normal file
26
src/api/player-auth.ts
Normal file
@@ -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<PlayerAuthLoginData> {
|
||||
return lotteryRequest.post<PlayerAuthLoginData>(`/player/auth/login`, body);
|
||||
}
|
||||
5
src/app/(player)/login/page.tsx
Normal file
5
src/app/(player)/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PlayerLoginScreen } from "@/features/player/player-login-screen";
|
||||
|
||||
export default function PlayerLoginPage() {
|
||||
return <PlayerLoginScreen />;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
|
||||
<p className="text-sm font-semibold text-white/90">
|
||||
{balance?.credit_line_mode
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
@@ -120,6 +126,7 @@ export function HallWalletStrip() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreditPlayer ? null : (
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TransferInDialog
|
||||
idPrefix="hall-"
|
||||
@@ -141,6 +148,7 @@ export function HallWalletStrip() {
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
115
src/features/player/player-login-screen.tsx
Normal file
115
src/features/player/player-login-screen.tsx
Normal file
@@ -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<void> {
|
||||
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 (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative h-[32vh] min-h-[220px] bg-red-600">
|
||||
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority />
|
||||
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
|
||||
<LanguageSwitcher variant="header" showFlag={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-md flex-1 px-4 py-8">
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
{t("login.title", { defaultValue: "代理玩家登录" })}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("login.hint", { defaultValue: "使用代理为您开通的账号登录。主站用户请从主站进入。" })}
|
||||
</p>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="login-user">{t("login.username", { defaultValue: "登录账号" })}</Label>
|
||||
<Input
|
||||
id="login-user"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="login-pass">{t("login.password", { defaultValue: "密码" })}</Label>
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{t("login.submit", { defaultValue: "登录" })}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Link href="/" className="text-red-600 underline">
|
||||
{t("login.backEntry", { defaultValue: "返回入口" })}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"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) => ({
|
||||
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),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
label: t(f.labelKey, { defaultValue: FLOW_LABEL_FALLBACKS[f.labelKey] ?? f.labelKey }),
|
||||
}));
|
||||
}, [creditMode, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<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 className="flex flex-wrap gap-1.5">
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
@@ -113,7 +156,12 @@ export function WalletLogsBlock({
|
||||
</li>
|
||||
) : (
|
||||
logs.items.map((row) => (
|
||||
<LogRow key={row.log_id} item={row} currency={currency} />
|
||||
<LogRow
|
||||
key={row.log_id}
|
||||
item={row}
|
||||
currency={currency}
|
||||
creditMode={creditMode}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
@@ -149,9 +197,11 @@ export function WalletLogsBlock({
|
||||
export function LogRow({
|
||||
item,
|
||||
currency,
|
||||
creditMode = false,
|
||||
}: {
|
||||
item: WalletLogItem;
|
||||
currency: string;
|
||||
creditMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const ccy = item.currency_code || currency;
|
||||
@@ -171,7 +221,7 @@ export function LogRow({
|
||||
<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 />
|
||||
<p className="truncate text-base font-black leading-tight text-[#101a33]">
|
||||
{logTypeLabel(item.type, t)}
|
||||
{logTypeLabel(item.type, t, creditMode)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs font-medium text-slate-500">
|
||||
@@ -196,7 +246,9 @@ export function LogRow({
|
||||
</div>
|
||||
|
||||
<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">{t("wallet.balanceAfter")}</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]">
|
||||
{formatMinorAsCurrency(item.balance_after, ccy)}
|
||||
</span>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [creditMode, setCreditMode] = useState(false);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,16 +178,26 @@ export function WalletScreen() {
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
|
||||
<p className="text-sm font-semibold text-white/90">
|
||||
{isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<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">
|
||||
{formatMinorAsCurrency(balance?.balance ?? 0, currency)}
|
||||
{formatMinorAsCurrency(displayMinor, currency)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
{t("wallet.available", {
|
||||
{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),
|
||||
})}
|
||||
</p>
|
||||
@@ -189,6 +205,13 @@ export function WalletScreen() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isCreditPlayer ? (
|
||||
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("wallet.creditNoTransferHint", {
|
||||
defaultValue: "信用盘账号由代理授信,无需主站转入转出;额度调整请联系代理。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
@@ -214,8 +237,10 @@ export function WalletScreen() {
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WalletLogsBlock
|
||||
creditMode={isCreditPlayer}
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
||||
import { disconnectLotteryEcho, getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import {
|
||||
disconnectLotteryEcho,
|
||||
getLotteryEcho,
|
||||
isLotteryReverbConfigured,
|
||||
} from "@/lib/lottery-echo";
|
||||
import {
|
||||
useNetworkConnectionStore,
|
||||
type NetworkMode,
|
||||
@@ -334,9 +338,13 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reverbConfigured = isLotteryReverbConfigured();
|
||||
|
||||
return {
|
||||
mode,
|
||||
showDegradedBanner: mode === "polling" || mode === "offline",
|
||||
/** 仅当配置了 Reverb 却连不上时提示;本地未配 WS 时静默走 HTTP 轮询 */
|
||||
showDegradedBanner:
|
||||
reverbConfigured && (mode === "polling" || mode === "offline"),
|
||||
reconnect,
|
||||
startWalletPolling,
|
||||
stopWalletPolling,
|
||||
|
||||
@@ -347,6 +347,9 @@
|
||||
"title": "Wallet",
|
||||
"subtitle": "Balance and transfer records",
|
||||
"balance": "Wallet Balance",
|
||||
"creditAvailable": "Available credit",
|
||||
"creditSummary": "Limit {{limit}} · Used {{used}}",
|
||||
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Contact your agent to change your limit.",
|
||||
"available": "Available {{amount}}",
|
||||
"transferIn": "Transfer In",
|
||||
"transferOut": "Transfer Out",
|
||||
@@ -387,6 +390,12 @@
|
||||
"pendingDescription": "The following transfers have not been finally confirmed by the main site. Contact support if they do not arrive after a long time.",
|
||||
"pendingStatus": "Processing",
|
||||
"flowsTitle": "Wallet logs",
|
||||
"creditFlowsTitle": "Credit activity",
|
||||
"playerChannel": {
|
||||
"credit": "Credit-line player (agent credit ledger)",
|
||||
"wallet": "Main-site wallet player (transfers & wallet ledger)"
|
||||
},
|
||||
"creditAvailableAfter": "Available credit after",
|
||||
"totalRecords": "{{total}} records",
|
||||
"emptyLogs": "No wallet logs",
|
||||
"noMoreLogs": "No more transactions",
|
||||
@@ -409,6 +418,13 @@
|
||||
"refund": "Refund",
|
||||
"reversal": "Reversal"
|
||||
},
|
||||
"creditFlow": {
|
||||
"all": "All",
|
||||
"bet": "Bet hold",
|
||||
"prize": "Payout",
|
||||
"refund": "Credit release",
|
||||
"reversal": "Reversal"
|
||||
},
|
||||
"txnStatus": {
|
||||
"posted": "Success",
|
||||
"pending_reconcile": "Pending reconciliation",
|
||||
|
||||
@@ -49,6 +49,16 @@
|
||||
"footer": {
|
||||
"secure": "安全 · 可信 · 授权访问"
|
||||
},
|
||||
"login": {
|
||||
"title": "代理玩家登录",
|
||||
"hint": "使用代理为您开通的账号登录。主站用户请从主站进入。",
|
||||
"username": "登录账号",
|
||||
"password": "密码",
|
||||
"submit": "登录",
|
||||
"missingFields": "请填写账号和密码",
|
||||
"failed": "登录失败",
|
||||
"backEntry": "返回入口"
|
||||
},
|
||||
"errors": {
|
||||
"noToken": "未发现授权令牌",
|
||||
"noTokenDetail": "请返回主站后重试。",
|
||||
|
||||
@@ -330,7 +330,7 @@
|
||||
"2002": "该玩法已关闭,请选择其他玩法。",
|
||||
"2004": "号码格式或长度不符合该玩法要求。",
|
||||
"2005": "玩法参数不完整(如单双大小需选择位数与维度)。",
|
||||
"2006": "当前期号不可下注。",
|
||||
"2006": "期号无效或已切换,请刷新大厅后重试。",
|
||||
"2007": "该玩法暂不支持或缺少赔率配置。",
|
||||
"2008": "赔率或玩法配置已更新,请关闭预览后重新操作。",
|
||||
"2009": "该订单已退款或不可重复提交,请关闭预览后重新下注。",
|
||||
@@ -348,6 +348,9 @@
|
||||
"title": "钱包",
|
||||
"subtitle": "余额与划转记录",
|
||||
"balance": "钱包余额",
|
||||
"creditAvailable": "可用信用",
|
||||
"creditSummary": "授信 {{limit}} · 已用 {{used}}",
|
||||
"creditNoTransferHint": "信用盘账号由代理授信,无需主站转入转出;额度调整请联系代理。",
|
||||
"available": "可用 {{amount}}",
|
||||
"transferIn": "转入",
|
||||
"transferOut": "转出",
|
||||
@@ -388,6 +391,12 @@
|
||||
"pendingDescription": "以下划转主站结果未最终确认;若长时间未到账请联系客服。",
|
||||
"pendingStatus": "处理中",
|
||||
"flowsTitle": "资金流水",
|
||||
"creditFlowsTitle": "信用流水",
|
||||
"playerChannel": {
|
||||
"credit": "信用盘玩家(代理授信,流水来自信用账本)",
|
||||
"wallet": "主站钱包玩家(划转与钱包余额流水)"
|
||||
},
|
||||
"creditAvailableAfter": "变更后可用信用",
|
||||
"totalRecords": "共 {{total}} 条记录",
|
||||
"emptyLogs": "暂无流水",
|
||||
"noMoreLogs": "没有更多流水",
|
||||
@@ -410,6 +419,13 @@
|
||||
"refund": "退本",
|
||||
"reversal": "冲正"
|
||||
},
|
||||
"creditFlow": {
|
||||
"all": "全部",
|
||||
"bet": "下注占用",
|
||||
"prize": "派彩",
|
||||
"refund": "释放额度",
|
||||
"reversal": "冲正"
|
||||
},
|
||||
"txnStatus": {
|
||||
"posted": "成功",
|
||||
"pending_reconcile": "待对账",
|
||||
|
||||
@@ -12,6 +12,63 @@ function ensurePusherOnWindow(): void {
|
||||
}
|
||||
|
||||
let echoSingleton: Echo<"reverb"> | 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user