소스 검색

refine v3 report grading bands and print-friendly badge styles

yemeishu 3 주 전
부모
커밋
9c30316d4e
2개의 변경된 파일719개의 추가작업 그리고 8개의 파일을 삭제
  1. 567 1
      app/Services/ExamPdfExportService.php
  2. 152 7
      resources/views/exam-analysis/pdf-report-v3.blade.php

+ 567 - 1
app/Services/ExamPdfExportService.php

@@ -7,6 +7,7 @@ use App\DTO\ReportPayloadDto;
 use App\Models\Paper;
 use App\Models\Question;
 use App\Models\Student;
+use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
 use App\Support\GradingStyleQuestionStem;
 use App\Support\PaperNaming;
 use Illuminate\Support\Facades\DB;
@@ -269,6 +270,15 @@ class ExamPdfExportService
         }
 
         try {
+            $flowStart = microtime(true);
+            $lastMark = $flowStart;
+            $marks = [];
+            $mark = static function (string $label) use (&$lastMark, &$marks): void {
+                $now = microtime(true);
+                $marks[$label] = round(($now - $lastMark) * 1000, 1);
+                $lastMark = $now;
+            };
+
             Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
@@ -277,6 +287,7 @@ class ExamPdfExportService
 
             // 构建分析数据
             $analysisData = $this->buildAnalysisData($paperId, $studentId);
+            $mark('build_analysis_data_ms');
             if (! $analysisData) {
                 Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
                     'paper_id' => $paperId,
@@ -297,6 +308,7 @@ class ExamPdfExportService
             // 创建DTO
             $dto = ExamAnalysisDataDto::fromArray($analysisData);
             $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
+            $mark('build_payload_dto_ms');
 
             // 打印传给模板的数据
             $templateData = $payloadDto->toArray();
@@ -322,12 +334,15 @@ class ExamPdfExportService
                     $templateData['question_insights'][$idx] = MathFormulaProcessor::processQuestionData($insight);
                 }
             }
+            $mark('process_formula_data_ms');
 
             // 组装V3报告展示数据(模块化)
             $templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
+            $mark('build_v3_data_ms');
 
             // 渲染HTML(V3模板)
             $html = view('exam-analysis.pdf-report-v3', $templateData)->render();
+            $mark('render_html_ms');
             if (! $html) {
                 Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
 
@@ -336,6 +351,7 @@ class ExamPdfExportService
 
             // 生成PDF
             $pdfBinary = $this->buildPdf($html);
+            $mark('build_pdf_ms');
             if (! $pdfBinary) {
                 return null;
             }
@@ -349,6 +365,7 @@ class ExamPdfExportService
             $safeAnalysisFile = PaperNaming::toSafeFilename($analysisBase) . '.pdf';
             $path = "analysis_reports/{$safeAnalysisFile}";
             $url = $this->pdfStorageService->put($path, $pdfBinary);
+            $mark('upload_pdf_ms');
             if (! $url) {
                 Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
 
@@ -357,6 +374,15 @@ class ExamPdfExportService
 
             // 保存URL到数据库
             $this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
+            $mark('save_pdf_url_ms');
+            $marks['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1);
+
+            Log::info('ExamPdfExportService: 学情分析PDF耗时明细', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'record_id' => $recordId,
+                'timing' => $marks,
+            ]);
 
             return $url;
 
@@ -543,8 +569,22 @@ class ExamPdfExportService
             ? (float) $overallSummary['average_mastery']
             : $this->averageByKey(array_filter($dimensions, fn ($d) => $d['mastery_level'] !== null), 'mastery_level');
 
-        $overallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
         $paths = $this->buildV3LearningPaths($dimensions);
+        $difficultyInsight = $this->buildV3DifficultyInsight($templateData);
+        $comparisonInsight = $this->buildV3ComparisonInsight(
+            $templateData,
+            $scoreRate,
+            $avgMastery
+        );
+        $fallbackOverallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
+        $overallComposite = $this->buildV3OverallCompositeEvaluation(
+            $scoreRate,
+            $avgMastery,
+            $difficultyInsight,
+            $comparisonInsight,
+            $fallbackOverallLabel
+        );
+        $overallLabel = (string) ($overallComposite['label'] ?? $fallbackOverallLabel);
 
         // 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
         // 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
@@ -634,7 +674,10 @@ class ExamPdfExportService
                 'score_rate' => $scoreRate,
                 'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
                 'overall_label' => $overallLabel,
+                'overall_label_detail' => $overallComposite,
                 'stage' => $stage,
+                'difficulty' => $difficultyInsight,
+                'comparison' => $comparisonInsight,
             ],
             'radar' => $radar,
             'modules' => $moduleRows,
@@ -739,6 +782,527 @@ class ExamPdfExportService
         return $count > 0 ? ($sum / $count) : null;
     }
 
