diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 78365d2..3b2bda3 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -16,14 +16,17 @@ const path = require("path"); /** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery.yourdomain.com */ -const APP_CWD = path.resolve(__dirname); +const DEPLOY_ROOT = path.resolve(__dirname); +// deploy.sh uploads the standalone bundle to app/. Keep runtime and static assets +// on the same build so Next.js chunk references cannot become mismatched. +const APP_CWD = path.join(DEPLOY_ROOT, "app"); module.exports = { apps: [ { name: "lotteryfront", cwd: APP_CWD, - script: ".next/standalone/server.js", + script: "server.js", interpreter: "node", exec_mode: "fork", instances: 1, @@ -32,8 +35,8 @@ module.exports = { max_memory_restart: "1G", time: true, merge_logs: true, - out_file: path.join(APP_CWD, "logs/pm2-out.log"), - error_file: path.join(APP_CWD, "logs/pm2-error.log"), + out_file: path.join(DEPLOY_ROOT, "logs/pm2-out.log"), + error_file: path.join(DEPLOY_ROOT, "logs/pm2-error.log"), env: { NODE_ENV: "production", @@ -42,7 +45,7 @@ module.exports = { }, // LOTTERY_API_UPSTREAM、NEXT_PUBLIC_* 写在 .env;勿在此硬编码 LOTTERY_API_UPSTREAM - env_file: path.join(APP_CWD, ".env"), + env_file: path.join(DEPLOY_ROOT, ".env"), }, ], }; diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fe..d03a18b 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/features/hall/hall-bet-rules.ts b/src/features/hall/hall-bet-rules.ts index 08aba12..a5e991f 100644 --- a/src/features/hall/hall-bet-rules.ts +++ b/src/features/hall/hall-bet-rules.ts @@ -7,8 +7,8 @@ export type TicketNumberSpec = { mode: "digits" | "roll"; }; -const PLAY_3D = new Set(["pos_3a", "pos_3b", "pos_3c", "pos_3abc"]); -const PLAY_2D = new Set(["pos_2a", "pos_2b", "pos_2c", "pos_2abc"]); +const PLAY_3D = new Set(["pos_3a", "pos_3b", "pos_3c", "pos_3d", "pos_3e", "pos_3lower"]); +const PLAY_2D = new Set(["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"]); export function ticketNumberSpec(playCode: string): TicketNumberSpec { if (playCode === "roll") { diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 8f7bd9e..d75469c 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -10,6 +10,7 @@ import { getPlayEffective } from "@/api/play"; import { getWalletBalance } from "@/api/wallet"; import { postTicketPlace, postTicketPreview } from "@/api/ticket"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; @@ -29,7 +30,11 @@ import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getLotteryEcho } from "@/lib/lottery-echo"; -import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; +import { + formatMinorAmount, + formatMinorAsCurrency, + parseDecimalInputToMinor, +} from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; import { playerViewportFixedBarClass } from "@/lib/player-viewport"; import { @@ -48,10 +53,18 @@ import type { BetProviderRow } from "@/types/api/bet-provider"; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; type PlayHallCategory = Exclude; +const TRADITIONAL_PLAY_CODES: Record = { + D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"], + D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"], + D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"], +}; + type DraftRow = { id: string; number: string; amounts: Record; + providerCodes: string[]; + selectionType: "straight" | "reverse" | "full_cover" | "full_play" | "half_play"; }; type DraftEntry = { @@ -116,10 +129,22 @@ 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 }, + { code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true }, + { code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false }, + { code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false }, ]; +const DEFAULT_PROVIDER_CODE = "SG"; +const PROVIDER_COLUMN_TONES = [ + "bg-[#fff875] text-slate-950", + "bg-[#b7d9ff] text-slate-950", + "bg-[#ffc7ca] text-slate-950", + "bg-[#d9d8ff] text-slate-950", + "bg-[#a8f8a4] text-slate-950", +] as const; + +function providerColumnTone(index: number): string { + return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950"; +} const categoryTabs: { value: PlayHallCategory; label: string }[] = [ { value: "D4", label: "4D" }, @@ -161,7 +186,7 @@ function newDraftRow(): DraftRow { typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `row-${Date.now()}-${Math.random().toString(36).slice(2)}`; - return { id, number: "", amounts: {} }; + return { id, number: "", amounts: {}, providerCodes: [DEFAULT_PROVIDER_CODE], selectionType: "straight" }; } function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { @@ -265,6 +290,7 @@ function lineForPlay( displayNumber: string, amountMinor: number, digitSlot?: number, + selectionType: DraftRow["selectionType"] = "straight", ): TicketLineInput | null { const number = normalizeNumberForPlay(displayNumber, play.play_code); if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) { @@ -279,6 +305,7 @@ function lineForPlay( number, play_code: play.play_code, amount: amountMinor, + selection_type: selectionType, }; if (playNeedsDimension(play.play_code)) { @@ -470,10 +497,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [resultData, setResultData] = useState(null); const [activeCategory, setActiveCategory] = useState("D4"); const [betProviders, setBetProviders] = useState(FALLBACK_BET_PROVIDERS); - const [selectedProviderCodes, setSelectedProviderCodes] = useState([]); + const [syncAmountColumns, setSyncAmountColumns] = 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()); @@ -497,7 +523,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const catalogSeqRef = useRef(0); const walletSeqRef = useRef(0); const selectedCatalogProviderCode = - selectedProviderCodes[0] ?? betProviders.find((provider) => provider.is_default)?.code ?? betProviders[0]?.code; @@ -543,9 +568,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } if (cancelled) return; const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS; setBetProviders(items); - setSelectedProviderCodes((current) => { + setRows((current) => { const available = new Set(items.map((item) => item.code)); - return current.filter((code) => available.has(code)); + const fallback = items.find((item) => item.is_default)?.code ?? items[0]?.code; + return current.map((row) => { + const selected = row.providerCodes.filter((code) => available.has(code)); + const wasInitialDefault = row.providerCodes.length === 1 && row.providerCodes[0] === DEFAULT_PROVIDER_CODE; + return selected.length === 0 && wasInitialDefault && fallback + ? { ...row, providerCodes: [fallback] } + : { ...row, providerCodes: selected }; + }); }); }) .catch(() => { @@ -586,7 +618,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, [catalogState]); const activeCategoryPlays = useMemo( - () => openPlays.filter((play) => playCategory(play.play_code) === activeCategory), + () => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)), [activeCategory, openPlays], ); @@ -613,22 +645,34 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]"; const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]"; + const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]"; const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]"; const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]"; + const desktopSelectionTypeColClass = "w-[4.75rem] min-w-[4.75rem]"; const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass; const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass; + const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass; + const showSelectionTypeColumn = activeCategory !== "D2"; 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 providerColClass = !isMobile + ? "w-[2.85rem] min-w-[2.85rem]" + : "w-[2.7rem] min-w-[2.7rem]"; + const rowTotalColClass = !isMobile + ? "w-[5.5rem] min-w-[5.5rem]" + : "w-[5rem] min-w-[5rem]"; - const tableMinWidthPx = useMemo(() => { + const tableWidthPx = useMemo(() => { const indexCol = !isMobile ? 34 : 28; const numberCol = !isMobile ? 88 : 68; + const selectionTypeCol = !isMobile ? 76 : 64; + const providerCol = !isMobile ? 46 : 43; + const rowTotalCol = !isMobile ? 88 : 80; const amountCol = !isMobile ? 62 : 64; - const deleteCol = 32; - return indexCol + numberCol + playColumns.length * amountCol + deleteCol; - }, [playColumns.length, isMobile]); + return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol; + }, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]); const showWideTableHint = isMobile && playColumns.length > 8; @@ -668,28 +712,45 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setActiveRowId(id); }, [activeCategory]); - const updateAmount = useCallback((rowId: string, playCode: string, value: string) => { - setRows((current) => - current.map((row) => - row.id === rowId - ? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } } - : row, - ), - ); - setActiveRowId(rowId); + const updateRowSelectionType = useCallback((id: string, selectionType: DraftRow["selectionType"]) => { + setRows((current) => current.map((row) => row.id === id ? { ...row, selectionType } : row)); }, []); - const removeRow = useCallback((id: string) => { - setRows((current) => { - if (current.length <= 1) return current; - return current.filter((row) => row.id !== id); - }); - setActiveRowId((prev) => { - if (prev === id) return null; - return prev; - }); + const updateAllSelectionTypes = useCallback((selectionType: DraftRow["selectionType"]) => { + setRows((current) => current.map((row) => ({ ...row, selectionType }))); }, []); + const updateAmount = useCallback((rowId: string, playCode: string, value: string) => { + const amount = sanitizeAmount(value); + const column = allPlayColumns.find((item) => item.key === playCode); + const syncColumn = syncAmountColumns[playCode] === true && column !== undefined; + + setRows((current) => current.map((row) => { + if (!syncColumn) { + return row.id === rowId + ? { ...row, amounts: { ...row.amounts, [playCode]: amount } } + : row; + } + + const validNumber = 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 (!validNumber || status === "sold_out" || (column.play.config !== null && !column.play.config.is_enabled)) { + return row; + } + + return { ...row, amounts: { ...row.amounts, [playCode]: amount } }; + })); + setActiveRowId(rowId); + }, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]); + const clearAllRows = () => { if (tableDisabled) return; const next = [newDraftRow()]; @@ -786,6 +847,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ...row, number: previousRow.number, amounts: nextAmounts, + providerCodes: previousRow.providerCodes, }; }), ); @@ -826,14 +888,23 @@ 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 toggleRowProvider = useCallback((rowId: string, code: string) => { + setRows((current) => current.map((row) => { + if (row.id !== rowId) return row; + return row.providerCodes.includes(code) + ? { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) } + : { ...row, providerCodes: [...row.providerCodes, code] }; + })); + }, []); + + const toggleProviderColumn = useCallback((code: string, checked: boolean) => { + setRows((current) => current.map((row) => { + const hasCode = row.providerCodes.includes(code); + if (checked && !hasCode) return { ...row, providerCodes: [...row.providerCodes, code] }; + if (!checked && hasCode) return { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) }; + return row; + })); + }, []); const hasAmountsForPlay = useCallback( (playCode: string): boolean => @@ -958,6 +1029,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return issues; }, [allPlayColumns, currencyCode, rows]); + const providerMissingRow = useCallback((): number | null => { + for (const [index, row] of rows.entries()) { + const hasAmount = Object.values(row.amounts).some((value) => { + const amount = parseDecimalInputToMinor(value, currencyCode); + return amount !== null && amount > 0; + }); + if (hasAmount && row.providerCodes.length === 0) return index + 1; + } + return null; + }, [currencyCode, rows]); + const formatDraftLineIssue = useCallback( (issue: DraftLineIssue): string => { const label = playLabel(issue.playCode, t); @@ -972,8 +1054,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } allPlayColumns.forEach((column) => { const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode); if (amount === null || amount <= 0) return; - const line = lineForPlay(column.play, row.number, amount, column.digitSlot); + const line = lineForPlay(column.play, row.number, amount, column.digitSlot, row.selectionType); if (!line) return; + line.provider_codes = row.providerCodes; entries.push({ rowId: row.id, rowNo: rowIndex + 1, @@ -990,25 +1073,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, [allPlayColumns, currencyCode, rows]); const draftEntries = collectEntries(); - const effectiveProviderCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1; const draftSummary = useMemo(() => { - const base = draftEntries.reduce( + return draftEntries.reduce( (acc, entry) => { const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate); const rebate = Math.round(entry.amountMinor * rebateRate); - acc.bet += entry.amountMinor; - acc.rebate += rebate; - acc.actual += Math.max(0, entry.amountMinor - rebate); + const providerCount = entry.line.provider_codes?.length ?? 0; + acc.bet += entry.amountMinor * providerCount; + acc.rebate += rebate * providerCount; + acc.actual += Math.max(0, entry.amountMinor - rebate) * providerCount; return acc; }, { bet: 0, rebate: 0, actual: 0 }, ); - return { - bet: base.bet * effectiveProviderCount, - rebate: base.rebate * effectiveProviderCount, - actual: base.actual * effectiveProviderCount, - }; - }, [draftEntries, effectiveProviderCount]); + }, [draftEntries]); useEffect(() => { const id = window.setTimeout(() => { @@ -1068,6 +1146,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } } const lineIssues = collectDraftLineIssues(); + const missingProviderAt = providerMissingRow(); + if (missingProviderAt !== null) { + toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` })); + return; + } if (lineIssues.length > 0) { toast.error(formatDraftLineIssue(lineIssues[0])); return; @@ -1090,7 +1173,6 @@ 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); @@ -1131,6 +1213,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } } const lineIssues = collectDraftLineIssues(); + const missingProviderAt = providerMissingRow(); + if (missingProviderAt !== null) { + toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` })); + return; + } if (lineIssues.length > 0) { toast.error(formatDraftLineIssue(lineIssues[0])); return; @@ -1158,7 +1245,6 @@ 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, }); @@ -1258,27 +1344,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const submitActualMinor = previewData?.summary.total_actual_deduct ?? debouncedSummary.actual; + const selectedProviderCount = new Set(rows.flatMap((row) => row.providerCodes)).size; const canSubmit = - !tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor; + !tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && 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", { - count: providerSummaryCount, - }) - : implicitDefaultProvider - ? t("hall.providers.platformDefaultSummary", { - provider: implicitDefaultProvider.name, - }) - : t("hall.providers.platformDefaultSummaryNoProvider"); - return ( <>
@@ -1515,107 +1585,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } -
-
-
- - {t("hall.providers.title")} - - {!isMobile ? betProviders.map((provider) => { - const checked = selectedProviderCodes.includes(provider.code); - - return ( - - ); - }) : ( - - {selectedProviderNames.length > 0 - ? selectedProviderNames.join(" / ") - : implicitDefaultProvider - ? t("hall.providers.platformDefault", { provider: implicitDefaultProvider.name }) - : t("hall.providers.platformDefaultNoProvider")} - - )} -
-
-

