continue fixing
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ build/
|
|||||||
# Other
|
# Other
|
||||||
coverage/
|
coverage/
|
||||||
.codebuddy
|
.codebuddy
|
||||||
|
.codex-docwork/
|
||||||
|
|||||||
@@ -9,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import path from 'path';
|
|||||||
import csv from 'csv-parser';
|
import csv from 'csv-parser';
|
||||||
import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } 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';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const adminController = new AdminController();
|
const adminController = new AdminController();
|
||||||
@@ -60,18 +61,55 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
|||||||
'multipleChoice'
|
'multipleChoice'
|
||||||
];
|
];
|
||||||
|
|
||||||
const INVALIDATING_VOCABULARY_RISK_FLAGS = [
|
|
||||||
'too_fast',
|
|
||||||
'too_slow',
|
|
||||||
'missing_input_value_trace',
|
|
||||||
'input_value_mismatch',
|
|
||||||
'uniform_answer_intervals',
|
|
||||||
'repeated_whole_second_intervals'
|
|
||||||
];
|
|
||||||
|
|
||||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||||
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVocabularySummaryFilters = (query: Record<string, any>) => {
|
||||||
|
const studentNo = String(query.studentNo || '').trim();
|
||||||
|
const wordCountResult = parseOptionalPositiveInt(query.minWords, '单次练习单词数量');
|
||||||
|
if ('error' in wordCountResult) return { error: wordCountResult.error };
|
||||||
|
|
||||||
|
const minWords = wordCountResult.value;
|
||||||
|
const userMatchStages = studentNo
|
||||||
|
? [{
|
||||||
|
$match: {
|
||||||
|
'user.username': {
|
||||||
|
$regex: escapeRegex(studentNo),
|
||||||
|
$options: 'i'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const baseMatch: Record<string, any> = {};
|
||||||
|
if (typeof minWords === 'number') {
|
||||||
|
baseMatch['stats.totalWords'] = { $gte: minWords };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentNo,
|
||||||
|
minWords,
|
||||||
|
userMatchStages,
|
||||||
|
baseMatch
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const parseAdminDateRange = (source: Record<string, any>) => {
|
const parseAdminDateRange = (source: Record<string, any>) => {
|
||||||
const start = new Date(String(source.start || source.startTime || ''));
|
const start = new Date(String(source.start || source.startTime || ''));
|
||||||
const end = new Date(String(source.end || source.endTime || ''));
|
const end = new Date(String(source.end || source.endTime || ''));
|
||||||
@@ -545,20 +583,16 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { start, end } = range as { start: Date; end: Date };
|
const { start, end } = range as { start: Date; end: Date };
|
||||||
const studentNo = String(req.query.studentNo || '').trim();
|
const summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
|
||||||
const userMatchStages = studentNo
|
if ('error' in summaryFilters) {
|
||||||
? [{
|
return res.status(400).json({ message: summaryFilters.error });
|
||||||
$match: {
|
|
||||||
'user.username': {
|
|
||||||
$regex: escapeRegex(studentNo),
|
|
||||||
$options: 'i'
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}]
|
const { userMatchStages, baseMatch } = summaryFilters;
|
||||||
: [];
|
|
||||||
const validPassItems = await VocabularyTestRecord.aggregate([
|
const validPassItems = await VocabularyTestRecord.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
|
...baseMatch,
|
||||||
invalidated: { $ne: true },
|
invalidated: { $ne: true },
|
||||||
'stats.endTime': { $gte: start, $lte: end },
|
'stats.endTime': { $gte: start, $lte: end },
|
||||||
'stats.correctWords': { $gt: 0 }
|
'stats.correctWords': { $gt: 0 }
|
||||||
@@ -610,6 +644,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
|||||||
const recordItems = await VocabularyTestRecord.aggregate([
|
const recordItems = await VocabularyTestRecord.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
|
...baseMatch,
|
||||||
'stats.endTime': { $gte: start, $lte: end }
|
'stats.endTime': { $gte: start, $lte: end }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -737,6 +772,12 @@ router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (re
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { start, end } = range as { start: Date; end: Date };
|
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;
|
const { userId } = req.params;
|
||||||
if (!mongoose.isValidObjectId(userId)) {
|
if (!mongoose.isValidObjectId(userId)) {
|
||||||
return res.status(400).json({ message: '学生ID无效' });
|
return res.status(400).json({ message: '学生ID无效' });
|
||||||
@@ -749,6 +790,7 @@ router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (re
|
|||||||
|
|
||||||
const records = await VocabularyTestRecord.find({
|
const records = await VocabularyTestRecord.find({
|
||||||
user: user._id,
|
user: user._id,
|
||||||
|
...baseMatch,
|
||||||
'stats.endTime': { $gte: start, $lte: end }
|
'stats.endTime': { $gte: start, $lte: end }
|
||||||
})
|
})
|
||||||
.populate('wordSet', 'name description')
|
.populate('wordSet', 'name description')
|
||||||
@@ -971,6 +1013,12 @@ router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { start, end } = range as { start: Date; end: Date };
|
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 || {};
|
const { userId } = req.body || {};
|
||||||
if (!mongoose.isValidObjectId(userId)) {
|
if (!mongoose.isValidObjectId(userId)) {
|
||||||
return res.status(400).json({ message: '学生ID无效' });
|
return res.status(400).json({ message: '学生ID无效' });
|
||||||
@@ -984,6 +1032,7 @@ router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) =
|
|||||||
const records = await VocabularyTestRecord.find({
|
const records = await VocabularyTestRecord.find({
|
||||||
user: user._id,
|
user: user._id,
|
||||||
invalidated: { $ne: true },
|
invalidated: { $ne: true },
|
||||||
|
...baseMatch,
|
||||||
'stats.endTime': { $gte: start, $lte: end },
|
'stats.endTime': { $gte: start, $lte: end },
|
||||||
'stats.correctWords': { $gt: 0 }
|
'stats.correctWords': { $gt: 0 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import csv from 'csv-parser';
|
import csv from 'csv-parser';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
import {
|
||||||
|
INVALIDATING_VOCABULARY_RISK_FLAGS,
|
||||||
|
analyzeVocabularyAttemptResultRisk
|
||||||
|
} from '../utils/vocabularyRisk';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -1196,21 +1200,6 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchRiskFlags = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []);
|
|
||||||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
|
||||||
const shouldInvalidateBatch = batchRiskFlags.length > 0 ||
|
|
||||||
['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag));
|
|
||||||
|
|
||||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
|
||||||
{ _id: attempt._id, user: userId, status: 'active' },
|
|
||||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!claimedAttempt) {
|
|
||||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
|
||||||
}
|
|
||||||
|
|
||||||
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
||||||
const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word');
|
const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word');
|
||||||
const submittedWordMap = new Map<string, string>();
|
const submittedWordMap = new Map<string, string>();
|
||||||
@@ -1229,14 +1218,36 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalWords = evaluatedResults.length;
|
||||||
|
const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length;
|
||||||
|
const batchRiskFlags = [
|
||||||
|
...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])),
|
||||||
|
...analyzeVocabularyAttemptResultRisk({
|
||||||
|
testType: attemptAny.testType,
|
||||||
|
totalWords,
|
||||||
|
correctWords: originallyCorrectWords
|
||||||
|
})
|
||||||
|
];
|
||||||
|
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||||||
|
const shouldInvalidateBatch = INVALIDATING_VOCABULARY_RISK_FLAGS.some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||||||
|
|
||||||
|
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||||
|
{ _id: attempt._id, user: userId, status: 'active' },
|
||||||
|
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!claimedAttempt) {
|
||||||
|
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldInvalidateBatch) {
|
if (!shouldInvalidateBatch) {
|
||||||
for (const result of evaluatedResults) {
|
for (const result of evaluatedResults) {
|
||||||
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWords = evaluatedResults.length;
|
const correctWords = shouldInvalidateBatch ? 0 : originallyCorrectWords;
|
||||||
const correctWords = shouldInvalidateBatch ? 0 : evaluatedResults.filter(result => result.isCorrect).length;
|
|
||||||
const verifiedStats = {
|
const verifiedStats = {
|
||||||
totalWords,
|
totalWords,
|
||||||
correctWords,
|
correctWords,
|
||||||
|
|||||||
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');
|
||||||
51
server/utils/vocabularyRisk.ts
Normal file
51
server/utils/vocabularyRisk.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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[] = [
|
||||||
|
'too_fast',
|
||||||
|
'too_slow',
|
||||||
|
'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 [];
|
||||||
|
};
|
||||||
@@ -124,6 +124,13 @@ export interface VocabularyPassSummaryResponse {
|
|||||||
items: VocabularyPassSummaryItem[];
|
items: VocabularyPassSummaryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VocabularyAuditQueryParams {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
studentNo?: string;
|
||||||
|
minWords?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClearVocabularyPassSummaryResponse {
|
export interface ClearVocabularyPassSummaryResponse {
|
||||||
message: string;
|
message: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -336,15 +343,15 @@ export const adminApi = {
|
|||||||
return api.put('/api/vocabulary/words', { words });
|
return api.put('/api/vocabulary/words', { words });
|
||||||
},
|
},
|
||||||
|
|
||||||
getVocabularyPassSummary: async (params: { start: string; end: string; studentNo?: string }): Promise<VocabularyPassSummaryResponse> => {
|
getVocabularyPassSummary: async (params: VocabularyAuditQueryParams): Promise<VocabularyPassSummaryResponse> => {
|
||||||
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||||
},
|
},
|
||||||
|
|
||||||
getVocabularyPassSummaryDetails: async (params: { userId: string; start: string; end: string }): Promise<VocabularyPassSummaryDetailsResponse> => {
|
getVocabularyPassSummaryDetails: async (params: { userId: string } & VocabularyAuditQueryParams): Promise<VocabularyPassSummaryDetailsResponse> => {
|
||||||
const { userId, ...query } = params;
|
const { userId, ...query } = params;
|
||||||
return api.get<VocabularyPassSummaryDetailsResponse>(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query });
|
return api.get<VocabularyPassSummaryDetailsResponse>(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnsw
|
|||||||
const AdminVocabularyScoreAudit: React.FC = () => {
|
const AdminVocabularyScoreAudit: React.FC = () => {
|
||||||
const [range, setRange] = useState<[string, string] | null>(null);
|
const [range, setRange] = useState<[string, string] | null>(null);
|
||||||
const [studentNo, setStudentNo] = useState('');
|
const [studentNo, setStudentNo] = useState('');
|
||||||
|
const [minWords, setMinWords] = useState('');
|
||||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
||||||
@@ -103,6 +104,11 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
const [details, setDetails] = useState<VocabularyPassSummaryDetailsResponse | null>(null);
|
const [details, setDetails] = useState<VocabularyPassSummaryDetailsResponse | null>(null);
|
||||||
const [approvingRecordId, setApprovingRecordId] = useState<string | null>(null);
|
const [approvingRecordId, setApprovingRecordId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getFilterParams = () => ({
|
||||||
|
studentNo: studentNo.trim() || undefined,
|
||||||
|
minWords: minWords.trim() || undefined
|
||||||
|
});
|
||||||
|
|
||||||
const fetchSummary = async () => {
|
const fetchSummary = async () => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
message.warning('请先选择开始和结束时间');
|
message.warning('请先选择开始和结束时间');
|
||||||
@@ -114,7 +120,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
const response = await adminApi.getVocabularyPassSummary({
|
const response = await adminApi.getVocabularyPassSummary({
|
||||||
start: range[0],
|
start: range[0],
|
||||||
end: range[1],
|
end: range[1],
|
||||||
studentNo: studentNo.trim() || undefined
|
...getFilterParams()
|
||||||
});
|
});
|
||||||
setSummary(response);
|
setSummary(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -133,7 +139,8 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
const response = await adminApi.getVocabularyPassSummaryDetails({
|
const response = await adminApi.getVocabularyPassSummaryDetails({
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
start: range[0],
|
start: range[0],
|
||||||
end: range[1]
|
end: range[1],
|
||||||
|
...getFilterParams()
|
||||||
});
|
});
|
||||||
setDetails(response);
|
setDetails(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,7 +188,8 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
const response = await adminApi.clearVocabularyPassSummary({
|
const response = await adminApi.clearVocabularyPassSummary({
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
start: range[0],
|
start: range[0],
|
||||||
end: range[1]
|
end: range[1],
|
||||||
|
...getFilterParams()
|
||||||
});
|
});
|
||||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||||
await fetchSummary();
|
await fetchSummary();
|
||||||
@@ -560,6 +568,15 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
onPressEnter={fetchSummary}
|
onPressEnter={fetchSummary}
|
||||||
style={{ width: 220 }}
|
style={{ width: 220 }}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
type="number"
|
||||||
|
placeholder="单次最少单词数"
|
||||||
|
value={minWords}
|
||||||
|
onChange={event => setMinWords(event.target.value)}
|
||||||
|
onPressEnter={fetchSummary}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={fetchSummary}>
|
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={fetchSummary}>
|
||||||
查询
|
查询
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user