+    private function buildV3DifficultyInsight(array $templateData): array
+    {
+        $paper = $templateData['paper'] ?? [];
+        $questions = $templateData['questions'] ?? [];
+        $rawCategory = (string) ($paper['difficulty_category'] ?? '');
+        $level = QuestionDifficultyCalibrationAnalyzer::parsePaperDifficultyCategory($rawCategory);
+        $levelInt = $level !== null ? (int) round($level) : null;
+        $range = $this->difficultyRangeByLevel($levelInt);
+
+        $difficulties = [];
+        foreach ($questions as $q) {
+            $d = $q['difficulty'] ?? null;
+            if ($d !== null && is_numeric($d)) {
+                $dv = (float) $d;
+                if ($dv >= 0.0 && $dv <= 1.0) {
+                    $difficulties[] = $dv;
+                }
+            }
+        }
+
+        $actualAvg = ! empty($difficulties) ? (array_sum($difficulties) / count($difficulties)) : null;
+        $actualLevel = $actualAvg !== null ? $this->mapDifficultyValueToLevel($actualAvg) : null;
+        $targetCenter = $range !== null ? (($range['min'] + $range['max']) / 2.0) : null;
+        $deviation = ($actualAvg !== null && $targetCenter !== null) ? ($actualAvg - $targetCenter) : null;
+
+        $matchStatus = '暂无';
+        if ($actualAvg !== null && $range !== null) {
+            if ($actualAvg > $range['max']) {
+                $matchStatus = '偏难';
+            } elseif ($actualAvg < $range['min']) {
+                $matchStatus = '偏易';
+            } else {
+                $matchStatus = '匹配';
+            }
+        }
+
+        $explain = '暂无足够数据评估难度匹配。';
+        if ($matchStatus === '匹配') {
+            $explain = '本次题目整体难度与学案目标难度基本一致,结果可直接反映当前掌握水平。';
+        } elseif ($matchStatus === '偏难') {
+            $explain = '本次题目整体偏难,错误率偏高有客观因素,建议先补齐同模块中档题再冲高档。';
+        } elseif ($matchStatus === '偏易') {
+            $explain = '本次题目整体偏易,若得分高不代表上限已到,建议补充更高一档难度验证稳定性。';
+        }
+
+        return [
+            'target_category_raw' => $rawCategory,
+            'target_level' => $levelInt,
+            'target_label' => $this->difficultyLevelLabel($levelInt, $rawCategory),
+            'target_range' => $range,
+            'actual_average_difficulty' => $actualAvg !== null ? round($actualAvg, 4) : null,
+            'actual_level' => $actualLevel,
+            'actual_label' => $actualLevel !== null ? $this->difficultyLevelLabel($actualLevel) : null,
+            'deviation' => $deviation !== null ? round($deviation, 4) : null,
+            'status' => $matchStatus,
+            'question_count' => count($difficulties),
+            'explain' => $explain,
+        ];
+    }
+
+    private function buildV3ComparisonInsight(array $templateData, ?float $currentScoreRate, ?float $currentMastery): array
+    {
+        $paper = $templateData['paper'] ?? [];
+        $student = $templateData['student'] ?? [];
+        $studentId = (string) ($student['id'] ?? '');
+        $grade = (string) ($student['grade'] ?? '');
+        $paperId = (string) ($paper['id'] ?? '');
+        $paperType = isset($paper['paper_type']) ? (int) $paper['paper_type'] : null;
+
+        $history = [
+            'has_data' => false,
+            'is_first_exam' => false,
+            'low_baseline_guard' => false,
+            'sample_size' => 0,
+            'baseline_score_rate' => null,
+            'delta_score_rate' => null,
+            'trend' => '暂无',
+            'message' => '历史样本不足,暂无法形成稳定趋势。',
+        ];
+        $peers = [
+            'has_data' => false,
+            'sample_size' => 0,
+            'raw_sample_size' => 0,
+            'peer_avg_score_rate' => null,
+            'delta_score_rate' => null,
+            'percentile' => null,
+            'band' => '暂无',
+            'display_mode' => 'none',
+            'show_line' => false,
+            'band_icon' => '•',
+            'band_color' => '#64748b',
+            'message' => '同群体样本不足,暂无法做稳健对比。',
+        ];
+
+        if ($studentId === '') {
+            return ['history' => $history, 'peers' => $peers];
+        }
+
+        try {
+            $historyRows = DB::connection('mysql')
+                ->table('papers as p')
+                ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id')
+                ->where('p.student_id', $studentId)
+                ->when($paperId !== '', fn ($q) => $q->where('p.paper_id', '!=', $paperId))
+                ->groupBy('p.paper_id', 'p.created_at')
+                ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score')
+                ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0')
+                ->orderByDesc('p.created_at')
+                ->limit(7)
+                ->get();
+
+            $historyRates = [];
+            foreach ($historyRows as $row) {
+                $total = (float) ($row->total_score ?? 0);
+                if ($total <= 0) {
+                    continue;
+                }
+                $historyRates[] = (float) ($row->obtained_score ?? 0) / $total;
+            }
+            if (! empty($historyRates) && $currentScoreRate !== null) {
+                $baseline = array_sum($historyRates) / count($historyRates);
+                $delta = $currentScoreRate - $baseline;
+                $history['has_data'] = true;
+                $history['sample_size'] = count($historyRates);
+                $history['baseline_score_rate'] = round($baseline, 4);
+                $history['delta_score_rate'] = round($delta, 4);
+                if ($baseline <= 0.02 && count($historyRates) >= 5) {
+                    $history['low_baseline_guard'] = true;
+                    $history['trend'] = '基线偏低';
+                    $history['message'] = '近7次历史基线过低,单次涨跌参考意义有限,建议重点看后续连续3次趋势。';
+                } else {
+                    $history['trend'] = $this->resolveDeltaTrendLabel($delta);
+                    $history['message'] = $delta >= 0
+                        ? '相较于近期个人表现,本次成绩处于上行区间。'
+                        : '相较于近期个人表现,本次成绩略有回落,建议优先复盘错因分布。';
+                }
+            } elseif ($currentScoreRate !== null) {
+                $history['is_first_exam'] = true;
+                $history['message'] = $this->pickFirstExamEncouragementMessageByScore($currentScoreRate);
+            }
+        } catch (\Throwable $e) {
+            Log::warning('ExamPdfExportService: 构建历史对比失败', ['error' => $e->getMessage()]);
+        }
+
+        try {
+            if ($grade !== '' && $currentScoreRate !== null) {
+                $peerRows = DB::connection('mysql')
+                    ->table('papers as p')
+                    ->join('students as s', 's.student_id', '=', 'p.student_id')
+                    ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id')
+                    ->where('s.grade', $grade)
+                    ->where('p.student_id', '!=', $studentId)
+                    ->when($paperType !== null, fn ($q) => $q->where('p.paper_type', $paperType))
+                    ->groupBy('p.paper_id', 'p.created_at')
+                    ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score')
+                    ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0')
+                    ->orderByDesc('p.created_at')
+                    ->limit(300)
+                    ->get();
+
+                $peerRates = [];
+                foreach ($peerRows as $row) {
+                    $total = (float) ($row->total_score ?? 0);
+                    if ($total <= 0) {
+                        continue;
+                    }
+                    $peerRates[] = (float) ($row->obtained_score ?? 0) / $total;
+                }
+
+                if (! empty($peerRates)) {
+                    sort($peerRates);
+                    $peerAvg = array_sum($peerRates) / count($peerRates);
+                    $ltCount = 0;
+                    $eqCount = 0;
+                    foreach ($peerRates as $rate) {
+                        if ($rate < $currentScoreRate) {
+                            $ltCount++;
+                        } elseif (abs($rate - $currentScoreRate) < 1e-9) {
+                            $eqCount++;
+                        }
+                    }
+                    // 使用 mid-rank percentile,避免并列值把分位夸大到 100%
+                    $percentile = (100.0 * ($ltCount + 0.5 * $eqCount)) / count($peerRates);
+                    $delta = $currentScoreRate - $peerAvg;
+                    $peerCount = count($peerRates);
+                    $band = $this->resolvePeerBand($percentile);
+                    $bandVisual = $this->resolvePeerBandVisual($band);
+
+                    $peers['has_data'] = true;
+                    $peers['show_line'] = true;
+                    $peers['sample_size'] = $peerCount;
+                    $peers['raw_sample_size'] = $peerCount;
+                    $peers['peer_avg_score_rate'] = round($peerAvg, 4);
+                    $peers['delta_score_rate'] = round($delta, 4);
+                    $peers['percentile'] = round($percentile, 1);
+                    $peers['band'] = $band;
+                    $peers['band_icon'] = $bandVisual['icon'];
+                    $peers['band_color'] = $bandVisual['color'];
+
+                    if ($peerCount < 50) {
+                        $peers['display_mode'] = 'masked';
+                        $peers['sample_size'] = null;
+                        $peers['peer_avg_score_rate'] = null;
+                        $peers['percentile'] = null;
+                        $peers['message'] = '已建立同群体参照,当前处于'.$band.'区间。';
+                    } elseif ($peerCount > 200) {
+                        $peers['display_mode'] = 'percentile_only';
+                        $peers['sample_size'] = null;
+                        $peers['peer_avg_score_rate'] = null;
+                        $peers['percentile'] = null;
+                        $peers['message'] = '和上百位同年级同类型学生相比,你当前处于'.$band.'水平。';
+                    } else {
+                        $peers['display_mode'] = 'detailed';
+                        $peers['message'] = '同年级同类型参照中,你当前位于 '
+                            .number_format($percentile, 1).'% 分位,群体均值 '
+                            .number_format($peerAvg * 100, 1).'% 。';
+                    }
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::warning('ExamPdfExportService: 构建同群体对比失败', ['error' => $e->getMessage()]);
+        }
+
+        return [
+            'history' => $history,
+            'peers' => $peers,
+            'mastery' => [
+                'current_average_mastery' => $currentMastery !== null ? round($currentMastery, 4) : null,
+            ],
+        ];
+    }
+
+    private function difficultyRangeByLevel(?int $level): ?array
+    {
+        $map = [
+            0 => ['min' => 0.00, 'max' => 0.10],
+            1 => ['min' => 0.10, 'max' => 0.25],
+            2 => ['min' => 0.25, 'max' => 0.50],
+            3 => ['min' => 0.50, 'max' => 0.75],
+            4 => ['min' => 0.75, 'max' => 1.00],
+        ];
+
+        if ($level === null || ! isset($map[$level])) {
+            return null;
+        }
+
+        return $map[$level];
+    }
+
+    private function mapDifficultyValueToLevel(float $difficulty): int
+    {
+        if ($difficulty < 0.10) {
+            return 0;
+        }
+        if ($difficulty < 0.25) {
+            return 1;
+        }
+        if ($difficulty < 0.50) {
+            return 2;
+        }
+        if ($difficulty < 0.75) {
+            return 3;
+        }
+
+        return 4;
+    }
+
+    private function difficultyLevelLabel(?int $level, ?string $fallback = null): string
+    {
+        $map = [
+            0 => '0基础',
+            1 => '筑基',
+            2 => '提分',
+            3 => '培优',
+            4 => '竞赛',
+        ];
+
+        if ($level !== null && isset($map[$level])) {
+            return $map[$level];
+        }
+
+        $fallback = trim((string) $fallback);
+
+        return $fallback !== '' ? $fallback : '未设置';
+    }
+
+    private function resolveDeltaTrendLabel(float $delta): string
+    {
+        if ($delta >= 0.05) {
+            return '显著提升';
+        }
+        if ($delta >= 0.02) {
+            return '小幅提升';
+        }
+        if ($delta > -0.02) {
+            return '基本持平';
+        }
+        if ($delta > -0.05) {
+            return '小幅回落';
+        }
+
+        return '明显回落';
+    }
+
+    private function resolvePeerBand(float $percentile): string
+    {
+        if ($percentile >= 75) {
+            return '领先';
+        }
+        if ($percentile >= 45) {
+            return '中上';
+        }
+        if ($percentile >= 25) {
+            return '中下';
+        }
+
+        return '待提升';
+    }
+
+    /**
+     * 综合当前表现、历史趋势、同群体位置,给出“整体水平”。
+     *
+     * @return array{
+     *   label:string,
+     *   composite_score:float,
+     *   current_score:float,
+     *   history_score:float,
+     *   peer_score:float,
+     *   difficulty_adjust:float
+     * }
+     */
+    private function buildV3OverallCompositeEvaluation(
+        ?float $scoreRate,
+        ?float $avgMastery,
+        array $difficultyInsight,
+        array $comparisonInsight,
+        string $fallbackLabel
+    ): array {
+        $currentScoreRatePct = $scoreRate !== null ? max(0.0, min(100.0, $scoreRate * 100.0)) : 50.0;
+        $currentMasteryPct = $avgMastery !== null ? max(0.0, min(100.0, $avgMastery * 100.0)) : 50.0;
+        $currentScore = (0.7 * $currentScoreRatePct) + (0.3 * $currentMasteryPct);
+
+        $history = $comparisonInsight['history'] ?? [];
+        $historyScore = 60.0;
+        if (! empty($history['is_first_exam'])) {
+            $historyScore = 60.0;
+        } elseif (! empty($history['low_baseline_guard'])) {
+            $historyScore = 60.0;
+        } elseif (! empty($history['has_data']) && isset($history['delta_score_rate'])) {
+            $delta = (float) $history['delta_score_rate']; // ±1
+            $historyScore = 60.0 + (200.0 * $delta); // delta 0.1 => +20
+            $historyScore = max(0.0, min(100.0, $historyScore));
+        }
+
+        $peers = $comparisonInsight['peers'] ?? [];
+        $peerScore = 60.0;
+        if (! empty($peers['show_line'])) {
+            if (isset($peers['percentile']) && $peers['percentile'] !== null) {
+                $peerScore = max(0.0, min(100.0, (float) $peers['percentile']));
+            } else {
+                $band = (string) ($peers['band'] ?? '暂无');
+                $peerScore = match ($band) {
+                    '领先' => 82.0,
+                    '中上' => 66.0,
+                    '中下' => 46.0,
+                    '待提升' => 28.0,
+                    default => 60.0,
+                };
+            }
+        }
+
+        $difficultyAdjust = 0.0;
+        $difficultyStatus = (string) ($difficultyInsight['status'] ?? '');
+        if ($difficultyStatus === '偏难') {
+            $difficultyAdjust = 5.0;
+        } elseif ($difficultyStatus === '偏易') {
+            $difficultyAdjust = -5.0;
+        }
+
+        $composite = (0.50 * $currentScore) + (0.25 * $historyScore) + (0.25 * $peerScore) + $difficultyAdjust;
+        $composite = max(0.0, min(100.0, $composite));
+
+        // 等级标准统一:
+        // S: 90-100, A: 75-89, B: 60-74, C: 40-59, D: 0-39
+        $grade = match (true) {
+            $composite >= 90.0 => 'S',
+            $composite >= 75.0 => 'A',
+            $composite >= 60.0 => 'B',
+            $composite >= 40.0 => 'C',
+            default => 'D',
+        };
+        $label = match ($grade) {
+            'S', 'A' => '优秀',
+            'B' => '良好',
+            'C' => '一般',
+            default => '需加强',
+        };
+
+        if ($scoreRate === null && $avgMastery === null) {
+            $label = $fallbackLabel !== '' ? $fallbackLabel : '待评估';
+        }
+
+        return [
+            'grade' => $grade,
+            'label' => $label,
+            'composite_score' => round($composite, 1),
+            'current_score' => round($currentScore, 1),
+            'history_score' => round($historyScore, 1),
+            'peer_score' => round($peerScore, 1),
+            'difficulty_adjust' => round($difficultyAdjust, 1),
+        ];
+    }
+
+    /**
+     * 首次出报告时,按得分档位随机抽取鼓励文案(每档10条)。
+     *
+     * 档位标准统一:
+     * A: 90-100, B: 75-89, C: 60-74, D: 40-59, E: 0-39
+     */
+    private function pickFirstExamEncouragementMessageByScore(?float $scoreRate): string
+    {
+        $score = 0.0;
+        if ($scoreRate !== null) {
+            $raw = (float) $scoreRate;
+            $score = $raw <= 1.0 ? ($raw * 100.0) : $raw;
+            $score = max(0.0, min(100.0, $score));
+        }
+
+        $bucket = match (true) {
+            $score >= 90.0 => 'A',
+            $score >= 75.0 => 'B',
+            $score >= 60.0 => 'C',
+            $score >= 40.0 => 'D',
+            default => 'E',
+        };
+
+        $messagesByBucket = [
+            'A' => [
+                '这次开局很稳,说明你的基础和状态都在线。',
+                '第一次就拿到高分,后续保持节奏会更强。',
+                '你的学习方法是有效的,继续按这个路径推进。',
+                '这是一个很好的起点,接下来可以适度挑战难题。',
+                '成绩很亮眼,说明你已经具备较强的掌握能力。',
+                '你的投入有明显回报,继续保持就会持续领先。',
+                '开局高分值得肯定,下一步重点是稳定输出。',
+                '这次表现优秀,后续可以往“又快又准”再升级。',
+                '你已经在高水平区间,继续打磨细节会更出色。',
+                '这是非常有竞争力的起步,继续冲就对了。',
+            ],
+            'B' => [
+                '这个分数是很不错的起点,方向完全正确。',
+                '你已经进入良好区间,再补几处薄弱点就能上台阶。',
+                '开局表现可圈可点,继续练会更稳定。',
+                '说明你有扎实基础,后续提升空间也很清晰。',
+                '这次成绩不错,下一步就是把失分点逐个清掉。',
+                '起步良好,继续保持专注,进步会很快。',
+                '你已经具备不错的能力,差的是一点点细节打磨。',
+                '这个起点很健康,后续很有机会冲到更高档。',
+                '成绩说明你在正轨上,继续按计划推进就行。',
+                '这次发挥稳定,接下来把短板补齐会很明显。',
+            ],
+            'C' => [
+                '这是正常且可提升的起点,先稳住基础最关键。',
+                '你已经有一定掌握度,接下来重点是补薄弱模块。',
+                '这个分数段提升通常很快,方向对了就会涨。',
+                '开局在中位区间,不焦虑,持续练习就会突破。',
+                '先把常错题型吃透,你的分数会明显上来。',
+                '这次结果能帮我们精准定位问题,价值很大。',
+                '起点清晰、空间也清晰,后续提升可期待。',
+                '你的基础在,下一步要把稳定性做出来。',
+                '这个阶段最怕放弃,最值得坚持。',
+                '继续按节奏推进,很快就能看到上升曲线。',
+            ],
+            'D' => [
+                '第一次这个分数不代表上限,只代表当前起点。',
+                '现在最重要的是先建立信心,再逐步提分。',
+                '这次结果很有价值,能帮你更精准地补基础。',
+                '先把核心概念补牢,分数会先稳再升。',
+                '这个阶段提升潜力很大,方法对了进步会很快。',
+                '不用和别人比,先和昨天的自己比就很好。',
+                '先做对“会做的题”,再攻“有难度的题”。',
+                '你现在需要的是节奏和耐心,不是否定自己。',
+                '起步偏低很常见,持续练习就会逐渐反转。',
+                '只要不放弃,这个分段通常最容易拉开增幅。',
+            ],
+            'E' => [
+                '第一次分数偏低很正常,先把学习路径走顺。',
+                '这不是结论,只是起点,我们从基础一点点重建。',
+                '先把会做题做稳,信心会先回来。',
+                '现在最关键的是“稳基础、慢提速”。',
+                '低分并不定义能力,持续训练才会定义结果。',
+                '先把核心知识补齐,后续提升会很明显。',
+                '今天看到的是起点,不是终点。',
+                '你需要的是清晰步骤,不是压力。',
+                '每次进步一点点,累计起来会很惊人。',
+                '从现在开始,踏实走每一步,结果一定会变。',
+            ],
+        ];
+
+        $messages = $messagesByBucket[$bucket] ?? $messagesByBucket['C'];
+        $idx = random_int(0, count($messages) - 1);
+
+        return $messages[$idx];
+    }
+
+    /**
+     * 为同群体区间提供可视化图标与颜色。
+     *
+     * @return array{icon:string,color:string}
+     */
+    private function resolvePeerBandVisual(string $band): array
+    {
+        return match ($band) {
+            '领先' => ['icon' => '▲', 'color' => '#16a34a'],
+            '中上' => ['icon' => '↗', 'color' => '#0ea5e9'],
+            '中下' => ['icon' => '↘', 'color' => '#f59e0b'],
+            '待提升' => ['icon' => '▼', 'color' => '#ef4444'],
+            default => ['icon' => '•', 'color' => '#64748b'],
+        };
+    }
+
     /**
      * 收集某学段根节点下的全量后代知识点(不含根节点本身)
      */
@@ -1749,6 +2313,7 @@ class ExamPdfExportService
                 'id' => $paper->paper_id,
                 'name' => $paper->paper_name,
                 'paper_type' => $paper->paper_type,
+                'difficulty_category' => $paper->difficulty_category,
                 'assemble_type_label' => $assembleTypeLabel,
                 'total_questions' => $paper->question_count,
                 'total_score' => $paper->total_score,
@@ -1983,6 +2548,7 @@ class ExamPdfExportService
                 'question_type' => $normalizedType,
                 'knowledge_point' => $kpCode,
                 'knowledge_point_name' => $kpName,
+                'difficulty' => isset($question->difficulty) ? (float) $question->difficulty : null,
                 'score' => $question->score,
                 'answer' => $this->formatNewlines($answer),  // 格式化换行
                 'solution' => $this->formatNewlines($solution),  // 格式化换行

+ 152 - 7
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -16,6 +16,38 @@
     $scoreRate = $summary['score_rate'] ?? null;
     $averageMastery = $summary['average_mastery'] ?? null;
     $overallLabel = $summary['overall_label'] ?? '待评估';
+    $difficultySummary = $summary['difficulty'] ?? [];
+    $comparisonSummary = $summary['comparison'] ?? [];
+    $overallLabelDetail = $summary['overall_label_detail'] ?? [];
+    $historySummary = $comparisonSummary['history'] ?? [];
+    $peerSummary = $comparisonSummary['peers'] ?? [];
+    $overallScore = isset($overallLabelDetail['composite_score']) ? (float) $overallLabelDetail['composite_score'] : null;
+    $overallGrade = (string) ($overallLabelDetail['grade'] ?? 'D');
+    $currentPart = (float) ($overallLabelDetail['current_score'] ?? 0);
+    $historyPart = (float) ($overallLabelDetail['history_score'] ?? 0);
+    $peerPart = (float) ($overallLabelDetail['peer_score'] ?? 0);
+    $adjustPart = (float) ($overallLabelDetail['difficulty_adjust'] ?? 0);
+    $compositeFormulaResult = (0.50 * $currentPart) + (0.25 * $historyPart) + (0.25 * $peerPart) + $adjustPart;
+    $overallBadge = function (string $grade): array {
+        return match ($grade) {
+            'S' => ['bg' => '#f5f3ff', 'border' => '#6d28d9', 'text' => '#6d28d9', 'class' => 'badge-s'],
+            'A' => ['bg' => '#ecfdf3', 'border' => '#22c55e', 'text' => '#166534', 'class' => 'badge-excellent'],
+            'B' => ['bg' => '#eff6ff', 'border' => '#3b82f6', 'text' => '#1d4ed8', 'class' => 'badge-good'],
+            'C' => ['bg' => '#fff7ed', 'border' => '#f59e0b', 'text' => '#b45309', 'class' => 'badge-average'],
+            default => ['bg' => '#fef2f2', 'border' => '#ef4444', 'text' => '#b91c1c', 'class' => 'badge-weak'],
+        };
+    };
+    $overallVisual = $overallBadge((string) $overallGrade);
+    $trendVisual = function (string $trend): array {
+        return match ($trend) {
+            '显著提升' => ['icon' => '▲', 'color' => '#16a34a'],
+            '小幅提升' => ['icon' => '↗', 'color' => '#0ea5e9'],
+            '基本持平' => ['icon' => '•', 'color' => '#64748b'],
+            '小幅回落' => ['icon' => '↘', 'color' => '#f59e0b'],
+            '明显回落' => ['icon' => '▼', 'color' => '#ef4444'],
+            default => ['icon' => '•', 'color' => '#64748b'],
+        };
+    };
 
     $statusColor = function (string $status): string {
         return match ($status) {
@@ -158,12 +190,72 @@
         .page { page-break-after: auto; }
         .header { text-align: left; margin-bottom: 16px; }
         .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
-        .meta-row { display: flex; justify-content: flex-start; gap: 16px; font-size: 13px; color: #475569; flex-wrap: wrap; }
         .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
         .section-title { font-size: 20px; margin-bottom: 10px; font-weight: 700; color: #0b3a75; border-left: 5px solid #3b82f6; padding-left: 10px; line-height: 1.3; }
-        .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; }
+        .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; position: relative; }
         .summary-list { margin: 0; padding-left: 18px; }
         .summary-list li { margin: 6px 0; font-size: 13px; }
+        .overall-badge {
+            position: absolute;
+            right: 14px;
+            top: 12px;
+            border-radius: 12px;
+            border: 0;
+            padding: 9px 16px;
+            min-width: 0;
+            width: auto;
+            text-align: center;
+            position: absolute;
+            overflow: hidden;
+            display: inline-block;
+            white-space: nowrap;
+            background: transparent !important;
+        }
+        .overall-badge .level { font-size: 28px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; }
+        .overall-badge .score { font-size: 13px; margin-top: 3px; }
+        .overall-badge.badge-s {
+            border: 5px solid #6d28d9;
+            border-radius: 14px;
+            box-shadow: none;
+            transform: rotate(-7deg);
+        }
+        .overall-badge.badge-s::before {
+            content: "";
+            position: absolute;
+            inset: 4px;
+            border: 2px dashed rgba(109, 40, 217, 0.65);
+            border-radius: 10px;
+            pointer-events: none;
+        }
+        .overall-badge.badge-s .level {
+            letter-spacing: 2px;
+            text-shadow: 0 1px 0 rgba(109, 40, 217, 0.24);
+        }
+        .overall-badge.badge-excellent {
+            border: 3px double #16a34a;
+            border-radius: 999px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-good {
+            border: 2px solid #2563eb;
+            border-radius: 10px;
+            clip-path: polygon(6% 0, 94% 0, 100% 50%, 94% 100%, 6% 100%, 0 50%);
+            box-shadow: none;
+        }
+        .overall-badge.badge-average {
+            border: 2px dashed #d97706;
+            border-radius: 14px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-weak {
+            border-left: 3px solid #ef4444;
+            border-right: 0;
+            border-top: 0;
+            border-bottom: 2px solid #ef4444;
+            border-radius: 0 10px 10px 0;
+            box-shadow: none;
+        }
+        .overall-meta { margin-top: 8px; font-size: 9px; color: #64748b; line-height: 1.6; white-space: nowrap; }
         .radar-center { text-align: center; }
         .legend { margin-top: 8px; font-size: 12px; color: #475569; }
         .legend span { margin: 0 8px; }
@@ -271,15 +363,15 @@
 <div class="page">
     <div class="header">
         <h1 class="paper-title">学情分析报告</h1>
-        <div class="meta-row">
-            <span>学生姓名:{{ $student['name'] ?? '未知' }}</span>
-            <span>报告日期:{{ now()->format('Y年n月j日') }}</span>
-        </div>
     </div>
 
     <div class="section">
         <div class="section-title">一、总体评估</div>
         <div class="card">
+            <div class="overall-badge {{ $overallVisual['class'] ?? '' }}"
+                 style="border-color:{{ $overallVisual['border'] }}; color:{{ $overallVisual['text'] }};">
+                <div class="level">{{ $overallGrade }}</div>
+            </div>
             <ul class="summary-list">
                 <li>本次诊断得分:
                     @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
@@ -290,8 +382,61 @@
                 </li>
                 <li>得分率:{{ $scoreRate !== null ? number_format((float) $scoreRate * 100, 1) . '%' : '暂无得分率' }}</li>
                 <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
-                <li>整体水平:{{ $overallLabel }}</li>
+                <li>
+                    难度匹配:
+                    @if(!empty($difficultySummary['target_label']) && isset($difficultySummary['actual_average_difficulty']))
+                        目标 {{ $difficultySummary['target_label'] }}
+                        @if(!empty($difficultySummary['target_range']))
+                            ({{ number_format((float)($difficultySummary['target_range']['min'] ?? 0), 2) }}~{{ number_format((float)($difficultySummary['target_range']['max'] ?? 0), 2) }})
+                        @endif
+                        ,实际 {{ number_format((float)($difficultySummary['actual_average_difficulty'] ?? 0), 3) }}
+                        ({{ $difficultySummary['status'] ?? '暂无' }})
+                    @else
+                        暂无难度匹配数据
+                    @endif
+                </li>
+                @if(!empty($difficultySummary['explain']))
+                    <li>难度说明:{{ $difficultySummary['explain'] }}</li>
+                @endif
+                <li>
+                    与历史自己对比:
+                    @if(!empty($historySummary['is_first_exam']))
+                        {{ $historySummary['message'] ?? '这是你的第一次分析报告,先积累样本再看趋势。' }}
+                    @elseif(!empty($historySummary['low_baseline_guard']))
+                        {{ $historySummary['message'] ?? '历史基线偏低,建议看连续趋势。' }}
+                    @elseif(!empty($historySummary['has_data']))
+                        @php
+                            $trendText = (string)($historySummary['trend'] ?? '—');
+                            $tVisual = $trendVisual($trendText);
+                        @endphp
+                        近几次均值对比:
+                        {{ number_format((float)($historySummary['baseline_score_rate'] ?? 0) * 100, 1) }}%,
+                        本次{{ ($historySummary['delta_score_rate'] ?? 0) >= 0 ? '提升' : '回落' }}
+                        {{ number_format(abs((float)($historySummary['delta_score_rate'] ?? 0)) * 100, 1) }}%
+                        (<span style="color:{{ $tVisual['color'] ?? '#64748b' }}; font-weight:600;">{{ $tVisual['icon'] ?? '•' }} {{ $trendText }}</span>)
+                    @else
+                        {{ $historySummary['message'] ?? '历史样本不足' }}
+                    @endif
+                </li>
+                @if(!empty($peerSummary['show_line']))
+                    <li>
+                        与同群体对比:
+                        {{ $peerSummary['message'] ?? '' }}
+                        (<span style="color:{{ $peerSummary['band_color'] ?? '#64748b' }}; font-weight:600;">{{ $peerSummary['band_icon'] ?? '•' }} {{ $peerSummary['band'] ?? '—' }}</span>)
+                    </li>
+                @endif
+                <li>
+                    整体水平:
+                    @if($overallScore !== null)
+                        {{ number_format($overallScore, 1) }} 分({{ $overallGrade }})
+                    @else
+                        待计算
+                    @endif
+                </li>
             </ul>
+            <div class="overall-meta">
+                规则:综合分 = 当前50% + 历史25% + 同群体25% + 难度校正,即:(({{ number_format($scoreRate !== null ? (float)$scoreRate * 100 : 0, 1) }}×70% + {{ number_format($averageMastery !== null ? (float)$averageMastery * 100 : 0, 1) }}×30%)×50%) + {{ number_format($historyPart, 1) }}×25% + {{ number_format($peerPart, 1) }}×25% + {{ number_format($adjustPart, 1) }} = {{ number_format($overallScore ?? $compositeFormulaResult, 1) }}
+            </div>
         </div>
     </div>