Files
own-tools/cmd/toolbox/web/tools/football.html
T
haopengzhan 8b65f1b630
Build and Push Docker Image / build (push) Successful in 3m56s
fix(git): resolve .gitignore issue to track football.html
2026-06-19 22:23:32 -07:00

399 lines
17 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "football"}}
<div id="panel-football" class="tool-panel">
<header class="page-header">
<h1 style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<span></span> 足球概率模拟
</h1>
<p style="font-size: 16px; color: #86868b; margin: 0;">使用蒙特卡洛算法进行足球赛事出线概率模拟计算。</p>
</header>
<div class="tabs-container">
<div class="tab-btn active" id="tab-btn-wc">2026世界杯小组赛模拟</div>
<div class="tab-btn" id="tab-btn-afc" style="cursor: default; opacity: 0.5; font-weight: normal;" title="暂未开放">18(3*6) AFC qualification</div>
</div>
<!-- Tab 1: World Cup Simulator -->
<div id="football-tab-wc" class="football-tab-content">
<!-- Inputs Group Boxes -->
<div style="margin-bottom: 24px;">
<h2 style="font-size: 18px; font-weight: 700; color: #1d1d1f; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;">
<span>📅 小组赛比分录入 (Completed Matches by Group)</span>
<button type="button" class="btn-sec" onclick="resetMatchesToDefault()" style="height: 28px; padding: 0 10px; font-size: 12px;">重置默认比分</button>
</h2>
<div id="groups-input-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px;">
<!-- JS 动态注入 12 个小组盒子 -->
</div>
</div>
<!-- Actions -->
<div class="card" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
<div style="display: flex; align-items: center; gap: 10px;">
<label for="sim-count" style="font-size: 15px; font-weight: 600;">模拟次数 (Runs):</label>
<select id="sim-count" style="width: 140px; height: 38px; padding: 0 10px; font-size: 14px;">
<option value="10000">10,000 (快速)</option>
<option value="50000" selected>50,000 (标准)</option>
<option value="100000">100,000 (精准)</option>
</select>
</div>
<button id="run-simulation-btn" onclick="startSimulation()" style="min-width: 180px; height: 44px; font-size: 15px;">
🚀 运行蒙特卡洛模拟
</button>
</div>
<!-- Loading -->
<div id="simulation-loading" style="display: none; text-align: center; padding: 30px; margin-bottom: 24px;" class="card">
<div class="spinner" style="border: 3px solid rgba(0,0,0,0.1); width: 40px; height: 40px; border-radius: 50%; border-left-color: var(--apple-blue); animation: spin 1s linear infinite; margin: 0 auto 12px;"></div>
<p style="font-size: 15px; color: #86868b; margin: 0;">正在进行蒙特卡洛模拟计算,请稍候...</p>
</div>
<!-- Results -->
<div id="simulation-results" style="display: none;">
<div class="card" style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="font-size: 20px; font-weight: 700; margin: 0;">📈 模拟预测结果</h2>
<span id="sim-duration-badge" style="font-size: 14px; color: #86868b; background: #f5f5f7; padding: 4px 10px; border-radius: 8px;"></span>
</div>
<!-- Group Cards Grid -->
<div id="groups-results-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
<!-- Group cards will be generated here -->
</div>
</div>
</div>
</div>
<style>
.btn-sec {
background-color: #f5f5f7;
color: #1d1d1f;
border: 1px solid #d2d2d7;
border-radius: 6px;
padding: 0 12px;
height: 32px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-sec:hover {
background-color: #e5e5e7;
}
.score-input {
width: 32px;
text-align: center;
border: 1px solid #d2d2d7;
border-radius: 4px;
padding: 2px;
font-size: 13px;
box-sizing: border-box;
}
.flag-preview {
font-size: 16px;
min-width: 20px;
display: inline-block;
text-align: center;
}
/* Group Card */
.group-input-card {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.group-input-card h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1d1d1f;
border-bottom: 1px solid #f5f5f7;
padding-bottom: 8px;
}
/* Group Result Card */
.group-result-card {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.group-result-card h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1d1d1f;
border-bottom: 1px solid #f5f5f7;
padding-bottom: 8px;
display: flex;
justify-content: space-between;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script>
const FIFA_TO_FLAG = {
'MEX': '🇲🇽', 'RSA': '🇿🇦', 'KOR': '🇰🇷', 'CZE': '🇨🇿',
'CAN': '🇨🇦', 'QAT': '🇶🇦', 'SUI': '🇨🇭', 'BIH': '🇧🇦',
'BRA': '🇧🇷', 'MAR': '🇲🇦', 'HAI': '🇭🇹', 'SCO': '🏴󠁧󠁢󠁳󠁣󠁴󠁿',
'USA': '🇺🇸', 'PAR': '🇵🇾', 'AUS': '🇦🇺', 'TUR': '🇹🇷',
'GER': '🇩🇪', 'CUW': '🇨🇼', 'CIV': '🇨🇮', 'ECU': '🇪🇨',
'NED': '🇳🇱', 'JPN': '🇯🇵', 'TUN': '🇹🇳', 'SWE': '🇸🇪',
'BEL': '🇧🇪', 'EGY': '🇪🇬', 'IRN': '🇮🇷', 'NZL': '🇳🇿',
'ESP': '🇪🇸', 'KSA': '🇸🇦', 'URU': '🇺🇾', 'CPV': '🇨🇻',
'FRA': '🇫🇷', 'SEN': '🇸🇳', 'NOR': '🇳🇴', 'COD': '🇨🇩',
'ARG': '🇦🇷', 'ALG': '🇩🇿', 'AUT': '🇦🇹', 'JOR': '🇯🇴',
'POR': '🇵🇹', 'COL': '🇨🇴', 'UZB': '🇺🇿', 'IRQ': '🇮🇶',
'ENG': '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'CRO': '🇭🇷', 'GHA': '🇬🇭', 'PAN': '🇵🇦'
};
function getFlag(teamCode) {
if (!teamCode) return '🏴';
return FIFA_TO_FLAG[teamCode.trim().toUpperCase()] || '🏴';
}
let defaultPayload = null;
async function loadFootballDefaults() {
try {
const response = await fetch('/api/football/defaults');
defaultPayload = await response.json();
resetMatchesToDefault();
} catch (e) {
console.error('Failed to load defaults:', e);
}
}
function resetMatchesToDefault() {
if (!defaultPayload) return;
renderGroupsInputGrid();
}
// Render the 12 input boxes programmatically
function renderGroupsInputGrid() {
const grid = document.getElementById('groups-input-grid');
grid.innerHTML = '';
const groups = defaultPayload.groups;
const completedMatches = defaultPayload.completed_matches;
// Sorted group keys (A to L)
const sortedGroups = Object.keys(groups).sort();
sortedGroups.forEach(grpName => {
const grpTeams = groups[grpName]; // Array of 4 teams
// Create Card Container
const card = document.createElement('div');
card.className = 'group-input-card';
let cardContentHTML = `<h3>小组 ${grpName} 比赛比分录入</h3>`;
cardContentHTML += `<table style="width: 100%; border-collapse: collapse; font-size: 13px;"><tbody>`;
// Filter completedMatches for matches belonging to this group (preserves chronological order)
const pairings = [];
completedMatches.forEach(m => {
if (grpTeams.includes(m.team_a) && grpTeams.includes(m.team_b)) {
pairings.push({
teamA: m.team_a,
teamB: m.team_b,
scoreA: m.score_a,
scoreB: m.score_b,
isCompleted: m.is_completed
});
}
});
// Render each pairing row
pairings.forEach(p => {
let scoreA = p.scoreA;
let scoreB = p.scoreB;
let isCompleted = p.isCompleted;
cardContentHTML += `
<tr class="match-input-row" data-team-a="${p.teamA}" data-team-b="${p.teamB}" style="border-bottom: 1px solid #f5f5f7;">
<td style="padding: 8px 0; display: flex; align-items: center; gap: 4px; height: 38px;">
<span class="flag-preview">${getFlag(p.teamA)}</span>
<span style="font-weight: 600;">${p.teamA}</span>
</td>
<td style="padding: 8px 0; text-align: center;">
<div style="display: inline-flex; align-items: center; gap: 3px;">
<input type="number" class="score-input score-a" value="${scoreA}" min="0">
<span style="color: #86868b;">:</span>
<input type="number" class="score-input score-b" value="${scoreB}" min="0">
</div>
</td>
<td style="padding: 8px 0; text-align: right;">
<div style="display: inline-flex; align-items: center; gap: 4px; justify-content: flex-end;">
<span style="font-weight: 600;">${p.teamB}</span>
<span class="flag-preview">${getFlag(p.teamB)}</span>
</div>
</td>
<td style="padding: 8px 0; text-align: right; width: 56px;">
<label style="display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: #86868b; cursor: pointer; margin: 0;">
<input type="checkbox" class="completed-chk" ${isCompleted ? 'checked' : ''} style="cursor: pointer; width: 13px; height: 13px; margin: 0;">
完赛
</label>
</td>
</tr>
`;
});
cardContentHTML += `</tbody></table>`;
card.innerHTML = cardContentHTML;
grid.appendChild(card);
});
}
async function startSimulation() {
const runBtn = document.getElementById('run-simulation-btn');
const loadingDiv = document.getElementById('simulation-loading');
const resultsDiv = document.getElementById('simulation-results');
runBtn.disabled = true;
loadingDiv.style.display = 'block';
resultsDiv.style.display = 'none';
try {
if (!defaultPayload) {
alert('默认数据尚未加载完成,请稍候再试。');
return;
}
const scoreOutcomes = defaultPayload.score_outcomes;
const groups = defaultPayload.groups;
const completedMatches = [];
const rows = document.querySelectorAll('.match-input-row');
rows.forEach(row => {
const teamA = row.getAttribute('data-team-a');
const teamB = row.getAttribute('data-team-b');
const scoreA = parseInt(row.querySelector('.score-a').value);
const scoreB = parseInt(row.querySelector('.score-b').value);
const isCompleted = row.querySelector('.completed-chk').checked;
completedMatches.push({
team_a: teamA,
team_b: teamB,
score_a: isNaN(scoreA) ? 0 : scoreA,
score_b: isNaN(scoreB) ? 0 : scoreB,
is_completed: isCompleted
});
});
const simulations = parseInt(document.getElementById('sim-count').value);
const startTime = performance.now();
const response = await fetch('/api/football/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
groups,
completed_matches: completedMatches,
score_outcomes: scoreOutcomes,
simulations
})
});
const results = await response.json();
const endTime = performance.now();
renderGroupedResults(results, endTime - startTime);
loadingDiv.style.display = 'none';
resultsDiv.style.display = 'block';
} catch (err) {
alert('计算出错!' + err.message);
loadingDiv.style.display = 'none';
} finally {
runBtn.disabled = false;
}
}
function renderGroupedResults(results, clientDuration) {
const grid = document.getElementById('groups-results-grid');
grid.innerHTML = '';
document.getElementById('sim-duration-badge').innerText = `耗时: ${(clientDuration / 1000).toFixed(2)} 秒`;
// Group the flat list by group name
const grouped = {};
results.forEach(r => {
if (!grouped[r.group]) {
grouped[r.group] = [];
}
grouped[r.group].push(r);
});
// Get sorted groups keys
const sortedGroups = Object.keys(grouped).sort();
sortedGroups.forEach(grpName => {
const groupTeams = grouped[grpName];
// Create a group card
const card = document.createElement('div');
card.className = 'group-result-card';
let tableRowsHTML = '';
groupTeams.forEach((teamProb, idx) => {
const flag = getFlag(teamProb.team_name);
const isQualifying = idx < 2; // top 2
const rankColor = isQualifying ? '#34c759' : '#86868b';
tableRowsHTML += `
<tr style="border-bottom: 1px solid #f5f5f7;">
<td style="padding: 8px 0; font-weight: bold; color: ${rankColor}; width: 20px;">${idx + 1}</td>
<td style="padding: 8px 0; display: flex; align-items: center; gap: 6px;">
<span>${flag}</span>
<span style="font-weight: 600;">${teamProb.team_name}</span>
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px;" title="直接出线概率 (小组前二)">
${(teamProb.direct_qual_prob * 100).toFixed(1)}%
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px; color: #86868b;" title="最佳第三名出线概率">
${(teamProb.third_qual_prob * 100).toFixed(1)}%
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px; font-weight: bold; color: var(--apple-blue);" title="总出线概率">
${(teamProb.total_qual_prob * 100).toFixed(1)}%
</td>
</tr>
`;
});
card.innerHTML = `
<h3>
<span>小组 ${grpName}</span>
<span style="font-size: 12px; font-weight: normal; color: #86868b;">前2名 / 第3名 / 总出线</span>
</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<tbody>
${tableRowsHTML}
</tbody>
</table>
`;
grid.appendChild(card);
});
}
document.addEventListener('DOMContentLoaded', () => {
loadFootballDefaults();
});
</script>
{{end}}