fix(git): resolve .gitignore issue to track football.html
Build and Push Docker Image / build (push) Successful in 3m56s

This commit is contained in:
2026-06-19 22:23:32 -07:00
parent 8b9cd80d41
commit 8b65f1b630
2 changed files with 399 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Binaries # Binaries
build/ build/
toolbox /toolbox
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
+398
View File
@@ -0,0 +1,398 @@
{{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}}