解决题目数量无法整除总分时出现无限小数的问题 后端: - 新增 ScoreDistributor 分数分配工具类 - 支持整数分配和小数分配两种模式 - 更新 exam_service.py 使用智能分配 - 考试总分固定100分,按题目数量智能分配 前端: - 新增 scoreFormatter.ts 分数格式化工具 - 提供分数显示、等级判断等辅助函数 示例:100分/6题 = [17,17,17,17,16,16] 总和=100
This commit is contained in:
@@ -11,6 +11,7 @@ from app.models.exam import Exam, Question, ExamResult
|
|||||||
from app.models.exam_mistake import ExamMistake
|
from app.models.exam_mistake import ExamMistake
|
||||||
from app.models.course import Course, KnowledgePoint
|
from app.models.course import Course, KnowledgePoint
|
||||||
from app.core.exceptions import BusinessException, ErrorCode
|
from app.core.exceptions import BusinessException, ErrorCode
|
||||||
|
from app.utils.score_distributor import ScoreDistributor
|
||||||
|
|
||||||
|
|
||||||
class ExamService:
|
class ExamService:
|
||||||
@@ -54,27 +55,38 @@ class ExamService:
|
|||||||
all_questions, min(question_count, len(all_questions))
|
all_questions, min(question_count, len(all_questions))
|
||||||
)
|
)
|
||||||
|
|
||||||
# 构建题目数据
|
# 使用智能分数分配器,确保总分为整数且分配合理
|
||||||
|
total_score = 100.0 # 固定总分为100分
|
||||||
|
actual_question_count = len(selected_questions)
|
||||||
|
|
||||||
|
# 创建分数分配器
|
||||||
|
distributor = ScoreDistributor(total_score, actual_question_count)
|
||||||
|
distributed_scores = distributor.distribute(mode="integer")
|
||||||
|
|
||||||
|
# 构建题目数据,使用分配后的分数
|
||||||
questions_data = []
|
questions_data = []
|
||||||
for q in selected_questions:
|
for i, q in enumerate(selected_questions):
|
||||||
question_data = {
|
question_data = {
|
||||||
"id": str(q.id),
|
"id": str(q.id),
|
||||||
"type": q.question_type,
|
"type": q.question_type,
|
||||||
"title": q.title,
|
"title": q.title,
|
||||||
"content": q.content,
|
"content": q.content,
|
||||||
"options": q.options,
|
"options": q.options,
|
||||||
"score": q.score,
|
"score": distributed_scores[i], # 使用智能分配的分数
|
||||||
}
|
}
|
||||||
questions_data.append(question_data)
|
questions_data.append(question_data)
|
||||||
|
|
||||||
|
# 计算及格分数(向上取整,确保公平)
|
||||||
|
pass_score = ScoreDistributor.calculate_pass_score(total_score, 0.6)
|
||||||
|
|
||||||
# 创建考试记录
|
# 创建考试记录
|
||||||
exam = Exam(
|
exam = Exam(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
course_id=course_id,
|
course_id=course_id,
|
||||||
exam_name=f"{course.name} - 随机测试",
|
exam_name=f"{course.name} - 随机测试",
|
||||||
question_count=len(selected_questions),
|
question_count=actual_question_count,
|
||||||
total_score=sum(q.score for q in selected_questions),
|
total_score=total_score,
|
||||||
pass_score=sum(q.score for q in selected_questions) * 0.6,
|
pass_score=pass_score,
|
||||||
duration_minutes=60,
|
duration_minutes=60,
|
||||||
status="started",
|
status="started",
|
||||||
questions={"questions": questions_data},
|
questions={"questions": questions_data},
|
||||||
@@ -141,6 +153,9 @@ class ExamService:
|
|||||||
question_id = question_data["id"]
|
question_id = question_data["id"]
|
||||||
question = questions_map.get(question_id)
|
question = questions_map.get(question_id)
|
||||||
|
|
||||||
|
# 使用考试创建时分配的分数(智能分配后的整数分数)
|
||||||
|
allocated_score = question_data.get("score", 10.0)
|
||||||
|
|
||||||
if not question:
|
if not question:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -148,7 +163,7 @@ class ExamService:
|
|||||||
is_correct = user_answer == question.correct_answer
|
is_correct = user_answer == question.correct_answer
|
||||||
|
|
||||||
if is_correct:
|
if is_correct:
|
||||||
total_score += question.score
|
total_score += allocated_score # 使用分配的分数
|
||||||
correct_count += 1
|
correct_count += 1
|
||||||
|
|
||||||
# 创建答题结果记录
|
# 创建答题结果记录
|
||||||
@@ -157,7 +172,7 @@ class ExamService:
|
|||||||
question_id=int(question_id),
|
question_id=int(question_id),
|
||||||
user_answer=user_answer,
|
user_answer=user_answer,
|
||||||
is_correct=is_correct,
|
is_correct=is_correct,
|
||||||
score=question.score if is_correct else 0.0,
|
score=allocated_score if is_correct else 0.0, # 使用分配的分数
|
||||||
)
|
)
|
||||||
db.add(exam_result)
|
db.add(exam_result)
|
||||||
|
|
||||||
|
|||||||
14
backend/app/utils/__init__.py
Normal file
14
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
工具模块
|
||||||
|
"""
|
||||||
|
from app.utils.score_distributor import (
|
||||||
|
ScoreDistributor,
|
||||||
|
distribute_scores,
|
||||||
|
get_question_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ScoreDistributor",
|
||||||
|
"distribute_scores",
|
||||||
|
"get_question_score",
|
||||||
|
]
|
||||||
218
backend/app/utils/score_distributor.py
Normal file
218
backend/app/utils/score_distributor.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
分数分配工具
|
||||||
|
|
||||||
|
解决题目分数无法整除的问题,确保:
|
||||||
|
1. 所有题目分数之和精确等于总分
|
||||||
|
2. 题目分数差异最小化(最多相差1分)
|
||||||
|
3. 支持整数分配和小数分配两种模式
|
||||||
|
"""
|
||||||
|
from typing import List, Tuple
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreDistributor:
|
||||||
|
"""
|
||||||
|
智能分数分配器
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
distributor = ScoreDistributor(total_score=100, question_count=6)
|
||||||
|
scores = distributor.distribute()
|
||||||
|
# 结果: [17, 17, 17, 17, 16, 16] 总和=100
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, total_score: float, question_count: int):
|
||||||
|
"""
|
||||||
|
初始化分配器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_score: 总分(如 100)
|
||||||
|
question_count: 题目数量(如 6)
|
||||||
|
"""
|
||||||
|
if question_count <= 0:
|
||||||
|
raise ValueError("题目数量必须大于0")
|
||||||
|
if total_score <= 0:
|
||||||
|
raise ValueError("总分必须大于0")
|
||||||
|
|
||||||
|
self.total_score = total_score
|
||||||
|
self.question_count = question_count
|
||||||
|
|
||||||
|
def distribute_integer(self) -> List[int]:
|
||||||
|
"""
|
||||||
|
整数分配模式
|
||||||
|
|
||||||
|
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分数列表,如 [17, 17, 17, 17, 16, 16]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||||
|
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
|
||||||
|
"""
|
||||||
|
total = int(self.total_score)
|
||||||
|
count = self.question_count
|
||||||
|
|
||||||
|
# 基础分数(向下取整)
|
||||||
|
base_score = total // count
|
||||||
|
# 需要额外加1分的题目数量
|
||||||
|
extra_count = total % count
|
||||||
|
|
||||||
|
# 生成分数列表
|
||||||
|
scores = []
|
||||||
|
for i in range(count):
|
||||||
|
if i < extra_count:
|
||||||
|
scores.append(base_score + 1)
|
||||||
|
else:
|
||||||
|
scores.append(base_score)
|
||||||
|
|
||||||
|
return scores
|
||||||
|
|
||||||
|
def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
|
||||||
|
"""
|
||||||
|
小数分配模式
|
||||||
|
|
||||||
|
将总分分配为小数,最后一题用于补齐差额
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decimal_places: 小数位数,默认1位
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
|
||||||
|
"""
|
||||||
|
count = self.question_count
|
||||||
|
|
||||||
|
# 计算每题分数并四舍五入
|
||||||
|
per_score = self.total_score / count
|
||||||
|
rounded_score = round(per_score, decimal_places)
|
||||||
|
|
||||||
|
# 前 n-1 题使用四舍五入的分数
|
||||||
|
scores = [rounded_score] * (count - 1)
|
||||||
|
|
||||||
|
# 最后一题用总分减去前面的和,确保总分精确
|
||||||
|
last_score = round(self.total_score - sum(scores), decimal_places)
|
||||||
|
scores.append(last_score)
|
||||||
|
|
||||||
|
return scores
|
||||||
|
|
||||||
|
def distribute(self, mode: str = "integer") -> List[float]:
|
||||||
|
"""
|
||||||
|
分配分数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: 分配模式
|
||||||
|
- "integer": 整数分配(推荐)
|
||||||
|
- "decimal": 小数分配
|
||||||
|
- "decimal_1": 保留1位小数
|
||||||
|
- "decimal_2": 保留2位小数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分数列表
|
||||||
|
"""
|
||||||
|
if mode == "integer":
|
||||||
|
return [float(s) for s in self.distribute_integer()]
|
||||||
|
elif mode == "decimal" or mode == "decimal_1":
|
||||||
|
return self.distribute_decimal(1)
|
||||||
|
elif mode == "decimal_2":
|
||||||
|
return self.distribute_decimal(2)
|
||||||
|
else:
|
||||||
|
return [float(s) for s in self.distribute_integer()]
|
||||||
|
|
||||||
|
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
|
||||||
|
"""
|
||||||
|
获取指定题目的分数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_index: 题目索引(从0开始)
|
||||||
|
mode: 分配模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
该题目的分数
|
||||||
|
"""
|
||||||
|
scores = self.distribute(mode)
|
||||||
|
if 0 <= question_index < len(scores):
|
||||||
|
return scores[question_index]
|
||||||
|
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
|
||||||
|
|
||||||
|
def validate(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
验证分配结果
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否有效, 信息)
|
||||||
|
"""
|
||||||
|
scores = self.distribute()
|
||||||
|
total = sum(scores)
|
||||||
|
|
||||||
|
if abs(total - self.total_score) < 0.01:
|
||||||
|
return True, f"分配有效:{scores},总分={total}"
|
||||||
|
else:
|
||||||
|
return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_score(score: float, decimal_places: int = 1) -> str:
|
||||||
|
"""
|
||||||
|
格式化分数显示
|
||||||
|
|
||||||
|
Args:
|
||||||
|
score: 分数
|
||||||
|
decimal_places: 小数位数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化的分数字符串
|
||||||
|
"""
|
||||||
|
if score == int(score):
|
||||||
|
return str(int(score))
|
||||||
|
return f"{score:.{decimal_places}f}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
|
||||||
|
"""
|
||||||
|
计算及格分数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_score: 总分
|
||||||
|
pass_rate: 及格率,默认60%
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
及格分数(整数)
|
||||||
|
"""
|
||||||
|
return math.ceil(total_score * pass_rate)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
|
||||||
|
"""
|
||||||
|
便捷函数:分配分数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_score: 总分
|
||||||
|
question_count: 题目数量
|
||||||
|
mode: 分配模式(integer/decimal)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分数列表
|
||||||
|
"""
|
||||||
|
distributor = ScoreDistributor(total_score, question_count)
|
||||||
|
return distributor.distribute(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def get_question_score(
|
||||||
|
total_score: float,
|
||||||
|
question_count: int,
|
||||||
|
question_index: int,
|
||||||
|
mode: str = "integer"
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
便捷函数:获取指定题目的分数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_score: 总分
|
||||||
|
question_count: 题目数量
|
||||||
|
question_index: 题目索引(从0开始)
|
||||||
|
mode: 分配模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
该题目的分数
|
||||||
|
"""
|
||||||
|
distributor = ScoreDistributor(total_score, question_count)
|
||||||
|
return distributor.get_score_for_question(question_index, mode)
|
||||||
@@ -49,9 +49,7 @@ export interface DepartmentData {
|
|||||||
pass_rate: number
|
pass_rate: number
|
||||||
avg_hours: number
|
avg_hours: number
|
||||||
avg_level: number
|
avg_level: number
|
||||||
}
|
}// 学习趋势
|
||||||
|
|
||||||
// 学习趋势
|
|
||||||
export interface TrendData {
|
export interface TrendData {
|
||||||
dates: string[]
|
dates: string[]
|
||||||
trend: Array<{
|
trend: Array<{
|
||||||
|
|||||||
154
frontend/src/utils/scoreFormatter.ts
Normal file
154
frontend/src/utils/scoreFormatter.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* 分数格式化工具
|
||||||
|
*
|
||||||
|
* 用于在前端显示分数时进行格式化,避免显示过长的小数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化分数显示
|
||||||
|
*
|
||||||
|
* @param score 分数
|
||||||
|
* @param decimalPlaces 小数位数,默认1位
|
||||||
|
* @returns 格式化后的分数字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatScore(16.666666) // "16.7"
|
||||||
|
* formatScore(17) // "17"
|
||||||
|
* formatScore(16.5, 0) // "17"
|
||||||
|
*/
|
||||||
|
export function formatScore(score: number, decimalPlaces: number = 1): string {
|
||||||
|
// 如果是整数,直接返回
|
||||||
|
if (Number.isInteger(score)) {
|
||||||
|
return score.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四舍五入到指定小数位
|
||||||
|
const rounded = Number(score.toFixed(decimalPlaces))
|
||||||
|
|
||||||
|
// 如果四舍五入后是整数,去掉小数点
|
||||||
|
if (Number.isInteger(rounded)) {
|
||||||
|
return rounded.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded.toFixed(decimalPlaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化分数显示(带单位)
|
||||||
|
*
|
||||||
|
* @param score 分数
|
||||||
|
* @param unit 单位,默认"分"
|
||||||
|
* @returns 格式化后的分数字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatScoreWithUnit(16.7) // "16.7分"
|
||||||
|
* formatScoreWithUnit(100) // "100分"
|
||||||
|
*/
|
||||||
|
export function formatScoreWithUnit(score: number, unit: string = '分'): string {
|
||||||
|
return `${formatScore(score)}${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化百分比
|
||||||
|
*
|
||||||
|
* @param value 值(0-1 或 0-100)
|
||||||
|
* @param isPercent 是否已经是百分比形式(0-100),默认false
|
||||||
|
* @returns 格式化后的百分比字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatPercent(0.8567) // "85.7%"
|
||||||
|
* formatPercent(85.67, true) // "85.7%"
|
||||||
|
*/
|
||||||
|
export function formatPercent(value: number, isPercent: boolean = false): string {
|
||||||
|
const percent = isPercent ? value : value * 100
|
||||||
|
return `${formatScore(percent)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算及格分数
|
||||||
|
*
|
||||||
|
* @param totalScore 总分
|
||||||
|
* @param passRate 及格率,默认0.6
|
||||||
|
* @returns 及格分数(向上取整)
|
||||||
|
*/
|
||||||
|
export function calculatePassScore(totalScore: number, passRate: number = 0.6): number {
|
||||||
|
return Math.ceil(totalScore * passRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否及格
|
||||||
|
*
|
||||||
|
* @param score 得分
|
||||||
|
* @param passScore 及格分数
|
||||||
|
* @returns 是否及格
|
||||||
|
*/
|
||||||
|
export function isPassed(score: number, passScore: number): boolean {
|
||||||
|
return score >= passScore
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分数等级
|
||||||
|
*
|
||||||
|
* @param score 得分
|
||||||
|
* @param totalScore 总分
|
||||||
|
* @returns 等级: 'excellent' | 'good' | 'pass' | 'fail'
|
||||||
|
*/
|
||||||
|
export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' {
|
||||||
|
const ratio = score / totalScore
|
||||||
|
|
||||||
|
if (ratio >= 0.9) return 'excellent'
|
||||||
|
if (ratio >= 0.75) return 'good'
|
||||||
|
if (ratio >= 0.6) return 'pass'
|
||||||
|
return 'fail'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分数等级对应的颜色
|
||||||
|
*
|
||||||
|
* @param level 等级
|
||||||
|
* @returns 颜色值
|
||||||
|
*/
|
||||||
|
export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string {
|
||||||
|
const colors = {
|
||||||
|
excellent: '#67c23a', // 绿色
|
||||||
|
good: '#409eff', // 蓝色
|
||||||
|
pass: '#e6a23c', // 橙色
|
||||||
|
fail: '#f56c6c', // 红色
|
||||||
|
}
|
||||||
|
return colors[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能分配分数(前端预览用)
|
||||||
|
*
|
||||||
|
* @param totalScore 总分
|
||||||
|
* @param questionCount 题目数量
|
||||||
|
* @returns 分数数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* distributeScores(100, 6) // [17, 17, 17, 17, 16, 16]
|
||||||
|
*/
|
||||||
|
export function distributeScores(totalScore: number, questionCount: number): number[] {
|
||||||
|
if (questionCount <= 0) return []
|
||||||
|
|
||||||
|
const baseScore = Math.floor(totalScore / questionCount)
|
||||||
|
const extraCount = totalScore % questionCount
|
||||||
|
|
||||||
|
const scores: number[] = []
|
||||||
|
for (let i = 0; i < questionCount; i++) {
|
||||||
|
scores.push(i < extraCount ? baseScore + 1 : baseScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
formatScore,
|
||||||
|
formatScoreWithUnit,
|
||||||
|
formatPercent,
|
||||||
|
calculatePassScore,
|
||||||
|
isPassed,
|
||||||
|
getScoreLevel,
|
||||||
|
getScoreLevelColor,
|
||||||
|
distributeScores,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user