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

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

View File

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

View File

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

View File

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

View File

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