Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd30f9a888 |
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-1
@@ -7,9 +7,11 @@
|
||||
<meta name="description" content="福彩3D数据分析工具 - 走势图、遗漏分析、形态分析、智能选号" />
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<title>福彩3D分析工具</title>
|
||||
<script type="module" crossorigin src="./assets/index-TyXIxciA.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/echarts-Bn9PLWWT.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CWbS8MAk.css">
|
||||
</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
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
福彩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
@@ -1,103 +0,0 @@
|
||||
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
@@ -1,56 +0,0 @@
|
||||
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
@@ -1,414 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,148 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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],
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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] },
|
||||
})),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
// 福彩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
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 福彩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 = ['百位', '十位', '个位']
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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