init: 福彩3D数据分析工具

This commit is contained in:
Colin
2026-08-03 17:22:18 +08:00
commit e974939b8a
22 changed files with 37382 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.log
+15
View File
@@ -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>
+1666
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+16
View File
@@ -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"
}
]
}
+166
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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
View File
@@ -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; }
}
+13
View File
@@ -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>
)
+148
View File
@@ -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>
)
}
+204
View File
@@ -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,
},
],
}
}
+219
View File
@@ -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],
},
}],
}
}
+181
View File
@@ -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] },
})),
}],
}
}
+155
View File
@@ -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>
)
}
+182
View File
@@ -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,
}],
}
}
+335
View File
@@ -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>
)
}
+335
View File
@@ -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
}
+72
View File
@@ -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 = ['百位', '十位', '个位']
+93
View File
@@ -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)
})
}
+17
View File
@@ -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'],
},
},
},
},
})