Compare commits

...

22 Commits

Author SHA1 Message Date
2841413927 fixed a bug 2026-06-13 12:04:54 +08:00
c6b2bb7800 ffff 2026-06-13 10:58:53 +08:00
f9311292cc more.. 2026-06-13 10:47:59 +08:00
b19c811048 continue fixing 2026-06-13 10:35:06 +08:00
76c2f17229 add bodysize 2026-06-07 18:47:39 +08:00
8fd1606424 add function of username search in admin vocabulary scoreaudit 2026-06-04 20:09:38 +08:00
37e4837e3f add more info of AdminVocabularyAudit 2026-05-27 08:29:50 +08:00
0ec12aa0ca fixed a bug 2026-05-26 10:45:32 +08:00
1de143740a check vocabulary 2026-05-25 11:25:12 +08:00
4088fbc9e7 fixed some bugs 2026-05-25 11:07:32 +08:00
6cac2f0c9a fixed cheating michanism 2026-05-24 20:11:00 +08:00
0de72a629c fixed a bug avoid crack answer 2026-05-22 14:50:30 +08:00
01bc52502f no soucemap generned 2026-05-22 14:23:59 +08:00
09aa034f52 fix some problems of games 2026-05-22 13:56:23 +08:00
2f76e6a3ca fixed some bugs 2026-05-21 17:16:44 +08:00
7f44194416 fixed fake post attack 2026-05-21 16:44:53 +08:00
dec29186e0 fixed a bug 2026-05-19 16:25:00 +08:00
257cee6fb2 add hint 2026-05-19 15:48:45 +08:00
5567bf572f sudoku leader board fixed 2026-05-15 23:21:31 +08:00
f1f906be55 added draft mode 2026-05-15 22:21:23 +08:00
8af733a274 add undo/redo feather 2026-05-15 22:00:36 +08:00
e54b9a0b7a radomirregular region 2026-05-15 21:53:08 +08:00
31 changed files with 4381 additions and 519 deletions

View File

@@ -1,6 +1,7 @@
# Server Configuration
SERVER_PORT=5001
REACT_APP_API_BASE_URL=http://localhost:5001
BODY_LIMIT=5mb
# Client Configuration
CLIENT_PORT=3001
REACT_APP_CLIENT_URL=http://localhost:3001

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ build/
# Other
coverage/
.codebuddy
.codex-docwork/

View File

@@ -84,7 +84,6 @@ _TD.a.push(function (TD) {
"laser_gun": {
damage: 25,
range: 6,
max_range: 10,
speed: 20,
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
life: 100,

View File

@@ -20,7 +20,20 @@
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
@@ -159,8 +172,36 @@
if (grid) grid.addBuilding(bi.type);
var b = grid && grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}

View File

@@ -19,7 +19,20 @@ window.__TD_getState = function () {
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
@@ -158,8 +171,36 @@ window.__TD_loadState = function (s) {
if (grid) grid.addBuilding(bi.type);
var b = grid && grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}

View File

@@ -268,7 +268,20 @@ var _TD = {
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
@@ -359,8 +372,36 @@ var _TD = {
if (grid) grid.addBuilding(bi.type);
var b = grid && grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}

View File

@@ -71,7 +71,20 @@
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
@@ -210,8 +223,36 @@
if (grid) grid.addBuilding(bi.type);
var b = grid && grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}

View File

@@ -10,7 +10,7 @@
"debug:server": "cross-env NODE_OPTIONS=\"--loader ts-node/esm --inspect=5858\" ts-node-dev server/server.ts",
"debug:client": "cross-env PORT=3001 react-scripts start",
"debug": "concurrently \"npm run debug:server\" \"npm run debug:client\"",
"build": "react-scripts build",
"build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"migrate-passwords": "ts-node -P tsconfig.json scripts/migratePasswords.ts"

View File

@@ -265,7 +265,20 @@ var _TD = {
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
return { money: TD.money, life: TD.life, score: TD.score, wave: (scene && scene.wave) || 0, buildings: buildings };
@@ -294,8 +307,36 @@ var _TD = {
if (grid) grid.addBuilding(bi.type);
var b = grid && grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}
@@ -3845,7 +3886,6 @@ _TD.a.push(function (TD) {
"laser_gun": {
damage: 25,
range: 6,
max_range: 10,
speed: 20,
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
life: 100,
@@ -4584,5 +4624,3 @@ _TD.a.push(function (TD) {
};
}); // _TD.a.push end

View File

@@ -3824,7 +3824,6 @@ _TD.a.push(function (TD) {
"laser_gun": {
damage: 25,
range: 6,
max_range: 10,
speed: 20,
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
life: 100,

View File

@@ -95,7 +95,20 @@
for (var i = 0; i < map.buildings.length; i++) {
var b = map.buildings[i];
if (!b || !b.grid) continue;
buildings.push({ type: b.type, mx: b.grid.mx, my: b.grid.my, level: b.level, money: b.money });
buildings.push({
type: b.type,
mx: b.grid.mx,
my: b.grid.my,
level: b.level,
money: b.money,
killed: b.killed,
damage: b.damage,
range: b.range,
speed: b.speed,
life: b.life,
shield: b.shield,
upgrade_records: b._upgrade_records || null
});
}
}
@@ -190,8 +203,36 @@
grid.addBuilding(bi.type);
var b = grid.building;
if (b) {
if (typeof bi.level !== 'undefined') b.level = bi.level;
var hasSavedBuildingAttrs = (
typeof bi.damage !== 'undefined' ||
typeof bi.range !== 'undefined' ||
typeof bi.speed !== 'undefined' ||
typeof bi.life !== 'undefined' ||
typeof bi.shield !== 'undefined'
);
if (!hasSavedBuildingAttrs && typeof bi.level !== 'undefined' && bi.level > 0 && typeof b.upgrade === 'function') {
for (var ul = 0; ul < bi.level; ul++) b.upgrade();
} else if (typeof bi.level !== 'undefined') {
b.level = bi.level;
}
if (typeof bi.damage !== 'undefined') b.damage = bi.damage;
if (typeof bi.range !== 'undefined') b.range = bi.range;
if (typeof bi.speed !== 'undefined') b.speed = bi.speed;
if (typeof bi.life !== 'undefined') b.life = bi.life;
if (typeof bi.shield !== 'undefined') b.shield = bi.shield;
if (typeof bi.money !== 'undefined') b.money = bi.money;
if (typeof bi.killed !== 'undefined') b.killed = bi.killed;
if (bi.upgrade_records) {
b._upgrade_records = {};
for (var rk in bi.upgrade_records) {
if (Object.prototype.hasOwnProperty.call(bi.upgrade_records, rk)) {
b._upgrade_records[rk] = bi.upgrade_records[rk];
}
}
}
b.range_px = b.range * TD.grid_size;
b.updateBtnDesc && b.updateBtnDesc();
}
}

View File

@@ -1,6 +1,7 @@
# 服务器配置
PORT=5001
NODE_ENV=development
BODY_LIMIT=5mb
# 数据库配置
MONGODB_URI=mongodb://localhost:27017/typeskill
@@ -8,5 +9,8 @@ MONGODB_URI=mongodb://localhost:27017/typeskill
# JWT配置
JWT_SECRET=your_jwt_secret_key_here
# 词汇防作弊配置
VOCABULARY_FULL_CORRECT_WORD_THRESHOLD=50
# CORS配置
CORS_ORIGIN=http://localhost:3000

View File

@@ -1,12 +1,14 @@
import mongoose, { Document, Schema } from 'mongoose';
export type SudokuDifficulty = 'easy' | 'medium' | 'hard';
export type SudokuGameMode = 'standard' | 'irregular';
export interface ISudokuRecord extends Document {
userId: mongoose.Types.ObjectId;
username: string;
fullname: string;
difficulty: SudokuDifficulty;
gameMode: SudokuGameMode;
timeSeconds: number;
won: boolean;
createdAt: Date;
@@ -17,6 +19,7 @@ export interface ISudokuLeaderboardRecord {
userId: mongoose.Types.ObjectId;
username: string;
fullname: string;
gameMode: SudokuGameMode;
bestTime: number;
totalGames: number;
wonGames: number;
@@ -43,6 +46,12 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
enum: ['easy', 'medium', 'hard'],
required: true
},
gameMode: {
type: String,
enum: ['standard', 'irregular'],
required: true,
default: 'standard'
},
timeSeconds: {
type: Number,
required: true,
@@ -56,25 +65,41 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
timestamps: true
});
sudokuRecordSchema.index({ userId: 1, difficulty: 1 });
sudokuRecordSchema.index({ difficulty: 1, won: 1, timeSeconds: 1 });
sudokuRecordSchema.index({ userId: 1, difficulty: 1, gameMode: 1 });
sudokuRecordSchema.index({ difficulty: 1, won: 1, gameMode: 1, timeSeconds: 1 });
sudokuRecordSchema.statics.getLeaderboard = function(
difficulty: SudokuDifficulty,
gameMode: SudokuGameMode | 'all',
skipCount: number,
pageSize: number
): 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([
{
$match: {
difficulty
}
$match: match
},
{
$group: {
_id: '$userId',
_id: {
userId: '$userId',
gameMode: { $ifNull: ['$gameMode', 'standard'] }
},
username: { $first: '$username' },
fullname: { $first: '$fullname' },
gameMode: { $first: { $ifNull: ['$gameMode', 'standard'] } },
bestTime: {
$min: {
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
@@ -92,7 +117,7 @@ sudokuRecordSchema.statics.getLeaderboard = function(
},
{
$addFields: {
userId: '$_id',
userId: '$_id.userId',
winRate: {
$multiply: [
{ $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] },
@@ -110,6 +135,7 @@ sudokuRecordSchema.statics.getLeaderboard = function(
interface SudokuRecordModel extends mongoose.Model<ISudokuRecord> {
getLeaderboard(
difficulty: SudokuDifficulty,
gameMode: SudokuGameMode | 'all',
skipCount: number,
pageSize: number
): mongoose.Aggregate<ISudokuLeaderboardRecord[]>;

View File

@@ -41,6 +41,7 @@ export interface IVocabularyTestRecord extends Document {
user: mongoose.Types.ObjectId;
wordSet: mongoose.Types.ObjectId;
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
attempt?: mongoose.Types.ObjectId;
stats: {
totalWords: number;
correctWords: number;
@@ -49,9 +50,64 @@ export interface IVocabularyTestRecord extends Document {
endTime: Date;
duration: number;
};
results?: Array<{
word: mongoose.Types.ObjectId;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}>;
invalidated?: boolean;
riskFlags?: string[];
reviewDecision?: 'approved';
reviewedBy?: mongoose.Types.ObjectId;
reviewedAt?: Date;
reviewNote?: string;
createdAt: Date;
}
export interface IVocabularyTestAttempt extends Document {
user: mongoose.Types.ObjectId;
wordSet: mongoose.Types.ObjectId;
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
questionWordIds: mongoose.Types.ObjectId[];
questionTokens: string[];
optionTokens?: string[][];
optionTexts?: string[][];
answeredQuestionTokens?: string[];
answerDurations?: number[];
answers?: Array<{
questionToken: string;
word: mongoose.Types.ObjectId;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
submittedAt: Date;
duration: number;
riskFlags: string[];
interactionSummary?: {
keyCount: number;
inputCount: number;
pasteCount: number;
focusCount: number;
blurCount: number;
pointerCount: number;
firstEventOffset: number;
lastEventOffset: number;
};
}>;
riskFlags?: string[];
reviewDecision?: 'approved';
reviewedBy?: mongoose.Types.ObjectId;
reviewedAt?: Date;
reviewNote?: string;
issuedAt: Date;
expiresAt: Date;
submittedAt?: Date;
status: 'active' | 'submitted' | 'expired';
createdAt: Date;
updatedAt: Date;
}
// 单词模式
const WordSchema = new Schema<IWord>({
word: { type: String, required: true, trim: true },
@@ -83,7 +139,8 @@ const ModeStatsSchema = new Schema({
totalWrong: { type: Number, default: 0 },
mastered: { type: Boolean, default: false },
inWrongBook: { type: Boolean, default: false },
lastTestedAt: Date
lastTestedAt: Date,
lastMasteredAt: Date
}, { _id: false });
// 简化后的主记录模式
@@ -107,6 +164,7 @@ const WordRecordSchema = new Schema({
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
attempt: { type: Schema.Types.ObjectId, ref: 'VocabularyTestAttempt' },
testType: {
type: String,
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
@@ -120,18 +178,86 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
endTime: { type: Date, required: true },
duration: { type: Number, required: true }
},
results: [{
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
userAnswer: { type: String, default: '' },
correctAnswer: { type: String, required: true },
isCorrect: { type: Boolean, required: true }
}],
invalidated: { type: Boolean, default: false },
riskFlags: [{ type: String }],
reviewDecision: { type: String, enum: ['approved'] },
reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' },
reviewedAt: Date,
reviewNote: { type: String, trim: true, maxlength: 500 },
createdAt: { type: Date, default: Date.now }
});
// 单词测试会话模式,用于防止直接伪造测试提交
const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
testType: {
type: String,
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
required: true
},
questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }],
questionTokens: [{ type: String, required: true }],
optionTokens: [[{ type: String }]],
optionTexts: [[{ type: String }]],
answeredQuestionTokens: [{ type: String }],
answerDurations: [{ type: Number }],
answers: [{
questionToken: { type: String, required: true },
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
userAnswer: { type: String, default: '' },
correctAnswer: { type: String, required: true },
isCorrect: { type: Boolean, required: true },
submittedAt: { type: Date, required: true },
duration: { type: Number, required: true },
riskFlags: [{ type: String }],
interactionSummary: {
keyCount: { type: Number, default: 0 },
inputCount: { type: Number, default: 0 },
pasteCount: { type: Number, default: 0 },
focusCount: { type: Number, default: 0 },
blurCount: { type: Number, default: 0 },
pointerCount: { type: Number, default: 0 },
firstEventOffset: { type: Number, default: 0 },
lastEventOffset: { type: Number, default: 0 }
}
}],
riskFlags: [{ type: String }],
reviewDecision: { type: String, enum: ['approved'] },
reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' },
reviewedAt: Date,
reviewNote: { type: String, trim: true, maxlength: 500 },
issuedAt: { type: Date, required: true },
expiresAt: { type: Date, required: true },
submittedAt: Date,
status: {
type: String,
enum: ['active', 'submitted', 'expired'],
default: 'active',
required: true
}
}, { timestamps: true });
VocabularyTestAttemptSchema.index({ user: 1, status: 1, expiresAt: 1 });
VocabularyTestAttemptSchema.index({ expiresAt: 1 });
// 创建和导出模型
export const Word = mongoose.model<IWord>('Word', WordSchema);
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
export const VocabularyTestAttempt = mongoose.model<IVocabularyTestAttempt>('VocabularyTestAttempt', VocabularyTestAttemptSchema);
export default {
Word,
WordSet,
WordRecord,
VocabularyTestRecord
VocabularyTestRecord,
VocabularyTestAttempt
};

View File

@@ -9,8 +9,13 @@ import multer from 'multer';
import fs from 'fs';
import path from 'path';
import csv from 'csv-parser';
import { Word, WordSet } from '../models/Vocabulary';
import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } from '../models/Vocabulary';
import mongoose from 'mongoose';
import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
import {
applyVocabularySummaryItemFilters,
buildVocabularySummaryFilters
} from '../utils/vocabularyAuditFilters';
const router = express.Router();
const adminController = new AdminController();
@@ -45,6 +50,211 @@ const upload = multer({
}
});
type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice';
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
'chinese-to-english': 'chineseToEnglish',
'audio-to-english': 'audioToEnglish',
'multiple-choice': 'multipleChoice'
};
const WORD_RECORD_MODES: WordRecordModeKey[] = [
'chineseToEnglish',
'audioToEnglish',
'multipleChoice'
];
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
const parseAdminDateRange = (source: Record<string, any>) => {
const start = new Date(String(source.start || source.startTime || ''));
const end = new Date(String(source.end || source.endTime || ''));
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { error: '请选择有效的开始和结束时间' };
}
if (start > end) {
return { error: '开始时间不能晚于结束时间' };
}
return { start, end };
};
const createEmptyModeStats = () => ({
streak: 0,
totalCorrect: 0,
totalWrong: 0,
mastered: false,
inWrongBook: false,
lastTestedAt: undefined as Date | undefined,
lastMasteredAt: undefined as Date | undefined
});
const applyVocabularyAnswer = (modeStats: ReturnType<typeof createEmptyModeStats>, isCorrect: boolean, testedAt: Date) => {
if (isCorrect) {
modeStats.streak = (modeStats.streak || 0) + 1;
modeStats.totalCorrect = (modeStats.totalCorrect || 0) + 1;
} else {
modeStats.streak = 0;
modeStats.totalWrong = (modeStats.totalWrong || 0) + 1;
}
modeStats.lastTestedAt = testedAt;
if (modeStats.streak >= 1) {
modeStats.mastered = true;
modeStats.lastMasteredAt = testedAt;
modeStats.inWrongBook = false;
}
if (!modeStats.mastered && modeStats.totalWrong >= 5) {
modeStats.inWrongBook = true;
}
};
const getLatestMasteredAt = (state: Record<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>): Date | undefined => {
let latest: Date | undefined;
WORD_RECORD_MODES.forEach(mode => {
[state[mode].lastMasteredAt, state[mode].lastTestedAt].forEach(value => {
if (!value) return;
if (!latest || value > latest) latest = value;
});
});
return latest;
};
const rebuildVocabularyWordRecordsForUser = async (
userId: string,
wordIds: string[]
) => {
const uniqueWordIds = Array.from(new Set(wordIds.filter(id => mongoose.isValidObjectId(id))));
if (uniqueWordIds.length === 0) return { affectedWords: 0, rebuiltWords: 0 };
const userObjectId = new mongoose.Types.ObjectId(userId);
const wordObjectIds = uniqueWordIds.map(id => new mongoose.Types.ObjectId(id));
const wordIdSet = new Set(uniqueWordIds);
const states = new Map<string, Record<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>>();
const records = await VocabularyTestRecord.find({
user: userObjectId,
invalidated: { $ne: true },
'results.word': { $in: wordObjectIds }
})
.select('testType stats.endTime createdAt results attempt')
.sort({ 'stats.endTime': 1, createdAt: 1 })
.lean();
const attemptIds = records
.map((record: any) => record.attempt?.toString?.() || '')
.filter(id => mongoose.isValidObjectId(id));
const attempts = attemptIds.length > 0
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags reviewDecision').lean()
: [];
const invalidatedAttemptIds = new Set(
attempts
.filter((attempt: any) =>
attempt.reviewDecision !== 'approved' &&
(attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag))
)
.map((attempt: any) => attempt._id.toString())
);
records.forEach((record: any) => {
const attemptId = record.attempt?.toString?.() || '';
if (attemptId && invalidatedAttemptIds.has(attemptId)) return;
const modeKey = TEST_TYPE_TO_MODE[record.testType as VocabularyTestType];
if (!modeKey) return;
const testedAt = new Date(record.stats?.endTime || record.createdAt || Date.now());
(record.results || []).forEach((result: any) => {
const wordId = result.word?.toString?.() || String(result.word || '');
if (!wordIdSet.has(wordId)) return;
if (!states.has(wordId)) {
states.set(wordId, {
chineseToEnglish: createEmptyModeStats(),
audioToEnglish: createEmptyModeStats(),
multipleChoice: createEmptyModeStats()
});
}
const state = states.get(wordId)!;
applyVocabularyAnswer(state[modeKey], Boolean(result.isCorrect), testedAt);
});
});
await WordRecord.deleteMany({ user: userObjectId, word: { $in: wordObjectIds } });
const rebuiltDocs = Array.from(states.entries()).map(([wordId, state]) => {
const isFullyMastered = WORD_RECORD_MODES.every(mode => state[mode].mastered);
return {
user: userObjectId,
word: new mongoose.Types.ObjectId(wordId),
chineseToEnglish: state.chineseToEnglish,
audioToEnglish: state.audioToEnglish,
multipleChoice: state.multipleChoice,
isFullyMastered,
lastFullyMasteredAt: isFullyMastered ? getLatestMasteredAt(state) : undefined,
createdAt: new Date()
};
});
if (rebuiltDocs.length > 0) {
await WordRecord.insertMany(rebuiltDocs);
}
return {
affectedWords: uniqueWordIds.length,
rebuiltWords: rebuiltDocs.length
};
};
const serializeUserRef = (value: any) => {
const id = getObjectIdString(value);
if (!id) return null;
return {
_id: id,
username: value?.username || '',
fullname: value?.fullname || '',
email: value?.email || ''
};
};
const serializeWordRef = (value: any) => {
const id = getObjectIdString(value);
if (!id) return null;
return {
_id: id,
word: value?.word || '',
translation: value?.translation || '',
pronunciation: value?.pronunciation || '',
example: value?.example || ''
};
};
const serializeWordSetRef = (value: any) => {
const id = getObjectIdString(value);
if (!id) return null;
return {
_id: id,
name: value?.name || '',
description: value?.description || ''
};
};
const flattenRiskFlags = (items: any[]): string[] => Array.from(new Set(
(items || [])
.flatMap(item => Array.isArray(item) ? item : [item])
.filter(Boolean)
.map(item => String(item))
));
router.get('/users', adminAuth, async (req, res) => {
try {
const users = await User.find()
@@ -321,4 +531,510 @@ router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => {
}
});
// 按时间段汇总学生词汇测试通过情况
router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
try {
const range = parseAdminDateRange(req.query as Record<string, any>);
if (range.error) {
return res.status(400).json({ message: range.error });
}
const { start, end } = range as { start: Date; end: Date };
const summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
if ('error' in summaryFilters) {
return res.status(400).json({ message: summaryFilters.error });
}
const { userMatchStages, baseMatch, minUniqueWords } = summaryFilters;
const validPassItems = await VocabularyTestRecord.aggregate([
{
$match: {
...baseMatch,
invalidated: { $ne: true },
'stats.endTime': { $gte: start, $lte: end },
'stats.correctWords': { $gt: 0 }
}
},
{ $unwind: '$results' },
{ $match: { 'results.isCorrect': true } },
{
$group: {
_id: '$user',
passedWords: { $sum: 1 },
uniqueWordIds: { $addToSet: '$results.word' },
testRecordIds: { $addToSet: '$_id' },
wordSetIds: { $addToSet: '$wordSet' },
testTypes: { $addToSet: '$testType' },
firstPassedAt: { $min: '$stats.endTime' },
lastPassedAt: { $max: '$stats.endTime' }
}
},
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'user'
}
},
{ $unwind: { path: '$user', preserveNullAndEmptyArrays: true } },
...userMatchStages,
{
$project: {
_id: 0,
userId: { $toString: '$_id' },
username: '$user.username',
fullname: '$user.fullname',
email: '$user.email',
passedWords: 1,
uniqueWords: { $size: '$uniqueWordIds' },
testRecords: { $size: '$testRecordIds' },
wordSets: { $size: '$wordSetIds' },
testTypes: 1,
firstPassedAt: 1,
lastPassedAt: 1
}
},
{ $sort: { passedWords: -1, lastPassedAt: -1 } }
]);
const recordItems = await VocabularyTestRecord.aggregate([
{
$match: {
...baseMatch,
'stats.endTime': { $gte: start, $lte: end }
}
},
{
$group: {
_id: '$user',
totalRecords: { $sum: 1 },
validRecords: {
$sum: {
$cond: [{ $eq: ['$invalidated', true] }, 0, 1]
}
},
invalidatedRecords: {
$sum: {
$cond: [{ $eq: ['$invalidated', true] }, 1, 0]
}
},
riskFlagSets: { $addToSet: '$riskFlags' },
wordSetIds: { $addToSet: '$wordSet' },
testTypes: { $addToSet: '$testType' },
firstRecordAt: { $min: '$stats.endTime' },
lastRecordAt: { $max: '$stats.endTime' }
}
},
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'user'
}
},
{ $unwind: { path: '$user', preserveNullAndEmptyArrays: true } },
...userMatchStages,
{
$project: {
_id: 0,
userId: { $toString: '$_id' },
username: '$user.username',
fullname: '$user.fullname',
email: '$user.email',
totalRecords: 1,
validRecords: 1,
invalidatedRecords: 1,
riskFlagSets: 1,
wordSets: { $size: '$wordSetIds' },
testTypes: 1,
firstRecordAt: 1,
lastRecordAt: 1
}
}
]);
const passMap = new Map(validPassItems.map((item: any) => [item.userId, item]));
const itemMap = new Map<string, any>();
recordItems.forEach((recordItem: any) => {
const passItem = passMap.get(recordItem.userId) || {};
itemMap.set(recordItem.userId, {
userId: recordItem.userId,
username: recordItem.username || passItem.username || '',
fullname: recordItem.fullname || passItem.fullname || '',
email: recordItem.email || passItem.email || '',
passedWords: passItem.passedWords || 0,
uniqueWords: passItem.uniqueWords || 0,
testRecords: passItem.testRecords || 0,
totalRecords: recordItem.totalRecords || 0,
validRecords: recordItem.validRecords || 0,
invalidatedRecords: recordItem.invalidatedRecords || 0,
wordSets: recordItem.wordSets || passItem.wordSets || 0,
testTypes: Array.from(new Set([...(recordItem.testTypes || []), ...(passItem.testTypes || [])])),
riskFlags: flattenRiskFlags(recordItem.riskFlagSets || []),
firstPassedAt: passItem.firstPassedAt,
lastPassedAt: passItem.lastPassedAt,
firstRecordAt: recordItem.firstRecordAt,
lastRecordAt: recordItem.lastRecordAt
});
});
validPassItems.forEach((passItem: any) => {
if (itemMap.has(passItem.userId)) return;
itemMap.set(passItem.userId, {
...passItem,
totalRecords: passItem.testRecords || 0,
validRecords: passItem.testRecords || 0,
invalidatedRecords: 0,
riskFlags: [],
firstRecordAt: passItem.firstPassedAt,
lastRecordAt: passItem.lastPassedAt
});
});
const items = applyVocabularySummaryItemFilters(Array.from(itemMap.values()), minUniqueWords).sort((left, right) => {
const passedDelta = (right.passedWords || 0) - (left.passedWords || 0);
if (passedDelta !== 0) return passedDelta;
const riskDelta = (right.invalidatedRecords || 0) - (left.invalidatedRecords || 0);
if (riskDelta !== 0) return riskDelta;
return new Date(right.lastRecordAt || right.lastPassedAt || 0).getTime() -
new Date(left.lastRecordAt || left.lastPassedAt || 0).getTime();
});
res.json({
start,
end,
totalStudents: items.length,
totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0),
totalRecords: items.reduce((sum: number, item: any) => sum + (item.totalRecords || 0), 0),
invalidatedRecords: items.reduce((sum: number, item: any) => sum + (item.invalidatedRecords || 0), 0),
items
});
} catch (error) {
console.error('获取词汇测试通过统计失败:', error);
res.status(500).json({ message: '获取词汇测试通过统计失败' });
}
});
// 查看某个学生在时间段内的词汇测试明细(包含风控 attempt 原始提交)
router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (req, res) => {
try {
const range = parseAdminDateRange(req.query as Record<string, any>);
if (range.error) {
return res.status(400).json({ message: range.error });
}
const { start, end } = range as { start: Date; end: Date };
const summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
if ('error' in summaryFilters) {
return res.status(400).json({ message: summaryFilters.error });
}
const { baseMatch } = summaryFilters;
const { userId } = req.params;
if (!mongoose.isValidObjectId(userId)) {
return res.status(400).json({ message: '学生ID无效' });
}
const user = await User.findById(userId).select('_id username fullname email').lean();
if (!user) {
return res.status(404).json({ message: '学生不存在' });
}
const records = await VocabularyTestRecord.find({
user: user._id,
...baseMatch,
'stats.endTime': { $gte: start, $lte: end }
})
.populate('wordSet', 'name description')
.populate('results.word', 'word translation pronunciation example')
.populate('reviewedBy', 'username fullname email')
.sort({ 'stats.endTime': -1, createdAt: -1 })
.lean();
const attemptIds = records
.map((record: any) => getObjectIdString(record.attempt))
.filter(id => mongoose.isValidObjectId(id));
const attempts = attemptIds.length > 0
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } })
.populate('answers.word', 'word translation pronunciation example')
.populate('reviewedBy', 'username fullname email')
.lean()
: [];
const attemptMap = new Map(attempts.map((attempt: any) => [attempt._id.toString(), attempt]));
const recordDetails = records.map((record: any) => {
const attemptId = getObjectIdString(record.attempt);
const attempt = attemptMap.get(attemptId);
return {
recordId: record._id.toString(),
attemptId,
wordSet: serializeWordSetRef(record.wordSet),
testType: record.testType,
stats: record.stats,
invalidated: Boolean(record.invalidated),
riskFlags: record.riskFlags || [],
reviewDecision: record.reviewDecision || attempt?.reviewDecision || '',
reviewedAt: record.reviewedAt || attempt?.reviewedAt || null,
reviewedBy: serializeUserRef(record.reviewedBy || attempt?.reviewedBy),
reviewNote: record.reviewNote || attempt?.reviewNote || '',
createdAt: record.createdAt,
results: (record.results || []).map((result: any, index: number) => ({
index: index + 1,
wordId: getObjectIdString(result.word),
word: serializeWordRef(result.word),
userAnswer: result.userAnswer || '',
correctAnswer: result.correctAnswer || '',
isCorrect: Boolean(result.isCorrect)
})),
attempt: attempt ? {
attemptId,
status: attempt.status,
testType: attempt.testType,
wordSetId: getObjectIdString(attempt.wordSet),
issuedAt: attempt.issuedAt,
expiresAt: attempt.expiresAt,
submittedAt: attempt.submittedAt,
riskFlags: attempt.riskFlags || [],
reviewDecision: attempt.reviewDecision || '',
reviewedAt: attempt.reviewedAt || null,
reviewedBy: serializeUserRef(attempt.reviewedBy),
reviewNote: attempt.reviewNote || '',
questionWordIds: (attempt.questionWordIds || []).map((wordId: any) => getObjectIdString(wordId)),
questionTokens: attempt.questionTokens || [],
optionTokens: attempt.optionTokens || [],
optionTexts: attempt.optionTexts || [],
answeredQuestionTokens: attempt.answeredQuestionTokens || [],
answerDurations: attempt.answerDurations || [],
answers: (attempt.answers || []).map((answer: any, index: number) => ({
index: index + 1,
questionToken: answer.questionToken || '',
wordId: getObjectIdString(answer.word),
word: serializeWordRef(answer.word),
userAnswer: answer.userAnswer || '',
correctAnswer: answer.correctAnswer || '',
isCorrect: Boolean(answer.isCorrect),
submittedAt: answer.submittedAt,
duration: answer.duration,
riskFlags: answer.riskFlags || [],
interactionSummary: answer.interactionSummary || null
}))
} : null
};
});
res.json({
start,
end,
user: {
userId: user._id.toString(),
username: user.username,
fullname: user.fullname,
email: user.email
},
totalRecords: recordDetails.length,
totalPassedWords: recordDetails
.filter((record: any) => !record.invalidated)
.reduce((sum: number, record: any) => sum + Number(record.stats?.correctWords || 0), 0),
invalidatedRecords: recordDetails.filter((record: any) => record.invalidated).length,
approvedRecords: recordDetails.filter((record: any) => record.reviewDecision === 'approved').length,
records: recordDetails
});
} catch (error) {
console.error('获取词汇测试明细失败:', error);
res.status(500).json({ message: '获取词汇测试明细失败' });
}
});
// 人工将风控作废的词汇测试记录转为有效成绩
router.post('/vocabulary/test-records/:recordId/approve', adminAuth, async (req, res) => {
try {
const { recordId } = req.params;
if (!mongoose.isValidObjectId(recordId)) {
return res.status(400).json({ message: '测试记录ID无效' });
}
const record: any = await VocabularyTestRecord.findById(recordId);
if (!record) {
return res.status(404).json({ message: '测试记录不存在' });
}
if (!record.invalidated) {
return res.json({
message: '该词汇测试记录已经是有效成绩',
recordId,
alreadyEffective: true
});
}
const attemptId = getObjectIdString(record.attempt);
if (!mongoose.isValidObjectId(attemptId)) {
return res.status(400).json({ message: '该测试记录没有可用于恢复的 attempt 数据' });
}
const attempt: any = await VocabularyTestAttempt.findById(attemptId);
if (!attempt) {
return res.status(404).json({ message: '原始提交 attempt 不存在,无法转为有效成绩' });
}
const submittedAnswers = Array.isArray(attempt.answers) ? attempt.answers : [];
const restoredResults = submittedAnswers
.map((answer: any) => {
const wordId = getObjectIdString(answer.word);
if (!mongoose.isValidObjectId(wordId)) return null;
return {
word: new mongoose.Types.ObjectId(wordId),
userAnswer: String(answer.userAnswer || ''),
correctAnswer: String(answer.correctAnswer || ''),
isCorrect: Boolean(answer.isCorrect)
};
})
.filter(Boolean) as Array<{
word: mongoose.Types.ObjectId;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}>;
if (restoredResults.length === 0) {
return res.status(400).json({ message: '该 attempt 没有可恢复的答题数据' });
}
const totalWords = restoredResults.length;
const correctWords = restoredResults.filter(result => result.isCorrect).length;
const reviewedAt = new Date();
const reviewNote = String(req.body?.note || '').trim().slice(0, 500);
const userId = getObjectIdString(record.user);
const affectedWordIds = restoredResults.map(result => result.word.toString());
await VocabularyTestRecord.updateOne(
{ _id: record._id },
{
$set: {
invalidated: false,
results: restoredResults,
'stats.totalWords': totalWords,
'stats.correctWords': correctWords,
'stats.accuracy': totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
reviewDecision: 'approved',
reviewedBy: req.user._id,
reviewedAt,
reviewNote
}
}
);
await VocabularyTestAttempt.updateOne(
{ _id: attempt._id },
{
$set: {
reviewDecision: 'approved',
reviewedBy: req.user._id,
reviewedAt,
reviewNote
}
}
);
const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, affectedWordIds);
res.json({
message: '已将风控记录转为有效成绩',
recordId,
attemptId,
totalWords,
correctWords,
accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
riskFlags: Array.from(new Set([...(record.riskFlags || []), ...(attempt.riskFlags || [])])),
affectedWords: rebuildResult.affectedWords,
rebuiltWords: rebuildResult.rebuiltWords
});
} catch (error) {
console.error('人工放行词汇测试记录失败:', error);
res.status(500).json({ message: '人工放行词汇测试记录失败' });
}
});
// 清除某个学生在指定时间段内的词汇测试成绩,并重算掌握状态
router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) => {
try {
const range = parseAdminDateRange(req.body || {});
if (range.error) {
return res.status(400).json({ message: range.error });
}
const { start, end } = range as { start: Date; end: Date };
const summaryFilters = buildVocabularySummaryFilters(req.body || {});
if ('error' in summaryFilters) {
return res.status(400).json({ message: summaryFilters.error });
}
const { baseMatch } = summaryFilters;
const { userId } = req.body || {};
if (!mongoose.isValidObjectId(userId)) {
return res.status(400).json({ message: '学生ID无效' });
}
const user = await User.findById(userId).select('_id username fullname');
if (!user) {
return res.status(404).json({ message: '学生不存在' });
}
const records = await VocabularyTestRecord.find({
user: user._id,
invalidated: { $ne: true },
...baseMatch,
'stats.endTime': { $gte: start, $lte: end },
'stats.correctWords': { $gt: 0 }
})
.select('_id stats.correctWords results.word results.isCorrect')
.lean();
if (records.length === 0) {
return res.json({
message: '没有找到需要清除的词汇测试成绩',
deletedRecords: 0,
removedPassedWords: 0,
affectedWords: 0,
rebuiltWords: 0
});
}
const recordIds = records.map(record => record._id);
const affectedWordIds = new Set<string>();
let removedPassedWords = 0;
records.forEach((record: any) => {
removedPassedWords += Number(record.stats?.correctWords || 0);
(record.results || []).forEach((result: any) => {
const wordId = result.word?.toString?.() || String(result.word || '');
if (wordId) affectedWordIds.add(wordId);
});
});
await VocabularyTestRecord.deleteMany({ _id: { $in: recordIds } });
const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, Array.from(affectedWordIds));
res.json({
message: '词汇测试成绩已清除',
userId,
username: user.username,
fullname: user.fullname,
deletedRecords: records.length,
removedPassedWords,
affectedWords: rebuildResult.affectedWords,
rebuiltWords: rebuildResult.rebuiltWords
});
} catch (error) {
console.error('清除词汇测试成绩失败:', error);
res.status(500).json({ message: '清除词汇测试成绩失败' });
}
});
export default router;

