feat: 增强注单处理逻辑并优化用户反馈体验

新增注单错误处理逻辑,支持已退款及待确认状态的异常场景处理。
更新 HallBetResultDialog:针对部分失败与已退款订单显示对应提示信息。
优化注单分组逻辑,新增订单状态处理,提升整体订单管理能力。
新增订单状态与用户通知相关多语言翻译,进一步提升用户体验。
This commit is contained in:
2026-05-26 16:32:53 +08:00
parent 51b2a36cc5
commit ab81da3199
19 changed files with 373 additions and 142 deletions

View File

@@ -36,6 +36,13 @@ export function mapTicketBetError(
"hall.ticketError.2008", "hall.ticketError.2008",
"赔率或玩法配置已更新,请关闭预览后重新操作。", "赔率或玩法配置已更新,请关闭预览后重新操作。",
); );
case 2009:
return msg(
"hall.ticketError.2009",
"该订单已退款或不可重复提交,请关闭预览后重新下注。",
);
case 1007:
return msg("hall.ticketError.1007", "彩票钱包已冻结,暂无法下注。");
case 1003: case 1003:
return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。"); return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。");
default: default:

View File

@@ -25,6 +25,9 @@ type HallBetResultDialogProps = {
jackpotEnabled?: boolean; jackpotEnabled?: boolean;
}; };
const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]);
const FAILURE_ITEM_STATUSES = new Set(["failed", "refunded"]);
export function HallBetResultDialog({ export function HallBetResultDialog({
open, open,
onOpenChange, onOpenChange,
@@ -34,18 +37,41 @@ export function HallBetResultDialog({
}: HallBetResultDialogProps) { }: HallBetResultDialogProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
// 成功注项状态为 pending_draw待开奖不是 success const successItems =
const successItems = data?.items.filter((item) => item.status !== "failed") ?? []; data?.items.filter((item) => SUCCESS_ITEM_STATUSES.has(item.status)) ?? [];
const failedItems = data?.items.filter((item) => item.status === "failed") ?? []; 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 totalSuccess = data?.summary.success_count ?? successItems.length;
const totalFailure = data?.summary.failure_count ?? failedItems.length; const totalFailure = data?.summary.failure_count ?? failedItems.length;
const isPartial = totalSuccess > 0 && totalFailure > 0; const pendingConfirmCount =
const isAllFailed = totalFailure > 0 && totalSuccess === 0; 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 const iconWrapClass = isAllFailed
? "border-rose-100 text-rose-600 shadow-[0_10px_24px_rgba(225,29,72,0.12)]" ? "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-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)]"; : "border-emerald-100 text-emerald-600 shadow-[0_10px_24px_rgba(22,163,74,0.12)]";
@@ -74,11 +100,15 @@ export function HallBetResultDialog({
</div> </div>
<DialogHeader className="mt-3 items-center gap-2"> <DialogHeader className="mt-3 items-center gap-2">
<DialogTitle className="text-2xl font-black text-slate-950"> <DialogTitle className="text-2xl font-black text-slate-950">
{isAllFailed {isRefundedOrder
? t("hall.result.titleAllFailed") ? t("hall.result.titleRefunded")
: isPartial : isAllFailed
? t("hall.result.titlePartial") ? t("hall.result.titleAllFailed")
: t("hall.result.title")} : isPartial
? t("hall.result.titlePartial")
: isPendingOnly
? t("hall.result.titlePendingConfirm")
: t("hall.result.title")}
</DialogTitle> </DialogTitle>
{data ? ( {data ? (
<DialogDescription className="text-sm leading-relaxed text-slate-500"> <DialogDescription className="text-sm leading-relaxed text-slate-500">
@@ -99,6 +129,16 @@ export function HallBetResultDialog({
</p> </p>
) : ( ) : (
<> <>
{isRefundedOrder ? (
<p className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-800">
{t("hall.result.refundedHint")}
</p>
) : null}
{isPendingOnly ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{t("hall.result.pendingConfirmHint")}
</p>
) : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-4 text-center"> <div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-4 text-center">
<p className="text-sm font-bold text-emerald-700"> <p className="text-sm font-bold text-emerald-700">
@@ -196,29 +236,38 @@ export function HallBetResultDialog({
</tbody> </tbody>
</table> </table>
</div> </div>
{totalFailure === 0 ? ( {failedItems.length === 0 && pendingItems.length === 0 ? (
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700"> <div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700">
{t("hall.result.noFailures")} {t("hall.result.noFailures")}
</div> </div>
) : ( ) : (
<div className="space-y-2 rounded-lg border border-rose-100 bg-rose-50 px-3 py-3"> <div className="space-y-2 rounded-lg border border-rose-100 bg-rose-50 px-3 py-3">
<p className="text-sm font-black text-[#e5002c]"> {failedItems.length > 0 ? (
{t("hall.result.failedItems")} <>
</p> <p className="text-sm font-black text-[#e5002c]">
{failedItems.map((item, index) => ( {t("hall.result.failedItems")}
<div </p>
key={`${item.ticket_no}-${index}`} {failedItems.map((item, index) => (
className="flex items-center justify-between gap-3 rounded-md bg-white px-3 py-2 text-xs" <div
> key={`${item.ticket_no}-${index}`}
<span className="min-w-0 truncate font-semibold text-slate-700"> className="flex items-center justify-between gap-3 rounded-md bg-white px-3 py-2 text-xs"
<span className="font-mono font-black text-slate-950">{item.number}</span>{" "} >
{playLabel(item.play_code, t)} <span className="min-w-0 truncate font-semibold text-slate-700">
</span> <span className="font-mono font-black text-slate-950">{item.number}</span>{" "}
<span className="shrink-0 font-bold text-[#e5002c]"> {playLabel(item.play_code, t)}
{item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")} </span>
</span> <span className="shrink-0 font-bold text-[#e5002c]">
</div> {item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")}
))} </span>
</div>
))}
</>
) : null}
{pendingItems.length > 0 ? (
<p className="text-xs text-amber-800">
{t("hall.result.pendingConfirmLines", { count: pendingItems.length })}
</p>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -721,6 +721,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const removed = clearAmountsForPlay(evt.play_code); const removed = clearAmountsForPlay(evt.play_code);
setPreviewOpen(false); setPreviewOpen(false);
setPreviewData(null); setPreviewData(null);
clearPlaceTraceId();
toast.warning( toast.warning(
removed removed
? t("hall.playConfig.playClosedDraftCleared", { ? t("hall.playConfig.playClosedDraftCleared", {
@@ -739,6 +740,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
void loadCatalog(); void loadCatalog();
setPreviewOpen(false); setPreviewOpen(false);
setPreviewData(null); setPreviewData(null);
clearPlaceTraceId();
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
}; };
@@ -855,7 +857,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const playCode = String(item?.play_code ?? ""); const playCode = String(item?.play_code ?? "");
if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return; if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return;
const entry = entries[clientLineNo - 1]; const entry = entries[clientLineNo - 1];
if (!entry) return; if (!entry || entry.play.play_code !== playCode) return;
cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`); 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")); toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
return; return;
} }
if (e instanceof LotteryApiBizError && code === 2008) {
setPreviewOpen(false);
setPreviewData(null);
clearPlaceTraceId();
}
toast.error(mapTicketBetError(code, msg, t)); toast.error(mapTicketBetError(code, msg, t));
} finally { } finally {
setPreviewLoading(false); setPreviewLoading(false);
@@ -1010,8 +1017,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
setPreviewOpen(false); setPreviewOpen(false);
setPreviewData(null); setPreviewData(null);
clearPlaceTraceId();
return; return;
} }
if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
setPreviewOpen(false);
setPreviewData(null);
clearPlaceTraceId();
}
toast.error(mapTicketBetError(code, msg, t)); toast.error(mapTicketBetError(code, msg, t));
} finally { } finally {
setPlaceLoading(false); 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 favoriteChips = favorites.slice(0, 10);
const historyChips = historyNumbers.slice(0, 20); const historyChips = historyNumbers.slice(0, 20);
@@ -1471,7 +1487,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
? t("hall.table.previewing") ? t("hall.table.previewing")
: !isBettable : !isBettable
? t("hall.closed.title") ? t("hall.closed.title")
: availableMinor < debouncedSummary.actual : availableMinor < submitActualMinor
? t("hall.table.insufficientBalance") ? t("hall.table.insufficientBalance")
: t("hall.table.submitBet")} : t("hall.table.submitBet")}
</Button> </Button>
@@ -1492,7 +1508,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
data={previewData} data={previewData}
placing={placeLoading} placing={placeLoading}
jackpotEnabled={Boolean(jackpot?.enabled)} jackpotEnabled={Boolean(jackpot?.enabled)}
allowSubmit={isBettable && availableMinor >= debouncedSummary.actual} allowSubmit={isBettable && availableMinor >= submitActualMinor}
onConfirmPlace={() => void handlePlace()} onConfirmPlace={() => void handlePlace()}
/> />

View File

@@ -3,6 +3,7 @@ import type { TicketItemListRow } from "@/types/api/ticket-items";
export type TicketItemGroup = { export type TicketItemGroup = {
key: string; key: string;
order_no: string | null; order_no: string | null;
order_status: string | null;
draw_no: string | null; draw_no: string | null;
placed_at: string | null; placed_at: string | null;
currency_code: string | null; currency_code: string | null;
@@ -29,6 +30,25 @@ export function getTicketItemGroupKey(row: TicketItemListRow): string {
return `fallback:${drawNo}|${placedAt}|${currency}|${status}`; 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[] { export function sortTicketGroupItems(items: TicketItemListRow[]): TicketItemListRow[] {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
const byPlay = a.play_code.localeCompare(b.play_code); const byPlay = a.play_code.localeCompare(b.play_code);
@@ -50,6 +70,7 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
group = { group = {
key, key,
order_no: orderNo || null, order_no: orderNo || null,
order_status: row.order_status ?? null,
draw_no: row.draw_no ?? null, draw_no: row.draw_no ?? null,
placed_at: row.placed_at ?? null, placed_at: row.placed_at ?? null,
currency_code: row.currency_code ?? null, currency_code: row.currency_code ?? null,
@@ -72,7 +93,13 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
return order.map((key) => { return order.map((key) => {
const group = map.get(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,
};
}); });
} }

View File

@@ -7,13 +7,25 @@ export function ticketStatusDisplay(
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string, t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
): { label: string; dotClass: string; ring?: boolean } { ): { label: string; dotClass: string; ring?: boolean } {
const total = winMinor + jackpotMinor; const total = winMinor + jackpotMinor;
if ( if (status === "partial_failed") {
status === "pending_draw" || return {
status === "placed" || label: t?.("ticketStatus.partial_failed") ?? status,
status === "partial_failed" || dotClass: "bg-amber-500",
status === "pending_confirm" || };
status === "partial_pending_confirm" }
) { 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 { return {
label: t?.("ticketStatus.pending_draw") ?? status, label: t?.("ticketStatus.pending_draw") ?? status,
dotClass: "bg-sky-500", dotClass: "bg-sky-500",

View File

@@ -185,6 +185,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const cur = data.currency_code ?? activeCurrency; const cur = data.currency_code ?? activeCurrency;
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t); 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 totalWin = data.win_amount + data.jackpot_win_amount;
const pub = data.published_draw_results; const pub = data.published_draw_results;
const first = pub?.results?.["1st"] ?? ""; const first = pub?.results?.["1st"] ?? "";
@@ -244,6 +250,20 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
{isPartialFailedOrder ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<p className="font-bold">{t("orders.partialFailedOrderTitle")}</p>
<p className="mt-1 leading-relaxed text-amber-800/90">
{t("orders.partialFailedOrderBody")}
</p>
</div>
) : null}
{isLineFailed && failReason ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
<p className="font-bold">{t("orders.lineFailedTitle")}</p>
<p className="mt-1 leading-relaxed">{failReason}</p>
</div>
) : null}
<div className="space-y-2.5 text-xs"> <div className="space-y-2.5 text-xs">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span> <span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>

View File

@@ -95,6 +95,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
</p> </p>
</div> </div>
</div> </div>
{group.status === "partial_failed" ? (
<p className="mt-2 text-xs font-bold text-amber-700">
{t("orders.partialFailedHint")}
</p>
) : null}
{totalWin > 0 && group.status === "settled_win" ? ( {totalWin > 0 && group.status === "settled_win" ? (
<p className="mt-2 text-xs font-bold text-emerald-600"> <p className="mt-2 text-xs font-bold text-emerald-600">
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })} {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
@@ -147,6 +152,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
</span> </span>
</span> </span>
</div> </div>
{row.status === "failed" || row.status === "refunded" ? (
<p className="mt-1 text-xs font-bold text-red-600">
{t("orders.lineFailedTitle")}
</p>
) : null}
{lineWin > 0 && row.status === "settled_win" ? ( {lineWin > 0 && row.status === "settled_win" ? (
<p className="mt-1 text-xs font-bold text-emerald-600"> <p className="mt-1 text-xs font-bold text-emerald-600">
{t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })} {t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })}

View File

@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CalendarRange, ChevronDown, Search } from "lucide-react"; import { CalendarRange, ChevronDown, Search } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawCurrent } from "@/api/draw";
import { getTicketItems } from "@/api/ticket-items"; import { getTicketItems } from "@/api/ticket-items";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; 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 { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import {
formatSchedulePickerYmd,
getTimeZoneShortLabel,
parseSchedulePickerYmd,
scheduleTodayYmd,
} from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -43,20 +51,6 @@ const STATUS_OPTIONS = [
"refunded", "refunded",
] as const; ] 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() { export function TicketOrdersListScreen() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
@@ -83,13 +77,18 @@ export function TicketOrdersListScreen() {
const [rangeOpen, setRangeOpen] = useState(false); const [rangeOpen, setRangeOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const [calendarMonth, setCalendarMonth] = useState(() => new Date()); const [calendarMonth, setCalendarMonth] = useState(() => new Date());
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const initialLoadDone = useRef(false); const initialLoadDone = useRef(false);
useEffect(() => {
setQueryDrawNo(drawNoFilter);
}, [drawNoFilter]);
const selectedRange = useMemo(() => { const selectedRange = useMemo(() => {
const from = parseYmd(fromDate); const from = parseSchedulePickerYmd(fromDate);
const to = parseYmd(toDate); const to = parseSchedulePickerYmd(toDate);
if (!from && !to) return undefined; if (!from && !to) return undefined;
if (from && to) return { from, to }; if (from && to) return { from, to };
if (from) return { from }; if (from) return { from };
@@ -140,6 +139,22 @@ export function TicketOrdersListScreen() {
[drawNoFilter, fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate], [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(() => { useEffect(() => {
if (!initialLoadDone.current) { if (!initialLoadDone.current) {
initialLoadDone.current = true; initialLoadDone.current = true;
@@ -262,7 +277,7 @@ export function TicketOrdersListScreen() {
/> />
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]"> <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"> <p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
{t("orders.dateRangeHint")} {t("orders.dateRangeHint", { tz: scheduleTzLabel })}
</p> </p>
<Calendar <Calendar
mode="range" mode="range"
@@ -276,11 +291,24 @@ export function TicketOrdersListScreen() {
setToDate(""); setToDate("");
return; return;
} }
setFromDate(range?.from ? formatYmd(range.from) : ""); setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
setToDate(range?.to ? formatYmd(range.to) : ""); setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
}} }}
/> />
<div className="flex items-center justify-end gap-2 border-t px-2 py-1.5"> <div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
const today = scheduleTodayYmd(scheduleTimezone);
setFromDate(today);
setToDate(today);
}}
>
{t("orders.scheduleToday")}
</Button>
<div className="flex items-center gap-2">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -295,6 +323,7 @@ export function TicketOrdersListScreen() {
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}> <Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
{t("actions.done")} {t("actions.done")}
</Button> </Button>
</div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -437,6 +466,11 @@ export function TicketOrdersListScreen() {
</p> </p>
</div> </div>
</div> </div>
{group.status === "partial_failed" ? (
<p className="mt-2 text-xs font-bold text-amber-700">
{t("orders.partialFailedHint")}
</p>
) : null}
{totalWin > 0 && group.status === "settled_win" ? ( {totalWin > 0 && group.status === "settled_win" ? (
<p className="mt-2 text-xs font-bold text-emerald-600"> <p className="mt-2 text-xs font-bold text-emerald-600">
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })} {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
@@ -498,11 +532,11 @@ export function TicketOrdersListScreen() {
{t("actions.next")} {t("actions.next")}
</Button> </Button>
</div> </div>
) : ( ) : lastPage > 1 ? (
<p className="py-2 text-center text-xs text-slate-400"> <p className="py-2 text-center text-xs text-slate-400">
{t("orders.noMore")} {t("orders.noMore")}
</p> </p>
)} ) : null}
</> </>
)} )}
</div> </div>

View File

@@ -25,20 +25,6 @@ export function WalletLogsScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(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 fetchPassRef = useRef(true);
const load = useCallback(async (targetPage = 1, append = false) => { const load = useCallback(async (targetPage = 1, append = false) => {
@@ -56,6 +42,7 @@ export function WalletLogsScreen() {
page: targetPage, page: targetPage,
size: WALLET_LOGS_PAGE_SIZE, size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined, type: filter || undefined,
currency,
}); });
setLogs((current) => setLogs((current) =>
append && current append && current
@@ -72,7 +59,7 @@ export function WalletLogsScreen() {
setLogsLoading(false); setLogsLoading(false);
setLoadingMore(false); setLoadingMore(false);
} }
}, [filter, t]); }, [currency, filter, t]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
@@ -131,7 +118,7 @@ export function WalletLogsScreen() {
) : null} ) : null}
<WalletLogsBlock <WalletLogsBlock
logs={logsForCurrency} logs={logs}
logsLoading={loading || logsLoading} logsLoading={loading || logsLoading}
loadingMore={loadingMore} loadingMore={loadingMore}
hasMore={hasMore} hasMore={hasMore}

View File

@@ -2,7 +2,7 @@
import { Wallet } from "lucide-react"; import { Wallet } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getWalletBalance, getWalletLogs } from "@/api/wallet"; import { getWalletBalance, getWalletLogs } from "@/api/wallet";
@@ -42,6 +42,7 @@ export function WalletScreen() {
page: targetPage, page: targetPage,
size: WALLET_LOGS_PAGE_SIZE, size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined, type: filter || undefined,
currency,
}); });
setLogs((current) => setLogs((current) =>
append && current append && current
@@ -49,7 +50,7 @@ export function WalletScreen() {
: nextLogs, : nextLogs,
); );
return nextLogs; return nextLogs;
}, [filter]); }, [currency, filter]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -107,20 +108,6 @@ export function WalletScreen() {
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [refreshAll]); }, [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 hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -222,7 +209,7 @@ export function WalletScreen() {
</div> </div>
<WalletLogsBlock <WalletLogsBlock
logs={logsForCurrency} logs={logs}
logsLoading={loading || logsLoading} logsLoading={loading || logsLoading}
loadingMore={loadingMore} loadingMore={loadingMore}
hasMore={hasMore} hasMore={hasMore}

View File

@@ -156,10 +156,10 @@ export function triggerWalletPollingAfterBet(): void {
// 启动限时轮询 // 启动限时轮询
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
// 检查是否过期 const live = useNetworkConnectionStore.getState();
if (store.walletPollingExpiryAt && Date.now() > store.walletPollingExpiryAt) { if (live.walletPollingExpiryAt && Date.now() > live.walletPollingExpiryAt) {
window.clearInterval(intervalId); window.clearInterval(intervalId);
store.setWalletPollingIntervalId(null); live.setWalletPollingIntervalId(null);
return; return;
} }
void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
@@ -167,8 +167,8 @@ export function triggerWalletPollingAfterBet(): void {
}); });
}, POLLING_INTERVAL_MS); }, POLLING_INTERVAL_MS);
store.setWalletPollingIntervalId(intervalId); useNetworkConnectionStore.getState().setWalletPollingIntervalId(intervalId);
store.setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS); useNetworkConnectionStore.getState().setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS);
// 2分钟后自动清理 // 2分钟后自动清理
window.setTimeout(() => { window.setTimeout(() => {

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { getDrawCurrent } from "@/api/draw";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryEcho } from "@/lib/lottery-echo";
@@ -63,11 +62,8 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
// 刷新画作数据 // 刷新画作数据
const refreshDraw = useCallback(async () => { const refreshDraw = useCallback(async () => {
try { // 由 useHallDrawLive 监听并拉取,避免与大厅重复请求 draw/current
await getDrawCurrent(); window.dispatchEvent(new Event("lottery-hall-refresh"));
} catch {
// 静默处理错误,避免频繁报错
}
}, []); }, []);
// 刷新钱包余额 // 刷新钱包余额
@@ -138,21 +134,11 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
clearWalletPolling(); clearWalletPolling();
}, [clearWalletPolling]); }, [clearWalletPolling]);
// 尝试连接 WebSocket // 检测 Pusher 是否已真正 connected非仅订阅频道
const connectWebSocket = useCallback(() => { const isPusherConnected = useCallback((): boolean => {
const echo = getLotteryEcho(); const echo = getLotteryEcho();
if (!echo) { const state = echo?.connector?.pusher?.connection?.state;
// 配置不完整,无法连接 return state === "connected";
return false;
}
try {
// 连接到 lottery-hall 频道以测试连接
echo.channel("lottery-hall");
return true;
} catch {
return false;
}
}, []); }, []);
// 处理连接断开 // 处理连接断开
@@ -207,20 +193,19 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
incrementReconnectAttempts(); incrementReconnectAttempts();
const success = connectWebSocket(); if (isPusherConnected()) {
if (success) {
handleConnect(); handleConnect();
} else { return;
// 继续重连
reconnectTimerRef.current = window.setTimeout(
() => attemptReconnectRef.current(),
RECONNECT_INTERVAL_MS,
);
} }
reconnectTimerRef.current = window.setTimeout(
() => attemptReconnectRef.current(),
RECONNECT_INTERVAL_MS,
);
}, [ }, [
reconnectAttempts, reconnectAttempts,
incrementReconnectAttempts, incrementReconnectAttempts,
connectWebSocket, isPusherConnected,
handleConnect, handleConnect,
setReconnecting, setReconnecting,
]); ]);
@@ -292,13 +277,10 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
connection.bind("unavailable", handleDisconnect); connection.bind("unavailable", handleDisconnect);
connection.bind("failed", handleDisconnect); connection.bind("failed", handleDisconnect);
syncConnectionState(); syncConnectionState();
} else if (isPusherConnected()) {
handleConnect();
} else { } else {
const success = connectWebSocket(); handleDisconnect();
if (success) {
handleConnect();
} else {
handleDisconnect();
}
} }
return () => { return () => {
@@ -313,7 +295,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
} }
}; };
}, [ }, [
connectWebSocket, isPusherConnected,
handleConnect, handleConnect,
handleDisconnect, handleDisconnect,
startDrawPolling, startDrawPolling,

View File

@@ -248,6 +248,11 @@
"title": "Bet placed", "title": "Bet placed",
"titlePartial": "Partially placed", "titlePartial": "Partially placed",
"titleAllFailed": "Bet not 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", "draw": "Issue",
"empty": "No result yet.", "empty": "No result yet.",
"successCount": "Successful lines", "successCount": "Successful lines",
@@ -306,6 +311,7 @@
"2006": "This issue cannot accept bets.", "2006": "This issue cannot accept bets.",
"2007": "This play type is unsupported or missing odds configuration.", "2007": "This play type is unsupported or missing odds configuration.",
"2008": "Odds or play configuration has changed. Close the preview and try again.", "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.", "1003": "Stake amount is outside the allowed range for this play type.",
"fallback": "Bet failed. Please try again later." "fallback": "Bet failed. Please try again later."
}, },
@@ -404,7 +410,12 @@
"betNow": "Bet Now", "betNow": "Bet Now",
"empty": "No bet records yet.", "empty": "No bet records yet.",
"dateRange": "Date range", "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", "status": "Status",
"statusFilter": "Status filter", "statusFilter": "Status filter",
"noMore": "No more tickets", "noMore": "No more tickets",

View File

@@ -248,6 +248,11 @@
"title": "बेट सफल", "title": "बेट सफल",
"titlePartial": "आंशिक सफल", "titlePartial": "आंशिक सफल",
"titleAllFailed": "बेट असफल", "titleAllFailed": "बेट असफल",
"titleRefunded": "अर्डर फिर्ता",
"titlePendingConfirm": "पुष्टि बाँकी",
"refundedHint": "यो अर्डर पहिले नै फिर्ता भइसकेको छ। नयाँ बेटका लागि पूर्वावलोकन बन्द गरी फेरि गर्नुहोस्।",
"pendingConfirmHint": "बेट पेश भयो। वालेट कट्टी पुष्टि हुँदैछ — छिट्टै मेरो बेट हेर्नुहोस्।",
"pendingConfirmLines": "{{count}} लाइन कट्टी पुष्टि बाँकी",
"draw": "इश्यू", "draw": "इश्यू",
"empty": "नतिजा छैन।", "empty": "नतिजा छैन।",
"successCount": "सफल लाइनहरू", "successCount": "सफल लाइनहरू",
@@ -306,6 +311,7 @@
"2006": "यो इश्यूले बेट स्वीकार गर्न सक्दैन।", "2006": "यो इश्यूले बेट स्वीकार गर्न सक्दैन।",
"2007": "यो प्ले प्रकार समर्थित छैन वा odds कन्फिगरेसन छैन।", "2007": "यो प्ले प्रकार समर्थित छैन वा odds कन्फिगरेसन छैन।",
"2008": "Odds वा प्ले कन्फिगरेसन परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।", "2008": "Odds वा प्ले कन्फिगरेसन परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।",
"2009": "यो अर्डर फिर्ता भइसकेको छ वा पुन: पेश गर्न मिल्दैन। पूर्वावलोकन बन्द गरी नयाँ बेट गर्नुहोस्।",
"1003": "बेट रकम यो प्ले प्रकारको अनुमत दायराभन्दा बाहिर छ।", "1003": "बेट रकम यो प्ले प्रकारको अनुमत दायराभन्दा बाहिर छ।",
"fallback": "बेट असफल। कृपया पछि प्रयास गर्नुहोस्।" "fallback": "बेट असफल। कृपया पछि प्रयास गर्नुहोस्।"
}, },
@@ -404,7 +410,12 @@
"betNow": "अहिले बेट", "betNow": "अहिले बेट",
"empty": "अहिलेसम्म बेट रेकर्ड छैन।", "empty": "अहिलेसम्म बेट रेकर्ड छैन।",
"dateRange": "मिति दायरा", "dateRange": "मिति दायरा",
"dateRangeHint": "अर्डर समयलाई तालिका दिन (UTC) अनुसार फिल्टर गर्छ", "dateRangeHint": "अर्डर समय तालिका दिन ({{tz}}) अनुसार फिल्टर",
"scheduleToday": "तालिका आज",
"partialFailedHint": "यो अर्डरका केही लाइन सफल भएनन् — प्रत्येक लाइन हेर्नुहोस्",
"partialFailedOrderTitle": "आंशिक असफल अर्डर",
"partialFailedOrderBody": "यस अर्डरका केही लाइन सफल, केही असफल। यो पृष्ठमा यो लाइन मात्र देखाइएको छ।",
"lineFailedTitle": "यो लाइन सफल भएन",
"status": "स्थिति", "status": "स्थिति",
"statusFilter": "स्थिति फिल्टर", "statusFilter": "स्थिति फिल्टर",
"noMore": "थप टिकट छैन", "noMore": "थप टिकट छैन",

View File

@@ -248,6 +248,11 @@
"title": "下注成功", "title": "下注成功",
"titlePartial": "部分注项成功", "titlePartial": "部分注项成功",
"titleAllFailed": "下注未成功", "titleAllFailed": "下注未成功",
"titleRefunded": "订单已退款",
"titlePendingConfirm": "待确认扣款",
"refundedHint": "该订单此前已退款,未产生新的扣款。请关闭后重新预览并下注。",
"pendingConfirmHint": "注项已提交,钱包扣款确认中,请稍后在「我的注单」查看最终状态。",
"pendingConfirmLines": "{{count}} 笔注项待确认扣款",
"draw": "期号", "draw": "期号",
"empty": "暂无结果。", "empty": "暂无结果。",
"successCount": "成功注项", "successCount": "成功注项",
@@ -306,6 +311,8 @@
"2006": "当前期号不可下注。", "2006": "当前期号不可下注。",
"2007": "该玩法暂不支持或缺少赔率配置。", "2007": "该玩法暂不支持或缺少赔率配置。",
"2008": "赔率或玩法配置已更新,请关闭预览后重新操作。", "2008": "赔率或玩法配置已更新,请关闭预览后重新操作。",
"2009": "该订单已退款或不可重复提交,请关闭预览后重新下注。",
"1007": "彩票钱包已冻结,暂无法下注。",
"1003": "下注金额超出该玩法允许范围。", "1003": "下注金额超出该玩法允许范围。",
"fallback": "下注失败,请稍后重试。" "fallback": "下注失败,请稍后重试。"
}, },
@@ -404,7 +411,12 @@
"betNow": "立即下注", "betNow": "立即下注",
"empty": "暂无下注记录。", "empty": "暂无下注记录。",
"dateRange": "日期范围", "dateRange": "日期范围",
"dateRangeHint": "按彩票排期日(UTC)筛选下单时间", "dateRangeHint": "按彩票排期日({{tz}})筛选下单时间",
"scheduleToday": "排期今天",
"partialFailedHint": "本单部分注项未成功,请查看各注项状态",
"partialFailedOrderTitle": "部分注项失败",
"partialFailedOrderBody": "该订单中部分注项已成功、部分未成功;下方展示本注项结果。",
"lineFailedTitle": "本注项未成功",
"status": "状态", "status": "状态",
"statusFilter": "状态筛选", "statusFilter": "状态筛选",
"noMore": "没有更多注单", "noMore": "没有更多注单",

View File

@@ -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用于界面说明。 */ /** 浏览器本地时区短标签(如 CST、GMT+8用于界面说明。 */
export function getBrowserTimeZoneLabel(date = new Date()): string { export function getBrowserTimeZoneLabel(date = new Date()): string {
try { try {

View File

@@ -3,6 +3,7 @@ import type { DrawResultListItem } from "@/types/api/draw-results";
export type TicketItemListRow = { export type TicketItemListRow = {
ticket_no: string; ticket_no: string;
order_no: string | null | undefined; order_no: string | null | undefined;
order_status?: string | null;
draw_no: string | null | undefined; draw_no: string | null | undefined;
currency_code: string | null | undefined; currency_code: string | null | undefined;
play_code: string; play_code: string;
@@ -34,6 +35,7 @@ export type TicketItemCombinationRow = {
export type TicketItemDetailPayload = { export type TicketItemDetailPayload = {
ticket_no: string; ticket_no: string;
order_no: string | null | undefined; order_no: string | null | undefined;
order_status?: string | null;
draw_no: string | null | undefined; draw_no: string | null | undefined;
currency_code: string | null | undefined; currency_code: string | null | undefined;
play_code: string; play_code: string;
@@ -46,6 +48,8 @@ export type TicketItemDetailPayload = {
rebate_rate_snapshot: string; rebate_rate_snapshot: string;
actual_deduct_amount: number; actual_deduct_amount: number;
status: string; status: string;
fail_reason_code?: string | null;
fail_reason_text?: string | null;
win_amount: number; win_amount: number;
jackpot_win_amount: number; jackpot_win_amount: number;
settled_at: string | null | undefined; settled_at: string | null | undefined;

View File

@@ -73,9 +73,11 @@ export type TicketPlaceItem = {
export type TicketPlaceData = { export type TicketPlaceData = {
order_no: string; order_no: string;
draw: { draw_id: string; status: string }; draw: { draw_id: string; status: string; db_status?: string };
summary: TicketPreviewData["summary"] & { summary: TicketPreviewData["summary"] & {
order_status?: string;
success_count?: number; success_count?: number;
pending_confirm_count?: number;
failure_count?: number; failure_count?: number;
}; };
balance_after: number; balance_after: number;

View File

@@ -49,4 +49,6 @@ export type GetWalletLogsParams = {
size?: number; size?: number;
/** 逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal */ /** 逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal */
type?: string; type?: string;
/** 按钱包币种筛选 */
currency?: string;
}; };