Files
chatroom/resources/js/chat-room/horse-race-panel.js
T
2026-04-25 18:33:08 +08:00

553 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 赛马竞猜主面板 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<any>}
*/
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<string, any>}
*/
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<any>}
*/
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<string, any>} 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<void>}
*/
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<void>}
*/
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<string, any>} data 进度广播数据
* @returns {void}
*/
updateProgress(data) {
this.phase = "running";
this.positions = {
...this.positions,
...(data.positions || {}),
};
this.leaderId = data.leader_id;
},
/**
* 显示结算结果。
*
* @param {Record<string, any>} 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<void>}
*/
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<void>}
*/
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<string, any>} 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<string, any>} 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<string, any>} 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;
}