fix(git): resolve .gitignore issue to track football.html
Build and Push Docker Image / build (push) Successful in 3m56s
Build and Push Docker Image / build (push) Successful in 3m56s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
build/
|
build/
|
||||||
toolbox
|
/toolbox
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user