init: 福彩3D数据分析工具
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="description" content="福彩3D数据分析工具 - 走势图、遗漏分析、形态分析、智能选号" />
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<title>福彩3D分析工具</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1666
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ssq-3d",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "福彩3D分析工具",
|
||||
"short_name": "3D分析",
|
||||
"description": "福彩3D数据分析 - 走势图、遗漏分析、形态分析、智能选号",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#e94560",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
福彩3D历史数据抓取脚本
|
||||
数据来源:中国福利彩票官网 (cwl.gov.cn)
|
||||
输出格式:JSON,字段 { period, date, hundreds, tens, ones }
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 输出路径
|
||||
OUTPUT_FILE = os.path.join(os.path.dirname(__file__), '..', 'src', 'data', 'history.json')
|
||||
|
||||
# 福彩3D API
|
||||
BASE_URL = 'https://www.cwl.gov.cn/cwl_admin/front/cwlkj/search/kjxx/findDrawNotice'
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.cwl.gov.cn/',
|
||||
}
|
||||
|
||||
|
||||
def fetch_page(page_no, page_size=100):
|
||||
"""获取一页数据"""
|
||||
params = {
|
||||
'name': '3d',
|
||||
'issueCount': '',
|
||||
'issueStart': '',
|
||||
'issueEnd': '',
|
||||
'dayStart': '',
|
||||
'dayEnd': '',
|
||||
'pageNo': page_no,
|
||||
'pageSize': page_size,
|
||||
'systemType': 'PC',
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f' [请求失败] 第 {page_no} 页: {e}')
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f' [解析失败] 第 {page_no} 页: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def parse_result(item):
|
||||
"""解析单条开奖数据"""
|
||||
# 开奖号码格式: "9,7,8" 或 "1 4 7"
|
||||
code = item.get('red', '')
|
||||
if not code:
|
||||
return None
|
||||
|
||||
# 支持逗号或空格分隔
|
||||
code = code.replace(',', ' ')
|
||||
parts = code.strip().split()
|
||||
if len(parts) != 3:
|
||||
parts = list(code.replace(' ', '').strip())
|
||||
|
||||
try:
|
||||
hundreds = int(parts[0])
|
||||
tens = int(parts[1])
|
||||
ones = int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
# 日期格式 "2026-08-02(日)" -> "2026-08-02"
|
||||
date_str = item.get('date', '')
|
||||
date_str = date_str.split('(')[0]
|
||||
|
||||
return {
|
||||
'period': item.get('code', ''), # 期号
|
||||
'date': date_str, # 日期
|
||||
'hundreds': hundreds,
|
||||
'tens': tens,
|
||||
'ones': ones,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print('福彩3D历史数据抓取')
|
||||
print('=' * 50)
|
||||
|
||||
all_data = []
|
||||
page_no = 1
|
||||
max_consecutive_empty = 3 # 连续空页数上限
|
||||
empty_count = 0
|
||||
|
||||
while True:
|
||||
print(f'正在获取第 {page_no} 页...', end=' ')
|
||||
|
||||
data = fetch_page(page_no)
|
||||
if not data:
|
||||
empty_count += 1
|
||||
if empty_count >= max_consecutive_empty:
|
||||
print(f'\n连续 {max_consecutive_empty} 页失败,停止')
|
||||
break
|
||||
page_no += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
empty_count = 0
|
||||
items = data.get('result', [])
|
||||
if not items:
|
||||
print('无数据,抓取完成')
|
||||
break
|
||||
|
||||
page_data = []
|
||||
for item in items:
|
||||
parsed = parse_result(item)
|
||||
if parsed:
|
||||
page_data.append(parsed)
|
||||
|
||||
all_data.extend(page_data)
|
||||
print(f'获取 {len(page_data)} 条')
|
||||
|
||||
# 检查是否还有更多页
|
||||
total_pages = data.get('pageNum', 0)
|
||||
if page_no >= total_pages:
|
||||
print(f'已获取全部 {total_pages} 页')
|
||||
break
|
||||
|
||||
page_no += 1
|
||||
time.sleep(0.5) # 避免请求过快
|
||||
|
||||
# 去重并排序(按日期升序)
|
||||
seen = set()
|
||||
unique_data = []
|
||||
for d in all_data:
|
||||
if d['period'] not in seen:
|
||||
seen.add(d['period'])
|
||||
unique_data.append(d)
|
||||
|
||||
unique_data.sort(key=lambda x: (x['date'], x['period']))
|
||||
|
||||
print(f'\n总计获取 {len(unique_data)} 条唯一数据')
|
||||
|
||||
if not unique_data:
|
||||
print('未获取到任何数据,可能 API 有变动。')
|
||||
print('可以尝试手动从 https://www.cwl.gov.cn/ 下载数据。')
|
||||
return
|
||||
|
||||
# 写入文件
|
||||
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
||||
json_str = json.dumps(unique_data, ensure_ascii=False, indent=2)
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(json_str)
|
||||
|
||||
print(f'已保存到: {OUTPUT_FILE}')
|
||||
|
||||
# 摘要
|
||||
first = unique_data[0]
|
||||
last = unique_data[-1]
|
||||
print(f'数据范围: {first["period"]} ({first["date"]}) ~ {last["period"]} ({last["date"]})')
|
||||
|
||||
# 构建后重新运行
|
||||
print(f'\n数据已更新,重新构建项目即可生效:')
|
||||
print(f' npm run build')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
||||
import Overview from './pages/Overview'
|
||||
import Trend from './pages/Trend'
|
||||
import Missing from './pages/Missing'
|
||||
import Pattern from './pages/Pattern'
|
||||
import Picker from './pages/Picker'
|
||||
import Verify from './pages/Verify'
|
||||
import DataManage from './pages/DataManage'
|
||||
import { importData, addDraw, getCount } from './utils/db'
|
||||
import historyData from './data/history.json'
|
||||
|
||||
const NAV = [
|
||||
{ path: '/', label: '数据总览', icon: '📊' },
|
||||
{ path: '/trend', label: '走势图', icon: '📈' },
|
||||
{ path: '/missing', label: '遗漏分析', icon: '🔍' },
|
||||
{ path: '/pattern', label: '形态分析', icon: '📐' },
|
||||
{ path: '/picker', label: '智能选号', icon: '🎯' },
|
||||
{ path: '/verify', label: '验证工具', icon: '✅' },
|
||||
{ path: '/data', label: '数据管理', icon: '🗄️' },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [dataReady, setDataReady] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// 首次加载时导入历史数据到 IndexedDB
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const count = await getCount()
|
||||
if (count === 0 && historyData.length > 0) {
|
||||
await importData(historyData)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('数据初始化失败:', e)
|
||||
}
|
||||
setDataReady(true)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// 路由变化时关闭侧边栏
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false)
|
||||
}, [location])
|
||||
|
||||
if (!dataReady) {
|
||||
return (
|
||||
<div className="empty-state" style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* 移动端头部 */}
|
||||
<div className="mobile-header">
|
||||
<button className="menu-btn" onClick={() => setSidebarOpen(!sidebarOpen)}>☰</button>
|
||||
<div className="mobile-title">福彩3D分析</div>
|
||||
</div>
|
||||
|
||||
{/* 遮罩层 */}
|
||||
<div className={`overlay ${sidebarOpen ? 'open' : ''}`} onClick={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
福彩3D分析
|
||||
<span>数据驱动选号</span>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{NAV.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/'}
|
||||
className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="sidebar-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/trend" element={<Trend />} />
|
||||
<Route path="/missing" element={<Missing />} />
|
||||
<Route path="/pattern" element={<Pattern />} />
|
||||
<Route path="/picker" element={<Picker />} />
|
||||
<Route path="/verify" element={<Verify />} />
|
||||
<Route path="/data" element={<DataManage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32965
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
/**
|
||||
* ECharts Hook - 自动初始化和管理图表实例
|
||||
* @param {{ option: object | null, theme?: 'dark' }} params
|
||||
* @returns RefObject
|
||||
*/
|
||||
export function useECharts({ option, theme = 'dark' }) {
|
||||
const ref = useRef(null)
|
||||
const chartRef = useRef(null)
|
||||
const resizeObserverRef = useRef(null)
|
||||
const optionRef = useRef(option)
|
||||
optionRef.current = option
|
||||
|
||||
const callbackRef = useCallback(node => {
|
||||
if (!node) {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect()
|
||||
resizeObserverRef.current = null
|
||||
}
|
||||
chartRef.current?.dispose()
|
||||
chartRef.current = null
|
||||
ref.current = null
|
||||
return
|
||||
}
|
||||
|
||||
ref.current = node
|
||||
|
||||
if (!chartRef.current) {
|
||||
// 初始化时注册暗色主题
|
||||
chartRef.current = echarts.init(node, theme)
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
chartRef.current?.resize()
|
||||
})
|
||||
resizeObserverRef.current.observe(node)
|
||||
}
|
||||
|
||||
if (optionRef.current) {
|
||||
chartRef.current.setOption(optionRef.current, true)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
// 当 option 变化时更新
|
||||
const optionKey = JSON.stringify(option)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableOptionRef = useRef(option)
|
||||
if (JSON.stringify(stableOptionRef.current) !== optionKey) {
|
||||
stableOptionRef.current = option
|
||||
if (chartRef.current && option) {
|
||||
chartRef.current.setOption(option, true)
|
||||
}
|
||||
}
|
||||
|
||||
return callbackRef
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-input: #0f3460;
|
||||
--text: #eee;
|
||||
--text-secondary: #aaa;
|
||||
--primary: #e94560;
|
||||
--primary-hover: #ff6b81;
|
||||
--blue: #4a9eff;
|
||||
--green: #00d4aa;
|
||||
--orange: #ff9f43;
|
||||
--purple: #a855f7;
|
||||
--border: #2a3a5c;
|
||||
--radius: 8px;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* Layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-card);
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 0 20px 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-logo span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-icon { font-size: 18px; }
|
||||
|
||||
.main-content {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 移动端汉堡菜单 */
|
||||
.mobile-header {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 99;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Chart container */
|
||||
.chart-box {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover { background: #1a4a8a; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* Form */
|
||||
.input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus { border-color: var(--primary); }
|
||||
|
||||
.select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Flex utilities */
|
||||
.flex-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.gap-16 { gap: 16px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.mb-20 { margin-bottom: 20px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
|
||||
/* Grid 2 col */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Number ball */
|
||||
.ball {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.ball-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ball-red { background: var(--primary); }
|
||||
.ball-blue { background: var(--blue); }
|
||||
.ball-green { background: var(--green); }
|
||||
.ball-orange { background: var(--orange); }
|
||||
.ball-purple { background: var(--purple); }
|
||||
|
||||
/* Table */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
|
||||
/* Tag */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-red { background: rgba(233,69,96,0.2); color: var(--primary); }
|
||||
.tag-blue { background: rgba(74,158,255,0.2); color: var(--blue); }
|
||||
.tag-green { background: rgba(0,212,170,0.2); color: var(--green); }
|
||||
.tag-orange { background: rgba(255,159,67,0.2); color: var(--orange); }
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 { margin-bottom: 8px; color: var(--text); }
|
||||
|
||||
/* Number pick result */
|
||||
.num-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Result card */
|
||||
.result-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 16px;
|
||||
padding-top: 60px;
|
||||
}
|
||||
.mobile-header { display: flex; }
|
||||
.overlay.open { display: block; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.ball { width: 32px; height: 32px; font-size: 15px; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAllData, importData, addDraw, clearData, getCount } from '../utils/db'
|
||||
import { enrichData } from '../utils/analysis'
|
||||
|
||||
export default function DataManage() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [newDraw, setNewDraw] = useState({ period: '', date: '', hundreds: '', tens: '', ones: '' })
|
||||
const [msg, setMsg] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
const c = await getCount()
|
||||
setCount(c)
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
const { period, date, hundreds, tens, ones } = newDraw
|
||||
if (!period || !date || hundreds === '' || tens === '' || ones === '') {
|
||||
setMsg('请完整填写所有字段')
|
||||
return
|
||||
}
|
||||
const h = parseInt(hundreds), t = parseInt(tens), o = parseInt(ones)
|
||||
if (isNaN(h) || isNaN(t) || isNaN(o) || h < 0 || h > 9 || t < 0 || t > 9 || o < 0 || o > 9) {
|
||||
setMsg('号码必须在 0-9 之间')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addDraw({ period, date, hundreds: h, tens: t, ones: o })
|
||||
setMsg(`成功添加 ${period} 期!`)
|
||||
setNewDraw({ period: '', date: '', hundreds: '', tens: '', ones: '' })
|
||||
refresh()
|
||||
} catch (e) {
|
||||
setMsg('添加失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportJSON = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) { setImporting(false); return }
|
||||
const text = await file.text()
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
if (!Array.isArray(data)) throw new Error('JSON 必须是数组格式')
|
||||
await importData(data)
|
||||
setMsg(`成功导入 ${data.length} 条数据!`)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setMsg('导入失败: ' + err.message)
|
||||
}
|
||||
setImporting(false)
|
||||
}
|
||||
input.click()
|
||||
} catch (e) {
|
||||
setMsg('导入失败: ' + e.message)
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (confirm(`确定要清空全部 ${count} 条数据吗?此操作不可恢复!`)) {
|
||||
await clearData()
|
||||
setMsg('数据已清空')
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
const data = await getAllData()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `ssq3d_backup_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setMsg(`已导出 ${data.length} 条数据`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">数据管理</h1>
|
||||
<p className="page-subtitle">管理历史开奖数据,当前共 {count} 条记录</p>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div style={{
|
||||
background: msg.includes('失败') ? 'rgba(233,69,96,0.1)' : 'rgba(0,212,170,0.1)',
|
||||
border: `1px solid ${msg.includes('失败') ? '#e94560' : '#00d4aa'}`,
|
||||
color: msg.includes('失败') ? '#e94560' : '#00d4aa',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手动添加 */}
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">手动添加一期</div>
|
||||
<div className="flex-row" style={{ flexWrap: 'wrap' }}>
|
||||
<input className="input" placeholder="期号 (如 2026001)" value={newDraw.period}
|
||||
onChange={e => setNewDraw({ ...newDraw, period: e.target.value })} style={{ width: 140 }} />
|
||||
<input className="input" type="date" value={newDraw.date}
|
||||
onChange={e => setNewDraw({ ...newDraw, date: e.target.value })} />
|
||||
<input className="input" type="number" placeholder="百位 0-9" min={0} max={9}
|
||||
value={newDraw.hundreds} onChange={e => setNewDraw({ ...newDraw, hundreds: e.target.value })} style={{ width: 90 }} />
|
||||
<input className="input" type="number" placeholder="十位 0-9" min={0} max={9}
|
||||
value={newDraw.tens} onChange={e => setNewDraw({ ...newDraw, tens: e.target.value })} style={{ width: 90 }} />
|
||||
<input className="input" type="number" placeholder="个位 0-9" min={0} max={9}
|
||||
value={newDraw.ones} onChange={e => setNewDraw({ ...newDraw, ones: e.target.value })} style={{ width: 90 }} />
|
||||
<button className="btn btn-primary" onClick={handleAdd}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入导出 */}
|
||||
<div className="card">
|
||||
<div className="card-title">批量操作</div>
|
||||
<div className="flex-row gap-16">
|
||||
<button className="btn btn-secondary" onClick={handleImportJSON} disabled={importing}>
|
||||
📥 {importing ? '导入中...' : '导入 JSON 文件'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleExport}>
|
||||
📤 导出全部数据
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleClear} style={{ color: '#e94560', borderColor: '#e94560' }}>
|
||||
🗑️ 清空数据
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: '#aaa', lineHeight: 1.8 }}>
|
||||
<p><strong>导入格式:</strong>JSON 数组,每项包含 period, date, hundreds, tens, ones 字段</p>
|
||||
<p>示例:{`[{"period":"2026001","date":"2026-01-01","hundreds":1,"tens":4,"ones":7}]`}</p>
|
||||
<p><strong>数据来源:</strong>可从网上抓取 3D 历史开奖数据后导入</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData, calcMissing } from '../utils/analysis'
|
||||
import { POSITIONS, COLD_THRESHOLD, COLORS } from '../utils/constants'
|
||||
import { useECharts } from '../hooks/useECharts'
|
||||
|
||||
export default function Missing() {
|
||||
const [data, setData] = useState([])
|
||||
const [missing, setMissing] = useState(null)
|
||||
const [activePos, setActivePos] = useState(0) // 0=百位, 1=十位, 2=个位
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => {
|
||||
// 时间升序排序用于遗漏计算
|
||||
const sorted = [...raw].sort((a, b) => a.period.localeCompare(b.period))
|
||||
const enriched = enrichData(sorted).reverse() // id 从最新=1
|
||||
setData(enriched)
|
||||
setMissing(calcMissing(sorted.map((d, i) => ({ ...d, id: sorted.length - i }))))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const posKeys = ['hundreds', 'tens', 'ones']
|
||||
const posData = missing ? missing[posKeys[activePos]] : []
|
||||
const activePosData = posData.sort((a, b) => b.currentMissing - a.currentMissing)
|
||||
|
||||
// 冷热排行
|
||||
const sortedByCold = [...posData].sort((a, b) => b.currentMissing - a.currentMissing)
|
||||
const top3Cold = sortedByCold.slice(0, 3)
|
||||
const sortedByHot = [...posData].sort((a, b) => b.totalShows - a.totalShows)
|
||||
const top3Hot = sortedByHot.slice(0, 3)
|
||||
|
||||
const barChartRef = useECharts({
|
||||
option: posData.length > 0 ? missingBarOption(posData, POSITIONS[activePos]) : null,
|
||||
})
|
||||
|
||||
if (!missing) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>暂无数据</h3>
|
||||
<p>请先去"数据管理"导入历史数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const digitColor = (d) => {
|
||||
const colors = ['#e94560', '#4a9eff', '#00d4aa', '#ff9f43', '#a855f7', '#f43f5e', '#06b6d4', '#84cc16', '#ec4899', '#f97316']
|
||||
return colors[d]
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">遗漏分析</h1>
|
||||
<p className="page-subtitle">冷热号、遗漏值统计,共 {data.length} 期</p>
|
||||
</div>
|
||||
|
||||
{/* 位置切换 */}
|
||||
<div className="flex-row mb-16">
|
||||
{POSITIONS.map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`btn ${activePos === i ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setActivePos(i)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 冷热榜 */}
|
||||
<div className="stat-grid mb-20">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label" style={{ marginBottom: 8 }}>最冷 Top3</div>
|
||||
<div className="flex-row" style={{ justifyContent: 'center', gap: 6 }}>
|
||||
{top3Cold.map((d, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<div className="ball ball-sm" style={{ background: digitColor(d.digit) }}>{d.digit}</div>
|
||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 2 }}>{d.currentMissing}期</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label" style={{ marginBottom: 8 }}>最热 Top3</div>
|
||||
<div className="flex-row" style={{ justifyContent: 'center', gap: 6 }}>
|
||||
{top3Hot.map((d, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<div className="ball ball-sm" style={{ background: digitColor(d.digit) }}>{d.digit}</div>
|
||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 2 }}>{d.totalShows}次</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#ff9f43' }}>{Math.max(...posData.filter(d => d.maxMissing !== null).map(d => d.maxMissing))}</div>
|
||||
<div className="stat-label">最大历史遗漏</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{top3Cold[0]?.currentMissing || '-'}</div>
|
||||
<div className="stat-label">当前最大遗漏</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 遗漏柱状图 */}
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">{POSITIONS[activePos]}遗漏值分布</div>
|
||||
<div ref={barChartRef} className="chart-box" style={{ height: 350 }} />
|
||||
</div>
|
||||
|
||||
{/* 遗漏详情表 */}
|
||||
<div className="card">
|
||||
<div className="card-title">{POSITIONS[activePos]}遗漏详情</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>号码</th>
|
||||
<th>当前遗漏</th>
|
||||
<th>最大遗漏</th>
|
||||
<th>平均遗漏</th>
|
||||
<th>出现次数</th>
|
||||
<th>上次出现</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activePosData.map(d => (
|
||||
<tr key={d.digit}>
|
||||
<td><span className="ball ball-sm" style={{ background: digitColor(d.digit) }}>{d.digit}</span></td>
|
||||
<td style={{ fontWeight: d.currentMissing >= COLD_THRESHOLD ? '700' : '400', color: d.currentMissing >= COLD_THRESHOLD ? '#e94560' : '#eee' }}>{d.currentMissing}</td>
|
||||
<td>{d.maxMissing}</td>
|
||||
<td>{d.avgMissing ?? '-'}</td>
|
||||
<td>{d.totalShows}</td>
|
||||
<td>{d.lastSeen || '-'}</td>
|
||||
<td>
|
||||
<span className={`tag ${d.currentMissing >= COLD_THRESHOLD ? 'tag-red' : d.currentMissing === 0 ? 'tag-green' : 'tag-blue'}`}>
|
||||
{d.currentMissing >= COLD_THRESHOLD ? '极冷' : d.currentMissing === 0 ? '刚出' : '正常'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function missingBarOption(data, posName) {
|
||||
const currentData = data.map(d => d.currentMissing)
|
||||
const avgData = data.map(d => d.avgMissing ?? 0)
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['当前遗漏', '平均遗漏'],
|
||||
textStyle: { color: '#aaa' },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => `号码 ${d.digit}`),
|
||||
axisLabel: { color: '#aaa' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '当前遗漏',
|
||||
type: 'bar',
|
||||
data: currentData,
|
||||
itemStyle: {
|
||||
color: (params) => {
|
||||
if (params.value >= COLD_THRESHOLD) return '#e94560'
|
||||
if (params.value === 0) return '#00d4aa'
|
||||
return '#4a9eff'
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#aaa',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '平均遗漏',
|
||||
type: 'line',
|
||||
data: avgData,
|
||||
lineStyle: { color: '#ff9f43', type: 'dashed', width: 1.5 },
|
||||
itemStyle: { color: '#ff9f43' },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData, calcFrequency, calcSumDist, calcSpanDist } from '../utils/analysis'
|
||||
import { getPattern, COLORS } from '../utils/constants'
|
||||
import { useECharts } from '../hooks/useECharts'
|
||||
|
||||
export default function Overview() {
|
||||
const [data, setData] = useState([])
|
||||
const [enriched, setEnriched] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => {
|
||||
setData(raw)
|
||||
setEnriched(enrichData(raw))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const latest = enriched[0]
|
||||
const freq = enriched.length > 0 ? calcFrequency(enriched) : null
|
||||
const sumDist = enriched.length > 0 ? calcSumDist(enriched) : []
|
||||
const spanDist = enriched.length > 0 ? calcSpanDist(enriched) : []
|
||||
|
||||
const sumChartRef = useECharts({
|
||||
option: sumDist.length > 0 ? sumChartOption(sumDist) : null,
|
||||
})
|
||||
|
||||
const spanChartRef = useECharts({
|
||||
option: spanDist.length > 0 ? spanChartOption(spanDist) : null,
|
||||
})
|
||||
|
||||
if (!latest) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>暂无数据</h3>
|
||||
<p>请先去"数据管理"导入历史数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const digitColor = (d) => {
|
||||
const colors = ['#e94560', '#4a9eff', '#00d4aa', '#ff9f43', '#a855f7', '#f43f5e', '#06b6d4', '#84cc16', '#ec4899', '#f97316']
|
||||
return colors[d]
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">数据总览</h1>
|
||||
<p className="page-subtitle">共 {enriched.length} 期数据,最新更新:{latest.date}</p>
|
||||
</div>
|
||||
|
||||
{/* 最新开奖 */}
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">最新开奖 — {latest.period} 期</div>
|
||||
<div className="num-display">
|
||||
<span className="ball" style={{ background: digitColor(latest.hundreds) }}>{latest.hundreds}</span>
|
||||
<span className="ball" style={{ background: digitColor(latest.tens) }}>{latest.tens}</span>
|
||||
<span className="ball" style={{ background: digitColor(latest.ones) }}>{latest.ones}</span>
|
||||
</div>
|
||||
<div className="flex-row" style={{ justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<span className="tag tag-red">和值 {latest.sum}</span>
|
||||
<span className="tag tag-blue" style={{ marginLeft: 8 }}>跨度 {latest.span}</span>
|
||||
<span className="tag tag-green" style={{ marginLeft: 8 }}>{latest.pattern}</span>
|
||||
</div>
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>
|
||||
奇偶 {latest.oddEven} | 大小 {latest.bigSmall} | {latest.roads}路
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{latest.pattern}</div>
|
||||
<div className="stat-label">最新形态</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{latest.sum}</div>
|
||||
<div className="stat-label">最新和值</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{latest.span}</div>
|
||||
<div className="stat-label">最新跨度</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{latest.oddEven}</div>
|
||||
<div className="stat-label">最新奇偶比</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图表 */}
|
||||
<div className="grid-2 mb-20">
|
||||
<div className="card">
|
||||
<div className="card-title">和值分布</div>
|
||||
<div ref={sumChartRef} className="chart-box" />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">跨度分布</div>
|
||||
<div ref={spanChartRef} className="chart-box" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各位频率 Top5 */}
|
||||
<div className="grid-2 mb-20">
|
||||
{freq && ['hundreds', 'tens', 'ones'].map((pos, pi) => (
|
||||
<div className="card" key={pos}>
|
||||
<div className="card-title">{['百位', '十位', '个位'][pi]}号码频率 Top5</div>
|
||||
<div className="flex-row" style={{ justifyContent: 'space-around' }}>
|
||||
{freq[pos].slice(0, 5).map((item, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<div className="ball ball-sm" style={{ background: digitColor(item.digit) }}>{item.digit}</div>
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>{item.count}次</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 最近 20 期 */}
|
||||
<div className="card">
|
||||
<div className="card-title">最近 20 期开奖</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>期号</th>
|
||||
<th>日期</th>
|
||||
<th>号码</th>
|
||||
<th>和值</th>
|
||||
<th>跨度</th>
|
||||
<th>形态</th>
|
||||
<th>奇偶</th>
|
||||
<th>大小</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enriched.slice(0, 20).map(d => (
|
||||
<tr key={d.period}>
|
||||
<td>{d.period}</td>
|
||||
<td>{d.date}</td>
|
||||
<td>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.hundreds) }}>{d.hundreds}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.tens) }}>{d.tens}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.ones) }}>{d.ones}</span>
|
||||
</td>
|
||||
<td>{d.sum}</td>
|
||||
<td>{d.span}</td>
|
||||
<td><span className={`tag ${d.pattern === '组六' ? 'tag-blue' : d.pattern === '组三' ? 'tag-orange' : 'tag-red'}`}>{d.pattern}</span></td>
|
||||
<td>{d.oddEven}</td>
|
||||
<td>{d.bigSmall}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sumChartOption(data) {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.sum),
|
||||
axisLabel: { color: '#aaa', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: data.map(d => d.count),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#e94560' },
|
||||
{ offset: 1, color: '#0f3460' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function spanChartOption(data) {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.span),
|
||||
axisLabel: { color: '#aaa', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: data.map(d => d.count),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#4a9eff' },
|
||||
{ offset: 1, color: '#0f3460' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData, calcPatternDist, calcOddEvenDist, calcBigSmallDist } from '../utils/analysis'
|
||||
import { COLORS, PATTERN } from '../utils/constants'
|
||||
import { useECharts } from '../hooks/useECharts'
|
||||
|
||||
export default function Pattern() {
|
||||
const [data, setData] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => setData(enrichData(raw)))
|
||||
}, [])
|
||||
|
||||
const patternDist = data.length > 0 ? calcPatternDist(data) : []
|
||||
const oddEvenDist = data.length > 0 ? calcOddEvenDist(data) : []
|
||||
const bigSmallDist = data.length > 0 ? calcBigSmallDist(data) : []
|
||||
|
||||
const patternChartRef = useECharts({
|
||||
option: patternDist.length > 0 ? pieOption(patternDist, '形态分布') : null,
|
||||
})
|
||||
|
||||
const oeChartRef = useECharts({
|
||||
option: oddEvenDist.length > 0 ? pieOption(oddEvenDist, '奇偶比分布') : null,
|
||||
})
|
||||
|
||||
const bsChartRef = useECharts({
|
||||
option: bigSmallDist.length > 0 ? pieOption(bigSmallDist, '大小比分布') : null,
|
||||
})
|
||||
|
||||
// 统计 012 路分布
|
||||
const roadDist = { '000': 0, '111': 0, '222': 0 }
|
||||
const roadCounts = { '0路': 0, '1路': 0, '2路': 0 }
|
||||
if (data.length > 0) {
|
||||
data.forEach(d => {
|
||||
const road = d.roads
|
||||
if (road === '000') roadDist['000']++
|
||||
else if (road === '111') roadDist['111']++
|
||||
else if (road === '222') roadDist['222']++
|
||||
})
|
||||
// 总数
|
||||
const total = data.length * 3
|
||||
data.forEach(d => {
|
||||
[d.hundreds, d.tens, d.ones].forEach(digit => {
|
||||
roadCounts[`${digit % 3}路`]++
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 形态连续期数分析
|
||||
const recentPatterns = data.slice(0, 100).map(d => d.pattern)
|
||||
let maxConsecutive = { [PATTERN.ZU_LIU]: 0, [PATTERN.ZU_SAN]: 0, [PATTERN.BAO_ZI]: 0 }
|
||||
let currentPattern = recentPatterns[0]
|
||||
let currentCount = 1
|
||||
for (let i = 1; i < recentPatterns.length; i++) {
|
||||
if (recentPatterns[i] === currentPattern) {
|
||||
currentCount++
|
||||
} else {
|
||||
maxConsecutive[currentPattern] = Math.max(maxConsecutive[currentPattern], currentCount)
|
||||
currentPattern = recentPatterns[i]
|
||||
currentCount = 1
|
||||
}
|
||||
}
|
||||
maxConsecutive[currentPattern] = Math.max(maxConsecutive[currentPattern], currentCount)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>暂无数据</h3>
|
||||
<p>请先去"数据管理"导入历史数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">形态分析</h1>
|
||||
<p className="page-subtitle">号码形态、奇偶比、大小分布分析</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="stat-grid mb-20">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{patternDist.find(p => p.name === '组六')?.pct || '0'}%</div>
|
||||
<div className="stat-label">组六占比</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{patternDist.find(p => p.name === '组三')?.pct || '0'}%</div>
|
||||
<div className="stat-label">组三占比</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{patternDist.find(p => p.name === '豹子')?.count || 0}</div>
|
||||
<div className="stat-label">豹子历史次数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{`${((data.filter(d => d.oddCount <= 1).length / data.length) * 100).toFixed(1)}%`}</div>
|
||||
<div className="stat-label">偏偶(0:3,1:2)占比</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连续形态 */}
|
||||
<div className="stat-grid mb-20">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#4a9eff' }}>{maxConsecutive['组六']}</div>
|
||||
<div className="stat-label">组六 近100期最长连续</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#ff9f43' }}>{maxConsecutive['组三']}</div>
|
||||
<div className="stat-label">组三 近100期最长连续</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#e94560' }}>{maxConsecutive['豹子']}</div>
|
||||
<div className="stat-label">豹子 近100期最长连续</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 饼图 */}
|
||||
<div className="grid-2 mb-20">
|
||||
<div className="card">
|
||||
<div className="card-title">形态分布</div>
|
||||
<div ref={patternChartRef} className="chart-box" />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">奇偶比分布</div>
|
||||
<div ref={oeChartRef} className="chart-box" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2 mb-20">
|
||||
<div className="card">
|
||||
<div className="card-title">大小比分布</div>
|
||||
<div ref={bsChartRef} className="chart-box" />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">012路分布</div>
|
||||
<div className="flex-row" style={{ justifyContent: 'space-around', paddingTop: 40 }}>
|
||||
{Object.entries(roadCounts).map(([key, val]) => {
|
||||
const total = data.length * 3
|
||||
const pct = ((val / total) * 100).toFixed(1)
|
||||
return (
|
||||
<div key={key} style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, color: key === '0路' ? '#e94560' : key === '1路' ? '#4a9eff' : '#00d4aa' }}>{pct}%</div>
|
||||
<div style={{ fontSize: 12, color: '#aaa', marginTop: 4 }}>{key} ({val}/{total})</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function pieOption(data, title) {
|
||||
return {
|
||||
title: { text: '', left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} 次 ({d}%)' },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: { color: '#aaa', fontSize: 12 },
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['40%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 16, fontWeight: 'bold' },
|
||||
},
|
||||
data: data.map((d, i) => ({
|
||||
value: d.count,
|
||||
name: d.name,
|
||||
itemStyle: { color: COLORS.chartColors[i % COLORS.chartColors.length] },
|
||||
})),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData, generateNumbers } from '../utils/analysis'
|
||||
import { PATTERN, COLORS, getPattern } from '../utils/constants'
|
||||
|
||||
export default function Picker() {
|
||||
const [data, setData] = useState([])
|
||||
const [results, setResults] = useState([])
|
||||
const [options, setOptions] = useState({
|
||||
count: 10,
|
||||
patternFilter: '',
|
||||
sumMin: 0,
|
||||
sumMax: 27,
|
||||
spanMin: 0,
|
||||
spanMax: 9,
|
||||
oddEvenFilter: '',
|
||||
bigSmallFilter: '',
|
||||
excludeRecent: 50,
|
||||
preferCold: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => setData(enrichData(raw)))
|
||||
}, [])
|
||||
|
||||
const handleGenerate = () => {
|
||||
const result = generateNumbers(data, {
|
||||
...options,
|
||||
patternFilter: options.patternFilter || null,
|
||||
oddEvenFilter: options.oddEvenFilter || null,
|
||||
bigSmallFilter: options.bigSmallFilter || null,
|
||||
count: Number(options.count),
|
||||
sumMin: Number(options.sumMin),
|
||||
sumMax: Number(options.sumMax),
|
||||
spanMin: Number(options.spanMin),
|
||||
spanMax: Number(options.spanMax),
|
||||
excludeRecent: Number(options.excludeRecent),
|
||||
})
|
||||
setResults(result)
|
||||
}
|
||||
|
||||
const digitColor = (d) => {
|
||||
const colors = ['#e94560', '#4a9eff', '#00d4aa', '#ff9f43', '#a855f7', '#f43f5e', '#06b6d4', '#84cc16', '#ec4899', '#f97316']
|
||||
return colors[d]
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">智能选号</h1>
|
||||
<p className="page-subtitle">基于统计规则生成候选号码,仅供娱乐参考</p>
|
||||
</div>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">选号条件</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>生成数量</label>
|
||||
<input className="input" type="number" value={options.count} onChange={e => setOptions({ ...options, count: e.target.value })} min={1} max={50} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>形态筛选</label>
|
||||
<select className="select" value={options.patternFilter} onChange={e => setOptions({ ...options, patternFilter: e.target.value })} style={{ width: '100%' }}>
|
||||
<option value="">不限</option>
|
||||
<option value={PATTERN.ZU_LIU}>组六</option>
|
||||
<option value={PATTERN.ZU_SAN}>组三</option>
|
||||
<option value={PATTERN.BAO_ZI}>豹子</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>和值范围</label>
|
||||
<div className="flex-row gap-8">
|
||||
<input className="input" type="number" value={options.sumMin} onChange={e => setOptions({ ...options, sumMin: e.target.value })} min={0} max={27} style={{ width: 70 }} />
|
||||
<span style={{ color: '#aaa' }}>~</span>
|
||||
<input className="input" type="number" value={options.sumMax} onChange={e => setOptions({ ...options, sumMax: e.target.value })} min={0} max={27} style={{ width: 70 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>跨度范围</label>
|
||||
<div className="flex-row gap-8">
|
||||
<input className="input" type="number" value={options.spanMin} onChange={e => setOptions({ ...options, spanMin: e.target.value })} min={0} max={9} style={{ width: 70 }} />
|
||||
<span style={{ color: '#aaa' }}>~</span>
|
||||
<input className="input" type="number" value={options.spanMax} onChange={e => setOptions({ ...options, spanMax: e.target.value })} min={0} max={9} style={{ width: 70 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>奇偶比</label>
|
||||
<select className="select" value={options.oddEvenFilter} onChange={e => setOptions({ ...options, oddEvenFilter: e.target.value })} style={{ width: '100%' }}>
|
||||
<option value="">不限</option>
|
||||
<option value="3:0">3:0 (全奇)</option>
|
||||
<option value="2:1">2:1</option>
|
||||
<option value="1:2">1:2</option>
|
||||
<option value="0:3">0:3 (全偶)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>大小比</label>
|
||||
<select className="select" value={options.bigSmallFilter} onChange={e => setOptions({ ...options, bigSmallFilter: e.target.value })} style={{ width: '100%' }}>
|
||||
<option value="">不限</option>
|
||||
<option value="3:0">3:0 (全大)</option>
|
||||
<option value="2:1">2:1</option>
|
||||
<option value="1:2">1:2</option>
|
||||
<option value="0:3">0:3 (全小)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>排除最近 N 期</label>
|
||||
<input className="input" type="number" value={options.excludeRecent} onChange={e => setOptions({ ...options, excludeRecent: e.target.value })} min={0} max={1000} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#aaa', display: 'block', marginBottom: 4 }}>偏好策略</label>
|
||||
<select className="select" value={options.preferCold ? 'cold' : 'hot'} onChange={e => setOptions({ ...options, preferCold: e.target.value === 'cold' })} style={{ width: '100%' }}>
|
||||
<option value="hot">热号优先</option>
|
||||
<option value="cold">冷号优先</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<button className="btn btn-primary" onClick={handleGenerate} style={{ width: '100%' }}>
|
||||
🎯 生成推荐号码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果 */}
|
||||
{results.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">生成结果 ({results.length} 注)</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{results.map((r, i) => (
|
||||
<div className="result-card" key={i}>
|
||||
<span className="tag tag-blue" style={{ minWidth: 40, textAlign: 'center' }}>#{i + 1}</span>
|
||||
<span className="ball" style={{ background: digitColor(r.hundreds) }}>{r.hundreds}</span>
|
||||
<span className="ball" style={{ background: digitColor(r.tens) }}>{r.tens}</span>
|
||||
<span className="ball" style={{ background: digitColor(r.ones) }}>{r.ones}</span>
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="tag tag-red">和 {r.sum}</span>
|
||||
<span className="tag tag-blue">跨 {r.span}</span>
|
||||
<span className={`tag ${r.pattern === '组六' ? 'tag-green' : 'tag-orange'}`}>{r.pattern}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>
|
||||
奇偶 {r.oddEven} | 大小 {r.bigSmall}
|
||||
</div>
|
||||
</div>
|
||||
<span className="tag tag-orange">得分 {r.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData } from '../utils/analysis'
|
||||
import { COLORS, POSITIONS } from '../utils/constants'
|
||||
import { useECharts } from '../hooks/useECharts'
|
||||
|
||||
export default function Trend() {
|
||||
const [data, setData] = useState([])
|
||||
const [range, setRange] = useState(100) // 默认近100期
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => setData(enrichData(raw)))
|
||||
}, [])
|
||||
|
||||
const displayData = data.slice(0, range).reverse() // 时间升序
|
||||
|
||||
const digitChartRef = useECharts({
|
||||
option: displayData.length > 0 ? digitTrendOption(displayData) : null,
|
||||
})
|
||||
|
||||
const sumChartRef = useECharts({
|
||||
option: displayData.length > 0 ? sumTrendOption(displayData) : null,
|
||||
})
|
||||
|
||||
const spanChartRef = useECharts({
|
||||
option: displayData.length > 0 ? spanTrendOption(displayData) : null,
|
||||
})
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>暂无数据</h3>
|
||||
<p>请先去"数据管理"导入历史数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">走势图</h1>
|
||||
<p className="page-subtitle">号码走势分析,共 {data.length} 期数据</p>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>显示近</span>
|
||||
<select className="select" value={range} onChange={e => setRange(Number(e.target.value))}>
|
||||
<option value={30}>30 期</option>
|
||||
<option value={50}>50 期</option>
|
||||
<option value={100}>100 期</option>
|
||||
<option value={200}>200 期</option>
|
||||
<option value={500}>500 期</option>
|
||||
<option value={data.length}>全部</option>
|
||||
</select>
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>期</span>
|
||||
</div>
|
||||
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">百十个位走势</div>
|
||||
<div ref={digitChartRef} style={{ width: '100%', height: 400 }} />
|
||||
</div>
|
||||
|
||||
<div className="grid-2 mb-20">
|
||||
<div className="card">
|
||||
<div className="card-title">和值走势</div>
|
||||
<div ref={sumChartRef} className="chart-box" />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">跨度走势</div>
|
||||
<div ref={spanChartRef} className="chart-box" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function digitTrendOption(data) {
|
||||
const periods = data.map(d => d.period.slice(-3))
|
||||
const colors = ['#e94560', '#4a9eff', '#00d4aa']
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: POSITIONS,
|
||||
textStyle: { color: '#aaa' },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: periods,
|
||||
axisLabel: { color: '#aaa', fontSize: 10, rotate: 45 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: -0.5,
|
||||
max: 9.5,
|
||||
interval: 1,
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: ['hundreds', 'tens', 'ones'].map((pos, i) => ({
|
||||
name: POSITIONS[i],
|
||||
type: 'line',
|
||||
data: data.map(d => d[pos]),
|
||||
connectNulls: true,
|
||||
lineStyle: { color: colors[i], width: 1.5 },
|
||||
itemStyle: { color: colors[i] },
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function sumTrendOption(data) {
|
||||
const periods = data.map(d => d.period.slice(-3))
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: periods,
|
||||
axisLabel: { color: '#aaa', fontSize: 9, rotate: 45 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: [{
|
||||
name: '和值',
|
||||
type: 'line',
|
||||
data: data.map(d => d.sum),
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(233,69,96,0.3)' },
|
||||
{ offset: 1, color: 'rgba(233,69,96,0.02)' },
|
||||
]),
|
||||
},
|
||||
lineStyle: { color: '#e94560', width: 1.5 },
|
||||
itemStyle: { color: '#e94560' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function spanTrendOption(data) {
|
||||
const periods = data.map(d => d.period.slice(-3))
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: periods,
|
||||
axisLabel: { color: '#aaa', fontSize: 9, rotate: 45 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#aaa' },
|
||||
splitLine: { lineStyle: { color: '#2a3a5c' } },
|
||||
},
|
||||
series: [{
|
||||
name: '跨度',
|
||||
type: 'line',
|
||||
data: data.map(d => d.span),
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(74,158,255,0.3)' },
|
||||
{ offset: 1, color: 'rgba(74,158,255,0.02)' },
|
||||
]),
|
||||
},
|
||||
lineStyle: { color: '#4a9eff', width: 1.5 },
|
||||
itemStyle: { color: '#4a9eff' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAllData } from '../utils/db'
|
||||
import { enrichData, checkWin, backtestDraws } from '../utils/analysis'
|
||||
import { getPattern, PATTERN } from '../utils/constants'
|
||||
|
||||
export default function Verify() {
|
||||
const [data, setData] = useState([])
|
||||
const [bet, setBet] = useState({ hundreds: '', tens: '', ones: '' })
|
||||
const [betType, setBetType] = useState('direct')
|
||||
const [results, setResults] = useState(null)
|
||||
const [mode, setMode] = useState('single') // single | multi | backtest
|
||||
|
||||
// 多期条件
|
||||
const [range, setRange] = useState(100)
|
||||
const [patternOnly, setPatternOnly] = useState('')
|
||||
|
||||
// 连续投注
|
||||
const [betNumber, setBetNumber] = useState({ hundreds: '', tens: '', ones: '' })
|
||||
const [betPeriods, setBetPeriods] = useState(10)
|
||||
const [backtestResult, setBacktestResult] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
getAllData().then(raw => setData(enrichData(raw)))
|
||||
}, [])
|
||||
|
||||
const handleSingleCheck = () => {
|
||||
const h = parseInt(bet.hundreds)
|
||||
const t = parseInt(bet.tens)
|
||||
const o = parseInt(bet.ones)
|
||||
if (isNaN(h) || isNaN(t) || isNaN(o)) return
|
||||
if (h < 0 || h > 9 || t < 0 || t > 9 || o < 0 || o > 9) return
|
||||
|
||||
const wins = []
|
||||
data.forEach(draw => {
|
||||
const result = checkWin({ hundreds: h, tens: t, ones: o }, draw)
|
||||
if (result.hit) {
|
||||
wins.push({ draw, prize: result.prize, type: result.type })
|
||||
}
|
||||
})
|
||||
setResults({
|
||||
code: { hundreds: h, tens: t, ones: o },
|
||||
totalChecks: data.length,
|
||||
wins,
|
||||
totalPrize: wins.reduce((s, w) => s + w.prize, 0),
|
||||
totalCost: data.length * 2,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMultiCheck = () => {
|
||||
const filtered = patternOnly
|
||||
? data.filter(d => d.pattern === patternOnly)
|
||||
: data
|
||||
|
||||
const matches = filtered.slice(0, range).filter(draw => {
|
||||
const h = parseInt(bet.hundreds)
|
||||
const t = parseInt(bet.tens)
|
||||
const o = parseInt(bet.ones)
|
||||
if (isNaN(h) || isNaN(t) || isNaN(o)) return false
|
||||
|
||||
// 按投注类型判断
|
||||
if (betType === 'direct') {
|
||||
return draw.hundreds === h && draw.tens === t && draw.ones === o
|
||||
} else {
|
||||
const sorted = [h, t, o].sort((a, b) => a - b)
|
||||
const drawSorted = [draw.hundreds, draw.tens, draw.ones].sort((a, b) => a - b)
|
||||
return sorted[0] === drawSorted[0] && sorted[1] === drawSorted[1] && sorted[2] === drawSorted[2]
|
||||
}
|
||||
})
|
||||
|
||||
setResults({ matches, totalChecked: Math.min(range, filtered.length), mode: 'multi' })
|
||||
}
|
||||
|
||||
const handleBacktest = () => {
|
||||
const h = parseInt(betNumber.hundreds)
|
||||
const t = parseInt(betNumber.tens)
|
||||
const o = parseInt(betNumber.ones)
|
||||
if (isNaN(h) || isNaN(t) || isNaN(o)) return
|
||||
|
||||
const betNum = { hundreds: h, tens: t, ones: o }
|
||||
const recentData = data.slice(0, betPeriods).reverse()
|
||||
const wins = []
|
||||
let totalCost = 0
|
||||
|
||||
for (let i = 0; i < recentData.length; i++) {
|
||||
totalCost += 2
|
||||
const result = checkWin(betNum, recentData[i])
|
||||
if (result.hit) {
|
||||
wins.push({ draw: recentData[i], prize: result.prize, type: result.type, drawIndex: i })
|
||||
}
|
||||
}
|
||||
|
||||
setBacktestResult({
|
||||
numberOf: h * 100 + t * 10 + o,
|
||||
periods: betPeriods,
|
||||
totalCost,
|
||||
wins,
|
||||
totalPrize: wins.reduce((s, w) => s + w.prize, 0),
|
||||
})
|
||||
}
|
||||
|
||||
const digitColor = (d) => {
|
||||
const colors = ['#e94560', '#4a9eff', '#00d4aa', '#ff9f43', '#a855f7', '#f43f5e', '#06b6d4', '#84cc16', '#ec4899', '#f97316']
|
||||
return colors[d]
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">验证工具</h1>
|
||||
<p className="page-subtitle">回溯号码中奖情况,验证策略效果</p>
|
||||
</div>
|
||||
|
||||
{/* 模式切换 */}
|
||||
<div className="flex-row mb-16">
|
||||
{[
|
||||
{ key: 'single', label: '全量回溯' },
|
||||
{ key: 'multi', label: '条件筛选' },
|
||||
{ key: 'backtest', label: '连续投注模拟' },
|
||||
].map(m => (
|
||||
<button
|
||||
key={m.key}
|
||||
className={`btn ${mode === m.key ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => { setMode(m.key); setResults(null); setBacktestResult(null) }}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 全量回溯 */}
|
||||
{mode === 'single' && (
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">输入号码,回溯全部历史中奖情况</div>
|
||||
<div className="flex-row mb-16">
|
||||
<input className="input" type="number" placeholder="百位" min={0} max={9}
|
||||
value={bet.hundreds} onChange={e => setBet({ ...bet, hundreds: e.target.value })} style={{ width: 80 }} />
|
||||
<input className="input" type="number" placeholder="十位" min={0} max={9}
|
||||
value={bet.tens} onChange={e => setBet({ ...bet, tens: e.target.value })} style={{ width: 80 }} />
|
||||
<input className="input" type="number" placeholder="个位" min={0} max={9}
|
||||
value={bet.ones} onChange={e => setBet({ ...bet, ones: e.target.value })} style={{ width: 80 }} />
|
||||
<button className="btn btn-primary" onClick={handleSingleCheck}>开始回溯</button>
|
||||
</div>
|
||||
{results && results.wins && (
|
||||
<div>
|
||||
<div className="stat-grid mb-16">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{results.totalChecks}</div>
|
||||
<div className="stat-label">历史总期数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: results.totalPrize > results.totalCost ? '#00d4aa' : '#e94560' }}>
|
||||
{results.totalPrize > 0 ? '+' : ''}{results.totalPrize - results.totalCost}
|
||||
</div>
|
||||
<div className="stat-label">总盈亏 (奖金{results.totalPrize} - 成本{results.totalCost})</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#ff9f43' }}>{results.wins.length}</div>
|
||||
<div className="stat-label">中奖次数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{results.wins.length > 0 ? ((results.wins.length / results.totalChecks) * 100).toFixed(2) + '%' : '0%'}</div>
|
||||
<div className="stat-label">中奖率</div>
|
||||
</div>
|
||||
</div>
|
||||
{results.wins.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>期号</th>
|
||||
<th>日期</th>
|
||||
<th>号码</th>
|
||||
<th>中奖类型</th>
|
||||
<th>奖金</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.wins.map((w, i) => (
|
||||
<tr key={i}>
|
||||
<td>{w.draw.period}</td>
|
||||
<td>{w.draw.date}</td>
|
||||
<td>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.hundreds) }}>{w.draw.hundreds}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.tens) }}>{w.draw.tens}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.ones) }}>{w.draw.ones}</span>
|
||||
</td>
|
||||
<td><span className={`tag ${w.type === '直选' ? 'tag-red' : w.type === '组三' ? 'tag-orange' : 'tag-blue'}`}>{w.type}</span></td>
|
||||
<td>{w.prize}元</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 条件筛选 */}
|
||||
{mode === 'multi' && (
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">在指定条件下查找中奖记录</div>
|
||||
<div className="filter-bar">
|
||||
<input className="input" type="number" placeholder="百位" min={0} max={9}
|
||||
value={bet.hundreds} onChange={e => setBet({ ...bet, hundreds: e.target.value })} style={{ width: 70 }} />
|
||||
<input className="input" type="number" placeholder="十位" min={0} max={9}
|
||||
value={bet.tens} onChange={e => setBet({ ...bet, tens: e.target.value })} style={{ width: 70 }} />
|
||||
<input className="input" type="number" placeholder="个位" min={0} max={9}
|
||||
value={bet.ones} onChange={e => setBet({ ...bet, ones: e.target.value })} style={{ width: 70 }} />
|
||||
<select className="select" value={betType} onChange={e => setBetType(e.target.value)}>
|
||||
<option value="direct">直选</option>
|
||||
<option value="group">组选</option>
|
||||
</select>
|
||||
<select className="select" value={patternOnly} onChange={e => setPatternOnly(e.target.value)}>
|
||||
<option value="">所有形态</option>
|
||||
<option value={PATTERN.ZU_LIU}>组六</option>
|
||||
<option value={PATTERN.ZU_SAN}>组三</option>
|
||||
<option value={PATTERN.BAO_ZI}>豹子</option>
|
||||
</select>
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>近</span>
|
||||
<input className="input" type="number" value={range} onChange={e => setRange(Number(e.target.value))} style={{ width: 70 }} />
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>期</span>
|
||||
<button className="btn btn-primary" onClick={handleMultiCheck}>查找</button>
|
||||
</div>
|
||||
{results && results.mode === 'multi' && (
|
||||
<div>
|
||||
<div style={{ color: '#aaa', fontSize: 13, marginBottom: 12 }}>
|
||||
在最近 {results.totalChecked} 期中,找到 {results.matches.length} 条匹配记录
|
||||
</div>
|
||||
{results.matches.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>期号</th><th>日期</th><th>号码</th><th>和值</th><th>跨度</th><th>形态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.matches.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td>{d.period}</td>
|
||||
<td>{d.date}</td>
|
||||
<td>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.hundreds) }}>{d.hundreds}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.tens) }}>{d.tens}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(d.ones) }}>{d.ones}</span>
|
||||
</td>
|
||||
<td>{d.sum}</td>
|
||||
<td>{d.span}</td>
|
||||
<td><span className={`tag ${d.pattern === '组六' ? 'tag-blue' : 'tag-orange'}`}>{d.pattern}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#aaa' }}>未找到匹配记录</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连续投注模拟 */}
|
||||
{mode === 'backtest' && (
|
||||
<div className="card mb-20">
|
||||
<div className="card-title">模拟连续多期投注同一号码</div>
|
||||
<div className="flex-row mb-16">
|
||||
<input className="input" type="number" placeholder="百位" min={0} max={9}
|
||||
value={betNumber.hundreds} onChange={e => setBetNumber({ ...betNumber, hundreds: e.target.value })} style={{ width: 70 }} />
|
||||
<input className="input" type="number" placeholder="十位" min={0} max={9}
|
||||
value={betNumber.tens} onChange={e => setBetNumber({ ...betNumber, tens: e.target.value })} style={{ width: 70 }} />
|
||||
<input className="input" type="number" placeholder="个位" min={0} max={9}
|
||||
value={betNumber.ones} onChange={e => setBetNumber({ ...betNumber, ones: e.target.value })} style={{ width: 70 }} />
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>连续投注</span>
|
||||
<input className="input" type="number" value={betPeriods} onChange={e => setBetPeriods(Number(e.target.value))} style={{ width: 70 }} />
|
||||
<span style={{ color: '#aaa', fontSize: 13 }}>期</span>
|
||||
<button className="btn btn-primary" onClick={handleBacktest}>模拟</button>
|
||||
</div>
|
||||
|
||||
{backtestResult && (
|
||||
<div>
|
||||
<div className="stat-grid mb-16">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{backtestResult.periods}</div>
|
||||
<div className="stat-label">模拟期数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{backtestResult.totalCost}</div>
|
||||
<div className="stat-label">总投入(元)</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#00d4aa' }}>{backtestResult.totalPrize}</div>
|
||||
<div className="stat-label">总奖金(元)</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: backtestResult.totalPrize > backtestResult.totalCost ? '#00d4aa' : '#e94560' }}>
|
||||
{backtestResult.totalPrize > backtestResult.totalCost ? '+' : ''}{backtestResult.totalPrize - backtestResult.totalCost}
|
||||
</div>
|
||||
<div className="stat-label">盈亏(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
{backtestResult.wins.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>第几期</th><th>期号</th><th>日期</th><th>开奖号码</th><th>中奖类型</th><th>奖金</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backtestResult.wins.map((w, i) => (
|
||||
<tr key={i}>
|
||||
<td>第 {w.drawIndex + 1} 期</td>
|
||||
<td>{w.draw.period}</td>
|
||||
<td>{w.draw.date}</td>
|
||||
<td>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.hundreds) }}>{w.draw.hundreds}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.tens) }}>{w.draw.tens}</span>
|
||||
<span className="ball ball-sm" style={{ background: digitColor(w.draw.ones) }}>{w.draw.ones}</span>
|
||||
</td>
|
||||
<td><span className={`tag ${w.type === '直选' ? 'tag-red' : w.type === '组三' ? 'tag-orange' : 'tag-blue'}`}>{w.type}</span></td>
|
||||
<td>{w.prize}元</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#aaa' }}>该号码在最近 {betPeriods} 期中未中奖</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// 福彩3D 数据分析工具
|
||||
|
||||
import { getPattern, getOddEven, getBigSmall, getRoad, SUM_ZONES, PATTERN } from './constants'
|
||||
|
||||
/**
|
||||
* 对原始号码数据进行分析,补充衍生指标
|
||||
* @param {{ period: string, date: string, hundreds: number, tens: number, ones: number }[]} rawData
|
||||
* @returns 完整分析数据
|
||||
*/
|
||||
export function enrichData(rawData) {
|
||||
return rawData.map((item, index) => {
|
||||
const { hundreds, tens, ones } = item
|
||||
const sum = hundreds + tens + ones
|
||||
const span = Math.max(hundreds, tens, ones) - Math.min(hundreds, tens, ones)
|
||||
const pattern = getPattern(hundreds, tens, ones)
|
||||
const oddEven = `${getOddEven(hundreds)}${getOddEven(tens)}${getOddEven(ones)}`
|
||||
const oddCount = [hundreds, tens, ones].filter(d => d % 2 === 1).length
|
||||
const bigSmall = `${getBigSmall(hundreds)}${getBigSmall(tens)}${getBigSmall(ones)}`
|
||||
const bigCount = [hundreds, tens, ones].filter(d => d >= 5).length
|
||||
const roads = `${getRoad(hundreds)}${getRoad(tens)}${getRoad(ones)}`
|
||||
const id = rawData.length - index // id=1 为最新期
|
||||
|
||||
return {
|
||||
...item,
|
||||
sum,
|
||||
span,
|
||||
pattern,
|
||||
oddEven,
|
||||
oddCount,
|
||||
bigSmall,
|
||||
bigCount,
|
||||
roads,
|
||||
id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计数字频率(百十个位分别统计)
|
||||
* @param {object[]} data 完整数据
|
||||
* @returns {object} { hundreds: [{digit, count}], tens: [...], ones: [...] }
|
||||
*/
|
||||
export function calcFrequency(data) {
|
||||
const count = (pos) => {
|
||||
const map = {}
|
||||
for (let i = 0; i < 10; i++) map[i] = { digit: i, count: 0 }
|
||||
data.forEach(item => { map[item[pos]].count++ })
|
||||
return Object.values(map).sort((a, b) => b.count - a.count)
|
||||
}
|
||||
return {
|
||||
hundreds: count('hundreds'),
|
||||
tens: count('tens'),
|
||||
ones: count('ones'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算遗漏
|
||||
* @param {object[]} data 完整数据(按时间升序)
|
||||
* @returns 遗漏分析数据
|
||||
*/
|
||||
export function calcMissing(data) {
|
||||
const total = data.length
|
||||
const calc = (pos) => {
|
||||
const lastSeen = Array(10).fill(-1) // 每数字上次出现期号(id)
|
||||
const maxMissing = Array(10).fill(0)
|
||||
const currentMissing = Array(10).fill(0)
|
||||
const history = Array(10).fill(null).map(() => []) // 遗漏历史
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const digit = data[i][pos]
|
||||
const id = data[i].id // id 从最新=1 递增到最早=data.length
|
||||
const revIdx = data.length - 1 - i // 从最早开始
|
||||
|
||||
if (lastSeen[digit] === -1) {
|
||||
// 首次出现
|
||||
currentMissing[digit] = revIdx
|
||||
} else {
|
||||
const gap = revIdx - lastSeen[digit] - 1
|
||||
if (gap > maxMissing[digit]) maxMissing[digit] = gap
|
||||
history[digit].push(gap)
|
||||
}
|
||||
lastSeen[digit] = revIdx
|
||||
}
|
||||
|
||||
// 当前遗漏:距最后一期的距离
|
||||
for (let d = 0; d < 10; d++) {
|
||||
currentMissing[d] = total - 1 - lastSeen[d]
|
||||
if (lastSeen[d] === -1) currentMissing[d] = total
|
||||
// 检查最后一段遗漏
|
||||
if (lastSeen[d] !== -1) {
|
||||
const lastGap = total - 1 - lastSeen[d]
|
||||
if (lastGap > maxMissing[d]) maxMissing[d] = lastGap
|
||||
}
|
||||
}
|
||||
|
||||
const digits = []
|
||||
for (let d = 0; d < 10; d++) {
|
||||
digits.push({
|
||||
digit: d,
|
||||
currentMissing: currentMissing[d],
|
||||
maxMissing: maxMissing[d],
|
||||
lastSeen: lastSeen[d] === -1 ? null : data[lastSeen[d]]?.period,
|
||||
avgMissing: history[d].length > 0
|
||||
? Math.round(history[d].reduce((a, b) => a + b, 0) / history[d].length * 10) / 10
|
||||
: null,
|
||||
totalShows: history[d].length + (lastSeen[d] !== -1 ? 1 : 0),
|
||||
history: history[d],
|
||||
})
|
||||
}
|
||||
|
||||
return digits
|
||||
}
|
||||
|
||||
return {
|
||||
hundreds: calc('hundreds'),
|
||||
tens: calc('tens'),
|
||||
ones: calc('ones'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 形态分布统计
|
||||
*/
|
||||
export function calcPatternDist(data) {
|
||||
const dist = { [PATTERN.BAO_ZI]: 0, [PATTERN.ZU_SAN]: 0, [PATTERN.ZU_LIU]: 0 }
|
||||
data.forEach(d => { dist[d.pattern]++ })
|
||||
const total = data.length
|
||||
return Object.entries(dist).map(([name, count]) => ({
|
||||
name,
|
||||
count,
|
||||
pct: total > 0 ? (count / total * 100).toFixed(1) : '0',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 和值分布
|
||||
*/
|
||||
export function calcSumDist(data) {
|
||||
const dist = Array(28).fill(0) // 0-27
|
||||
data.forEach(d => { dist[d.sum]++ })
|
||||
return dist.map((count, sum) => ({ sum, count }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨度分布
|
||||
*/
|
||||
export function calcSpanDist(data) {
|
||||
const dist = Array(10).fill(0) // 0-9
|
||||
data.forEach(d => { dist[d.span]++ })
|
||||
return dist.map((count, span) => ({ span, count }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 奇偶比分布
|
||||
*/
|
||||
export function calcOddEvenDist(data) {
|
||||
const dist = { '3:0': 0, '2:1': 0, '1:2': 0, '0:3': 0 }
|
||||
data.forEach(d => { dist[`${d.oddCount}:${3 - d.oddCount}`]++ })
|
||||
return Object.entries(dist).map(([name, count]) => ({ name, count }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小比分布
|
||||
*/
|
||||
export function calcBigSmallDist(data) {
|
||||
const dist = { '3:0': 0, '2:1': 0, '1:2': 0, '0:3': 0 }
|
||||
data.forEach(d => { dist[`${d.bigCount}:${3 - d.bigCount}`]++ })
|
||||
return Object.entries(dist).map(([name, count]) => ({ name, count }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取和值区间
|
||||
*/
|
||||
export function getSumZone(sum) {
|
||||
for (const zone of SUM_ZONES) {
|
||||
if (sum >= zone.min && sum <= zone.max) return zone.label
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成智能选号
|
||||
* @param {object} data - 完整数据
|
||||
* @param {object} options - 筛选条件
|
||||
* @returns {object[]} 候选号码
|
||||
*/
|
||||
export function generateNumbers(data, options = {}) {
|
||||
const {
|
||||
count = 10,
|
||||
patternFilter = null,
|
||||
sumMin = 0,
|
||||
sumMax = 27,
|
||||
spanMin = 0,
|
||||
spanMax = 9,
|
||||
oddEvenFilter = null, // '2:1', '1:2' etc
|
||||
bigSmallFilter = null,
|
||||
excludeRecent = 0, // 排除最近N期已开的
|
||||
preferCold = false,
|
||||
} = options
|
||||
|
||||
const recentSet = new Set()
|
||||
if (excludeRecent > 0) {
|
||||
data.slice(0, excludeRecent).forEach(d => {
|
||||
recentSet.add(`${d.hundreds}${d.tens}${d.ones}`)
|
||||
})
|
||||
}
|
||||
|
||||
// 冷热号加权
|
||||
const freq = calcFrequency(data)
|
||||
const hotScore = (pos, digit) => {
|
||||
const item = freq[pos].find(f => f.digit === digit)
|
||||
return item ? item.count : 0
|
||||
}
|
||||
|
||||
const missing = calcMissing(data)
|
||||
const coldScore = (pos, digit) => {
|
||||
const item = missing[pos].find(m => m.digit === digit)
|
||||
return item ? item.currentMissing : 0
|
||||
}
|
||||
|
||||
const results = []
|
||||
let attempts = 0
|
||||
const maxAttempts = count * 500
|
||||
|
||||
while (results.length < count && attempts < maxAttempts) {
|
||||
attempts++
|
||||
const h = Math.floor(Math.random() * 10)
|
||||
const t = Math.floor(Math.random() * 10)
|
||||
const o = Math.floor(Math.random() * 10)
|
||||
|
||||
const key = `${h}${t}${o}`
|
||||
if (recentSet.has(key)) continue
|
||||
if (results.find(r => r.key === key)) continue
|
||||
|
||||
const sum = h + t + o
|
||||
if (sum < sumMin || sum > sumMax) continue
|
||||
|
||||
const span = Math.max(h, t, o) - Math.min(h, t, o)
|
||||
if (span < spanMin || span > spanMax) continue
|
||||
|
||||
const pattern = getPattern(h, t, o)
|
||||
if (patternFilter && pattern !== patternFilter) continue
|
||||
|
||||
const oddCount = [h, t, o].filter(d => d % 2 === 1).length
|
||||
const oeRatio = `${oddCount}:${3 - oddCount}`
|
||||
if (oddEvenFilter && oeRatio !== oddEvenFilter) continue
|
||||
|
||||
const bigCount = [h, t, o].filter(d => d >= 5).length
|
||||
const bsRatio = `${bigCount}:${3 - bigCount}`
|
||||
if (bigSmallFilter && bsRatio !== bigSmallFilter) continue
|
||||
|
||||
let score = 0
|
||||
if (preferCold) {
|
||||
score = coldScore('hundreds', h) + coldScore('tens', t) + coldScore('ones', o)
|
||||
} else {
|
||||
score = hotScore('hundreds', h) + hotScore('tens', t) + hotScore('ones', o)
|
||||
}
|
||||
|
||||
results.push({
|
||||
key,
|
||||
hundreds: h,
|
||||
tens: t,
|
||||
ones: o,
|
||||
sum,
|
||||
span,
|
||||
pattern,
|
||||
oddEven: oeRatio,
|
||||
bigSmall: bsRatio,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
// 按得分排序
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证中奖
|
||||
* @param {object} bet - { hundreds, tens, ones, type: 'direct'|'group3'|'group6' }
|
||||
* @param {object} draw - { hundreds, tens, ones }
|
||||
* @returns {{ hit: boolean, prize: number }}
|
||||
*/
|
||||
export function checkWin(bet, draw) {
|
||||
const { hundreds: bh, tens: bt, ones: bo } = bet
|
||||
const { hundreds: dh, tens: dt, ones: do_ } = draw
|
||||
const sorted = [dh, dt, do_].sort((a, b) => a - b)
|
||||
const betSorted = [bh, bt, bo].sort((a, b) => a - b)
|
||||
|
||||
// 直选
|
||||
if (bh === dh && bt === dt && bo === do_) {
|
||||
return { hit: true, prize: 1040, type: '直选' }
|
||||
}
|
||||
|
||||
// 组三
|
||||
if (new Set(betSorted).size === 2 && betSorted[0] === sorted[0] && betSorted[1] === sorted[1] && betSorted[2] === sorted[2]) {
|
||||
return { hit: true, prize: 346, type: '组三' }
|
||||
}
|
||||
|
||||
// 组六
|
||||
if (new Set(betSorted).size === 3 && betSorted[0] === sorted[0] && betSorted[1] === sorted[1] && betSorted[2] === sorted[2]) {
|
||||
return { hit: true, prize: 173, type: '组六' }
|
||||
}
|
||||
|
||||
return { hit: false, prize: 0, type: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 回溯投注记录
|
||||
* @param {object[]} data 完整历史数据
|
||||
* @param {object[]} bets 投注记录
|
||||
* @returns 中奖结果
|
||||
*/
|
||||
export function backtestDraws(data, bets) {
|
||||
const results = {
|
||||
totalBets: bets.length * data.length,
|
||||
wins: [],
|
||||
totalPrize: 0,
|
||||
totalCost: bets.length * data.length * 2,
|
||||
}
|
||||
|
||||
for (const bet of bets) {
|
||||
for (const draw of data) {
|
||||
const result = checkWin(bet, draw)
|
||||
if (result.hit) {
|
||||
results.wins.push({ bet, draw, ...result })
|
||||
results.totalPrize += result.prize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.wins.sort((a, b) => a.draw.id - b.draw.id)
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 福彩3D 分析常量
|
||||
|
||||
// 形态类型
|
||||
export const PATTERN = {
|
||||
BAO_ZI: '豹子', // 3个相同
|
||||
ZU_SAN: '组三', // 有且仅有2个相同
|
||||
ZU_LIU: '组六', // 3个各不相同
|
||||
}
|
||||
|
||||
// 获取号码形态
|
||||
export function getPattern(hundreds, tens, ones) {
|
||||
if (hundreds === tens && tens === ones) return PATTERN.BAO_ZI
|
||||
if (hundreds === tens || hundreds === ones || tens === ones) return PATTERN.ZU_SAN
|
||||
return PATTERN.ZU_LIU
|
||||
}
|
||||
|
||||
// 和值范围 0-27
|
||||
export const SUM_RANGE = { min: 0, max: 27 }
|
||||
|
||||
// 跨度范围 0-9
|
||||
export const SPAN_RANGE = { min: 0, max: 9 }
|
||||
|
||||
// 和值区间分类
|
||||
export const SUM_ZONES = [
|
||||
{ label: '极小(0-4)', min: 0, max: 4 },
|
||||
{ label: '小(5-9)', min: 5, max: 9 },
|
||||
{ label: '中(10-14)', min: 10, max: 14 },
|
||||
{ label: '大(15-19)', min: 15, max: 19 },
|
||||
{ label: '极大(20-27)', min: 20, max: 27 },
|
||||
]
|
||||
|
||||
// 大小定义:0-4 为小,5-9 为大
|
||||
export function getBigSmall(digit) {
|
||||
return digit < 5 ? '小' : '大'
|
||||
}
|
||||
|
||||
// 奇偶
|
||||
export function getOddEven(digit) {
|
||||
return digit % 2 === 0 ? '偶' : '奇'
|
||||
}
|
||||
|
||||
// 质合:0,1,4,6,8,9为合数,2,3,5,7为质数
|
||||
export function getPrimeComposite(digit) {
|
||||
return [2, 3, 5, 7].includes(digit) ? '质' : '合'
|
||||
}
|
||||
|
||||
// 012路
|
||||
export function getRoad(digit) {
|
||||
return digit % 3
|
||||
}
|
||||
|
||||
// 最大遗漏阈值,超过则标为"极冷"
|
||||
export const COLD_THRESHOLD = 30
|
||||
|
||||
// 图表配色
|
||||
export const COLORS = {
|
||||
primary: '#e94560',
|
||||
secondary: '#0f3460',
|
||||
accent: '#16213e',
|
||||
bg: '#1a1a2e',
|
||||
text: '#eee',
|
||||
textSecondary: '#aaa',
|
||||
red: '#e94560',
|
||||
blue: '#4a9eff',
|
||||
green: '#00d4aa',
|
||||
orange: '#ff9f43',
|
||||
purple: '#a855f7',
|
||||
chartColors: ['#e94560', '#4a9eff', '#00d4aa', '#ff9f43', '#a855f7', '#f43f5e', '#06b6d4', '#84cc16', '#ec4899', '#f97316'],
|
||||
}
|
||||
|
||||
// 位置名称
|
||||
export const POSITIONS = ['百位', '十位', '个位']
|
||||
@@ -0,0 +1,93 @@
|
||||
// IndexedDB 数据存储
|
||||
const DB_NAME = 'ssq3d'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'draws'
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = (e) => {
|
||||
const db = e.target.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'period' })
|
||||
store.createIndex('date', 'date', { unique: false })
|
||||
store.createIndex('id', 'id', { unique: false })
|
||||
}
|
||||
}
|
||||
request.onsuccess = (e) => resolve(e.target.result)
|
||||
request.onerror = (e) => reject(e.target.error)
|
||||
})
|
||||
}
|
||||
|
||||
// 批量写入数据
|
||||
export async function importData(draws) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
let count = 0
|
||||
for (const draw of draws) {
|
||||
store.put(draw)
|
||||
count++
|
||||
}
|
||||
tx.oncomplete = () => resolve(count)
|
||||
tx.onerror = (e) => reject(e.target.error)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有数据(按 period 降序)
|
||||
export async function getAllData() {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
request.onsuccess = () => {
|
||||
const data = request.result
|
||||
data.sort((a, b) => b.period.localeCompare(a.period))
|
||||
resolve(data)
|
||||
}
|
||||
request.onerror = (e) => reject(e.target.error)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取数据条数
|
||||
export async function getCount() {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.count()
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = (e) => reject(e.target.error)
|
||||
})
|
||||
}
|
||||
|
||||
// 追加一条开奖数据
|
||||
export async function addDraw(draw) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.add(draw)
|
||||
request.onsuccess = () => resolve(true)
|
||||
request.onerror = () => {
|
||||
// 如果 period 重复,更新
|
||||
const putReq = store.put(draw)
|
||||
putReq.onsuccess = () => resolve(true)
|
||||
putReq.onerror = (e) => reject(e.target.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清空数据
|
||||
export async function clearData() {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.clear()
|
||||
request.onsuccess = () => resolve(true)
|
||||
request.onerror = (e) => reject(e.target.error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
echarts: ['echarts'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user