feat: 生成记录预览 + 中奖比对功能
功能一:生成记录预览 - 新增 API /api/records/<id>/preview 读取 Excel 返回号码数据 - 前端记录列表新增「预览」按钮,弹窗展示号码表格 - 支持最多显示100注,完整数据可通过下载按钮获取 功能二:中奖比对 - 新增 API /api/records/<id>/compare 根据生成时间匹配开奖数据 - 比对规则:生成时间 < 开奖日20:00 → 与当日开奖比对;≥ 20:00 → 与下次开奖日比对 - 按双色球官方规则判断一等奖至六等奖 - 前端记录列表新增「比对」按钮,弹窗展示比对结果 - 中奖号码高亮显示,未中奖号码半透明 - 若下次开奖尚未举行,返回等待状态提示 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -12,10 +12,13 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from flask import Flask, send_from_directory, jsonify, request, send_file, abort
|
from flask import Flask, send_from_directory, jsonify, request, send_file, abort
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import re
|
||||||
|
|
||||||
# 将项目目录加入路径,以便导入 lottery.py 和 history_loader.py
|
# 将项目目录加入路径,以便导入 lottery.py 和 history_loader.py
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
@@ -113,6 +116,91 @@ def load_history_dataframe():
|
|||||||
"""加载历史数据 Excel,委托公共模块处理多格式兼容。"""
|
"""加载历史数据 Excel,委托公共模块处理多格式兼容。"""
|
||||||
return _load_history(CONFIG['history_file'])
|
return _load_history(CONFIG['history_file'])
|
||||||
|
|
||||||
|
|
||||||
|
def load_latest_draw():
|
||||||
|
"""加载最近一期开奖数据,返回 dict 或 None。"""
|
||||||
|
try:
|
||||||
|
df = load_history_dataframe()
|
||||||
|
if df is None or df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解析日期,按开奖时间降序找最近一期
|
||||||
|
df['_dt'] = pd.to_datetime(df['开奖时间'], errors='coerce')
|
||||||
|
df = df.sort_values('_dt', ascending=False).reset_index(drop=True)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = df.iloc[0]
|
||||||
|
s = str(row.get('号码', '')).strip()
|
||||||
|
reds, blue = parse_number_string(s)
|
||||||
|
|
||||||
|
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||||||
|
draw_date = None
|
||||||
|
try:
|
||||||
|
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
try:
|
||||||
|
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||||||
|
if pd.notna(dt):
|
||||||
|
draw_date = dt.to_pydatetime()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'date': draw_date_str,
|
||||||
|
'date_obj': draw_date,
|
||||||
|
'period': str(row.get('期数', '')).strip(),
|
||||||
|
'reds': reds,
|
||||||
|
'blue': blue,
|
||||||
|
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_next_draw_after(date_obj):
|
||||||
|
"""查找给定日期之后最近一期的开奖数据,返回 dict 或 None。"""
|
||||||
|
try:
|
||||||
|
df = load_history_dataframe()
|
||||||
|
if df is None or df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
df['_dt'] = pd.to_datetime(df['开奖时间'], errors='coerce')
|
||||||
|
# 找开奖日期 > date_obj 的最近一期
|
||||||
|
future = df[df['_dt'] > date_obj].sort_values('_dt', ascending=True)
|
||||||
|
if future.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = future.iloc[0]
|
||||||
|
s = str(row.get('号码', '')).strip()
|
||||||
|
reds, blue = parse_number_string(s)
|
||||||
|
|
||||||
|
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||||||
|
draw_date = None
|
||||||
|
try:
|
||||||
|
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
try:
|
||||||
|
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||||||
|
if pd.notna(dt):
|
||||||
|
draw_date = dt.to_pydatetime()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'date': draw_date_str,
|
||||||
|
'date_obj': draw_date,
|
||||||
|
'period': str(row.get('期数', '')).strip(),
|
||||||
|
'reds': reds,
|
||||||
|
'blue': blue,
|
||||||
|
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 认证装饰器(可选)
|
# 认证装饰器(可选)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -313,6 +401,230 @@ def api_download(filepath):
|
|||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API:生成记录预览
|
||||||
|
# ============================================================
|
||||||
|
@app.route('/api/records/<record_id>/preview')
|
||||||
|
@require_auth
|
||||||
|
def api_preview_record(record_id):
|
||||||
|
"""预览生成记录中的号码数据(无需下载 Excel)"""
|
||||||
|
try:
|
||||||
|
records = load_records()
|
||||||
|
target = None
|
||||||
|
for r in records:
|
||||||
|
if r['id'] == record_id:
|
||||||
|
target = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||||
|
|
||||||
|
filepath = os.path.join(BASE_DIR, target['filename'])
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return jsonify({'success': False, 'error': '文件不存在'}), 404
|
||||||
|
|
||||||
|
# 读取 Excel
|
||||||
|
df = pd.read_excel(filepath, sheet_name='生成号码', engine='openpyxl')
|
||||||
|
|
||||||
|
tickets = []
|
||||||
|
red_cols = [f'红球{i}' for i in range(1, 7)]
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
reds = [int(row[c]) for c in red_cols if c in df.columns and pd.notna(row[c])]
|
||||||
|
blue = int(row['蓝球']) if '蓝球' in df.columns and pd.notna(row['蓝球']) else 0
|
||||||
|
ticket = {
|
||||||
|
'index': int(row['序号']) if '序号' in df.columns and pd.notna(row['序号']) else len(tickets) + 1,
|
||||||
|
'reds': reds,
|
||||||
|
'blue': blue,
|
||||||
|
'sum_value': int(row['和值']) if '和值' in df.columns and pd.notna(row['和值']) else sum(reds),
|
||||||
|
'odd_even': str(row['奇偶比']) if '奇偶比' in df.columns and pd.notna(row['奇偶比']) else '',
|
||||||
|
'size_ratio': str(row['大小比']) if '大小比' in df.columns and pd.notna(row['大小比']) else '',
|
||||||
|
'span': int(row['跨度']) if '跨度' in df.columns and pd.notna(row['跨度']) else (max(reds) - min(reds) if reds else 0),
|
||||||
|
}
|
||||||
|
tickets.append(ticket)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'record': target,
|
||||||
|
'tickets': tickets,
|
||||||
|
'total': len(tickets)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'success': False, 'error': '预览数据加载失败'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API:中奖比对
|
||||||
|
# ============================================================
|
||||||
|
@app.route('/api/records/<record_id>/compare')
|
||||||
|
@require_auth
|
||||||
|
def api_compare_record(record_id):
|
||||||
|
"""将生成记录与对应开奖数据进行中奖比对。
|
||||||
|
|
||||||
|
比对规则:
|
||||||
|
- 生成时间 < 开奖日 20:00 → 与当日开奖比对
|
||||||
|
- 生成时间 >= 开奖日 20:00 → 与下次开奖日比对
|
||||||
|
|
||||||
|
双色球中奖规则:
|
||||||
|
- 一等奖:6红+1蓝
|
||||||
|
- 二等奖:6红+0蓝
|
||||||
|
- 三等奖:5红+1蓝
|
||||||
|
- 四等奖:5红+0蓝 或 4红+1蓝
|
||||||
|
- 五等奖:4红+0蓝 或 3红+1蓝
|
||||||
|
- 六等奖:2红+1蓝 或 1红+1蓝 或 0红+1蓝
|
||||||
|
- 未中奖:其他
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = load_records()
|
||||||
|
target = None
|
||||||
|
for r in records:
|
||||||
|
if r['id'] == record_id:
|
||||||
|
target = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||||
|
|
||||||
|
filepath = os.path.join(BASE_DIR, target['filename'])
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return jsonify({'success': False, 'error': '文件不存在'}), 404
|
||||||
|
|
||||||
|
# 解析生成时间
|
||||||
|
gen_time_str = target.get('created_at', '')
|
||||||
|
try:
|
||||||
|
gen_time = datetime.strptime(gen_time_str, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'success': False, 'error': '无法解析生成时间'}), 400
|
||||||
|
|
||||||
|
# 读取生成的号码
|
||||||
|
df = pd.read_excel(filepath, sheet_name='生成号码', engine='openpyxl')
|
||||||
|
red_cols = [f'红球{i}' for i in range(1, 7)]
|
||||||
|
tickets = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
reds = [int(row[c]) for c in red_cols if c in df.columns and pd.notna(row[c])]
|
||||||
|
blue = int(row['蓝球']) if '蓝球' in df.columns and pd.notna(row['蓝球']) else 0
|
||||||
|
tickets.append({'reds': reds, 'blue': blue})
|
||||||
|
|
||||||
|
if not tickets:
|
||||||
|
return jsonify({'success': False, 'error': '无号码数据可比对'}), 400
|
||||||
|
|
||||||
|
# 加载历史开奖数据,找最近一期
|
||||||
|
latest_draw = load_latest_draw()
|
||||||
|
if not latest_draw or not latest_draw.get('reds'):
|
||||||
|
return jsonify({'success': False, 'error': '无法加载开奖数据'}), 500
|
||||||
|
|
||||||
|
# 确定比对的开奖期
|
||||||
|
# 规则:生成时间 < 开奖日 20:00 → 与当日开奖比对;>= 20:00 → 与下次开奖日比对
|
||||||
|
draw_to_compare = None
|
||||||
|
compare_type = ''
|
||||||
|
|
||||||
|
latest_date = latest_draw.get('date_obj')
|
||||||
|
if latest_date:
|
||||||
|
# 当日 20:00 的时间点
|
||||||
|
draw_day_8pm = latest_date.replace(hour=20, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
if gen_time < draw_day_8pm:
|
||||||
|
# 生成时间在开奖日 20:00 之前 → 与当日(最近一期)比对
|
||||||
|
draw_to_compare = latest_draw
|
||||||
|
compare_type = 'current'
|
||||||
|
else:
|
||||||
|
# 生成时间在开奖日 20:00 之后 → 与下次开奖日比对
|
||||||
|
next_draw = find_next_draw_after(latest_date)
|
||||||
|
if next_draw:
|
||||||
|
draw_to_compare = next_draw
|
||||||
|
compare_type = 'next'
|
||||||
|
else:
|
||||||
|
# 暂无下次开奖数据,返回等待状态
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'status': 'waiting',
|
||||||
|
'message': f'生成时间 {gen_time_str} 在最近开奖日 {latest_draw["date"]} 20:00 之后,下次开奖尚未举行,请等待开奖后再次比对。',
|
||||||
|
'gen_time': gen_time_str,
|
||||||
|
'latest_draw_date': latest_draw['date'],
|
||||||
|
'latest_draw_period': latest_draw['period'],
|
||||||
|
'compare_rule': '生成时间 ≥ 开奖日 20:00 → 等待下次开奖日比对'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 无法解析开奖日期,直接用最近一期
|
||||||
|
draw_to_compare = latest_draw
|
||||||
|
compare_type = 'current_fallback'
|
||||||
|
|
||||||
|
# 执行比对
|
||||||
|
draw_reds = set(draw_to_compare['reds'])
|
||||||
|
draw_blue = draw_to_compare['blue']
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, t in enumerate(tickets):
|
||||||
|
ticket_reds = set(t['reds'])
|
||||||
|
ticket_blue = t['blue']
|
||||||
|
|
||||||
|
red_matches = len(ticket_reds & draw_reds)
|
||||||
|
blue_match = (ticket_blue == draw_blue)
|
||||||
|
|
||||||
|
# 判断中奖等级
|
||||||
|
prize_level = '未中奖'
|
||||||
|
prize_desc = ''
|
||||||
|
|
||||||
|
if red_matches == 6 and blue_match:
|
||||||
|
prize_level = '一等奖'
|
||||||
|
prize_desc = '6红+1蓝 中大奖!'
|
||||||
|
elif red_matches == 6 and not blue_match:
|
||||||
|
prize_level = '二等奖'
|
||||||
|
prize_desc = '6红+0蓝'
|
||||||
|
elif red_matches == 5 and blue_match:
|
||||||
|
prize_level = '三等奖'
|
||||||
|
prize_desc = '5红+1蓝'
|
||||||
|
elif (red_matches == 5 and not blue_match) or (red_matches == 4 and blue_match):
|
||||||
|
prize_level = '四等奖'
|
||||||
|
prize_desc = f'{red_matches}红+{"1" if blue_match else "0"}蓝'
|
||||||
|
elif (red_matches == 4 and not blue_match) or (red_matches == 3 and blue_match):
|
||||||
|
prize_level = '五等奖'
|
||||||
|
prize_desc = f'{red_matches}红+{"1" if blue_match else "0"}蓝'
|
||||||
|
elif blue_match and red_matches <= 2:
|
||||||
|
prize_level = '六等奖'
|
||||||
|
prize_desc = f'{red_matches}红+1蓝'
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'index': i + 1,
|
||||||
|
'reds': t['reds'],
|
||||||
|
'blue': ticket_blue,
|
||||||
|
'red_matches': red_matches,
|
||||||
|
'blue_match': blue_match,
|
||||||
|
'prize_level': prize_level,
|
||||||
|
'prize_desc': prize_desc,
|
||||||
|
'is_win': prize_level != '未中奖'
|
||||||
|
})
|
||||||
|
|
||||||
|
win_count = sum(1 for r in results if r['is_win'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'status': 'compared',
|
||||||
|
'gen_time': gen_time_str,
|
||||||
|
'compare_type': compare_type,
|
||||||
|
'compare_rule': '生成时间 < 开奖日 20:00 → 与当日开奖比对' if compare_type == 'current' else '生成时间 ≥ 开奖日 20:00 → 与下次开奖日比对',
|
||||||
|
'draw': {
|
||||||
|
'date': draw_to_compare['date'],
|
||||||
|
'period': draw_to_compare['period'],
|
||||||
|
'reds': draw_to_compare['reds'],
|
||||||
|
'blue': draw_to_compare['blue']
|
||||||
|
},
|
||||||
|
'results': results,
|
||||||
|
'total': len(results),
|
||||||
|
'win_count': win_count,
|
||||||
|
'win_tickets': [r for r in results if r['is_win']]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'success': False, 'error': '中奖比对失败'}), 500
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# API:历史数据查看
|
# API:历史数据查看
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
+292
@@ -527,6 +527,122 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Modal / Preview
|
||||||
|
============================================================ */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 40px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-overlay.show { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--card-bg);
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
.modal-header .title { font-size: 16px; font-weight: 600; }
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 8px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
.modal-body { padding: 20px; }
|
||||||
|
|
||||||
|
.compare-ticket {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.compare-ticket:last-child { border-bottom: none; }
|
||||||
|
.compare-ticket.win {
|
||||||
|
background: rgba(39,174,96,0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.prize-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.prize-badge.win { background: #27ae60; color: white; }
|
||||||
|
.prize-badge.lose { background: #f0f0f0; color: var(--text-light); }
|
||||||
|
.compare-ticket-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.match-info {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.match-info.match { background: #27ae60; color: white; }
|
||||||
|
|
||||||
|
.compare-summary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.compare-summary .item .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.compare-summary .item .value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-overlay { padding: 20px 8px; }
|
||||||
|
.modal { max-height: 90vh; }
|
||||||
|
.modal-header { padding: 12px 16px; }
|
||||||
|
.modal-body { padding: 14px; }
|
||||||
|
.compare-ticket { padding: 8px 10px; gap: 4px; }
|
||||||
|
.compare-ticket-info { margin-left: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
History Table Mobile Card View
|
History Table Mobile Card View
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -680,6 +796,19 @@ Page: 号码生成
|
|||||||
</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
|
Mobile Bottom Navigation
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
@@ -971,6 +1100,8 @@ function renderRecords(data, container, pagination) {
|
|||||||
<div class="desc">${rec.created_at} · ${fileSize}</div>
|
<div class="desc">${rec.created_at} · ${fileSize}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="record-actions">
|
<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-blue btn-sm" onclick="window.open('/api/download/${rec.filename}', '_blank')">📥 下载</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="deleteRecord('${rec.id}')">🗑️ 删除</button>
|
<button class="btn btn-danger btn-sm" onclick="deleteRecord('${rec.id}')">🗑️ 删除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1162,6 +1293,167 @@ async function loadStatsOverview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Modal helpers
|
||||||
|
// ============================================================
|
||||||
|
function closeModal(e) {
|
||||||
|
if (e.target === document.getElementById('modalOverlay')) closeModalDirect();
|
||||||
|
}
|
||||||
|
function closeModalDirect() {
|
||||||
|
document.getElementById('modalOverlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Record Preview
|
||||||
|
// ============================================================
|
||||||
|
async function previewRecord(id) {
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalBody = document.getElementById('modalBody');
|
||||||
|
const overlay = document.getElementById('modalOverlay');
|
||||||
|
|
||||||
|
modalTitle.textContent = '👁️ 号码预览';
|
||||||
|
modalBody.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
|
||||||
|
overlay.classList.add('show');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/records/${id}/preview`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || '加载失败');
|
||||||
|
|
||||||
|
const rec = data.data.record;
|
||||||
|
const tickets = data.data.tickets;
|
||||||
|
const total = data.data.total;
|
||||||
|
const displayCount = Math.min(tickets.length, 100);
|
||||||
|
|
||||||
|
let html = `<div style="margin-bottom:12px;color:var(--text-light);font-size:13px;">
|
||||||
|
${rec.strategy} · ${rec.num_tickets} 注 · ${rec.created_at} · 共 ${total} 注
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (total > displayCount) {
|
||||||
|
html += `<div style="margin-bottom:8px;font-size:12px;color:var(--text-light);">仅显示前 ${displayCount} 注,完整数据请下载 Excel</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
'<th>序号</th><th>红球</th><th>蓝球</th><th>和值</th><th>奇偶比</th><th>大小比</th><th>跨度</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
for (let i = 0; i < displayCount; i++) {
|
||||||
|
const t = tickets[i];
|
||||||
|
const redBalls = t.reds.map(r => `<span class="ball ball-red">${String(r).padStart(2,'0')}</span>`).join('');
|
||||||
|
const blueBall = `<span class="ball ball-blue">${String(t.blue).padStart(2,'0')}</span>`;
|
||||||
|
html += `<tr>
|
||||||
|
<td>${String(t.index).padStart(3,'0')}</td>
|
||||||
|
<td style="white-space:nowrap;">${redBalls}</td>
|
||||||
|
<td>${blueBall}</td>
|
||||||
|
<td>${t.sum_value}</td>
|
||||||
|
<td>${t.odd_even}</td>
|
||||||
|
<td>${t.size_ratio}</td>
|
||||||
|
<td>${t.span}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
html += `<div class="btn-group" style="margin-top:16px;">
|
||||||
|
<button class="btn btn-blue btn-sm" onclick="window.open('/api/download/${rec.filename}', '_blank')">📥 下载完整 Excel</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
modalBody.innerHTML = `<div class="empty-state"><div class="icon">😅</div><div>${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Win Compare
|
||||||
|
// ============================================================
|
||||||
|
async function compareRecord(id) {
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalBody = document.getElementById('modalBody');
|
||||||
|
const overlay = document.getElementById('modalOverlay');
|
||||||
|
|
||||||
|
modalTitle.textContent = '🎯 中奖比对';
|
||||||
|
modalBody.innerHTML = '<div class="loading"><div class="spinner"></div><div>加载中...</div></div>';
|
||||||
|
overlay.classList.add('show');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/records/${id}/compare`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || '比对失败');
|
||||||
|
|
||||||
|
const d = data.data;
|
||||||
|
|
||||||
|
if (d.status === 'waiting') {
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div style="text-align:center;padding:30px 16px;">
|
||||||
|
<div style="font-size:36px;margin-bottom:12px;">⏳</div>
|
||||||
|
<div style="font-size:15px;font-weight:600;margin-bottom:8px;">等待开奖</div>
|
||||||
|
<div style="color:var(--text-light);font-size:13px;line-height:1.8;">
|
||||||
|
${d.message}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;background:#f8f9fa;padding:12px;border-radius:8px;font-size:12px;color:var(--text-light);">
|
||||||
|
比对规则:${d.compare_rule}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = d.draw;
|
||||||
|
const results = d.results;
|
||||||
|
const winCount = d.win_count;
|
||||||
|
|
||||||
|
let html = '<div class="compare-summary">';
|
||||||
|
html += `<div class="item"><div class="label">生成时间</div><div class="value" style="font-size:14px;">${d.gen_time}</div></div>`;
|
||||||
|
html += `<div class="item"><div class="label">比对开奖期</div><div class="value" style="font-size:14px;">${draw.period} · ${draw.date}</div></div>`;
|
||||||
|
html += `<div class="item"><div class="label">总注数</div><div class="value">${d.total}</div></div>`;
|
||||||
|
html += `<div class="item"><div class="label">中奖注数</div><div class="value" style="color:${winCount > 0 ? '#27ae60' : 'var(--text-light)'};">${winCount}</div></div>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Show draw result
|
||||||
|
html += `<div style="margin-bottom:12px;background:#f0f0f0;padding:10px;border-radius:8px;">
|
||||||
|
<span style="font-size:13px;font-weight:600;margin-right:8px;">开奖号码:</span>
|
||||||
|
${draw.reds.map(r => `<span class="ball ball-red">${String(r).padStart(2,'0')}</span>`).join('')}
|
||||||
|
<span class="ball ball-blue">${String(draw.blue).padStart(2,'0')}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
html += `<div style="font-size:12px;color:var(--text-light);margin-bottom:10px;">比对规则:${d.compare_rule}</div>`;
|
||||||
|
|
||||||
|
const displayCount = Math.min(results.length, 100);
|
||||||
|
for (let i = 0; i < displayCount; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
const redBalls = r.reds.map(rd => {
|
||||||
|
const isMatch = draw.reds.includes(rd);
|
||||||
|
return `<span class="ball ball-red${isMatch ? '' : ''}" style="${isMatch ? 'box-shadow:0 0 0 3px #27ae60;' : 'opacity:0.4;'}">${String(rd).padStart(2,'0')}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const blueBall = `<span class="ball ball-blue" style="${r.blue_match ? 'box-shadow:0 0 0 3px #27ae60;' : 'opacity:0.4;'}">${String(r.blue).padStart(2,'0')}</span>`;
|
||||||
|
const prizeBadge = r.is_win
|
||||||
|
? `<span class="prize-badge win">${r.prize_level}</span>`
|
||||||
|
: `<span class="prize-badge lose">未中奖</span>`;
|
||||||
|
const matchInfo = `<span class="match-info ${r.red_matches > 0 ? 'match' : ''}">红${r.red_matches}</span> <span class="match-info ${r.blue_match ? 'match' : ''}">蓝${r.blue_match ? '✓' : '✗'}</span>`;
|
||||||
|
|
||||||
|
html += `<div class="compare-ticket${r.is_win ? ' win' : ''}">
|
||||||
|
<span style="font-size:12px;color:var(--text-light);min-width:36px;">${String(r.index).padStart(3,'0')}</span>
|
||||||
|
${redBalls}${blueBall}
|
||||||
|
${prizeBadge}
|
||||||
|
<span class="compare-ticket-info">${matchInfo} ${r.prize_desc || ''}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length > displayCount) {
|
||||||
|
html += `<div style="text-align:center;padding:12px;font-size:13px;color:var(--text-light);">... 仅显示前 ${displayCount} 注,共 ${results.length} 注</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winCount === 0) {
|
||||||
|
html += '<div style="text-align:center;padding:16px;font-size:14px;color:var(--text-light);">本次生成号码未中奖,继续努力!</div>';
|
||||||
|
} else {
|
||||||
|
html += `<div style="text-align:center;padding:16px;font-size:14px;color:#27ae60;font-weight:600;">🎉 共 ${winCount} 注中奖!</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalBody.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
modalBody.innerHTML = `<div class="empty-state"><div class="icon">😅</div><div>${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user