Files
Lottery/index.html
徐聪 5d459b4505 feat(BIZ-105): 记录-比对 中奖金额展示 v1.2 实施
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>
2026-08-31 04:45:06 +08:00

1551 lines
58 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>&nbsp;</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>