feat: 增强投注结果弹窗与投注网格功能

更新 HallBetResultDialog:根据成功与失败数量显示不同的结果图标与标题。
优化 HallBettingGrid:新增 place trace ID 管理机制,更好地处理投注提交流程,并防止重复扣费。
增强注单详情页面:针对临时状态中的注单自动刷新,提升用户体验。
新增多语言翻译:补充投注结果与状态相关文案,支持更完善的用户反馈。
This commit is contained in:
2026-05-26 14:50:21 +08:00
parent d768a3f6ba
commit 51b2a36cc5
11 changed files with 257 additions and 90 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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