{providerSummaryText}

- {isMobile ? ( - - ) : null} -
-
- {isMobile && providersExpanded ? ( -
- {betProviders.map((provider) => { - const checked = selectedProviderCodes.includes(provider.code); - - return ( - - ); - })} -
- ) : null} -
-
{categoryTabs.map((tab) => { const hasPlays = openPlays.some( @@ -1689,7 +1658,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } - {formatMinorAsCurrency(submitActualMinor, currencyCode)} + {formatMinorAmount(submitActualMinor)} {allPlayColumns.map((column) => { const colTotalMinor = rows.reduce((sum, row) => { @@ -1707,7 +1676,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return ( - {formatMinorAsCurrency(colTotalMinor, currencyCode)} + {formatMinorAmount(colTotalMinor)} ); })} @@ -1716,10 +1685,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
-
+
@@ -1751,12 +1720,65 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } t, )} - - {t("hall.table.amountPlaceholder")} - + setSyncAmountColumns((current) => ({ + ...current, + [column.key]: checked === true, + }))} + aria-label={`${playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t)} 同步本列金额`} + title="同步本列金额" + className="mx-auto mt-1 size-3.5" + /> ))} - + ) : null} + {betProviders.map((provider, providerIndex) => { + const checked = rows.length > 0 && rows.every((row) => row.providerCodes.includes(provider.code)); + return ( + + ); + })} + @@ -1770,24 +1792,51 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } numberPlaceholder={numberPlaceholder} activeCategory={activeCategory} numberMaxChars={numberMaxChars} + betProviders={betProviders} playColumns={playColumns} alertRows={alertRows} liveSoldOutNumbers={liveSoldOutNumbers} liveWarningNumbers={liveWarningNumbers} updateRowNumber={updateRowNumber} + updateRowSelectionType={updateRowSelectionType} updateAmount={updateAmount} + toggleRowProvider={toggleRowProvider} setActiveRowId={setActiveRowId} - removeRow={removeRow} t={t} isMobile={isMobile} - removable={rows.length > 1} indexColClass={indexColClass} numberColClass={numberColClass} stickyNumberLeftClass={stickyNumberLeftClass} + selectionTypeColClass={selectionTypeColClass} + showSelectionTypeColumn={showSelectionTypeColumn} + providerColClass={providerColClass} + rowTotalColClass={rowTotalColClass} + currencyCode={currencyCode} /> ))}
+ {showSelectionTypeColumn ? ( + + {t("hall.table.selectionType", { defaultValue: "Type" })} + + + {provider.short_code} + toggleProviderColumn(provider.code, next === true)} + aria-label={`全选 ${provider.name}`} + className="mx-auto mt-1 border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800" + /> + + {t("hall.table.rowTotal", { defaultValue: "Total" })} +
+ {!isMobile ? ( + + ) : null}
@@ -1925,20 +1974,26 @@ const DraftRowItem = memo(function DraftRowItem({ numberPlaceholder, activeCategory, numberMaxChars, + betProviders, playColumns, alertRows, liveSoldOutNumbers, liveWarningNumbers, updateRowNumber, + updateRowSelectionType, updateAmount, + toggleRowProvider, setActiveRowId, - removeRow, t, isMobile, - removable, indexColClass, numberColClass, stickyNumberLeftClass, + selectionTypeColClass, + showSelectionTypeColumn, + providerColClass, + rowTotalColClass, + currencyCode, }: { row: DraftRow; index: number; @@ -1947,22 +2002,32 @@ const DraftRowItem = memo(function DraftRowItem({ numberPlaceholder: string; activeCategory: PlayHallCategory; numberMaxChars: number; + betProviders: BetProviderRow[]; playColumns: PlayColumn[]; alertRows: DrawCurrentRiskPoolAlert[]; liveSoldOutNumbers: Set; liveWarningNumbers: Set; updateRowNumber: (id: string, value: string) => void; + updateRowSelectionType: (id: string, value: DraftRow["selectionType"]) => void; updateAmount: (rowId: string, playCode: string, value: string) => void; + toggleRowProvider: (rowId: string, code: string) => void; setActiveRowId: (id: string) => void; - removeRow: (id: string) => void; t: HallTranslate; isMobile: boolean; - removable: boolean; indexColClass: string; numberColClass: string; stickyNumberLeftClass: string; + selectionTypeColClass: string; + showSelectionTypeColumn: boolean; + providerColClass: string; + rowTotalColClass: string; + currencyCode: string; }) { const displayNumber = sanitizeNumber(row.number, activeCategory); + const rowTotalMinor = playColumns.reduce((total, column) => { + if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) return total; + return total + (parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0); + }, 0) * row.providerCodes.length; return ( - ); })} - - + toggleRowProvider(row.id, provider.code)} + aria-label={`${provider.name} ${index + 1}`} + className="mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800" + /> + + ))} + + {formatMinorAmount(rowTotalMinor)} ); diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 851cff1..4c2c74d 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -9,23 +9,18 @@ import { PLAYER_DESKTOP_BREAKPOINT_PX } from "@/lib/player-viewport"; * 桌面端启用侧栏 + 顶栏布局(见 UI 设计稿)。 */ export function useIsMobile(): boolean { - const [isMobile, setIsMobile] = useState(() => { - if (typeof window === "undefined") return true; - return window.matchMedia( - `(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`, - ).matches; - }); + // The server cannot know the viewport. Keep the first browser render aligned + // with SSR, then read matchMedia after hydration to avoid className mismatches. + const [isMobile, setIsMobile] = useState(true); useEffect(() => { const query = `(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`; const mq = window.matchMedia(query); const sync = () => setIsMobile(mq.matches); - // sync() is still needed for the rare case where the lazy initializer - // was correct but the viewport changed between SSR and hydration. sync(); mq.addEventListener("change", sync); return () => mq.removeEventListener("change", sync); }, []); return isMobile; -} \ No newline at end of file +} diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 7705dc4..e9b4666 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -233,6 +233,8 @@ "table": { "no": "No.", "number": "Number", + "selectionType": "Type", + "rowTotal": "Row Total", "stake": "Stake Amount", "rebate": "Commission / Rebate", "actual": "Actual Deduction", @@ -242,6 +244,9 @@ "totalBet": "Total", "totalRebate": "Rebate", "actualTotal": "Estimated Deduction", + "orderSummary": "Order Summary", + "lineCount": "Lines", + "providerCount": "Providers", "sealedHint": "Closed: this table is locked. Please wait for the next issue.", "previewing": "Previewing...", "submitBet": "Submit Bet", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 199cdd0..ed4247c 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -232,6 +232,8 @@ "table": { "no": "序号", "number": "号码", + "selectionType": "种类", + "rowTotal": "本行合计", "stake": "下注金额", "rebate": "佣金 / 回水", "actual": "实扣金额", @@ -241,6 +243,9 @@ "totalBet": "合计", "totalRebate": "回水", "actualTotal": "预计扣款", + "orderSummary": "本单概览", + "lineCount": "注项", + "providerCount": "开注商", "sealedHint": "已封盘:当前表格不可编辑,请等待下一期。", "previewing": "预览中...", "submitBet": "提交下注", diff --git a/src/lib/money.ts b/src/lib/money.ts index b6e2a4f..4898104 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -52,13 +52,12 @@ function formatMinorWithDisplaySettings( return `${negative ? "-" : ""}${integerPartFormatted}${decimalSeparator}${fractionPadded}`; } -export function formatMinorAsCurrency( +export function formatMinorAmount( minor: number | string, - currencyCode: string, decimalPlaces?: number, ): string { const n = typeof minor === "string" ? Number(minor) : minor; - if (!Number.isFinite(n)) return `${currencyCode} —`; + if (!Number.isFinite(n)) return "—"; const format = getCurrencyDisplayFormat(); const resolvedDisplayDecimals = @@ -68,10 +67,18 @@ export function formatMinorAsCurrency( ? Math.min(12, Math.trunc(decimalPlaces)) : format.displayDecimals; - const amount = formatMinorWithDisplaySettings(n, { + return formatMinorWithDisplaySettings(n, { ...format, displayDecimals: resolvedDisplayDecimals, }); +} + +export function formatMinorAsCurrency( + minor: number | string, + currencyCode: string, + decimalPlaces?: number, +): string { + const amount = formatMinorAmount(minor, decimalPlaces); return `${currencyCode} ${amount}`; } diff --git a/src/lib/play-labels.ts b/src/lib/play-labels.ts index 059418d..9d45394 100644 --- a/src/lib/play-labels.ts +++ b/src/lib/play-labels.ts @@ -15,6 +15,17 @@ const LABELS: Record = { pos_2b: "2B", pos_2c: "2C", pos_2abc: "2ABC", + four_any: "4N", + four_top: "A", + four_lower: "C", + pos_3d: "3D", + pos_3e: "3E", + pos_3lower: "C", + pos_2d: "2D", + pos_2e: "2E", + pos_2any: "2N", + five_d: "5D", + six_d: "6D", straight: "Straight", box: "Box", ibox: "iBox", diff --git a/src/types/api/bet-provider.ts b/src/types/api/bet-provider.ts index d2ff8db..2303e31 100644 --- a/src/types/api/bet-provider.ts +++ b/src/types/api/bet-provider.ts @@ -1,6 +1,7 @@ export type BetProviderRow = { code: string; name: string; + short_code: string; sort_order: number; is_default: boolean; }; diff --git a/src/types/api/ticket.ts b/src/types/api/ticket.ts index f9a81d5..f68f08e 100644 --- a/src/types/api/ticket.ts +++ b/src/types/api/ticket.ts @@ -3,8 +3,10 @@ export type TicketLineInput = { number: string; play_code: string; amount: number; + provider_codes?: string[]; digit_slot?: number; dimension?: "D2" | "D3" | "D4"; + selection_type?: "straight" | "reverse" | "full_cover" | "full_play" | "half_play"; }; export type TicketConfigVersions = {