feat: 生成记录预览 + 中奖比对功能

功能一:生成记录预览
- 新增 API /api/records/<id>/preview 读取 Excel 返回号码数据
- 前端记录列表新增「预览」按钮,弹窗展示号码表格
- 支持最多显示100注,完整数据可通过下载按钮获取

功能二:中奖比对
- 新增 API /api/records/<id>/compare 根据生成时间匹配开奖数据
- 比对规则:生成时间 < 开奖日20:00 → 与当日开奖比对;≥ 20:00 → 与下次开奖日比对
- 按双色球官方规则判断一等奖至六等奖
- 前端记录列表新增「比对」按钮,弹窗展示比对结果
- 中奖号码高亮显示,未中奖号码半透明
- 若下次开奖尚未举行,返回等待状态提示

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-07-06 04:06:42 +08:00
parent 462f1505ee
commit e43862be9f
2 changed files with 605 additions and 1 deletions
+292
View File
@@ -527,6 +527,122 @@
font-weight: 600;
}
/* ============================================================
Modal / Preview
============================================================ */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: flex-start;
padding: 40px 16px;
overflow-y: auto;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
width: 100%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
margin-bottom: 40px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--card-bg);
z-index: 10;
border-radius: var(--radius) var(--radius) 0 0;
}
.modal-header .title { font-size: 16px; font-weight: 600; }
.modal-close {
background: none;
border: none;
font-size: 24px;
color: var(--text-light);
cursor: pointer;
padding: 0 8px;
line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal-body { padding: 20px; }
.compare-ticket {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f0f0f0;
flex-wrap: wrap;
}
.compare-ticket:last-child { border-bottom: none; }
.compare-ticket.win {
background: rgba(39,174,96,0.06);
border-radius: 8px;
}
.prize-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 6px;
}
.prize-badge.win { background: #27ae60; color: white; }
.prize-badge.lose { background: #f0f0f0; color: var(--text-light); }
.compare-ticket-info {
font-size: 12px;
color: var(--text-light);
margin-left: auto;
}
.match-info {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: #f0f0f0;
color: var(--text);
}
.match-info.match { background: #27ae60; color: white; }
.compare-summary {
background: #f8f9fa;
border-radius: 8px;
padding: 14px;
margin-bottom: 16px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.compare-summary .item .label {
font-size: 12px;
color: var(--text-light);
}
.compare-summary .item .value {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
@media (max-width: 768px) {
.modal-overlay { padding: 20px 8px; }
.modal { max-height: 90vh; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 14px; }
.compare-ticket { padding: 8px 10px; gap: 4px; }
.compare-ticket-info { margin-left: 0; width: 100%; }
}
/* ============================================================
History Table Mobile Card View
============================================================ */
@@ -680,6 +796,19 @@ Page: 号码生成
</div>
</div>
<!-- ============================================================
Modal: Record Preview / Compare
============================================================ -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" id="modalContent" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="title" id="modalTitle">预览</span>
<button class="modal-close" onclick="closeModalDirect()">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<!-- ============================================================
Mobile Bottom Navigation
============================================================ -->
@@ -971,6 +1100,8 @@ function renderRecords(data, container, pagination) {
<div class="desc">${rec.created_at} · ${fileSize}</div>
</div>
<div class="record-actions">
<button class="btn btn-secondary btn-sm" onclick="previewRecord('${rec.id}')">👁️ 预览</button>
<button class="btn btn-blue btn-sm" onclick="compareRecord('${rec.id}')">🎯 比对</button>
<button class="btn btn-blue btn-sm" onclick="window.open('/api/download/${rec.filename}', '_blank')">📥 下载</button>
<button class="btn btn-danger btn-sm" onclick="deleteRecord('${rec.id}')">🗑️ 删除</button>
</div>
@@ -1162,6 +1293,167 @@ async function loadStatsOverview() {
}
}
// ============================================================
// Modal helpers
// ============================================================
function closeModal(e) {
if (e.target === document.getElementById('modalOverlay')) closeModalDirect();
}
function closeModalDirect() {
document.getElementById('modalOverlay').classList.remove('show');
}
// ============================================================
// Record Preview
// ============================================================
async function previewRecord(id) {
const modalTitle = document.getElementById('modalTitle');
const modalBody = document.getElementById('modalBody');
const overlay = document.getElementById('modalOverlay');
modalTitle.textContent = '👁️ 号码预览';
modalBody.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
overlay.classList.add('show');
try {
const res = await fetch(`/api/records/${id}/preview`);
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
const rec = data.data.record;
const tickets = data.data.tickets;
const total = data.data.total;
const displayCount = Math.min(tickets.length, 100);
let html = `<div style="margin-bottom:12px;color:var(--text-light);font-size:13px;">
${rec.strategy} · ${rec.num_tickets} 注 · ${rec.created_at} · 共 ${total}
</div>`;
if (total > displayCount) {
html += `<div style="margin-bottom:8px;font-size:12px;color:var(--text-light);">仅显示前 ${displayCount} 注,完整数据请下载 Excel</div>`;
}
html += '<div class="table-wrap"><table><thead><tr>' +
'<th>序号</th><th>红球</th><th>蓝球</th><th>和值</th><th>奇偶比</th><th>大小比</th><th>跨度</th>' +
'</tr></thead><tbody>';
for (let i = 0; i < displayCount; i++) {
const t = tickets[i];
const redBalls = t.reds.map(r => `<span class="ball ball-red">${String(r).padStart(2,'0')}</span>`).join('');
const blueBall = `<span class="ball ball-blue">${String(t.blue).padStart(2,'0')}</span>`;
html += `<tr>
<td>${String(t.index).padStart(3,'0')}</td>
<td style="white-space:nowrap;">${redBalls}</td>
<td>${blueBall}</td>
<td>${t.sum_value}</td>
<td>${t.odd_even}</td>
<td>${t.size_ratio}</td>
<td>${t.span}</td>
</tr>`;
}
html += '</tbody></table></div>';
html += `<div class="btn-group" style="margin-top:16px;">
<button class="btn btn-blue btn-sm" onclick="window.open('/api/download/${rec.filename}', '_blank')">📥 下载完整 Excel</button>
</div>`;
modalBody.innerHTML = html;
} catch (e) {
modalBody.innerHTML = `<div class="empty-state"><div class="icon">😅</div><div>${e.message}</div></div>`;
}
}
// ============================================================
// Win Compare
// ============================================================
async function compareRecord(id) {
const modalTitle = document.getElementById('modalTitle');
const modalBody = document.getElementById('modalBody');
const overlay = document.getElementById('modalOverlay');
modalTitle.textContent = '🎯 中奖比对';
modalBody.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
overlay.classList.add('show');
try {
const res = await fetch(`/api/records/${id}/compare`);
const data = await res.json();
if (!data.success) throw new Error(data.error || '比对失败');
const d = data.data;
if (d.status === 'waiting') {
modalBody.innerHTML = `
<div style="text-align:center;padding:30px 16px;">
<div style="font-size:36px;margin-bottom:12px;">⏳</div>
<div style="font-size:15px;font-weight:600;margin-bottom:8px;">等待开奖</div>
<div style="color:var(--text-light);font-size:13px;line-height:1.8;">
${d.message}
</div>
<div style="margin-top:16px;background:#f8f9fa;padding:12px;border-radius:8px;font-size:12px;color:var(--text-light);">
比对规则:${d.compare_rule}
</div>
</div>`;
return;
}
const draw = d.draw;
const results = d.results;
const winCount = d.win_count;
let html = '<div class="compare-summary">';
html += `<div class="item"><div class="label">生成时间</div><div class="value" style="font-size:14px;">${d.gen_time}</div></div>`;
html += `<div class="item"><div class="label">比对开奖期</div><div class="value" style="font-size:14px;">${draw.period} · ${draw.date}</div></div>`;
html += `<div class="item"><div class="label">总注数</div><div class="value">${d.total}</div></div>`;
html += `<div class="item"><div class="label">中奖注数</div><div class="value" style="color:${winCount > 0 ? '#27ae60' : 'var(--text-light)'};">${winCount}</div></div>`;
html += '</div>';
// Show draw result
html += `<div style="margin-bottom:12px;background:#f0f0f0;padding:10px;border-radius:8px;">
<span style="font-size:13px;font-weight:600;margin-right:8px;">开奖号码:</span>
${draw.reds.map(r => `<span class="ball ball-red">${String(r).padStart(2,'0')}</span>`).join('')}
<span class="ball ball-blue">${String(draw.blue).padStart(2,'0')}</span>
</div>`;
html += `<div style="font-size:12px;color:var(--text-light);margin-bottom:10px;">比对规则:${d.compare_rule}</div>`;
const displayCount = Math.min(results.length, 100);
for (let i = 0; i < displayCount; i++) {
const r = results[i];
const redBalls = r.reds.map(rd => {
const isMatch = draw.reds.includes(rd);
return `<span class="ball ball-red${isMatch ? '' : ''}" style="${isMatch ? 'box-shadow:0 0 0 3px #27ae60;' : 'opacity:0.4;'}">${String(rd).padStart(2,'0')}</span>`;
}).join('');
const blueBall = `<span class="ball ball-blue" style="${r.blue_match ? 'box-shadow:0 0 0 3px #27ae60;' : 'opacity:0.4;'}">${String(r.blue).padStart(2,'0')}</span>`;
const prizeBadge = r.is_win
? `<span class="prize-badge win">${r.prize_level}</span>`
: `<span class="prize-badge lose">未中奖</span>`;
const matchInfo = `<span class="match-info ${r.red_matches > 0 ? 'match' : ''}">红${r.red_matches}</span> <span class="match-info ${r.blue_match ? 'match' : ''}">蓝${r.blue_match ? '✓' : '✗'}</span>`;
html += `<div class="compare-ticket${r.is_win ? ' win' : ''}">
<span style="font-size:12px;color:var(--text-light);min-width:36px;">${String(r.index).padStart(3,'0')}</span>
${redBalls}${blueBall}
${prizeBadge}
<span class="compare-ticket-info">${matchInfo} ${r.prize_desc || ''}</span>
</div>`;
}
if (results.length > displayCount) {
html += `<div style="text-align:center;padding:12px;font-size:13px;color:var(--text-light);">... 仅显示前 ${displayCount} 注,共 ${results.length} 注</div>`;
}
if (winCount === 0) {
html += '<div style="text-align:center;padding:16px;font-size:14px;color:var(--text-light);">本次生成号码未中奖,继续努力!</div>';
} else {
html += `<div style="text-align:center;padding:16px;font-size:14px;color:#27ae60;font-weight:600;">🎉 共 ${winCount} 注中奖!</div>`;
}
modalBody.innerHTML = html;
} catch (e) {
modalBody.innerHTML = `<div class="empty-state"><div class="icon">😅</div><div>${e.message}</div></div>`;
}
}
// ============================================================
// Init
// ============================================================