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:
Vendored
+124
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+72
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>订单管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-B_G5WsDc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BmLY7fra.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>彩票记录</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1996
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "lottery-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"axios": "^1.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem('user');
|
||||
const token = localStorage.getItem('token');
|
||||
if (savedUser && token) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData, token) => {
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
localStorage.setItem('token', token);
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div>加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={user ? <Navigate to="/dashboard" /> : <Login onLogin={handleLogin} />}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={user ? <Dashboard user={user} onLogout={handleLogout} /> : <Navigate to="/" />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { ocrAPI } from '../services/api';
|
||||
|
||||
function Camera({ onCapture, onClose }) {
|
||||
const videoRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [captured, setCaptured] = useState(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
startCamera();
|
||||
return () => stopCamera();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream && videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.onloadedmetadata = () => {
|
||||
videoRef.current.play().catch(() => {});
|
||||
};
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: 1280, height: 720 }
|
||||
});
|
||||
setStream(mediaStream);
|
||||
} catch (err) {
|
||||
setError('无法访问摄像头,请检查权限或使用上传功能');
|
||||
}
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const capture = () => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
setCaptured(blob);
|
||||
stopCamera();
|
||||
}, 'image/jpeg', 0.9);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setCaptured(file);
|
||||
stopCamera();
|
||||
}
|
||||
};
|
||||
|
||||
const retake = () => {
|
||||
setCaptured(null);
|
||||
setError('');
|
||||
startCamera();
|
||||
};
|
||||
|
||||
const parseImage = async () => {
|
||||
if (!captured) return;
|
||||
|
||||
setParsing(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { data } = await ocrAPI.parse(captured);
|
||||
onCapture(data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '识别失败,请重试或手动输入');
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="camera-fullscreen">
|
||||
{error && <div className="camera-error">{error}</div>}
|
||||
|
||||
<div className="camera-preview">
|
||||
{!captured ? (
|
||||
<video ref={videoRef} autoPlay playsInline muted />
|
||||
) : (
|
||||
<img src={URL.createObjectURL(captured)} alt="captured" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} style={{display: 'none'}} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
|
||||
<div className="camera-controls">
|
||||
{!captured ? (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={capture}>拍照</button>
|
||||
<button className="btn btn-secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
上传图片
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={parseImage} disabled={parsing}>
|
||||
{parsing ? '识别中...' : '开始识别'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={retake} disabled={parsing}>重拍</button>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={parsing}>取消</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Camera;
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function generateWelfare() {
|
||||
// 双色球:6个红球(1-33不重复) + 1个蓝球(1-16)
|
||||
const reds = [];
|
||||
while (reds.length < 6) {
|
||||
const n = randomInt(1, 33);
|
||||
if (!reds.includes(n)) reds.push(n);
|
||||
}
|
||||
reds.sort((a, b) => a - b);
|
||||
const blue = randomInt(1, 16);
|
||||
return {
|
||||
red: reds.map(n => String(n).padStart(2, '0')),
|
||||
blue: String(blue).padStart(2, '0')
|
||||
};
|
||||
}
|
||||
|
||||
function generateSports() {
|
||||
// 大乐透:5个前区(1-35不重复) + 2个后区(1-12不重复)
|
||||
const fronts = [];
|
||||
while (fronts.length < 5) {
|
||||
const n = randomInt(1, 35);
|
||||
if (!fronts.includes(n)) fronts.push(n);
|
||||
}
|
||||
fronts.sort((a, b) => a - b);
|
||||
const backs = [];
|
||||
while (backs.length < 2) {
|
||||
const n = randomInt(1, 12);
|
||||
if (!backs.includes(n)) backs.push(n);
|
||||
}
|
||||
backs.sort((a, b) => a - b);
|
||||
return {
|
||||
front: fronts.map(n => String(n).padStart(2, '0')),
|
||||
back: backs.map(n => String(n).padStart(2, '0'))
|
||||
};
|
||||
}
|
||||
|
||||
function RandomPick({ onSubmit, onClose }) {
|
||||
const [type, setType] = useState('welfare');
|
||||
const [betCount, setBetCount] = useState(5);
|
||||
const [multiple, setMultiple] = useState(1);
|
||||
const [isAdditional, setIsAdditional] = useState(false);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
|
||||
const handleGenerate = () => {
|
||||
const nums = [];
|
||||
for (let i = 0; i < betCount; i++) {
|
||||
nums.push(type === 'welfare' ? generateWelfare() : generateSports());
|
||||
}
|
||||
setGenerated(nums);
|
||||
};
|
||||
|
||||
const refreshOne = (index) => {
|
||||
const newGenerated = [...generated];
|
||||
newGenerated[index] = type === 'welfare' ? generateWelfare() : generateSports();
|
||||
setGenerated(newGenerated);
|
||||
};
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
setType(newType);
|
||||
setGenerated(null);
|
||||
setIsAdditional(false);
|
||||
};
|
||||
|
||||
const unitPrice = type === 'sports' && isAdditional ? 3 : 2;
|
||||
const totalCost = (generated ? generated.length : betCount) * multiple * unitPrice;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!generated) return;
|
||||
onSubmit({
|
||||
type,
|
||||
game: type === 'welfare' ? '双色球' : '大乐透',
|
||||
issue: null,
|
||||
numbers: generated,
|
||||
bet_count: generated.length,
|
||||
multiple: Number(multiple),
|
||||
is_additional: type === 'sports' ? isAdditional : false,
|
||||
cost: totalCost
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-fullscreen">
|
||||
<div className="form-header">
|
||||
<button className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
<h2>机选号码</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="form-content">
|
||||
{/* 彩票类型 */}
|
||||
<div className="type-selector">
|
||||
<button className={`type-btn ${type === 'welfare' ? 'active' : ''}`} onClick={() => handleTypeChange('welfare')}>
|
||||
福利彩票(双色球)
|
||||
</button>
|
||||
<button className={`type-btn ${type === 'sports' ? 'active' : ''}`} onClick={() => handleTypeChange('sports')}>
|
||||
体育彩票(大乐透)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 注数和倍数 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>注数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5].map(n => (
|
||||
<button key={n} className={`multiple-btn ${betCount === n ? 'active' : ''}`} onClick={() => { setBetCount(n); setGenerated(null); }}>{n}</button>
|
||||
))}
|
||||
<input type="number" min="1" value={betCount} onChange={(e) => { setBetCount(Math.max(1, Number(e.target.value))); setGenerated(null); }} className="multiple-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<label>倍数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5, 10].map(m => (
|
||||
<button key={m} className={`multiple-btn ${multiple === m ? 'active' : ''}`} onClick={() => setMultiple(m)}>{m}</button>
|
||||
))}
|
||||
<input type="number" min="1" value={multiple} onChange={(e) => setMultiple(Math.max(1, Number(e.target.value)))} className="multiple-input" />
|
||||
</div>
|
||||
</div>
|
||||
{type === 'sports' && (
|
||||
<div className="option-row">
|
||||
<label>追加投注</label>
|
||||
<button className={`toggle-btn ${isAdditional ? 'active' : ''}`} onClick={() => setIsAdditional(!isAdditional)}>
|
||||
{isAdditional ? '已追加 (+1元/注)' : '未追加'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button className="btn btn-primary" onClick={handleGenerate} style={{width: '100%', padding: '15px', fontSize: '16px', marginBottom: '20px'}}>
|
||||
🎲 随机生成
|
||||
</button>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{generated && (
|
||||
<div className="numbers-section">
|
||||
{generated.map((num, index) => (
|
||||
<div key={index} className="number-display" style={{display: 'flex', alignItems: 'center'}}>
|
||||
<div className="number-balls" style={{flex: 1}}>
|
||||
{type === 'welfare' ? (
|
||||
<>
|
||||
{num.red.map((r, i) => <span key={i} className="ball red">{r}</span>)}
|
||||
<span className="ball blue">{num.blue}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{num.front.map((f, i) => <span key={i} className="ball red">{f}</span>)}
|
||||
{num.back.map((b, i) => <span key={`b${i}`} className="ball blue">{b}</span>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button className="refresh-one-btn" onClick={() => refreshOne(index)} title="换一注">🔄</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 费用汇总 */}
|
||||
<div className="summary-section">
|
||||
<div className="summary-row">
|
||||
<span>注数</span>
|
||||
<span>{generated ? generated.length : betCount} 注</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>倍数</span>
|
||||
<span>{multiple} 倍</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>单价</span>
|
||||
<span>{unitPrice} 元/注</span>
|
||||
</div>
|
||||
<div className="summary-row total">
|
||||
<span>合计</span>
|
||||
<span>{totalCost} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
{generated && (
|
||||
<button className="btn btn-primary" onClick={handleSubmit} style={{width: '100%', padding: '15px', fontSize: '18px', marginTop: '20px'}}>
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RandomPick;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lotteryAPI } from '../services/api';
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: { label: '待开奖', className: 'pending' },
|
||||
won: { label: '已中奖', className: 'won' },
|
||||
lost: { label: '未中奖', className: 'lost' }
|
||||
};
|
||||
|
||||
const TYPE_MAP = {
|
||||
welfare: '福彩',
|
||||
sports: '体彩'
|
||||
};
|
||||
|
||||
function TicketDetail({ ticket, onBack, onDelete, onEdit }) {
|
||||
const [winningNumbers, setWinningNumbers] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ticket && ticket.issue && ticket.game) {
|
||||
lotteryAPI.result(ticket.game, ticket.issue).then(res => {
|
||||
if (res.data && res.data.numbers) {
|
||||
setWinningNumbers(res.data.numbers);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [ticket]);
|
||||
|
||||
if (!ticket) return null;
|
||||
|
||||
const status = STATUS_MAP[ticket.status] || STATUS_MAP.pending;
|
||||
const typeLabel = TYPE_MAP[ticket.type] || ticket.type;
|
||||
const numbers = ticket.numbers ? JSON.parse(ticket.numbers) : [];
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定删除这张彩票?')) {
|
||||
onDelete(ticket.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<div className="detail-header">
|
||||
<button className="btn btn-secondary" onClick={onBack}>返回</button>
|
||||
<h2>彩票详情</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">类型</span>
|
||||
<span className="detail-value">{typeLabel} - {ticket.game}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">期数</span>
|
||||
<span className="detail-value">{ticket.issue || '未填写'}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">状态</span>
|
||||
<span className={`ticket-status ${status.className}`}>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{numbers.length > 0 && (
|
||||
<div className="detail-card">
|
||||
<h3 className="detail-card-title">号码</h3>
|
||||
{numbers.map((num, index) => (
|
||||
<div key={index} className="number-display">
|
||||
{ticket.type === 'welfare' ? (
|
||||
<div className="number-balls">
|
||||
{num.red?.map((r, i) => (
|
||||
<span key={i} className="ball red">{r}</span>
|
||||
))}
|
||||
<span className="ball blue">{num.blue}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-balls">
|
||||
{num.front?.map((f, i) => (
|
||||
<span key={i} className="ball red">{f}</span>
|
||||
))}
|
||||
{num.back?.map((b, i) => (
|
||||
<span key={i} className="ball blue">{b}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{winningNumbers && (
|
||||
<div className="detail-card">
|
||||
<h3 className="detail-card-title">中奖号码</h3>
|
||||
<div className="number-display">
|
||||
{ticket.type === 'welfare' ? (
|
||||
<div className="number-balls">
|
||||
{winningNumbers.red?.map((r, i) => (
|
||||
<span key={i} className="ball red">{r}</span>
|
||||
))}
|
||||
<span className="ball blue">{winningNumbers.blue}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-balls">
|
||||
{winningNumbers.front?.map((f, i) => (
|
||||
<span key={i} className="ball red">{f}</span>
|
||||
))}
|
||||
{winningNumbers.back?.map((b, i) => (
|
||||
<span key={i} className="ball blue">{b}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">注数</span>
|
||||
<span className="detail-value">{ticket.bet_count} 注</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">倍数</span>
|
||||
<span className="detail-value">{ticket.multiple} 倍</span>
|
||||
</div>
|
||||
{ticket.type === 'sports' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">追加</span>
|
||||
<span className="detail-value">{ticket.is_additional ? '是' : '否'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">花费</span>
|
||||
<span className="detail-value expense">¥{ticket.cost}</span>
|
||||
</div>
|
||||
{ticket.prize > 0 && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">中奖</span>
|
||||
<span className="detail-value income">¥{ticket.prize}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">创建时间</span>
|
||||
<span className="detail-value">{new Date(ticket.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '12px', marginTop: '20px'}}>
|
||||
<button className="btn btn-primary" style={{flex: 1}} onClick={() => onEdit(ticket)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{flex: 1}} onClick={handleDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketDetail;
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { showToast } from './Toast';
|
||||
|
||||
function TicketForm({ initialData, onSubmit, onCancel, editMode }) {
|
||||
const [type, setType] = useState('welfare'); // welfare 或 sports
|
||||
const [numbers, setNumbers] = useState([createEmptyNumber('welfare')]);
|
||||
const [multiple, setMultiple] = useState(1);
|
||||
const [isAdditional, setIsAdditional] = useState(false);
|
||||
const [issue, setIssue] = useState('');
|
||||
const inputRefs = useRef({});
|
||||
|
||||
function createEmptyNumber(lotteryType) {
|
||||
if (lotteryType === 'welfare') {
|
||||
// 双色球:6红+1蓝
|
||||
return { red: ['', '', '', '', '', ''], blue: '' };
|
||||
} else {
|
||||
// 大乐透:5前区+2后区
|
||||
return { front: ['', '', '', '', ''], back: ['', ''] };
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData?.numbers) {
|
||||
setNumbers(initialData.numbers);
|
||||
if (initialData.type) setType(initialData.type);
|
||||
if (initialData.multiple) setMultiple(initialData.multiple);
|
||||
if (initialData.is_additional) setIsAdditional(initialData.is_additional);
|
||||
if (initialData.issue) setIssue(initialData.issue);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
setType(newType);
|
||||
setNumbers([createEmptyNumber(newType)]);
|
||||
setIsAdditional(false);
|
||||
};
|
||||
|
||||
const addNumber = () => {
|
||||
const newIndex = numbers.length;
|
||||
setNumbers([...numbers, createEmptyNumber(type)]);
|
||||
// 新增后聚焦到第一个输入框
|
||||
setTimeout(() => {
|
||||
const firstKey = type === 'welfare' ? `${newIndex}-red-0` : `${newIndex}-front-0`;
|
||||
if (inputRefs.current[firstKey]) {
|
||||
inputRefs.current[firstKey].focus();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const removeNumber = (index) => {
|
||||
if (numbers.length > 1) {
|
||||
setNumbers(numbers.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取下一个输入框的key
|
||||
const getNextInputKey = (rowIndex, field, subIndex) => {
|
||||
if (type === 'welfare') {
|
||||
if (field === 'red' && subIndex < 5) {
|
||||
return `${rowIndex}-red-${subIndex + 1}`;
|
||||
} else if (field === 'red' && subIndex === 5) {
|
||||
return `${rowIndex}-blue`;
|
||||
}
|
||||
} else {
|
||||
if (field === 'front' && subIndex < 4) {
|
||||
return `${rowIndex}-front-${subIndex + 1}`;
|
||||
} else if (field === 'front' && subIndex === 4) {
|
||||
return `${rowIndex}-back-0`;
|
||||
} else if (field === 'back' && subIndex === 0) {
|
||||
return `${rowIndex}-back-1`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateNumber = (index, field, subIndex, value) => {
|
||||
// 只允许数字,最多2位
|
||||
const cleaned = value.replace(/\D/g, '').slice(0, 2);
|
||||
const newNumbers = [...numbers];
|
||||
if (subIndex !== null) {
|
||||
newNumbers[index][field][subIndex] = cleaned;
|
||||
} else {
|
||||
newNumbers[index][field] = cleaned;
|
||||
}
|
||||
setNumbers(newNumbers);
|
||||
|
||||
// 输入两位数后自动跳转到下一个输入框
|
||||
if (cleaned.length === 2) {
|
||||
const nextKey = getNextInputKey(index, field, subIndex);
|
||||
if (nextKey && inputRefs.current[nextKey]) {
|
||||
inputRefs.current[nextKey].focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 计算注数(只计算填写完整的号码)
|
||||
const betCount = numbers.filter(n => {
|
||||
if (type === 'welfare') {
|
||||
return n.red.every(r => r !== '') && n.blue !== '';
|
||||
} else {
|
||||
return n.front.every(f => f !== '') && n.back.every(b => b !== '');
|
||||
}
|
||||
}).length;
|
||||
|
||||
// 计算总金额
|
||||
const unitPrice = type === 'sports' && isAdditional ? 3 : 2;
|
||||
const totalCost = betCount * multiple * unitPrice;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (betCount === 0) {
|
||||
showToast('请至少填写一注完整的号码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化号码,补零
|
||||
const formattedNumbers = numbers
|
||||
.filter(n => {
|
||||
if (type === 'welfare') {
|
||||
return n.red.every(r => r !== '') && n.blue !== '';
|
||||
} else {
|
||||
return n.front.every(f => f !== '') && n.back.every(b => b !== '');
|
||||
}
|
||||
})
|
||||
.map(n => {
|
||||
if (type === 'welfare') {
|
||||
return {
|
||||
red: n.red.map(r => r.padStart(2, '0')),
|
||||
blue: n.blue.padStart(2, '0')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
front: n.front.map(f => f.padStart(2, '0')),
|
||||
back: n.back.map(b => b.padStart(2, '0'))
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onSubmit({
|
||||
type,
|
||||
game: type === 'welfare' ? '双色球' : '大乐透',
|
||||
issue: issue || null,
|
||||
numbers: formattedNumbers,
|
||||
bet_count: betCount,
|
||||
multiple: Number(multiple),
|
||||
is_additional: type === 'sports' ? isAdditional : false,
|
||||
cost: totalCost
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-fullscreen">
|
||||
<div className="form-header">
|
||||
<button className="btn btn-secondary" onClick={onCancel}>取消</button>
|
||||
<h2>{editMode ? '编辑彩票' : '添加彩票'}</h2>
|
||||
<div style={{width: '60px'}}></div>
|
||||
</div>
|
||||
|
||||
<div className="form-content">
|
||||
{/* 彩票类型选择 */}
|
||||
<div className="type-selector">
|
||||
<button
|
||||
className={`type-btn ${type === 'welfare' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('welfare')}
|
||||
>
|
||||
福利彩票(双色球)
|
||||
</button>
|
||||
<button
|
||||
className={`type-btn ${type === 'sports' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('sports')}
|
||||
>
|
||||
体育彩票(大乐透)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 期数输入 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>期数</label>
|
||||
<input
|
||||
type="text"
|
||||
value={issue}
|
||||
onChange={(e) => setIssue(e.target.value)}
|
||||
placeholder="如:2026001"
|
||||
className="issue-input"
|
||||
style={{flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid #ddd', background: '#fff', color: '#333'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 号码输入 */}
|
||||
<div className="numbers-section">
|
||||
{numbers.map((num, index) => (
|
||||
<div key={index} className="number-row">
|
||||
{type === 'welfare' ? (
|
||||
<div className="number-inputs">
|
||||
<div className="input-group red">
|
||||
{num.red.map((r, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-red-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={r}
|
||||
onChange={(e) => updateNumber(index, 'red', i, e.target.value)}
|
||||
placeholder={`红${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-group blue">
|
||||
<input
|
||||
ref={el => inputRefs.current[`${index}-blue`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={num.blue}
|
||||
onChange={(e) => updateNumber(index, 'blue', null, e.target.value)}
|
||||
placeholder="蓝"
|
||||
/>
|
||||
</div>
|
||||
{numbers.length > 1 && (
|
||||
<button className="remove-btn" onClick={() => removeNumber(index)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="number-inputs">
|
||||
<div className="input-group front">
|
||||
{num.front.map((f, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-front-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={f}
|
||||
onChange={(e) => updateNumber(index, 'front', i, e.target.value)}
|
||||
placeholder={`前${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-group back">
|
||||
{num.back.map((b, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => inputRefs.current[`${index}-back-${i}`] = el}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={b}
|
||||
onChange={(e) => updateNumber(index, 'back', i, e.target.value)}
|
||||
placeholder={`后${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{numbers.length > 1 && (
|
||||
<button className="remove-btn" onClick={() => removeNumber(index)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className="add-number-btn" onClick={addNumber}>+ 添加一注</button>
|
||||
</div>
|
||||
|
||||
{/* 倍数和追加 */}
|
||||
<div className="options-section">
|
||||
<div className="option-row">
|
||||
<label>倍数</label>
|
||||
<div className="multiple-selector">
|
||||
{[1, 2, 3, 5, 10].map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`multiple-btn ${multiple === m ? 'active' : ''}`}
|
||||
onClick={() => setMultiple(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={multiple}
|
||||
onChange={(e) => setMultiple(Math.max(1, Number(e.target.value)))}
|
||||
className="multiple-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'sports' && (
|
||||
<div className="option-row">
|
||||
<label>追加投注</label>
|
||||
<button
|
||||
className={`toggle-btn ${isAdditional ? 'active' : ''}`}
|
||||
onClick={() => setIsAdditional(!isAdditional)}
|
||||
>
|
||||
{isAdditional ? '已追加 (+1元/注)' : '未追加'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 费用汇总 */}
|
||||
<div className="summary-section">
|
||||
<div className="summary-row">
|
||||
<span>注数</span>
|
||||
<span>{betCount} 注</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>单价</span>
|
||||
<span>{unitPrice} 元/注</span>
|
||||
</div>
|
||||
<div className="summary-row total">
|
||||
<span>合计</span>
|
||||
<span>{totalCost} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<button className="btn btn-primary" onClick={handleSubmit} style={{width: '100%', padding: '15px', fontSize: '18px', marginTop: '20px'}}>
|
||||
{editMode ? '更新' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketForm;
|
||||
@@ -0,0 +1,88 @@
|
||||
const STATUS_MAP = {
|
||||
pending: { label: '待开奖', className: 'pending' },
|
||||
won: { label: '已中奖', className: 'won' },
|
||||
lost: { label: '未中奖', className: 'lost' }
|
||||
};
|
||||
|
||||
const TYPE_MAP = {
|
||||
welfare: '福彩',
|
||||
sports: '体彩'
|
||||
};
|
||||
|
||||
function TicketList({ tickets, onSelect, title }) {
|
||||
if (!tickets || tickets.length === 0) {
|
||||
return (
|
||||
<div className="ticket-list">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<p>暂无彩票记录</p>
|
||||
<p>点击"拍照识别"或"手动添加"开始记录</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticket-list">
|
||||
<div className="ticket-list-header">
|
||||
<h3>{title || '彩票记录'} ({tickets.length})</h3>
|
||||
</div>
|
||||
|
||||
{tickets.map(ticket => {
|
||||
const status = STATUS_MAP[ticket.status] || STATUS_MAP.pending;
|
||||
const typeLabel = TYPE_MAP[ticket.type] || ticket.type;
|
||||
|
||||
// 格式化预计开奖时间
|
||||
const formatDrawTime = (isoString) => {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={ticket.id} className="ticket-item" onClick={() => onSelect(ticket)}>
|
||||
<div className="ticket-info">
|
||||
<h4>{typeLabel} - {ticket.game}</h4>
|
||||
<p>
|
||||
期号: {ticket.issue || '未填写'} |
|
||||
{ticket.bet_count}注 × {ticket.multiple}倍
|
||||
{ticket.is_additional ? ' (追加)' : ''}
|
||||
</p>
|
||||
{ticket.status === 'pending' && ticket.estimated_draw_time && (
|
||||
<p style={{color: '#ff9800', fontSize: '12px'}}>
|
||||
预计开奖: {formatDrawTime(ticket.estimated_draw_time)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
花费: ¥{ticket.cost}
|
||||
{ticket.prize > 0 && ` | 中奖: ¥${ticket.prize}`}
|
||||
</p>
|
||||
<p style={{fontSize: '12px', color: '#aaa'}}>
|
||||
{new Date(ticket.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '10px'}}>
|
||||
<span className={`ticket-status ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{color: '#ccc', fontSize: '20px'}}>›</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketList;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
let toastId = 0;
|
||||
let addToastFn = null;
|
||||
|
||||
export function showToast(message, type = 'success') {
|
||||
if (addToastFn) addToastFn({ id: ++toastId, message, type });
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((toast) => {
|
||||
setToasts(prev => [...prev, toast]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
addToastFn = addToast;
|
||||
return () => { addToastFn = null; };
|
||||
}, [addToast]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className={`toast toast-${t.type}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 登录页面 */
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.toggle-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toggle-link a {
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: white;
|
||||
padding: 15px 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.navbar-right span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.stats-grid .stat-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid-4 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.stat-card-mini {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 12px 8px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card-mini h3 {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-card-mini .value {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-card-mini.expense .value { color: #ff4757; }
|
||||
.stat-card-mini.income .value { color: #2ed573; }
|
||||
.stat-card-mini.net .value { color: #667eea; }
|
||||
.stat-card-mini.count .value { color: #ffa502; }
|
||||
.stat-card-mini.random .value { font-size: 24px; }
|
||||
.stat-card-mini.random:active { transform: scale(0.95); }
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-card.expense .value { color: #ff4757; }
|
||||
.stat-card.income .value { color: #2ed573; }
|
||||
.stat-card.net .value { color: #667eea; }
|
||||
|
||||
/* 彩票列表 */
|
||||
.ticket-list {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket-list-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ticket-info h4 {
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ticket-info p {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ticket-status.pending { background: #fff3cd; color: #856404; }
|
||||
.ticket-status.won { background: #d4edda; color: #155724; }
|
||||
.ticket-status.lost { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* 拍照组件 - 全屏 */
|
||||
.camera-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-preview video,
|
||||
.camera-preview img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
/* 全屏表单 */
|
||||
.form-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #f5f5f5;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
border-color: #667eea;
|
||||
background: #f0f3ff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.numbers-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.number-row {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.number-row:last-of-type {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.number-inputs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-group.red input {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.input-group.blue input {
|
||||
border-color: #4dabf7;
|
||||
color: #228be6;
|
||||
}
|
||||
|
||||
.input-group.front input {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.input-group.back input {
|
||||
border-color: #4dabf7;
|
||||
color: #228be6;
|
||||
}
|
||||
|
||||
.add-number-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.options-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.option-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-row label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-right: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multiple-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multiple-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multiple-btn.active {
|
||||
border-color: #667eea;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.multiple-input {
|
||||
width: 50px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
border-color: #2ed573;
|
||||
background: #2ed573;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-row.total {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 底部导航 */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
display: flex;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 我的页面 */
|
||||
.mine-page {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.user-info-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mine-actions {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.mine-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.mine-btn:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mine-btn:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.mine-btn.logout {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.mine-btn-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 18px 8px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 详情页 */
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.detail-card-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value.expense {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.detail-value.income {
|
||||
color: #2ed573;
|
||||
}
|
||||
|
||||
.number-display {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.number-display:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.number-index {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.number-balls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ball {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ball.red {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
|
||||
}
|
||||
|
||||
.ball.blue {
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ticket-item:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
/* 筛选区域 */
|
||||
.filter-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-right: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag.active {
|
||||
border-color: #667eea;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.2s forwards;
|
||||
}
|
||||
|
||||
.toast-success { background: #2ed573; }
|
||||
.toast-error { background: #ff4757; }
|
||||
.toast-info { background: #667eea; }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* 换一注按钮 */
|
||||
.refresh-one-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.refresh-one-btn:active {
|
||||
background: #ddd;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ticketAPI, lotteryAPI } from '../services/api';
|
||||
import Camera from '../components/Camera';
|
||||
import TicketForm from '../components/TicketForm';
|
||||
import TicketList from '../components/TicketList';
|
||||
import TicketDetail from '../components/TicketDetail';
|
||||
import RandomPick from '../components/RandomPick';
|
||||
import ToastContainer, { showToast } from '../components/Toast';
|
||||
|
||||
function Dashboard({ user, onLogout }) {
|
||||
const [activeTab, setActiveTab] = useState('stats');
|
||||
const [stats, setStats] = useState(null);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showCamera, setShowCamera] = useState(false);
|
||||
const [parsedData, setParsedData] = useState(null);
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [showRandomPick, setShowRandomPick] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [editingTicket, setEditingTicket] = useState(null);
|
||||
|
||||
const filteredTickets = tickets.filter(t => {
|
||||
if (filterStatus !== 'all' && t.status !== filterStatus) return false;
|
||||
if (filterType !== 'all' && t.type !== filterType) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredStats = {
|
||||
totalCost: filteredTickets.reduce((sum, t) => sum + Number(t.cost || 0), 0),
|
||||
totalPrize: filteredTickets.reduce((sum, t) => sum + Number(t.prize || 0), 0),
|
||||
totalCount: filteredTickets.length,
|
||||
wonCount: filteredTickets.filter(t => t.status === 'won').length,
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [statsRes, ticketsRes] = await Promise.all([
|
||||
ticketAPI.stats(),
|
||||
ticketAPI.list()
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setTickets(ticketsRes.data.tickets);
|
||||
} catch (err) {
|
||||
console.error('加载数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await lotteryAPI.refresh();
|
||||
await loadData();
|
||||
showToast('开奖数据已刷新');
|
||||
} catch (err) {
|
||||
showToast('刷新失败', 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCameraCapture = (data) => {
|
||||
setParsedData(data);
|
||||
setShowCamera(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingTicket) {
|
||||
await ticketAPI.update(editingTicket.id, data);
|
||||
showToast('更新成功');
|
||||
setEditingTicket(null);
|
||||
setSelectedTicket(null);
|
||||
} else {
|
||||
await ticketAPI.create(data);
|
||||
showToast('添加成功');
|
||||
}
|
||||
setShowForm(false);
|
||||
setParsedData(null);
|
||||
if (data.issue) {
|
||||
await lotteryAPI.refresh();
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.error || '保存失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await ticketAPI.delete(id);
|
||||
setSelectedTicket(null);
|
||||
await loadData();
|
||||
showToast('删除成功');
|
||||
} catch (err) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (ticket) => {
|
||||
const numbers = ticket.numbers ? JSON.parse(ticket.numbers) : [];
|
||||
setEditingTicket(ticket);
|
||||
setParsedData({
|
||||
type: ticket.type,
|
||||
numbers,
|
||||
multiple: ticket.multiple,
|
||||
is_additional: !!ticket.is_additional,
|
||||
issue: ticket.issue || '',
|
||||
});
|
||||
setSelectedTicket(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handlePageRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// 详情页
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<TicketDetail
|
||||
ticket={selectedTicket}
|
||||
onBack={() => setSelectedTicket(null)}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<ToastContainer />
|
||||
<nav className="navbar">
|
||||
<h1>🎰 彩票记录</h1>
|
||||
</nav>
|
||||
|
||||
<div className="container" style={{paddingBottom: '70px'}}>
|
||||
{activeTab === 'stats' && (
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="stats-grid-4">
|
||||
<div className="stat-card-mini expense">
|
||||
<h3>总支出</h3>
|
||||
<div className="value">{stats.totalCost.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini income">
|
||||
<h3>总收益</h3>
|
||||
<div className="value">{stats.totalPrize.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini net">
|
||||
<h3>净收入</h3>
|
||||
<div className="value">{stats.netIncome.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{display: 'flex', gap: '8px', marginBottom: '8px'}}>
|
||||
<button className="btn btn-secondary action-btn" onClick={() => { setParsedData(null); setEditingTicket(null); setShowForm(true); }}>
|
||||
✏️ 手动添加
|
||||
</button>
|
||||
<button className="btn btn-secondary action-btn" onClick={() => setShowRandomPick(true)}>
|
||||
🎲 机选号码
|
||||
</button>
|
||||
<button className="btn btn-secondary action-btn" onClick={handleRefresh} disabled={loading}>
|
||||
🔄 刷新开奖
|
||||
</button>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: '8px', marginBottom: '20px'}}>
|
||||
<button className="btn btn-primary action-btn" onClick={() => setShowCamera(true)}>
|
||||
📷 拍照识别
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 未开奖彩票列表 */}
|
||||
{!loading && tickets.filter(t => t.status === 'pending').length > 0 && (
|
||||
<TicketList tickets={tickets.filter(t => t.status === 'pending')} onSelect={setSelectedTicket} title="未开奖彩票" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'records' && (
|
||||
<>
|
||||
{/* 筛选条件 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-group">
|
||||
<span className="filter-label">状态</span>
|
||||
<div className="filter-tags">
|
||||
{[['all','全部'],['pending','未开奖'],['lost','未中奖'],['won','已中奖']].map(([val, label]) => (
|
||||
<button key={val} className={`filter-tag ${filterStatus === val ? 'active' : ''}`} onClick={() => setFilterStatus(val)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<span className="filter-label">类型</span>
|
||||
<div className="filter-tags">
|
||||
{[['all','全部'],['welfare','福利彩票'],['sports','体育彩票']].map(([val, label]) => (
|
||||
<button key={val} className={`filter-tag ${filterType === val ? 'active' : ''}`} onClick={() => setFilterType(val)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选结果统计 */}
|
||||
<div className="stats-grid-4">
|
||||
<div className="stat-card-mini expense">
|
||||
<h3>总支出</h3>
|
||||
<div className="value">{filteredStats.totalCost.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini income">
|
||||
<h3>总收入</h3>
|
||||
<div className="value">{filteredStats.totalPrize.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini count">
|
||||
<h3>彩票总数</h3>
|
||||
<div className="value">{filteredStats.totalCount}</div>
|
||||
</div>
|
||||
<div className="stat-card-mini net">
|
||||
<h3>中奖数</h3>
|
||||
<div className="value">{filteredStats.wonCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 彩票列表 */}
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div>加载中...</div>
|
||||
) : (
|
||||
<TicketList tickets={filteredTickets} onSelect={setSelectedTicket} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'mine' && (
|
||||
<div className="mine-page">
|
||||
<div className="user-info-card">
|
||||
<div className="user-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-name">{user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="mine-actions">
|
||||
<button className="mine-btn" onClick={handlePageRefresh}>
|
||||
<span className="mine-btn-icon">🔄</span>
|
||||
<span>刷新页面</span>
|
||||
</button>
|
||||
<button className="mine-btn logout" onClick={onLogout}>
|
||||
<span className="mine-btn-icon">🚪</span>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拍照组件 */}
|
||||
{showCamera && (
|
||||
<Camera
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑表单 */}
|
||||
{showForm && (
|
||||
<TicketForm
|
||||
initialData={parsedData}
|
||||
editMode={!!editingTicket}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => { setShowForm(false); setParsedData(null); setEditingTicket(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 机选 */}
|
||||
{showRandomPick && (
|
||||
<RandomPick
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await ticketAPI.create(data);
|
||||
setShowRandomPick(false);
|
||||
await loadData();
|
||||
showToast('保存成功');
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.error || '保存失败', 'error');
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowRandomPick(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部导航 */}
|
||||
<div className="bottom-nav">
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<span className="nav-icon">📊</span>
|
||||
<span className="nav-label">统计</span>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'records' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('records')}
|
||||
>
|
||||
<span className="nav-icon">📋</span>
|
||||
<span className="nav-label">记录</span>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-item ${activeTab === 'mine' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('mine')}
|
||||
>
|
||||
<span className="nav-icon">👤</span>
|
||||
<span className="nav-label">我的</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
|
||||
function Login({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await authAPI.login(username, password);
|
||||
onLogin(data.user, data.token);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? '处理中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,62 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加token
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 - 处理401
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 认证API
|
||||
export const authAPI = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
register: (username, password) => api.post('/auth/register', { username, password }),
|
||||
};
|
||||
|
||||
// 彩票API
|
||||
export const ticketAPI = {
|
||||
list: (page = 1, limit = 20) => api.get('/tickets', { params: { page, limit } }),
|
||||
stats: () => api.get('/tickets/stats'),
|
||||
create: (data) => api.post('/tickets', data),
|
||||
update: (id, data) => api.put(`/tickets/${id}`, data),
|
||||
delete: (id) => api.delete(`/tickets/${id}`),
|
||||
};
|
||||
|
||||
// 开奖API
|
||||
export const lotteryAPI = {
|
||||
result: (game, issue) => api.get('/lottery/result', { params: { game, issue } }),
|
||||
refresh: () => api.post('/lottery/refresh'),
|
||||
recent: (game, limit = 10) => api.get('/lottery/recent', { params: { game, limit } }),
|
||||
};
|
||||
|
||||
// OCR API
|
||||
export const ocrAPI = {
|
||||
parse: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
return api.post('/ocr/parse', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4444'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user