修改-试卷总分设置项
This commit is contained in:
@@ -44,149 +44,14 @@ export class QuizController {
|
||||
});
|
||||
}
|
||||
|
||||
let questions: Question[] = [];
|
||||
const remainingScore = subject.totalScore;
|
||||
|
||||
// 构建包含所有类别的数组,根据比重重复对应次数
|
||||
const allCategories: string[] = [];
|
||||
for (const [category, catRatio] of Object.entries(subject.categoryRatios)) {
|
||||
if (catRatio > 0) {
|
||||
// 根据比重计算该类别应占的总题目数比例
|
||||
const count = Math.round((catRatio / 100) * 100); // 放大100倍避免小数问题
|
||||
for (let i = 0; i < count; i++) {
|
||||
allCategories.push(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保总题目数至少为1
|
||||
if (allCategories.length === 0) {
|
||||
allCategories.push('通用');
|
||||
}
|
||||
|
||||
// 按题型分配题目
|
||||
for (const [type, typeRatio] of Object.entries(subject.typeRatios)) {
|
||||
if (typeRatio <= 0) continue;
|
||||
|
||||
// 计算该题型应占的总分
|
||||
const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore);
|
||||
let currentTypeScore = 0;
|
||||
let typeQuestions: Question[] = [];
|
||||
|
||||
// 尝试获取足够分数的题目
|
||||
while (currentTypeScore < targetTypeScore) {
|
||||
// 随机选择一个类别
|
||||
const randomCategory = allCategories[Math.floor(Math.random() * allCategories.length)];
|
||||
|
||||
// 获取该类型和类别的随机题目
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(
|
||||
type as any,
|
||||
10, // 一次获取多个,提高效率
|
||||
[randomCategory]
|
||||
);
|
||||
|
||||
if (availableQuestions.length === 0) {
|
||||
break; // 该类型/类别没有题目,跳过
|
||||
}
|
||||
|
||||
// 过滤掉已选题目
|
||||
const availableUnselected = availableQuestions.filter(q =>
|
||||
!questions.some(selected => selected.id === q.id)
|
||||
);
|
||||
|
||||
if (availableUnselected.length === 0) {
|
||||
break; // 没有可用的新题目了
|
||||
}
|
||||
|
||||
// 选择分数最接近剩余需求的题目
|
||||
const remainingForType = targetTypeScore - currentTypeScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingForType - prev.score);
|
||||
const currDiff = Math.abs(remainingForType - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
// 添加到题型题目列表
|
||||
typeQuestions.push(selectedQuestion);
|
||||
currentTypeScore += selectedQuestion.score;
|
||||
|
||||
// 防止无限循环
|
||||
if (typeQuestions.length > 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
questions.push(...typeQuestions);
|
||||
}
|
||||
|
||||
// 如果总分不足,尝试补充题目
|
||||
let totalScore = questions.reduce((sum, q) => sum + q.score, 0);
|
||||
while (totalScore < subject.totalScore) {
|
||||
// 获取所有类型的随机题目
|
||||
const allTypes = Object.keys(subject.typeRatios).filter(type => subject.typeRatios[type as keyof typeof subject.typeRatios] > 0);
|
||||
if (allTypes.length === 0) break;
|
||||
|
||||
const randomType = allTypes[Math.floor(Math.random() * allTypes.length)];
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(
|
||||
randomType as any,
|
||||
10,
|
||||
allCategories
|
||||
);
|
||||
|
||||
if (availableQuestions.length === 0) break;
|
||||
|
||||
// 过滤掉已选题目
|
||||
const availableUnselected = availableQuestions.filter(q =>
|
||||
!questions.some(selected => selected.id === q.id)
|
||||
);
|
||||
|
||||
if (availableUnselected.length === 0) break;
|
||||
|
||||
// 选择分数最接近剩余需求的题目
|
||||
const remainingScore = subject.totalScore - totalScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingScore - prev.score);
|
||||
const currDiff = Math.abs(remainingScore - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
questions.push(selectedQuestion);
|
||||
totalScore += selectedQuestion.score;
|
||||
|
||||
// 防止无限循环
|
||||
if (questions.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果总分超过,尝试移除一些题目
|
||||
while (totalScore > subject.totalScore) {
|
||||
// 找到最接近剩余差值的题目
|
||||
const excessScore = totalScore - subject.totalScore;
|
||||
let closestIndex = -1;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const diff = Math.abs(questions[i].score - excessScore);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestIndex === -1) break;
|
||||
|
||||
// 移除该题目
|
||||
totalScore -= questions[closestIndex].score;
|
||||
questions.splice(closestIndex, 1);
|
||||
}
|
||||
const result = await ExamSubjectModel.generateQuizQuestions(subject);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimit: subject.timeLimitMinutes
|
||||
questions: result.questions,
|
||||
totalScore: result.totalScore,
|
||||
timeLimit: result.timeLimitMinutes
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -33,19 +33,247 @@ const parseJson = <T>(value: string, fallback: T): T => {
|
||||
}
|
||||
};
|
||||
|
||||
const validateRatiosSum100 = (ratios: Record<string, number>, label: string) => {
|
||||
const values = Object.values(ratios);
|
||||
const validateNonNegativeNumbers = (valuesMap: Record<string, number>, label: string) => {
|
||||
const values = Object.values(valuesMap);
|
||||
if (values.length === 0) throw new Error(`${label}不能为空`);
|
||||
if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) {
|
||||
throw new Error(`${label}必须是非负数字`);
|
||||
}
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
if (Math.abs(sum - 100) > 0.01) {
|
||||
throw new Error(`${label}总和必须为100`);
|
||||
if (!values.some((v) => v > 0)) {
|
||||
throw new Error(`${label}至少需要一个大于0的配置`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateIntegerCounts = (valuesMap: Record<string, number>, label: string) => {
|
||||
validateNonNegativeNumbers(valuesMap, label);
|
||||
if (Object.values(valuesMap).some((v) => !Number.isInteger(v))) {
|
||||
throw new Error(`${label}必须为整数`);
|
||||
}
|
||||
};
|
||||
|
||||
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
|
||||
|
||||
const isRatioMode = (valuesMap: Record<string, number>) => {
|
||||
const values = Object.values(valuesMap);
|
||||
if (values.length === 0) return false;
|
||||
if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) return false;
|
||||
if (values.some((v) => v > 100)) return false;
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
return Math.abs(sum - 100) <= 0.01;
|
||||
};
|
||||
|
||||
export class ExamSubjectModel {
|
||||
static async generateQuizQuestions(subject: ExamSubject): Promise<{
|
||||
questions: Awaited<ReturnType<typeof import('./question').QuestionModel.getRandomQuestions>>;
|
||||
totalScore: number;
|
||||
timeLimitMinutes: number;
|
||||
}> {
|
||||
const { QuestionModel } = await import('./question');
|
||||
let questions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
|
||||
|
||||
const typeRatioMode = isRatioMode(subject.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(subject.categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
throw new Error('题型配置与题目类别配置必须同为比例或同为数量');
|
||||
}
|
||||
|
||||
const categoryEntries = Object.entries(subject.categoryRatios).filter(([, v]) => v > 0);
|
||||
const categories = categoryEntries.map(([c]) => c);
|
||||
if (categories.length === 0) categories.push('通用');
|
||||
|
||||
if (typeRatioMode) {
|
||||
const weightedCategories: string[] = [];
|
||||
for (const [category, ratio] of categoryEntries) {
|
||||
const weight = Math.min(100, Math.max(0, Math.round(ratio)));
|
||||
for (let i = 0; i < weight; i++) weightedCategories.push(category);
|
||||
}
|
||||
if (weightedCategories.length === 0) {
|
||||
weightedCategories.push('通用');
|
||||
}
|
||||
|
||||
for (const [type, typeRatio] of Object.entries(subject.typeRatios)) {
|
||||
if (typeRatio <= 0) continue;
|
||||
|
||||
const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore);
|
||||
let currentTypeScore = 0;
|
||||
let typeQuestions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
|
||||
|
||||
while (currentTypeScore < targetTypeScore) {
|
||||
const randomCategory = weightedCategories[Math.floor(Math.random() * weightedCategories.length)];
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(type as any, 10, [randomCategory]);
|
||||
|
||||
if (availableQuestions.length === 0) break;
|
||||
|
||||
const availableUnselected = availableQuestions.filter((q) => !questions.some((selected) => selected.id === q.id));
|
||||
if (availableUnselected.length === 0) break;
|
||||
|
||||
const remainingForType = targetTypeScore - currentTypeScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingForType - prev.score);
|
||||
const currDiff = Math.abs(remainingForType - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
typeQuestions.push(selectedQuestion);
|
||||
currentTypeScore += selectedQuestion.score;
|
||||
|
||||
if (typeQuestions.length > 100) break;
|
||||
}
|
||||
|
||||
questions.push(...typeQuestions);
|
||||
}
|
||||
let totalScore = questions.reduce((sum, q) => sum + q.score, 0);
|
||||
while (totalScore < subject.totalScore) {
|
||||
const allTypes = Object.keys(subject.typeRatios).filter((t) => (subject.typeRatios as any)[t] > 0);
|
||||
if (allTypes.length === 0) break;
|
||||
|
||||
const randomType = allTypes[Math.floor(Math.random() * allTypes.length)];
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(randomType as any, 10, categories);
|
||||
if (availableQuestions.length === 0) break;
|
||||
|
||||
const availableUnselected = availableQuestions.filter((q) => !questions.some((selected) => selected.id === q.id));
|
||||
if (availableUnselected.length === 0) break;
|
||||
|
||||
const remainingScore = subject.totalScore - totalScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingScore - prev.score);
|
||||
const currDiff = Math.abs(remainingScore - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
questions.push(selectedQuestion);
|
||||
totalScore += selectedQuestion.score;
|
||||
|
||||
if (questions.length > 200) break;
|
||||
}
|
||||
|
||||
while (totalScore > subject.totalScore) {
|
||||
const excessScore = totalScore - subject.totalScore;
|
||||
let closestIndex = -1;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const diff = Math.abs(questions[i].score - excessScore);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestIndex === -1) break;
|
||||
totalScore -= questions[closestIndex].score;
|
||||
questions.splice(closestIndex, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimitMinutes: subject.timeLimitMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
const categoryRemaining = new Map<string, number>();
|
||||
for (const [category, count] of categoryEntries) {
|
||||
const c = Math.max(0, Math.round(count));
|
||||
if (c > 0) categoryRemaining.set(category, c);
|
||||
}
|
||||
if (categoryRemaining.size === 0) {
|
||||
categoryRemaining.set('通用', sumValues(subject.typeRatios as any));
|
||||
if (!categories.includes('通用')) categories.push('通用');
|
||||
}
|
||||
|
||||
const pickCategory = (exclude?: Set<string>) => {
|
||||
const entries = Array.from(categoryRemaining.entries()).filter(([, c]) => c > 0);
|
||||
const usable = exclude ? entries.filter(([k]) => !exclude.has(k)) : entries;
|
||||
if (usable.length === 0) return null;
|
||||
const total = usable.reduce((s, [, c]) => s + c, 0);
|
||||
let r = Math.floor(Math.random() * total);
|
||||
for (const [k, c] of usable) {
|
||||
if (r < c) return k;
|
||||
r -= c;
|
||||
}
|
||||
return usable[0][0];
|
||||
};
|
||||
|
||||
let currentTotal = 0;
|
||||
let remainingSlots = sumValues(Object.fromEntries(categoryRemaining) as any);
|
||||
|
||||
for (const [type, rawCount] of Object.entries(subject.typeRatios)) {
|
||||
const targetCount = Math.max(0, Math.round(rawCount));
|
||||
if (targetCount <= 0) continue;
|
||||
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const tried = new Set<string>();
|
||||
let selected = null as null | Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>>[number];
|
||||
let selectedCategory: string | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < categoryRemaining.size; attempt++) {
|
||||
const category = pickCategory(tried);
|
||||
if (!category) break;
|
||||
tried.add(category);
|
||||
|
||||
const desiredAvg = remainingSlots > 0 ? (subject.totalScore - currentTotal) / remainingSlots : 0;
|
||||
const fetched = await QuestionModel.getRandomQuestions(type as any, 30, [category]);
|
||||
const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id));
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
selected = candidates.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(desiredAvg - prev.score);
|
||||
const currDiff = Math.abs(desiredAvg - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
selectedCategory = category;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!selected || !selectedCategory) {
|
||||
throw new Error('题库中缺少满足当前配置的题目');
|
||||
}
|
||||
|
||||
questions.push(selected);
|
||||
currentTotal += selected.score;
|
||||
categoryRemaining.set(selectedCategory, (categoryRemaining.get(selectedCategory) || 0) - 1);
|
||||
remainingSlots -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
let totalScore = questions.reduce((sum, q) => sum + q.score, 0);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const diff = subject.totalScore - totalScore;
|
||||
if (diff === 0) break;
|
||||
if (questions.length === 0) break;
|
||||
|
||||
const idx = Math.floor(Math.random() * questions.length);
|
||||
const base = questions[idx];
|
||||
const fetched = await QuestionModel.getRandomQuestions(base.type as any, 30, [base.category]);
|
||||
const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id));
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
const currentBest = Math.abs(diff);
|
||||
let best = null as null | (typeof candidates)[number];
|
||||
let bestAbs = currentBest;
|
||||
|
||||
for (const cand of candidates) {
|
||||
const nextTotal = totalScore - base.score + cand.score;
|
||||
const abs = Math.abs(subject.totalScore - nextTotal);
|
||||
if (abs < bestAbs) {
|
||||
bestAbs = abs;
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) continue;
|
||||
questions[idx] = best;
|
||||
totalScore = totalScore - base.score + best.score;
|
||||
}
|
||||
|
||||
return {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimitMinutes: subject.timeLimitMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
static async findAll(): Promise<ExamSubject[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
@@ -128,9 +356,24 @@ export class ExamSubjectModel {
|
||||
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||||
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||||
|
||||
validateRatiosSum100(data.typeRatios, '题型比重');
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||||
validateRatiosSum100(categoryRatios, '题目类别比重');
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
throw new Error('题型配置与题目类别配置必须同为比例或同为数量');
|
||||
}
|
||||
if (typeRatioMode) {
|
||||
validateNonNegativeNumbers(data.typeRatios as any, '题型配置');
|
||||
validateNonNegativeNumbers(categoryRatios, '题目类别配置');
|
||||
} else {
|
||||
validateIntegerCounts(data.typeRatios as any, '题型数量配置');
|
||||
validateIntegerCounts(categoryRatios, '题目类别数量配置');
|
||||
const typeTotal = sumValues(data.typeRatios as any);
|
||||
const categoryTotal = sumValues(categoryRatios);
|
||||
if (typeTotal !== categoryTotal) {
|
||||
throw new Error('题型数量总和必须等于题目类别数量总和');
|
||||
}
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const sql = `
|
||||
@@ -175,9 +418,24 @@ export class ExamSubjectModel {
|
||||
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||||
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||||
|
||||
validateRatiosSum100(data.typeRatios, '题型比重');
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||||
validateRatiosSum100(categoryRatios, '题目类别比重');
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
throw new Error('题型配置与题目类别配置必须同为比例或同为数量');
|
||||
}
|
||||
if (typeRatioMode) {
|
||||
validateNonNegativeNumbers(data.typeRatios as any, '题型配置');
|
||||
validateNonNegativeNumbers(categoryRatios, '题目类别配置');
|
||||
} else {
|
||||
validateIntegerCounts(data.typeRatios as any, '题型数量配置');
|
||||
validateIntegerCounts(categoryRatios, '题目类别数量配置');
|
||||
const typeTotal = sumValues(data.typeRatios as any);
|
||||
const categoryTotal = sumValues(categoryRatios);
|
||||
if (typeTotal !== categoryTotal) {
|
||||
throw new Error('题型数量总和必须等于题目类别数量总和');
|
||||
}
|
||||
}
|
||||
|
||||
const sql = `
|
||||
UPDATE exam_subjects
|
||||
|
||||
@@ -577,148 +577,8 @@ export class ExamTaskModel {
|
||||
const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId));
|
||||
if (!subject) throw new Error('科目不存在');
|
||||
|
||||
const { QuestionModel } = await import('./question');
|
||||
let questions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
|
||||
|
||||
// 构建包含所有类别的数组,根据比重重复对应次数
|
||||
const allCategories: string[] = [];
|
||||
for (const [category, catRatio] of Object.entries(subject.categoryRatios)) {
|
||||
if (catRatio > 0) {
|
||||
// 根据比重计算该类别应占的总题目数比例
|
||||
const count = Math.round((catRatio / 100) * 100); // 放大100倍避免小数问题
|
||||
for (let i = 0; i < count; i++) {
|
||||
allCategories.push(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保总题目数至少为1
|
||||
if (allCategories.length === 0) {
|
||||
allCategories.push('通用');
|
||||
}
|
||||
|
||||
// 按题型分配题目
|
||||
for (const [type, typeRatio] of Object.entries(subject.typeRatios)) {
|
||||
if (typeRatio <= 0) continue;
|
||||
|
||||
// 计算该题型应占的总分
|
||||
const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore);
|
||||
let currentTypeScore = 0;
|
||||
let typeQuestions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
|
||||
|
||||
// 尝试获取足够分数的题目
|
||||
while (currentTypeScore < targetTypeScore) {
|
||||
// 随机选择一个类别
|
||||
const randomCategory = allCategories[Math.floor(Math.random() * allCategories.length)];
|
||||
|
||||
// 获取该类型和类别的随机题目
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(
|
||||
type as any,
|
||||
10, // 一次获取多个,提高效率
|
||||
[randomCategory]
|
||||
);
|
||||
|
||||
if (availableQuestions.length === 0) {
|
||||
break; // 该类型/类别没有题目,跳过
|
||||
}
|
||||
|
||||
// 过滤掉已选题目
|
||||
const availableUnselected = availableQuestions.filter(q =>
|
||||
!questions.some(selected => selected.id === q.id)
|
||||
);
|
||||
|
||||
if (availableUnselected.length === 0) {
|
||||
break; // 没有可用的新题目了
|
||||
}
|
||||
|
||||
// 选择分数最接近剩余需求的题目
|
||||
const remainingForType = targetTypeScore - currentTypeScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingForType - prev.score);
|
||||
const currDiff = Math.abs(remainingForType - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
// 添加到题型题目列表
|
||||
typeQuestions.push(selectedQuestion);
|
||||
currentTypeScore += selectedQuestion.score;
|
||||
|
||||
// 防止无限循环
|
||||
if (typeQuestions.length > 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
questions.push(...typeQuestions);
|
||||
}
|
||||
|
||||
// 如果总分不足,尝试补充题目
|
||||
let totalScore = questions.reduce((sum, q) => sum + q.score, 0);
|
||||
while (totalScore < subject.totalScore) {
|
||||
// 获取所有类型的随机题目
|
||||
const allTypes = Object.keys(subject.typeRatios).filter(type => subject.typeRatios[type as keyof typeof subject.typeRatios] > 0);
|
||||
if (allTypes.length === 0) break;
|
||||
|
||||
const randomType = allTypes[Math.floor(Math.random() * allTypes.length)];
|
||||
const availableQuestions = await QuestionModel.getRandomQuestions(
|
||||
randomType as any,
|
||||
10,
|
||||
allCategories
|
||||
);
|
||||
|
||||
if (availableQuestions.length === 0) break;
|
||||
|
||||
// 过滤掉已选题目
|
||||
const availableUnselected = availableQuestions.filter(q =>
|
||||
!questions.some(selected => selected.id === q.id)
|
||||
);
|
||||
|
||||
if (availableUnselected.length === 0) break;
|
||||
|
||||
// 选择分数最接近剩余需求的题目
|
||||
const remainingScore = subject.totalScore - totalScore;
|
||||
const selectedQuestion = availableUnselected.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(remainingScore - prev.score);
|
||||
const currDiff = Math.abs(remainingScore - curr.score);
|
||||
return currDiff < prevDiff ? curr : prev;
|
||||
});
|
||||
|
||||
questions.push(selectedQuestion);
|
||||
totalScore += selectedQuestion.score;
|
||||
|
||||
// 防止无限循环
|
||||
if (questions.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果总分超过,尝试移除一些题目
|
||||
while (totalScore > subject.totalScore) {
|
||||
// 找到最接近剩余差值的题目
|
||||
const excessScore = totalScore - subject.totalScore;
|
||||
let closestIndex = -1;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const diff = Math.abs(questions[i].score - excessScore);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestIndex === -1) break;
|
||||
|
||||
// 移除该题目
|
||||
totalScore -= questions[closestIndex].score;
|
||||
questions.splice(closestIndex, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimitMinutes: subject.timeLimitMinutes
|
||||
};
|
||||
const { ExamSubjectModel } = await import('./examSubject');
|
||||
return await ExamSubjectModel.generateQuizQuestions(subject);
|
||||
}
|
||||
|
||||
static async getUserTasks(userId: string): Promise<UserExamTask[]> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col, Tag } from 'antd';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Progress, Row, Col, Tag, Radio } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
@@ -49,6 +49,7 @@ const ExamSubjectPage = () => {
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [currentSubject, setCurrentSubject] = useState<ExamSubject | null>(null);
|
||||
// 引入状态管理来跟踪实时的比例配置
|
||||
const [configMode, setConfigMode] = useState<'ratio' | 'count'>('count');
|
||||
const [typeRatios, setTypeRatios] = useState<Record<string, number>>({
|
||||
single: 40,
|
||||
multiple: 30,
|
||||
@@ -59,6 +60,49 @@ const ExamSubjectPage = () => {
|
||||
通用: 100
|
||||
});
|
||||
|
||||
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
|
||||
|
||||
const isRatioMode = (valuesMap: Record<string, number>) => {
|
||||
const values = Object.values(valuesMap);
|
||||
if (values.length === 0) return false;
|
||||
if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) return false;
|
||||
if (values.some((v) => v > 100)) return false;
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
return Math.abs(sum - 100) <= 0.01;
|
||||
};
|
||||
|
||||
const ensureRatioSum100 = (valuesMap: Record<string, number>) => {
|
||||
const entries = Object.entries(valuesMap);
|
||||
if (entries.length === 0) return valuesMap;
|
||||
const result: Record<string, number> = {};
|
||||
for (const [k, v] of entries) result[k] = Math.max(0, Math.min(100, Math.round((Number(v) || 0) * 100) / 100));
|
||||
const keys = Object.keys(result);
|
||||
const lastKey = keys[keys.length - 1];
|
||||
const sumWithoutLast = keys.slice(0, -1).reduce((s, k) => s + (result[k] ?? 0), 0);
|
||||
result[lastKey] = Math.max(0, Math.min(100, Math.round((100 - sumWithoutLast) * 100) / 100));
|
||||
return result;
|
||||
};
|
||||
|
||||
const convertCountsToRatios = (valuesMap: Record<string, number>) => {
|
||||
const entries = Object.entries(valuesMap);
|
||||
if (entries.length === 0) return valuesMap;
|
||||
const total = entries.reduce((s, [, v]) => s + Math.max(0, Number(v) || 0), 0);
|
||||
if (total <= 0) return ensureRatioSum100(Object.fromEntries(entries.map(([k]) => [k, 0])) as any);
|
||||
const ratios: Record<string, number> = {};
|
||||
for (const [k, v] of entries) {
|
||||
ratios[k] = Math.round(((Math.max(0, Number(v) || 0) / total) * 100) * 100) / 100;
|
||||
}
|
||||
return ensureRatioSum100(ratios);
|
||||
};
|
||||
|
||||
const convertRatiosToCounts = (valuesMap: Record<string, number>) => {
|
||||
const result: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(valuesMap)) {
|
||||
result[k] = Math.max(0, Math.round(Number(v) || 0));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 题型配置
|
||||
const questionTypes = [
|
||||
{ key: 'single', label: '单选题', color: '#52c41a' },
|
||||
@@ -91,10 +135,11 @@ const ExamSubjectPage = () => {
|
||||
setEditingSubject(null);
|
||||
form.resetFields();
|
||||
// 设置默认值
|
||||
const defaultTypeRatios = { single: 40, multiple: 30, judgment: 20, text: 10 };
|
||||
const defaultCategoryRatios: Record<string, number> = { 通用: 100 };
|
||||
const defaultTypeRatios = { single: 4, multiple: 3, judgment: 2, text: 1 };
|
||||
const defaultCategoryRatios: Record<string, number> = { 通用: 10 };
|
||||
|
||||
// 初始化状态
|
||||
setConfigMode('count');
|
||||
setTypeRatios(defaultTypeRatios);
|
||||
setCategoryRatios(defaultCategoryRatios);
|
||||
|
||||
@@ -121,6 +166,8 @@ const ExamSubjectPage = () => {
|
||||
}
|
||||
|
||||
// 确保状态与表单值正确同步
|
||||
const inferredMode = isRatioMode(initialTypeRatios) && isRatioMode(initialCategoryRatios) ? 'ratio' : 'count';
|
||||
setConfigMode(inferredMode);
|
||||
setTypeRatios(initialTypeRatios);
|
||||
setCategoryRatios(initialCategoryRatios);
|
||||
|
||||
@@ -146,22 +193,44 @@ const ExamSubjectPage = () => {
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
// 首先验证状态中的值,确保总和为100%
|
||||
|
||||
// 验证题型比重总和(使用状态中的值,允许±0.01的精度误差)
|
||||
const typeTotal = Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0);
|
||||
console.log('题型比重总和(状态):', typeTotal);
|
||||
if (Math.abs(typeTotal - 100) > 0.01) {
|
||||
message.error('题型比重总和必须为100%');
|
||||
return;
|
||||
}
|
||||
const typeTotal = sumValues(typeRatios);
|
||||
const categoryTotal = sumValues(categoryRatios);
|
||||
|
||||
// 验证类别比重总和(使用状态中的值,允许±0.01的精度误差)
|
||||
const categoryTotal = Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0);
|
||||
console.log('类别比重总和(状态):', categoryTotal);
|
||||
if (Math.abs(categoryTotal - 100) > 0.01) {
|
||||
message.error('题目类别比重总和必须为100%');
|
||||
return;
|
||||
if (configMode === 'ratio') {
|
||||
console.log('题型比重总和(状态):', typeTotal);
|
||||
if (Math.abs(typeTotal - 100) > 0.01) {
|
||||
message.error('题型比重总和必须为100%');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('类别比重总和(状态):', categoryTotal);
|
||||
if (Math.abs(categoryTotal - 100) > 0.01) {
|
||||
message.error('题目类别比重总和必须为100%');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const typeValues = Object.values(typeRatios);
|
||||
const categoryValues = Object.values(categoryRatios);
|
||||
if (typeValues.length === 0 || categoryValues.length === 0) {
|
||||
message.error('题型数量与题目类别数量不能为空');
|
||||
return;
|
||||
}
|
||||
if (typeValues.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0 || !Number.isInteger(v))) {
|
||||
message.error('题型数量必须为非负整数');
|
||||
return;
|
||||
}
|
||||
if (categoryValues.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0 || !Number.isInteger(v))) {
|
||||
message.error('题目类别数量必须为非负整数');
|
||||
return;
|
||||
}
|
||||
if (!typeValues.some((v) => v > 0) || !categoryValues.some((v) => v > 0)) {
|
||||
message.error('题型数量与题目类别数量至少需要一个大于0的配置');
|
||||
return;
|
||||
}
|
||||
if (typeTotal !== categoryTotal) {
|
||||
message.error('题型数量总和必须等于题目类别数量总和');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 然后才获取表单值,确保表单验证通过
|
||||
@@ -188,6 +257,25 @@ const ExamSubjectPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigModeChange = (nextMode: 'ratio' | 'count') => {
|
||||
if (nextMode === configMode) return;
|
||||
|
||||
let nextTypeRatios = typeRatios;
|
||||
let nextCategoryRatios = categoryRatios;
|
||||
if (nextMode === 'count') {
|
||||
nextTypeRatios = convertRatiosToCounts(typeRatios);
|
||||
nextCategoryRatios = convertRatiosToCounts(categoryRatios);
|
||||
} else {
|
||||
nextTypeRatios = convertCountsToRatios(typeRatios);
|
||||
nextCategoryRatios = convertCountsToRatios(categoryRatios);
|
||||
}
|
||||
|
||||
setConfigMode(nextMode);
|
||||
setTypeRatios(nextTypeRatios);
|
||||
setCategoryRatios(nextCategoryRatios);
|
||||
form.setFieldsValue({ typeRatios: nextTypeRatios, categoryRatios: nextCategoryRatios });
|
||||
};
|
||||
|
||||
const handleTypeRatioChange = (type: string, value: number) => {
|
||||
const newRatios = { ...typeRatios, [type]: value };
|
||||
setTypeRatios(newRatios);
|
||||
@@ -245,70 +333,81 @@ const ExamSubjectPage = () => {
|
||||
{title: '题型分布',
|
||||
dataIndex: 'typeRatios',
|
||||
key: 'typeRatios',
|
||||
render: (ratios: Record<string, number>) => (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([type, ratio]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${ratio}%`,
|
||||
backgroundColor: typeConfig?.color || '#1890ff'
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
render: (ratios: Record<string, number>) => {
|
||||
const ratioMode = isRatioMode(ratios || {});
|
||||
const total = sumValues(ratios || {});
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([type, value]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
const widthPercent = ratioMode ? value : (total > 0 ? (value / total) * 100 : 0);
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${widthPercent}%`,
|
||||
backgroundColor: typeConfig?.color || '#1890ff'
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([type, value]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={type} className="flex items-center text-sm">
|
||||
<span
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: typeConfig?.color || '#1890ff' }}
|
||||
></span>
|
||||
<span className="flex-1">{typeConfig?.label || type}</span>
|
||||
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([type, ratio]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
return (
|
||||
<div key={type} className="flex items-center text-sm">
|
||||
<span
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: typeConfig?.color || '#1890ff' }}
|
||||
></span>
|
||||
<span className="flex-1">{typeConfig?.label || type}</span>
|
||||
<span className="font-medium">{ratio}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{title: '题目类别分布',
|
||||
dataIndex: 'categoryRatios',
|
||||
key: 'categoryRatios',
|
||||
render: (ratios: Record<string, number>) => {
|
||||
const ratioMode = isRatioMode(ratios || {});
|
||||
const total = sumValues(ratios || {});
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([category, ratio]) => (
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${ratio}%`,
|
||||
width: `${ratioMode ? value : (total > 0 ? (value / total) * 100 : 0)}%`,
|
||||
backgroundColor: getCategoryColorHex(category)
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([category, ratio]) => (
|
||||
<div key={category} className="flex items-center text-sm">
|
||||
<span
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: getCategoryColorHex(category) }}
|
||||
></span>
|
||||
<span className="flex-1">{category}</span>
|
||||
<span className="font-medium">{ratio}%</span>
|
||||
</div>
|
||||
))}
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => {
|
||||
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={category} className="flex items-center text-sm">
|
||||
<span
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: getCategoryColorHex(category) }}
|
||||
></span>
|
||||
<span className="flex-1">{category}</span>
|
||||
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -433,13 +532,33 @@ const ExamSubjectPage = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">配置模式</span>
|
||||
<Radio.Group
|
||||
value={configMode}
|
||||
onChange={(e) => handleConfigModeChange(e.target.value)}
|
||||
options={[
|
||||
{ label: '比例(%)', value: 'ratio' },
|
||||
{ label: '数量(题)', value: 'count' },
|
||||
]}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>题型比重配置</span>
|
||||
<span className={`text-sm ${Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
总计:{Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0)}%
|
||||
<span>{configMode === 'ratio' ? '题型比重配置' : '题型数量配置'}</span>
|
||||
<span className={`text-sm ${
|
||||
configMode === 'ratio'
|
||||
? (Math.abs(sumValues(typeRatios) - 100) <= 0.01 ? 'text-green-600' : 'text-red-600')
|
||||
: (sumValues(typeRatios) > 0 ? 'text-green-600' : 'text-red-600')
|
||||
}`}>
|
||||
总计:{sumValues(typeRatios)}{configMode === 'ratio' ? '%' : '题'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -448,24 +567,27 @@ const ExamSubjectPage = () => {
|
||||
<Form.Item name="typeRatios" noStyle>
|
||||
<div className="space-y-4">
|
||||
{questionTypes.map((type) => {
|
||||
const ratio = typeRatios[type.key] || 0;
|
||||
const value = typeRatios[type.key] || 0;
|
||||
const total = sumValues(typeRatios);
|
||||
const percent = configMode === 'ratio' ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={type.key}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<span className="text-blue-600 font-bold">{ratio}%</span>
|
||||
<span className="text-blue-600 font-bold">{configMode === 'ratio' ? `${value}%` : `${value}题`}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={ratio}
|
||||
onChange={(value) => handleTypeRatioChange(type.key, value || 0)}
|
||||
max={configMode === 'ratio' ? 100 : 200}
|
||||
precision={configMode === 'ratio' ? 2 : 0}
|
||||
value={value}
|
||||
onChange={(nextValue) => handleTypeRatioChange(type.key, nextValue || 0)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Progress
|
||||
percent={ratio}
|
||||
percent={percent}
|
||||
strokeColor={type.color}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
@@ -483,9 +605,13 @@ const ExamSubjectPage = () => {
|
||||
size="small"
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>题目类别比重配置</span>
|
||||
<span className={`text-sm ${Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
总计:{Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0)}%
|
||||
<span>{configMode === 'ratio' ? '题目类别比重配置' : '题目类别数量配置'}</span>
|
||||
<span className={`text-sm ${
|
||||
configMode === 'ratio'
|
||||
? (Math.abs(sumValues(categoryRatios) - 100) <= 0.01 ? 'text-green-600' : 'text-red-600')
|
||||
: (sumValues(categoryRatios) === sumValues(typeRatios) && sumValues(categoryRatios) > 0 ? 'text-green-600' : 'text-red-600')
|
||||
}`}>
|
||||
总计:{sumValues(categoryRatios)}{configMode === 'ratio' ? '%' : '题'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -493,24 +619,27 @@ const ExamSubjectPage = () => {
|
||||
<Form.Item name="categoryRatios" noStyle>
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const ratio = categoryRatios[category.name] || 0;
|
||||
const value = categoryRatios[category.name] || 0;
|
||||
const total = sumValues(categoryRatios);
|
||||
const percent = configMode === 'ratio' ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<span className="text-blue-600 font-bold">{ratio}%</span>
|
||||
<span className="text-blue-600 font-bold">{configMode === 'ratio' ? `${value}%` : `${value}题`}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={ratio}
|
||||
onChange={(value) => handleCategoryRatioChange(category.name, value || 0)}
|
||||
max={configMode === 'ratio' ? 100 : 200}
|
||||
precision={configMode === 'ratio' ? 2 : 0}
|
||||
value={value}
|
||||
onChange={(nextValue) => handleCategoryRatioChange(category.name, nextValue || 0)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Progress
|
||||
percent={ratio}
|
||||
percent={percent}
|
||||
strokeColor="#1890ff"
|
||||
showInfo={false}
|
||||
size="small"
|
||||
|
||||
@@ -213,6 +213,61 @@ test('管理员任务分页统计接口返回结构正确', async () => {
|
||||
assert.equal(firstGenerate.json?.success, true);
|
||||
assert.ok(Array.isArray(firstGenerate.json?.data?.questions));
|
||||
|
||||
const countSubjectId = randomUUID();
|
||||
await run(
|
||||
`INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
countSubjectId,
|
||||
'题量科目',
|
||||
JSON.stringify({ single: 2, multiple: 1, judgment: 1, text: 0 }),
|
||||
JSON.stringify({ 通用: 4 }),
|
||||
40,
|
||||
60,
|
||||
],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), '题量-单选1', 'single', JSON.stringify(['A', 'B', 'C', 'D']), 'A', 10, '通用'],
|
||||
);
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), '题量-单选2', 'single', JSON.stringify(['A', 'B', 'C', 'D']), 'B', 10, '通用'],
|
||||
);
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), '题量-多选1', 'multiple', JSON.stringify(['A', 'B', 'C', 'D']), JSON.stringify(['A', 'B']), 10, '通用'],
|
||||
);
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), '题量-判断1', 'judgment', null, 'A', 10, '通用'],
|
||||
);
|
||||
|
||||
const countGenerate = await jsonFetch(baseUrl, '/api/quiz/generate', {
|
||||
method: 'POST',
|
||||
body: { userId: userA.id, subjectId: countSubjectId },
|
||||
});
|
||||
assert.equal(countGenerate.status, 200);
|
||||
assert.equal(countGenerate.json?.success, true);
|
||||
assert.equal(countGenerate.json?.data?.timeLimit, 60);
|
||||
assert.equal(countGenerate.json?.data?.totalScore, 40);
|
||||
assert.ok(Array.isArray(countGenerate.json?.data?.questions));
|
||||
assert.equal(countGenerate.json?.data?.questions?.length, 4);
|
||||
|
||||
const byType = (countGenerate.json?.data?.questions as any[]).reduce((acc, q) => {
|
||||
acc[q.type] = (acc[q.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
assert.equal(byType.single, 2);
|
||||
assert.equal(byType.multiple, 1);
|
||||
assert.equal(byType.judgment, 1);
|
||||
assert.equal(byType.text || 0, 0);
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
Reference in New Issue
Block a user