sudoku leader board fixed
This commit is contained in:
@@ -7,6 +7,7 @@ export interface ISudokuRecord extends Document {
|
|||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
difficulty: SudokuDifficulty;
|
difficulty: SudokuDifficulty;
|
||||||
|
gameMode: 'standard' | 'irregular';
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
won: boolean;
|
won: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -17,6 +18,7 @@ export interface ISudokuLeaderboardRecord {
|
|||||||
userId: mongoose.Types.ObjectId;
|
userId: mongoose.Types.ObjectId;
|
||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
|
gameMode: 'standard' | 'irregular';
|
||||||
bestTime: number;
|
bestTime: number;
|
||||||
totalGames: number;
|
totalGames: number;
|
||||||
wonGames: number;
|
wonGames: number;
|
||||||
@@ -43,6 +45,12 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
|
|||||||
enum: ['easy', 'medium', 'hard'],
|
enum: ['easy', 'medium', 'hard'],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
gameMode: {
|
||||||
|
type: String,
|
||||||
|
enum: ['standard', 'irregular'],
|
||||||
|
required: true,
|
||||||
|
default: 'standard'
|
||||||
|
},
|
||||||
timeSeconds: {
|
timeSeconds: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -56,25 +64,41 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
|
|||||||
timestamps: true
|
timestamps: true
|
||||||
});
|
});
|
||||||
|
|
||||||
sudokuRecordSchema.index({ userId: 1, difficulty: 1 });
|
sudokuRecordSchema.index({ userId: 1, difficulty: 1, gameMode: 1 });
|
||||||
sudokuRecordSchema.index({ difficulty: 1, won: 1, timeSeconds: 1 });
|
sudokuRecordSchema.index({ difficulty: 1, won: 1, gameMode: 1, timeSeconds: 1 });
|
||||||
|
|
||||||
sudokuRecordSchema.statics.getLeaderboard = function(
|
sudokuRecordSchema.statics.getLeaderboard = function(
|
||||||
difficulty: SudokuDifficulty,
|
difficulty: SudokuDifficulty,
|
||||||
|
gameMode: 'standard' | 'irregular' | 'all',
|
||||||
skipCount: number,
|
skipCount: number,
|
||||||
pageSize: number
|
pageSize: number
|
||||||
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
|
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
|
||||||
|
const match: any = {
|
||||||
|
difficulty
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameMode === 'standard') {
|
||||||
|
match.$or = [
|
||||||
|
{ gameMode: 'standard' },
|
||||||
|
{ gameMode: { $exists: false } }
|
||||||
|
];
|
||||||
|
} else if (gameMode === 'irregular') {
|
||||||
|
match.gameMode = 'irregular';
|
||||||
|
}
|
||||||
|
|
||||||
return this.aggregate([
|
return this.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: match
|
||||||
difficulty
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: '$userId',
|
_id: {
|
||||||
|
userId: '$userId',
|
||||||
|
gameMode: { $ifNull: ['$gameMode', 'standard'] }
|
||||||
|
},
|
||||||
username: { $first: '$username' },
|
username: { $first: '$username' },
|
||||||
fullname: { $first: '$fullname' },
|
fullname: { $first: '$fullname' },
|
||||||
|
gameMode: { $first: { $ifNull: ['$gameMode', 'standard'] } },
|
||||||
bestTime: {
|
bestTime: {
|
||||||
$min: {
|
$min: {
|
||||||
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
|
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
|
||||||
@@ -92,7 +116,7 @@ sudokuRecordSchema.statics.getLeaderboard = function(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
$addFields: {
|
$addFields: {
|
||||||
userId: '$_id',
|
userId: '$_id.userId',
|
||||||
winRate: {
|
winRate: {
|
||||||
$multiply: [
|
$multiply: [
|
||||||
{ $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] },
|
{ $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] },
|
||||||
|
|||||||
@@ -51,34 +51,71 @@ router.post('/record', auth, async (req: Request, res: Response) => {
|
|||||||
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
|
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { difficulty } = req.params;
|
const { difficulty } = req.params;
|
||||||
const { page = '1', limit = '10' } = req.query;
|
const { page = '1', limit = '10', mode = 'all' } = req.query;
|
||||||
|
|
||||||
const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard'];
|
const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard'];
|
||||||
|
const validModes = ['all', 'standard', 'irregular'];
|
||||||
|
|
||||||
if (!validDifficulties.includes(difficulty as SudokuDifficulty)) {
|
if (!validDifficulties.includes(difficulty as SudokuDifficulty)) {
|
||||||
return res.status(400).json({ error: '无效的难度级别' });
|
return res.status(400).json({ error: '无效的难度级别' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validModes.includes(mode as string)) {
|
||||||
|
return res.status(400).json({ error: '无效的游戏模式' });
|
||||||
|
}
|
||||||
|
|
||||||
const pageNum = parseInt(page as string, 10);
|
const pageNum = parseInt(page as string, 10);
|
||||||
const pageSize = parseInt(limit as string, 10);
|
const pageSize = parseInt(limit as string, 10);
|
||||||
const skipCount = (pageNum - 1) * pageSize;
|
const skipCount = (pageNum - 1) * pageSize;
|
||||||
|
const gameMode = mode as 'standard' | 'irregular' | 'all';
|
||||||
|
|
||||||
const records = await SudokuRecord.getLeaderboard(
|
const records = await SudokuRecord.getLeaderboard(
|
||||||
difficulty as SudokuDifficulty,
|
difficulty as SudokuDifficulty,
|
||||||
|
gameMode,
|
||||||
skipCount,
|
skipCount,
|
||||||
pageSize
|
pageSize
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalUsers = await SudokuRecord.distinct('userId', {
|
const totalMatch: any = {
|
||||||
difficulty,
|
difficulty,
|
||||||
won: true
|
won: true
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (gameMode === 'standard') {
|
||||||
|
totalMatch.$or = [
|
||||||
|
{ gameMode: 'standard' },
|
||||||
|
{ gameMode: { $exists: false } }
|
||||||
|
];
|
||||||
|
} else if (gameMode === 'irregular') {
|
||||||
|
totalMatch.gameMode = 'irregular';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCountResult = await SudokuRecord.aggregate([
|
||||||
|
{
|
||||||
|
$match: totalMatch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
userId: '$userId',
|
||||||
|
gameMode: { $ifNull: ['$gameMode', 'standard'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$count: 'count'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = totalCountResult[0]?.count || 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
records,
|
records,
|
||||||
total: totalUsers.length,
|
total,
|
||||||
currentPage: pageNum,
|
currentPage: pageNum,
|
||||||
totalPages: Math.ceil(totalUsers.length / pageSize),
|
totalPages: Math.ceil(total / pageSize),
|
||||||
difficulty
|
difficulty,
|
||||||
|
mode: gameMode
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取数独排行榜失败:', error);
|
console.error('获取数独排行榜失败:', error);
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ const SudokuGame: React.FC = () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
difficulty,
|
difficulty,
|
||||||
|
gameMode,
|
||||||
timeSeconds,
|
timeSeconds,
|
||||||
won
|
won
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
|||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
|
||||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
type GameMode = 'all' | 'standard' | 'irregular';
|
||||||
|
|
||||||
interface LeaderboardRecord {
|
interface LeaderboardRecord {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
|
gameMode: 'standard' | 'irregular';
|
||||||
bestTime: number;
|
bestTime: number;
|
||||||
totalGames: number;
|
totalGames: number;
|
||||||
wonGames: number;
|
wonGames: number;
|
||||||
@@ -47,6 +49,7 @@ const DIFFICULTY_LABELS: Record<Difficulty, string> = {
|
|||||||
|
|
||||||
const SudokuLeaderboard: React.FC = () => {
|
const SudokuLeaderboard: React.FC = () => {
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
|
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
|
||||||
|
const [gameModeFilter, setGameModeFilter] = useState<GameMode>('all');
|
||||||
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
|
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@@ -55,14 +58,14 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLeaderboard();
|
fetchLeaderboard();
|
||||||
}, [difficulty, page]);
|
}, [difficulty, page, gameModeFilter]);
|
||||||
|
|
||||||
const fetchLeaderboard = async () => {
|
const fetchLeaderboard = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE_URL}/api/sudoku/leaderboard/${difficulty}?page=${page}&limit=10`
|
`${API_BASE_URL}/api/sudoku/leaderboard/${difficulty}?page=${page}&limit=10&mode=${gameModeFilter}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -85,6 +88,11 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGameModeChange = (_event: React.SyntheticEvent, newValue: GameMode) => {
|
||||||
|
setGameModeFilter(newValue);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
|
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
|
||||||
setPage(value);
|
setPage(value);
|
||||||
};
|
};
|
||||||
@@ -139,12 +147,27 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
|
<Tabs
|
||||||
|
value={gameModeFilter}
|
||||||
|
onChange={handleGameModeChange}
|
||||||
|
centered
|
||||||
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
>
|
||||||
|
<Tab value="all" label="全部" />
|
||||||
|
<Tab value="standard" label="标准" />
|
||||||
|
<Tab value="irregular" label="不规则" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<TableContainer component={Paper} sx={{ mb: 3 }}>
|
<TableContainer component={Paper} sx={{ mb: 3 }}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center" width="80px">排名</TableCell>
|
<TableCell align="center" width="80px">排名</TableCell>
|
||||||
<TableCell>姓名</TableCell>
|
<TableCell>姓名</TableCell>
|
||||||
|
<TableCell align="center">模式</TableCell>
|
||||||
<TableCell align="center">最佳时间</TableCell>
|
<TableCell align="center">最佳时间</TableCell>
|
||||||
<TableCell align="center">总游戏次数</TableCell>
|
<TableCell align="center">总游戏次数</TableCell>
|
||||||
<TableCell align="center">获胜次数</TableCell>
|
<TableCell align="center">获胜次数</TableCell>
|
||||||
@@ -155,7 +178,7 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} align="center">
|
<TableCell colSpan={8} align="center">
|
||||||
<Typography variant="body2" color="textSecondary" py={3}>
|
<Typography variant="body2" color="textSecondary" py={3}>
|
||||||
暂无排行榜数据
|
暂无排行榜数据
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -167,7 +190,7 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
const badge = page === 1 ? getRankBadge(rank) : null;
|
const badge = page === 1 ? getRankBadge(rank) : null;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={record.userId}
|
key={`${record.userId}_${record.gameMode}`}
|
||||||
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
|
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
|
||||||
>
|
>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
@@ -181,6 +204,13 @@ const SudokuLeaderboard: React.FC = () => {
|
|||||||
{record.fullname || record.username}
|
{record.fullname || record.username}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={record.gameMode === 'standard' ? '标准' : '不规则'}
|
||||||
|
color={record.gameMode === 'standard' ? 'primary' : 'secondary'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Chip
|
<Chip
|
||||||
label={formatTime(record.bestTime)}
|
label={formatTime(record.bestTime)}
|
||||||
|
|||||||
Reference in New Issue
Block a user