From 1f1c329085f98326b558f8a103805d480a735925 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 25 Apr 2026 18:33:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=B5=9B=E9=A9=AC=E4=B8=BB?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/chat-room.js | 8 + resources/js/chat-room/horse-race-panel.js | 552 ++++++++++++++++++ .../partials/games/horse-race-panel.blade.php | 410 +------------ 3 files changed, 568 insertions(+), 402 deletions(-) create mode 100644 resources/js/chat-room/horse-race-panel.js diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 5213e2a..ea7aa81 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -32,6 +32,7 @@ * - game-bootstrap.js:提供非关键游戏延迟初始化工具。 * - game-panels.js:处理通用游戏面板关闭事件。 * - gomoku-controls.js:处理五子棋外部打开和接受邀请入口。 + * - horse-race-panel.js:提供赛马竞猜主面板 Alpine 组件和下注流程。 * - horse-race-fab.js:处理赛马竞猜悬浮按钮拖动与打开面板。 * - horse-race-events.js:处理赛马广播事件和页面恢复当前场次。 * - holiday-modal.js:处理节日福利弹窗、广播监听、领取状态和系统消息入口。 @@ -116,6 +117,7 @@ export { bindGameHallControls, closeGameHall, openGameHall } from "./chat-room/g export { bindGameBootstrapControls, deferChatGameBootstrap } from "./chat-room/game-bootstrap.js"; export { bindGamePanelControls } from "./chat-room/game-panels.js"; export { acceptGomokuInvite, bindGomokuControls, openGomokuPanel } from "./chat-room/gomoku-controls.js"; +export { bindHorseRacePanelControls, horseRacePanel, requestHorseRaceJson } from "./chat-room/horse-race-panel.js"; export { bindHorseRaceFabControls, horseRaceFab } from "./chat-room/horse-race-fab.js"; export { bindHorseRaceEvents } from "./chat-room/horse-race-events.js"; export { @@ -284,6 +286,7 @@ import { bindGameHallControls, closeGameHall, openGameHall } from "./chat-room/g import { bindGameBootstrapControls, deferChatGameBootstrap } from "./chat-room/game-bootstrap.js"; import { bindGamePanelControls } from "./chat-room/game-panels.js"; import { acceptGomokuInvite, bindGomokuControls, openGomokuPanel } from "./chat-room/gomoku-controls.js"; +import { bindHorseRacePanelControls, horseRacePanel, requestHorseRaceJson } from "./chat-room/horse-race-panel.js"; import { bindHorseRaceFabControls, horseRaceFab } from "./chat-room/horse-race-fab.js"; import { bindHorseRaceEvents } from "./chat-room/horse-race-events.js"; import { @@ -462,6 +465,9 @@ if (typeof window !== "undefined") { acceptGomokuInvite, bindGomokuControls, openGomokuPanel, + bindHorseRacePanelControls, + horseRacePanel, + requestHorseRaceJson, bindHorseRaceFabControls, horseRaceFab, bindHorseRaceEvents, @@ -641,6 +647,7 @@ if (typeof window !== "undefined") { window.openGameHall = openGameHall; window.acceptGomokuInvite = acceptGomokuInvite; window.openGomokuPanel = openGomokuPanel; + window.horseRacePanel = horseRacePanel; window.horseRaceFab = horseRaceFab; window.openLotteryPanel = openLotteryPanel; window.openBankModal = openBankModal; @@ -729,6 +736,7 @@ if (typeof window !== "undefined") { bindGameBootstrapControls(); bindGamePanelControls(); bindGomokuControls(); + bindHorseRacePanelControls(); bindHorseRaceFabControls(); bindHorseRaceEvents(); bindHolidayModalControls(); diff --git a/resources/js/chat-room/horse-race-panel.js b/resources/js/chat-room/horse-race-panel.js new file mode 100644 index 0000000..54e8b6a --- /dev/null +++ b/resources/js/chat-room/horse-race-panel.js @@ -0,0 +1,552 @@ +// 赛马竞猜主面板 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; +} diff --git a/resources/views/chat/partials/games/horse-race-panel.blade.php b/resources/views/chat/partials/games/horse-race-panel.blade.php index c35f1e0..c58cc08 100644 --- a/resources/views/chat/partials/games/horse-race-panel.blade.php +++ b/resources/views/chat/partials/games/horse-race-panel.blade.php @@ -20,7 +20,13 @@ {{-- ─── 赛马主面板 ─── --}} -
+
- +{{-- 赛马竞猜主面板脚本已迁移到 resources/js/chat-room/horse-race-panel.js --}}