This commit is contained in:
2026-06-13 10:47:59 +08:00
parent b19c811048
commit f9311292cc
5 changed files with 97 additions and 54 deletions

View File

@@ -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 || ''));

View 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');

View 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
};
};

View File

@@ -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);
},

View File

@@ -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 }}
/>