diff --git a/src/features/hall/hall-bet-errors.ts b/src/features/hall/hall-bet-errors.ts index 5b65af7..806152e 100644 --- a/src/features/hall/hall-bet-errors.ts +++ b/src/features/hall/hall-bet-errors.ts @@ -36,6 +36,13 @@ export function mapTicketBetError( "hall.ticketError.2008", "赔率或玩法配置已更新,请关闭预览后重新操作。", ); + case 2009: + return msg( + "hall.ticketError.2009", + "该订单已退款或不可重复提交,请关闭预览后重新下注。", + ); + case 1007: + return msg("hall.ticketError.1007", "彩票钱包已冻结,暂无法下注。"); case 1003: return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。"); default: diff --git a/src/features/hall/hall-bet-result-dialog.tsx b/src/features/hall/hall-bet-result-dialog.tsx index ff80efc..d7ad22a 100644 --- a/src/features/hall/hall-bet-result-dialog.tsx +++ b/src/features/hall/hall-bet-result-dialog.tsx @@ -25,6 +25,9 @@ type HallBetResultDialogProps = { jackpotEnabled?: boolean; }; +const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]); +const FAILURE_ITEM_STATUSES = new Set(["failed", "refunded"]); + export function HallBetResultDialog({ open, onOpenChange, @@ -34,18 +37,41 @@ export function HallBetResultDialog({ }: HallBetResultDialogProps) { const { t } = useTranslation("player"); - // 成功注项状态为 pending_draw(待开奖),不是 success - const successItems = data?.items.filter((item) => item.status !== "failed") ?? []; - const failedItems = data?.items.filter((item) => item.status === "failed") ?? []; + const successItems = + data?.items.filter((item) => SUCCESS_ITEM_STATUSES.has(item.status)) ?? []; + const failedItems = + data?.items.filter((item) => FAILURE_ITEM_STATUSES.has(item.status)) ?? []; + const pendingItems = + data?.items.filter((item) => item.status === "pending_confirm") ?? []; + + const orderStatus = data?.summary.order_status; + const isRefundedOrder = orderStatus === "refunded"; 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 pendingConfirmCount = + data?.summary.pending_confirm_count ?? pendingItems.length; - const ResultIcon = isAllFailed ? XCircle : isPartial ? AlertTriangle : CheckCircle2; + const isAllFailed = + isRefundedOrder || + (totalFailure > 0 && totalSuccess === 0 && pendingConfirmCount === 0); + const isPartial = + !isRefundedOrder && + totalSuccess > 0 && + (totalFailure > 0 || pendingConfirmCount > 0); + const isPendingOnly = + !isRefundedOrder && + pendingConfirmCount > 0 && + totalSuccess === 0 && + totalFailure === 0; + + const ResultIcon = isAllFailed + ? XCircle + : isPartial || isPendingOnly + ? AlertTriangle + : CheckCircle2; const iconWrapClass = isAllFailed ? "border-rose-100 text-rose-600 shadow-[0_10px_24px_rgba(225,29,72,0.12)]" - : isPartial + : isPartial || isPendingOnly ? "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)]"; @@ -74,11 +100,15 @@ export function HallBetResultDialog({ - {isAllFailed - ? t("hall.result.titleAllFailed") - : isPartial - ? t("hall.result.titlePartial") - : t("hall.result.title")} + {isRefundedOrder + ? t("hall.result.titleRefunded") + : isAllFailed + ? t("hall.result.titleAllFailed") + : isPartial + ? t("hall.result.titlePartial") + : isPendingOnly + ? t("hall.result.titlePendingConfirm") + : t("hall.result.title")} {data ? ( @@ -99,6 +129,16 @@ export function HallBetResultDialog({

) : ( <> + {isRefundedOrder ? ( +

+ {t("hall.result.refundedHint")} +

+ ) : null} + {isPendingOnly ? ( +

+ {t("hall.result.pendingConfirmHint")} +

+ ) : null}

@@ -196,29 +236,38 @@ export function HallBetResultDialog({

- {totalFailure === 0 ? ( + {failedItems.length === 0 && pendingItems.length === 0 ? (
{t("hall.result.noFailures")}
) : (
-

- {t("hall.result.failedItems")} -

- {failedItems.map((item, index) => ( -
- - {item.number}{" "} - {playLabel(item.play_code, t)} - - - {item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")} - -
- ))} + {failedItems.length > 0 ? ( + <> +

+ {t("hall.result.failedItems")} +

+ {failedItems.map((item, index) => ( +
+ + {item.number}{" "} + {playLabel(item.play_code, t)} + + + {item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")} + +
+ ))} + + ) : null} + {pendingItems.length > 0 ? ( +

+ {t("hall.result.pendingConfirmLines", { count: pendingItems.length })} +

+ ) : null}
)}
diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 323a2da..89cf9ef 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -721,6 +721,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const removed = clearAmountsForPlay(evt.play_code); setPreviewOpen(false); setPreviewData(null); + clearPlaceTraceId(); toast.warning( removed ? t("hall.playConfig.playClosedDraftCleared", { @@ -739,6 +740,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } void loadCatalog(); setPreviewOpen(false); setPreviewData(null); + clearPlaceTraceId(); toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); }; @@ -855,7 +857,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const playCode = String(item?.play_code ?? ""); if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return; const entry = entries[clientLineNo - 1]; - if (!entry) return; + if (!entry || entry.play.play_code !== playCode) return; cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`); }); @@ -929,6 +931,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); return; } + if (e instanceof LotteryApiBizError && code === 2008) { + setPreviewOpen(false); + setPreviewData(null); + clearPlaceTraceId(); + } toast.error(mapTicketBetError(code, msg, t)); } finally { setPreviewLoading(false); @@ -1010,8 +1017,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); setPreviewOpen(false); setPreviewData(null); + clearPlaceTraceId(); return; } + if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) { + setPreviewOpen(false); + setPreviewData(null); + clearPlaceTraceId(); + } toast.error(mapTicketBetError(code, msg, t)); } finally { setPlaceLoading(false); @@ -1051,7 +1064,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ); } - const canSubmit = !tableDisabled && draftEntries.length > 0 && availableMinor >= debouncedSummary.actual; + const submitActualMinor = + previewData?.summary.total_actual_deduct ?? debouncedSummary.actual; + const canSubmit = + !tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor; const favoriteChips = favorites.slice(0, 10); const historyChips = historyNumbers.slice(0, 20); @@ -1471,7 +1487,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ? t("hall.table.previewing") : !isBettable ? t("hall.closed.title") - : availableMinor < debouncedSummary.actual + : availableMinor < submitActualMinor ? t("hall.table.insufficientBalance") : t("hall.table.submitBet")} @@ -1492,7 +1508,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } data={previewData} placing={placeLoading} jackpotEnabled={Boolean(jackpot?.enabled)} - allowSubmit={isBettable && availableMinor >= debouncedSummary.actual} + allowSubmit={isBettable && availableMinor >= submitActualMinor} onConfirmPlace={() => void handlePlace()} /> diff --git a/src/features/orders/group-ticket-items.ts b/src/features/orders/group-ticket-items.ts index 7b31093..a123383 100644 --- a/src/features/orders/group-ticket-items.ts +++ b/src/features/orders/group-ticket-items.ts @@ -3,6 +3,7 @@ import type { TicketItemListRow } from "@/types/api/ticket-items"; export type TicketItemGroup = { key: string; order_no: string | null; + order_status: string | null; draw_no: string | null; placed_at: string | null; currency_code: string | null; @@ -29,6 +30,25 @@ export function getTicketItemGroupKey(row: TicketItemListRow): string { return `fallback:${drawNo}|${placedAt}|${currency}|${status}`; } +function resolveGroupStatus(items: TicketItemListRow[]): string { + const orderStatus = items.map((row) => row.order_status).find((s) => s != null && s !== ""); + if (orderStatus === "partial_failed" || orderStatus === "partial_pending_confirm") { + 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")) + ) { + return "partial_failed"; + } + + return items[0]?.status ?? "unknown"; +} + export function sortTicketGroupItems(items: TicketItemListRow[]): TicketItemListRow[] { return [...items].sort((a, b) => { const byPlay = a.play_code.localeCompare(b.play_code); @@ -50,6 +70,7 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[] group = { key, order_no: orderNo || null, + order_status: row.order_status ?? null, draw_no: row.draw_no ?? null, placed_at: row.placed_at ?? null, currency_code: row.currency_code ?? null, @@ -72,7 +93,13 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[] return order.map((key) => { const group = map.get(key)!; - return { ...group, items: sortTicketGroupItems(group.items) }; + const sortedItems = sortTicketGroupItems(group.items); + return { + ...group, + items: sortedItems, + status: resolveGroupStatus(sortedItems), + order_status: sortedItems[0]?.order_status ?? group.order_status, + }; }); } diff --git a/src/features/orders/ticket-item-status.tsx b/src/features/orders/ticket-item-status.tsx index 398ace0..1758ce0 100644 --- a/src/features/orders/ticket-item-status.tsx +++ b/src/features/orders/ticket-item-status.tsx @@ -7,13 +7,25 @@ export function ticketStatusDisplay( t?: (key: string, options?: { defaultValue?: string; status?: string }) => string, ): { label: string; dotClass: string; ring?: boolean } { const total = winMinor + jackpotMinor; - if ( - status === "pending_draw" || - status === "placed" || - status === "partial_failed" || - status === "pending_confirm" || - status === "partial_pending_confirm" - ) { + if (status === "partial_failed") { + return { + label: t?.("ticketStatus.partial_failed") ?? status, + dotClass: "bg-amber-500", + }; + } + if (status === "pending_confirm" || status === "partial_pending_confirm") { + return { + label: t?.("ticketStatus.pending_confirm") ?? status, + dotClass: "bg-violet-500", + }; + } + if (status === "refunded") { + return { + label: t?.("ticketStatus.refunded") ?? status, + dotClass: "bg-slate-400", + }; + } + if (status === "pending_draw" || status === "placed") { return { label: t?.("ticketStatus.pending_draw") ?? status, dotClass: "bg-sky-500", diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index dfaf95b..88d3f1d 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -185,6 +185,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { const cur = data.currency_code ?? activeCurrency; const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t); + const orderStatus = data.order_status ?? null; + const isPartialFailedOrder = orderStatus === "partial_failed"; + const isLineFailed = data.status === "failed" || data.status === "refunded"; + const failReason = + (data.fail_reason_text ?? "").trim() || + (data.fail_reason_code ? t(`orders.failReason.${data.fail_reason_code}`, { defaultValue: data.fail_reason_code }) : ""); const totalWin = data.win_amount + data.jackpot_win_amount; const pub = data.published_draw_results; const first = pub?.results?.["1st"] ?? ""; @@ -244,6 +250,20 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { + {isPartialFailedOrder ? ( +
+

{t("orders.partialFailedOrderTitle")}

+

+ {t("orders.partialFailedOrderBody")} +

+
+ ) : null} + {isLineFailed && failReason ? ( +
+

{t("orders.lineFailedTitle")}

+

{failReason}

+
+ ) : null}
{t("orders.drawNo")} diff --git a/src/features/orders/ticket-order-group-screen.tsx b/src/features/orders/ticket-order-group-screen.tsx index 687886e..8871461 100644 --- a/src/features/orders/ticket-order-group-screen.tsx +++ b/src/features/orders/ticket-order-group-screen.tsx @@ -95,6 +95,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps

+ {group.status === "partial_failed" ? ( +

+ {t("orders.partialFailedHint")} +

+ ) : null} {totalWin > 0 && group.status === "settled_win" ? (

{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })} @@ -147,6 +152,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps + {row.status === "failed" || row.status === "refunded" ? ( +

+ {t("orders.lineFailedTitle")} +

+ ) : null} {lineWin > 0 && row.status === "settled_win" ? (

{t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })} diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index aa7dbdc..a1e044b 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarRange, ChevronDown, Search } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { getDrawCurrent } from "@/api/draw"; import { getTicketItems } from "@/api/ticket-items"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -23,7 +24,14 @@ import { OrderMetaLine } from "@/features/orders/order-meta-line"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useIsMobile } from "@/hooks/use-mobile"; +import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { formatMinorAsCurrency } from "@/lib/money"; +import { + formatSchedulePickerYmd, + getTimeZoneShortLabel, + parseSchedulePickerYmd, + scheduleTodayYmd, +} from "@/lib/player-datetime"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { playLabel } from "@/lib/play-labels"; import { cn } from "@/lib/utils"; @@ -43,20 +51,6 @@ const STATUS_OPTIONS = [ "refunded", ] as const; -function parseYmd(value: string): Date | undefined { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!m) return undefined; - const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); - return Number.isNaN(d.getTime()) ? undefined : d; -} - -function formatYmd(value: Date): string { - const y = value.getFullYear(); - const m = String(value.getMonth() + 1).padStart(2, "0"); - const d = String(value.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - export function TicketOrdersListScreen() { const searchParams = useSearchParams(); const { t } = useTranslation("player"); @@ -83,13 +77,18 @@ export function TicketOrdersListScreen() { const [rangeOpen, setRangeOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false); const [calendarMonth, setCalendarMonth] = useState(() => new Date()); + const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE); const loadMoreRef = useRef(null); const isMobile = useIsMobile(); const initialLoadDone = useRef(false); + useEffect(() => { + setQueryDrawNo(drawNoFilter); + }, [drawNoFilter]); + const selectedRange = useMemo(() => { - const from = parseYmd(fromDate); - const to = parseYmd(toDate); + const from = parseSchedulePickerYmd(fromDate); + const to = parseSchedulePickerYmd(toDate); if (!from && !to) return undefined; if (from && to) return { from, to }; if (from) return { from }; @@ -140,6 +139,22 @@ export function TicketOrdersListScreen() { [drawNoFilter, fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate], ); + useEffect(() => { + void getDrawCurrent() + .then((res) => { + const tz = res.data?.schedule_timezone?.trim(); + if (tz) setScheduleTimezone(tz); + }) + .catch(() => { + /* 保留默认排期时区 */ + }); + }, []); + + const scheduleTzLabel = useMemo( + () => getTimeZoneShortLabel(scheduleTimezone), + [scheduleTimezone], + ); + useEffect(() => { if (!initialLoadDone.current) { initialLoadDone.current = true; @@ -262,7 +277,7 @@ export function TicketOrdersListScreen() { />

- {t("orders.dateRangeHint")} + {t("orders.dateRangeHint", { tz: scheduleTzLabel })}

-
+
+ +
+
@@ -437,6 +466,11 @@ export function TicketOrdersListScreen() {

+ {group.status === "partial_failed" ? ( +

+ {t("orders.partialFailedHint")} +

+ ) : null} {totalWin > 0 && group.status === "settled_win" ? (

{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })} @@ -498,11 +532,11 @@ export function TicketOrdersListScreen() { {t("actions.next")} - ) : ( + ) : lastPage > 1 ? (

{t("orders.noMore")}

- )} + ) : null} )} diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index 99f6d89..441954d 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -25,20 +25,6 @@ export function WalletLogsScreen() { const [error, setError] = useState(null); const loadMoreRef = useRef(null); - const logsForCurrency = useMemo(() => { - if (!logs) return null; - const code = currency.toUpperCase(); - return { - ...logs, - items: logs.items.filter( - (item) => (item.currency_code || code).toUpperCase() === code, - ), - pending_reconcile: logs.pending_reconcile.filter( - (item) => item.currency_code.toUpperCase() === code, - ), - }; - }, [currency, logs]); - const fetchPassRef = useRef(true); const load = useCallback(async (targetPage = 1, append = false) => { @@ -56,6 +42,7 @@ export function WalletLogsScreen() { page: targetPage, size: WALLET_LOGS_PAGE_SIZE, type: filter || undefined, + currency, }); setLogs((current) => append && current @@ -72,7 +59,7 @@ export function WalletLogsScreen() { setLogsLoading(false); setLoadingMore(false); } - }, [filter, t]); + }, [currency, filter, t]); useEffect(() => { queueMicrotask(() => { @@ -131,7 +118,7 @@ export function WalletLogsScreen() { ) : null} append && current @@ -49,7 +50,7 @@ export function WalletScreen() { : nextLogs, ); return nextLogs; - }, [filter]); + }, [currency, filter]); useEffect(() => { let cancelled = false; @@ -107,20 +108,6 @@ export function WalletScreen() { return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); }, [refreshAll]); - const logsForCurrency = useMemo(() => { - if (!logs) return null; - const code = currency.toUpperCase(); - return { - ...logs, - items: logs.items.filter( - (item) => (item.currency_code || code).toUpperCase() === code, - ), - pending_reconcile: logs.pending_reconcile.filter( - (item) => item.currency_code.toUpperCase() === code, - ), - }; - }, [currency, logs]); - const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; const loadMore = useCallback(() => { @@ -222,7 +209,7 @@ export function WalletScreen() { { - // 检查是否过期 - if (store.walletPollingExpiryAt && Date.now() > store.walletPollingExpiryAt) { + const live = useNetworkConnectionStore.getState(); + if (live.walletPollingExpiryAt && Date.now() > live.walletPollingExpiryAt) { window.clearInterval(intervalId); - store.setWalletPollingIntervalId(null); + live.setWalletPollingIntervalId(null); return; } void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { @@ -167,8 +167,8 @@ export function triggerWalletPollingAfterBet(): void { }); }, POLLING_INTERVAL_MS); - store.setWalletPollingIntervalId(intervalId); - store.setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS); + useNetworkConnectionStore.getState().setWalletPollingIntervalId(intervalId); + useNetworkConnectionStore.getState().setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS); // 2分钟后自动清理 window.setTimeout(() => { diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts index 0ff41b5..503e758 100644 --- a/src/hooks/use-websocket-manager.ts +++ b/src/hooks/use-websocket-manager.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react"; -import { getDrawCurrent } from "@/api/draw"; import { getWalletBalance } from "@/api/wallet"; import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; import { getLotteryEcho } from "@/lib/lottery-echo"; @@ -63,11 +62,8 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { // 刷新画作数据 const refreshDraw = useCallback(async () => { - try { - await getDrawCurrent(); - } catch { - // 静默处理错误,避免频繁报错 - } + // 由 useHallDrawLive 监听并拉取,避免与大厅重复请求 draw/current + window.dispatchEvent(new Event("lottery-hall-refresh")); }, []); // 刷新钱包余额 @@ -138,21 +134,11 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { clearWalletPolling(); }, [clearWalletPolling]); - // 尝试连接 WebSocket - const connectWebSocket = useCallback(() => { + // 检测 Pusher 是否已真正 connected(非仅订阅频道) + const isPusherConnected = useCallback((): boolean => { const echo = getLotteryEcho(); - if (!echo) { - // 配置不完整,无法连接 - return false; - } - - try { - // 连接到 lottery-hall 频道以测试连接 - echo.channel("lottery-hall"); - return true; - } catch { - return false; - } + const state = echo?.connector?.pusher?.connection?.state; + return state === "connected"; }, []); // 处理连接断开 @@ -207,20 +193,19 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { incrementReconnectAttempts(); - const success = connectWebSocket(); - if (success) { + if (isPusherConnected()) { handleConnect(); - } else { - // 继续重连 - reconnectTimerRef.current = window.setTimeout( - () => attemptReconnectRef.current(), - RECONNECT_INTERVAL_MS, - ); + return; } + + reconnectTimerRef.current = window.setTimeout( + () => attemptReconnectRef.current(), + RECONNECT_INTERVAL_MS, + ); }, [ reconnectAttempts, incrementReconnectAttempts, - connectWebSocket, + isPusherConnected, handleConnect, setReconnecting, ]); @@ -292,13 +277,10 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { connection.bind("unavailable", handleDisconnect); connection.bind("failed", handleDisconnect); syncConnectionState(); + } else if (isPusherConnected()) { + handleConnect(); } else { - const success = connectWebSocket(); - if (success) { - handleConnect(); - } else { - handleDisconnect(); - } + handleDisconnect(); } return () => { @@ -313,7 +295,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { } }; }, [ - connectWebSocket, + isPusherConnected, handleConnect, handleDisconnect, startDrawPolling, diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 28a6d30..5deab19 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -248,6 +248,11 @@ "title": "Bet placed", "titlePartial": "Partially placed", "titleAllFailed": "Bet not placed", + "titleRefunded": "Order refunded", + "titlePendingConfirm": "Pending confirmation", + "refundedHint": "This order was already refunded. Close and preview again to place a new bet.", + "pendingConfirmHint": "Your bet was submitted. Wallet deduction is being confirmed — check My Bets shortly.", + "pendingConfirmLines": "{{count}} line(s) pending wallet confirmation", "draw": "Issue", "empty": "No result yet.", "successCount": "Successful lines", @@ -306,6 +311,7 @@ "2006": "This issue cannot accept bets.", "2007": "This play type is unsupported or missing odds configuration.", "2008": "Odds or play configuration has changed. Close the preview and try again.", + "2009": "This order was refunded or cannot be resubmitted. Close the preview and place a new bet.", "1003": "Stake amount is outside the allowed range for this play type.", "fallback": "Bet failed. Please try again later." }, @@ -404,7 +410,12 @@ "betNow": "Bet Now", "empty": "No bet records yet.", "dateRange": "Date range", - "dateRangeHint": "Filters by order time in schedule day (UTC)", + "dateRangeHint": "Filters order time by schedule day ({{tz}})", + "scheduleToday": "Schedule today", + "partialFailedHint": "Some lines in this order did not succeed — check each line", + "partialFailedOrderTitle": "Partially failed order", + "partialFailedOrderBody": "Some lines in this order succeeded and some did not. This page shows this line only.", + "lineFailedTitle": "This line did not succeed", "status": "Status", "statusFilter": "Status filter", "noMore": "No more tickets", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 477ea7d..1d902dc 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -248,6 +248,11 @@ "title": "बेट सफल", "titlePartial": "आंशिक सफल", "titleAllFailed": "बेट असफल", + "titleRefunded": "अर्डर फिर्ता", + "titlePendingConfirm": "पुष्टि बाँकी", + "refundedHint": "यो अर्डर पहिले नै फिर्ता भइसकेको छ। नयाँ बेटका लागि पूर्वावलोकन बन्द गरी फेरि गर्नुहोस्।", + "pendingConfirmHint": "बेट पेश भयो। वालेट कट्टी पुष्टि हुँदैछ — छिट्टै मेरो बेट हेर्नुहोस्।", + "pendingConfirmLines": "{{count}} लाइन कट्टी पुष्टि बाँकी", "draw": "इश्यू", "empty": "नतिजा छैन।", "successCount": "सफल लाइनहरू", @@ -306,6 +311,7 @@ "2006": "यो इश्यूले बेट स्वीकार गर्न सक्दैन।", "2007": "यो प्ले प्रकार समर्थित छैन वा odds कन्फिगरेसन छैन।", "2008": "Odds वा प्ले कन्फिगरेसन परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।", + "2009": "यो अर्डर फिर्ता भइसकेको छ वा पुन: पेश गर्न मिल्दैन। पूर्वावलोकन बन्द गरी नयाँ बेट गर्नुहोस्।", "1003": "बेट रकम यो प्ले प्रकारको अनुमत दायराभन्दा बाहिर छ।", "fallback": "बेट असफल। कृपया पछि प्रयास गर्नुहोस्।" }, @@ -404,7 +410,12 @@ "betNow": "अहिले बेट", "empty": "अहिलेसम्म बेट रेकर्ड छैन।", "dateRange": "मिति दायरा", - "dateRangeHint": "अर्डर समयलाई तालिका दिन (UTC) अनुसार फिल्टर गर्छ", + "dateRangeHint": "अर्डर समय तालिका दिन ({{tz}}) अनुसार फिल्टर", + "scheduleToday": "तालिका आज", + "partialFailedHint": "यो अर्डरका केही लाइन सफल भएनन् — प्रत्येक लाइन हेर्नुहोस्", + "partialFailedOrderTitle": "आंशिक असफल अर्डर", + "partialFailedOrderBody": "यस अर्डरका केही लाइन सफल, केही असफल। यो पृष्ठमा यो लाइन मात्र देखाइएको छ।", + "lineFailedTitle": "यो लाइन सफल भएन", "status": "स्थिति", "statusFilter": "स्थिति फिल्टर", "noMore": "थप टिकट छैन", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index dfad47a..d888bbb 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -248,6 +248,11 @@ "title": "下注成功", "titlePartial": "部分注项成功", "titleAllFailed": "下注未成功", + "titleRefunded": "订单已退款", + "titlePendingConfirm": "待确认扣款", + "refundedHint": "该订单此前已退款,未产生新的扣款。请关闭后重新预览并下注。", + "pendingConfirmHint": "注项已提交,钱包扣款确认中,请稍后在「我的注单」查看最终状态。", + "pendingConfirmLines": "{{count}} 笔注项待确认扣款", "draw": "期号", "empty": "暂无结果。", "successCount": "成功注项", @@ -306,6 +311,8 @@ "2006": "当前期号不可下注。", "2007": "该玩法暂不支持或缺少赔率配置。", "2008": "赔率或玩法配置已更新,请关闭预览后重新操作。", + "2009": "该订单已退款或不可重复提交,请关闭预览后重新下注。", + "1007": "彩票钱包已冻结,暂无法下注。", "1003": "下注金额超出该玩法允许范围。", "fallback": "下注失败,请稍后重试。" }, @@ -404,7 +411,12 @@ "betNow": "立即下注", "empty": "暂无下注记录。", "dateRange": "日期范围", - "dateRangeHint": "按彩票排期日(UTC)筛选下单时间", + "dateRangeHint": "按彩票排期日({{tz}})筛选下单时间", + "scheduleToday": "排期今天", + "partialFailedHint": "本单部分注项未成功,请查看各注项状态", + "partialFailedOrderTitle": "部分注项失败", + "partialFailedOrderBody": "该订单中部分注项已成功、部分未成功;下方展示本注项结果。", + "lineFailedTitle": "本注项未成功", "status": "状态", "statusFilter": "状态筛选", "noMore": "没有更多注单", diff --git a/src/lib/player-datetime.ts b/src/lib/player-datetime.ts index ec101c9..cfe7ece 100644 --- a/src/lib/player-datetime.ts +++ b/src/lib/player-datetime.ts @@ -53,6 +53,64 @@ function parseScheduleClockToMs( ); } +/** 将时刻格式化为指定 IANA 时区下的 `YYYY-MM-DD`(用于排期日筛选)。 */ +export function formatYmdInTimeZone(date: Date, timeZone: string): string { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const map = new Map(parts.map((part) => [part.type, part.value])); + const year = map.get("year") ?? "0000"; + const month = map.get("month") ?? "00"; + const day = map.get("day") ?? "00"; + return `${year}-${month}-${day}`; + } catch { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; + } +} + +/** 排期时区下的「今天」`YYYY-MM-DD`。 */ +export function scheduleTodayYmd(timeZone: string): string { + return formatYmdInTimeZone(new Date(), timeZone); +} + +/** + * 日期选择器单元格对应的排期日字面量(Y-M-D 与格子上显示的数字一致)。 + * 与 API `start_date`/`end_date`(按排期时区解释)对齐。 + */ +export function formatSchedulePickerYmd(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export function parseSchedulePickerYmd(value: string): Date | undefined { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!m) return undefined; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +/** IANA 时区短标签(如 UTC、GMT+8)。 */ +export function getTimeZoneShortLabel(timeZone: string, date = new Date()): string { + try { + const parts = new Intl.DateTimeFormat(undefined, { + timeZone, + timeZoneName: "short", + }).formatToParts(date); + return parts.find((part) => part.type === "timeZoneName")?.value ?? timeZone; + } catch { + return timeZone; + } +} + /** 浏览器本地时区短标签(如 CST、GMT+8),用于界面说明。 */ export function getBrowserTimeZoneLabel(date = new Date()): string { try { diff --git a/src/types/api/ticket-items.ts b/src/types/api/ticket-items.ts index ba3b581..9cabb0d 100644 --- a/src/types/api/ticket-items.ts +++ b/src/types/api/ticket-items.ts @@ -3,6 +3,7 @@ import type { DrawResultListItem } from "@/types/api/draw-results"; export type TicketItemListRow = { ticket_no: string; order_no: string | null | undefined; + order_status?: string | null; draw_no: string | null | undefined; currency_code: string | null | undefined; play_code: string; @@ -34,6 +35,7 @@ export type TicketItemCombinationRow = { export type TicketItemDetailPayload = { ticket_no: string; order_no: string | null | undefined; + order_status?: string | null; draw_no: string | null | undefined; currency_code: string | null | undefined; play_code: string; @@ -46,6 +48,8 @@ export type TicketItemDetailPayload = { rebate_rate_snapshot: string; actual_deduct_amount: number; status: string; + fail_reason_code?: string | null; + fail_reason_text?: string | null; win_amount: number; jackpot_win_amount: number; settled_at: string | null | undefined; diff --git a/src/types/api/ticket.ts b/src/types/api/ticket.ts index 8fe44bc..eff822f 100644 --- a/src/types/api/ticket.ts +++ b/src/types/api/ticket.ts @@ -73,9 +73,11 @@ export type TicketPlaceItem = { export type TicketPlaceData = { order_no: string; - draw: { draw_id: string; status: string }; + draw: { draw_id: string; status: string; db_status?: string }; summary: TicketPreviewData["summary"] & { + order_status?: string; success_count?: number; + pending_confirm_count?: number; failure_count?: number; }; balance_after: number; diff --git a/src/types/api/wallet-logs.ts b/src/types/api/wallet-logs.ts index bbb8e61..9143e13 100644 --- a/src/types/api/wallet-logs.ts +++ b/src/types/api/wallet-logs.ts @@ -49,4 +49,6 @@ export type GetWalletLogsParams = { size?: number; /** 逗号分隔:transfer_in,transfer_out,bet,prize,refund,reversal */ type?: string; + /** 按钱包币种筛选 */ + currency?: string; };