feat: 增强注单处理逻辑并优化用户反馈体验
新增注单错误处理逻辑,支持已退款及待确认状态的异常场景处理。 更新 HallBetResultDialog:针对部分失败与已退款订单显示对应提示信息。 优化注单分组逻辑,新增订单状态处理,提升整体订单管理能力。 新增订单状态与用户通知相关多语言翻译,进一步提升用户体验。
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
<DialogHeader className="mt-3 items-center gap-2">
|
||||
<DialogTitle className="text-2xl font-black text-slate-950">
|
||||
{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")}
|
||||
</DialogTitle>
|
||||
{data ? (
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-500">
|
||||
@@ -99,6 +129,16 @@ export function HallBetResultDialog({
|
||||
</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="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-4 text-center">
|
||||
<p className="text-sm font-bold text-emerald-700">
|
||||
@@ -196,29 +236,38 @@ export function HallBetResultDialog({
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
{t("hall.result.noFailures")}
|
||||
</div>
|
||||
) : (
|
||||
<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]">
|
||||
{t("hall.result.failedItems")}
|
||||
</p>
|
||||
{failedItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.ticket_no}-${index}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-white px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 truncate font-semibold text-slate-700">
|
||||
<span className="font-mono font-black text-slate-950">{item.number}</span>{" "}
|
||||
{playLabel(item.play_code, t)}
|
||||
</span>
|
||||
<span className="shrink-0 font-bold text-[#e5002c]">
|
||||
{item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{failedItems.length > 0 ? (
|
||||
<>
|
||||
<p className="text-sm font-black text-[#e5002c]">
|
||||
{t("hall.result.failedItems")}
|
||||
</p>
|
||||
{failedItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.ticket_no}-${index}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-white px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 truncate font-semibold text-slate-700">
|
||||
<span className="font-mono font-black text-slate-950">{item.number}</span>{" "}
|
||||
{playLabel(item.play_code, t)}
|
||||
</span>
|
||||
<span className="shrink-0 font-bold text-[#e5002c]">
|
||||
{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>
|
||||
|
||||
@@ -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")}
|
||||
</Button>
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
||||
|
||||
@@ -95,6 +95,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
|
||||
</p>
|
||||
</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" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
@@ -147,6 +152,11 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
|
||||
</span>
|
||||
</span>
|
||||
</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" ? (
|
||||
<p className="mt-1 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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() {
|
||||
/>
|
||||
<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")}
|
||||
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
@@ -276,11 +291,24 @@ export function TicketOrdersListScreen() {
|
||||
setToDate("");
|
||||
return;
|
||||
}
|
||||
setFromDate(range?.from ? formatYmd(range.from) : "");
|
||||
setToDate(range?.to ? formatYmd(range.to) : "");
|
||||
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
||||
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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -295,6 +323,7 @@ export function TicketOrdersListScreen() {
|
||||
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
||||
{t("actions.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -437,6 +466,11 @@ export function TicketOrdersListScreen() {
|
||||
</p>
|
||||
</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" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
@@ -498,11 +532,11 @@ export function TicketOrdersListScreen() {
|
||||
{t("actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : lastPage > 1 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("orders.noMore")}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,20 +25,6 @@ export function WalletLogsScreen() {
|
||||
const [error, setError] = useState<string | 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 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}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logsForCurrency}
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
hasMore={hasMore}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Wallet } from "lucide-react";
|
||||
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 { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
||||
@@ -42,6 +42,7 @@ export function WalletScreen() {
|
||||
page: targetPage,
|
||||
size: WALLET_LOGS_PAGE_SIZE,
|
||||
type: filter || undefined,
|
||||
currency,
|
||||
});
|
||||
setLogs((current) =>
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logsForCurrency}
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
hasMore={hasMore}
|
||||
|
||||
Reference in New Issue
Block a user