Compare commits
18 Commits
5567bf572f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2841413927 | |||
| c6b2bb7800 | |||
| f9311292cc | |||
| b19c811048 | |||
| 76c2f17229 | |||
| 8fd1606424 | |||
| 37e4837e3f | |||
| 0ec12aa0ca | |||
| 1de143740a | |||
| 4088fbc9e7 | |||
| 6cac2f0c9a | |||
| 0de72a629c | |||
| 01bc52502f | |||
| 09aa034f52 | |||
| 2f76e6a3ca | |||
| 7f44194416 | |||
| dec29186e0 | |||
| 257cee6fb2 |
@@ -1,6 +1,7 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
SERVER_PORT=5001
|
SERVER_PORT=5001
|
||||||
REACT_APP_API_BASE_URL=http://localhost:5001
|
REACT_APP_API_BASE_URL=http://localhost:5001
|
||||||
|
BODY_LIMIT=5mb
|
||||||
# Client Configuration
|
# Client Configuration
|
||||||
CLIENT_PORT=3001
|
CLIENT_PORT=3001
|
||||||
REACT_APP_CLIENT_URL=http://localhost:3001
|
REACT_APP_CLIENT_URL=http://localhost:3001
|
||||||
@@ -12,4 +13,4 @@ MONGODB_DB_NAME=typeskill
|
|||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
JWT_SECRET=your_jwt_secret_key
|
JWT_SECRET=your_jwt_secret_key
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
SKIP_PREFLIGHT_CHECK=true
|
SKIP_PREFLIGHT_CHECK=true
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ build/
|
|||||||
# Other
|
# Other
|
||||||
coverage/
|
coverage/
|
||||||
.codebuddy
|
.codebuddy
|
||||||
|
.codex-docwork/
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ _TD.a.push(function (TD) {
|
|||||||
"laser_gun": {
|
"laser_gun": {
|
||||||
damage: 25,
|
damage: 25,
|
||||||
range: 6,
|
range: 6,
|
||||||
max_range: 10,
|
|
||||||
speed: 20,
|
speed: 20,
|
||||||
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
||||||
life: 100,
|
life: 100,
|
||||||
|
|||||||
@@ -20,7 +20,20 @@
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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,11 +171,39 @@
|
|||||||
var grid = map.getGrid(bi.mx, bi.my);
|
var grid = map.getGrid(bi.mx, bi.my);
|
||||||
if (grid) grid.addBuilding(bi.type);
|
if (grid) grid.addBuilding(bi.type);
|
||||||
var b = grid && grid.building;
|
var b = grid && grid.building;
|
||||||
if (b) {
|
if (b) {
|
||||||
if (typeof bi.level !== 'undefined') b.level = bi.level;
|
var hasSavedBuildingAttrs = (
|
||||||
if (typeof bi.money !== 'undefined') b.money = bi.money;
|
typeof bi.damage !== 'undefined' ||
|
||||||
b.updateBtnDesc && b.updateBtnDesc();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,20 @@ window.__TD_getState = function () {
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +170,39 @@ window.__TD_loadState = function (s) {
|
|||||||
var grid = map.getGrid(bi.mx, bi.my);
|
var grid = map.getGrid(bi.mx, bi.my);
|
||||||
if (grid) grid.addBuilding(bi.type);
|
if (grid) grid.addBuilding(bi.type);
|
||||||
var b = grid && grid.building;
|
var b = grid && grid.building;
|
||||||
if (b) {
|
if (b) {
|
||||||
if (typeof bi.level !== 'undefined') b.level = bi.level;
|
var hasSavedBuildingAttrs = (
|
||||||
if (typeof bi.money !== 'undefined') b.money = bi.money;
|
typeof bi.damage !== 'undefined' ||
|
||||||
b.updateBtnDesc && b.updateBtnDesc();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,20 @@ var _TD = {
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +371,39 @@ var _TD = {
|
|||||||
var grid = map.getGrid(bi.mx, bi.my);
|
var grid = map.getGrid(bi.mx, bi.my);
|
||||||
if (grid) grid.addBuilding(bi.type);
|
if (grid) grid.addBuilding(bi.type);
|
||||||
var b = grid && grid.building;
|
var b = grid && grid.building;
|
||||||
if (b) {
|
if (b) {
|
||||||
if (typeof bi.level !== 'undefined') b.level = bi.level;
|
var hasSavedBuildingAttrs = (
|
||||||
if (typeof bi.money !== 'undefined') b.money = bi.money;
|
typeof bi.damage !== 'undefined' ||
|
||||||
b.updateBtnDesc && b.updateBtnDesc();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,20 @@
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +222,39 @@
|
|||||||
var grid = map.getGrid(bi.mx, bi.my);
|
var grid = map.getGrid(bi.mx, bi.my);
|
||||||
if (grid) grid.addBuilding(bi.type);
|
if (grid) grid.addBuilding(bi.type);
|
||||||
var b = grid && grid.building;
|
var b = grid && grid.building;
|
||||||
if (b) {
|
if (b) {
|
||||||
if (typeof bi.level !== 'undefined') b.level = bi.level;
|
var hasSavedBuildingAttrs = (
|
||||||
if (typeof bi.money !== 'undefined') b.money = bi.money;
|
typeof bi.damage !== 'undefined' ||
|
||||||
b.updateBtnDesc && b.updateBtnDesc();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"debug:server": "cross-env NODE_OPTIONS=\"--loader ts-node/esm --inspect=5858\" ts-node-dev server/server.ts",
|
"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:client": "cross-env PORT=3001 react-scripts start",
|
||||||
"debug": "concurrently \"npm run debug:server\" \"npm run debug:client\"",
|
"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",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"migrate-passwords": "ts-node -P tsconfig.json scripts/migratePasswords.ts"
|
"migrate-passwords": "ts-node -P tsconfig.json scripts/migratePasswords.ts"
|
||||||
|
|||||||
48
public/tower-defense/td-pkg-zh-min.js
vendored
48
public/tower-defense/td-pkg-zh-min.js
vendored
@@ -265,7 +265,20 @@ var _TD = {
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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 };
|
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);
|
if (grid) grid.addBuilding(bi.type);
|
||||||
var b = grid && grid.building;
|
var b = grid && grid.building;
|
||||||
if (b) {
|
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.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();
|
b.updateBtnDesc && b.updateBtnDesc();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3845,7 +3886,6 @@ _TD.a.push(function (TD) {
|
|||||||
"laser_gun": {
|
"laser_gun": {
|
||||||
damage: 25,
|
damage: 25,
|
||||||
range: 6,
|
range: 6,
|
||||||
max_range: 10,
|
|
||||||
speed: 20,
|
speed: 20,
|
||||||
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
||||||
life: 100,
|
life: 100,
|
||||||
@@ -4584,5 +4624,3 @@ _TD.a.push(function (TD) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
}); // _TD.a.push end
|
}); // _TD.a.push end
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3824,7 +3824,6 @@ _TD.a.push(function (TD) {
|
|||||||
"laser_gun": {
|
"laser_gun": {
|
||||||
damage: 25,
|
damage: 25,
|
||||||
range: 6,
|
range: 6,
|
||||||
max_range: 10,
|
|
||||||
speed: 20,
|
speed: 20,
|
||||||
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
// bullet_speed: 10, // laser_gun 的 bullet_speed 属性没有用
|
||||||
life: 100,
|
life: 100,
|
||||||
|
|||||||
@@ -95,7 +95,20 @@
|
|||||||
for (var i = 0; i < map.buildings.length; i++) {
|
for (var i = 0; i < map.buildings.length; i++) {
|
||||||
var b = map.buildings[i];
|
var b = map.buildings[i];
|
||||||
if (!b || !b.grid) continue;
|
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);
|
grid.addBuilding(bi.type);
|
||||||
var b = grid.building;
|
var b = grid.building;
|
||||||
if (b) {
|
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.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();
|
b.updateBtnDesc && b.updateBtnDesc();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 服务器配置
|
# 服务器配置
|
||||||
PORT=5001
|
PORT=5001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
BODY_LIMIT=5mb
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
MONGODB_URI=mongodb://localhost:27017/typeskill
|
MONGODB_URI=mongodb://localhost:27017/typeskill
|
||||||
@@ -8,5 +9,8 @@ MONGODB_URI=mongodb://localhost:27017/typeskill
|
|||||||
# JWT配置
|
# JWT配置
|
||||||
JWT_SECRET=your_jwt_secret_key_here
|
JWT_SECRET=your_jwt_secret_key_here
|
||||||
|
|
||||||
|
# 词汇防作弊配置
|
||||||
|
VOCABULARY_FULL_CORRECT_WORD_THRESHOLD=50
|
||||||
|
|
||||||
# CORS配置
|
# CORS配置
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import mongoose, { Document, Schema } from 'mongoose';
|
import mongoose, { Document, Schema } from 'mongoose';
|
||||||
|
|
||||||
export type SudokuDifficulty = 'easy' | 'medium' | 'hard';
|
export type SudokuDifficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
export type SudokuGameMode = 'standard' | 'irregular';
|
||||||
|
|
||||||
export interface ISudokuRecord extends Document {
|
export interface ISudokuRecord extends Document {
|
||||||
userId: mongoose.Types.ObjectId;
|
userId: mongoose.Types.ObjectId;
|
||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
difficulty: SudokuDifficulty;
|
difficulty: SudokuDifficulty;
|
||||||
gameMode: 'standard' | 'irregular';
|
gameMode: SudokuGameMode;
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
won: boolean;
|
won: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -18,7 +19,7 @@ export interface ISudokuLeaderboardRecord {
|
|||||||
userId: mongoose.Types.ObjectId;
|
userId: mongoose.Types.ObjectId;
|
||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
gameMode: 'standard' | 'irregular';
|
gameMode: SudokuGameMode;
|
||||||
bestTime: number;
|
bestTime: number;
|
||||||
totalGames: number;
|
totalGames: number;
|
||||||
wonGames: number;
|
wonGames: number;
|
||||||
@@ -69,7 +70,7 @@ sudokuRecordSchema.index({ difficulty: 1, won: 1, gameMode: 1, timeSeconds: 1 })
|
|||||||
|
|
||||||
sudokuRecordSchema.statics.getLeaderboard = function(
|
sudokuRecordSchema.statics.getLeaderboard = function(
|
||||||
difficulty: SudokuDifficulty,
|
difficulty: SudokuDifficulty,
|
||||||
gameMode: 'standard' | 'irregular' | 'all',
|
gameMode: SudokuGameMode | 'all',
|
||||||
skipCount: number,
|
skipCount: number,
|
||||||
pageSize: number
|
pageSize: number
|
||||||
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
|
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
|
||||||
@@ -134,6 +135,7 @@ sudokuRecordSchema.statics.getLeaderboard = function(
|
|||||||
interface SudokuRecordModel extends mongoose.Model<ISudokuRecord> {
|
interface SudokuRecordModel extends mongoose.Model<ISudokuRecord> {
|
||||||
getLeaderboard(
|
getLeaderboard(
|
||||||
difficulty: SudokuDifficulty,
|
difficulty: SudokuDifficulty,
|
||||||
|
gameMode: SudokuGameMode | 'all',
|
||||||
skipCount: number,
|
skipCount: number,
|
||||||
pageSize: number
|
pageSize: number
|
||||||
): mongoose.Aggregate<ISudokuLeaderboardRecord[]>;
|
): mongoose.Aggregate<ISudokuLeaderboardRecord[]>;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface IVocabularyTestRecord extends Document {
|
|||||||
user: mongoose.Types.ObjectId;
|
user: mongoose.Types.ObjectId;
|
||||||
wordSet: mongoose.Types.ObjectId;
|
wordSet: mongoose.Types.ObjectId;
|
||||||
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||||
|
attempt?: mongoose.Types.ObjectId;
|
||||||
stats: {
|
stats: {
|
||||||
totalWords: number;
|
totalWords: number;
|
||||||
correctWords: number;
|
correctWords: number;
|
||||||
@@ -49,9 +50,64 @@ export interface IVocabularyTestRecord extends Document {
|
|||||||
endTime: Date;
|
endTime: Date;
|
||||||
duration: number;
|
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;
|
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>({
|
const WordSchema = new Schema<IWord>({
|
||||||
word: { type: String, required: true, trim: true },
|
word: { type: String, required: true, trim: true },
|
||||||
@@ -83,7 +139,8 @@ const ModeStatsSchema = new Schema({
|
|||||||
totalWrong: { type: Number, default: 0 },
|
totalWrong: { type: Number, default: 0 },
|
||||||
mastered: { type: Boolean, default: false },
|
mastered: { type: Boolean, default: false },
|
||||||
inWrongBook: { type: Boolean, default: false },
|
inWrongBook: { type: Boolean, default: false },
|
||||||
lastTestedAt: Date
|
lastTestedAt: Date,
|
||||||
|
lastMasteredAt: Date
|
||||||
}, { _id: false });
|
}, { _id: false });
|
||||||
|
|
||||||
// 简化后的主记录模式
|
// 简化后的主记录模式
|
||||||
@@ -107,6 +164,7 @@ const WordRecordSchema = new Schema({
|
|||||||
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||||
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
||||||
|
attempt: { type: Schema.Types.ObjectId, ref: 'VocabularyTestAttempt' },
|
||||||
testType: {
|
testType: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
||||||
@@ -120,18 +178,86 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
|||||||
endTime: { type: Date, required: true },
|
endTime: { type: Date, required: true },
|
||||||
duration: { type: Number, 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 }
|
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 Word = mongoose.model<IWord>('Word', WordSchema);
|
||||||
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
|
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
|
||||||
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
|
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
|
||||||
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
|
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
|
||||||
|
export const VocabularyTestAttempt = mongoose.model<IVocabularyTestAttempt>('VocabularyTestAttempt', VocabularyTestAttemptSchema);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Word,
|
Word,
|
||||||
WordSet,
|
WordSet,
|
||||||
WordRecord,
|
WordRecord,
|
||||||
VocabularyTestRecord
|
VocabularyTestRecord,
|
||||||
};
|
VocabularyTestAttempt
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import multer from 'multer';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import csv from 'csv-parser';
|
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 mongoose from 'mongoose';
|
||||||
|
import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
|
||||||
|
import {
|
||||||
|
applyVocabularySummaryItemFilters,
|
||||||
|
buildVocabularySummaryFilters
|
||||||
|
} from '../utils/vocabularyAuditFilters';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const adminController = new AdminController();
|
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) => {
|
router.get('/users', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await User.find()
|
const users = await User.find()
|
||||||
@@ -321,4 +531,510 @@ router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
// 按时间段汇总学生词汇测试通过情况
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { SudokuRecord, SudokuDifficulty } from '../models/SudokuRecord';
|
import { SudokuRecord, SudokuDifficulty, SudokuGameMode } from '../models/SudokuRecord';
|
||||||
import { auth } from '../middleware/auth';
|
import { auth } from '../middleware/auth';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -7,7 +7,7 @@ const router = express.Router();
|
|||||||
// 提交数独记录(需要登录)
|
// 提交数独记录(需要登录)
|
||||||
router.post('/record', auth, async (req: Request, res: Response) => {
|
router.post('/record', auth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { difficulty, timeSeconds, won } = req.body;
|
const { difficulty, timeSeconds, won, gameMode = 'standard' } = req.body;
|
||||||
|
|
||||||
if (!req.user?._id) {
|
if (!req.user?._id) {
|
||||||
return res.status(401).json({ error: '未登录' });
|
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: '无效的游戏结果' });
|
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({
|
const record = new SudokuRecord({
|
||||||
userId: req.user._id,
|
userId: req.user._id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
fullname: req.user.fullname || req.user.username,
|
fullname: req.user.fullname || req.user.username,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
gameMode,
|
||||||
timeSeconds,
|
timeSeconds,
|
||||||
won
|
won
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,13 @@ import sudokuRouter from './routes/sudoku';
|
|||||||
// 加载环境变量
|
// 加载环境变量
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
const BODY_LIMIT = process.env.BODY_LIMIT || '5mb';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// 中间件配置
|
// 中间件配置
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: BODY_LIMIT }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT }));
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
// 静态文件服务 - 优先处理 public 目录下的静态文件
|
// 静态文件服务 - 优先处理 public 目录下的静态文件
|
||||||
@@ -202,7 +204,40 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
// 错误处理中间件
|
// 错误处理中间件
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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!');
|
res.status(500).send('Something broke!');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,4 +253,4 @@ httpServer.listen(PORT, () => {
|
|||||||
console.log(`WebSocket server is ready`);
|
console.log(`WebSocket server is ready`);
|
||||||
console.log(`Environment:`, process.env.NODE_ENV);
|
console.log(`Environment:`, process.env.NODE_ENV);
|
||||||
console.log(`REACT_APP_API_BASE_URL:`, process.env.REACT_APP_API_BASE_URL);
|
console.log(`REACT_APP_API_BASE_URL:`, process.env.REACT_APP_API_BASE_URL);
|
||||||
});
|
});
|
||||||
|
|||||||
48
server/utils/vocabularyAuditFilters.test.ts
Normal file
48
server/utils/vocabularyAuditFilters.test.ts
Normal 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');
|
||||||
52
server/utils/vocabularyAuditFilters.ts
Normal file
52
server/utils/vocabularyAuditFilters.ts
Normal 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);
|
||||||
|
};
|
||||||
58
server/utils/vocabularyRisk.test.ts
Normal file
58
server/utils/vocabularyRisk.test.ts
Normal 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');
|
||||||
59
server/utils/vocabularyRisk.ts
Normal file
59
server/utils/vocabularyRisk.ts
Normal 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 [];
|
||||||
|
};
|
||||||
184
src/api/admin.ts
184
src/api/admin.ts
@@ -94,6 +94,172 @@ export interface Word {
|
|||||||
createdAt: string;
|
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 = {
|
export const adminApi = {
|
||||||
getUsers: async (): Promise<User[]> => {
|
getUsers: async (): Promise<User[]> => {
|
||||||
return api.get<User[]>('/admin/users');
|
return api.get<User[]>('/admin/users');
|
||||||
@@ -176,5 +342,21 @@ export const adminApi = {
|
|||||||
updateWords: async (words: any[]) => {
|
updateWords: async (words: any[]) => {
|
||||||
return api.put('/api/vocabulary/words', { words });
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import AdminCodeManager from './AdminCodeManager';
|
|||||||
import AdminPracticeRecords from './AdminPracticeRecords';
|
import AdminPracticeRecords from './AdminPracticeRecords';
|
||||||
import AdminOAuth2Manager from './AdminOAuth2Manager';
|
import AdminOAuth2Manager from './AdminOAuth2Manager';
|
||||||
import AdminVocabularyManager from './AdminVocabularyManager';
|
import AdminVocabularyManager from './AdminVocabularyManager';
|
||||||
|
import AdminVocabularyScoreAudit from './AdminVocabularyScoreAudit';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -62,6 +63,7 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<Tab label="代码管理" />
|
<Tab label="代码管理" />
|
||||||
<Tab label="练习记录" />
|
<Tab label="练习记录" />
|
||||||
<Tab label="单词库管理" />
|
<Tab label="单词库管理" />
|
||||||
|
<Tab label="词汇成绩核查" />
|
||||||
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
|
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -77,8 +79,11 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<TabPanel value={tabValue} index={3}>
|
<TabPanel value={tabValue} index={3}>
|
||||||
<AdminVocabularyManager />
|
<AdminVocabularyManager />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={tabValue} index={4}>
|
||||||
|
<AdminVocabularyScoreAudit />
|
||||||
|
</TabPanel>
|
||||||
{user.username === 'bobcoc' && (
|
{user.username === 'bobcoc' && (
|
||||||
<TabPanel value={tabValue} index={4}>
|
<TabPanel value={tabValue} index={5}>
|
||||||
<AdminOAuth2Manager />
|
<AdminOAuth2Manager />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
@@ -87,4 +92,4 @@ const AdminDashboard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|||||||
658
src/components/AdminVocabularyScoreAudit.tsx
Normal file
658
src/components/AdminVocabularyScoreAudit.tsx
Normal 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;
|
||||||
@@ -27,6 +27,7 @@ const CELL_SIZE = 80;
|
|||||||
const CELL_FONT_SIZE = 36;
|
const CELL_FONT_SIZE = 36;
|
||||||
const DRAFT_SIZE = CELL_SIZE / 2;
|
const DRAFT_SIZE = CELL_SIZE / 2;
|
||||||
const DRAFT_FONT_SIZE = CELL_FONT_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 createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => {
|
||||||
const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode);
|
const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode);
|
||||||
@@ -39,6 +40,50 @@ const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound
|
|||||||
const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]);
|
const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]);
|
||||||
const cloneDraftCells = (draftCells: DraftCells): DraftCells => draftCells.map(row => [...row]);
|
const cloneDraftCells = (draftCells: DraftCells): DraftCells => draftCells.map(row => [...row]);
|
||||||
const createEmptyDraftCells = (): DraftCells => Array.from({ length: 9 }, () => Array(9).fill(false));
|
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 = () => {
|
const SudokuGame: React.FC = () => {
|
||||||
// 游戏难度
|
// 游戏难度
|
||||||
@@ -429,13 +474,25 @@ const SudokuGame: React.FC = () => {
|
|||||||
for (let r = 0; r < 9; r++) {
|
for (let r = 0; r < 9; r++) {
|
||||||
for (let c = 0; c < 9; c++) {
|
for (let c = 0; c < 9; c++) {
|
||||||
if (getCellHighlightType(r, c) !== 'none') {
|
if (getCellHighlightType(r, c) !== 'none') {
|
||||||
highlighted.add(`${r}-${c}`);
|
highlighted.add(getCellKey(r, c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return highlighted;
|
return highlighted;
|
||||||
}, [board, selectedCell, config, regionMap]);
|
}, [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(() => {
|
const remainingCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -453,11 +510,13 @@ const SudokuGame: React.FC = () => {
|
|||||||
const isFixed = fixedCells[row][col];
|
const isFixed = fixedCells[row][col];
|
||||||
const highlightType = getCellHighlightType(row, col);
|
const highlightType = getCellHighlightType(row, col);
|
||||||
const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col;
|
const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col;
|
||||||
const displayValue = getCellValue(cell);
|
const isConflicting = conflictCells.has(getCellKey(row, col));
|
||||||
|
|
||||||
// 基础背景色 - 根据高亮类型设置不同颜色
|
// 基础背景色 - 根据高亮类型设置不同颜色
|
||||||
let backgroundColor = 'white';
|
let backgroundColor = 'white';
|
||||||
if (isSelected) {
|
if (isConflicting) {
|
||||||
|
backgroundColor = '#fff1f0'; // 冲突数字 - 警告红色
|
||||||
|
} else if (isSelected) {
|
||||||
backgroundColor = '#69b1ff'; // 选中时的深蓝色
|
backgroundColor = '#69b1ff'; // 选中时的深蓝色
|
||||||
} else if (highlightType === 'box') {
|
} else if (highlightType === 'box') {
|
||||||
backgroundColor = '#d9f7be'; // 宫 - 浅绿色
|
backgroundColor = '#d9f7be'; // 宫 - 浅绿色
|
||||||
@@ -495,9 +554,12 @@ const SudokuGame: React.FC = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: `${CELL_FONT_SIZE}px`,
|
fontSize: `${CELL_FONT_SIZE}px`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: isFixed ? '#333' : '#1890ff',
|
color: isConflicting ? '#cf1322' : isFixed ? '#333' : '#1890ff',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
boxShadow: isConflicting ? 'inset 0 0 0 3px #ff4d4f' : undefined,
|
||||||
|
outline: isSelected ? '3px solid #1677ff' : undefined,
|
||||||
|
outlineOffset: '-6px',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,9 +575,25 @@ const SudokuGame: React.FC = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: `${isDraft ? DRAFT_FONT_SIZE : CELL_FONT_SIZE}px`,
|
fontSize: `${isDraft ? DRAFT_FONT_SIZE : CELL_FONT_SIZE}px`,
|
||||||
lineHeight: 1,
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '20px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '20px' }}>
|
||||||
{/* 顶部控制和配置区 */}
|
{/* 顶部控制和配置区 */}
|
||||||
@@ -666,13 +744,19 @@ const SudokuGame: React.FC = () => {
|
|||||||
row.map((cell, colIndex) => {
|
row.map((cell, colIndex) => {
|
||||||
const displayValue = getCellValue(cell);
|
const displayValue = getCellValue(cell);
|
||||||
const cellStyle = getCellStyle(rowIndex, colIndex);
|
const cellStyle = getCellStyle(rowIndex, colIndex);
|
||||||
|
const candidates = availableNumbers[rowIndex][colIndex];
|
||||||
|
const isConflicting = conflictCells.has(getCellKey(rowIndex, colIndex));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${rowIndex}-${colIndex}`}
|
key={`${rowIndex}-${colIndex}`}
|
||||||
onClick={() => handleCellClick(rowIndex, colIndex)}
|
onClick={() => handleCellClick(rowIndex, colIndex)}
|
||||||
style={cellStyle}
|
style={cellStyle}
|
||||||
|
title={isConflicting ? '该数字与同一行、列或区域中的数字冲突' : undefined}
|
||||||
>
|
>
|
||||||
|
{displayValue === null && candidates.length > 0 && (
|
||||||
|
<span style={getAvailableNumbersStyle()}>{candidates.join('')}</span>
|
||||||
|
)}
|
||||||
{displayValue !== null ? (
|
{displayValue !== null ? (
|
||||||
<span style={getCellValueStyle(rowIndex, colIndex)}>{displayValue}</span>
|
<span style={getCellValueStyle(rowIndex, colIndex)}>{displayValue}</span>
|
||||||
) : ''}
|
) : ''}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api, ApiError, authEvents } from '../api/apiClient';
|
import { api, ApiError, authEvents } from '../api/apiClient';
|
||||||
import { API_PATHS } from '../config';
|
import { API_PATHS } from '../config';
|
||||||
import type { TabsProps } from 'antd';
|
import type { TabsProps } from 'antd';
|
||||||
import type { RadioChangeEvent } from 'antd/lib/radio';
|
|
||||||
|
|
||||||
interface Word {
|
interface Word {
|
||||||
|
_id?: string;
|
||||||
id: string;
|
id: string;
|
||||||
word: string;
|
word: string;
|
||||||
translation: string;
|
translation: string;
|
||||||
@@ -56,11 +57,99 @@ interface LeaderboardItem {
|
|||||||
rank: number;
|
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 VocabularyStudy: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wordSets, setWordSets] = useState<WordSet[]>([]);
|
const [wordSets, setWordSets] = useState<WordSet[]>([]);
|
||||||
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
|
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
|
||||||
|
const [loadedWordSetId, setLoadedWordSetId] = useState<string>('');
|
||||||
const [currentWords, setCurrentWords] = useState<Word[]>([]);
|
const [currentWords, setCurrentWords] = useState<Word[]>([]);
|
||||||
const [studyStats, setStudyStats] = useState<StudyStats>({
|
const [studyStats, setStudyStats] = useState<StudyStats>({
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
@@ -76,13 +165,11 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
const [showAnswer, setShowAnswer] = useState(false);
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
const [testStarted, setTestStarted] = useState(false);
|
const [testStarted, setTestStarted] = useState(false);
|
||||||
const [testFinished, setTestFinished] = useState(false);
|
const [testFinished, setTestFinished] = useState(false);
|
||||||
const [testResults, setTestResults] = useState<{
|
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||||
word: string;
|
const [testAttemptId, setTestAttemptId] = useState<string>('');
|
||||||
userAnswer: string;
|
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
|
||||||
correctAnswer: string;
|
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
|
||||||
isCorrect: boolean;
|
const [options, setOptions] = useState<TestOption[]>([]);
|
||||||
}[]>([]);
|
|
||||||
const [options, setOptions] = useState<string[]>([]);
|
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const timeOffsetRef = useRef<number>(0);
|
const timeOffsetRef = useRef<number>(0);
|
||||||
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
||||||
@@ -96,6 +183,9 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
// 使用ref存储当前选中ID,这样可以立即访问
|
// 使用ref存储当前选中ID,这样可以立即访问
|
||||||
const currentWordSetIdRef = useRef<string>('');
|
const currentWordSetIdRef = useRef<string>('');
|
||||||
|
const answerSubmittingRef = useRef(false);
|
||||||
|
const interactionsRef = useRef<InteractionEventPayload[]>([]);
|
||||||
|
const questionShownAtRef = useRef<number>(0);
|
||||||
|
|
||||||
// 测试输入框ref
|
// 测试输入框ref
|
||||||
const inputRef = useRef<any>(null);
|
const inputRef = useRef<any>(null);
|
||||||
@@ -103,7 +193,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 新增 state
|
// 新增 state
|
||||||
const [studyWords, setStudyWords] = useState<Word[]>([]);
|
const [studyWords, setStudyWords] = useState<Word[]>([]);
|
||||||
const [studyIndex, setStudyIndex] = useState(0);
|
const [studyIndex, setStudyIndex] = useState(0);
|
||||||
const [testWords, setTestWords] = useState<Word[]>([]);
|
const [testWords, setTestWords] = useState<TestQuestion[]>([]);
|
||||||
const [testIndex, setTestIndex] = useState(0);
|
const [testIndex, setTestIndex] = useState(0);
|
||||||
|
|
||||||
// 添加声音相关的状态
|
// 添加声音相关的状态
|
||||||
@@ -192,9 +282,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
console.log('获取到的学习单词:', response);
|
console.log('获取到的学习单词:', response);
|
||||||
|
|
||||||
if (response && response.length > 0) {
|
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);
|
setStudyWords(shuffledWords);
|
||||||
setStudyIndex(0);
|
setStudyIndex(0);
|
||||||
|
setLoadedWordSetId(effectiveId);
|
||||||
setActiveTab('study');
|
setActiveTab('study');
|
||||||
message.success(`成功加载 ${shuffledWords.length} 个单词`);
|
message.success(`成功加载 ${shuffledWords.length} 个单词`);
|
||||||
} else {
|
} else {
|
||||||
@@ -241,7 +336,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 处理单词数量变更
|
// 处理单词数量变更
|
||||||
const handleWordCountChange = (value: number | null) => {
|
const handleWordCountChange = (value: number | null) => {
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
setWordCount(value);
|
setWordCount(Math.max(MIN_STUDY_WORD_COUNT, Math.min(value, MAX_STUDY_WORD_COUNT)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -365,54 +460,59 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开始测试
|
// 开始测试会话
|
||||||
const startTest = () => {
|
const startTest = async () => {
|
||||||
if (studyWords.length === 0) {
|
if (studyWords.length === 0) {
|
||||||
message.error('请先加载单词');
|
message.error('请先加载单词');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
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
|
|
||||||
});
|
|
||||||
setActiveTab('test');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成多选题选项
|
try {
|
||||||
const generateMultipleChoiceOptions = (index: number, wordsArr?: Word[]) => {
|
setTestAttemptLoading(true);
|
||||||
const arr = wordsArr || testWords;
|
setTestAttemptId('');
|
||||||
const correctTranslation = arr[index].translation;
|
setPendingAnswers([]);
|
||||||
let availableOptions = arr
|
setTestResults([]);
|
||||||
.filter(w => w.translation !== correctTranslation)
|
const wordIds = studyWords.map(getWordId).filter(Boolean);
|
||||||
.map(w => w.translation);
|
if (wordIds.length === 0) {
|
||||||
|
message.error('单词数据不完整,请重新加载后再试');
|
||||||
// 如果可用选项太少,添加一些假选项
|
return;
|
||||||
if (availableOptions.length < 3) {
|
}
|
||||||
const fakeOptions = [
|
const response = await api.post<TestAttemptResponse>(API_PATHS.VOCABULARY.TEST_ATTEMPT, {
|
||||||
'假选项1', '假选项2', '假选项3', '假选项4', '假选项5'
|
wordSetId: loadedWordSetId || selectedWordSet || currentWordSetIdRef.current,
|
||||||
].filter(opt => opt !== correctTranslation);
|
testType,
|
||||||
availableOptions = [...availableOptions, ...fakeOptions];
|
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,
|
||||||
|
accuracy: 0,
|
||||||
|
startTime: new Date(),
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
} 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;
|
return newArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeAnswer(str: string): string {
|
const getWordId = (word: Word): string => word._id || word.id;
|
||||||
return str
|
|
||||||
.replace(/(/g, '(')
|
const normalizeOptions = (question?: TestQuestion): TestOption[] => {
|
||||||
.replace(/)/g, ')')
|
if (!question?.options) return [];
|
||||||
.replace(/,/g, ',') // 全角逗号转半角
|
return question.options.map(option => {
|
||||||
.replace(/\s+/g, '') // 去除所有空格
|
if (typeof option === 'string') {
|
||||||
.replace(/,/g, '') // 去除所有逗号
|
return { text: option, token: '' };
|
||||||
.replace(/[^\w()]/g, '') // 只保留字母、数字、括号
|
}
|
||||||
.toLowerCase();
|
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 () => {
|
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];
|
const currentQuestion = testWords[testIndex];
|
||||||
let isCorrect = false;
|
if (!currentQuestion) {
|
||||||
let correctAnswer = '';
|
answerSubmittingRef.current = false;
|
||||||
|
return;
|
||||||
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 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,
|
|
||||||
{
|
|
||||||
word: currentWord.word,
|
|
||||||
userAnswer,
|
|
||||||
correctAnswer,
|
|
||||||
isCorrect
|
|
||||||
}
|
|
||||||
];
|
|
||||||
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 {
|
try {
|
||||||
await api.post(API_PATHS.VOCABULARY.WORD_RECORD, {
|
const submittedAnswer = {
|
||||||
wordId: currentWord._id || currentWord.id,
|
questionToken: currentQuestion.questionToken,
|
||||||
isCorrect,
|
userAnswer: normalizedUserAnswer,
|
||||||
testType
|
submittedAt,
|
||||||
|
answerProof,
|
||||||
|
interactions: interactionsRef.current
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.post<SubmitAnswerResponse>(API_PATHS.VOCABULARY.TEST_ANSWER, {
|
||||||
|
attemptId: testAttemptId,
|
||||||
|
...submittedAnswer
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updatedPendingAnswers = [
|
||||||
|
...pendingAnswers,
|
||||||
|
{
|
||||||
|
questionToken: currentQuestion.questionToken,
|
||||||
|
userAnswer: normalizedUserAnswer,
|
||||||
|
submittedAt,
|
||||||
|
answerProof
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setPendingAnswers(updatedPendingAnswers);
|
||||||
|
setTestResults(prev => [...prev, response.result]);
|
||||||
|
setShowAnswer(true);
|
||||||
|
setUserAnswer('');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (testIndex < testWords.length - 1) {
|
||||||
|
const nextIndex = testIndex + 1;
|
||||||
|
setTestIndex(nextIndex);
|
||||||
|
setShowAnswer(false);
|
||||||
|
resetInteractionTrace();
|
||||||
|
answerSubmittingRef.current = false;
|
||||||
|
if (testType === 'audio-to-english') {
|
||||||
|
setTimeout(() => {
|
||||||
|
playWordSound(testWords[nextIndex]?.word || '');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
if (testType === 'multiple-choice') {
|
||||||
|
setOptions(normalizeOptions(testWords[nextIndex]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
answerSubmittingRef.current = false;
|
||||||
|
finishTestWithResults(updatedPendingAnswers);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('记录单词学习结果失败', error);
|
answerSubmittingRef.current = false;
|
||||||
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
message.success('正确!');
|
|
||||||
} else {
|
|
||||||
message.error(`错误! 正确答案是: ${correctAnswer}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowAnswer(true);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (testIndex < testWords.length - 1) {
|
|
||||||
setTestIndex(testIndex + 1);
|
|
||||||
setUserAnswer('');
|
|
||||||
setShowAnswer(false);
|
|
||||||
// 多选题生成选项
|
|
||||||
if (testType === 'multiple-choice') {
|
|
||||||
generateMultipleChoiceOptions(testIndex + 1);
|
|
||||||
}
|
|
||||||
// 听力自动播放
|
|
||||||
if (testType === 'audio-to-english') {
|
|
||||||
setTimeout(() => {
|
|
||||||
playWordSound(testWords[testIndex + 1].word);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 最后一题,使用本地更新的结果,避免异步状态更新问题
|
|
||||||
finishTestWithResults(updatedResults);
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用指定结果完成测试
|
// 使用指定结果完成测试
|
||||||
const finishTestWithResults = async (finalResults: any[]) => {
|
const finishTestWithResults = async (finalAnswers: PendingAnswer[]) => {
|
||||||
try {
|
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('测试完成状态(带结果):', {
|
console.log('测试完成状态(带结果):', {
|
||||||
finalResultsLength: finalResults.length,
|
finalResultsLength: finalAnswers.length,
|
||||||
testWordsLength: testWords.length,
|
testWordsLength: testWords.length,
|
||||||
correctCount,
|
attemptId: testAttemptId,
|
||||||
totalCount,
|
|
||||||
currentStudyStats: studyStats
|
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);
|
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
|
||||||
|
attemptId: testAttemptId
|
||||||
// 提交测试记录 - 使用计算好的最新数据
|
|
||||||
await api.post(API_PATHS.VOCABULARY.TEST_RECORD, {
|
|
||||||
wordSetId: selectedWordSet,
|
|
||||||
testType,
|
|
||||||
stats: updatedStats,
|
|
||||||
results: finalResults // 发送详细的测试结果,包括每道题的回答情况
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
message.success('测试完成,记录已保存');
|
if (savedRecord.invalidated) {
|
||||||
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
|
} else {
|
||||||
|
message.success('测试完成,记录已保存');
|
||||||
|
}
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
message.error(`保存测试记录失败: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
message.error('保存测试记录失败');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 完成测试 - 兼容原来的调用方式
|
// 完成测试 - 兼容原来的调用方式
|
||||||
const finishTest = async () => {
|
const finishTest = async () => {
|
||||||
// 使用当前的测试结果完成测试
|
// 使用当前的测试结果完成测试
|
||||||
await finishTestWithResults([...testResults]);
|
await finishTestWithResults([...pendingAnswers]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重新开始测试
|
// 重新开始测试
|
||||||
const restartTest = () => {
|
const restartTest = () => {
|
||||||
const shuffled = shuffleArray([...testWords]);
|
setTestWords([]);
|
||||||
setTestWords(shuffled);
|
|
||||||
setTestIndex(0);
|
setTestIndex(0);
|
||||||
|
setTestAttemptId('');
|
||||||
setTestStarted(false); // 回到模式选择界面
|
setTestStarted(false); // 回到模式选择界面
|
||||||
setTestFinished(false);
|
setTestFinished(false);
|
||||||
setUserAnswer('');
|
setUserAnswer('');
|
||||||
setShowAnswer(false);
|
setShowAnswer(false);
|
||||||
setTestResults([]);
|
setTestResults([]);
|
||||||
|
setPendingAnswers([]);
|
||||||
|
setOptions([]);
|
||||||
|
interactionsRef.current = [];
|
||||||
|
questionShownAtRef.current = 0;
|
||||||
|
answerSubmittingRef.current = false;
|
||||||
// 重置统计信息
|
// 重置统计信息
|
||||||
setStudyStats({
|
setStudyStats({
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
@@ -704,7 +894,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [testIndex, testStarted, testType, testFinished]);
|
}, [testIndex, testStarted, testType, testFinished]);
|
||||||
|
|
||||||
// 切换题目时自动生成选项
|
// 切换题目时同步多选题选项
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
testStarted &&
|
testStarted &&
|
||||||
@@ -712,10 +902,16 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
testType === 'multiple-choice' &&
|
testType === 'multiple-choice' &&
|
||||||
testWords.length > 0
|
testWords.length > 0
|
||||||
) {
|
) {
|
||||||
generateMultipleChoiceOptions(testIndex);
|
setOptions(normalizeOptions(testWords[testIndex]));
|
||||||
}
|
}
|
||||||
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (testStarted && !testFinished && testWords.length > 0) {
|
||||||
|
resetInteractionTrace();
|
||||||
|
}
|
||||||
|
}, [testIndex, testStarted, testFinished, testWords.length]);
|
||||||
|
|
||||||
// 自动播放第一个听力单词
|
// 自动播放第一个听力单词
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -729,6 +925,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [testStarted, testType, testWords, testFinished, testIndex]);
|
}, [testStarted, testType, testWords, testFinished, testIndex]);
|
||||||
|
|
||||||
|
const handleTabChange = (nextTab: string) => {
|
||||||
|
if (testStarted && !testFinished && activeTab === 'test' && nextTab !== 'test') {
|
||||||
|
message.warning('测试进行中,请完成本次测试后再切换页面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
};
|
||||||
|
|
||||||
// Tab 切换
|
// Tab 切换
|
||||||
const items: TabsProps['items'] = [
|
const items: TabsProps['items'] = [
|
||||||
{
|
{
|
||||||
@@ -794,14 +998,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
<h4>学习单词数量:</h4>
|
<h4>学习单词数量:</h4>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={5}
|
min={MIN_STUDY_WORD_COUNT}
|
||||||
max={100}
|
max={MAX_STUDY_WORD_COUNT}
|
||||||
value={wordCount}
|
value={wordCount}
|
||||||
onChange={handleWordCountChange}
|
onChange={handleWordCountChange}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
<span style={{ marginLeft: 10, color: '#888' }}>
|
<span style={{ marginLeft: 10, color: '#888' }}>
|
||||||
(范围: 5-100个单词)
|
(范围: {MIN_STUDY_WORD_COUNT}-{MAX_STUDY_WORD_COUNT}个单词)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -976,30 +1180,9 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={startTest}
|
||||||
// 只有这里才初始化测试
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={studyWords.length === 0}
|
disabled={studyWords.length === 0}
|
||||||
|
loading={testAttemptLoading}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
|
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
|
||||||
@@ -1169,12 +1352,20 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
{testType === 'multiple-choice' ? (
|
{testType === 'multiple-choice' ? (
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={userAnswer}
|
value={userAnswer}
|
||||||
onChange={e => setUserAnswer(e.target.value)}
|
disabled={showAnswer}
|
||||||
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
>
|
>
|
||||||
{options.map(option => (
|
{options.map(option => (
|
||||||
<Radio key={option} value={option} style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}>
|
<Radio
|
||||||
{option}
|
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>
|
||||||
))}
|
))}
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
@@ -1183,46 +1374,85 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="请输入英文单词"
|
placeholder="请输入英文单词"
|
||||||
value={userAnswer}
|
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}
|
disabled={showAnswer}
|
||||||
style={{ marginBottom: 15 }}
|
style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }}
|
||||||
onPressEnter={e => {
|
onPressEnter={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!userAnswer.trim() || showAnswer) return;
|
||||||
submitAnswer();
|
submitAnswer();
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
size="large"
|
size="large"
|
||||||
onPaste={e => e.preventDefault()}
|
onPaste={e => {
|
||||||
|
recordInteraction({ type: 'paste', value: userAnswer, valueLength: userAnswer.length });
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAnswer && (
|
{showAnswer && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 15,
|
padding: 15,
|
||||||
backgroundColor: testResults[testResults.length - 1]?.isCorrect ? '#f6ffed' : '#fff2f0',
|
backgroundColor: '#f6f8fa',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
marginBottom: 15,
|
marginBottom: 15,
|
||||||
border: `1px solid ${testResults[testResults.length - 1]?.isCorrect ? '#b7eb8f' : '#ffccc7'}`
|
border: '1px solid #d9d9d9'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: testResults[testResults.length - 1]?.isCorrect ? '#52c41a' : '#f5222d',
|
color: '#1677ff',
|
||||||
marginBottom: 5
|
marginBottom: 5
|
||||||
}}>
|
}}>
|
||||||
{testResults[testResults.length - 1]?.isCorrect ? '✓ 回答正确' : '✗ 回答错误'}
|
本题已提交
|
||||||
</div>
|
</div>
|
||||||
<div>正确答案: {testType === 'multiple-choice'
|
<div>正在进入下一题...</div>
|
||||||
? testWords[testIndex].translation
|
|
||||||
: testWords[testIndex].word}
|
|
||||||
</div>
|
|
||||||
<div>你的答案: {userAnswer}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={submitAnswer}
|
onClick={submitAnswer}
|
||||||
disabled={!userAnswer || showAnswer}
|
disabled={!userAnswer.trim() || showAnswer}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
提交答案
|
提交答案
|
||||||
@@ -1516,7 +1746,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs activeKey={activeTab} items={items} onChange={setActiveTab} />
|
<Tabs activeKey={activeTab} items={items} onChange={handleTabChange} />
|
||||||
|
|
||||||
{/* 添加声音设置弹窗 */}
|
{/* 添加声音设置弹窗 */}
|
||||||
{renderVoiceSettings()}
|
{renderVoiceSettings()}
|
||||||
@@ -1570,4 +1800,4 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VocabularyStudy;
|
export default VocabularyStudy;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const API_PATHS = {
|
|||||||
WORD_SETS: '/vocabulary/word-sets',
|
WORD_SETS: '/vocabulary/word-sets',
|
||||||
STUDY_WORDS: '/vocabulary/study-words',
|
STUDY_WORDS: '/vocabulary/study-words',
|
||||||
UPLOAD: '/vocabulary/upload',
|
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',
|
WORD_RECORD: '/vocabulary/word-record',
|
||||||
TEST_RECORD: '/vocabulary/test-record',
|
TEST_RECORD: '/vocabulary/test-record',
|
||||||
STUDY_RECORDS: '/vocabulary/test-records',
|
STUDY_RECORDS: '/vocabulary/test-records',
|
||||||
@@ -117,4 +120,4 @@ export const config = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 默认导出
|
// 默认导出
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user