more..
This commit is contained in:
@@ -12,6 +12,7 @@ import csv from 'csv-parser';
|
||||
import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
|
||||
import { buildVocabularySummaryFilters } from '../utils/vocabularyAuditFilters';
|
||||
|
||||
const router = express.Router();
|
||||
const adminController = new AdminController();
|
||||
@@ -62,53 +63,6 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
||||
];
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
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 start = new Date(String(source.start || source.startTime || ''));
|
||||
|
||||
42
server/utils/vocabularyAuditFilters.test.ts
Normal file
42
server/utils/vocabularyAuditFilters.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildVocabularySummaryFilters } from './vocabularyAuditFilters';
|
||||
|
||||
const correctWordFilter = buildVocabularySummaryFilters({ minCorrectWords: '40' });
|
||||
assert.deepEqual(correctWordFilter, {
|
||||
minCorrectWords: 40,
|
||||
userMatchStages: [],
|
||||
baseMatch: {
|
||||
'stats.correctWords': { $gte: 40 }
|
||||
}
|
||||
});
|
||||
|
||||
const legacyFilter = buildVocabularySummaryFilters({ minWords: '35' });
|
||||
assert.deepEqual(legacyFilter, {
|
||||
minCorrectWords: 35,
|
||||
userMatchStages: [],
|
||||
baseMatch: {
|
||||
'stats.correctWords': { $gte: 35 }
|
||||
}
|
||||
});
|
||||
|
||||
const emptyFilter = buildVocabularySummaryFilters({});
|
||||
assert.deepEqual(emptyFilter, {
|
||||
minCorrectWords: undefined,
|
||||
userMatchStages: [],
|
||||
baseMatch: {}
|
||||
});
|
||||
|
||||
const studentFilter = buildVocabularySummaryFilters({ studentNo: 'A.12' });
|
||||
assert.deepEqual(studentFilter.userMatchStages, [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: 'A\\.12',
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
const invalidFilter = buildVocabularySummaryFilters({ minCorrectWords: '4.5' });
|
||||
assert.deepEqual(invalidFilter, { error: '正确单词数量必须是正整数' });
|
||||
|
||||
console.log('vocabularyAuditFilters tests passed');
|
||||
47
server/utils/vocabularyAuditFilters.ts
Normal file
47
server/utils/vocabularyAuditFilters.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 rawMinCorrectWords = query.minCorrectWords ?? query.minWords;
|
||||
const wordCountResult = parseOptionalPositiveInt(rawMinCorrectWords, '正确单词数量');
|
||||
if ('error' in wordCountResult) return { error: wordCountResult.error };
|
||||
|
||||
const minCorrectWords = wordCountResult.value;
|
||||
const userMatchStages = studentNo
|
||||
? [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: escapeRegex(studentNo),
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]
|
||||
: [];
|
||||
|
||||
const baseMatch: Record<string, any> = {};
|
||||
if (typeof minCorrectWords === 'number') {
|
||||
baseMatch['stats.correctWords'] = { $gte: minCorrectWords };
|
||||
}
|
||||
|
||||
return {
|
||||
minCorrectWords,
|
||||
userMatchStages,
|
||||
baseMatch
|
||||
};
|
||||
};
|
||||
@@ -128,7 +128,7 @@ export interface VocabularyAuditQueryParams {
|
||||
start: string;
|
||||
end: string;
|
||||
studentNo?: string;
|
||||
minWords?: string;
|
||||
minCorrectWords?: string;
|
||||
}
|
||||
|
||||
export interface ClearVocabularyPassSummaryResponse {
|
||||
@@ -347,7 +347,7 @@ export const adminApi = {
|
||||
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
||||
},
|
||||
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minCorrectWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||
},
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnsw
|
||||
const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
const [range, setRange] = useState<[string, string] | null>(null);
|
||||
const [studentNo, setStudentNo] = useState('');
|
||||
const [minWords, setMinWords] = useState('');
|
||||
const [minCorrectWords, setMinCorrectWords] = useState('');
|
||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
||||
@@ -106,7 +106,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
|
||||
const getFilterParams = () => ({
|
||||
studentNo: studentNo.trim() || undefined,
|
||||
minWords: minWords.trim() || undefined
|
||||
minCorrectWords: minCorrectWords.trim() || undefined
|
||||
});
|
||||
|
||||
const fetchSummary = async () => {
|
||||
@@ -571,9 +571,9 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
<Input
|
||||
allowClear
|
||||
type="number"
|
||||
placeholder="单次最少单词数"
|
||||
value={minWords}
|
||||
onChange={event => setMinWords(event.target.value)}
|
||||
placeholder="单次最少正确数"
|
||||
value={minCorrectWords}
|
||||
onChange={event => setMinCorrectWords(event.target.value)}
|
||||
onPressEnter={fetchSummary}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user