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:
2026-06-05 18:01:11 +08:00
parent 3cd87ce014
commit 36adf8699d
21 changed files with 496 additions and 80 deletions

View File

@@ -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:

View File

@@ -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
/>

View File

@@ -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);

View File

@@ -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,27 +126,29 @@ export function HallWalletStrip() {
</div>
</div>
<div className="grid grid-cols-2 gap-2.5">
<TransferInDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
lotteryMinor={availableMinor}
mainMinor={mainMinor}
onSuccess={refresh}
/>
<TransferOutDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
availableMinor={availableMinor}
onSuccess={refresh}
/>
</div>
{isCreditPlayer ? null : (
<div className="grid grid-cols-2 gap-2.5">
<TransferInDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
lotteryMinor={availableMinor}
mainMinor={mainMinor}
onSuccess={refresh}
/>
<TransferOutDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
availableMinor={availableMinor}
onSuccess={refresh}
/>
</div>
)}
</section>
);
}

View File

@@ -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";
}

View File

@@ -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;

View 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>
);
}

View File

@@ -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) => ({
...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 (
<>
<section className="space-y-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
<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>

View File

@@ -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>

View File

@@ -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() {
<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", {
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),
})}
</p>
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
/>
</div>
{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-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
/>
</div>
)}
<WalletLogsBlock
creditMode={isCreditPlayer}
logs={logs}
logsLoading={loading || logsLoading}
loadingMore={loadingMore}