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
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'
}
}
})