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>
This commit is contained in:
Developer
2026-03-12 11:26:49 +08:00
co-authored by Claude Opus 4.6
commit 7b1073e23b
73 changed files with 7736 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Logs
*.log
# Trained data (OCR)
*.traineddata
# Node modules
node_modules/
# Claude settings
.claude/
# OS files
.DS_Store
Thumbs.db
Executable
+68
View File
@@ -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"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View 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>
+12
View File
@@ -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>
+1996
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+51
View File
@@ -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;
+135
View File
@@ -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;
+195
View File
@@ -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;
+163
View File
@@ -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;
+324
View File
@@ -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;
+88
View File
@@ -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;
+36
View File
@@ -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>
);
}
+956
View File
@@ -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;
}
+10
View File
@@ -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>
)
+330
View File
@@ -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;
+62
View File
@@ -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;
+62
View File
@@ -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;
+13
View File
@@ -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'
}
}
})
+10
View File
@@ -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\""
}
}
+44
View File
@@ -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;
Binary file not shown.
+43
View File
@@ -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}`);
});
+25
View File
@@ -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 };
+2080
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+52
View File
@@ -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;
+65
View File
@@ -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;
+54
View File
@@ -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;
+138
View File
@@ -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;
+250
View File
@@ -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+215元
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 };
+207
View File
@@ -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 };
Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB