This commit is contained in:
2026-06-13 10:58:53 +08:00
parent f9311292cc
commit c6b2bb7800
5 changed files with 46 additions and 32 deletions

View File

@@ -12,7 +12,10 @@ 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'; import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
import { buildVocabularySummaryFilters } from '../utils/vocabularyAuditFilters'; import {
applyVocabularySummaryItemFilters,
buildVocabularySummaryFilters
} from '../utils/vocabularyAuditFilters';
const router = express.Router(); const router = express.Router();
const adminController = new AdminController(); const adminController = new AdminController();
@@ -542,7 +545,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
return res.status(400).json({ message: summaryFilters.error }); return res.status(400).json({ message: summaryFilters.error });
} }
const { userMatchStages, baseMatch } = summaryFilters; const { userMatchStages, baseMatch, minUniqueWords } = summaryFilters;
const validPassItems = await VocabularyTestRecord.aggregate([ const validPassItems = await VocabularyTestRecord.aggregate([
{ {
$match: { $match: {
@@ -691,7 +694,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
}); });
}); });
const items = Array.from(itemMap.values()).sort((left, right) => { const items = applyVocabularySummaryItemFilters(Array.from(itemMap.values()), minUniqueWords).sort((left, right) => {
const passedDelta = (right.passedWords || 0) - (left.passedWords || 0); const passedDelta = (right.passedWords || 0) - (left.passedWords || 0);
if (passedDelta !== 0) return passedDelta; if (passedDelta !== 0) return passedDelta;

View File

@@ -1,27 +1,26 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { buildVocabularySummaryFilters } from './vocabularyAuditFilters'; import {
applyVocabularySummaryItemFilters,
buildVocabularySummaryFilters
} from './vocabularyAuditFilters';
const correctWordFilter = buildVocabularySummaryFilters({ minCorrectWords: '40' }); const uniqueWordFilter = buildVocabularySummaryFilters({ minUniqueWords: '40' });
assert.deepEqual(correctWordFilter, { assert.deepEqual(uniqueWordFilter, {
minCorrectWords: 40, minUniqueWords: 40,
userMatchStages: [], userMatchStages: [],
baseMatch: { baseMatch: {}
'stats.correctWords': { $gte: 40 }
}
}); });
const legacyFilter = buildVocabularySummaryFilters({ minWords: '35' }); const legacyFilter = buildVocabularySummaryFilters({ minCorrectWords: '35' });
assert.deepEqual(legacyFilter, { assert.deepEqual(legacyFilter, {
minCorrectWords: 35, minUniqueWords: 35,
userMatchStages: [], userMatchStages: [],
baseMatch: { baseMatch: {}
'stats.correctWords': { $gte: 35 }
}
}); });
const emptyFilter = buildVocabularySummaryFilters({}); const emptyFilter = buildVocabularySummaryFilters({});
assert.deepEqual(emptyFilter, { assert.deepEqual(emptyFilter, {
minCorrectWords: undefined, minUniqueWords: undefined,
userMatchStages: [], userMatchStages: [],
baseMatch: {} baseMatch: {}
}); });
@@ -36,7 +35,14 @@ assert.deepEqual(studentFilter.userMatchStages, [{
} }
}]); }]);
const invalidFilter = buildVocabularySummaryFilters({ minCorrectWords: '4.5' }); const invalidFilter = buildVocabularySummaryFilters({ minUniqueWords: '4.5' });
assert.deepEqual(invalidFilter, { error: '正确单词数量必须是正整数' }); 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'); console.log('vocabularyAuditFilters tests passed');

View File

@@ -18,11 +18,11 @@ const parseOptionalPositiveInt = (value: unknown, fieldName: string) => {
export const buildVocabularySummaryFilters = (query: Record<string, any>) => { export const buildVocabularySummaryFilters = (query: Record<string, any>) => {
const studentNo = String(query.studentNo || '').trim(); const studentNo = String(query.studentNo || '').trim();
const rawMinCorrectWords = query.minCorrectWords ?? query.minWords; const rawMinUniqueWords = query.minUniqueWords ?? query.minCorrectWords ?? query.minWords;
const wordCountResult = parseOptionalPositiveInt(rawMinCorrectWords, '正确单词数量'); const wordCountResult = parseOptionalPositiveInt(rawMinUniqueWords, '去重单词数量');
if ('error' in wordCountResult) return { error: wordCountResult.error }; if ('error' in wordCountResult) return { error: wordCountResult.error };
const minCorrectWords = wordCountResult.value; const minUniqueWords = wordCountResult.value;
const userMatchStages = studentNo const userMatchStages = studentNo
? [{ ? [{
$match: { $match: {
@@ -35,13 +35,18 @@ export const buildVocabularySummaryFilters = (query: Record<string, any>) => {
: []; : [];
const baseMatch: Record<string, any> = {}; const baseMatch: Record<string, any> = {};
if (typeof minCorrectWords === 'number') {
baseMatch['stats.correctWords'] = { $gte: minCorrectWords };
}
return { return {
minCorrectWords, minUniqueWords,
userMatchStages, userMatchStages,
baseMatch baseMatch
}; };
}; };
export const applyVocabularySummaryItemFilters = <T extends { uniqueWords?: number }>(
items: T[],
minUniqueWords?: number
): T[] => {
if (typeof minUniqueWords !== 'number') return items;
return items.filter(item => Number(item.uniqueWords || 0) >= minUniqueWords);
};

View File

@@ -128,7 +128,7 @@ export interface VocabularyAuditQueryParams {
start: string; start: string;
end: string; end: string;
studentNo?: string; studentNo?: string;
minCorrectWords?: string; minUniqueWords?: string;
} }
export interface ClearVocabularyPassSummaryResponse { export interface ClearVocabularyPassSummaryResponse {
@@ -347,7 +347,7 @@ export const adminApi = {
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; studentNo?: string; minCorrectWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => { 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); 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 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 [minCorrectWords, setMinCorrectWords] = useState(''); const [minUniqueWords, setMinUniqueWords] = 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);
@@ -106,7 +106,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
const getFilterParams = () => ({ const getFilterParams = () => ({
studentNo: studentNo.trim() || undefined, studentNo: studentNo.trim() || undefined,
minCorrectWords: minCorrectWords.trim() || undefined minUniqueWords: minUniqueWords.trim() || undefined
}); });
const fetchSummary = async () => { const fetchSummary = async () => {
@@ -571,9 +571,9 @@ const AdminVocabularyScoreAudit: React.FC = () => {
<Input <Input
allowClear allowClear
type="number" type="number"
placeholder="单次最少正确数" placeholder="最少去重单词数"
value={minCorrectWords} value={minUniqueWords}
onChange={event => setMinCorrectWords(event.target.value)} onChange={event => setMinUniqueWords(event.target.value)}
onPressEnter={fetchSummary} onPressEnter={fetchSummary}
style={{ width: 180 }} style={{ width: 180 }}
/> />