diff --git a/src/api/bet-providers.ts b/src/api/bet-providers.ts new file mode 100644 index 0000000..ae468e6 --- /dev/null +++ b/src/api/bet-providers.ts @@ -0,0 +1,6 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import type { BetProvidersData } from "@/types/api/bet-provider"; + +export function getBetProviders(): Promise { + return lotteryRequest.get("/bet-providers"); +} diff --git a/src/app/globals.css b/src/app/globals.css index d385090..0eebfc2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -144,7 +144,7 @@ } } -/* 下注表格横向滚动:右侧渐变提示可继续滑动 */ +/* 下注表格横向滚动 */ .player-table-scroll { -webkit-overflow-scrolling: touch; scrollbar-width: thin; @@ -154,17 +154,6 @@ position: relative; } -.player-table-scroll-wrap::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 1.25rem; - pointer-events: none; - background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent); -} - /* 玩家端侧栏:浅色底 + 与后台一致的红色激活态 */ .player-sidebar { --sidebar: #ffffff; @@ -330,4 +319,4 @@ to { transform: rotate(360deg); } -} \ No newline at end of file +} diff --git a/src/components/layout/player-panel.tsx b/src/components/layout/player-panel.tsx index bf72b70..0ffad70 100644 --- a/src/components/layout/player-panel.tsx +++ b/src/components/layout/player-panel.tsx @@ -25,7 +25,7 @@ export function PlayerPanel({
); -} \ No newline at end of file +} diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx index c7af4e9..580c2ff 100644 --- a/src/features/hall/hall-bet-preview-dialog.tsx +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -61,6 +61,10 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) { ); } +function providerLabel(providerName?: string | null, providerCode?: string | null): string { + return providerName || providerCode || "-"; +} + function SubmittingPanel() { const { t } = useTranslation("player"); @@ -206,7 +210,7 @@ export function HallBetPreviewDialog({ ) : null}
- +
@@ -216,6 +220,9 @@ export function HallBetPreviewDialog({ + @@ -245,6 +252,11 @@ export function HallBetPreviewDialog({ + diff --git a/src/features/hall/hall-bet-result-dialog.tsx b/src/features/hall/hall-bet-result-dialog.tsx index 6d5b59e..1f9e8e1 100644 --- a/src/features/hall/hall-bet-result-dialog.tsx +++ b/src/features/hall/hall-bet-result-dialog.tsx @@ -29,6 +29,10 @@ type HallBetResultDialogProps = { const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]); const FAILURE_ITEM_STATUSES = new Set(["failed", "refunded"]); +function providerLabel(providerName?: string | null, providerCode?: string | null): string { + return providerName || providerCode || "-"; +} + export function HallBetResultDialog({ open, onOpenChange, @@ -202,7 +206,7 @@ export function HallBetResultDialog({ {t("hall.result.items")}

-
No. {t("orders.play")} + {t("hall.providers.title", { defaultValue: "开注商" })} + {t("hall.preview.amount")} {ln.play_code} + + {providerLabel(ln.provider_name, ln.provider_code)} + + {formatMinorAsCurrency(ln.total_bet_amount, currencyCode)}
+
+ @@ -239,6 +246,11 @@ export function HallBetResultDialog({ + @@ -265,7 +277,7 @@ export function HallBetResultDialog({ > {item.number}{" "} - {playLabel(item.play_code, t)} + {playLabel(item.play_code, t)} · {providerLabel(item.provider_name, item.provider_code)} {item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")} diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 0e7bc3f..d2d263c 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -1,10 +1,11 @@ "use client"; -import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react"; +import { CirclePlus, ChevronDown, ChevronUp, Lock, Ticket, Trash2, Star } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { getBetProviders } from "@/api/bet-providers"; import { getPlayEffective } from "@/api/play"; import { getWalletBalance } from "@/api/wallet"; import { postTicketPlace, postTicketPreview } from "@/api/ticket"; @@ -30,6 +31,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; +import { playerViewportFixedBarClass } from "@/lib/player-viewport"; import { PLAY_CATALOG_REFRESH_EVENT, type PlayCatalogRefreshSource, @@ -41,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors"; import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective"; import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; +import type { BetProviderRow } from "@/types/api/bet-provider"; const MAX_ROWS = 50; @@ -113,10 +116,16 @@ type RiskWarningWsEvent = { type CellRiskState = "open" | "warning" | "sold_out"; type QuickFillState = Record; +const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [ + { code: "SG", name: "Singapore", sort_order: 10, is_default: true }, + { code: "MY", name: "Malaysia", sort_order: 20, is_default: false }, + { code: "TH", name: "Thailand", sort_order: 30, is_default: false }, +]; + const categoryTabs: { value: HallCategory; label: string }[] = [ - { value: "D2", label: "2D" }, - { value: "D3", label: "3D" }, { value: "D4", label: "4D" }, + { value: "D3", label: "3D" }, + { value: "D2", label: "2D" }, ]; const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const; @@ -141,6 +150,7 @@ const D4_PLAY_ORDER = [ "digit_big", "digit_small", ] as const; +const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const; function newDraftRow(): DraftRow { const id = @@ -445,7 +455,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [resultOpen, setResultOpen] = useState(false); const [resultData, setResultData] = useState(null); const [activeCategory, setActiveCategory] = useState>("D4"); + const [betProviders, setBetProviders] = useState(FALLBACK_BET_PROVIDERS); + const [selectedProviderCodes, setSelectedProviderCodes] = useState([]); const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState()); + const [quickFillExpanded, setQuickFillExpanded] = useState(false); + const [providersExpanded, setProvidersExpanded] = useState(false); const [riskStateDrawNo, setRiskStateDrawNo] = useState(display?.draw_no ?? null); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set()); const [liveWarningNumbers, setLiveWarningNumbers] = useState>(() => new Set()); @@ -501,6 +515,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }, [loadCatalog, refreshWallet]); + useEffect(() => { + let cancelled = false; + void getBetProviders() + .then((data) => { + if (cancelled) return; + const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS; + setBetProviders(items); + setSelectedProviderCodes((current) => { + const available = new Set(items.map((item) => item.code)); + return current.filter((code) => available.has(code)); + }); + }) + .catch(() => { + if (cancelled) return; + setBetProviders(FALLBACK_BET_PROVIDERS); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { const onCatalogRefresh = (ev: Event) => { void loadCatalog(); @@ -554,13 +589,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return playColumnsForCategory(activeCategoryPlays, activeCategory); }, [activeCategory, activeCategoryPlays]); + const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]"; + const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]"; + const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]"; + const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]"; + const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass; + const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass; + const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]"; const amountColClass = !isMobile ? "w-[3.9rem] min-w-[3.9rem]" : "w-[4rem] min-w-[4rem]"; const tableMinWidthPx = useMemo(() => { - const indexCol = 34; - const numberCol = 88; + const indexCol = !isMobile ? 34 : 28; + const numberCol = !isMobile ? 88 : 68; const amountCol = !isMobile ? 62 : 64; const deleteCol = 32; return indexCol + numberCol + playColumns.length * amountCol + deleteCol; @@ -581,7 +623,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setLiveWarningNumbers(new Set()); } - const alertRows = display?.risk_pool_alerts ?? []; + const alertRows = useMemo( + () => display?.risk_pool_alerts ?? [], + [display?.risk_pool_alerts], + ); const jackpot = display?.jackpot; const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] }; const favorites = currentQuickFill.favorites; @@ -638,6 +683,21 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setActiveRowId(next[0].id); }; + const clearActiveRowAmounts = useCallback(() => { + const targetId = activeRowId ?? rows[0]?.id; + if (!targetId || tableDisabled) return; + setRows((current) => + current.map((row) => { + if (row.id !== targetId) return row; + const nextAmounts = { ...row.amounts }; + playColumns.forEach((column) => { + nextAmounts[column.key] = ""; + }); + return { ...row, amounts: nextAmounts }; + }), + ); + }, [activeRowId, playColumns, rows, tableDisabled]); + const fillCurrentRow = (number: string) => { if (tableDisabled) return; const targetId = activeRowId ?? rows[0]?.id; @@ -645,6 +705,78 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } updateRowNumber(targetId, number); }; + const applyQuickAmountToActiveRow = useCallback( + (amount: string) => { + const targetId = activeRowId ?? rows[0]?.id; + if (!targetId || tableDisabled) return; + setRows((current) => + current.map((row) => { + if (row.id !== targetId) return row; + if ( + row.number.trim().length === 0 || + playColumns.every( + (column) => + draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null, + ) + ) { + return row; + } + + const nextAmounts = { ...row.amounts }; + playColumns.forEach((column) => { + const isInputValid = + draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null; + const status = cellRiskState( + column.play, + row.number, + playCategory(column.play.play_code), + alertRows, + liveSoldOutNumbers, + liveWarningNumbers, + column.digitSlot, + ); + if (!isInputValid || status === "sold_out") return; + nextAmounts[column.key] = amount; + }); + return { ...row, amounts: nextAmounts }; + }), + ); + }, + [ + activeRowId, + alertRows, + liveSoldOutNumbers, + liveWarningNumbers, + playColumns, + rows, + tableDisabled, + ], + ); + + const copyPreviousRowToActiveRow = useCallback(() => { + const targetId = activeRowId ?? rows[0]?.id; + if (!targetId || tableDisabled) return; + const targetIndex = rows.findIndex((row) => row.id === targetId); + if (targetIndex <= 0) return; + const previousRow = rows[targetIndex - 1]; + if (!previousRow) return; + + setRows((current) => + current.map((row, index) => { + if (index !== targetIndex) return row; + const nextAmounts = { ...row.amounts }; + playColumns.forEach((column) => { + nextAmounts[column.key] = previousRow.amounts[column.key] ?? ""; + }); + return { + ...row, + number: previousRow.number, + amounts: nextAmounts, + }; + }), + ); + }, [activeRowId, playColumns, rows, tableDisabled]); + const toggleFavoriteNumber = (number: string) => { const keys = quickFillKeys(activeCategory); setQuickFillState((current) => { @@ -680,6 +812,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }; + const toggleProvider = (code: string) => { + setSelectedProviderCodes((current) => { + if (current.includes(code)) { + return current.filter((item) => item !== code); + } + return [...current, code]; + }); + }; + const hasAmountsForPlay = useCallback( (playCode: string): boolean => rows.some((row) => @@ -835,8 +976,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, [allPlayColumns, currencyCode, rows]); const draftEntries = collectEntries(); + const effectiveProviderCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1; const draftSummary = useMemo(() => { - return draftEntries.reduce( + const base = draftEntries.reduce( (acc, entry) => { const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate); const rebate = Math.round(entry.amountMinor * rebateRate); @@ -847,7 +989,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, { bet: 0, rebate: 0, actual: 0 }, ); - }, [draftEntries]); + return { + bet: base.bet * effectiveProviderCount, + rebate: base.rebate * effectiveProviderCount, + actual: base.actual * effectiveProviderCount, + }; + }, [draftEntries, effectiveProviderCount]); useEffect(() => { const id = window.setTimeout(() => { @@ -929,6 +1076,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } draw_id: display.draw_no, currency_code: currencyCode, client_trace_id: placeTraceIdRef.current, + provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined, lines, }); setPreviewData(data); @@ -996,6 +1144,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } draw_id: drawIdForBet, currency_code: currencyCode, client_trace_id: traceId, + provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined, lines, expected_config_versions: previewData.config_versions, }); @@ -1065,7 +1214,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } if (catalogState.kind === "loading") { return ( -
+
@@ -1096,6 +1248,23 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } !tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor; const favoriteChips = favorites.slice(0, 10); const historyChips = historyNumbers.slice(0, 20); + const selectedProviderNames = betProviders + .filter((provider) => selectedProviderCodes.includes(provider.code)) + .map((provider) => provider.name); + const implicitDefaultProvider = + betProviders.find((provider) => provider.is_default) ?? betProviders[0] ?? null; + const providerSummaryCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1; + const providerSummaryText = + selectedProviderCodes.length > 0 + ? t("hall.providers.summary", { + defaultValue: "已选 {{count}} 家,金额按每家公司计算", + count: providerSummaryCount, + }) + : t("hall.providers.platformDefaultSummary", { + defaultValue: implicitDefaultProvider + ? `未选择时走平台默认(${implicitDefaultProvider.name})` + : "未选择时走平台默认", + }); return ( <> @@ -1123,17 +1292,55 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ) : null} -
+

{t("hall.quickFill.title")}

-

- {t("hall.quickFill.description")} -

+ {!isMobile ? ( +

+ {t("hall.quickFill.description")} +

+ ) : ( +

+ {t("hall.mobile.quickFillSummary", { + defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个", + favorites: favorites.length, + history: historyNumbers.length, + })} +

+ )}
+ {isMobile ? ( + + ) : null} {activeRow?.number ? (
-
+
+ {isMobile && quickFillExpanded ? ( +
+
+ + {t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })} + + {MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => ( + + ))} + + +
+
+ ) : null} + + {!isMobile || quickFillExpanded ? ( + <> {favoriteChips.length > 0 ? (
@@ -1251,10 +1497,115 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } )}
+ + ) : null}
-
+
+
+
+ + {t("hall.providers.title", { defaultValue: "开注商" })} + + {!isMobile ? betProviders.map((provider) => { + const checked = selectedProviderCodes.includes(provider.code); + + return ( + + ); + }) : ( + + {selectedProviderNames.length > 0 + ? selectedProviderNames.join(" / ") + : t("hall.providers.platformDefault", { + defaultValue: implicitDefaultProvider + ? `平台默认(${implicitDefaultProvider.name})` + : "平台默认", + })} + + )} +
+
+

{providerSummaryText}

+ {isMobile ? ( + + ) : null} +
+
+ {isMobile && providersExpanded ? ( +
+ {betProviders.map((provider) => { + const checked = selectedProviderCodes.includes(provider.code); + + return ( + + ); + })} +
+ ) : null} +
+ +
{categoryTabs.map((tab) => { const hasPlays = openPlays.some( (play) => playCategory(play.play_code) === tab.value, @@ -1268,7 +1619,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } disabled={!hasPlays} onClick={() => setActiveCategory(tab.value)} className={cn( - "inline-flex min-w-[6rem] items-center justify-center rounded-t-xl border px-4 py-3 text-sm font-bold transition-colors", + "inline-flex items-center justify-center border font-bold transition-colors", + isMobile + ? "min-w-[5.25rem] rounded-t-xl border-b-0 px-4 py-3 text-sm" + : "min-w-[6rem] rounded-t-xl px-4 py-3 text-sm", active ? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]" : "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]", @@ -1294,13 +1648,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
) : null} - {showWideTableHint ? ( -

{t("hall.table.scrollHint")}

- ) : null} -
-
@@ -214,6 +218,9 @@ export function HallBetResultDialog({ {t("orders.play")} + {t("hall.providers.title", { defaultValue: "开注商" })} + {t("hall.result.actualDeduct")} {playLabel(item.play_code, t)} + + {providerLabel(item.provider_name, item.provider_code)} + + {formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)}
+ {t("hall.table.no", { defaultValue: "No." })} {t("hall.table.number", { defaultValue: "Number" })} @@ -1416,31 +1768,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } t={t} isMobile={isMobile} removable={rows.length > 1} + indexColClass={indexColClass} + numberColClass={numberColClass} + stickyNumberLeftClass={stickyNumberLeftClass} /> ))}
- -
- - {t("hall.table.draftTotal")} - - - {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)} - -
+ {!isMobile ? ( +
+ + {t("hall.table.draftTotal")} + + + {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)} + +
+ ) : null} {sealedBetUi ? (

@@ -1448,32 +1796,80 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }

) : null} - + {!isMobile ? ( + + ) : null} + {isMobile ? ( +
+
+
+
+

+ {t("hall.table.draftTotal")} +

+

+ {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)} +

+
+ +
+
+
+ ) : null} + { @@ -1524,7 +1920,10 @@ const DraftRowItem = memo(function DraftRowItem({ removeRow, t, isMobile, - removable + removable, + indexColClass, + numberColClass, + stickyNumberLeftClass, }: { row: DraftRow; index: number; @@ -1542,6 +1941,9 @@ const DraftRowItem = memo(function DraftRowItem({ t: HallTranslate; isMobile: boolean; removable: boolean; + indexColClass: string; + numberColClass: string; + stickyNumberLeftClass: string; }) { return ( @@ -1560,15 +1963,18 @@ const DraftRowItem = memo(function DraftRowItem({ setActiveRowId(row.id)} onClick={() => setActiveRowId(row.id)} diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index 3fbb852..9b7ee61 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -15,6 +15,7 @@ import { } from "@/features/wallet/wallet-transfer-dialogs"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useApiQuery } from "@/hooks/use-api-query"; +import { useIsMobile } from "@/hooks/use-mobile"; import { formatMinorAsCurrency } from "@/lib/money"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; @@ -30,6 +31,7 @@ export function HallWalletStrip() { const { activeCurrency } = useActivePlayerCurrency(); const { mutate } = useSWRConfig(); const degradedWalletPollRef = useRef(null); + const isMobile = useIsMobile(); const currency = activeCurrency; const isDegraded = mode === "polling" || mode === "offline"; @@ -75,7 +77,7 @@ export function HallWalletStrip() { return (
-
-
- +
+
+
-

+

{isCreditPlayer ? t("wallet.creditAvailable", { defaultValue: "可用信用" }) : t("wallet.balance")}

{loading ? ( - + ) : ( )} {isCreditPlayer && !loading && balance ? ( -

+

{t("wallet.creditSummary", { defaultValue: "授信 {{limit}} · 已用 {{used}}", limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency), @@ -128,12 +138,15 @@ export function HallWalletStrip() {

{isCreditPlayer ? null : ( -
+
{ await mutate(BALANCE_KEY(currency)); }} diff --git a/src/features/orders/group-ticket-items.ts b/src/features/orders/group-ticket-items.ts index 871c17a..6cf55b4 100644 --- a/src/features/orders/group-ticket-items.ts +++ b/src/features/orders/group-ticket-items.ts @@ -83,6 +83,8 @@ export function sortTicketGroupItems(items: TicketItemListRow[]): TicketItemList return [...items].sort((a, b) => { const byPlay = a.play_code.localeCompare(b.play_code); if (byPlay !== 0) return byPlay; + const byProvider = (a.provider_code ?? "").localeCompare(b.provider_code ?? ""); + if (byProvider !== 0) return byProvider; return (a.original_number ?? "").localeCompare(b.original_number ?? ""); }); } @@ -135,4 +137,4 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[] export function ticketDetailHref(ticketNo: string): string { return `/orders/${encodeURIComponent(ticketNo)}`; -} \ No newline at end of file +} diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index 48a2bce..546e4b3 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -76,6 +76,10 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & { }; }; +function providerLabel(providerName?: string | null, providerCode?: string | null): string { + return providerName || providerCode || "-"; +} + /** 界面文档 §4.8 注单详情 */ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { const { t } = useTranslation("player"); @@ -282,6 +286,10 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { {playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}

+ + {providerLabel(row.provider_name, row.provider_code)} + + {" · "} {t("orders.deduction")}{" "} {formatMinorAsCurrency(row.actual_deduct_amount, lineCur)} @@ -356,6 +364,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { {playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)

+
+ + {t("hall.providers.title", { defaultValue: "开注商" })} + + + {providerLabel(data.provider_name, data.provider_code)} + +
{t("orders.amount")} diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 003c0d2..4e4e159 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -47,6 +47,10 @@ const STATUS_OPTIONS = [ "refunded", ] as const; +function providerLabel(providerName?: string | null, providerCode?: string | null): string { + return providerName || providerCode || "-"; +} + export function TicketOrdersListScreen() { const router = useRouter(); const searchParams = useSearchParams(); @@ -528,6 +532,10 @@ export function TicketOrdersListScreen() { {playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}

+ + {providerLabel(row.provider_name, row.provider_code)} + + {" · "} {t("orders.deduction")}{" "} {formatMinorAsCurrency(row.actual_deduct_amount, lineCur)} @@ -573,4 +581,3 @@ export function TicketOrdersListScreen() { ); } - diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 9f101ee..86af25e 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -255,7 +255,10 @@ "quickFill": { "title": "Quick fill", "description": "Fill the current row with favorite or recent numbers in one tap.", + "batchTitle": "Quick amount", "clearAll": "Clear all", + "clearRow": "Clear row amounts", + "copyPrev": "Copy prev row", "favorite": "Favorite number", "unfavorite": "Remove favorite", "favorites": "Favorites", @@ -264,6 +267,13 @@ "history": "Recent 20 numbers", "emptyHistory": "No recent numbers" }, + "mobile": { + "availableBalance": "Available {{amount}}", + "quickFillSummary": "{{favorites}} favorites, {{history}} recent", + "collapseQuickFill": "Collapse quick fill", + "expandQuickFill": "Expand quick fill", + "noProvidersSelected": "None selected" + }, "preview": { "title": "Confirm bet", "description": "Check the number, play type, and actual deduction. After confirmation, the lottery wallet will be debited and the ticket cannot be cancelled.", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 832536f..8bc30be 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -255,7 +255,10 @@ "quickFill": { "title": "छिटो भर्ने", "description": "मनपर्ने वा पछिल्ला नम्बरहरू एक ट्यापमा हालको पंक्तिमा भर्नुहोस्।", + "batchTitle": "छिटो रकम", "clearAll": "सबै हटाउनुहोस्", + "clearRow": "यो पङ्क्तिको रकम हटाउनुहोस्", + "copyPrev": "अघिल्लो पङ्क्ति कपी गर्नुहोस्", "favorite": "नम्बर मनपर्नेमा राख्नुहोस्", "unfavorite": "मनपर्नेबाट हटाउनुहोस्", "favorites": "मनपर्ने", @@ -264,6 +267,13 @@ "history": "पछिल्ला 20 नम्बर", "emptyHistory": "इतिहास नम्बर छैन" }, + "mobile": { + "availableBalance": "उपलब्ध मौज्दात {{amount}}", + "quickFillSummary": "{{favorites}} मनपर्ने, {{history}} इतिहास", + "collapseQuickFill": "छिटो भर्ने खुम्च्याउनुहोस्", + "expandQuickFill": "छिटो भर्ने विस्तार गर्नुहोस्", + "noProvidersSelected": "छानिएको छैन" + }, "preview": { "title": "बेट पुष्टि गर्नुहोस्", "description": "नम्बर, प्ले प्रकार र वास्तविक कट्टा जाँच गर्नुहोस्। पुष्टि गरेपछि वालेटबाट रकम कट्टा हुनेछ र टिकट रद्द गर्न सकिँदैन।", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 95cbf96..d2e0a86 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -254,7 +254,10 @@ "quickFill": { "title": "快速填单", "description": "收藏号码、最近号码可一键填入当前行。", + "batchTitle": "快捷金额", "clearAll": "批量清空", + "clearRow": "清空本行金额", + "copyPrev": "复制上一行", "favorite": "收藏号码", "unfavorite": "取消收藏", "favorites": "收藏", @@ -263,6 +266,13 @@ "history": "最近 20 个历史号码", "emptyHistory": "暂无历史号码" }, + "mobile": { + "availableBalance": "可用余额 {{amount}}", + "quickFillSummary": "收藏 {{favorites}} 个,历史 {{history}} 个", + "collapseQuickFill": "收起快捷填单", + "expandQuickFill": "展开快捷填单", + "noProvidersSelected": "未选择" + }, "preview": { "title": "确认下注", "description": "请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单。", diff --git a/src/lib/player-spacing.ts b/src/lib/player-spacing.ts index 6079d3e..3d4b189 100644 --- a/src/lib/player-spacing.ts +++ b/src/lib/player-spacing.ts @@ -1,8 +1,8 @@ /** 主滚动区外边距(灰底区域与顶栏/底栏留白) */ -export const playerMainInset = "px-3 pt-3 pb-3 lg:px-8 lg:pt-6 lg:pb-8"; +export const playerMainInset = "px-2 pt-2 pb-2 lg:px-6 lg:pt-4 lg:pb-6"; /** 页内白卡片内边距 */ -export const playerPageShellPadding = "px-3 pt-3 pb-6 lg:px-8 lg:pt-8 lg:pb-8"; +export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-6 lg:pt-6 lg:pb-6"; /** 大厅等直接写在 section 上的内边距 */ export const playerPageInset = playerPageShellPadding; diff --git a/src/types/api/bet-provider.ts b/src/types/api/bet-provider.ts new file mode 100644 index 0000000..d2ff8db --- /dev/null +++ b/src/types/api/bet-provider.ts @@ -0,0 +1,10 @@ +export type BetProviderRow = { + code: string; + name: string; + sort_order: number; + is_default: boolean; +}; + +export type BetProvidersData = { + items: BetProviderRow[]; +}; diff --git a/src/types/api/ticket-items.ts b/src/types/api/ticket-items.ts index 9cabb0d..bc471dc 100644 --- a/src/types/api/ticket-items.ts +++ b/src/types/api/ticket-items.ts @@ -6,6 +6,8 @@ export type TicketItemListRow = { order_status?: string | null; draw_no: string | null | undefined; currency_code: string | null | undefined; + provider_code?: string | null; + provider_name?: string | null; play_code: string; original_number: string | null; total_bet_amount: number; @@ -38,6 +40,8 @@ export type TicketItemDetailPayload = { order_status?: string | null; draw_no: string | null | undefined; currency_code: string | null | undefined; + provider_code?: string | null; + provider_name?: string | null; play_code: string; dimension: number | null; digit_slot: number | null; diff --git a/src/types/api/ticket.ts b/src/types/api/ticket.ts index bf0f280..f9a81d5 100644 --- a/src/types/api/ticket.ts +++ b/src/types/api/ticket.ts @@ -17,6 +17,7 @@ export type TicketPreviewPayload = { draw_id: string; currency_code: string; client_trace_id?: string | null; + provider_codes?: string[]; lines: TicketLineInput[]; }; @@ -26,6 +27,8 @@ export type TicketPlacePayload = TicketPreviewPayload & { export type TicketPreviewLine = { client_line_no: number; + provider_code?: string; + provider_name?: string; number: string; play_code: string; normalized_number: string; @@ -61,6 +64,8 @@ export type TicketPreviewData = { export type TicketPlaceItem = { ticket_no: string; + provider_code?: string; + provider_name?: string; play_code: string; number: string; total_bet_amount: number;