Initial commit: lottery project structure
Add core project files including: - Client and server directories - Backup script - Package configuration - Git ignore rules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -0,0 +1,15 @@
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Trained data (OCR)
|
||||
*.traineddata
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Claude settings
|
||||
.claude/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 彩票项目定时备份脚本
|
||||
# 备份时间: 每天 4:10
|
||||
|
||||
# 配置
|
||||
SOURCE_DIR="/opt/lottery"
|
||||
BACKUP_ROOT="/opt/backup/data"
|
||||
PROJECT_NAME="lottery"
|
||||
|
||||
# 获取当前日期
|
||||
DATE=$(date +%Y%m%d)
|
||||
BACKUP_DIR="${BACKUP_ROOT}/${DATE}/${PROJECT_NAME}"
|
||||
|
||||
# 日志
|
||||
LOG_FILE="/var/log/backup_lottery.log"
|
||||
|
||||
# 记录日志
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# 开始备份
|
||||
log "========== 开始备份 =========="
|
||||
|
||||
# 创建备份目录
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 备份项目源代码 (排除 node_modules 和 .git)
|
||||
log "备份项目源代码..."
|
||||
rsync -a --exclude='node_modules' --exclude='.git' --exclude='*.log' \
|
||||
"$SOURCE_DIR/" "$BACKUP_DIR/source/" 2>&1 | tee -a "$LOG_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "源代码备份完成"
|
||||
else
|
||||
log "警告: 源代码备份出现问题"
|
||||
fi
|
||||
|
||||
# 备份数据库
|
||||
log "备份数据库..."
|
||||
if [ -f "$SOURCE_DIR/server/db/lottery.db" ]; then
|
||||
mkdir -p "$BACKUP_DIR/db"
|
||||
cp "$SOURCE_DIR/server/db/lottery.db" "$BACKUP_DIR/db/"
|
||||
log "数据库备份完成"
|
||||
else
|
||||
log "警告: 数据库文件不存在"
|
||||
fi
|
||||
|
||||
# 备份上传文件
|
||||
log "备份上传文件..."
|
||||
if [ -d "$SOURCE_DIR/server/uploads" ]; then
|
||||
mkdir -p "$BACKUP_DIR/uploads"
|
||||
cp -r "$SOURCE_DIR/server/uploads/"* "$BACKUP_DIR/uploads/" 2>/dev/null
|
||||
log "上传文件备份完成"
|
||||
else
|
||||
log "警告: 上传目录不存在"
|
||||
fi
|
||||
|
||||
# 清理旧备份 (保留最近 30 天)
|
||||
log "清理 30 天前的旧备份..."
|
||||
find "$BACKUP_ROOT" -type d -mtime +30 -exec rm -rf {} \; 2>/dev/null
|
||||
|
||||
log "========== 备份完成 =========="
|
||||
log ""
|
||||
|
||||
# 输出备份目录大小
|
||||
du -sh "$BACKUP_DIR" | tee -a "$LOG_FILE"
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>订单管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-B_G5WsDc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BmLY7fra.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>彩票记录</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "lottery-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"axios": "^1.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem('user');
|
||||
const token = localStorage.getItem('token');
|
||||
if (savedUser && token) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData, token) => {
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
localStorage.setItem('token', token);
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div>加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={user ? <Navigate to="/dashboard" /> : <Login onLogin={handleLogin} />}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={user ? <Dashboard user={user} onLogout={handleLogout} /> : <Navigate to="/" />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { ocrAPI } from '../services/api';
|
||||
|
||||
function Camera({ onCapture, onClose }) {
|
||||
const videoRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [captured, setCaptured] = useState(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
startCamera();
|
||||
return () => stopCamera();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream && videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.onloadedmetadata = () => {
|
||||
videoRef.current.play().catch(() => {});
|
||||
};
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: 1280, height: 720 }
|
||||
});
|
||||
setStream(mediaStream);
|
||||
} catch (err) {
|
||||
setError('无法访问摄像头,请检查权限或使用上传功能');
|
||||
}
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const capture = () => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
setCaptured(blob);
|
||||
stopCamera();
|
||||
}, 'image/jpeg', 0.9);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setCaptured(file);
|
||||
stopCamera();
|
||||
}
|
||||
};
|
||||
|
||||
const retake = () => {
|
||||
setCaptured(null);
|
||||
setError('');
|
||||
startCamera();
|
||||
};
|
||||
|
||||
const parseImage = async () => {
|
||||
if (!captured) return;
|
||||
|
||||
setParsing(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { data } = await ocrAPI.parse(captured);
|
||||
onCapture(data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '识别失败,请重试或手动输入');
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="camera-fullscreen">
|
||||
{error && <div className="camera-error">{error}</div>}
|
||||
|
||||
<div className="camera-preview">
|
||||
{!captured ? (
|
||||
<video ref={videoRef} autoPlay playsInline muted />
|
||||
) : (
|
||||
<img src={URL.createObjectURL(captured)} alt="captured" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} style={{display: 'none'}} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
|
||||
<div className="camera-controls">
|
||||
{!captured ? (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={capture}>拍照</button>
|
||||
<button className="btn btn-secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
上传图片
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={parseImage} disabled={parsing}>
|
||||
{parsing ? '识别中...' : '开始识别'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={retake} disabled={parsing}>重拍</button>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={parsing}>取消</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Camera;
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function generateWelfare() {
|
||||
// 双色球:6个红球(1-33不重复) + 1个蓝球(1-16)
|
||||
const reds = [];
|
||||
while (reds.length < 6) {
|
||||
const n = randomInt(1, 33);
|
||||
if (!reds.includes(n)) reds.push(n);
|
||||
}
|
||||
reds.sort((a, b) => a - b);
|
||||
const blue = randomInt(1, 16);
|
||||
return {
|
||||
red: reds.map(n => String(n).padStart(2, '0')),
|
||||
blue: String(blue).padStart(2, '0')
|
||||
};
|
||||
}
|
||||
|
||||
function generateSports() {
|
||||
// 大乐透:5个前区(1-35不重复) + 2个后区(1-12不重复)
|
||||
const fronts = [];
|
||||
while (fronts.length < 5) {
|
||||
const n = randomInt(1, 35);
|
||||
if (!fronts.includes(n)) fronts.push(n);
|
||||
}
|
||||
fronts.sort((a, b) => a - b);
|
||||
const backs = [];
|
||||
while (backs.length < 2) {
|
||||
const n = randomInt(1, 12);
|
||||
if (!backs.includes(n)) backs.push(n);
|
||||
}
|
||||
backs.sort((a, b) => a - b);
|
||||
return {
|
||||
front: fronts.map(n => String(n).padStart(2, '0')),
|
||||
back: backs.map(n => String(n).padStart(2, '0'))
|
||||
};
|
||||
}
|
||||
|
||||
function RandomPick({ onSubmit, onClose }) {
|
||||
const [type, setType] = useState('welfare');
|
||||
const [betCount, setBetCount] = useState(5);
|
||||
const [multiple, setMultiple] = useState(1);
|
||||
const [isAdditional, setIsAdditional] = useState(false);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
|
||||
const handleGenerate = () => {
|
||||
const nums = [];
|
||||
for (let i = 0; i < betCount; i++) {
|
||||
nums.push(type === 'welfare' ? generateWelfare() : generateSports());
|
||||
}
|
||||
setGenerated(nums);
|
||||
};
|
||||
|
||||
const refreshOne = (index) => {
|
||||
const newGenerated = [...generated];
|
||||
newGenerated[index] = type === 'welfare' ? generateWelfare() : generateSports();
|
||||
setGenerated(newGenerated);
|
||||
};
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
setType(newType);
|
||||
setGenerated(null);
|
||||
setIsAdditional(false);
|
||||
};
|
||||
|
||||
const unitPrice = type === 'sports' && isAdditional ? 3 : 2;
|
||||
const totalCost = (generated ? generated.length : betCount) * multiple * unitPrice;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!generated) return;
|
||||
onSubmit({
|
||||
type,
|
||||
game: type === 'welfare' ? '双色球' : '大乐透',
|
||||
issue: null,
|
||||
numbers: generated,
|
||||
bet_count: generated.length,
|
||||
multiple: Number(multiple),
|
||||
is_additional: type === 'sports' ? isAdditional : false,
|
||||
cost: totalCost
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-fullscreen">
|
||||
<div className="form-header">
|
||||
<button className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
<h2>机选号码</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="form-content">
|
||||
{/* 彩票类型 */}
|
||||
<div className="type-selector">
|
||||
<button className={`type-btn ${type === 'welfare' ? 'active' : ''}`} onClick={() => handleTypeChange('welfare')}>
|
||||
福利彩票(双色球)
|
||||
</button>
|
||||
<button className={`type-btn ${type === 'sports' ? 'active' : ''}`} onClick={() => handleTypeChange('sports')}>
|
||||
体育彩票(大乐透)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 注数和倍数 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>注数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5].map(n => (
|
||||
<button key={n} className={`multiple-btn ${betCount === n ? 'active' : ''}`} onClick={() => { setBetCount(n); setGenerated(null); }}>{n}</button>
|
||||
))}
|
||||
<input type="number" min="1" value={betCount} onChange={(e) => { setBetCount(Math.max(1, Number(e.target.value))); setGenerated(null); }} className="multiple-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<label>倍数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5, 10].map(m => (
|
||||
<button key={m} className={`multiple-btn ${multiple === m ? 'active' : ''}`} onClick={() => setMultiple(m)}>{m}</button>
|
||||
))}
|
||||
<input type="number" min="1" value={multiple} onChange={(e) => setMultiple(Math.max(1, Number(e.target.value)))} className="multiple-input" />
|
||||
</div>
|
||||
</div>
|
||||
{type === 'sports' && (
|
||||
<div className="option-row">
|
||||
<label>追加投注</label>
|
||||
<button className={`toggle-btn ${isAdditional ? 'active' : ''}`} onClick={() => setIsAdditional(!isAdditional)}>
|
||||
{isAdditional ? '已追加 (+1元/注)' : '未追加'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button className="btn btn-primary" onClick={handleGenerate} style={{width: '100%', padding: '15px', fontSize: '16px', marginBottom: '20px'}}>
|
||||
🎲 随机生成
|
||||
</button>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{generated && (
|
||||
<div className="numbers-section">
|
||||
{generated.map((num, index) => (
|
||||
<div key={index} className="number-display" style={{display: 'flex', alignItems: 'center'}}>
|
||||
<div className="number-balls" style={{flex: 1}}>
|
||||
{type === 'welfare' ? (
|
||||
<>
|
||||
{num.red.map((r, i) => <span key={i} className="ball red">{r}</span>)}
|
||||
<span className="ball blue">{num.blue}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{num.front.map((f, i) => <span key={i} className="ball red">{f}</span>)}
|
||||
{num.back.map((b, i) => <span key={`b${i}`} className="ball blue">{b}</span>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button className="refresh-one-btn" onClick={() => refreshOne(index)} title="换一注">🔄</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 费用汇总 */}
|
||||
<div className="summary-section">
|
||||
<div className="summary-row">
|
||||
<span>注数</span>
|
||||
<span>{generated ? generated.length : betCount} 注</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>倍数</span>
|
||||
<span>{multiple} 倍</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>单价</span>
|
||||
<span>{unitPrice} 元/注</span>
|
||||
</div>
|
||||
<div className="summary-row total">
|
||||
<span>合计</span>
|
||||
<span>{totalCost} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
{generated && (
|
||||
<button className="btn btn-primary" onClick={handleSubmit} style={{width: '100%', padding: '15px', fontSize: '18px', marginTop: '20px'}}>
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RandomPick;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lotteryAPI } from '../services/api';
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: { label: '待开奖', className: 'pending' },
|
||||
won: { label: '已中奖', className: 'won' },
|
||||
lost: { label: '未中奖', className: 'lost' }
|
||||
};
|
||||
|
||||
const TYPE_MAP = {
|
||||
welfare: '福彩',
|
||||
sports: '体彩'
|
||||
};
|
||||
|
||||
function TicketDetail({ ticket, onBack, onDelete, onEdit }) {
|
||||
const [winningNumbers, setWinningNumbers] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ticket && ticket.issue && ticket.game) {
|
||||
lotteryAPI.result(ticket.game, ticket.issue).then(res => {
|
||||
if (res.data && res.data.numbers) {
|
||||
setWinningNumbers(res.data.numbers);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [ticket]);
|
||||
|
||||
if (!ticket) return null;
|
||||
|
||||
const status = STATUS_MAP[ticket.status] || STATUS_MAP.pending;
|
||||
const typeLabel = TYPE_MAP[ticket.type] || ticket.type;
|
||||
const numbers = ticket.numbers ? JSON.parse(ticket.numbers) : [];
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定删除这张彩票?')) {
|
||||
onDelete(ticket.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<div className="detail-header">
|
||||
<button className="btn btn-secondary" onClick={onBack}>返回</button>
|
||||
<h2>彩票详情</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">类型</span>
|
||||
<span className="detail-value">{typeLabel} - {ticket.game}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">期数</span>
|
||||
<span className="detail-value">{ticket.issue || '未填写'}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">状态</span>
|
||||
<span className={`ticket-status ${status.className}`}>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{numbers.length > 0 && (
|
||||
<div className="detail-card">
|
||||
<h3 className="detail-card-title">号码</h3>
|
||||
{numbers.map((num, index) => (
|
||||
<div key={index} className="number-display">
|
||||
{ticket.type === 'welfare' ? (
|
||||
<div className="number-balls">
|
||||
{num.red?.map((r, i) => (
|
||||
<span key={i} className="ball red">{r}</span>
|
||||
))}
|
||||
<span className="ball blue">{num.blue}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-balls">
|
||||
{num.front?.map((f, i) => (
|
||||
<span key={i} className="ball red">{f}</span>
|
||||
))}
|
||||
{num.back?.map((b, i) => (
|
||||
<span key={i} className="ball blue">{b}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{winningNumbers && (
|
||||
<div className="detail-card">
|
||||
<h3 className="detail-card-title">中奖号码</h3>
|
||||
<div className="number-display">
|
||||
{ticket.type === 'welfare' ? (
|
||||
<div className="number-balls">
|
||||
{winningNumbers.red?.map((r, i) => (
|
||||
<span key={i} className="ball red">{r}</span>
|
||||
))}
|
||||
<span className="ball blue">{winningNumbers.blue}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-balls">
|
||||
{winningNumbers.front?.map((f, i) => (
|
||||
<span key={i} className="ball red">{f}</span>
|
||||
))}
|
||||
{winningNumbers.back?.map((b, i) => (
|
||||
<span key={i} className="ball blue">{b}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">注数</span>
|
||||
<span className="detail-value">{ticket.bet_count} 注</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">倍数</span>
|
||||
<span className="detail-value">{ticket.multiple} 倍</span>
|
||||
</div>
|
||||
{ticket.type === 'sports' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">追加</span>
|
||||
<span className="detail-value">{ticket.is_additional ? '是' : '否'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">花费</span>
|
||||
<span className="detail-value expense">¥{ticket.cost}</span>
|
||||
</div>
|
||||
{ticket.prize > 0 && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">中奖</span>
|
||||
<span className="detail-value income">¥{ticket.prize}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">创建时间</span>
|
||||
<span className="detail-value">{new Date(ticket.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '12px', marginTop: '20px'}}>
|
||||
<button className="btn btn-primary" style={{flex: 1}} onClick={() => onEdit(ticket)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{flex: 1}} onClick={handleDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketDetail;
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { showToast } from './Toast';
|
||||
|
||||
function TicketForm({ initialData, onSubmit, onCancel, editMode }) {
|
||||
const [type, setType] = useState('welfare'); // welfare 或 sports
|
||||
const [numbers, setNumbers] = useState([createEmptyNumber('welfare')]);
|
||||
const [multiple, setMultiple] = useState(1);
|
||||
const [isAdditional, setIsAdditional] = useState(false);
|
||||
const [issue, setIssue] = useState('');
|
||||
const inputRefs = useRef({});
|
||||
|
||||
function createEmptyNumber(lotteryType) {
|
||||
if (lotteryType === 'welfare') {
|
||||
// 双色球:6红+1蓝
|
||||
return { red: ['', '', '', '', '', ''], blue: '' };
|
||||
} else {
|
||||
// 大乐透:5前区+2后区
|
||||
return { front: ['', '', '', '', ''], back: ['', ''] };
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData?.numbers) {
|
||||
setNumbers(initialData.numbers);
|
||||
if (initialData.type) setType(initialData.type);
|
||||
if (initialData.multiple) setMultiple(initialData.multiple);
|
||||
if (initialData.is_additional) setIsAdditional(initialData.is_additional);
|
||||
if (initialData.issue) setIssue(initialData.issue);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
setType(newType);
|
||||
setNumbers([createEmptyNumber(newType)]);
|
||||
setIsAdditional(false);
|
||||
};
|
||||
|
||||
const addNumber = () => {
|
||||
const newIndex = numbers.length;
|
||||
setNumbers([...numbers, createEmptyNumber(type)]);
|
||||
// 新增后聚焦到第一个输入框
|
||||
setTimeout(() => {
|
||||
const firstKey = type === 'welfare' ? `${newIndex}-red-0` : `${newIndex}-front-0`;
|
||||
if (inputRefs.current[firstKey]) {
|
||||
inputRefs.current[firstKey].focus();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const removeNumber = (index) => {
|
||||
if (numbers.length > 1) {
|
||||
setNumbers(numbers.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取下一个输入框的key
|
||||
const getNextInputKey = (rowIndex, field, subIndex) => {
|
||||
if (type === 'welfare') {
|
||||
if (field === 'red' && subIndex < 5) {
|
||||
return `${rowIndex}-red-${subIndex + 1}`;
|
||||
} else if (field === 'red' && subIndex === 5) {
|
||||
return `${rowIndex}-blue`;
|
||||
}
|
||||
} else {
|
||||
if (field === 'front' && subIndex < 4) {
|
||||
return `${rowIndex}-front-${subIndex + 1}`;
|
||||
} else if (field === 'front' && subIndex === 4) {
|
||||
return `${rowIndex}-back-0`;
|
||||
} else if (field === 'back' && subIndex === 0) {
|
||||
return `${rowIndex}-back-1`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateNumber = (index, field, subIndex, value) => {
|
||||
// 只允许数字,最多2位
|
||||
const cleaned = value.replace(/\D/g, '').slice(0, 2);
|
||||
const newNumbers = [...numbers];
|
||||
if (subIndex !== null) {
|
||||
newNumbers[index][field][subIndex] = cleaned;
|
||||
} else {
|
||||
newNumbers[index][field] = cleaned;
|
||||
}
|
||||
setNumbers(newNumbers);
|
||||
|
||||
// 输入两位数后自动跳转到下一个输入框
|
||||
if (cleaned.length === 2) {
|
||||
const nextKey = getNextInputKey(index, field, subIndex);
|
||||
if (nextKey && inputRefs.current[nextKey]) {
|
||||
inputRefs.current[nextKey].focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 计算注数(只计算填写完整的号码)
|
||||
const betCount = numbers.filter(n => {
|
||||
if (type === 'welfare') {
|
||||
return n.red.every(r => r !== '') && n.blue !== '';
|
||||
} else {
|
||||
return n.front.every(f => f !== '') && n.back.every(b => b !== '');
|
||||
}
|
||||
}).length;
|
||||
|
||||
// 计算总金额
|
||||
const unitPrice = type === 'sports' && isAdditional ? 3 : 2;
|
||||
const totalCost = betCount * multiple * unitPrice;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (betCount === 0) {
|
||||
showToast('请至少填写一注完整的号码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化号码,补零
|
||||
const formattedNumbers = numbers
|
||||
.filter(n => {
|
||||
if (type === 'welfare') {
|
||||
return n.red.every(r => r !== '') && n.blue !== '';
|
||||
} else {
|
||||
return n.front.every(f => f !== '') && n.back.every(b => b !== '');
|
||||
}
|
||||
})
|
||||
.map(n => {
|
||||
if (type === 'welfare') {
|
||||
return {
|
||||
red: n.red.map(r => r.padStart(2, '0')),
|
||||
blue: n.blue.padStart(2, '0')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
front: n.front.map(f => f.padStart(2, '0')),
|
||||
back: n.back.map(b => b.padStart(2, '0'))
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onSubmit({
|
||||
type,
|
||||
game: type === 'welfare' ? '双色球' : '大乐透',
|
||||
issue: issue || null,
|
||||
numbers: formattedNumbers,
|
||||
bet_count: betCount,
|
||||
multiple: Number(multiple),
|
||||
is_additional: type === 'sports' ? isAdditional : false,
|
||||
cost: totalCost
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-fullscreen">
|
||||
<div className="form-header">
|
||||
<button className="btn btn-secondary" onClick={onCancel}>取消</button>
|
||||
<h2>{editMode ? '编辑彩票' : '添加彩票'}</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="form-content">
|
||||
{/* 彩票类型选择 */}
|
||||
<div className="type-selector">
|
||||
<button
|
||||
className={`type-btn ${type === 'welfare' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('welfare')}
|
||||
>
|
||||
福利彩票(双色球)
|
||||
</button>
|
||||
<button
|
||||
className={`type-btn ${type === 'sports' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('sports')}
|
||||
>
|
||||
体育彩票(大乐透)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 期数输入 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>期数</label>
|
||||
<input
|
||||
type="text"
|
||||
value={issue}
|
||||
onChange={(e) => setIssue(e.target.value)}
|
||||
placeholder="如:2026001"
|
||||
className="issue-input"
|
||||
style={{flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid #ddd', background: '#fff', color: '#333'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 号码输入 */}
|
||||
<div className="numbers-section">
|
||||
{numbers.map((num, index) => (
|
||||
<div key={index} className="number-row">
|
||||
{type === 'welfare' ? (
|
||||
<div className="number-inputs">
|
||||
<div className="input-group red">
|
||||
{num.red.map((r, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-red-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={r}
|
||||
onChange={(e) => updateNumber(index, 'red', i, e.target.value)}
|
||||
placeholder={`红${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-group blue">
|
||||
<input
|
||||
ref={el => inputRefs.current[`${index}-blue`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={num.blue}
|
||||
onChange={(e) => updateNumber(index, 'blue', null, e.target.value)}
|
||||
placeholder="蓝"
|
||||
/>
|
||||
</div>
|
||||
{numbers.length > 1 && (
|
||||
<button className="remove-btn" onClick={() => removeNumber(index)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-inputs">
|
||||
<div className="input-group front">
|
||||
{num.front.map((f, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-front-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={f}
|
||||
onChange={(e) => updateNumber(index, 'front', i, e.target.value)}
|
||||
placeholder={`前${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-group back">
|
||||
{num.back.map((b, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-back-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={b}
|
||||
onChange={(e) => updateNumber(index, 'back', i, e.target.value)}
|
||||
placeholder={`后${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{numbers.length > 1 && (
|
||||
<button className="remove-btn" onClick={() => removeNumber(index)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className="add-number-btn" onClick={addNumber}>+ 添加一注</button>
|
||||
</div>
|
||||
|
||||
{/* 倍数和追加 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>倍数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5, 10].map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`multiple-btn ${multiple === m ? 'active' : ''}`}
|
||||
onClick={() => setMultiple(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={multiple}
|
||||
onChange={(e) => setMultiple(Math.max(1, Number(e.target.value)))}
|
||||
className="multiple-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'sports' && (
|
||||
<div className="option-row">
|
||||
<label>追加投注</label>
|
||||
<button
|
||||
className={`toggle-btn ${isAdditional ? 'active' : ''}`}
|
||||
onClick={() => setIsAdditional(!isAdditional)}
|
||||
>
|
||||
{isAdditional ? '已追加 (+1元/注)' : '未追加'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 费用汇总 */}
|
||||
<div className="summary-section">
|
||||
<div className="summary-row">
|
||||
<span>注数</span>
|
||||
<span>{betCount} 注</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>单价</span>
|
||||
<span>{unitPrice} 元/注</span>
|
||||
</div>
|
||||
<div className="summary-row total">
|
||||
<span>合计</span>
|
||||
<span>{totalCost} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<button className="btn btn-primary" onClick={handleSubmit} style={{width: '100%', padding: '15px', fontSize: '18px', marginTop: '20px'}}>
|
||||
{editMode ? '更新' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketForm;
|
||||
@@ -0,0 +1,88 @@
|
||||
const STATUS_MAP = {
|
||||
pending: { label: '待开奖', className: 'pending' },
|
||||
won: { label: '已中奖', className: 'won' },
|
||||
lost: { label: '未中奖', className: 'lost' }
|
||||
};
|
||||
|
||||
const TYPE_MAP = {
|
||||
welfare: '福彩',
|
||||
sports: '体彩'
|
||||
};
|
||||
|
||||
function TicketList({ tickets, onSelect, title }) {
|
||||
if (!tickets || tickets.length === 0) {
|
||||
return (
|
||||
<div className="ticket-list">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<p>暂无彩票记录</p>
|
||||
<p>点击"拍照识别"或"手动添加"开始记录</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticket-list">
|
||||
<div className="ticket-list-header">
|
||||
<h3>{title || '彩票记录'} ({tickets.length})</h3>
|
||||
</div>
|
||||
|
||||
{tickets.map(ticket => {
|
||||
const status = STATUS_MAP[ticket.status] || STATUS_MAP.pending;
|
||||
const typeLabel = TYPE_MAP[ticket.type] || ticket.type;
|
||||
|
||||
// 格式化预计开奖时间
|
||||
const formatDrawTime = (isoString) => {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={ticket.id} className="ticket-item" onClick={() => onSelect(ticket)}>
|
||||
<div className="ticket-info">
|
||||
<h4>{typeLabel} - {ticket.game}</h4>
|
||||
<p>
|
||||
期号: {ticket.issue || '未填写'} |
|
||||
{ticket.bet_count}注 × {ticket.multiple}倍
|
||||
{ticket.is_additional ? ' (追加)' : ''}
|
||||
</p>
|
||||
{ticket.status === 'pending' && ticket.estimated_draw_time && (
|
||||
<p style={{color: '#ff9800', fontSize: '12px'}}>
|
||||
预计开奖: {formatDrawTime(ticket.estimated_draw_time)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
花费: ¥{ticket.cost}
|
||||
{ticket.prize > 0 && ` | 中奖: ¥${ticket.prize}`}
|
||||
</p>
|
||||
<p style={{fontSize: '12px', color: '#aaa'}}>
|
||||
{new Date(ticket.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '10px'}}>
|
||||
<span className={`ticket-status ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{color: '#ccc', fontSize: '20px'}}>›</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketList;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
let toastId = 0;
|
||||
let addToastFn = null;
|
||||
|
||||
export function showToast(message, type = 'success') {
|
||||
if (addToastFn) addToastFn({ id: ++toastId, message, type });
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((toast) => {
|
||||
setToasts(prev => [...prev, toast]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
addToastFn = addToast;
|
||||
return () => { addToastFn = null; };
|
||||
}, [addToast]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className={`toast toast-${t.type}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 登录页面 */
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.toggle-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toggle-link a {
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: white;
|
||||
padding: 15px 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.navbar-right span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.stats-grid .stat-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid-4 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.stat-card-mini {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 12px 8px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card-mini h3 {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-card-mini .value {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-card-mini.expense .value { color: #ff4757; }
|
||||
.stat-card-mini.income .value { color: #2ed573; }
|
||||
.stat-card-mini.net .value { color: #667eea; }
|
||||
.stat-card-mini.count .value { color: #ffa502; }
|
||||
.stat-card-mini.random .value { font-size: 24px; }
|
||||
.stat-card-mini.random:active { transform: scale(0.95); }
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-card.expense .value { color: #ff4757; }
|
||||
.stat-card.income .value { color: #2ed573; }
|
||||
.stat-card.net .value { color: #667eea; }
|
||||
|
||||
/* 彩票列表 */
|
||||
.ticket-list {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket-list-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ticket-info h4 {
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ticket-info p {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ticket-status.pending { background: #fff3cd; color: #856404; }
|
||||
.ticket-status.won { background: #d4edda; color: #155724; }
|
||||
.ticket-status.lost { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* 拍照组件 - 全屏 */
|
||||
.camera-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-preview video,
|
||||
.camera-preview img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
/* 全屏表单 */
|
||||
.form-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #f5f5f5;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
border-color: #667eea;
|
||||
background: #f0f3ff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.numbers-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.number-row {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.number-row:last-of-type {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.number-inputs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-group.red input {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.input-group.blue input {
|
||||
border-color: #4dabf7;
|
||||
color: #228be6;
|
||||
}
|
||||
|
||||
.input-group.front input {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.input-group.back input {
|
||||
border-color: #4dabf7;
|
||||
color: #228be6;
|
||||
}
|
||||
|
||||
.add-number-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.options-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.option-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-row label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-right: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multiple-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multiple-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multiple-btn.active {
|
||||
border-color: #667eea;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.multiple-input {
|
||||
width: 50px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
border-color: #2ed573;
|
||||
background: #2ed573;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-row.total {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 底部导航 */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
display: flex;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 我的页面 */
|
||||
.mine-page {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.user-info-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mine-actions {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.mine-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.mine-btn:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mine-btn:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.mine-btn.logout {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.mine-btn-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 18px 8px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 详情页 */
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.detail-card-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value.expense {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.detail-value.income {
|
||||
color: #2ed573;
|
||||
}
|
||||
|
||||
.number-display {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.number-display:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.number-index {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.number-balls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ball {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ball.red {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
|
||||
}
|
||||
|
||||
.ball.blue {
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ticket-item:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
/* 筛选区域 */
|
||||
.filter-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-right: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag.active {
|
||||
border-color: #667eea;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.2s forwards;
|
||||
}
|
||||
|
||||
.toast-success { background: #2ed573; }
|
||||
.toast-error { background: #ff4757; }
|
||||
.toast-info { background: #667eea; }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* 换一注按钮 */
|
||||
.refresh-one-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.refresh-one-btn:active {
|
||||
background: #ddd;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ticketAPI, lotteryAPI } from '../services/api';
|
||||
import Camera from '../components/Camera';
|
||||
import TicketForm from '../components/TicketForm';
|
||||
import TicketList from '../components/TicketList';
|
||||
import TicketDetail from '../components/TicketDetail';
|
||||
import RandomPick from '../components/RandomPick';
|
||||
import ToastContainer, { showToast } from '../components/Toast';
|
||||
|
||||
function Dashboard({ user, onLogout }) {
|
||||
const [activeTab, setActiveTab] = useState('stats');
|
||||
const [stats, setStats] = useState(null);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showCamera, setShowCamera] = useState(false);
|
||||
const [parsedData, setParsedData] = useState(null);
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [showRandomPick, setShowRandomPick] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [editingTicket, setEditingTicket] = useState(null);
|
||||
|
||||
const filteredTickets = tickets.filter(t => {
|
||||
if (filterStatus !== 'all' && t.status !== filterStatus) return false;
|
||||
if (filterType !== 'all' && t.type !== filterType) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredStats = {
|
||||
totalCost: filteredTickets.reduce((sum, t) => sum + Number(t.cost || 0), 0),
|
||||
totalPrize: filteredTickets.reduce((sum, t) => sum + Number(t.prize || 0), 0),
|
||||
totalCount: filteredTickets.length,
|
||||
wonCount: filteredTickets.filter(t => t.status === 'won').length,
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [statsRes, ticketsRes] = await Promise.all([
|
||||
ticketAPI.stats(),
|
||||
ticketAPI.list()
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setTickets(ticketsRes.data.tickets);
|
||||
} catch (err) {
|
||||
console.error('加载数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await lotteryAPI.refresh();
|
||||
await loadData();
|
||||
showToast('开奖数据已刷新');
|
||||
} catch (err) {
|
||||
showToast('刷新失败', 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCameraCapture = (data) => {
|
||||
setParsedData(data);
|
||||
setShowCamera(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingTicket) {
|
||||
await ticketAPI.update(editingTicket.id, data);
|
||||
showToast('更新成功');
|
||||
setEditingTicket(null);
|
||||
setSelectedTicket(null);
|
||||
} else {
|
||||
await ticketAPI.create(data);
|
||||
showToast('添加成功');
|
||||
}
|
||||
setShowForm(false);
|
||||
setParsedData(null);
|
||||
if (data.issue) {
|
||||
await lotteryAPI.refresh();
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.error || '保存失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await ticketAPI.delete(id);
|
||||
setSelectedTicket(null);
|
||||
await loadData();
|
||||
showToast('删除成功');
|
||||
} catch (err) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (ticket) => {
|
||||
const numbers = ticket.numbers ? JSON.parse(ticket.numbers) : [];
|
||||
setEditingTicket(ticket);
|
||||
setParsedData({
|
||||
type: ticket.type,
|
||||
numbers,
|
||||
multiple: ticket.multiple,
|
||||
is_additional: !!ticket.is_additional,
|
||||
issue: ticket.issue || '',
|
||||
});
|
||||
setSelectedTicket(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handlePageRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// 详情页
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<TicketDetail
|
||||
ticket={selectedTicket}
|
||||
onBack={() => setSelectedTicket(null)}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<ToastContainer />
|
||||
<nav className="navbar">
|
||||
<h1>🎰 彩票记录</h1>
|
||||
</nav>
|
||||
|
||||
<div className="container" style={{paddingBottom: '70px'}}>
|
||||
{activeTab === 'stats' && (
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="stats-grid-4">
|
||||
<div className="stat-card-mini expense">
|
||||
<h3>总支出</h3>
|
||||
<div className="value">{stats.totalCost.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini income">
|
||||
<h3>总收益</h3>
|
||||
<div className="value">{stats.totalPrize.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini net">
|
||||
<h3>净收入</h3>
|
||||
<div className="value">{stats.netIncome.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{display: 'flex', gap: '8px', marginBottom: '8px'}}>
|
||||
<button className="btn btn-secondary action-btn" onClick={() => { setParsedData(null); setEditingTicket(null); setShowForm(true); }}>
|
||||
✏️ 手动添加
|
||||
</button>
|
||||
<button className="btn btn-secondary action-btn" onClick={() => setShowRandomPick(true)}>
|
||||
🎲 机选号码
|
||||
</button>
|
||||
<button className="btn btn-secondary action-btn" onClick={handleRefresh} disabled={loading}>
|
||||
🔄 刷新开奖
|
||||
</button>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: '8px', marginBottom: '20px'}}>
|
||||
<button className="btn btn-primary action-btn" onClick={() => setShowCamera(true)}>
|
||||
📷 拍照识别
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 未开奖彩票列表 */}
|
||||
{!loading && tickets.filter(t => t.status === 'pending').length > 0 && (
|
||||
<TicketList tickets={tickets.filter(t => t.status === 'pending')} onSelect={setSelectedTicket} title="未开奖彩票" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'records' && (
|
||||
<>
|
||||
{/* 筛选条件 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-group">
|
||||
<span className="filter-label">状态</span>
|
||||
<div className="filter-tags">
|
||||
{[['all','全部'],['pending','未开奖'],['lost','未中奖'],['won','已中奖']].map(([val, label]) => (
|
||||
<button key={val} className={`filter-tag ${filterStatus === val ? 'active' : ''}`} onClick={() => setFilterStatus(val)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<span className="filter-label">类型</span>
|
||||
<div className="filter-tags">
|
||||
{[['all','全部'],['welfare','福利彩票'],['sports','体育彩票']].map(([val, label]) => (
|
||||
<button key={val} className={`filter-tag ${filterType === val ? 'active' : ''}`} onClick={() => setFilterType(val)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选结果统计 */}
|
||||
<div className="stats-grid-4">
|
||||
<div className="stat-card-mini expense">
|
||||
<h3>总支出</h3>
|
||||
<div className="value">{filteredStats.totalCost.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini income">
|
||||
<h3>总收入</h3>
|
||||
<div className="value">{filteredStats.totalPrize.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini count">
|
||||
<h3>彩票总数</h3>
|
||||
<div className="value">{filteredStats.totalCount}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini net">
|
||||
<h3>中奖数</h3>
|
||||
<div className="value">{filteredStats.wonCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 彩票列表 */}
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div>加载中...</div>
|
||||
) : (
|
||||
<TicketList tickets={filteredTickets} onSelect={setSelectedTicket} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'mine' && (
|
||||
<div className="mine-page">
|
||||
<div className="user-info-card">
|
||||
<div className="user-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-name">{user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="mine-actions">
|
||||
<button className="mine-btn" onClick={handlePageRefresh}>
|
||||
<span className="mine-btn-icon">🔄</span>
|
||||
<span>刷新页面</span>
|
||||
</button>
|
||||
<button className="mine-btn logout" onClick={onLogout}>
|
||||
<span className="mine-btn-icon">🚪</span>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拍照组件 */}
|
||||
{showCamera && (
|
||||
<Camera
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑表单 */}
|
||||
{showForm && (
|
||||
<TicketForm
|
||||
initialData={parsedData}
|
||||
editMode={!!editingTicket}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => { setShowForm(false); setParsedData(null); setEditingTicket(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 机选 */}
|
||||
{showRandomPick && (
|
||||
<RandomPick
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await ticketAPI.create(data);
|
||||
setShowRandomPick(false);
|
||||
await loadData();
|
||||
showToast('保存成功');
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.error || '保存失败', 'error');
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowRandomPick(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部导航 */}
|
||||
<div className="bottom-nav">
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<span className="nav-icon">📊</span>
|
||||
<span className="nav-label">统计</span>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'records' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('records')}
|
||||
>
|
||||
<span className="nav-icon">📋</span>
|
||||
<span className="nav-label">记录</span>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'mine' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('mine')}
|
||||
>
|
||||
<span className="nav-icon">👤</span>
|
||||
<span className="nav-label">我的</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
|
||||
function Login({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await authAPI.login(username, password);
|
||||
onLogin(data.user, data.token);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? '处理中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,62 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加token
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 - 处理401
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 认证API
|
||||
export const authAPI = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
register: (username, password) => api.post('/auth/register', { username, password }),
|
||||
};
|
||||
|
||||
// 彩票API
|
||||
export const ticketAPI = {
|
||||
list: (page = 1, limit = 20) => api.get('/tickets', { params: { page, limit } }),
|
||||
stats: () => api.get('/tickets/stats'),
|
||||
create: (data) => api.post('/tickets', data),
|
||||
update: (id, data) => api.put(`/tickets/${id}`, data),
|
||||
delete: (id) => api.delete(`/tickets/${id}`),
|
||||
};
|
||||
|
||||
// 开奖API
|
||||
export const lotteryAPI = {
|
||||
result: (game, issue) => api.get('/lottery/result', { params: { game, issue } }),
|
||||
refresh: () => api.post('/lottery/refresh'),
|
||||
recent: (game, limit = 10) => api.get('/lottery/recent', { params: { game, limit } }),
|
||||
};
|
||||
|
||||
// OCR API
|
||||
export const ocrAPI = {
|
||||
parse: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
return api.post('/ocr/parse', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4444'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "lottery-tracker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"server": "cd server && npm start",
|
||||
"client": "cd client && npm start",
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const db = new Database(path.join(__dirname, 'lottery.db'));
|
||||
|
||||
// 初始化表结构
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
issue TEXT,
|
||||
numbers TEXT,
|
||||
bet_count INTEGER DEFAULT 1,
|
||||
multiple INTEGER DEFAULT 1,
|
||||
is_additional INTEGER DEFAULT 0,
|
||||
cost REAL NOT NULL,
|
||||
prize REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
image_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lottery_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game TEXT NOT NULL,
|
||||
issue TEXT NOT NULL,
|
||||
numbers TEXT NOT NULL,
|
||||
draw_date DATE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(game, issue)
|
||||
);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,43 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
// 初始化数据库
|
||||
require('./db/init');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const ticketRoutes = require('./routes/tickets');
|
||||
const lotteryRoutes = require('./routes/lottery');
|
||||
const ocrRoutes = require('./routes/ocr');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4444;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// API 路由
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/tickets', ticketRoutes);
|
||||
app.use('/api/lottery', lotteryRoutes);
|
||||
app.use('/api/ocr', ocrRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 静态文件服务 - 前端构建产物
|
||||
const clientDist = path.join(__dirname, '../client/dist');
|
||||
app.use(express.static(clientDist));
|
||||
|
||||
// SPA 路由 - 所有非 API 请求返回 index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`服务器运行在 http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'lottery-secret-key-change-in-production';
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'token无效' });
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, generateToken, JWT_SECRET };
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "lottery-server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"tesseract.js": "^5.0.4",
|
||||
"axios": "^1.6.7",
|
||||
"cheerio": "^1.0.0-rc.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db/init');
|
||||
const { generateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 注册
|
||||
router.post('/register', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||
const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)');
|
||||
const result = stmt.run(username, hashedPassword);
|
||||
|
||||
const user = { id: result.lastInsertRowid, username };
|
||||
const token = generateToken(user);
|
||||
|
||||
res.json({ user, token });
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
return res.status(400).json({ error: '用户名已存在' });
|
||||
}
|
||||
res.status(500).json({ error: '注册失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 登录
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||
const user = stmt.get(username);
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
res.json({ user: { id: user.id, username: user.username }, token });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,65 @@
|
||||
const express = require('express');
|
||||
const db = require('../db/init');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { fetchLotteryResult, updateTicketsStatus, getEstimatedDrawTime } = require('../services/lottery');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// 查询开奖结果
|
||||
router.get('/result', async (req, res) => {
|
||||
const { game, issue } = req.query;
|
||||
|
||||
if (!game || !issue) {
|
||||
return res.status(400).json({ error: '请提供彩票类型和期号' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchLotteryResult(game, issue);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
// 未开奖时返回预计开奖时间
|
||||
const estimatedTime = getEstimatedDrawTime(game, issue);
|
||||
res.json({
|
||||
message: '暂未开奖或未找到数据',
|
||||
drawn: false,
|
||||
estimated_draw_time: estimatedTime
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: '查询失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新用户所有待开奖彩票状态
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
await updateTicketsStatus(req.user.id);
|
||||
res.json({ message: '刷新成功' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: '刷新失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取最近开奖记录
|
||||
router.get('/recent', async (req, res) => {
|
||||
const { game, limit = 10 } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM lottery_results';
|
||||
const params = [];
|
||||
|
||||
if (game) {
|
||||
query += ' WHERE game = ?';
|
||||
params.push(game);
|
||||
}
|
||||
|
||||
query += ' ORDER BY draw_date DESC LIMIT ?';
|
||||
params.push(Number(limit));
|
||||
|
||||
const results = db.prepare(query).all(...params);
|
||||
res.json(results.map(r => ({ ...r, numbers: JSON.parse(r.numbers) })));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,54 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { parseTicketImage } = require('../services/ocr');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 配置文件上传
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 放宽图片类型限制
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只支持图片文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// 上传并解析彩票图片
|
||||
router.post('/parse', upload.single('image'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: '请上传图片' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseTicketImage(req.file.path);
|
||||
result.image_path = `/uploads/${req.file.filename}`;
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message || '解析失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,138 @@
|
||||
const express = require('express');
|
||||
const db = require('../db/init');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { getEstimatedDrawTime } = require('../services/lottery');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// 获取彩票列表
|
||||
router.get('/', (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const tickets = db.prepare(`
|
||||
SELECT * FROM tickets WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(req.user.id, limit, offset);
|
||||
|
||||
// 为未开奖彩票添加预计开奖时间
|
||||
const ticketsWithDrawTime = tickets.map(ticket => {
|
||||
if (ticket.status === 'pending' && ticket.game && ticket.issue) {
|
||||
return {
|
||||
...ticket,
|
||||
estimated_draw_time: getEstimatedDrawTime(ticket.game, ticket.issue)
|
||||
};
|
||||
}
|
||||
return ticket;
|
||||
});
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) as count FROM tickets WHERE user_id = ?').get(req.user.id);
|
||||
|
||||
res.json({ tickets: ticketsWithDrawTime, total: total.count, page: Number(page), limit: Number(limit) });
|
||||
});
|
||||
|
||||
// 获取统计数据
|
||||
router.get('/stats', (req, res) => {
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(cost), 0) as total_cost,
|
||||
COALESCE(SUM(prize), 0) as total_prize,
|
||||
COUNT(*) as total_tickets,
|
||||
SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count,
|
||||
SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count
|
||||
FROM tickets WHERE user_id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
res.json({
|
||||
totalCost: stats.total_cost,
|
||||
totalPrize: stats.total_prize,
|
||||
netIncome: stats.total_prize - stats.total_cost,
|
||||
totalTickets: stats.total_tickets,
|
||||
wonCount: stats.won_count || 0,
|
||||
lostCount: stats.lost_count || 0,
|
||||
pendingCount: stats.pending_count || 0
|
||||
});
|
||||
});
|
||||
|
||||
// 添加彩票
|
||||
router.post('/', (req, res) => {
|
||||
const { type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path } = req.body;
|
||||
|
||||
if (!type || !game || !cost) {
|
||||
return res.status(400).json({ error: '缺少必要字段' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO tickets (user_id, type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
req.user.id, type, game, issue || null,
|
||||
numbers ? JSON.stringify(numbers) : null,
|
||||
bet_count || 1, multiple || 1, is_additional ? 1 : 0, cost, image_path || null
|
||||
);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: '添加成功' });
|
||||
});
|
||||
|
||||
// 更新彩票
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { type, game, issue, numbers, bet_count, multiple, is_additional, cost, prize, status } = req.body;
|
||||
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: '彩票不存在' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE tickets SET type = ?, game = ?, issue = ?, numbers = ?, bet_count = ?, multiple = ?, is_additional = ?, cost = ?, prize = ?, status = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(
|
||||
type ?? ticket.type,
|
||||
game ?? ticket.game,
|
||||
issue !== undefined ? issue : ticket.issue,
|
||||
numbers ? JSON.stringify(numbers) : ticket.numbers,
|
||||
bet_count ?? ticket.bet_count,
|
||||
multiple ?? ticket.multiple,
|
||||
is_additional !== undefined ? (is_additional ? 1 : 0) : ticket.is_additional,
|
||||
cost ?? ticket.cost,
|
||||
prize ?? ticket.prize,
|
||||
status ?? ticket.status,
|
||||
id
|
||||
);
|
||||
|
||||
res.json({ message: '更新成功' });
|
||||
});
|
||||
|
||||
// 删除彩票
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const ticketId = Number(id);
|
||||
|
||||
console.log('删除请求:', { id, ticketId, userId: req.user.id });
|
||||
|
||||
if (isNaN(ticketId)) {
|
||||
return res.status(400).json({ error: '无效的ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db.prepare('DELETE FROM tickets WHERE id = ? AND user_id = ?').run(ticketId, req.user.id);
|
||||
console.log('删除结果:', result);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: '彩票不存在' });
|
||||
}
|
||||
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (err) {
|
||||
console.error('删除错误:', err);
|
||||
res.status(500).json({ error: '删除失败: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,250 @@
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const db = require('../db/init');
|
||||
|
||||
// 获取预计开奖时间
|
||||
function getEstimatedDrawTime(game, issue) {
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
|
||||
let drawDays = []; // 开奖日期(周日=0,周一=1...)
|
||||
|
||||
if (game === '双色球') {
|
||||
drawDays = [2, 4, 0]; // 周二、周四、周日
|
||||
} else if (game === '大乐透') {
|
||||
drawDays = [1, 3, 5]; // 周一、周三、周六
|
||||
}
|
||||
|
||||
if (drawDays.length === 0) return null;
|
||||
|
||||
// 按日期排序
|
||||
drawDays.sort((a, b) => a - b);
|
||||
|
||||
// 找到最近的下一个开奖日
|
||||
let daysUntilDraw = -1;
|
||||
for (const day of drawDays) {
|
||||
if (day > currentDay) {
|
||||
daysUntilDraw = day - currentDay;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果今天就是开奖日,检查是否已过开奖时间
|
||||
if (daysUntilDraw === -1) {
|
||||
// 没有找到更晚的日期,循环回到第一个开奖日
|
||||
daysUntilDraw = (drawDays[0] + 7) - currentDay;
|
||||
} else if (daysUntilDraw === 0) {
|
||||
// 今天是开奖日,检查是否已过21:15
|
||||
if (currentHour > 21 || (currentHour === 21 && currentMinute >= 15)) {
|
||||
daysUntilDraw = (drawDays[0] + 7) - currentDay;
|
||||
}
|
||||
}
|
||||
|
||||
const nextDraw = new Date(now);
|
||||
nextDraw.setDate(now.getDate() + daysUntilDraw);
|
||||
nextDraw.setHours(21, 15, 0, 0);
|
||||
|
||||
return nextDraw.toISOString();
|
||||
}
|
||||
|
||||
// 获取开奖结果
|
||||
async function fetchLotteryResult(game, issue) {
|
||||
// 先查本地缓存
|
||||
const cached = db.prepare('SELECT * FROM lottery_results WHERE game = ? AND issue = ?').get(game, issue);
|
||||
if (cached) {
|
||||
return { ...cached, numbers: JSON.parse(cached.numbers) };
|
||||
}
|
||||
|
||||
// 从网络获取
|
||||
try {
|
||||
const result = await fetchFromWeb(game, issue);
|
||||
if (result) {
|
||||
// 缓存到数据库
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO lottery_results (game, issue, numbers, draw_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(game, issue, JSON.stringify(result.numbers), result.draw_date);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('获取开奖数据失败:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从500.com获取数据
|
||||
async function fetchFromWeb(game, issue) {
|
||||
try {
|
||||
if (game === '双色球') {
|
||||
return await fetchSSQ(issue);
|
||||
} else if (game === '大乐透') {
|
||||
return await fetchDLT(issue);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`获取${game}开奖数据失败:`, err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取双色球开奖数据
|
||||
async function fetchSSQ(issue) {
|
||||
const url = 'https://datachart.500.com/ssq/history/newinc/history.php?limit=50';
|
||||
const response = await axios.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let result = null;
|
||||
|
||||
// 处理期号格式:2026019 -> 26019 或 26019 -> 26019
|
||||
const shortIssue = issue.length > 5 ? issue.slice(-5) : issue;
|
||||
|
||||
$('#tdata tr').each((i, row) => {
|
||||
const tds = $(row).find('td');
|
||||
const rowIssue = $(tds[0]).text().trim();
|
||||
|
||||
if (rowIssue === shortIssue || rowIssue === issue) {
|
||||
const red = [];
|
||||
for (let j = 1; j <= 6; j++) {
|
||||
red.push($(tds[j]).text().trim());
|
||||
}
|
||||
const blue = $(tds[7]).text().trim();
|
||||
const drawDate = $(tds[15]).text().trim();
|
||||
|
||||
result = {
|
||||
game: '双色球',
|
||||
issue: issue, // 返回原始期号
|
||||
numbers: { red, blue },
|
||||
draw_date: drawDate
|
||||
};
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取大乐透开奖数据
|
||||
async function fetchDLT(issue) {
|
||||
const url = 'https://datachart.500.com/dlt/history/newinc/history.php?limit=50';
|
||||
const response = await axios.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let result = null;
|
||||
|
||||
// 处理期号格式:2026019 -> 26019 或 26019 -> 26019
|
||||
const shortIssue = issue.length > 5 ? issue.slice(-5) : issue;
|
||||
|
||||
$('#tdata tr').each((i, row) => {
|
||||
const tds = $(row).find('td');
|
||||
const rowIssue = $(tds[0]).text().trim();
|
||||
|
||||
if (rowIssue === shortIssue || rowIssue === issue) {
|
||||
const front = [];
|
||||
for (let j = 1; j <= 5; j++) {
|
||||
front.push($(tds[j]).text().trim());
|
||||
}
|
||||
const back = [];
|
||||
for (let j = 6; j <= 7; j++) {
|
||||
back.push($(tds[j]).text().trim());
|
||||
}
|
||||
const drawDate = $(tds[14]).text().trim();
|
||||
|
||||
result = {
|
||||
game: '大乐透',
|
||||
issue: issue, // 返回原始期号
|
||||
numbers: { front, back },
|
||||
draw_date: drawDate
|
||||
};
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查中奖
|
||||
function checkWinning(game, ticketNumbers, resultNumbers) {
|
||||
if (!ticketNumbers || !resultNumbers) return { won: false, prize: 0 };
|
||||
|
||||
if (game === '双色球') {
|
||||
return checkSSQWinning(ticketNumbers, resultNumbers);
|
||||
} else if (game === '大乐透') {
|
||||
return checkDLTWinning(ticketNumbers, resultNumbers);
|
||||
}
|
||||
|
||||
return { won: false, prize: 0 };
|
||||
}
|
||||
|
||||
// 双色球中奖检查
|
||||
function checkSSQWinning(ticket, result) {
|
||||
const redMatch = ticket.red.filter(n => result.red.includes(n)).length;
|
||||
const blueMatch = ticket.blue === result.blue;
|
||||
|
||||
if (redMatch === 6 && blueMatch) return { won: true, level: 1, prize: 5000000 };
|
||||
if (redMatch === 6) return { won: true, level: 2, prize: 100000 };
|
||||
if (redMatch === 5 && blueMatch) return { won: true, level: 3, prize: 3000 };
|
||||
if (redMatch === 5 || (redMatch === 4 && blueMatch)) return { won: true, level: 4, prize: 200 };
|
||||
if (redMatch === 4 || (redMatch === 3 && blueMatch)) return { won: true, level: 5, prize: 10 };
|
||||
if (blueMatch) return { won: true, level: 6, prize: 5 };
|
||||
|
||||
return { won: false, prize: 0 };
|
||||
}
|
||||
|
||||
// 大乐透中奖检查
|
||||
function checkDLTWinning(ticket, result) {
|
||||
const frontMatch = ticket.front.filter(n => result.front.includes(n)).length;
|
||||
const backMatch = ticket.back.filter(n => result.back.includes(n)).length;
|
||||
|
||||
if (frontMatch === 5 && backMatch === 2) return { won: true, level: 1, prize: 10000000 };
|
||||
if (frontMatch === 5 && backMatch === 1) return { won: true, level: 2, prize: 100000 };
|
||||
if (frontMatch === 5) return { won: true, level: 3, prize: 10000 };
|
||||
if (frontMatch === 4 && backMatch === 2) return { won: true, level: 4, prize: 3000 };
|
||||
if (frontMatch === 4 && backMatch === 1) return { won: true, level: 5, prize: 300 };
|
||||
if (frontMatch === 3 && backMatch === 2) return { won: true, level: 6, prize: 200 };
|
||||
if (frontMatch === 4) return { won: true, level: 7, prize: 100 };
|
||||
// 8等奖:3+1 或 2+2,15元
|
||||
if ((frontMatch === 3 && backMatch === 1) || (frontMatch === 2 && backMatch === 2)) return { won: true, level: 8, prize: 15 };
|
||||
// 9等奖:3+0 或 2+1 或 1+2 或 后区2个,5元
|
||||
if (frontMatch === 3 || (frontMatch === 2 && backMatch === 1) || (frontMatch === 1 && backMatch === 2) || backMatch === 2) return { won: true, level: 9, prize: 5 };
|
||||
|
||||
return { won: false, prize: 0 };
|
||||
}
|
||||
|
||||
// 批量更新彩票状态
|
||||
async function updateTicketsStatus(userId) {
|
||||
const pendingTickets = db.prepare(`
|
||||
SELECT * FROM tickets WHERE user_id = ? AND status = 'pending' AND issue IS NOT NULL
|
||||
`).all(userId);
|
||||
|
||||
for (const ticket of pendingTickets) {
|
||||
const result = await fetchLotteryResult(ticket.game, ticket.issue);
|
||||
if (result) {
|
||||
const ticketNumbers = ticket.numbers ? JSON.parse(ticket.numbers) : null;
|
||||
if (ticketNumbers && ticketNumbers.length > 0) {
|
||||
let totalPrize = 0;
|
||||
for (const num of ticketNumbers) {
|
||||
const winning = checkWinning(ticket.game, num, result.numbers);
|
||||
if (winning.won) {
|
||||
totalPrize += winning.prize * ticket.multiple;
|
||||
// 大乐透只有1等奖和2等奖追加才有80%浮动奖金,其他等级没有
|
||||
if (ticket.is_additional && winning.level >= 1 && winning.level <= 2) {
|
||||
totalPrize += Math.floor(winning.prize * 0.8) * ticket.multiple;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE tickets SET prize = ?, status = ? WHERE id = ?')
|
||||
.run(totalPrize, totalPrize > 0 ? 'won' : 'lost', ticket.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchLotteryResult, checkWinning, updateTicketsStatus, getEstimatedDrawTime };
|
||||
@@ -0,0 +1,207 @@
|
||||
const Tesseract = require('tesseract.js');
|
||||
const path = require('path');
|
||||
|
||||
// 彩票类型识别规则
|
||||
const LOTTERY_PATTERNS = {
|
||||
// 福利彩票
|
||||
ssq: { name: '双色球', type: 'welfare', pattern: /双\s*色\s*球|SSQ/i, price: 2 },
|
||||
fc3d: { name: '福彩3D', type: 'welfare', pattern: /福彩\s*3D|3D/i, price: 2 },
|
||||
qlc: { name: '七乐彩', type: 'welfare', pattern: /七\s*乐\s*彩/i, price: 2 },
|
||||
// 体育彩票
|
||||
dlt: { name: '大乐透', type: 'sports', pattern: /大\s*乐\s*透|超级\s*大\s*乐\s*透/i, price: 2 },
|
||||
pl3: { name: '排列3', type: 'sports', pattern: /排列\s*3|排列\s*三/i, price: 2 },
|
||||
pl5: { name: '排列5', type: 'sports', pattern: /排列\s*5|排列\s*五/i, price: 2 },
|
||||
qxc: { name: '七星彩', type: 'sports', pattern: /七\s*星\s*彩/i, price: 2 },
|
||||
};
|
||||
|
||||
// 使用本地tesseract.js-core进行OCR识别
|
||||
async function parseWithLocalTesseract(imagePath) {
|
||||
try {
|
||||
// 使用本地core文件
|
||||
const corePath = path.join(__dirname, '../node_modules/tesseract.js-core');
|
||||
|
||||
const { data } = await Tesseract.recognize(imagePath, 'chi_sim+eng', {
|
||||
corePath: corePath,
|
||||
logger: m => console.log('OCR:', m.status, m.progress)
|
||||
});
|
||||
|
||||
return data.text;
|
||||
} catch (err) {
|
||||
console.error('tesseract.js识别失败:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析彩票图片
|
||||
async function parseTicketImage(imagePath) {
|
||||
let text = '';
|
||||
|
||||
try {
|
||||
text = await parseWithLocalTesseract(imagePath);
|
||||
console.log('OCR识别完成, 文本长度:', text.length);
|
||||
} catch (err) {
|
||||
console.error('OCR识别失败:', err);
|
||||
throw new Error('图片识别失败');
|
||||
}
|
||||
|
||||
// 调试:打印识别的文本
|
||||
console.log('识别的文本:', text.substring(0, 500));
|
||||
|
||||
return parseTicketText(text);
|
||||
}
|
||||
|
||||
// 解析彩票文本
|
||||
function parseTicketText(text) {
|
||||
const result = {
|
||||
type: null,
|
||||
game: null,
|
||||
issue: null,
|
||||
numbers: [],
|
||||
bet_count: 1,
|
||||
multiple: 1,
|
||||
is_additional: false,
|
||||
cost: 0,
|
||||
raw_text: text
|
||||
};
|
||||
|
||||
// 识别彩票类型
|
||||
for (const [key, lottery] of Object.entries(LOTTERY_PATTERNS)) {
|
||||
if (lottery.pattern.test(text)) {
|
||||
result.type = lottery.type;
|
||||
result.game = lottery.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取期号 - 优先匹配"期号:xxx"格式
|
||||
const issueMatch = text.match(/期\s*号\s*[::]\s*(\d{5,})/);
|
||||
if (issueMatch) {
|
||||
result.issue = issueMatch[1];
|
||||
} else {
|
||||
const issueMatch2 = text.match(/第?\s*(\d{5,})\s*期/);
|
||||
if (issueMatch2) {
|
||||
result.issue = issueMatch2[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 提取注数
|
||||
const betMatch = text.match(/(\d+)\s*注/);
|
||||
if (betMatch) {
|
||||
result.bet_count = parseInt(betMatch[1]);
|
||||
}
|
||||
|
||||
// 提取倍数
|
||||
const multipleMatch = text.match(/(\d+)\s*倍/);
|
||||
if (multipleMatch) {
|
||||
result.multiple = parseInt(multipleMatch[1]);
|
||||
}
|
||||
|
||||
// 检测是否追加
|
||||
result.is_additional = /追加|追加投注/.test(text);
|
||||
|
||||
// 提取金额 - 支持多种格式
|
||||
const costMatch = text.match(/金额\s*[::]\s*(\d+(?:[.,]\d+)?)\s*元|合计[::]\s*(\d+(?:\.\d+)?)\s*元|(\d+(?:\.\d+)?)\s*元/);
|
||||
if (costMatch) {
|
||||
const costStr = (costMatch[1] || costMatch[2] || costMatch[3]).replace(',', '.');
|
||||
result.cost = parseFloat(costStr);
|
||||
}
|
||||
|
||||
// 提取号码 - 根据不同彩票类型
|
||||
result.numbers = extractNumbers(text, result.game);
|
||||
|
||||
// 根据识别到的号码数量更新注数
|
||||
if (result.numbers.length > 0) {
|
||||
result.bet_count = result.numbers.length;
|
||||
}
|
||||
|
||||
// 如果没有识别到金额,根据注数和倍数计算
|
||||
if (!result.cost && result.game && result.numbers.length > 0) {
|
||||
const lottery = Object.values(LOTTERY_PATTERNS).find(l => l.name === result.game);
|
||||
if (lottery) {
|
||||
let basePrice = lottery.price * result.bet_count * result.multiple;
|
||||
if (result.is_additional) basePrice += result.bet_count * result.multiple;
|
||||
result.cost = basePrice;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 提取号码
|
||||
function extractNumbers(text, game) {
|
||||
const numbers = [];
|
||||
|
||||
// 预处理文本:修复常见OCR错误
|
||||
let cleanText = text
|
||||
.replace(/[oO]/g, '0') // o和O经常被误识别为0
|
||||
.replace(/[D]/g, '0') // D经常被误识别
|
||||
.replace(/[lI|]/g, '1') // l和I和|经常被误识别为1
|
||||
.replace(/[S\$§]/g, '5') // S和$和§经常被误识别为5
|
||||
.replace(/[@®»«]/g, '') // 去掉这些特殊字符
|
||||
.replace(/[::]/g, ' ') // 冒号替换为空格
|
||||
.replace(/[g]/g, '9'); // g经常被误识别为9
|
||||
|
||||
// 处理连在一起的数字:将6位以上连续数字按每2位分割
|
||||
cleanText = cleanText.replace(/\b(\d{6,})\b/g, (match) => {
|
||||
return match.match(/.{1,2}/g).join(' ');
|
||||
});
|
||||
// 处理4位连续数字
|
||||
cleanText = cleanText.replace(/\b(\d{4})\b/g, (match) => {
|
||||
return match.slice(0, 2) + ' ' + match.slice(2);
|
||||
});
|
||||
|
||||
// 移除开奖号码行(包含"开奖号码"的行)
|
||||
cleanText = cleanText.split('\n')
|
||||
.filter(line => !line.includes('开奖') && !line.includes('号码'))
|
||||
.join('\n');
|
||||
|
||||
if (game === '双色球') {
|
||||
// 双色球: 6红+1蓝,支持多种格式
|
||||
const patterns = [
|
||||
/(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})[-+](\d{2})/g,
|
||||
/(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s*[+|]\s*(\d{2})/g,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = cleanText.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
numbers.push({
|
||||
red: [match[1], match[2], match[3], match[4], match[5], match[6]],
|
||||
blue: match[7]
|
||||
});
|
||||
}
|
||||
if (numbers.length > 0) break;
|
||||
}
|
||||
} else if (game === '大乐透') {
|
||||
// 大乐透: 5前区+2后区
|
||||
const patterns = [
|
||||
// 带空格的标准格式
|
||||
/(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})[-+](\d{2})\s+(\d{2})/g,
|
||||
/(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s*[+|]\s*(\d{2})\s+(\d{2})/g,
|
||||
// 连在一起无空格的格式(第一注可能这样)
|
||||
/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})[-+](\d{2})(\d{2})/g,
|
||||
/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})[+|](\d{2})(\d{2})/g,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = cleanText.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
numbers.push({
|
||||
front: [match[1], match[2], match[3], match[4], match[5]],
|
||||
back: [match[6], match[7]]
|
||||
});
|
||||
}
|
||||
if (numbers.length > 0) break;
|
||||
}
|
||||
} else if (game === '福彩3D' || game === '排列3') {
|
||||
// 3位数
|
||||
const matches = cleanText.matchAll(/[^\d](\d)\s*(\d)\s*(\d)[^\d]/g);
|
||||
for (const match of matches) {
|
||||
numbers.push([match[1], match[2], match[3]]);
|
||||
}
|
||||
}
|
||||
|
||||
return numbers;
|
||||
}
|
||||
|
||||
module.exports = { parseTicketImage, parseTicketText };
|
||||
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 249 KiB |