5d459b4505
PRD v1.2 §4.3 落地,参考 BIZ-102 PRD 与 UI 原型。
后端(app.py):
- 新增 PRIZE_AMOUNT / PRIZE_KEY_MAP 常量双表(PRD §4.3)
- 新增 format_amount 千分位格式化(PRD §3.2:紧邻「元」不空格)
- 新增 compute_prize_summary 聚合函数
- api_compare_record 接口扩展:
- 单注:prize_amount + prize_amount_display 字段
- 顶层:total_prize / total_prize_display / has_variable_prize /
prize_summary / prize_display 字段
- 一/二等奖标记为浮动奖(历史数据无奖池字段),不计入 total_prize
前端(index.html):
- 总中奖金额字段(橙色「含浮动奖」角标)
- 中奖奖项汇总表(不含浮动奖金额)
- 浮动奖橙色提示条(区别于固定奖绿色 #1e8e4a)
- 每注金额 badge(固定奖绿 / 浮动奖橙)
测试(tests/test_compare_prize.py):
- 31 个用例,100% 通过
- 覆盖:6 档奖金档 / 未中奖 / waiting 契约 / 0 注 / 全中奖 /
仅有浮动奖 / 混合 / Mock 一致性(验收 #16)/ 响应结构契约
- running: pytest tests/test_compare_prize.py -v → 31 passed
不变项:比对算法 / 归期规则 / Excel 解析 / 认证 — 全部不动。
Co-authored-by: multica-agent <github@multica.ai>
1551 lines
58 KiB
HTML
1551 lines
58 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>🎯 双色球号码生成系统</title>
|
||
<style>
|
||
/* ============================================================
|
||
CSS Variables & Reset
|
||
============================================================ */
|
||
:root {
|
||
--primary: #e74c3c;
|
||
--primary-dark: #c0392b;
|
||
--blue: #3498db;
|
||
--blue-dark: #2980b9;
|
||
--bg: #f5f6fa;
|
||
--card-bg: #ffffff;
|
||
--text: #2c3e50;
|
||
--text-light: #7f8c8d;
|
||
--border: #e0e0e0;
|
||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||
--radius: 12px;
|
||
--nav-height: 60px;
|
||
--mobile-nav-height: 56px;
|
||
--red-ball: #e74c3c;
|
||
--blue-ball: #3498db;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
padding-bottom: 80px;
|
||
}
|
||
|
||
/* ============================================================
|
||
Header
|
||
============================================================ */
|
||
.header {
|
||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 50%, #8e44ad 100%);
|
||
color: white;
|
||
padding: 20px 24px;
|
||
text-align: center;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||
}
|
||
.header h1 { font-size: 22px; font-weight: 700; letter-spacing: 1px; }
|
||
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
||
|
||
/* ============================================================
|
||
Navigation Tabs
|
||
============================================================ */
|
||
.nav-tabs {
|
||
display: flex;
|
||
background: var(--card-bg);
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky;
|
||
top: 68px;
|
||
z-index: 99;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||
}
|
||
.nav-tab {
|
||
flex: 1;
|
||
padding: 14px 8px;
|
||
text-align: center;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-light);
|
||
cursor: pointer;
|
||
border-bottom: 3px solid transparent;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
}
|
||
.nav-tab:hover { color: var(--text); background: #fafafa; }
|
||
.nav-tab.active {
|
||
color: var(--primary);
|
||
border-bottom-color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
.nav-tab .icon { margin-right: 4px; }
|
||
|
||
/* ============================================================
|
||
Container & Pages
|
||
============================================================ */
|
||
.container { max-width: 960px; margin: 0 auto; padding: 16px; }
|
||
.page { display: none; }
|
||
.page.active { display: block; }
|
||
|
||
/* ============================================================
|
||
Card
|
||
============================================================ */
|
||
.card {
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
/* ============================================================
|
||
Form Controls
|
||
============================================================ */
|
||
.form-group {
|
||
margin-bottom: 14px;
|
||
}
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-bottom: 6px;
|
||
color: var(--text);
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
}
|
||
.form-row .form-group { flex: 1; min-width: 120px; margin-bottom: 0; }
|
||
|
||
select, input[type="number"] {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
color: var(--text);
|
||
background: white;
|
||
transition: border-color 0.2s;
|
||
outline: none;
|
||
}
|
||
select:focus, input[type="number"]:focus {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(231,76,60,0.1);
|
||
}
|
||
|
||
/* ============================================================
|
||
Buttons
|
||
============================================================ */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 12px 28px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
}
|
||
.btn:active { transform: scale(0.97); }
|
||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
||
color: white;
|
||
}
|
||
.btn-primary:hover:not(:disabled) { box-shadow: 0 4px 15px rgba(231,76,60,0.4); }
|
||
|
||
.btn-secondary {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.btn-secondary:hover:not(:disabled) { background: #e8e8e8; }
|
||
|
||
.btn-blue {
|
||
background: linear-gradient(135deg, #3498db, #2980b9);
|
||
color: white;
|
||
}
|
||
.btn-blue:hover:not(:disabled) { box-shadow: 0 4px 15px rgba(52,152,219,0.4); }
|
||
|
||
.btn-sm {
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
}
|
||
.btn-danger {
|
||
background: #e74c3c;
|
||
color: white;
|
||
}
|
||
.btn-danger:hover:not(:disabled) { background: #c0392b; }
|
||
|
||
.btn-group {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* ============================================================
|
||
Ball Display
|
||
============================================================ */
|
||
.ball {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
margin: 2px;
|
||
box-shadow: inset 0 -2px 4px rgba(0,0,0,0.2);
|
||
}
|
||
.ball-red { background: radial-gradient(circle at 35% 35%, #ff6b6b, var(--red-ball)); }
|
||
.ball-blue { background: radial-gradient(circle at 35% 35%, #5dade2, var(--blue-ball)); }
|
||
|
||
.ball-lg {
|
||
width: 44px;
|
||
height: 44px;
|
||
font-size: 17px;
|
||
}
|
||
|
||
/* ============================================================
|
||
Ticket Display
|
||
============================================================ */
|
||
.ticket {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ticket:last-child { border-bottom: none; }
|
||
.ticket-index {
|
||
font-size: 12px;
|
||
color: var(--text-light);
|
||
min-width: 36px;
|
||
font-weight: 500;
|
||
}
|
||
.ticket-info {
|
||
font-size: 12px;
|
||
color: var(--text-light);
|
||
margin-left: auto;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ticket-info span {
|
||
background: #f0f0f0;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* ============================================================
|
||
Table
|
||
============================================================ */
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th, td {
|
||
padding: 10px 8px;
|
||
text-align: center;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
th {
|
||
background: #f8f9fa;
|
||
font-weight: 600;
|
||
color: var(--text-light);
|
||
font-size: 12px;
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
tr:hover { background: #fafafa; }
|
||
|
||
/* ============================================================
|
||
Pagination
|
||
============================================================ */
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pagination button {
|
||
padding: 6px 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: white;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
}
|
||
.pagination button:hover:not(:disabled) { background: var(--bg); }
|
||
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.pagination .page-info {
|
||
font-size: 13px;
|
||
color: var(--text-light);
|
||
}
|
||
|
||
/* ============================================================
|
||
Loading & Toast
|
||
============================================================ */
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-light);
|
||
}
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 3px solid var(--border);
|
||
border-top-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
margin-bottom: 10px;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.toast {
|
||
position: fixed;
|
||
top: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #333;
|
||
color: white;
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
z-index: 999;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
pointer-events: none;
|
||
max-width: 90%;
|
||
text-align: center;
|
||
}
|
||
.toast.show { opacity: 1; }
|
||
.toast.success { background: #27ae60; }
|
||
.toast.error { background: #e74c3c; }
|
||
|
||
/* ============================================================
|
||
Stats Grid
|
||
============================================================ */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.stat-item {
|
||
background: #f8f9fa;
|
||
padding: 14px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
.stat-item .label {
|
||
font-size: 12px;
|
||
color: var(--text-light);
|
||
margin-bottom: 4px;
|
||
}
|
||
.stat-item .value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
}
|
||
.stat-item .sub {
|
||
font-size: 12px;
|
||
color: var(--text-light);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.ball-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* ============================================================
|
||
Records
|
||
============================================================ */
|
||
.record-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.record-item:last-child { border-bottom: none; }
|
||
.record-meta { flex: 1; min-width: 150px; }
|
||
.record-meta .title { font-size: 14px; font-weight: 500; }
|
||
.record-meta .desc { font-size: 12px; color: var(--text-light); margin-top: 2px; }
|
||
.record-actions { display: flex; gap: 6px; }
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-light);
|
||
}
|
||
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
||
|
||
/* ============================================================
|
||
Search Bar
|
||
============================================================ */
|
||
.search-bar {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.search-bar input {
|
||
flex: 1;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
}
|
||
.search-bar input:focus { border-color: var(--primary); }
|
||
|
||
/* ============================================================
|
||
Mobile Bottom Nav
|
||
============================================================ */
|
||
.mobile-nav {
|
||
display: none;
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--card-bg);
|
||
border-top: 1px solid var(--border);
|
||
z-index: 100;
|
||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||
}
|
||
.mobile-nav-items {
|
||
display: flex;
|
||
}
|
||
.mobile-nav-item {
|
||
flex: 1;
|
||
padding: 8px 4px;
|
||
text-align: center;
|
||
font-size: 11px;
|
||
color: var(--text-light);
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
user-select: none;
|
||
}
|
||
.mobile-nav-item .m-icon { font-size: 22px; display: block; margin-bottom: 2px; }
|
||
.mobile-nav-item.active { color: var(--primary); font-weight: 600; }
|
||
|
||
/* ============================================================
|
||
Responsive
|
||
============================================================ */
|
||
@media (max-width: 768px) {
|
||
.header h1 { font-size: 18px; }
|
||
.header p { font-size: 12px; }
|
||
.nav-tabs { display: none; }
|
||
.mobile-nav { display: block; }
|
||
body { padding-bottom: 70px; }
|
||
.container { padding: 12px; }
|
||
.card { padding: 14px; }
|
||
.ball { width: 30px; height: 30px; font-size: 12px; }
|
||
.ball-lg { width: 36px; height: 36px; font-size: 14px; }
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.ticket { padding: 8px 10px; }
|
||
.ticket-info { margin-left: 0; width: 100%; justify-content: flex-start; }
|
||
.form-row { flex-direction: column; }
|
||
.form-row .form-group { min-width: auto; }
|
||
.btn { padding: 10px 20px; font-size: 14px; }
|
||
.record-actions { width: 100%; justify-content: flex-end; }
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.ball { width: 26px; height: 26px; font-size: 11px; }
|
||
.ball-lg { width: 32px; height: 32px; font-size: 13px; }
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||
.stat-item { padding: 10px; }
|
||
.stat-item .value { font-size: 16px; }
|
||
}
|
||
|
||
/* ============================================================
|
||
Scrollbar
|
||
============================================================ */
|
||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #aaa; }
|
||
|
||
/* ============================================================
|
||
Result Section
|
||
============================================================ */
|
||
.result-section { display: none; }
|
||
.result-section.show { display: block; }
|
||
|
||
.result-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.result-header .title { font-size: 16px; font-weight: 600; }
|
||
.result-header .badge {
|
||
background: #27ae60;
|
||
color: white;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
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); }
|
||
/* PRD v1.2 BIZ-105:每注中奖金额(紧邻「元」不空格) */
|
||
.ticket-amount {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
margin-left: 4px;
|
||
background: rgba(30, 142, 74, 0.1);
|
||
color: #1e8e4a;
|
||
}
|
||
.ticket-amount.floating {
|
||
background: rgba(230, 126, 34, 0.1);
|
||
color: #e67e22;
|
||
}
|
||
/* PRD v1.2 BIZ-105:浮动奖顶部提示条 */
|
||
.variable-prize-tip {
|
||
background: #fff3e0;
|
||
color: #e67e22;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
margin-top: 10px;
|
||
line-height: 1.6;
|
||
}
|
||
/* PRD v1.2 BIZ-105:奖项汇总表 */
|
||
.prize-table-wrap {
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.prize-table-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
margin-bottom: 8px;
|
||
}
|
||
.prize-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
.prize-table th, .prize-table td {
|
||
padding: 6px 4px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
}
|
||
.prize-table th {
|
||
font-size: 12px;
|
||
color: var(--text-light);
|
||
font-weight: 500;
|
||
}
|
||
.prize-table tr:last-child td { border-bottom: none; }
|
||
.prize-table .col-level { text-align: left; color: var(--text); font-weight: 500; }
|
||
.prize-table .col-count { color: var(--text-light); }
|
||
.prize-table .col-amount { font-weight: 600; color: #1e8e4a; }
|
||
.prize-table .col-amount.floating { color: #e67e22; }
|
||
.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
|
||
============================================================ */
|
||
@media (max-width: 600px) {
|
||
.history-table thead { display: none; }
|
||
.history-table tr {
|
||
display: block;
|
||
padding: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.history-table td {
|
||
display: inline-block;
|
||
border: none;
|
||
padding: 2px 4px;
|
||
font-size: 12px;
|
||
}
|
||
.history-table td:first-child {
|
||
display: block;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ============================================================
|
||
Toast
|
||
============================================================ -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<!-- ============================================================
|
||
Header
|
||
============================================================ -->
|
||
<div class="header">
|
||
<h1>🎯 双色球号码生成系统</h1>
|
||
<p>基于历史数据分析 · 支持 PC 端和移动端</p>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
Desktop Navigation
|
||
============================================================ -->
|
||
<div class="nav-tabs" id="desktopNav">
|
||
<div class="nav-tab active" data-page="generate" onclick="switchPage('generate')">
|
||
<span class="icon">🎲</span> 号码生成
|
||
</div>
|
||
<div class="nav-tab" data-page="history" onclick="switchPage('history')">
|
||
<span class="icon">📊</span> 历史数据
|
||
</div>
|
||
<div class="nav-tab" data-page="records" onclick="switchPage('records')">
|
||
<span class="icon">📋</span> 生成记录
|
||
</div>
|
||
<div class="nav-tab" data-page="stats" onclick="switchPage('stats')">
|
||
<span class="icon">📈</span> 统计分析
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
Page: 号码生成
|
||
============================================================ -->
|
||
<div class="container">
|
||
<div class="page active" id="page-generate">
|
||
<div class="card">
|
||
<div class="card-title">🎲 生成双色球号码</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>生成策略</label>
|
||
<select id="strategy">
|
||
<option value="advanced">高级策略(基于历史数据分析)</option>
|
||
<option value="basic">基础策略(完全随机)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>生成注数</label>
|
||
<input type="number" id="numTickets" value="10" min="1" max="1000">
|
||
</div>
|
||
<div class="form-group" style="flex:0 0 auto;">
|
||
<label> </label>
|
||
<button class="btn btn-primary" id="generateBtn" onclick="generateTickets()">
|
||
🚀 立即生成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成结果 -->
|
||
<div class="card result-section" id="resultSection">
|
||
<div class="result-header">
|
||
<div>
|
||
<span class="title">📋 生成结果</span>
|
||
<span class="badge" id="resultBadge">10 注</span>
|
||
</div>
|
||
<div class="btn-group" style="margin:0;">
|
||
<button class="btn btn-blue btn-sm" id="downloadBtn" onclick="downloadResult()">📥 下载 Excel</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="generateTickets()">🔄 重新生成</button>
|
||
</div>
|
||
</div>
|
||
<div id="resultContent"></div>
|
||
</div>
|
||
|
||
<!-- 统计概览 -->
|
||
<div class="card" id="statsOverview">
|
||
<div class="card-title">📊 数据概览</div>
|
||
<div id="statsOverviewContent">
|
||
<div class="loading"><div class="spinner"></div><div>加载统计数据中...</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
Page: 历史数据
|
||
============================================================ -->
|
||
<div class="page" id="page-history">
|
||
<div class="card">
|
||
<div class="card-title">📊 双色球历史开奖数据</div>
|
||
<div class="search-bar">
|
||
<input type="text" id="historySearch" placeholder="搜索期号或日期..." oninput="debounceSearch()">
|
||
<button class="btn btn-secondary btn-sm" onclick="loadHistory(1)">🔍 搜索</button>
|
||
</div>
|
||
<div id="historyContent">
|
||
<div class="loading"><div class="spinner"></div><div>加载历史数据中...</div></div>
|
||
</div>
|
||
<div class="pagination" id="historyPagination"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
Page: 生成记录
|
||
============================================================ -->
|
||
<div class="page" id="page-records">
|
||
<div class="card">
|
||
<div class="card-title">📋 生成记录</div>
|
||
<div id="recordsContent">
|
||
<div class="loading"><div class="spinner"></div><div>加载记录中...</div></div>
|
||
</div>
|
||
<div class="pagination" id="recordsPagination"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
Page: 统计分析
|
||
============================================================ -->
|
||
<div class="page" id="page-stats">
|
||
<div class="card">
|
||
<div class="card-title">📈 号码统计分析</div>
|
||
<div id="statsContent">
|
||
<div class="loading"><div class="spinner"></div><div>加载统计中...</div></div>
|
||
</div>
|
||
</div>
|
||
</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
|
||
============================================================ -->
|
||
<div class="mobile-nav">
|
||
<div class="mobile-nav-items">
|
||
<div class="mobile-nav-item active" data-page="generate" onclick="switchPage('generate')">
|
||
<span class="m-icon">🎲</span> 生成
|
||
</div>
|
||
<div class="mobile-nav-item" data-page="history" onclick="switchPage('history')">
|
||
<span class="m-icon">📊</span> 历史
|
||
</div>
|
||
<div class="mobile-nav-item" data-page="records" onclick="switchPage('records')">
|
||
<span class="m-icon">📋</span> 记录
|
||
</div>
|
||
<div class="mobile-nav-item" data-page="stats" onclick="switchPage('stats')">
|
||
<span class="m-icon">📈</span> 统计
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
JavaScript
|
||
============================================================ -->
|
||
<script>
|
||
// ============================================================
|
||
// State
|
||
// ============================================================
|
||
const state = {
|
||
currentPage: 'generate',
|
||
lastResult: null,
|
||
historyPage: 1,
|
||
recordsPage: 1,
|
||
searchTimer: null,
|
||
};
|
||
|
||
// ============================================================
|
||
// Toast
|
||
// ============================================================
|
||
function showToast(msg, type = '') {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast show ' + type;
|
||
clearTimeout(t._hide);
|
||
t._hide = setTimeout(() => t.classList.remove('show'), 2500);
|
||
}
|
||
|
||
// ============================================================
|
||
// Page Switching
|
||
// ============================================================
|
||
function switchPage(page) {
|
||
state.currentPage = page;
|
||
|
||
// Desktop tabs
|
||
document.querySelectorAll('.nav-tab').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.page === page);
|
||
});
|
||
// Mobile tabs
|
||
document.querySelectorAll('.mobile-nav-item').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.page === page);
|
||
});
|
||
// Pages
|
||
document.querySelectorAll('.page').forEach(el => {
|
||
el.classList.toggle('active', el.id === 'page-' + page);
|
||
});
|
||
|
||
// Load data on demand
|
||
if (page === 'history') loadHistory(state.historyPage);
|
||
if (page === 'records') loadRecords(state.recordsPage);
|
||
if (page === 'stats') loadStatsPage();
|
||
}
|
||
|
||
// ============================================================
|
||
// API Helper
|
||
// ============================================================
|
||
async function api(path, options = {}) {
|
||
try {
|
||
const res = await fetch(path, {
|
||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||
...options
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error || '请求失败');
|
||
return data.data;
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Generate Tickets
|
||
// ============================================================
|
||
async function generateTickets() {
|
||
const btn = document.getElementById('generateBtn');
|
||
const numTickets = parseInt(document.getElementById('numTickets').value) || 10;
|
||
const strategy = document.getElementById('strategy').value;
|
||
|
||
if (numTickets < 1 || numTickets > 1000) {
|
||
showToast('注数必须在 1-1000 之间', 'error');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ 生成中...';
|
||
document.getElementById('resultSection').classList.remove('show');
|
||
|
||
try {
|
||
const data = await api('/api/generate', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ num_tickets: numTickets, strategy })
|
||
});
|
||
|
||
state.lastResult = data;
|
||
renderResult(data);
|
||
document.getElementById('resultSection').classList.add('show');
|
||
showToast(`✅ 成功生成 ${data.total} 注号码`, 'success');
|
||
} catch (e) {
|
||
// error already shown by api()
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '🚀 立即生成';
|
||
}
|
||
}
|
||
|
||
function renderResult(data) {
|
||
const container = document.getElementById('resultContent');
|
||
const badge = document.getElementById('resultBadge');
|
||
badge.textContent = `${data.total} 注`;
|
||
|
||
if (!data.tickets || data.tickets.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">😅</div><div>未生成号码</div></div>';
|
||
return;
|
||
}
|
||
|
||
let 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>';
|
||
|
||
const displayCount = Math.min(data.tickets.length, 50);
|
||
for (let i = 0; i < displayCount; i++) {
|
||
const t = data.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>`;
|
||
}
|
||
|
||
if (data.total > 50) {
|
||
html += `<tr><td colspan="7" style="color:var(--text-light);font-size:13px;">... 仅显示前 50 注,完整数据请下载 Excel 查看</td></tr>`;
|
||
}
|
||
|
||
html += '</tbody></table></div>';
|
||
container.innerHTML = html;
|
||
|
||
// Update download button
|
||
document.getElementById('downloadBtn').onclick = () => {
|
||
window.open(data.download_url, '_blank');
|
||
};
|
||
}
|
||
|
||
function downloadResult() {
|
||
if (state.lastResult && state.lastResult.download_url) {
|
||
window.open(state.lastResult.download_url, '_blank');
|
||
} else {
|
||
showToast('没有可下载的文件', 'error');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// History Data
|
||
// ============================================================
|
||
async function loadHistory(page) {
|
||
state.historyPage = page;
|
||
const container = document.getElementById('historyContent');
|
||
const pagination = document.getElementById('historyPagination');
|
||
const search = document.getElementById('historySearch').value;
|
||
|
||
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
|
||
pagination.innerHTML = '';
|
||
|
||
try {
|
||
const data = await api(`/api/history?page=${page}&page_size=20&search=${encodeURIComponent(search)}`);
|
||
renderHistory(data, container, pagination);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">😅</div><div>暂无历史数据</div></div>';
|
||
}
|
||
}
|
||
|
||
function renderHistory(data, container, pagination) {
|
||
if (!data.records || data.records.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>暂无数据</div></div>';
|
||
pagination.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Find ball columns
|
||
const ballCols = [];
|
||
for (let i = 1; i <= 6; i++) {
|
||
const name = `红球 ${i}`;
|
||
if (data.columns.includes(name)) ballCols.push(name);
|
||
}
|
||
const blueCol = data.columns.includes('蓝球') ? '蓝球' : null;
|
||
|
||
let html = '<div class="table-wrap"><table class="history-table"><thead><tr>';
|
||
const displayCols = ['期号', '开奖日期', ...ballCols];
|
||
if (blueCol) displayCols.push(blueCol);
|
||
// Add extra useful columns
|
||
const extraCols = data.columns.filter(c =>
|
||
!displayCols.includes(c) &&
|
||
!['特别号', '奖池'].includes(c)
|
||
);
|
||
displayCols.push(...extraCols);
|
||
|
||
displayCols.forEach(c => { html += `<th>${c}</th>`; });
|
||
html += '</tr></thead><tbody>';
|
||
|
||
data.records.forEach(rec => {
|
||
html += '<tr>';
|
||
displayCols.forEach(col => {
|
||
let val = rec[col];
|
||
if (val === null || val === undefined) val = '-';
|
||
if (ballCols.includes(col) || col === blueCol) {
|
||
const isBlue = col === blueCol;
|
||
val = `<span class="ball ${isBlue ? 'ball-blue' : 'ball-red'}">${String(val).padStart(2,'0')}</span>`;
|
||
}
|
||
html += `<td>${val}</td>`;
|
||
});
|
||
html += '</tr>';
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
container.innerHTML = html;
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(data.total / data.page_size);
|
||
if (totalPages > 1) {
|
||
let p = '';
|
||
p += `<button ${data.page <= 1 ? 'disabled' : ''} onclick="loadHistory(${data.page - 1})">‹ 上一页</button>`;
|
||
p += `<span class="page-info">第 ${data.page}/${totalPages} 页 (共 ${data.total} 条)</span>`;
|
||
p += `<button ${data.page >= totalPages ? 'disabled' : ''} onclick="loadHistory(${data.page + 1})">下一页 ›</button>`;
|
||
pagination.innerHTML = p;
|
||
}
|
||
}
|
||
|
||
function debounceSearch() {
|
||
clearTimeout(state.searchTimer);
|
||
state.searchTimer = setTimeout(() => loadHistory(1), 500);
|
||
}
|
||
|
||
// ============================================================
|
||
// Generation Records
|
||
// ============================================================
|
||
async function loadRecords(page) {
|
||
state.recordsPage = page;
|
||
const container = document.getElementById('recordsContent');
|
||
const pagination = document.getElementById('recordsPagination');
|
||
|
||
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
|
||
pagination.innerHTML = '';
|
||
|
||
try {
|
||
const data = await api(`/api/records?page=${page}&page_size=20`);
|
||
renderRecords(data, container, pagination);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>暂无生成记录</div></div>';
|
||
}
|
||
}
|
||
|
||
function renderRecords(data, container, pagination) {
|
||
if (!data.records || data.records.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>暂无生成记录</div></div>';
|
||
pagination.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
data.records.forEach(rec => {
|
||
const fileSize = rec.filesize ? (rec.filesize / 1024).toFixed(1) + ' KB' : '未知';
|
||
html += `<div class="record-item">
|
||
<div class="record-meta">
|
||
<div class="title">${rec.strategy} · ${rec.num_tickets} 注</div>
|
||
<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>
|
||
</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(data.total / data.page_size);
|
||
if (totalPages > 1) {
|
||
let p = '';
|
||
p += `<button ${data.page <= 1 ? 'disabled' : ''} onclick="loadRecords(${data.page - 1})">‹ 上一页</button>`;
|
||
p += `<span class="page-info">第 ${data.page}/${totalPages} 页 (共 ${data.total} 条)</span>`;
|
||
p += `<button ${data.page >= totalPages ? 'disabled' : ''} onclick="loadRecords(${data.page + 1})">下一页 ›</button>`;
|
||
pagination.innerHTML = p;
|
||
}
|
||
}
|
||
|
||
async function deleteRecord(id) {
|
||
if (!confirm('确定要删除这条记录吗?')) return;
|
||
try {
|
||
await api(`/api/records/${id}`, { method: 'DELETE' });
|
||
showToast('✅ 记录已删除', 'success');
|
||
loadRecords(state.recordsPage);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ============================================================
|
||
// Statistics Page
|
||
// ============================================================
|
||
async function loadStatsPage() {
|
||
const container = document.getElementById('statsContent');
|
||
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载统计中...</div></div>';
|
||
|
||
try {
|
||
const data = await api('/api/statistics');
|
||
renderStatsPage(data, container);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">😅</div><div>暂无统计数据</div></div>';
|
||
}
|
||
}
|
||
|
||
function renderStatsPage(data, container) {
|
||
let html = '';
|
||
|
||
// History count
|
||
if (data.history_count) {
|
||
html += `<div class="stats-grid" style="margin-bottom:16px;">
|
||
<div class="stat-item">
|
||
<div class="label">历史开奖期数</div>
|
||
<div class="value">${data.history_count}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Hot red balls
|
||
if (data.hot_reds) {
|
||
html += `<div class="card-title" style="margin-top:16px;">🔥 红球热号 TOP 15</div>
|
||
<div class="ball-list">`;
|
||
data.hot_reds.forEach(r => {
|
||
html += `<span class="ball ball-red ball-lg">${String(r).padStart(2,'0')}</span>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// Cold red balls
|
||
if (data.cold_reds) {
|
||
html += `<div class="card-title" style="margin-top:16px;">❄️ 红球冷号 TOP 15</div>
|
||
<div class="ball-list">`;
|
||
data.cold_reds.forEach(r => {
|
||
html += `<span class="ball ball-red ball-lg">${String(r).padStart(2,'0')}</span>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// Hot blue balls
|
||
if (data.hot_blues) {
|
||
html += `<div class="card-title" style="margin-top:16px;">🔥 蓝球热号 TOP 8</div>
|
||
<div class="ball-list">`;
|
||
data.hot_blues.forEach(b => {
|
||
html += `<span class="ball ball-blue ball-lg">${String(b).padStart(2,'0')}</span>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// Stats grid
|
||
html += '<div class="stats-grid" style="margin-top:16px;">';
|
||
|
||
if (data.common_odd_even) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">最常见奇偶比</div>
|
||
<div class="value">${data.common_odd_even}</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (data.common_size_ratio) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">最常见大小比</div>
|
||
<div class="value">${data.common_size_ratio}</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (data.sum_range) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">和值范围</div>
|
||
<div class="value">${data.sum_range.min} - ${data.sum_range.max}</div>
|
||
<div class="sub">平均 ${data.sum_range.mean?.toFixed(1)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (data.span_range) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">跨度范围</div>
|
||
<div class="value">${data.span_range.min} - ${data.span_range.max}</div>
|
||
<div class="sub">平均 ${data.span_range.mean?.toFixed(1)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ============================================================
|
||
// Stats Overview (on generate page)
|
||
// ============================================================
|
||
async function loadStatsOverview() {
|
||
const container = document.getElementById('statsOverviewContent');
|
||
try {
|
||
const data = await api('/api/statistics');
|
||
let html = '<div class="stats-grid">';
|
||
|
||
if (data.history_count) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">历史期数</div>
|
||
<div class="value">${data.history_count}</div>
|
||
</div>`;
|
||
}
|
||
if (data.common_odd_even) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">常见奇偶比</div>
|
||
<div class="value">${data.common_odd_even}</div>
|
||
</div>`;
|
||
}
|
||
if (data.common_size_ratio) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">常见大小比</div>
|
||
<div class="value">${data.common_size_ratio}</div>
|
||
</div>`;
|
||
}
|
||
if (data.sum_range) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">和值范围</div>
|
||
<div class="value">${data.sum_range.min}-${data.sum_range.max}</div>
|
||
</div>`;
|
||
}
|
||
if (data.span_range) {
|
||
html += `<div class="stat-item">
|
||
<div class="label">跨度范围</div>
|
||
<div class="value">${data.span_range.min}-${data.span_range.max}</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// Hot balls preview
|
||
if (data.hot_reds) {
|
||
html += `<div style="margin-top:12px;">
|
||
<div style="font-size:13px;font-weight:500;margin-bottom:8px;">🔥 红球热号 TOP 10</div>
|
||
<div class="ball-list">`;
|
||
data.hot_reds.slice(0, 10).forEach(r => {
|
||
html += `<span class="ball ball-red">${String(r).padStart(2,'0')}</span>`;
|
||
});
|
||
html += '</div></div>';
|
||
}
|
||
|
||
if (data.hot_blues) {
|
||
html += `<div style="margin-top:12px;">
|
||
<div style="font-size:13px;font-weight:500;margin-bottom:8px;">🔥 蓝球热号 TOP 5</div>
|
||
<div class="ball-list">`;
|
||
data.hot_blues.slice(0, 5).forEach(b => {
|
||
html += `<span class="ball ball-blue">${String(b).padStart(2,'0')}</span>`;
|
||
});
|
||
html += '</div></div>';
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">😅</div><div>无法加载统计数据</div></div>';
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 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;
|
||
// PRD v1.2 BIZ-105:中奖金额汇总字段
|
||
const totalPrize = d.total_prize || 0;
|
||
const totalPrizeDisplay = d.total_prize_display || '0元';
|
||
const hasVariablePrize = !!d.has_variable_prize;
|
||
const prizeDisplay = d.prize_display || [];
|
||
|
||
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 class="item"><div class="label">总中奖金额${hasVariablePrize ? '<span style="font-size:10px;color:#e67e22;font-weight:400;margin-left:4px;">含浮动奖</span>' : ''}</div><div class="value" style="color:${totalPrize > 0 ? '#27ae60' : 'var(--text-light)'};">${totalPrizeDisplay}</div></div>`;
|
||
html += '</div>';
|
||
|
||
// PRD v1.2 BIZ-105:按奖项分行汇总(不展示浮动奖金额)
|
||
if (prizeDisplay.length > 0) {
|
||
html += '<div class="prize-table-wrap">';
|
||
html += '<div class="prize-table-title">中奖奖项汇总</div>';
|
||
html += '<table class="prize-table"><thead><tr><th class="col-level">奖级</th><th class="col-count">中奖注数</th><th class="col-amount">单注金额</th></tr></thead><tbody>';
|
||
prizeDisplay.forEach(row => {
|
||
html += `<tr><td class="col-level">${row.level}</td><td class="col-count">${row.count} 注</td><td class="col-amount">${row.amount}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
// PRD v1.2 BIZ-105:浮动奖提示(一/二等奖金额仅参考,不计入总金额)
|
||
if (hasVariablePrize) {
|
||
html += '<div class="variable-prize-tip">说明:一/二等奖金额为占位值,实际按奖池浮动,不计入「总中奖金额」。</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>`;
|
||
// PRD v1.2 BIZ-105:每注中奖金额(未中奖不展示)
|
||
let amountBadge = '';
|
||
if (r.is_win) {
|
||
const isFloating = r.prize_level === '一等奖' || r.prize_level === '二等奖';
|
||
const amountDisplay = r.prize_amount_display || `${r.prize_amount || 0}元`;
|
||
amountBadge = `<span class="ticket-amount${isFloating ? ' floating' : ''}">${amountDisplay}</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}${amountBadge}
|
||
<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
|
||
// ============================================================
|
||
loadStatsOverview();
|
||
</script>
|
||
</body>
|
||
</html>
|