View File

@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
import { SudokuRecord, SudokuDifficulty } from '../models/SudokuRecord';
import { SudokuRecord, SudokuDifficulty, SudokuGameMode } from '../models/SudokuRecord';
import { auth } from '../middleware/auth';
const router = express.Router();
@@ -7,7 +7,7 @@ const router = express.Router();
// 提交数独记录(需要登录)
router.post('/record', auth, async (req: Request, res: Response) => {
try {
const { difficulty, timeSeconds, won } = req.body;
const { difficulty, timeSeconds, won, gameMode = 'standard' } = req.body;
if (!req.user?._id) {
return res.status(401).json({ error: '未登录' });
@@ -26,11 +26,17 @@ router.post('/record', auth, async (req: Request, res: Response) => {
return res.status(400).json({ error: '无效的游戏结果' });
}
const validGameModes: SudokuGameMode[] = ['standard', 'irregular'];
if (!validGameModes.includes(gameMode)) {
return res.status(400).json({ error: '无效的游戏模式' });
}
const record = new SudokuRecord({
userId: req.user._id,
username: req.user.username,
fullname: req.user.fullname || req.user.username,
difficulty,
gameMode,
timeSeconds,
won
});
@@ -51,34 +57,71 @@ router.post('/record', auth, async (req: Request, res: Response) => {
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
try {
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 validModes = ['all', 'standard', 'irregular'];
if (!validDifficulties.includes(difficulty as SudokuDifficulty)) {
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 pageSize = parseInt(limit as string, 10);
const skipCount = (pageNum - 1) * pageSize;
const gameMode = mode as 'standard' | 'irregular' | 'all';
const records = await SudokuRecord.getLeaderboard(
difficulty as SudokuDifficulty,
gameMode,
skipCount,
pageSize
);
const totalUsers = await SudokuRecord.distinct('userId', {
const totalMatch: any = {
difficulty,
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({
records,
total: totalUsers.length,
total,
currentPage: pageNum,
totalPages: Math.ceil(totalUsers.length / pageSize),
difficulty
totalPages: Math.ceil(total / pageSize),
difficulty,
mode: gameMode
});
} catch (error) {
console.error('获取数独排行榜失败:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +25,13 @@ import sudokuRouter from './routes/sudoku';
// 加载环境变量
dotenv.config();
const BODY_LIMIT = process.env.BODY_LIMIT || '5mb';
const app = express();
// 中间件配置
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: BODY_LIMIT }));
app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT }));
app.use(cors());
// 静态文件服务 - 优先处理 public 目录下的静态文件
@@ -202,7 +204,40 @@ app.use((req, res, next) => {
// 错误处理中间件
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
if (res.headersSent) {
return next(err);
}
if (
err?.type === 'entity.too.large' ||
err?.name === 'PayloadTooLargeError' ||
err?.status === 413 ||
err?.statusCode === 413
) {
console.warn('Request body too large:', {
method: req.method,
path: req.path,
limit: BODY_LIMIT
});
return res.status(413).json({
error: '请求体过大,请缩小单次提交内容',
code: 'PAYLOAD_TOO_LARGE'
});
}
if (err?.type === 'entity.parse.failed') {
console.error('Invalid JSON payload:', {
method: req.method,
path: req.path,
message: err?.message
});
return res.status(400).json({
error: '请求体格式无效',
code: 'INVALID_JSON'
});
}
console.error(err?.stack || err);
res.status(500).send('Something broke!');
});

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import {
applyVocabularySummaryItemFilters,
buildVocabularySummaryFilters
} from './vocabularyAuditFilters';
const uniqueWordFilter = buildVocabularySummaryFilters({ minUniqueWords: '40' });
assert.deepEqual(uniqueWordFilter, {
minUniqueWords: 40,
userMatchStages: [],
baseMatch: {}
});
const legacyFilter = buildVocabularySummaryFilters({ minCorrectWords: '35' });
assert.deepEqual(legacyFilter, {
minUniqueWords: 35,
userMatchStages: [],
baseMatch: {}
});
const emptyFilter = buildVocabularySummaryFilters({});
assert.deepEqual(emptyFilter, {
minUniqueWords: undefined,
userMatchStages: [],
baseMatch: {}
});
const studentFilter = buildVocabularySummaryFilters({ studentNo: 'A.12' });
assert.deepEqual(studentFilter.userMatchStages, [{
$match: {
'user.username': {
$regex: 'A\\.12',
$options: 'i'
}
}
}]);
const invalidFilter = buildVocabularySummaryFilters({ minUniqueWords: '4.5' });
assert.deepEqual(invalidFilter, { error: '去重单词数量必须是正整数' });
const filteredItems = applyVocabularySummaryItemFilters([
{ userId: 'below', uniqueWords: 39 },
{ userId: 'equal', uniqueWords: 40 },
{ userId: 'above', uniqueWords: 41 }
], 40);
assert.deepEqual(filteredItems.map(item => item.userId), ['equal', 'above']);
console.log('vocabularyAuditFilters tests passed');

View File

@@ -0,0 +1,52 @@
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parseOptionalPositiveInt = (value: unknown, fieldName: string) => {
const raw = String(value ?? '').trim();
if (!raw) return { value: undefined as number | undefined };
if (!/^\d+$/.test(raw)) {
return { error: `${fieldName}必须是正整数` };
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return { error: `${fieldName}必须大于0` };
}
return { value: parsed };
};
export const buildVocabularySummaryFilters = (query: Record<string, any>) => {
const studentNo = String(query.studentNo || '').trim();
const rawMinUniqueWords = query.minUniqueWords ?? query.minCorrectWords ?? query.minWords;
const wordCountResult = parseOptionalPositiveInt(rawMinUniqueWords, '去重单词数量');
if ('error' in wordCountResult) return { error: wordCountResult.error };
const minUniqueWords = wordCountResult.value;
const userMatchStages = studentNo
? [{
$match: {
'user.username': {
$regex: escapeRegex(studentNo),
$options: 'i'
}
}
}]
: [];
const baseMatch: Record<string, any> = {};
return {
minUniqueWords,
userMatchStages,
baseMatch
};
};
export const applyVocabularySummaryItemFilters = <T extends { uniqueWords?: number }>(
items: T[],
minUniqueWords?: number
): T[] => {
if (typeof minUniqueWords !== 'number') return items;
return items.filter(item => Number(item.uniqueWords || 0) >= minUniqueWords);
};

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import {
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD,
VOCABULARY_FULL_CORRECT_RISK_FLAG,
analyzeVocabularyAttemptResultRisk
} from './vocabularyRisk';
const defaultThreshold = DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
assert.equal(defaultThreshold, 50);
assert.deepEqual(
analyzeVocabularyAttemptResultRisk({
testType: 'chinese-to-english',
totalWords: defaultThreshold + 1,
correctWords: defaultThreshold + 1
}),
[VOCABULARY_FULL_CORRECT_RISK_FLAG]
);
assert.deepEqual(
analyzeVocabularyAttemptResultRisk({
testType: 'audio-to-english',
totalWords: defaultThreshold,
correctWords: defaultThreshold
}),
[]
);
assert.deepEqual(
analyzeVocabularyAttemptResultRisk({
testType: 'chinese-to-english',
totalWords: defaultThreshold + 1,
correctWords: defaultThreshold
}),
[]
);
assert.deepEqual(
analyzeVocabularyAttemptResultRisk({
testType: 'multiple-choice',
totalWords: defaultThreshold + 1,
correctWords: defaultThreshold + 1
}),
[]
);
assert.deepEqual(
analyzeVocabularyAttemptResultRisk({
testType: 'audio-to-english',
totalWords: 41,
correctWords: 41,
threshold: 40
}),
[VOCABULARY_FULL_CORRECT_RISK_FLAG]
);
console.log('vocabularyRisk tests passed');

View File

@@ -0,0 +1,59 @@
export type VocabularyRiskTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
export const DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD = 50;
export const VOCABULARY_FULL_CORRECT_RISK_FLAG = 'large_batch_all_correct';
export const INVALIDATING_VOCABULARY_RISK_FLAGS: string[] = [
'invalid_origin',
'invalid_answer_proof',
'invalid_question_token',
'invalid_option_token',
'invalid_submitted_at',
'submitted_at_out_of_sync',
'attempt_expired',
'too_fast',
'too_slow',
'missing_choice_interaction',
'missing_input_value_trace',
'input_value_mismatch',
'uniform_answer_intervals',
'repeated_whole_second_intervals',
VOCABULARY_FULL_CORRECT_RISK_FLAG
];
const parsePositiveThreshold = (value: unknown): number | undefined => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
};
export const getVocabularyFullCorrectWordThreshold = (): number => {
return parsePositiveThreshold(process.env.VOCABULARY_FULL_CORRECT_WORD_THRESHOLD) ??
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
};
export const analyzeVocabularyAttemptResultRisk = ({
testType,
totalWords,
correctWords,
threshold = getVocabularyFullCorrectWordThreshold()
}: {
testType: VocabularyRiskTestType | string;
totalWords: number;
correctWords: number;
threshold?: number;
}): string[] => {
const effectiveThreshold = parsePositiveThreshold(threshold) ??
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
const safeTotalWords = Number.isFinite(totalWords) ? totalWords : 0;
const safeCorrectWords = Number.isFinite(correctWords) ? correctWords : 0;
if (
testType !== 'multiple-choice' &&
safeTotalWords > effectiveThreshold &&
safeTotalWords === safeCorrectWords
) {
return [VOCABULARY_FULL_CORRECT_RISK_FLAG];
}
return [];
};

View File

@@ -94,6 +94,172 @@ export interface Word {
createdAt: string;
}
export interface VocabularyPassSummaryItem {
userId: string;
username: string;
fullname: string;
email?: string;
passedWords: number;
uniqueWords: number;
testRecords: number;
totalRecords?: number;
validRecords?: number;
invalidatedRecords?: number;
wordSets: number;
testTypes: string[];
riskFlags?: string[];
firstPassedAt: string;
lastPassedAt: string;
firstRecordAt?: string;
lastRecordAt?: string;
}
export interface VocabularyPassSummaryResponse {
start: string;
end: string;
totalStudents: number;
totalPassedWords: number;
totalRecords?: number;
invalidatedRecords?: number;
items: VocabularyPassSummaryItem[];
}
export interface VocabularyAuditQueryParams {
start: string;
end: string;
studentNo?: string;
minUniqueWords?: string;
}
export interface ClearVocabularyPassSummaryResponse {
message: string;
userId?: string;
username?: string;
fullname?: string;
deletedRecords: number;
removedPassedWords: number;
affectedWords: number;
rebuiltWords: number;
}
export interface VocabularyAuditWordBrief {
_id: string;
word?: string;
translation?: string;
pronunciation?: string;
example?: string;
}
export interface VocabularyAuditUserBrief {
_id?: string;
userId?: string;
username?: string;
fullname?: string;
email?: string;
}
export interface VocabularyAuditInteractionSummary {
keyCount: number;
inputCount: number;
pasteCount: number;
focusCount: number;
blurCount: number;
pointerCount: number;
firstEventOffset: number;
lastEventOffset: number;
}
export interface VocabularyAuditResultDetail {
index: number;
wordId: string;
word: VocabularyAuditWordBrief | null;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}
export interface VocabularyAuditAnswerDetail extends VocabularyAuditResultDetail {
questionToken: string;
submittedAt?: string;
duration?: number;
riskFlags: string[];
interactionSummary?: VocabularyAuditInteractionSummary | null;
}
export interface VocabularyAuditAttemptDetail {
attemptId: string;
status: string;
testType: string;
wordSetId: string;
issuedAt?: string;
expiresAt?: string;
submittedAt?: string;
riskFlags: string[];
reviewDecision?: string;
reviewedAt?: string;
reviewedBy?: VocabularyAuditUserBrief | null;
reviewNote?: string;
questionWordIds: string[];
questionTokens: string[];
optionTokens: string[][];
optionTexts: string[][];
answeredQuestionTokens: string[];
answerDurations: number[];
answers: VocabularyAuditAnswerDetail[];
}
export interface VocabularyAuditRecordDetail {
recordId: string;
attemptId: string;
wordSet: {
_id: string;
name?: string;
description?: string;
} | null;
testType: string;
stats: {
totalWords: number;
correctWords: number;
accuracy: number;
startTime: string;
endTime: string;
duration: number;
};
invalidated: boolean;
riskFlags: string[];
reviewDecision?: string;
reviewedAt?: string;
reviewedBy?: VocabularyAuditUserBrief | null;
reviewNote?: string;
createdAt: string;
results: VocabularyAuditResultDetail[];
attempt: VocabularyAuditAttemptDetail | null;
}
export interface VocabularyPassSummaryDetailsResponse {
start: string;
end: string;
user: VocabularyAuditUserBrief;
totalRecords: number;
totalPassedWords: number;
invalidatedRecords: number;
approvedRecords: number;
records: VocabularyAuditRecordDetail[];
}
export interface ApproveVocabularyTestRecordResponse {
message: string;
recordId: string;
attemptId?: string;
totalWords?: number;
correctWords?: number;
accuracy?: number;
riskFlags?: string[];
affectedWords?: number;
rebuiltWords?: number;
alreadyEffective?: boolean;
}
export const adminApi = {
getUsers: async (): Promise<User[]> => {
return api.get<User[]>('/admin/users');
@@ -176,5 +342,21 @@ export const adminApi = {
updateWords: async (words: any[]) => {
return api.put('/api/vocabulary/words', { words });
},
};
getVocabularyPassSummary: async (params: VocabularyAuditQueryParams): Promise<VocabularyPassSummaryResponse> => {
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
},
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minUniqueWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => {
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
},
getVocabularyPassSummaryDetails: async (params: { userId: string } & VocabularyAuditQueryParams): Promise<VocabularyPassSummaryDetailsResponse> => {
const { userId, ...query } = params;
return api.get<VocabularyPassSummaryDetailsResponse>(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query });
},
approveVocabularyTestRecord: async (recordId: string, data: { note?: string } = {}): Promise<ApproveVocabularyTestRecordResponse> => {
return api.post<ApproveVocabularyTestRecordResponse>(`/admin/vocabulary/test-records/${recordId}/approve`, data);
},
};

View File

@@ -13,6 +13,7 @@ import AdminCodeManager from './AdminCodeManager';
import AdminPracticeRecords from './AdminPracticeRecords';
import AdminOAuth2Manager from './AdminOAuth2Manager';
import AdminVocabularyManager from './AdminVocabularyManager';
import AdminVocabularyScoreAudit from './AdminVocabularyScoreAudit';
interface TabPanelProps {
children?: React.ReactNode;
@@ -62,6 +63,7 @@ const AdminDashboard: React.FC = () => {
<Tab label="代码管理" />
<Tab label="练习记录" />
<Tab label="单词库管理" />
<Tab label="词汇成绩核查" />
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
</Tabs>
@@ -77,8 +79,11 @@ const AdminDashboard: React.FC = () => {
<TabPanel value={tabValue} index={3}>
<AdminVocabularyManager />
</TabPanel>
{user.username === 'bobcoc' && (
<TabPanel value={tabValue} index={4}>
<AdminVocabularyScoreAudit />
</TabPanel>
{user.username === 'bobcoc' && (
<TabPanel value={tabValue} index={5}>
<AdminOAuth2Manager />
</TabPanel>
)}

View File

@@ -0,0 +1,658 @@
import React, { useState } from 'react';
import { Button, Card, Collapse, DatePicker, Descriptions, Input, message, Modal, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CheckCircleOutlined, DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
import {
adminApi,
VocabularyAuditAnswerDetail,
VocabularyAuditRecordDetail,
VocabularyPassSummaryDetailsResponse,
VocabularyPassSummaryItem,
VocabularyPassSummaryResponse
} from '../api/admin';
const { RangePicker } = DatePicker;
const TEST_TYPE_LABELS: Record<string, string> = {
'chinese-to-english': '看中文写英文',
'audio-to-english': '听发音写单词',
'multiple-choice': '选择正确翻译'
};
const toIsoString = (value: any): string => {
if (!value) return '';
if (typeof value.toDate === 'function') return value.toDate().toISOString();
if (typeof value.toISOString === 'function') return value.toISOString();
return new Date(value).toISOString();
};
const formatDateTime = (value?: string) => {
if (!value) return '-';
return new Date(value).toLocaleString('zh-CN');
};
const formatDuration = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) return '-';
return `${Math.round(value * 10) / 10}`;
};
const renderCopyableId = (value?: string) => {
if (!value) return '-';
return (
<Typography.Text copyable ellipsis style={{ display: 'inline-block', maxWidth: 180 }}>
{value}
</Typography.Text>
);
};
const renderRiskTags = (flags?: string[]) => {
const uniqueFlags = Array.from(new Set((flags || []).filter(Boolean)));
if (uniqueFlags.length === 0) return <Typography.Text type="secondary">-</Typography.Text>;
return (
<Space wrap size={[4, 4]}>
{uniqueFlags.map(flag => (
<Tag key={flag} color="orange">{flag}</Tag>
))}
</Space>
);
};
const renderJsonBlock = (value: any) => (
<pre
style={{
margin: 0,
maxHeight: 320,
overflow: 'auto',
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{JSON.stringify(value ?? null, null, 2)}
</pre>
);
const getRecordRiskFlags = (record: VocabularyAuditRecordDetail) => Array.from(new Set([
...(record.riskFlags || []),
...(record.attempt?.riskFlags || [])
]));
const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnswerDetail[] => {
if (record.attempt?.answers?.length) return record.attempt.answers;
return record.results.map(result => ({
...result,
questionToken: '',
riskFlags: [],
interactionSummary: null
}));
};
const AdminVocabularyScoreAudit: React.FC = () => {
const [range, setRange] = useState<[string, string] | null>(null);
const [studentNo, setStudentNo] = useState('');
const [minUniqueWords, setMinUniqueWords] = useState('');
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<VocabularyPassSummaryItem | null>(null);
const [details, setDetails] = useState<VocabularyPassSummaryDetailsResponse | null>(null);
const [approvingRecordId, setApprovingRecordId] = useState<string | null>(null);
const getFilterParams = () => ({
studentNo: studentNo.trim() || undefined,
minUniqueWords: minUniqueWords.trim() || undefined
});
const fetchSummary = async () => {
if (!range) {
message.warning('请先选择开始和结束时间');
return;
}
try {
setLoading(true);
const response = await adminApi.getVocabularyPassSummary({
start: range[0],
end: range[1],
...getFilterParams()
});
setSummary(response);
} catch (error) {
console.error('获取词汇测试通过统计失败:', error);
message.error('获取词汇测试通过统计失败');
} finally {
setLoading(false);
}
};
const fetchStudentDetails = async (row: VocabularyPassSummaryItem) => {
if (!range) return;
try {
setDetailLoading(true);
const response = await adminApi.getVocabularyPassSummaryDetails({
userId: row.userId,
start: range[0],
end: range[1],
...getFilterParams()
});
setDetails(response);
} catch (error) {
console.error('获取词汇测试明细失败:', error);
message.error('获取词汇测试明细失败');
} finally {
setDetailLoading(false);
}
};
const openStudentDetails = async (row: VocabularyPassSummaryItem) => {
setSelectedStudent(row);
setDetails(null);
setDetailOpen(true);
await fetchStudentDetails(row);
};
const approveRecord = async (record: VocabularyAuditRecordDetail) => {
try {
setApprovingRecordId(record.recordId);
const response = await adminApi.approveVocabularyTestRecord(record.recordId);
if (response.alreadyEffective) {
message.info(response.message);
} else {
message.success(`已恢复为有效成绩:${response.correctWords || 0}/${response.totalWords || 0}`);
}
if (selectedStudent) {
await fetchStudentDetails(selectedStudent);
}
await fetchSummary();
} catch (error) {
console.error('人工放行词汇测试记录失败:', error);
message.error('人工放行词汇测试记录失败');
} finally {
setApprovingRecordId(null);
}
};
const clearStudentScores = async (row: VocabularyPassSummaryItem) => {
if (!range) return;
try {
setClearingUserId(row.userId);
const response = await adminApi.clearVocabularyPassSummary({
userId: row.userId,
start: range[0],
end: range[1],
...getFilterParams()
});
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
await fetchSummary();
if (selectedStudent?.userId === row.userId) {
await fetchStudentDetails(row);
}
} catch (error) {
console.error('清除词汇测试成绩失败:', error);
message.error('清除词汇测试成绩失败');
} finally {
setClearingUserId(null);
}
};
const answerColumns: ColumnsType<VocabularyAuditAnswerDetail> = [
{
title: '#',
dataIndex: 'index',
key: 'index',
width: 56
},
{
title: '单词',
key: 'word',
width: 180,
render: (_, row) => (
<div>
<div style={{ fontWeight: 600 }}>{row.word?.word || row.wordId || '-'}</div>
<Typography.Text type="secondary">{row.word?.translation || '-'}</Typography.Text>
</div>
)
},
{
title: '提交答案',
dataIndex: 'userAnswer',
key: 'userAnswer',
width: 180
},
{
title: '正确答案',
dataIndex: 'correctAnswer',
key: 'correctAnswer',
width: 180
},
{
title: '结果',
dataIndex: 'isCorrect',
key: 'isCorrect',
width: 96,
render: (isCorrect: boolean) => (
<Tag color={isCorrect ? 'green' : 'red'}>{isCorrect ? '正确' : '错误'}</Tag>
)
},
{
title: '用时',
dataIndex: 'duration',
key: 'duration',
width: 96,
render: formatDuration
},
{
title: '提交时间',
dataIndex: 'submittedAt',
key: 'submittedAt',
width: 180,
render: formatDateTime
},
{
title: '题目 Token',
dataIndex: 'questionToken',
key: 'questionToken',
width: 200,
render: renderCopyableId
},
{
title: 'RiskFlags',
dataIndex: 'riskFlags',
key: 'riskFlags',
width: 220,
render: renderRiskTags
},
{
title: '交互摘要',
dataIndex: 'interactionSummary',
key: 'interactionSummary',
width: 260,
render: summary => {
if (!summary) return <Typography.Text type="secondary">-</Typography.Text>;
return (
<Space wrap size={[4, 4]}>
<Tag>key {summary.keyCount}</Tag>
<Tag>input {summary.inputCount}</Tag>
<Tag>paste {summary.pasteCount}</Tag>
<Tag>pointer {summary.pointerCount}</Tag>
<Tag>focus {summary.focusCount}</Tag>
<Tag>blur {summary.blurCount}</Tag>
</Space>
);
}
}
];
const renderRecordExpanded = (record: VocabularyAuditRecordDetail) => {
const recordJson = {
recordId: record.recordId,
attemptId: record.attemptId,
wordSet: record.wordSet,
testType: record.testType,
stats: record.stats,
invalidated: record.invalidated,
riskFlags: record.riskFlags,
reviewDecision: record.reviewDecision,
reviewedAt: record.reviewedAt,
reviewedBy: record.reviewedBy,
reviewNote: record.reviewNote,
createdAt: record.createdAt,
results: record.results
};
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 3 }}>
<Descriptions.Item label="记录ID">{renderCopyableId(record.recordId)}</Descriptions.Item>
<Descriptions.Item label="Attempt ID">{renderCopyableId(record.attemptId)}</Descriptions.Item>
<Descriptions.Item label="单词集">{record.wordSet?.name || record.wordSet?._id || '-'}</Descriptions.Item>
<Descriptions.Item label="开始时间">{formatDateTime(record.stats?.startTime)}</Descriptions.Item>
<Descriptions.Item label="结束时间">{formatDateTime(record.stats?.endTime)}</Descriptions.Item>
<Descriptions.Item label="时长">{formatDuration(record.stats?.duration)}</Descriptions.Item>
<Descriptions.Item label="成绩">{`${record.stats?.correctWords || 0}/${record.stats?.totalWords || 0}`}</Descriptions.Item>
<Descriptions.Item label="正确率">{`${Math.round((record.stats?.accuracy || 0) * 10) / 10}%`}</Descriptions.Item>
<Descriptions.Item label="人工审核">
{record.reviewDecision === 'approved' ? (
<Space wrap>
<Tag color="green"></Tag>
<Typography.Text type="secondary">
{record.reviewedBy?.fullname || record.reviewedBy?.username || ''}
</Typography.Text>
<Typography.Text type="secondary">{formatDateTime(record.reviewedAt)}</Typography.Text>
</Space>
) : (
<Typography.Text type="secondary">-</Typography.Text>
)}
</Descriptions.Item>
<Descriptions.Item label="记录 RiskFlags" span={3}>{renderRiskTags(record.riskFlags)}</Descriptions.Item>
<Descriptions.Item label="Attempt RiskFlags" span={3}>{renderRiskTags(record.attempt?.riskFlags)}</Descriptions.Item>
</Descriptions>
<Table
size="small"
columns={answerColumns}
dataSource={getAnswerRows(record)}
rowKey={row => `${record.recordId}-${row.index}-${row.questionToken || row.wordId}`}
pagination={false}
scroll={{ x: 1600 }}
/>
<Collapse
size="small"
items={[
{
key: 'record-json',
label: '成绩记录 JSON',
children: renderJsonBlock(recordJson)
},
...(record.attempt ? [{
key: 'attempt-json',
label: 'Attempt JSON',
children: renderJsonBlock(record.attempt)
}] : [])
]}
/>
</Space>
);
};
const detailColumns: ColumnsType<VocabularyAuditRecordDetail> = [
{
title: '时间',
dataIndex: ['stats', 'endTime'],
key: 'endTime',
width: 180,
render: formatDateTime,
sorter: (a, b) => new Date(a.stats?.endTime || 0).getTime() - new Date(b.stats?.endTime || 0).getTime(),
defaultSortOrder: 'descend'
},
{
title: '状态',
key: 'status',
width: 120,
render: (_, record) => {
if (record.invalidated) return <Tag color="red"></Tag>;
if (record.reviewDecision === 'approved') return <Tag color="green"></Tag>;
return <Tag color="blue"></Tag>;
}
},
{
title: '题型',
dataIndex: 'testType',
key: 'testType',
width: 150,
render: (type: string) => <Tag color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
},
{
title: '单词集',
key: 'wordSet',
width: 160,
render: (_, record) => record.wordSet?.name || record.wordSet?._id || '-'
},
{
title: '成绩',
key: 'score',
width: 110,
sorter: (a, b) => (a.stats?.correctWords || 0) - (b.stats?.correctWords || 0),
render: (_, record) => <Tag color="green">{record.stats?.correctWords || 0}/{record.stats?.totalWords || 0}</Tag>
},
{
title: 'Attempt ID',
dataIndex: 'attemptId',
key: 'attemptId',
width: 200,
render: renderCopyableId
},
{
title: 'RiskFlags',
key: 'riskFlags',
width: 260,
render: (_, record) => renderRiskTags(getRecordRiskFlags(record))
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 150,
render: (_, record) => record.invalidated ? (
<Popconfirm
title="确认将该风控记录转为有效成绩?"
description="会按原始 attempt 答案恢复正确率,并重算这些单词的掌握状态。"
okText="确认放行"
cancelText="取消"
onConfirm={() => approveRecord(record)}
>
<Button
type="primary"
icon={<CheckCircleOutlined />}
loading={approvingRecordId === record.recordId}
>
</Button>
</Popconfirm>
) : (
<Typography.Text type="secondary">-</Typography.Text>
)
}
];
const columns: ColumnsType<VocabularyPassSummaryItem> = [
{
title: '学生',
key: 'student',
render: (_, row) => (
<div>
<div style={{ fontWeight: 600 }}>{row.fullname || row.username || '未知学生'}</div>
<Typography.Text type="secondary">{row.username || row.email || row.userId}</Typography.Text>
</div>
)
},
{
title: '有效通过',
dataIndex: 'passedWords',
key: 'passedWords',
sorter: (a, b) => a.passedWords - b.passedWords,
defaultSortOrder: 'descend',
render: (value: number) => <Tag color="green">{value}</Tag>
},
{
title: '去重单词',
dataIndex: 'uniqueWords',
key: 'uniqueWords',
sorter: (a, b) => a.uniqueWords - b.uniqueWords
},
{
title: '测试记录',
key: 'records',
sorter: (a, b) => (a.totalRecords || a.testRecords || 0) - (b.totalRecords || b.testRecords || 0),
render: (_, row) => (
<Space wrap size={[4, 4]}>
<Tag color="blue"> {row.validRecords ?? row.testRecords ?? 0}</Tag>
<Tag> {row.totalRecords || row.testRecords || 0}</Tag>
</Space>
)
},
{
title: '风控',
key: 'risk',
sorter: (a, b) => (a.invalidatedRecords || 0) - (b.invalidatedRecords || 0),
render: (_, row) => (
<Space direction="vertical" size={4}>
<Tag color={(row.invalidatedRecords || 0) > 0 ? 'red' : 'green'}>
{row.invalidatedRecords || 0}
</Tag>
{renderRiskTags(row.riskFlags)}
</Space>
)
},
{
title: '题型',
dataIndex: 'testTypes',
key: 'testTypes',
render: (types: string[]) => (
<Space wrap>
{(types || []).map(type => (
<Tag key={type} color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
))}
</Space>
)
},
{
title: '首次记录',
key: 'firstRecordAt',
render: (_, row) => formatDateTime(row.firstRecordAt || row.firstPassedAt)
},
{
title: '最后记录',
key: 'lastRecordAt',
render: (_, row) => formatDateTime(row.lastRecordAt || row.lastPassedAt)
},
{
title: '操作',
key: 'actions',
render: (_, row) => (
<Space wrap>
<Button icon={<EyeOutlined />} onClick={() => openStudentDetails(row)}>
</Button>
<Popconfirm
title="确认清除该学生在所选时间段内的词汇测试成绩?"
description="会删除对应测试记录,并重算这些单词的掌握状态。"
okText="确认清除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => clearStudentScores(row)}
>
<Button
danger
icon={<DeleteOutlined />}
loading={clearingUserId === row.userId}
>
</Button>
</Popconfirm>
</Space>
)
}
];
return (
<Card title="词汇测试成绩核查">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space wrap>
<RangePicker
showTime
onChange={(dates: any) => {
if (!dates || !dates[0] || !dates[1]) {
setRange(null);
setSummary(null);
return;
}
setRange([toIsoString(dates[0]), toIsoString(dates[1])]);
}}
/>
<Input
allowClear
placeholder="输入学生学号"
value={studentNo}
onChange={event => setStudentNo(event.target.value)}
onPressEnter={fetchSummary}
style={{ width: 220 }}
/>
<Input
allowClear
type="number"
placeholder="最少去重单词数"
value={minUniqueWords}
onChange={event => setMinUniqueWords(event.target.value)}
onPressEnter={fetchSummary}
style={{ width: 180 }}
/>
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={fetchSummary}>
</Button>
</Space>
{summary && (
<Space wrap>
<Statistic title="学生数" value={summary.totalStudents} />
<Statistic title="通过单词总数" value={summary.totalPassedWords} />
<Statistic title="测试记录" value={summary.totalRecords || 0} />
<Statistic title="风控作废" value={summary.invalidatedRecords || 0} />
<Statistic title="开始时间" value={formatDateTime(summary.start)} />
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
</Space>
)}
<Table
columns={columns}
dataSource={summary?.items || []}
rowKey="userId"
loading={loading}
scroll={{ x: 1280 }}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: total => `${total} 名学生`
}}
/>
</Space>
<Modal
title={`词汇提交明细 - ${details?.user.fullname || details?.user.username || selectedStudent?.fullname || selectedStudent?.username || ''}`}
open={detailOpen}
width={1280}
footer={null}
destroyOnClose
onCancel={() => {
setDetailOpen(false);
setDetails(null);
setSelectedStudent(null);
}}
>
<Space direction="vertical" size="large" style={{ width: '100%', maxHeight: '72vh', overflow: 'auto' }}>
{details && (
<Space wrap>
<Statistic title="测试记录" value={details.totalRecords} />
<Statistic title="有效通过单词" value={details.totalPassedWords} />
<Statistic title="风控作废" value={details.invalidatedRecords} />
<Statistic title="人工放行" value={details.approvedRecords} />
<Statistic title="开始时间" value={formatDateTime(details.start)} />
<Statistic title="结束时间" value={formatDateTime(details.end)} />
</Space>
)}
<Table
size="small"
columns={detailColumns}
dataSource={details?.records || []}
rowKey="recordId"
loading={detailLoading}
expandable={{
expandedRowRender: renderRecordExpanded,
rowExpandable: record => Boolean(record.attempt || record.results.length > 0)
}}
scroll={{ x: 1300 }}
pagination={{
defaultPageSize: 5,
showSizeChanger: true,
showTotal: total => `${total} 条记录`
}}
/>
</Space>
</Modal>
</Card>
);
};
export default AdminVocabularyScoreAudit;

View File

@@ -1,17 +1,89 @@
import React, { useState, useMemo, useEffect } from 'react';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import { API_BASE_URL } from '../config';
import {
GameBoard,
Difficulty,
GameMode,
STANDARD_REGION_MAP,
generatePuzzle,
RegionMap,
generatePuzzleForMode,
getRegionId,
getRegionNeighbors,
getRegionMapByMode,
} from './sudokuEngine';
type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber';
type SudokuRound = {
board: GameBoard;
regionMap: RegionMap;
};
type DraftCells = boolean[][];
type BoardSnapshot = {
board: GameBoard;
draftCells: DraftCells;
};
const CELL_SIZE = 80;
const CELL_FONT_SIZE = 36;
const DRAFT_SIZE = CELL_SIZE / 2;
const DRAFT_FONT_SIZE = CELL_FONT_SIZE / 2;
const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => {
const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode);
return {
board: puzzle,
regionMap,
};
};
const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]);
const cloneDraftCells = (draftCells: DraftCells): DraftCells => draftCells.map(row => [...row]);
const createEmptyDraftCells = (): DraftCells => Array.from({ length: 9 }, () => Array(9).fill(false));
const getCellKey = (row: number, col: number): string => `${row}-${col}`;
const isFilledDigit = (value: number): boolean => value >= 1 && value <= 9;
const findConflictCells = (boardToCheck: GameBoard, regionMapToCheck: RegionMap): Set<string> => {
const conflictCells = new Set<string>();
const addGroupConflicts = (cells: Array<[number, number]>) => {
const cellsByValue = new Map<number, string[]>();
cells.forEach(([row, col]) => {
const value = boardToCheck[row][col];
if (!isFilledDigit(value)) return;
const keys = cellsByValue.get(value) ?? [];
keys.push(getCellKey(row, col));
cellsByValue.set(value, keys);
});
cellsByValue.forEach((keys) => {
if (keys.length <= 1) return;
keys.forEach((key) => conflictCells.add(key));
});
};
for (let row = 0; row < 9; row++) {
addGroupConflicts(Array.from({ length: 9 }, (_, col) => [row, col]));
}
for (let col = 0; col < 9; col++) {
addGroupConflicts(Array.from({ length: 9 }, (_, row) => [row, col]));
}
const cellsByRegion = Array.from({ length: 9 }, () => [] as Array<[number, number]>);
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
cellsByRegion[regionMapToCheck[row][col]].push([row, col]);
}
}
cellsByRegion.forEach(addGroupConflicts);
return conflictCells;
};
const SudokuGame: React.FC = () => {
// 游戏难度
@@ -20,10 +92,32 @@ const SudokuGame: React.FC = () => {
// 游戏模式:标准或不规则
const [gameMode, setGameMode] = useState<GameMode>('standard');
// 使用 useMemo 确保 regionMap 随 gameMode 变化
const regionMap = useMemo(() => getRegionMapByMode(gameMode), [gameMode]);
const [round, setRound] = useState<SudokuRound>(() => createSudokuRound('hard', 'standard'));
const { board, regionMap } = round;
const [draftCells, setDraftCells] = useState<DraftCells>(() => createEmptyDraftCells());
const [draftMode, setDraftMode] = useState(false);
const [undoStack, setUndoStack] = useState<BoardSnapshot[]>([]);
const [redoStack, setRedoStack] = useState<BoardSnapshot[]>([]);
const [board, setBoard] = useState<GameBoard>(() => generatePuzzle('hard', STANDARD_REGION_MAP));
const setBoard = (nextBoard: GameBoard) => {
setRound((currentRound) => ({
...currentRound,
board: nextBoard,
}));
};
const applyBoardChange = (nextBoard: GameBoard, nextDraftCells: DraftCells) => {
setUndoStack((previousStack) => [
...previousStack,
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
]);
setRedoStack([]);
setBoard(nextBoard);
setDraftCells(nextDraftCells);
};
const [fixedCells, setFixedCells] = useState<boolean[][]>(() =>
board.map(row => row.map(cell => cell !== 0))
@@ -109,6 +203,7 @@ const SudokuGame: React.FC = () => {
},
body: JSON.stringify({
difficulty,
gameMode,
timeSeconds,
won
})
@@ -128,14 +223,18 @@ const SudokuGame: React.FC = () => {
if (!selectedCell || isGameComplete) return;
const [row, col] = selectedCell;
if (fixedCells[row][col]) return;
if (board[row][col] === num && draftCells[row][col] === draftMode) return;
const newBoard = board.map(row => [...row]);
const newDraftCells = cloneDraftCells(draftCells);
newBoard[row][col] = num;
setBoard(newBoard);
newDraftCells[row][col] = draftMode;
applyBoardChange(newBoard, newDraftCells);
// 检查是否完成
setTimeout(() => {
if (checkIsComplete(newBoard)) {
setDraftCells(createEmptyDraftCells());
setIsGameComplete(true);
setIsTimerRunning(false);
setShowCompleteDialog(true);
@@ -175,16 +274,71 @@ const SudokuGame: React.FC = () => {
const handleClear = () => {
if (!selectedCell || isGameComplete) return;
const [row, col] = selectedCell;
if (fixedCells[row][col] || board[row][col] === 0) return;
const newBoard = board.map(row => [...row]);
const newDraftCells = cloneDraftCells(draftCells);
newBoard[row][col] = 0;
setBoard(newBoard);
newDraftCells[row][col] = false;
applyBoardChange(newBoard, newDraftCells);
};
const handleUndo = () => {
if (isGameComplete || undoStack.length === 0) return;
const previousSnapshot = undoStack[undoStack.length - 1];
setUndoStack(undoStack.slice(0, -1));
setRedoStack([
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
...redoStack,
]);
setBoard(cloneGameBoard(previousSnapshot.board));
setDraftCells(cloneDraftCells(previousSnapshot.draftCells));
};
const handleRedo = () => {
if (isGameComplete || redoStack.length === 0) return;
const nextSnapshot = redoStack[0];
setRedoStack(redoStack.slice(1));
setUndoStack([
...undoStack,
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
]);
setBoard(cloneGameBoard(nextSnapshot.board));
setDraftCells(cloneDraftCells(nextSnapshot.draftCells));
};
const canUndo = !isGameComplete && undoStack.length > 0;
const canRedo = !isGameComplete && redoStack.length > 0;
const getHistoryButtonStyle = (enabled: boolean): React.CSSProperties => ({
padding: '10px 16px',
fontSize: '16px',
cursor: enabled ? 'pointer' : 'not-allowed',
backgroundColor: enabled ? '#f0f0f0' : '#f7f7f7',
color: enabled ? '#333' : '#aaa',
border: 'none',
borderRadius: '4px',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
opacity: enabled ? 1 : 0.65,
});
const createNewGame = (level: Difficulty, mode: GameMode = gameMode) => {
const nextRegionMap = getRegionMapByMode(mode);
const newPuzzle = generatePuzzle(level, nextRegionMap);
setBoard(newPuzzle);
setFixedCells(newPuzzle.map(row => row.map(cell => cell !== 0)));
const nextRound = createSudokuRound(level, mode);
setRound(nextRound);
setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0)));
setDraftCells(createEmptyDraftCells());
setUndoStack([]);
setRedoStack([]);
setSelectedCell(null);
setIsGameComplete(false);
setShowCompleteDialog(false);
@@ -212,6 +366,25 @@ const SudokuGame: React.FC = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
const isModifierKey = e.ctrlKey || e.metaKey;
if (isModifierKey && key === 'z') {
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
return;
}
if (isModifierKey && key === 'y') {
e.preventDefault();
handleRedo();
return;
}
if (!selectedCell) return;
const [row, col] = selectedCell;
@@ -256,7 +429,7 @@ const SudokuGame: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedCell, board]);
}, [selectedCell, board, draftCells, draftMode, undoStack, redoStack, isGameComplete, fixedCells, timeElapsed, regionMap, difficulty]);
useEffect(() => {
if (!isTimerRunning) return;
@@ -301,12 +474,24 @@ const SudokuGame: React.FC = () => {
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (getCellHighlightType(r, c) !== 'none') {
highlighted.add(`${r}-${c}`);
highlighted.add(getCellKey(r, c));
}
}
}
return highlighted;
}, [board, selectedCell, config]);
}, [board, selectedCell, config, regionMap]);
const conflictCells = useMemo(() => findConflictCells(board, regionMap), [board, regionMap]);
const availableNumbers = useMemo(() => {
return board.map((row, rowIndex) =>
row.map((cell, colIndex) => (
cell === 0
? DIGITS.filter((num) => isValidInBoard(rowIndex, colIndex, num, board))
: []
))
);
}, [board, regionMap]);
// 计算剩余未填写的数字数量
const remainingCount = useMemo(() => {
@@ -325,11 +510,13 @@ const SudokuGame: React.FC = () => {
const isFixed = fixedCells[row][col];
const highlightType = getCellHighlightType(row, col);
const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col;
const displayValue = getCellValue(cell);
const isConflicting = conflictCells.has(getCellKey(row, col));
// 基础背景色 - 根据高亮类型设置不同颜色
let backgroundColor = 'white';
if (isSelected) {
if (isConflicting) {
backgroundColor = '#fff1f0'; // 冲突数字 - 警告红色
} else if (isSelected) {
backgroundColor = '#69b1ff'; // 选中时的深蓝色
} else if (highlightType === 'box') {
backgroundColor = '#d9f7be'; // 宫 - 浅绿色
@@ -343,8 +530,8 @@ const SudokuGame: React.FC = () => {
backgroundColor = '#f0f0f0'; // 初始固定格子
}
const thickBorder = '2px solid #666';
const thinBorder = '1px solid #bbb';
const thickBorder = '4px solid #666';
const thinBorder = '2px solid #bbb';
// 使用 getRegionNeighbors 判断边框粗细,支持不规则区域
const neighbors = getRegionNeighbors(regionMap, row, col);
@@ -355,8 +542,8 @@ const SudokuGame: React.FC = () => {
const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder;
return {
width: '40px',
height: '40px',
width: `${CELL_SIZE}px`,
height: `${CELL_SIZE}px`,
backgroundColor,
borderRight,
borderBottom,
@@ -365,13 +552,48 @@ const SudokuGame: React.FC = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontSize: `${CELL_FONT_SIZE}px`,
cursor: 'pointer',
color: isFixed ? '#333' : '#1890ff',
color: isConflicting ? '#cf1322' : isFixed ? '#333' : '#1890ff',
position: 'relative' as const,
boxSizing: 'border-box',
boxShadow: isConflicting ? 'inset 0 0 0 3px #ff4d4f' : undefined,
outline: isSelected ? '3px solid #1677ff' : undefined,
outlineOffset: '-6px',
};
};
const getCellValueStyle = (row: number, col: number): React.CSSProperties => {
const isDraft = draftCells[row][col] && !fixedCells[row][col];
const size = isDraft ? DRAFT_SIZE : CELL_SIZE;
return {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${isDraft ? DRAFT_FONT_SIZE : CELL_FONT_SIZE}px`,
lineHeight: 1,
position: 'relative' as const,
zIndex: 1,
};
};
const getAvailableNumbersStyle = (): React.CSSProperties => ({
position: 'absolute' as const,
top: '4px',
right: '5px',
maxWidth: '46px',
fontSize: '11px',
lineHeight: 1,
color: '#8c8c8c',
textAlign: 'right' as const,
pointerEvents: 'none' as const,
zIndex: 2,
fontVariantNumeric: 'tabular-nums',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '20px' }}>
{/* 顶部控制和配置区 */}
@@ -405,6 +627,26 @@ const SudokuGame: React.FC = () => {
>
</button>
<button
onClick={handleUndo}
disabled={!canUndo}
aria-label="撤销"
title="撤销 (Ctrl/Cmd+Z)"
style={getHistoryButtonStyle(canUndo)}
>
<UndoIcon fontSize="small" />
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
aria-label="重做"
title="重做 (Ctrl/Cmd+Y)"
style={getHistoryButtonStyle(canRedo)}
>
<RedoIcon fontSize="small" />
</button>
</div>
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
@@ -476,40 +718,59 @@ const SudokuGame: React.FC = () => {
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={draftMode}
onChange={(e) => setDraftMode(e.target.checked)}
/>
稿
</label>
</div>
{/* 数独棋盘 */}
<div style={{ width: '100%', overflowX: 'auto', display: 'flex', justifyContent: 'center', paddingBottom: '4px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(9, 40px)',
gridTemplateRows: 'repeat(9, 40px)',
border: '2px solid #333',
gridTemplateColumns: `repeat(9, ${CELL_SIZE}px)`,
gridTemplateRows: `repeat(9, ${CELL_SIZE}px)`,
border: '4px solid #333',
backgroundColor: '#fff',
flex: '0 0 auto',
}}
>
{board.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const displayValue = getCellValue(cell);
const cellStyle = getCellStyle(rowIndex, colIndex);
const candidates = availableNumbers[rowIndex][colIndex];
const isConflicting = conflictCells.has(getCellKey(rowIndex, colIndex));
return (
<div
key={`${rowIndex}-${colIndex}`}
onClick={() => handleCellClick(rowIndex, colIndex)}
style={cellStyle}
title={isConflicting ? '该数字与同一行、列或区域中的数字冲突' : undefined}
>
{displayValue !== null ? displayValue : ''}
{displayValue === null && candidates.length > 0 && (
<span style={getAvailableNumbersStyle()}>{candidates.join('')}</span>
)}
{displayValue !== null ? (
<span style={getCellValueStyle(rowIndex, colIndex)}>{displayValue}</span>
) : ''}
</div>
);
})
)}
</div>
</div>
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ padding: '15px 25px', backgroundColor: '#f5f5f5', borderRadius: '8px', textAlign: 'center' }}>
<span style={{ fontSize: '14px', color: '#666', display: 'block', marginBottom: '8px' }}>
{selectedCell ? '按键盘 1-9 输入数字' : '选择一个格子后输入数字'}
{selectedCell ? (draftMode ? '草稿模式:按键盘 1-9 试填数字' : '按键盘 1-9 输入数字') : '选择一个格子后输入数字'}
</span>
<span style={{ fontSize: '16px', color: '#666', display: 'block', marginBottom: '8px' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{formatTime(timeElapsed)}</strong>
@@ -524,7 +785,7 @@ const SudokuGame: React.FC = () => {
{/* 快捷键提示 */}
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
<strong>:</strong> 1-9 | /Backspace |
<strong>:</strong> 1-9 /稿 | /Backspace | Ctrl/Cmd+Z | Ctrl/Cmd+Y |
</div>
{/* 完成成功对话框 */}

View File

@@ -19,11 +19,13 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import { API_BASE_URL } from '../config';
type Difficulty = 'easy' | 'medium' | 'hard';
type GameMode = 'all' | 'standard' | 'irregular';
interface LeaderboardRecord {
userId: string;
username: string;
fullname: string;
gameMode: 'standard' | 'irregular';
bestTime: number;
totalGames: number;
wonGames: number;
@@ -47,6 +49,7 @@ const DIFFICULTY_LABELS: Record<Difficulty, string> = {
const SudokuLeaderboard: React.FC = () => {
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
const [gameModeFilter, setGameModeFilter] = useState<GameMode>('all');
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
@@ -55,14 +58,14 @@ const SudokuLeaderboard: React.FC = () => {
useEffect(() => {
fetchLeaderboard();
}, [difficulty, page]);
}, [difficulty, page, gameModeFilter]);
const fetchLeaderboard = async () => {
setLoading(true);
setError(null);
try {
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) {
@@ -85,6 +88,11 @@ const SudokuLeaderboard: React.FC = () => {
setPage(1);
};
const handleGameModeChange = (_event: React.SyntheticEvent, newValue: GameMode) => {
setGameModeFilter(newValue);
setPage(1);
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
@@ -139,12 +147,27 @@ const SudokuLeaderboard: React.FC = () => {
</Tabs>
</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 }}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center" width="80px"></TableCell>
<TableCell></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>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<TableCell colSpan={8} align="center">
<Typography variant="body2" color="textSecondary" py={3}>
</Typography>
@@ -167,7 +190,7 @@ const SudokuLeaderboard: React.FC = () => {
const badge = page === 1 ? getRankBadge(rank) : null;
return (
<TableRow
key={record.userId}
key={`${record.userId}_${record.gameMode}`}
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
>
<TableCell align="center">
@@ -181,6 +204,13 @@ const SudokuLeaderboard: React.FC = () => {
{record.fullname || record.username}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={record.gameMode === 'standard' ? '标准' : '不规则'}
color={record.gameMode === 'standard' ? 'primary' : 'secondary'}
size="small"
/>
</TableCell>
<TableCell align="center">
<Chip
label={formatTime(record.bestTime)}

View File

@@ -1,13 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Select, Table, Tag, InputNumber, Space } from 'antd';
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Table, Tag, InputNumber, Space } from 'antd';
import { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
import CryptoJS from 'crypto-js';
import { useNavigate } from 'react-router-dom';
import { api, ApiError, authEvents } from '../api/apiClient';
import { API_PATHS } from '../config';
import type { TabsProps } from 'antd';
import type { RadioChangeEvent } from 'antd/lib/radio';
interface Word {
_id?: string;
id: string;
word: string;
translation: string;
@@ -56,11 +57,99 @@ interface LeaderboardItem {
rank: number;
}
interface TestResult {
questionToken?: string;
wordId: string;
word: string;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}
interface PendingAnswer {
questionToken: string;
userAnswer: string;
submittedAt: number;
answerProof: string;
}
interface InteractionEventPayload {
type: string;
ts: number;
key?: string;
inputType?: string;
value?: string;
valueLength?: number;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
}
interface NativeInputLikeEvent extends Event {
inputType?: string;
}
interface TestOption {
text: string;
token: string;
}
interface TestQuestion {
questionToken: string;
wordId: string;
word?: string;
translation?: string;
pronunciation?: string;
options?: Array<string | TestOption>;
}
interface SavedVocabularyTestRecordResponse {
message: string;
stats: {
totalWords: number;
correctWords: number;
accuracy: number;
startTime: string | Date;
endTime: string | Date;
duration: number;
};
results: TestResult[];
invalidated?: boolean;
}
interface TestAttemptResponse {
attemptId: string;
testType: string;
expiresAt: string | Date;
questions: TestQuestion[];
}
interface SubmitAnswerResponse {
message: string;
result: TestResult;
progress: {
answered: number;
total: number;
finished: boolean;
};
}
interface OptionTokenResponse {
questionToken: string;
optionToken: string;
}
const ANSWER_PROOF_SALT = 'd1ktsalt';
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
const MIN_STUDY_WORD_COUNT = 10;
const MAX_STUDY_WORD_COUNT = 100;
const VocabularyStudy: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [wordSets, setWordSets] = useState<WordSet[]>([]);
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
const [loadedWordSetId, setLoadedWordSetId] = useState<string>('');
const [currentWords, setCurrentWords] = useState<Word[]>([]);
const [studyStats, setStudyStats] = useState<StudyStats>({
totalWords: 0,
@@ -76,13 +165,11 @@ const VocabularyStudy: React.FC = () => {
const [showAnswer, setShowAnswer] = useState(false);
const [testStarted, setTestStarted] = useState(false);
const [testFinished, setTestFinished] = useState(false);
const [testResults, setTestResults] = useState<{
word: string;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}[]>([]);
const [options, setOptions] = useState<string[]>([]);
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [testAttemptId, setTestAttemptId] = useState<string>('');
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
const [options, setOptions] = useState<TestOption[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const timeOffsetRef = useRef<number>(0);
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
@@ -96,6 +183,9 @@ const VocabularyStudy: React.FC = () => {
// 使用ref存储当前选中ID这样可以立即访问
const currentWordSetIdRef = useRef<string>('');
const answerSubmittingRef = useRef(false);
const interactionsRef = useRef<InteractionEventPayload[]>([]);
const questionShownAtRef = useRef<number>(0);
// 测试输入框ref
const inputRef = useRef<any>(null);
@@ -103,7 +193,7 @@ const VocabularyStudy: React.FC = () => {
// 新增 state
const [studyWords, setStudyWords] = useState<Word[]>([]);
const [studyIndex, setStudyIndex] = useState(0);
const [testWords, setTestWords] = useState<Word[]>([]);
const [testWords, setTestWords] = useState<TestQuestion[]>([]);
const [testIndex, setTestIndex] = useState(0);
// 添加声音相关的状态
@@ -192,9 +282,14 @@ const VocabularyStudy: React.FC = () => {
console.log('获取到的学习单词:', response);
if (response && response.length > 0) {
const shuffledWords = shuffleArray(response);
const normalizedWords = response.map(item => ({
...item,
id: item.id || item._id || ''
}));
const shuffledWords = shuffleArray(normalizedWords);
setStudyWords(shuffledWords);
setStudyIndex(0);
setLoadedWordSetId(effectiveId);
setActiveTab('study');
message.success(`成功加载 ${shuffledWords.length} 个单词`);
} else {
@@ -241,7 +336,7 @@ const VocabularyStudy: React.FC = () => {
// 处理单词数量变更
const handleWordCountChange = (value: number | null) => {
if (value !== null) {
setWordCount(value);
setWordCount(Math.max(MIN_STUDY_WORD_COUNT, Math.min(value, MAX_STUDY_WORD_COUNT)));
}
};
@@ -365,21 +460,47 @@ const VocabularyStudy: React.FC = () => {
}
};
// 开始测试
const startTest = () => {
// 开始测试会话
const startTest = async () => {
if (studyWords.length === 0) {
message.error('请先加载单词');
return;
}
const shuffled = shuffleArray([...studyWords]);
setTestWords(shuffled);
try {
setTestAttemptLoading(true);
setTestAttemptId('');
setPendingAnswers([]);
setTestResults([]);
const wordIds = studyWords.map(getWordId).filter(Boolean);
if (wordIds.length === 0) {
message.error('单词数据不完整,请重新加载后再试');
return;
}
const response = await api.post<TestAttemptResponse>(API_PATHS.VOCABULARY.TEST_ATTEMPT, {
wordSetId: loadedWordSetId || selectedWordSet || currentWordSetIdRef.current,
testType,
wordIds
});
const questions = response.questions.map(question => ({
...question,
options: question.options || []
}));
setTestAttemptId(response.attemptId);
setTestWords(questions);
setTestIndex(0);
answerSubmittingRef.current = false;
setTestStarted(true);
setTestFinished(false);
setUserAnswer('');
setShowAnswer(false);
setTestResults([]);
// 重置统计信息
setPendingAnswers([]);
setOptions(normalizeOptions(questions[0]));
resetInteractionTrace();
setIsModalVisible(false);
setStudyStats({
totalWords: 0,
correctWords: 0,
@@ -387,32 +508,11 @@ const VocabularyStudy: React.FC = () => {
startTime: new Date(),
duration: 0
});
setActiveTab('test');
};
// 生成多选题选项
const generateMultipleChoiceOptions = (index: number, wordsArr?: Word[]) => {
const arr = wordsArr || testWords;
const correctTranslation = arr[index].translation;
let availableOptions = arr
.filter(w => w.translation !== correctTranslation)
.map(w => w.translation);
// 如果可用选项太少,添加一些假选项
if (availableOptions.length < 3) {
const fakeOptions = [
'假选项1', '假选项2', '假选项3', '假选项4', '假选项5'
].filter(opt => opt !== correctTranslation);
availableOptions = [...availableOptions, ...fakeOptions];
} catch (error) {
message.error(INVALID_CREDENTIAL_MESSAGE);
} finally {
setTestAttemptLoading(false);
}
// 打乱并选择3个错误选项
availableOptions = shuffleArray(availableOptions);
const incorrectOptions = availableOptions.slice(0, 3);
// 合并正确选项和错误选项,然后打乱
const allOptions = shuffleArray([correctTranslation, ...incorrectOptions]);
setOptions(allOptions);
};
// 数组随机排序
@@ -425,171 +525,261 @@ const VocabularyStudy: React.FC = () => {
return newArray;
};
function normalizeAnswer(str: string): string {
return str
.replace(//g, '(')
.replace(//g, ')')
.replace(//g, ',') // 全角逗号转半角
.replace(/\s+/g, '') // 去除所有空格
.replace(/,/g, '') // 去除所有逗号
.replace(/[^\w()]/g, '') // 只保留字母、数字、括号
.toLowerCase();
const getWordId = (word: Word): string => word._id || word.id;
const normalizeOptions = (question?: TestQuestion): TestOption[] => {
if (!question?.options) return [];
return question.options.map(option => {
if (typeof option === 'string') {
return { text: option, token: '' };
}
return option;
});
};
const resetInteractionTrace = () => {
const now = Date.now();
interactionsRef.current = [{
type: 'question-shown',
ts: now
}];
questionShownAtRef.current = now;
};
const recordInteraction = (event: InteractionEventPayload) => {
if (!testStarted || testFinished || showAnswer) return;
interactionsRef.current = [
...interactionsRef.current,
{
...event,
ts: Date.now()
}
].slice(-300);
};
const getTestInputElement = (): HTMLInputElement | null => {
return inputRef.current?.input || null;
};
const moveTestInputCaretToEnd = () => {
window.setTimeout(() => {
const input = getTestInputElement();
if (!input) return;
const end = input.value.length;
input.setSelectionRange(end, end);
}, 0);
};
const focusTestInputAtEnd = () => {
inputRef.current?.focus?.();
moveTestInputCaretToEnd();
};
const shouldBlockInputKey = (event: React.KeyboardEvent<HTMLInputElement>): boolean => {
const input = event.currentTarget;
const selectionStart = input.selectionStart ?? input.value.length;
const selectionEnd = input.selectionEnd ?? input.value.length;
const hasSelection = selectionStart !== selectionEnd;
const caretAtEnd = selectionStart === input.value.length && selectionEnd === input.value.length;
const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
const shortcutKey = event.key.toLowerCase();
if (navigationKeys.includes(event.key) || event.key === 'Delete') return true;
if ((event.ctrlKey || event.metaKey) && ['a', 'x', 'v', 'z', 'y'].includes(shortcutKey)) return true;
if (event.key === 'Backspace') return hasSelection || !caretAtEnd;
return false;
};
const handleMultipleChoiceChange = async (option: TestOption) => {
const currentQuestion = testWords[testIndex];
if (!currentQuestion || !testAttemptId || showAnswer) return;
recordInteraction({
type: 'change',
valueLength: option.text.length
});
if (option.token) {
setUserAnswer(option.token);
return;
}
try {
const response = await api.post<OptionTokenResponse>(API_PATHS.VOCABULARY.TEST_OPTION_TOKEN, {
attemptId: testAttemptId,
questionToken: currentQuestion.questionToken,
optionText: option.text
});
setOptions(prev => prev.map(item =>
item.text === option.text
? { ...item, token: response.optionToken }
: item
));
setUserAnswer(response.optionToken);
} catch (error) {
setUserAnswer(option.text);
}
};
const buildAnswerProof = (
attemptId: string,
questionToken: string,
userAnswer: string,
submittedAt: number
): string => {
return CryptoJS.MD5(`${ANSWER_PROOF_SALT}:${attemptId}:${questionToken}:${submittedAt}:${userAnswer}:${ANSWER_PROOF_SALT}`).toString();
};
// 提交答案
const submitAnswer = async () => {
if (!testStarted || testWords.length === 0) return;
if (!testStarted || testWords.length === 0 || !testAttemptId || answerSubmittingRef.current) return;
answerSubmittingRef.current = true;
const currentWord = testWords[testIndex];
let isCorrect = false;
let correctAnswer = '';
switch (testType) {
case 'chinese-to-english':
case 'audio-to-english':
correctAnswer = currentWord.word;
const normUser = normalizeAnswer(userAnswer);
const normCorrect = normalizeAnswer(correctAnswer);
isCorrect = normUser === normCorrect;
break;
case 'multiple-choice':
correctAnswer = currentWord.translation;
isCorrect = userAnswer === correctAnswer;
break;
const currentQuestion = testWords[testIndex];
if (!currentQuestion) {
answerSubmittingRef.current = false;
return;
}
const normalizedUserAnswer = userAnswer.trim();
if (!normalizedUserAnswer) {
answerSubmittingRef.current = false;
return;
}
const isKnownMultipleChoiceAnswer = options.some(option =>
option.token === normalizedUserAnswer || option.text === normalizedUserAnswer
);
if (testType === 'multiple-choice' && options.length > 0 && !isKnownMultipleChoiceAnswer) {
answerSubmittingRef.current = false;
message.warning('请选择一个选项后再提交');
return;
}
const submittedAt = Date.now();
const answerProof = buildAnswerProof(
testAttemptId,
currentQuestion.questionToken,
normalizedUserAnswer,
submittedAt
);
// 更新测试结果数组
const updatedResults = [
...testResults,
try {
const submittedAnswer = {
questionToken: currentQuestion.questionToken,
userAnswer: normalizedUserAnswer,
submittedAt,
answerProof,
interactions: interactionsRef.current
};
const response = await api.post<SubmitAnswerResponse>(API_PATHS.VOCABULARY.TEST_ANSWER, {
attemptId: testAttemptId,
...submittedAnswer
});
const updatedPendingAnswers = [
...pendingAnswers,
{
word: currentWord.word,
userAnswer,
correctAnswer,
isCorrect
questionToken: currentQuestion.questionToken,
userAnswer: normalizedUserAnswer,
submittedAt,
answerProof
}
];
setTestResults(updatedResults);
// 更新学习统计信息
const correctCount = updatedResults.filter(r => r.isCorrect).length;
const totalCount = updatedResults.length;
setStudyStats(prev => ({
...prev,
totalWords: totalCount,
correctWords: correctCount,
accuracy: (correctCount / totalCount) * 100
}));
// 记录学习记录
try {
await api.post(API_PATHS.VOCABULARY.WORD_RECORD, {
wordId: currentWord._id || currentWord.id,
isCorrect,
testType
});
} catch (error) {
console.error('记录单词学习结果失败', error);
}
if (isCorrect) {
message.success('正确!');
} else {
message.error(`错误! 正确答案是: ${correctAnswer}`);
}
setPendingAnswers(updatedPendingAnswers);
setTestResults(prev => [...prev, response.result]);
setShowAnswer(true);
setUserAnswer('');
setTimeout(() => {
if (testIndex < testWords.length - 1) {
setTestIndex(testIndex + 1);
setUserAnswer('');
const nextIndex = testIndex + 1;
setTestIndex(nextIndex);
setShowAnswer(false);
// 多选题生成选项
if (testType === 'multiple-choice') {
generateMultipleChoiceOptions(testIndex + 1);
}
// 听力自动播放
resetInteractionTrace();
answerSubmittingRef.current = false;
if (testType === 'audio-to-english') {
setTimeout(() => {
playWordSound(testWords[testIndex + 1].word);
playWordSound(testWords[nextIndex]?.word || '');
}, 500);
}
if (testType === 'multiple-choice') {
setOptions(normalizeOptions(testWords[nextIndex]));
}
} else {
// 最后一题,使用本地更新的结果,避免异步状态更新问题
finishTestWithResults(updatedResults);
answerSubmittingRef.current = false;
finishTestWithResults(updatedPendingAnswers);
}
}, 1500);
} catch (error) {
answerSubmittingRef.current = false;
message.error(INVALID_CREDENTIAL_MESSAGE);
}
};
// 使用指定结果完成测试
const finishTestWithResults = async (finalResults: any[]) => {
const finishTestWithResults = async (finalAnswers: PendingAnswer[]) => {
try {
setTestFinished(true);
// 获取服务器时间
const { serverTime } = await api.get<{ serverTime: number }>(API_PATHS.SYSTEM.SERVER_TIME);
const correctCount = finalResults.filter(r => r.isCorrect).length;
const totalCount = finalResults.length;
// 添加调试信息
console.log('测试完成状态(带结果):', {
finalResultsLength: finalResults.length,
finalResultsLength: finalAnswers.length,
testWordsLength: testWords.length,
correctCount,
totalCount,
attemptId: testAttemptId,
currentStudyStats: studyStats
});
// 直接使用传入的测试结果计算
const updatedStats = {
totalWords: totalCount,
correctWords: correctCount,
accuracy: totalCount > 0 ? (correctCount / totalCount) * 100 : 0,
startTime: studyStats.startTime,
endTime: new Date(serverTime),
duration: (serverTime - studyStats.startTime.getTime()) / 1000
};
// 更新统计信息
setStudyStats(updatedStats);
// 提交测试记录 - 使用计算好的最新数据
await api.post(API_PATHS.VOCABULARY.TEST_RECORD, {
wordSetId: selectedWordSet,
testType,
stats: updatedStats,
results: finalResults // 发送详细的测试结果,包括每道题的回答情况
// 提交测试记录,后端会用服务端累计的逐题答案重新判分
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
attemptId: testAttemptId
});
setStudyStats({
totalWords: savedRecord.stats.totalWords,
correctWords: savedRecord.stats.correctWords,
accuracy: savedRecord.stats.accuracy,
startTime: new Date(savedRecord.stats.startTime),
endTime: new Date(savedRecord.stats.endTime),
duration: savedRecord.stats.duration
});
setTestResults(savedRecord.results);
setPendingAnswers([]);
setTestAttemptId('');
setShowAnswer(false);
setTestFinished(true);
if (savedRecord.invalidated) {
message.error(INVALID_CREDENTIAL_MESSAGE);
} else {
message.success('测试完成,记录已保存');
}
setIsModalVisible(true);
} catch (error) {
if (error instanceof ApiError) {
message.error(`保存测试记录失败: ${error.message}`);
} else {
message.error('保存测试记录失败');
}
message.error(INVALID_CREDENTIAL_MESSAGE);
}
};
// 完成测试 - 兼容原来的调用方式
const finishTest = async () => {
// 使用当前的测试结果完成测试
await finishTestWithResults([...testResults]);
await finishTestWithResults([...pendingAnswers]);
};
// 重新开始测试
const restartTest = () => {
const shuffled = shuffleArray([...testWords]);
setTestWords(shuffled);
setTestWords([]);
setTestIndex(0);
setTestAttemptId('');
setTestStarted(false); // 回到模式选择界面
setTestFinished(false);
setUserAnswer('');
setShowAnswer(false);
setTestResults([]);
setPendingAnswers([]);
setOptions([]);
interactionsRef.current = [];
questionShownAtRef.current = 0;
answerSubmittingRef.current = false;
// 重置统计信息
setStudyStats({
totalWords: 0,
@@ -704,7 +894,7 @@ const VocabularyStudy: React.FC = () => {
}
}, [testIndex, testStarted, testType, testFinished]);
// 切换题目时自动生成选项
// 切换题目时同步多选题选项
useEffect(() => {
if (
testStarted &&
@@ -712,10 +902,16 @@ const VocabularyStudy: React.FC = () => {
testType === 'multiple-choice' &&
testWords.length > 0
) {
generateMultipleChoiceOptions(testIndex);
setOptions(normalizeOptions(testWords[testIndex]));
}
}, [testIndex, testType, testStarted, testFinished, testWords]);
useEffect(() => {
if (testStarted && !testFinished && testWords.length > 0) {
resetInteractionTrace();
}
}, [testIndex, testStarted, testFinished, testWords.length]);
// 自动播放第一个听力单词
useEffect(() => {
if (
@@ -729,6 +925,14 @@ const VocabularyStudy: React.FC = () => {
}
}, [testStarted, testType, testWords, testFinished, testIndex]);
const handleTabChange = (nextTab: string) => {
if (testStarted && !testFinished && activeTab === 'test' && nextTab !== 'test') {
message.warning('测试进行中,请完成本次测试后再切换页面');
return;
}
setActiveTab(nextTab);
};
// Tab 切换
const items: TabsProps['items'] = [
{
@@ -794,14 +998,14 @@ const VocabularyStudy: React.FC = () => {
<h4>:</h4>
<InputNumber
min={5}
max={100}
min={MIN_STUDY_WORD_COUNT}
max={MAX_STUDY_WORD_COUNT}
value={wordCount}
onChange={handleWordCountChange}
style={{ width: 120 }}
/>
<span style={{ marginLeft: 10, color: '#888' }}>
(范围: 5-100)
(: {MIN_STUDY_WORD_COUNT}-{MAX_STUDY_WORD_COUNT})
</span>
</div>
@@ -976,30 +1180,9 @@ const VocabularyStudy: React.FC = () => {
</div>
<Button
type="primary"
onClick={() => {
// 只有这里才初始化测试
const shuffled = shuffleArray([...studyWords]);
setTestWords(shuffled);
setTestIndex(0);
setTestStarted(true);
setTestFinished(false);
setUserAnswer('');
setShowAnswer(false);
setTestResults([]);
// 重置统计信息
setStudyStats({
totalWords: 0,
correctWords: 0,
accuracy: 0,
startTime: new Date(),
duration: 0
});
// 如果是选择翻译,生成选项
if (testType === 'multiple-choice') {
generateMultipleChoiceOptions(0, shuffled);
}
}}
onClick={startTest}
disabled={studyWords.length === 0}
loading={testAttemptLoading}
style={{
width: '100%',
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
@@ -1169,12 +1352,20 @@ const VocabularyStudy: React.FC = () => {
{testType === 'multiple-choice' ? (
<Radio.Group
value={userAnswer}
onChange={e => setUserAnswer(e.target.value)}
disabled={showAnswer}
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
>
{options.map(option => (
<Radio key={option} value={option} style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}>
{option}
<Radio
key={option.text}
value={option.token || option.text}
onClick={() => {
recordInteraction({ type: 'click', valueLength: option.text.length });
handleMultipleChoiceChange(option);
}}
style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}
>
{option.text}
</Radio>
))}
</Radio.Group>
@@ -1183,46 +1374,85 @@ const VocabularyStudy: React.FC = () => {
ref={inputRef}
placeholder="请输入英文单词"
value={userAnswer}
onChange={e => setUserAnswer(e.target.value)}
onChange={e => {
recordInteraction({
type: 'input',
inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType,
value: e.target.value,
valueLength: e.target.value.length
});
setUserAnswer(e.target.value);
moveTestInputCaretToEnd();
}}
onKeyDown={e => {
recordInteraction({
type: 'keydown',
key: e.key,
value: userAnswer,
valueLength: userAnswer.length,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey
});
if (shouldBlockInputKey(e)) {
e.preventDefault();
moveTestInputCaretToEnd();
}
}}
onFocus={() => {
recordInteraction({ type: 'focus', valueLength: userAnswer.length });
moveTestInputCaretToEnd();
}}
onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })}
onMouseDown={e => {
e.preventDefault();
focusTestInputAtEnd();
}}
onMouseUp={moveTestInputCaretToEnd}
onSelect={moveTestInputCaretToEnd}
onKeyUp={moveTestInputCaretToEnd}
onContextMenu={e => e.preventDefault()}
onCut={e => e.preventDefault()}
onDrop={e => e.preventDefault()}
disabled={showAnswer}
style={{ marginBottom: 15 }}
style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }}
onPressEnter={e => {
e.stopPropagation();
if (!userAnswer.trim() || showAnswer) return;
submitAnswer();
}}
autoFocus
size="large"
onPaste={e => e.preventDefault()}
onPaste={e => {
recordInteraction({ type: 'paste', value: userAnswer, valueLength: userAnswer.length });
e.preventDefault();
}}
/>
)}
{showAnswer && (
<div style={{
padding: 15,
backgroundColor: testResults[testResults.length - 1]?.isCorrect ? '#f6ffed' : '#fff2f0',
backgroundColor: '#f6f8fa',
borderRadius: 4,
marginBottom: 15,
border: `1px solid ${testResults[testResults.length - 1]?.isCorrect ? '#b7eb8f' : '#ffccc7'}`
border: '1px solid #d9d9d9'
}}>
<div style={{
fontWeight: 'bold',
color: testResults[testResults.length - 1]?.isCorrect ? '#52c41a' : '#f5222d',
color: '#1677ff',
marginBottom: 5
}}>
{testResults[testResults.length - 1]?.isCorrect ? '✓ 回答正确' : '✗ 回答错误'}
</div>
<div>: {testType === 'multiple-choice'
? testWords[testIndex].translation
: testWords[testIndex].word}
</div>
<div>: {userAnswer}</div>
<div>...</div>
</div>
)}
<Button
type="primary"
onClick={submitAnswer}
disabled={!userAnswer || showAnswer}
disabled={!userAnswer.trim() || showAnswer}
style={{ width: '100%' }}
>
@@ -1516,7 +1746,7 @@ const VocabularyStudy: React.FC = () => {
</Button>
</div>
<Tabs activeKey={activeTab} items={items} onChange={setActiveTab} />
<Tabs activeKey={activeTab} items={items} onChange={handleTabChange} />
{/* 添加声音设置弹窗 */}
{renderVoiceSettings()}

View File

@@ -1,7 +1,9 @@
import {
createEmptyBoard,
generateFullBoard,
generateIrregularRegionMap,
generatePuzzle,
generatePuzzleForMode,
getRegionId,
getRegionNeighbors,
IRREGULAR_REGION_MAP,
@@ -35,6 +37,17 @@ describe('sudokuEngine', () => {
expect(isValidRegionMap(IRREGULAR_REGION_MAP)).toBe(true);
});
test('generateIrregularRegionMap creates a non-standard connected region map', () => {
const regionMap = generateIrregularRegionMap();
const differentCells = regionMap.reduce(
(count, row, rowIndex) => count + row.filter((regionId, colIndex) => regionId !== STANDARD_REGION_MAP[rowIndex][colIndex]).length,
0
);
expect(isValidRegionMap(regionMap)).toBe(true);
expect(differentCells).toBeGreaterThan(0);
});
test('isPlacementValid uses irregular regions instead of 3x3 boxes', () => {
const board = createEmptyBoard();
board[0][0] = 5;
@@ -56,6 +69,17 @@ describe('sudokuEngine', () => {
expect(puzzle.flat().some((cell) => cell === 0)).toBe(true);
});
test('generatePuzzleForMode pairs irregular puzzles with their generated region map', () => {
const { puzzle, regionMap } = generatePuzzleForMode('medium', 'irregular');
const fullBoard = generateFullBoard(regionMap);
expect(isValidRegionMap(regionMap)).toBe(true);
expect(puzzle).toHaveLength(9);
expect(puzzle.every((row) => row.length === 9)).toBe(true);
expect(puzzle.flat().some((cell) => cell === 0)).toBe(true);
expectSolvedBoard(fullBoard, regionMap);
});
test('generateFullBoard creates a solved irregular board', () => {
const board = generateFullBoard(IRREGULAR_REGION_MAP);

View File

@@ -2,10 +2,35 @@ export type GameBoard = number[][];
export type Difficulty = 'easy' | 'medium' | 'hard';
export type GameMode = 'standard' | 'irregular';
export type RegionMap = number[][];
export type GeneratedPuzzle = {
puzzle: GameBoard;
regionMap: RegionMap;
};
type SolvedSudoku = {
fullBoard: GameBoard;
regionMap: RegionMap;
};
type CellPosition = [number, number];
const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const DIRECTIONS: CellPosition[] = [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
];
const MIN_IRREGULAR_CELL_DIFFERENCE = 16;
const MAX_IRREGULAR_RESTARTS = 24;
const MAX_IRREGULAR_GREEDY_STEPS = 28;
const generatedFullBoards = new WeakMap<RegionMap, GameBoard>();
export const createEmptyBoard = (): GameBoard => Array.from({ length: 9 }, () => Array(9).fill(0));
const shuffleArray = (arr: number[]) => {
const shuffleArray = <T>(arr: T[]): T[] => {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -14,6 +39,10 @@ const shuffleArray = (arr: number[]) => {
return result;
};
const cloneBoard = (board: GameBoard): GameBoard => board.map((row) => [...row]);
const cloneRegionMap = (regionMap: RegionMap): RegionMap => regionMap.map((row) => [...row]);
export const STANDARD_REGION_MAP: RegionMap = Array.from({ length: 9 }, (_, row) =>
Array.from({ length: 9 }, (_, col) => Math.floor(row / 3) * 3 + Math.floor(col / 3))
);
@@ -44,6 +73,8 @@ const IRREGULAR_FULL_BOARD: GameBoard = [
export const getRegionId = (regionMap: RegionMap, row: number, col: number): number => regionMap[row][col];
const isInBounds = (row: number, col: number): boolean => row >= 0 && row < 9 && col >= 0 && col < 9;
export const isValidRegionMap = (regionMap: RegionMap): boolean => {
if (regionMap.length !== 9 || regionMap.some((row) => row.length !== 9)) {
return false;
@@ -69,19 +100,14 @@ const isConnectedRegion = (cells: Array<[number, number]>): boolean => {
const cellSet = new Set(cells.map(([row, col]) => `${row}-${col}`));
const seen = new Set<string>();
const queue: Array<[number, number]> = [cells[0]];
const queue: CellPosition[] = [cells[0]];
seen.add(`${cells[0][0]}-${cells[0][1]}`);
for (let index = 0; index < queue.length; index++) {
const [row, col] = queue[index];
const neighbors: Array<[number, number]> = [
[row - 1, col],
[row + 1, col],
[row, col - 1],
[row, col + 1],
];
for (const [nextRow, nextCol] of neighbors) {
for (const [rowOffset, colOffset] of DIRECTIONS) {
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
const key = `${nextRow}-${nextCol}`;
if (cellSet.has(key) && !seen.has(key)) {
seen.add(key);
@@ -98,12 +124,203 @@ const isSameRegionMap = (left: RegionMap, right: RegionMap): boolean =>
left.every((row, rowIndex) => row.length === right[rowIndex].length && row.every((cell, colIndex) => cell === right[rowIndex][colIndex]));
const randomizeDigits = (board: GameBoard): GameBoard => {
const shuffledDigits = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]);
const shuffledDigits = shuffleArray(DIGITS);
const digitMap = new Map(shuffledDigits.map((digit, index) => [index + 1, digit]));
return board.map((row) => row.map((cell) => digitMap.get(cell) ?? cell));
};
const generateRandomStandardFullBoard = (): GameBoard => {
const pattern = (row: number, col: number) => ((row * 3 + Math.floor(row / 3) + col) % 9) + 1;
const rows = shuffleArray([0, 1, 2]).flatMap((band) => shuffleArray([0, 1, 2]).map((row) => band * 3 + row));
const cols = shuffleArray([0, 1, 2]).flatMap((stack) => shuffleArray([0, 1, 2]).map((col) => stack * 3 + col));
const shuffledDigits = shuffleArray(DIGITS);
return rows.map((row) => cols.map((col) => shuffledDigits[pattern(row, col) - 1]));
};
const countDifferentCells = (left: RegionMap, right: RegionMap): number => {
let count = 0;
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (left[row][col] !== right[row][col]) {
count++;
}
}
}
return count;
};
const getTouchingRegionPairs = (regionMap: RegionMap): Array<[number, number]> => {
const pairs = new Set<string>();
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
for (const [rowOffset, colOffset] of DIRECTIONS) {
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
if (!isInBounds(nextRow, nextCol)) continue;
const currentRegion = regionMap[row][col];
const nextRegion = regionMap[nextRow][nextCol];
if (currentRegion === nextRegion) continue;
const [low, high] = currentRegion < nextRegion ? [currentRegion, nextRegion] : [nextRegion, currentRegion];
pairs.add(`${low}-${high}`);
}
}
}
return Array.from(pairs, (pair) => pair.split('-').map(Number) as [number, number]);
};
const getBoundaryCells = (regionMap: RegionMap, regionId: number, neighborRegionId: number): CellPosition[] => {
const cells: CellPosition[] = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (regionMap[row][col] !== regionId) continue;
const touchesNeighbor = DIRECTIONS.some(([rowOffset, colOffset]) => {
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
return isInBounds(nextRow, nextCol) && regionMap[nextRow][nextCol] === neighborRegionId;
});
if (touchesNeighbor) {
cells.push([row, col]);
}
}
}
return cells;
};
const swapRegionCells = (
regionMap: RegionMap,
firstCell: CellPosition,
secondCell: CellPosition,
firstRegionId: number,
secondRegionId: number
): RegionMap => {
const nextRegionMap = cloneRegionMap(regionMap);
const [firstRow, firstCol] = firstCell;
const [secondRow, secondCol] = secondCell;
nextRegionMap[firstRow][firstCol] = secondRegionId;
nextRegionMap[secondRow][secondCol] = firstRegionId;
return nextRegionMap;
};
const findBetterCompatibleRegionMap = (
regionMap: RegionMap,
fullBoard: GameBoard,
currentDifference: number
): RegionMap | null => {
let bestDifference = currentDifference;
let bestRegionMaps: RegionMap[] = [];
for (const [firstRegionId, secondRegionId] of shuffleArray(getTouchingRegionPairs(regionMap))) {
const firstBoundaryCells = shuffleArray(getBoundaryCells(regionMap, firstRegionId, secondRegionId));
const secondBoundaryCells = getBoundaryCells(regionMap, secondRegionId, firstRegionId);
for (const firstCell of firstBoundaryCells) {
const [firstRow, firstCol] = firstCell;
const matchingSecondCells = secondBoundaryCells.filter(([secondRow, secondCol]) => fullBoard[secondRow][secondCol] === fullBoard[firstRow][firstCol]);
for (const secondCell of shuffleArray(matchingSecondCells)) {
const candidateRegionMap = swapRegionCells(regionMap, firstCell, secondCell, firstRegionId, secondRegionId);
const candidateDifference = countDifferentCells(candidateRegionMap, STANDARD_REGION_MAP);
if (candidateDifference <= bestDifference || !isValidRegionMap(candidateRegionMap)) {
continue;
}
if (candidateDifference > bestDifference) {
bestDifference = candidateDifference;
bestRegionMaps = [];
}
bestRegionMaps.push(candidateRegionMap);
}
}
}
if (bestRegionMaps.length === 0) {
return null;
}
return shuffleArray(bestRegionMaps)[0];
};
const isSolvedBoardForRegionMap = (board: GameBoard, regionMap: RegionMap): boolean => {
const expected = DIGITS.join(',');
const toKey = (values: number[]) => [...values].sort((a, b) => a - b).join(',');
for (let index = 0; index < 9; index++) {
if (toKey(board[index]) !== expected) return false;
if (toKey(board.map((row) => row[index])) !== expected) return false;
const regionValues: number[] = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (regionMap[row][col] === index) {
regionValues.push(board[row][col]);
}
}
}
if (toKey(regionValues) !== expected) return false;
}
return true;
};
const rememberSolvedSudoku = (solvedSudoku: SolvedSudoku): SolvedSudoku => {
generatedFullBoards.set(solvedSudoku.regionMap, cloneBoard(solvedSudoku.fullBoard));
return solvedSudoku;
};
const generateSolvedIrregularSudoku = (): SolvedSudoku => {
let bestSolvedSudoku: SolvedSudoku | null = null;
let bestDifference = -1;
for (let restart = 0; restart < MAX_IRREGULAR_RESTARTS; restart++) {
const fullBoard = generateRandomStandardFullBoard();
let regionMap = cloneRegionMap(STANDARD_REGION_MAP);
let difference = 0;
for (let step = 0; step < MAX_IRREGULAR_GREEDY_STEPS; step++) {
const nextRegionMap = findBetterCompatibleRegionMap(regionMap, fullBoard, difference);
if (!nextRegionMap) break;
regionMap = nextRegionMap;
difference = countDifferentCells(regionMap, STANDARD_REGION_MAP);
}
if (isValidRegionMap(regionMap) && isSolvedBoardForRegionMap(fullBoard, regionMap) && difference > bestDifference) {
bestSolvedSudoku = { fullBoard, regionMap };
bestDifference = difference;
}
if (bestSolvedSudoku && bestDifference >= MIN_IRREGULAR_CELL_DIFFERENCE) {
return rememberSolvedSudoku(bestSolvedSudoku);
}
}
if (bestSolvedSudoku && bestDifference > 0) {
return rememberSolvedSudoku(bestSolvedSudoku);
}
return rememberSolvedSudoku({
fullBoard: randomizeDigits(IRREGULAR_FULL_BOARD),
regionMap: cloneRegionMap(IRREGULAR_REGION_MAP),
});
};
export const generateIrregularRegionMap = (): RegionMap => generateSolvedIrregularSudoku().regionMap;
export const isPlacementValid = (
board: GameBoard,
row: number,
@@ -132,26 +349,40 @@ export const isPlacementValid = (
};
const fillBoard = (board: GameBoard, regionMap: RegionMap): boolean => {
let bestCell: { row: number; col: number; candidates: number[] } | null = null;
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (board[row][col] !== 0) continue;
const numbers = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]);
for (const num of numbers) {
if (isPlacementValid(board, row, col, num, regionMap)) {
board[row][col] = num;
const candidates = DIGITS.filter((num) => isPlacementValid(board, row, col, num, regionMap));
if (!bestCell || candidates.length < bestCell.candidates.length) {
bestCell = { row, col, candidates };
}
if (candidates.length <= 1) {
break;
}
}
if (bestCell && bestCell.candidates.length <= 1) {
break;
}
}
if (!bestCell) {
return true;
}
for (const num of shuffleArray(bestCell.candidates)) {
board[bestCell.row][bestCell.col] = num;
if (fillBoard(board, regionMap)) {
return true;
}
board[row][col] = 0;
}
board[bestCell.row][bestCell.col] = 0;
}
return false;
}
}
return true;
};
export const generateFullBoard = (regionMap: RegionMap): GameBoard => {
@@ -159,6 +390,11 @@ export const generateFullBoard = (regionMap: RegionMap): GameBoard => {
throw new Error('Invalid sudoku region map');
}
const generatedFullBoard = generatedFullBoards.get(regionMap);
if (generatedFullBoard) {
return cloneBoard(generatedFullBoard);
}
if (isSameRegionMap(regionMap, IRREGULAR_REGION_MAP)) {
return randomizeDigits(IRREGULAR_FULL_BOARD);
}
@@ -183,8 +419,7 @@ const getRemovalCount = (difficulty: Difficulty) => {
}
};
export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard => {
const fullBoard = generateFullBoard(regionMap);
const createPuzzleFromFullBoard = (difficulty: Difficulty, fullBoard: GameBoard): GameBoard => {
const puzzle = fullBoard.map((row) => [...row]);
const positions = Array.from({ length: 81 }, (_, idx) => idx);
const toRemove = shuffleArray(positions).slice(0, getRemovalCount(difficulty));
@@ -198,6 +433,25 @@ export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): Ga
return puzzle;
};
export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard =>
createPuzzleFromFullBoard(difficulty, generateFullBoard(regionMap));
export const generatePuzzleForMode = (difficulty: Difficulty, mode: GameMode): GeneratedPuzzle => {
if (mode === 'standard') {
const regionMap = cloneRegionMap(STANDARD_REGION_MAP);
return {
puzzle: createPuzzleFromFullBoard(difficulty, generateRandomStandardFullBoard()),
regionMap,
};
}
const { fullBoard, regionMap } = generateSolvedIrregularSudoku();
return {
puzzle: createPuzzleFromFullBoard(difficulty, fullBoard),
regionMap,
};
};
export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: number) => {
const regionId = getRegionId(regionMap, row, col);
@@ -210,4 +464,4 @@ export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: numbe
};
export const getRegionMapByMode = (mode: GameMode): RegionMap =>
mode === 'irregular' ? IRREGULAR_REGION_MAP : STANDARD_REGION_MAP;
mode === 'irregular' ? generateIrregularRegionMap() : cloneRegionMap(STANDARD_REGION_MAP);

View File

@@ -48,6 +48,9 @@ export const API_PATHS = {
WORD_SETS: '/vocabulary/word-sets',
STUDY_WORDS: '/vocabulary/study-words',
UPLOAD: '/vocabulary/upload',
TEST_ATTEMPT: '/vocabulary/test-attempt',
TEST_ANSWER: '/vocabulary/test-answer',
TEST_OPTION_TOKEN: '/vocabulary/test-option-token',
WORD_RECORD: '/vocabulary/word-record',
TEST_RECORD: '/vocabulary/test-record',
STUDY_RECORDS: '/vocabulary/test-records',