diff --git a/api/controllers/quizController.ts b/api/controllers/quizController.ts index 3111cd6..f5ea83f 100644 --- a/api/controllers/quizController.ts +++ b/api/controllers/quizController.ts @@ -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) { diff --git a/api/models/examSubject.ts b/api/models/examSubject.ts index 931d680..ab4c5ef 100644 --- a/api/models/examSubject.ts +++ b/api/models/examSubject.ts @@ -33,19 +33,247 @@ const parseJson = (value: string, fallback: T): T => { } }; -const validateRatiosSum100 = (ratios: Record, label: string) => { - const values = Object.values(ratios); +const validateNonNegativeNumbers = (valuesMap: Record, 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, label: string) => { + validateNonNegativeNumbers(valuesMap, label); + if (Object.values(valuesMap).some((v) => !Number.isInteger(v))) { + throw new Error(`${label}必须为整数`); + } +}; + +const sumValues = (valuesMap: Record) => Object.values(valuesMap).reduce((a, b) => a + b, 0); + +const isRatioMode = (valuesMap: Record) => { + 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>; + totalScore: number; + timeLimitMinutes: number; + }> { + const { QuestionModel } = await import('./question'); + let questions: Awaited> = []; + + 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> = []; + + 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(); + 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) => { + 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(); + let selected = null as null | Awaited>[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 { 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 diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 4dde73e..73d1429 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -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> = []; - - // 构建包含所有类别的数组,根据比重重复对应次数 - 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> = []; - - // 尝试获取足够分数的题目 - 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 { diff --git a/src/pages/admin/ExamSubjectPage.tsx b/src/pages/admin/ExamSubjectPage.tsx index a92d6cd..d162f6b 100644 --- a/src/pages/admin/ExamSubjectPage.tsx +++ b/src/pages/admin/ExamSubjectPage.tsx @@ -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(null); // 引入状态管理来跟踪实时的比例配置 + const [configMode, setConfigMode] = useState<'ratio' | 'count'>('count'); const [typeRatios, setTypeRatios] = useState>({ single: 40, multiple: 30, @@ -59,6 +60,49 @@ const ExamSubjectPage = () => { 通用: 100 }); + const sumValues = (valuesMap: Record) => Object.values(valuesMap).reduce((a, b) => a + b, 0); + + const isRatioMode = (valuesMap: Record) => { + 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) => { + const entries = Object.entries(valuesMap); + if (entries.length === 0) return valuesMap; + const result: Record = {}; + 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) => { + 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 = {}; + 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) => { + const result: Record = {}; + 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 = { 通用: 100 }; + const defaultTypeRatios = { single: 4, multiple: 3, judgment: 2, text: 1 }; + const defaultCategoryRatios: Record = { 通用: 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) => ( -
-
- {ratios && Object.entries(ratios).map(([type, ratio]) => { - const typeConfig = questionTypes.find(t => t.key === type); - return ( -
- ); - })} + render: (ratios: Record) => { + const ratioMode = isRatioMode(ratios || {}); + const total = sumValues(ratios || {}); + return ( +
+
+ {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 ( +
+ ); + })} +
+
+ {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 ( +
+ + {typeConfig?.label || type} + {ratioMode ? `${value}%` : `${value}题(${percent}%)`} +
+ ); + })} +
-
- {ratios && Object.entries(ratios).map(([type, ratio]) => { - const typeConfig = questionTypes.find(t => t.key === type); - return ( -
- - {typeConfig?.label || type} - {ratio}% -
- ); - })} -
-
- ), + ); + }, }, {title: '题目类别分布', dataIndex: 'categoryRatios', key: 'categoryRatios', render: (ratios: Record) => { + const ratioMode = isRatioMode(ratios || {}); + const total = sumValues(ratios || {}); return (
- {ratios && Object.entries(ratios).map(([category, ratio]) => ( + {ratios && Object.entries(ratios).map(([category, value]) => (
0 ? (value / total) * 100 : 0)}%`, backgroundColor: getCategoryColorHex(category) }} >
))}
- {ratios && Object.entries(ratios).map(([category, ratio]) => ( -
- - {category} - {ratio}% -
- ))} + {ratios && Object.entries(ratios).map(([category, value]) => { + const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0); + return ( +
+ + {category} + {ratioMode ? `${value}%` : `${value}题(${percent}%)`} +
+ ); + })}
); @@ -433,13 +532,33 @@ const ExamSubjectPage = () => { + +
+ 配置模式 + handleConfigModeChange(e.target.value)} + options={[ + { label: '比例(%)', value: 'ratio' }, + { label: '数量(题)', value: 'count' }, + ]} + optionType="button" + buttonStyle="solid" + /> +
+
+ - 题型比重配置 - sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}> - 总计:{Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0)}% + {configMode === 'ratio' ? '题型比重配置' : '题型数量配置'} + 0 ? 'text-green-600' : 'text-red-600') + }`}> + 总计:{sumValues(typeRatios)}{configMode === 'ratio' ? '%' : '题'}
} @@ -448,24 +567,27 @@ const ExamSubjectPage = () => {
{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 (
{type.label} - {ratio}% + {configMode === 'ratio' ? `${value}%` : `${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 }} />
{ size="small" title={
- 题目类别比重配置 - sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}> - 总计:{Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0)}% + {configMode === 'ratio' ? '题目类别比重配置' : '题目类别数量配置'} + 0 ? 'text-green-600' : 'text-red-600') + }`}> + 总计:{sumValues(categoryRatios)}{configMode === 'ratio' ? '%' : '题'}
} @@ -493,24 +619,27 @@ const ExamSubjectPage = () => {
{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 (
{category.name} - {ratio}% + {configMode === 'ratio' ? `${value}%` : `${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 }} />
{ 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); + 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 (?, ?, ?, ?, ?, ?, ?, ?)`,