feat: 增强投注结果弹窗与投注网格功能
更新 HallBetResultDialog:根据成功与失败数量显示不同的结果图标与标题。 优化 HallBettingGrid:新增 place trace ID 管理机制,更好地处理投注提交流程,并防止重复扣费。 增强注单详情页面:针对临时状态中的注单自动刷新,提升用户体验。 新增多语言翻译:补充投注结果与状态相关文案,支持更完善的用户反馈。
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, ClipboardList, Ticket, XIcon } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, ClipboardList, Ticket, XCircle, XIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TicketPlaceData } from "@/types/api/ticket";
|
||||
|
||||
type HallBetResultDialogProps = {
|
||||
@@ -38,6 +39,15 @@ export function HallBetResultDialog({
|
||||
const failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
|
||||
const totalSuccess = data?.summary.success_count ?? successItems.length;
|
||||
const totalFailure = data?.summary.failure_count ?? failedItems.length;
|
||||
const isPartial = totalSuccess > 0 && totalFailure > 0;
|
||||
const isAllFailed = totalFailure > 0 && totalSuccess === 0;
|
||||
|
||||
const ResultIcon = isAllFailed ? XCircle : isPartial ? AlertTriangle : CheckCircle2;
|
||||
const iconWrapClass = isAllFailed
|
||||
? "border-rose-100 text-rose-600 shadow-[0_10px_24px_rgba(225,29,72,0.12)]"
|
||||
: isPartial
|
||||
? "border-amber-100 text-amber-600 shadow-[0_10px_24px_rgba(217,119,6,0.12)]"
|
||||
: "border-emerald-100 text-emerald-600 shadow-[0_10px_24px_rgba(22,163,74,0.12)]";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -54,12 +64,21 @@ export function HallBetResultDialog({
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600 shadow-[0_10px_24px_rgba(22,163,74,0.12)]">
|
||||
<CheckCircle2 className="size-11" strokeWidth={2.5} />
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex size-16 items-center justify-center rounded-full border-4 bg-white",
|
||||
iconWrapClass,
|
||||
)}
|
||||
>
|
||||
<ResultIcon className="size-11" strokeWidth={2.5} />
|
||||
</div>
|
||||
<DialogHeader className="mt-3 items-center gap-2">
|
||||
<DialogTitle className="text-2xl font-black text-slate-950">
|
||||
{t("hall.result.title")}
|
||||
{isAllFailed
|
||||
? t("hall.result.titleAllFailed")
|
||||
: isPartial
|
||||
? t("hall.result.titlePartial")
|
||||
: t("hall.result.title")}
|
||||
</DialogTitle>
|
||||
{data ? (
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-500">
|
||||
|
||||
@@ -450,6 +450,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
number: null,
|
||||
longPress: false,
|
||||
});
|
||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||
const placeTraceIdRef = useRef<string | null>(null);
|
||||
|
||||
const newPlaceTraceId = (): string =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `pl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const clearPlaceTraceId = () => {
|
||||
placeTraceIdRef.current = null;
|
||||
};
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||
try {
|
||||
@@ -466,7 +477,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const wallet = await getWalletBalance({ currency: currencyParam });
|
||||
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
||||
} catch {
|
||||
setAvailableMinor(0);
|
||||
// 保留上次可用余额,避免短暂失败导致误报余额不足
|
||||
}
|
||||
}, [currencyParam]);
|
||||
|
||||
@@ -892,16 +903,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewLoading || placeLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
placeTraceIdRef.current = newPlaceTraceId();
|
||||
const data = await postTicketPreview({
|
||||
draw_id: display.draw_no,
|
||||
currency_code: currencyCode,
|
||||
client_trace_id: `pv-${
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now())
|
||||
}`,
|
||||
client_trace_id: placeTraceIdRef.current,
|
||||
lines,
|
||||
});
|
||||
setPreviewData(data);
|
||||
@@ -925,6 +937,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const handlePlace = async () => {
|
||||
if (!display || !previewData) return;
|
||||
if (placeLoading) {
|
||||
return;
|
||||
}
|
||||
if (!isBettable) {
|
||||
toast.error(t("hall.closedSubmit"));
|
||||
return;
|
||||
@@ -942,18 +957,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = placeTraceIdRef.current ?? newPlaceTraceId();
|
||||
placeTraceIdRef.current = traceId;
|
||||
|
||||
setPlaceLoading(true);
|
||||
try {
|
||||
const data = await postTicketPlace({
|
||||
draw_id: display.draw_no,
|
||||
currency_code: currencyCode,
|
||||
client_trace_id:
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `pl-${Date.now()}`,
|
||||
client_trace_id: traceId,
|
||||
lines,
|
||||
expected_config_versions: previewData.config_versions,
|
||||
});
|
||||
clearPlaceTraceId();
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
setResultData(data);
|
||||
@@ -963,17 +979,26 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
triggerWalletPollingAfterBet();
|
||||
void refreshWallet();
|
||||
void reloadDraw();
|
||||
toast.success(
|
||||
t("hall.placeSuccess", {
|
||||
orderNo: data.order_no,
|
||||
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
||||
}),
|
||||
);
|
||||
if ((data.summary.failure_count ?? 0) > 0) {
|
||||
const failureCount = data.summary.failure_count ?? 0;
|
||||
const successCount = data.summary.success_count ?? 0;
|
||||
if (failureCount > 0 && successCount === 0) {
|
||||
toast.error(
|
||||
t("hall.placeAllFailed", {
|
||||
failed: failureCount,
|
||||
}),
|
||||
);
|
||||
} else if (failureCount > 0) {
|
||||
toast.warning(
|
||||
t("hall.placePartialFailed", {
|
||||
success: data.summary.success_count ?? 0,
|
||||
failed: data.summary.failure_count ?? 0,
|
||||
success: successCount,
|
||||
failed: failureCount,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("hall.placeSuccess", {
|
||||
orderNo: data.order_no,
|
||||
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1268,7 +1293,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
|
||||
>
|
||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
||||
{playColumnHeaderLabel(column.play, activeCategory, column.digitSlot, t)}
|
||||
{playColumnHeaderLabel(
|
||||
column.play,
|
||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||
column.digitSlot,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]">
|
||||
{t("hall.table.amountPlaceholder")}
|
||||
@@ -1451,7 +1481,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
open={previewOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPreviewOpen(open);
|
||||
if (!open) setPreviewData(null);
|
||||
if (!open) {
|
||||
setPreviewData(null);
|
||||
if (!placeLoading) {
|
||||
clearPlaceTraceId();
|
||||
}
|
||||
}
|
||||
}}
|
||||
currencyCode={currencyCode}
|
||||
data={previewData}
|
||||
|
||||
@@ -83,8 +83,7 @@ export function HallWalletStrip() {
|
||||
};
|
||||
}, [mode, refresh]);
|
||||
|
||||
const lotteryMinor = Number(balance?.balance ?? 0);
|
||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
||||
|
||||
return (
|
||||
<section className="mb-3 space-y-2.5" aria-label={t("wallet.balance")}>
|
||||
@@ -110,7 +109,7 @@ export function HallWalletStrip() {
|
||||
<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(lotteryMinor, currency)}
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,7 +123,7 @@ export function HallWalletStrip() {
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
currency={currency}
|
||||
lotteryMinor={lotteryMinor}
|
||||
lotteryMinor={availableMinor}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
|
||||
@@ -85,37 +85,43 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
(s) => s.setWalletPollingExpiryAt,
|
||||
);
|
||||
|
||||
const latestSnapshotMsRef = useRef(0);
|
||||
|
||||
const applySnapshot = useCallback((anchorMs: number, data: DrawCurrentPayload | null) => {
|
||||
if (anchorMs < latestSnapshotMsRef.current) {
|
||||
return;
|
||||
}
|
||||
latestSnapshotMsRef.current = anchorMs;
|
||||
setServerNowMs(anchorMs);
|
||||
setRaw(data);
|
||||
setEmittedAtMs(anchorMs);
|
||||
}, []);
|
||||
|
||||
const mergeFromWs = useCallback((evt: HallWsEnvelope) => {
|
||||
const anchor = evt.emitted_at_ms ?? Date.now();
|
||||
setServerNowMs(anchor);
|
||||
setRaw(evt.data);
|
||||
setEmittedAtMs(anchor);
|
||||
}, []);
|
||||
applySnapshot(anchor, evt.data);
|
||||
}, [applySnapshot]);
|
||||
|
||||
const mergeCountdownFromWs = useCallback((evt: HallWsEnvelope) => {
|
||||
if (evt.data === null) return;
|
||||
const anchor = evt.emitted_at_ms ?? Date.now();
|
||||
setServerNowMs(anchor);
|
||||
setRaw(evt.data);
|
||||
setEmittedAtMs(anchor);
|
||||
}, []);
|
||||
applySnapshot(anchor, evt.data);
|
||||
}, [applySnapshot]);
|
||||
|
||||
const updateFromResponse = useCallback((resp: DrawCurrentResponse) => {
|
||||
setServerNowMs(resp.server_now_ms);
|
||||
setRaw(resp.data);
|
||||
setEmittedAtMs(resp.server_now_ms);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (options?: { force?: boolean }) => {
|
||||
try {
|
||||
setError(null);
|
||||
const d = await getDrawCurrent();
|
||||
updateFromResponse(d);
|
||||
const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected;
|
||||
if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) {
|
||||
return;
|
||||
}
|
||||
applySnapshot(d.server_now_ms, d.data);
|
||||
} catch {
|
||||
setError("draw.loadFailedRefresh");
|
||||
setRaw(undefined);
|
||||
}
|
||||
}, [updateFromResponse]);
|
||||
}, [applySnapshot]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
@@ -212,7 +218,9 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
}
|
||||
|
||||
return () => {
|
||||
echo.leave("lottery-hall");
|
||||
channel.stopListening(".draw.countdown");
|
||||
channel.stopListening(".draw.status_change");
|
||||
channel.stopListening(".result.published");
|
||||
if (echo.connector?.pusher) {
|
||||
echo.connector.pusher.connection.unbind("connected", handleConnected);
|
||||
echo.connector.pusher.connection.unbind(
|
||||
@@ -312,7 +320,7 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
|
||||
if (trigger && zeroRefreshKeyRef.current !== trigger) {
|
||||
zeroRefreshKeyRef.current = trigger;
|
||||
void load();
|
||||
void load({ force: true });
|
||||
}
|
||||
}, [display, nowMs, load]);
|
||||
|
||||
@@ -342,14 +350,17 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [needsFastDrawPoll, load]);
|
||||
|
||||
// WebSocket 已连接时的兜底轮询(tick 延迟时的保险)
|
||||
// WebSocket 已连接时的兜底轮询(tick 延迟时的保险;常态下由 WS 推送,避免 HTTP 覆盖新快照)
|
||||
useEffect(() => {
|
||||
if (isWebSocketConnected && !needsFastDrawPoll) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = needsFastDrawPoll ? 15_000 : 45_000;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [load, needsFastDrawPoll]);
|
||||
}, [load, needsFastDrawPoll, isWebSocketConnected]);
|
||||
|
||||
return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable };
|
||||
}
|
||||
|
||||
@@ -52,6 +52,13 @@ type TimelineRow = {
|
||||
time: string;
|
||||
};
|
||||
|
||||
const TRANSIENT_TICKET_STATUSES = new Set([
|
||||
"pending_confirm",
|
||||
"partial_pending_confirm",
|
||||
"pending_draw",
|
||||
"pending_payout",
|
||||
]);
|
||||
|
||||
type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
||||
timeline?: TimelineRow[];
|
||||
match_result?: {
|
||||
@@ -98,17 +105,23 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
};
|
||||
}, [fromGroupKey, groupTickets, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const load = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const row = await getTicketItemDetail(ticketNo);
|
||||
setData(row);
|
||||
} catch {
|
||||
setData(null);
|
||||
setError(t("orders.notFound"));
|
||||
if (!options?.silent) {
|
||||
setError(t("orders.notFound"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!options?.silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [ticketNo, t]);
|
||||
|
||||
@@ -118,6 +131,21 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !TRANSIENT_TICKET_STATUSES.has(data.status)) {
|
||||
return;
|
||||
}
|
||||
const refresh = () => void load({ silent: true });
|
||||
const intervalId = window.setInterval(refresh, 12_000);
|
||||
window.addEventListener("lottery-hall-refresh", refresh);
|
||||
window.addEventListener("lottery-wallet-refresh", refresh);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("lottery-hall-refresh", refresh);
|
||||
window.removeEventListener("lottery-wallet-refresh", refresh);
|
||||
};
|
||||
}, [data?.status, load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
|
||||
@@ -30,7 +30,18 @@ import { cn } from "@/lib/utils";
|
||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
const ORDERS_PAGE_SIZE = 20;
|
||||
const STATUS_OPTIONS = ["pending_draw", "pending_payout", "settled_win", "settled_lose", "failed"] as const;
|
||||
const STATUS_OPTIONS = [
|
||||
"pending_confirm",
|
||||
"partial_pending_confirm",
|
||||
"placed",
|
||||
"pending_draw",
|
||||
"partial_failed",
|
||||
"pending_payout",
|
||||
"settled_win",
|
||||
"settled_lose",
|
||||
"failed",
|
||||
"refunded",
|
||||
] as const;
|
||||
|
||||
function parseYmd(value: string): Date | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
@@ -141,6 +152,18 @@ export function TicketOrdersListScreen() {
|
||||
void fetchPage(1, false);
|
||||
}, [fetchPage, queryDrawNo, queryNumber, queryStatuses, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshFromEvents = () => {
|
||||
void fetchPage(1, false);
|
||||
};
|
||||
window.addEventListener("lottery-hall-refresh", refreshFromEvents);
|
||||
window.addEventListener("lottery-wallet-refresh", refreshFromEvents);
|
||||
return () => {
|
||||
window.removeEventListener("lottery-hall-refresh", refreshFromEvents);
|
||||
window.removeEventListener("lottery-wallet-refresh", refreshFromEvents);
|
||||
};
|
||||
}, [fetchPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||
@@ -238,6 +261,9 @@ export function TicketOrdersListScreen() {
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("orders.dateRangeHint")}
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
month={calendarMonth}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -149,6 +149,7 @@ export function TransferInPanel({
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const idempotentKeyRef = useRef<string | null>(null);
|
||||
const tid = `${idPrefix}in-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
@@ -159,25 +160,31 @@ export function TransferInPanel({
|
||||
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError(t("wallet.invalidAmount"));
|
||||
return;
|
||||
}
|
||||
if (idempotentKeyRef.current === null) {
|
||||
idempotentKeyRef.current = randomIdempotentKey();
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postWalletTransferIn({
|
||||
amount: parsedMinor,
|
||||
currency,
|
||||
idempotent_key: randomIdempotentKey(),
|
||||
idempotent_key: idempotentKeyRef.current,
|
||||
});
|
||||
idempotentKeyRef.current = null;
|
||||
toast.success(t("wallet.successIn"));
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
emitWalletRefresh();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
@@ -270,6 +277,7 @@ export function TransferOutPanel({
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const idempotentKeyRef = useRef<string | null>(null);
|
||||
const tid = `${idPrefix}out-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
@@ -288,6 +296,9 @@ export function TransferOutPanel({
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError(t("wallet.invalidAmount"));
|
||||
@@ -297,20 +308,23 @@ export function TransferOutPanel({
|
||||
setLocalError(t("wallet.outExceeds"));
|
||||
return;
|
||||
}
|
||||
if (idempotentKeyRef.current === null) {
|
||||
idempotentKeyRef.current = randomIdempotentKey();
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postWalletTransferOut({
|
||||
amount: parsedMinor,
|
||||
currency,
|
||||
idempotent_key: randomIdempotentKey(),
|
||||
idempotent_key: idempotentKeyRef.current,
|
||||
});
|
||||
idempotentKeyRef.current = null;
|
||||
toast.success(t("wallet.successOut"));
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
emitWalletRefresh();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
|
||||
@@ -259,46 +259,55 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
};
|
||||
}, [mode, reconnect, store]);
|
||||
|
||||
// 初始化和 WebSocket 监控
|
||||
// 初始化:绑定 Pusher 真实连接事件(勿仅凭 channel 对象存在判定已连接)
|
||||
useEffect(() => {
|
||||
const echo = getLotteryEcho();
|
||||
if (!echo) {
|
||||
// 没有 Echo 配置,直接启用轮询模式
|
||||
switchToPollingMode();
|
||||
startDrawPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试初始连接
|
||||
const success = connectWebSocket();
|
||||
if (success) {
|
||||
handleConnect();
|
||||
} else {
|
||||
handleDisconnect();
|
||||
}
|
||||
echo.channel("lottery-hall");
|
||||
|
||||
// 设置连接状态监控(通过定期订阅状态)
|
||||
const checkConnection = () => {
|
||||
try {
|
||||
// 尝试访问频道来测试连接
|
||||
const channel = echo.channel("lottery-hall");
|
||||
if (channel) {
|
||||
if (!isWebSocketConnected) {
|
||||
handleConnect();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (isWebSocketConnected) {
|
||||
handleDisconnect();
|
||||
}
|
||||
const pusher = echo.connector?.pusher;
|
||||
const connection = pusher?.connection;
|
||||
|
||||
const syncConnectionState = () => {
|
||||
const state = connection?.state;
|
||||
if (state === "connected") {
|
||||
handleConnect();
|
||||
} else if (
|
||||
state === "disconnected" ||
|
||||
state === "unavailable" ||
|
||||
state === "failed"
|
||||
) {
|
||||
handleDisconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// 每 5 秒检查一次连接状态
|
||||
const checkInterval = window.setInterval(checkConnection, 5000);
|
||||
if (connection) {
|
||||
connection.bind("connected", handleConnect);
|
||||
connection.bind("disconnected", handleDisconnect);
|
||||
connection.bind("unavailable", handleDisconnect);
|
||||
connection.bind("failed", handleDisconnect);
|
||||
syncConnectionState();
|
||||
} else {
|
||||
const success = connectWebSocket();
|
||||
if (success) {
|
||||
handleConnect();
|
||||
} else {
|
||||
handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearInterval(checkInterval);
|
||||
if (connection) {
|
||||
connection.unbind("connected", handleConnect);
|
||||
connection.unbind("disconnected", handleDisconnect);
|
||||
connection.unbind("unavailable", handleDisconnect);
|
||||
connection.unbind("failed", handleDisconnect);
|
||||
}
|
||||
if (reconnectTimerRef.current) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
@@ -307,7 +316,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
connectWebSocket,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
isWebSocketConnected,
|
||||
startDrawPolling,
|
||||
switchToPollingMode,
|
||||
]);
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"placeFailed": "Submission failed",
|
||||
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
|
||||
"placePartialFailed": "{{success}} succeeded, {{failed}} failed",
|
||||
"placeAllFailed": "All {{failed}} lines failed",
|
||||
"playConfig": {
|
||||
"playClosedDraftCleared": "{{playCode}} is closed. Related draft amounts have been cleared.",
|
||||
"playClosed": "{{playCode}} is closed.",
|
||||
@@ -244,7 +245,9 @@
|
||||
"warningsDescription": "The following numbers have high payout pool usage for this issue. Betting is still allowed, but the order may be rejected as sold out if capacity is insufficient."
|
||||
},
|
||||
"result": {
|
||||
"title": "Bet result",
|
||||
"title": "Bet placed",
|
||||
"titlePartial": "Partially placed",
|
||||
"titleAllFailed": "Bet not placed",
|
||||
"draw": "Issue",
|
||||
"empty": "No result yet.",
|
||||
"successCount": "Successful lines",
|
||||
@@ -401,6 +404,7 @@
|
||||
"betNow": "Bet Now",
|
||||
"empty": "No bet records yet.",
|
||||
"dateRange": "Date range",
|
||||
"dateRangeHint": "Filters by order time in schedule day (UTC)",
|
||||
"status": "Status",
|
||||
"statusFilter": "Status filter",
|
||||
"noMore": "No more tickets",
|
||||
@@ -575,11 +579,16 @@
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "Awaiting draw",
|
||||
"placed": "Awaiting draw",
|
||||
"pending_confirm": "Confirming",
|
||||
"partial_pending_confirm": "Partially confirming",
|
||||
"pending_draw": "Awaiting draw",
|
||||
"partial_failed": "Partially failed",
|
||||
"pending_payout": "Won, pending payout",
|
||||
"settled_win": "Paid",
|
||||
"settled_lose": "Not won",
|
||||
"failed": "Failed",
|
||||
"refunded": "Refunded",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"placeFailed": "पेश गर्न असफल",
|
||||
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
|
||||
"placePartialFailed": "{{success}} सफल, {{failed}} असफल",
|
||||
"placeAllFailed": "सबै {{failed}} लाइन असफल",
|
||||
"playConfig": {
|
||||
"playClosedDraftCleared": "{{playCode}} बन्द छ। सम्बन्धित ड्राफ्ट रकम हटाइएको छ।",
|
||||
"playClosed": "{{playCode}} बन्द छ।",
|
||||
@@ -244,7 +245,9 @@
|
||||
"warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।"
|
||||
},
|
||||
"result": {
|
||||
"title": "बेट नतिजा",
|
||||
"title": "बेट सफल",
|
||||
"titlePartial": "आंशिक सफल",
|
||||
"titleAllFailed": "बेट असफल",
|
||||
"draw": "इश्यू",
|
||||
"empty": "नतिजा छैन।",
|
||||
"successCount": "सफल लाइनहरू",
|
||||
@@ -401,6 +404,7 @@
|
||||
"betNow": "अहिले बेट",
|
||||
"empty": "अहिलेसम्म बेट रेकर्ड छैन।",
|
||||
"dateRange": "मिति दायरा",
|
||||
"dateRangeHint": "अर्डर समयलाई तालिका दिन (UTC) अनुसार फिल्टर गर्छ",
|
||||
"status": "स्थिति",
|
||||
"statusFilter": "स्थिति फिल्टर",
|
||||
"noMore": "थप टिकट छैन",
|
||||
@@ -575,11 +579,16 @@
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "ड्र पर्खँदै",
|
||||
"placed": "ड्र पर्खँदै",
|
||||
"pending_confirm": "पुष्टि हुँदै",
|
||||
"partial_pending_confirm": "आंशिक पुष्टि",
|
||||
"pending_draw": "ड्र पर्खँदै",
|
||||
"partial_failed": "आंशिक असफल",
|
||||
"pending_payout": "जितेको, भुक्तानी बाँकी",
|
||||
"settled_win": "भुक्तानी भयो",
|
||||
"settled_lose": "जितेन",
|
||||
"failed": "असफल",
|
||||
"refunded": "फिर्ता",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
|
||||
@@ -243,8 +243,11 @@
|
||||
"warningsTitle": "赔付池预警",
|
||||
"warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。"
|
||||
},
|
||||
"placeAllFailed": "本次 {{failed}} 条注项均未成功",
|
||||
"result": {
|
||||
"title": "下注结果",
|
||||
"title": "下注成功",
|
||||
"titlePartial": "部分注项成功",
|
||||
"titleAllFailed": "下注未成功",
|
||||
"draw": "期号",
|
||||
"empty": "暂无结果。",
|
||||
"successCount": "成功注项",
|
||||
@@ -401,6 +404,7 @@
|
||||
"betNow": "立即下注",
|
||||
"empty": "暂无下注记录。",
|
||||
"dateRange": "日期范围",
|
||||
"dateRangeHint": "按彩票排期日(UTC)筛选下单时间",
|
||||
"status": "状态",
|
||||
"statusFilter": "状态筛选",
|
||||
"noMore": "没有更多注单",
|
||||
@@ -575,11 +579,16 @@
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "待开奖",
|
||||
"placed": "待开奖",
|
||||
"pending_confirm": "确认中",
|
||||
"partial_pending_confirm": "部分确认中",
|
||||
"pending_draw": "待开奖",
|
||||
"partial_failed": "部分失败",
|
||||
"pending_payout": "已中奖待派彩",
|
||||
"settled_win": "已派彩",
|
||||
"settled_lose": "未中奖",
|
||||
"failed": "失败",
|
||||
"refunded": "已退款",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
|
||||
Reference in New Issue
Block a user