// 赛马竞猜主面板 Alpine 组件,负责当前场次、下注、跑马进度和结算展示。 const DEFAULT_HORSE_RACE_CURRENT_URL = "/horse-race/current"; const DEFAULT_HORSE_RACE_BET_URL = "/horse-race/bet"; const DEFAULT_HORSE_RACE_HISTORY_URL = "/horse-race/history"; const DEFAULT_HORSE_RACE_MIN_BET = 100; const DEFAULT_HORSE_RACE_MAX_BET = 100000; const DEFAULT_HORSE_RACE_TOTAL_SECONDS = 90; const SETTLED_AUTO_CLOSE_SECONDS = 10; /** * 读取赛马接口地址,优先使用 Blade 写入的命名路由。 * * @returns {{current: string, bet: string, history: string}} */ function horseRaceUrls() { const panel = document.getElementById("horse-race-panel"); return { current: panel?.dataset.horseRaceCurrentUrl || DEFAULT_HORSE_RACE_CURRENT_URL, bet: panel?.dataset.horseRaceBetUrl || DEFAULT_HORSE_RACE_BET_URL, history: panel?.dataset.horseRaceHistoryUrl || DEFAULT_HORSE_RACE_HISTORY_URL, }; } /** * 读取 CSRF Token。 * * @returns {string} */ function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; } /** * 生成快捷下注金额,保持固定 5 档按钮稳定渲染。 * * @param {number} min 最小下注额 * @param {number} max 最大下注额 * @returns {number[]} */ function buildQuickBetAmounts(min, max) { const candidates = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; const steps = candidates .map((multiplier) => min * multiplier) .filter((value) => value >= min && value < max); let sortedSteps = [...new Set([...steps, max])].sort((a, b) => a - b); if (sortedSteps.length >= 5) { return [ sortedSteps[0], sortedSteps[Math.floor((sortedSteps.length - 1) * 0.25)], sortedSteps[Math.floor((sortedSteps.length - 1) * 0.5)], sortedSteps[Math.floor((sortedSteps.length - 1) * 0.75)], sortedSteps[sortedSteps.length - 1], ]; } // 下注范围很小时补齐中间档位,避免按钮数量变化造成布局跳动。 while (sortedSteps.length < 5) { let maxGap = 0; let insertIndex = -1; for (let index = 0; index < sortedSteps.length - 1; index += 1) { if (sortedSteps[index + 1] - sortedSteps[index] > maxGap) { maxGap = sortedSteps[index + 1] - sortedSteps[index]; insertIndex = index; } } if (insertIndex === -1) { break; } let newValue = Math.floor((sortedSteps[insertIndex] + sortedSteps[insertIndex + 1]) / 2); if (newValue > 100) { newValue = Math.floor(newValue / 10) * 10; } sortedSteps.splice(insertIndex + 1, 0, newValue); } return sortedSteps; } /** * 读取赛马接口 JSON;若后端返回 HTML/警告页,则抛出可诊断错误。 * * @param {string} url 请求地址 * @param {RequestInit} options fetch 选项 * @returns {Promise} */ export async function requestHorseRaceJson(url, options = {}) { const requestUrl = new URL(url, window.location.origin); requestUrl.searchParams.set("_ts", Date.now().toString()); const response = await fetch(requestUrl.toString(), { cache: "no-store", headers: { Accept: "application/json", "Cache-Control": "no-cache", Pragma: "no-cache", ...(options.headers || {}), }, ...options, }); const rawText = await response.text(); try { return JSON.parse(rawText); } catch (error) { const preview = rawText.slice(0, 160).replace(/\s+/g, " ").trim(); throw new Error(`赛马接口未返回 JSON(${response.status}): ${preview}`); } } /** * 创建赛马竞猜主面板 Alpine 组件。 * * @returns {Record} */ export function horseRacePanel() { return { show: false, phase: "idle", raceId: null, totalSeconds: DEFAULT_HORSE_RACE_TOTAL_SECONDS, countdown: DEFAULT_HORSE_RACE_TOTAL_SECONDS, countdownTimer: null, settledCountdown: SETTLED_AUTO_CLOSE_SECONDS, settledTimer: null, horses: [], positions: {}, leaderId: null, totalPool: 0, myBet: false, myBetHorseId: null, myBetHorseName: "", myBetAmount: 0, selectedHorse: null, betAmount: DEFAULT_HORSE_RACE_MIN_BET, minBet: DEFAULT_HORSE_RACE_MIN_BET, maxBet: DEFAULT_HORSE_RACE_MAX_BET, submitting: false, winnerName: "", winnerEmoji: "", myWon: false, myPayout: 0, history: [], /** * 兼容事件模块调用的 JSON 请求入口。 * * @param {string} url 请求地址 * @param {RequestInit} options fetch 选项 * @returns {Promise} */ requestJson(url, options = {}) { return requestHorseRaceJson(url, options); }, /** * 同步全局聊天上下文中的金币余额,供弹窗右上角与其他面板共用。 * * @param {number|string|null|undefined} jjb 最新金币余额 * @returns {void} */ syncUserGold(jjb) { if (jjb === undefined || jjb === null || !window.chatContext) { return; } window.chatContext.userJjb = Number(jjb); window.chatContext.myGold = Number(jjb); }, /** * 获取当前选中马匹的预览名称。 * * @returns {string} */ get myBetHorsePreviewName() { if (!this.selectedHorse) { return ""; } const horse = this.horses.find((item) => item.id === this.selectedHorse); return horse ? `${horse.emoji}${horse.name}` : ""; }, /** * 获取快捷下注金额数组。 * * @returns {number[]} */ get quickBetAmounts() { return buildQuickBetAmounts(this.minBet || DEFAULT_HORSE_RACE_MIN_BET, this.maxBet || DEFAULT_HORSE_RACE_MAX_BET); }, /** * 开赛:填充场次数据并开始倒计时。 * * @param {Record} data 开赛广播数据 * @returns {void} */ openRace(data) { this.phase = "betting"; this.raceId = data.race_id; this.countdown = data.bet_seconds || DEFAULT_HORSE_RACE_TOTAL_SECONDS; this.totalSeconds = this.countdown; this.horses = data.horses || []; this.totalPool = data.total_pool || 0; this.myBet = false; this.myBetHorseId = null; this.myBetHorseName = ""; this.myBetAmount = 0; this.selectedHorse = null; this.betAmount = this.minBet || DEFAULT_HORSE_RACE_MIN_BET; this.positions = {}; this.leaderId = null; this.show = true; this.loadCurrentRace(); this.startCountdown(); this.updateFab(true); }, /** * 从接口获取当前场次状态,包括本人下注、注池和赔率。 * * @returns {Promise} */ async loadCurrentRace() { try { const data = await this.requestJson(horseRaceUrls().current); this.syncUserGold(data.jjb); if (data.race) { this.applyRaceState(data.race); return; } this.resetToIdleState(); } catch (error) { // 当前场次刷新失败不弹窗,避免影响用户关闭或查看面板。 } }, /** * 启动下注倒计时。 * * @returns {void} */ startCountdown() { clearInterval(this.countdownTimer); this.countdownTimer = setInterval(() => { this.countdown -= 1; if (this.countdown <= 0) { clearInterval(this.countdownTimer); this.countdownTimer = null; this.phase = "running"; } }, 1000); }, /** * 提交本场下注。 * * @returns {Promise} */ async submitBet() { if (!this.selectedHorse || this.betAmount < DEFAULT_HORSE_RACE_MIN_BET || this.submitting) { return; } this.submitting = true; try { const response = await fetch(horseRaceUrls().bet, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-TOKEN": csrfToken(), }, body: JSON.stringify({ race_id: this.raceId, horse_id: this.selectedHorse, amount: this.betAmount, }), }); const data = await response.json(); if (data.ok) { this.myBet = true; this.myBetHorseId = data.horse_id; this.selectedHorse = data.horse_id; this.myBetAmount = data.amount; this.setMyBetHorseName(data.horse_id); await this.loadCurrentRace(); return; } window.chatDialog?.alert(data.message || "下注失败", "提示", "#ef4444"); } catch (error) { window.chatDialog?.alert("网络异常,请稍后重试。", "错误", "#ef4444"); } finally { this.submitting = false; } }, /** * 接收跑马进度更新。 * * @param {Record} data 进度广播数据 * @returns {void} */ updateProgress(data) { this.phase = "running"; this.positions = { ...this.positions, ...(data.positions || {}), }; this.leaderId = data.leader_id; }, /** * 显示结算结果。 * * @param {Record} data 结算广播数据 * @returns {void} */ showResult(data) { clearInterval(this.countdownTimer); clearInterval(this.settledTimer); this.countdownTimer = null; this.settledTimer = null; this.phase = "settled"; this.show = true; this.totalPool = data.total_pool || this.totalPool; const winner = this.horses.find((horse) => horse.id === data.winner_horse_id); this.winnerName = winner ? `${winner.emoji}${winner.name}` : data.winner_name || "未知"; this.winnerEmoji = winner ? winner.emoji : "🐎"; if (this.myBet && this.myBetHorseId === data.winner_horse_id) { this.myWon = true; this.myPayout = this.calculatePayout(data); } else { this.myWon = false; this.myPayout = 0; } this.updateFab(false); this.loadHistory(); this.startSettledCountdown(); }, /** * 加载历史记录。 * * @returns {Promise} */ async loadHistory() { try { const data = await this.requestJson(horseRaceUrls().history); this.history = (data.history || []).reverse(); } catch (error) { // 历史趋势失败不影响下注主流程。 } }, /** * 更新赛马悬浮按钮显示状态。 * * @param {boolean} visible 是否显示 * @returns {void} */ updateFab(visible) { const fab = document.getElementById("horse-race-fab"); if (fab && typeof window.Alpine?.$data === "function") { window.Alpine.$data(fab).visible = visible; } }, /** * 关闭面板;下注中保留悬浮按钮方便再次打开。 * * @returns {void} */ close() { this.show = false; clearInterval(this.settledTimer); this.settledTimer = null; if (this.phase === "betting") { this.updateFab(true); } }, /** * 从游戏大厅入口打开面板,先同步当前场次最新状态。 * * @returns {Promise} */ async openFromHall() { try { const data = await this.requestJson(horseRaceUrls().current); this.syncUserGold(data.jjb); if (data.race) { this.applyRaceState(data.race); this.syncPhaseFromRace(data.race); } else { this.resetToIdleState(); } } catch (error) { console.warn("[赛马] openFromHall 失败", error); } this.show = true; }, /** * 套用接口返回的场次基础状态。 * * @param {Record} race 当前场次 * @returns {void} */ applyRaceState(race) { this.raceId = race.id; this.horses = race.horses || this.horses; this.totalPool = race.total_pool || 0; this.minBet = race.min_bet || DEFAULT_HORSE_RACE_MIN_BET; this.maxBet = race.max_bet || DEFAULT_HORSE_RACE_MAX_BET; if (race.my_bet) { this.myBet = true; this.myBetHorseId = race.my_bet.horse_id; this.myBetAmount = race.my_bet.amount; this.selectedHorse = race.my_bet.horse_id; this.setMyBetHorseName(race.my_bet.horse_id); return; } this.myBet = false; this.myBetHorseId = null; this.myBetHorseName = ""; this.myBetAmount = 0; }, /** * 根据接口状态同步面板阶段和倒计时。 * * @param {Record} race 当前场次 * @returns {void} */ syncPhaseFromRace(race) { if (race.status === "betting" && (race.seconds_left ?? 0) > 0) { this.phase = "betting"; this.countdown = race.seconds_left; this.totalSeconds = race.seconds_left; this.startCountdown(); return; } if (race.status === "running") { this.phase = "running"; return; } this.phase = "settled"; }, /** * 重置为没有进行中场次的空状态。 * * @returns {void} */ resetToIdleState() { clearInterval(this.countdownTimer); this.countdownTimer = null; this.raceId = null; this.horses = []; this.totalPool = 0; this.myBet = false; this.myBetHorseId = null; this.myBetHorseName = ""; this.myBetAmount = 0; this.selectedHorse = null; this.phase = "idle"; this.countdown = 0; }, /** * 根据马匹 ID 回填本人下注马匹名称。 * * @param {number|string} horseId 马匹 ID * @returns {void} */ setMyBetHorseName(horseId) { const horse = this.horses.find((item) => item.id === horseId); this.myBetHorseName = horse ? `${horse.emoji}${horse.name}` : ""; }, /** * 按后端同公式还原个人赔付展示值。 * * @param {Record} data 结算广播数据 * @returns {number} */ calculatePayout(data) { const winnerPool = Number(data.winner_pool || 0); const distributablePool = Number(data.distributable_pool || 0); return winnerPool > 0 ? Math.round(distributablePool * (Number(this.myBetAmount || 0) / winnerPool)) : 0; }, /** * 启动结算弹窗自动关闭倒计时。 * * @returns {void} */ startSettledCountdown() { this.settledCountdown = SETTLED_AUTO_CLOSE_SECONDS; clearInterval(this.settledTimer); this.settledTimer = setInterval(() => { this.settledCountdown -= 1; if (this.settledCountdown <= 0) { clearInterval(this.settledTimer); this.settledTimer = null; this.close(); } }, 1000); }, }; } /** * 挂载赛马主面板全局组件名,兼容 Blade 的 x-data。 * * @returns {void} */ export function bindHorseRacePanelControls() { if (typeof window === "undefined") { return; } window.horseRacePanel = horseRacePanel; }