diff --git a/backend/app/services/exam_service.py b/backend/app/services/exam_service.py index 55c82e6..0b65fa1 100644 --- a/backend/app/services/exam_service.py +++ b/backend/app/services/exam_service.py @@ -11,6 +11,7 @@ from app.models.exam import Exam, Question, ExamResult from app.models.exam_mistake import ExamMistake from app.models.course import Course, KnowledgePoint from app.core.exceptions import BusinessException, ErrorCode +from app.utils.score_distributor import ScoreDistributor class ExamService: @@ -54,27 +55,38 @@ class ExamService: 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 = [] - for q in selected_questions: + for i, q in enumerate(selected_questions): question_data = { "id": str(q.id), "type": q.question_type, "title": q.title, "content": q.content, "options": q.options, - "score": q.score, + "score": distributed_scores[i], # 使用智能分配的分数 } questions_data.append(question_data) + # 计算及格分数(向上取整,确保公平) + pass_score = ScoreDistributor.calculate_pass_score(total_score, 0.6) + # 创建考试记录 exam = Exam( user_id=user_id, course_id=course_id, exam_name=f"{course.name} - 随机测试", - question_count=len(selected_questions), - total_score=sum(q.score for q in selected_questions), - pass_score=sum(q.score for q in selected_questions) * 0.6, + question_count=actual_question_count, + total_score=total_score, + pass_score=pass_score, duration_minutes=60, status="started", questions={"questions": questions_data}, @@ -140,6 +152,9 @@ class ExamService: for question_data in exam.questions["questions"]: question_id = question_data["id"] question = questions_map.get(question_id) + + # 使用考试创建时分配的分数(智能分配后的整数分数) + allocated_score = question_data.get("score", 10.0) if not question: continue @@ -148,7 +163,7 @@ class ExamService: is_correct = user_answer == question.correct_answer if is_correct: - total_score += question.score + total_score += allocated_score # 使用分配的分数 correct_count += 1 # 创建答题结果记录 @@ -157,7 +172,7 @@ class ExamService: question_id=int(question_id), user_answer=user_answer, 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) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..ab2286d --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,14 @@ +""" +工具模块 +""" +from app.utils.score_distributor import ( + ScoreDistributor, + distribute_scores, + get_question_score, +) + +__all__ = [ + "ScoreDistributor", + "distribute_scores", + "get_question_score", +] diff --git a/backend/app/utils/score_distributor.py b/backend/app/utils/score_distributor.py new file mode 100644 index 0000000..dced455 --- /dev/null +++ b/backend/app/utils/score_distributor.py @@ -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) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 0e4d79c..ffb4a99 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -49,9 +49,7 @@ export interface DepartmentData { pass_rate: number avg_hours: number avg_level: number -} - -// 学习趋势 +}// 学习趋势 export interface TrendData { dates: string[] trend: Array<{ @@ -175,4 +173,4 @@ export function getTeamDashboard() { */ export function getFullDashboardData() { return request.get('/dashboard/all') -} \ No newline at end of file +} diff --git a/frontend/src/utils/scoreFormatter.ts b/frontend/src/utils/scoreFormatter.ts new file mode 100644 index 0000000..147d9ee --- /dev/null +++ b/frontend/src/utils/scoreFormatter.ts @@ -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, +}