Files
Lottery/index.html
vincent dcc7339384 feat(BIZ-110): index.html 前端鉴权适配 - localStorage + login 页 + Bearer 注入
方案 A 落地(严维序 COO 09-01 14:46 GMT+8 确认):
- 新增登录浮层(#/login 概念,hash 路由由 bootstrap() 管控)
- localStorage.lotto_api_token 存/取/清 token
- api() 函数自动注入 'Authorization: Bearer <token>'
- 401 → 清 token + 跳登录页
- Header 右侧 '退出' 按钮
- bootstrap() 启动入口:无 token 跳登录,有 token 进主页

测试:tests/mock_auth_server.py + jsdom 21/21 全过。
闭环:前端适配完成,待严维序撤 AUTH_BYPASS_PATHS 白名单。

—— 徐聪 (costcodev) | 2026-09-02 16:35 GMT+8

Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 16:45:43 +08:00

1780 lines
65 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; }
.header .logout-btn {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.18);
color: white;
border: 1px solid rgba(255,255,255,0.35);
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.header .logout-btn:hover { background: rgba(255,255,255,0.32); }
/* ============================================================
Login Overlay (BIZ-110)
============================================================ */
.login-overlay {
display: none;
position: fixed;
inset: 0;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 50%, #8e44ad 100%);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-overlay.show { display: flex; }
.login-card {
background: white;
border-radius: 16px;
padding: 36px 32px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}
.login-title { font-size: 22px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
.login-subtitle { font-size: 13px; color: var(--text-light); margin-bottom: 22px; }
.login-hint { font-size: 13px; color: var(--text-light); margin-bottom: 12px; }
.login-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 12px;
}
.login-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(231,76,60,0.1); }
.login-btn {
width: 100%;
padding: 12px 24px;
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(231,76,60,0.3); }
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.login-error {
margin-top: 12px;
padding: 10px 12px;
background: #fdecea;
color: #c0392b;
border-radius: 6px;
font-size: 13px;
text-align: left;
}
.login-foot { font-size: 12px; color: var(--text-light); margin-top: 16px; }
/* ============================================================
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>
<!-- ============================================================
Login Overlay (BIZ-110)
============================================================ -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<div class="login-title">🔐 登录验证</div>
<div class="login-subtitle">双色球号码生成系统</div>
<div class="login-hint" id="loginHint">请输入访问令牌(LOTTO_API_TOKEN)以继续</div>
<input type="password" class="login-input" id="loginTokenInput" placeholder="在此粘贴令牌..." autocomplete="off" onkeydown="if(event.key==='Enter')handleLoginSubmit()">
<button class="login-btn" id="loginSubmitBtn" onclick="handleLoginSubmit()">登录</button>
<div class="login-error" id="loginError" style="display:none;"></div>
<div class="login-foot">令牌保存在浏览器 localStorage,仅本机有效。</div>
</div>
</div>
<main id="mainApp">
<!-- ============================================================
Toast
============================================================ -->
<div class="toast" id="toast"></div>
<!-- ============================================================
Header
============================================================ -->
<div class="header">
<button class="logout-btn" onclick="logout()" title="退出登录(清除本地令牌)">退出</button>
<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,
};
// ============================================================
// Login Page (BIZ-110)
// ============================================================
function showLoginPage(reason) {
const overlay = document.getElementById('loginOverlay');
if (!overlay) return;
overlay.classList.add('show');
// 隐藏主内容
const main = document.getElementById('mainApp');
if (main) main.style.display = 'none';
// 提示信息
const hint = document.getElementById('loginHint');
if (hint) {
hint.textContent = reason || '请输入访问令牌(LOTTO_API_TOKEN)以继续';
}
// 自动聚焦输入框
const input = document.getElementById('loginTokenInput');
if (input) {
input.value = '';
setTimeout(() => input.focus(), 50);
}
}
function showMainPage() {
const overlay = document.getElementById('loginOverlay');
if (overlay) overlay.classList.remove('show');
const main = document.getElementById('mainApp');
if (main) main.style.display = '';
}
async function handleLoginSubmit() {
const input = document.getElementById('loginTokenInput');
const btn = document.getElementById('loginSubmitBtn');
const errEl = document.getElementById('loginError');
if (!input || !btn) return;
const token = (input.value || '').trim();
if (!token) {
if (errEl) {
errEl.textContent = '令牌不能为空';
errEl.style.display = 'block';
}
return;
}
if (errEl) errEl.style.display = 'none';
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = '⏳ 验证中...';
try {
// 试探一下 /api/status 验证 token(BIZ-110:/api/status 不走 require_auth,
// 因此用 /api/records 探测,且带回业务数据)
setToken(token);
await api('/api/records?page=1&page_size=1');
// 验证成功
showMainPage();
showToast('✅ 登录成功', 'success');
// 重新加载当前页面数据
if (state.currentPage) switchPage(state.currentPage);
} catch (e) {
setToken('');
if (errEl) {
errEl.textContent = '令牌无效:' + (e.message || '验证失败');
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = oldText;
}
}
// ============================================================
// 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();
}
// ============================================================
// Auth (BIZ-110)
// ============================================================
const AUTH_STORAGE_KEY = 'lotto_api_token';
function getToken() {
try {
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (e) {
return '';
}
}
function setToken(t) {
try {
if (t) localStorage.setItem(AUTH_STORAGE_KEY, t);
else localStorage.removeItem(AUTH_STORAGE_KEY);
} catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ }
}
function isLoggedIn() {
return !!getToken();
}
function logout(reason) {
setToken('');
showToast(reason || '已退出登录', 'info');
showLoginPage();
}
// ============================================================
// API Helper (BIZ-110: 注入 Bearer token)
// ============================================================
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
const token = getToken();
if (token) headers['Authorization'] = 'Bearer ' + token;
let res;
try {
res = await fetch(path, { ...options, headers });
} catch (e) {
showToast('网络错误:' + e.message, 'error');
throw e;
}
// 401 → 清 token + 跳登录页
if (res.status === 401) {
let msg = '未授权访问';
try {
const j = await res.json();
if (j && j.error) msg = j.error;
} catch (_) { /* 忽略 JSON 解析错误 */ }
setToken('');
showLoginPage(msg);
throw new Error(msg);
}
let data;
try {
data = await res.json();
} catch (e) {
showToast('响应解析失败', 'error');
throw e;
}
if (!data.success) {
showToast(data.error || '请求失败', 'error');
throw new Error(data.error || '请求失败');
}
return data.data;
}
// ============================================================
// 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
// ============================================================
function bootstrap() {
if (!isLoggedIn()) {
showLoginPage();
return;
}
showMainPage();
loadStatsOverview();
}
bootstrap();
</script>
</main>
</body>
</html>