diff --git a/app.py b/app.py index de79384..6273fc9 100644 --- a/app.py +++ b/app.py @@ -12,10 +12,13 @@ import json import uuid import traceback import threading -from datetime import datetime +from datetime import datetime, timedelta from flask import Flask, send_from_directory, jsonify, request, send_file, abort from functools import wraps +import pandas as pd +import re + # 将项目目录加入路径,以便导入 lottery.py 和 history_loader.py BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -113,6 +116,91 @@ def load_history_dataframe(): """加载历史数据 Excel,委托公共模块处理多格式兼容。""" 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) +# ============================================================ +# API:生成记录预览 +# ============================================================ +@app.route('/api/records//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//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:历史数据查看 # ============================================================ diff --git a/index.html b/index.html index 7561421..610f4f9 100644 --- a/index.html +++ b/index.html @@ -527,6 +527,122 @@ font-weight: 600; } + /* ============================================================ + Modal / Preview + ============================================================ */ + .modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + justify-content: center; + align-items: flex-start; + padding: 40px 16px; + overflow-y: auto; + } + .modal-overlay.show { display: flex; } + .modal { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: 0 8px 40px rgba(0,0,0,0.3); + width: 100%; + max-width: 800px; + max-height: 85vh; + overflow-y: auto; + margin-bottom: 40px; + } + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--card-bg); + z-index: 10; + border-radius: var(--radius) var(--radius) 0 0; + } + .modal-header .title { font-size: 16px; font-weight: 600; } + .modal-close { + background: none; + border: none; + font-size: 24px; + color: var(--text-light); + cursor: pointer; + padding: 0 8px; + line-height: 1; + } + .modal-close:hover { color: var(--text); } + .modal-body { padding: 20px; } + + .compare-ticket { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid #f0f0f0; + flex-wrap: wrap; + } + .compare-ticket:last-child { border-bottom: none; } + .compare-ticket.win { + background: rgba(39,174,96,0.06); + border-radius: 8px; + } + .prize-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-left: 6px; + } + .prize-badge.win { background: #27ae60; color: white; } + .prize-badge.lose { background: #f0f0f0; color: var(--text-light); } + .compare-ticket-info { + font-size: 12px; + color: var(--text-light); + margin-left: auto; + } + .match-info { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + background: #f0f0f0; + color: var(--text); + } + .match-info.match { background: #27ae60; color: white; } + + .compare-summary { + background: #f8f9fa; + border-radius: 8px; + padding: 14px; + margin-bottom: 16px; + display: flex; + gap: 16px; + flex-wrap: wrap; + } + .compare-summary .item .label { + font-size: 12px; + color: var(--text-light); + } + .compare-summary .item .value { + font-size: 18px; + font-weight: 700; + color: var(--text); + } + + @media (max-width: 768px) { + .modal-overlay { padding: 20px 8px; } + .modal { max-height: 90vh; } + .modal-header { padding: 12px 16px; } + .modal-body { padding: 14px; } + .compare-ticket { padding: 8px 10px; gap: 4px; } + .compare-ticket-info { margin-left: 0; width: 100%; } + } + /* ============================================================ History Table Mobile Card View ============================================================ */ @@ -680,6 +796,19 @@ Page: 号码生成 + + + @@ -971,6 +1100,8 @@ function renderRecords(data, container, pagination) {
${rec.created_at} · ${fileSize}
+ +
@@ -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 = '
加载中...
'; + 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 = `
+ ${rec.strategy} · ${rec.num_tickets} 注 · ${rec.created_at} · 共 ${total} 注 +
`; + + if (total > displayCount) { + html += `
仅显示前 ${displayCount} 注,完整数据请下载 Excel
`; + } + + html += '
' + + '' + + ''; + + for (let i = 0; i < displayCount; i++) { + const t = tickets[i]; + const redBalls = t.reds.map(r => `${String(r).padStart(2,'0')}`).join(''); + const blueBall = `${String(t.blue).padStart(2,'0')}`; + html += ` + + + + + + + + `; + } + + html += '
序号红球蓝球和值奇偶比大小比跨度
${String(t.index).padStart(3,'0')}${redBalls}${blueBall}${t.sum_value}${t.odd_even}${t.size_ratio}${t.span}
'; + html += `
+ +
`; + + modalBody.innerHTML = html; + } catch (e) { + modalBody.innerHTML = `
😅
${e.message}
`; + } +} + +// ============================================================ +// 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 = '
加载中...
'; + 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 = ` +
+
+
等待开奖
+
+ ${d.message} +
+
+ 比对规则:${d.compare_rule} +
+
`; + return; + } + + const draw = d.draw; + const results = d.results; + const winCount = d.win_count; + + let html = '
'; + html += `
生成时间
${d.gen_time}
`; + html += `
比对开奖期
${draw.period} · ${draw.date}
`; + html += `
总注数
${d.total}
`; + html += `
中奖注数
${winCount}
`; + html += '
'; + + // Show draw result + html += `
+ 开奖号码: + ${draw.reds.map(r => `${String(r).padStart(2,'0')}`).join('')} + ${String(draw.blue).padStart(2,'0')} +
`; + + html += `
比对规则:${d.compare_rule}
`; + + 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 `${String(rd).padStart(2,'0')}`; + }).join(''); + const blueBall = `${String(r.blue).padStart(2,'0')}`; + const prizeBadge = r.is_win + ? `${r.prize_level}` + : `未中奖`; + const matchInfo = `红${r.red_matches} 蓝${r.blue_match ? '✓' : '✗'}`; + + html += `
+ ${String(r.index).padStart(3,'0')} + ${redBalls}${blueBall} + ${prizeBadge} + ${matchInfo} ${r.prize_desc || ''} +
`; + } + + if (results.length > displayCount) { + html += `
... 仅显示前 ${displayCount} 注,共 ${results.length} 注
`; + } + + if (winCount === 0) { + html += '
本次生成号码未中奖,继续努力!
'; + } else { + html += `
🎉 共 ${winCount} 注中奖!
`; + } + + modalBody.innerHTML = html; + } catch (e) { + modalBody.innerHTML = `
😅
${e.message}
`; + } +} + // ============================================================ // Init // ============================================================