|
|
@@ -28,7 +28,6 @@ use Symfony\Component\Process\Process;
|
|
|
class ExamPdfExportService
|
|
|
{
|
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
|
- private ?array $knowledgePointMetaCache = null;
|
|
|
private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
|
|
|
private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
|
|
|
|
|
|
@@ -323,11 +322,8 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 组装V3报告展示数据(模块化)
|
|
|
- $templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
|
|
|
-
|
|
|
- // 渲染HTML(V3模板)
|
|
|
- $html = view('exam-analysis.pdf-report-v3', $templateData)->render();
|
|
|
+ // 渲染HTML
|
|
|
+ $html = view('exam-analysis.pdf-report', $templateData)->render();
|
|
|
if (! $html) {
|
|
|
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
|
|
|
|
@@ -374,616 +370,6 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 构建学情报告 V3 展示数据(模块化视图)
|
|
|
- */
|
|
|
- private function buildAnalysisReportV3Data(array $templateData): array
|
|
|
- {
|
|
|
- $rawAnalysis = $templateData['analysis_data'] ?? [];
|
|
|
- $kpRows = $rawAnalysis['knowledge_point_analysis'] ?? [];
|
|
|
- $questionRows = $rawAnalysis['question_analysis'] ?? [];
|
|
|
- $overallSummary = $rawAnalysis['overall_summary'] ?? [];
|
|
|
-
|
|
|
- $kpMeta = $this->getKnowledgePointMetaMap();
|
|
|
- $grade = (string) ($templateData['student']['grade'] ?? '');
|
|
|
- $stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
|
|
|
- $profile = $this->getRadarProfileByStage($stage);
|
|
|
- $rootCode = (string) ($profile['root_code'] ?? 'M00');
|
|
|
- $moduleCodes = $profile['module_codes'] ?? [];
|
|
|
- $moduleNames = $profile['module_names'] ?? [];
|
|
|
- $dimensionDefs = $profile['dimension_defs'] ?? [];
|
|
|
-
|
|
|
- $moduleAgg = [];
|
|
|
- foreach ($moduleCodes as $moduleCode) {
|
|
|
- $moduleAgg[$moduleCode] = [
|
|
|
- 'mastery_sum' => 0.0,
|
|
|
- 'mastery_count' => 0,
|
|
|
- 'question_max' => 0.0,
|
|
|
- 'question_obtained' => 0.0,
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- $fullParentMap = $templateData['full_parent_mastery_levels'] ?? [];
|
|
|
- if (! is_array($fullParentMap)) {
|
|
|
- $fullParentMap = [];
|
|
|
- }
|
|
|
-
|
|
|
- foreach ($kpRows as $row) {
|
|
|
- $kpId = trim((string) ($row['kp_id'] ?? ''));
|
|
|
- if ($kpId === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
|
|
|
- if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- $mastery = (float) ($row['mastery_level'] ?? 0);
|
|
|
- $moduleAgg[$moduleCode]['mastery_sum'] += $mastery;
|
|
|
- $moduleAgg[$moduleCode]['mastery_count']++;
|
|
|
- }
|
|
|
-
|
|
|
- foreach ($questionRows as $questionRow) {
|
|
|
- $maxScore = (float) ($questionRow['max_score'] ?? 0);
|
|
|
- $obtainedScore = (float) ($questionRow['score_obtained'] ?? 0);
|
|
|
- if ($maxScore <= 0) {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- $kpId = '';
|
|
|
- $questionKps = $questionRow['knowledge_points'] ?? [];
|
|
|
- if (is_array($questionKps) && ! empty($questionKps)) {
|
|
|
- $first = $questionKps[0] ?? [];
|
|
|
- if (is_array($first)) {
|
|
|
- $kpId = trim((string) ($first['kp_id'] ?? ''));
|
|
|
- }
|
|
|
- }
|
|
|
- if ($kpId === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
|
|
|
- if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- $moduleAgg[$moduleCode]['question_max'] += $maxScore;
|
|
|
- $moduleAgg[$moduleCode]['question_obtained'] += max(0.0, min($obtainedScore, $maxScore));
|
|
|
- }
|
|
|
-
|
|
|
- $moduleRows = [];
|
|
|
- foreach ($moduleCodes as $moduleCode) {
|
|
|
- $agg = $moduleAgg[$moduleCode] ?? null;
|
|
|
- $masteryLevel = null;
|
|
|
- $kpCount = 0;
|
|
|
-
|
|
|
- if (isset($fullParentMap[$moduleCode])) {
|
|
|
- $parentRow = $fullParentMap[$moduleCode];
|
|
|
- $masteryLevel = isset($parentRow['mastery_level']) ? (float) $parentRow['mastery_level'] : null;
|
|
|
- $kpCount = (int) ($parentRow['children_total_count'] ?? 0);
|
|
|
- }
|
|
|
-
|
|
|
- if ($masteryLevel === null && $agg && $agg['mastery_count'] > 0) {
|
|
|
- $masteryLevel = $agg['mastery_sum'] / $agg['mastery_count'];
|
|
|
- }
|
|
|
- $masteryScore5 = $masteryLevel !== null ? round($masteryLevel * 5, 2) : null;
|
|
|
- $examMax = (float) ($agg['question_max'] ?? 0.0);
|
|
|
- $examObtained = (float) ($agg['question_obtained'] ?? 0.0);
|
|
|
- $examRate = $examMax > 0 ? round($examObtained / $examMax, 4) : null;
|
|
|
- if ($kpCount <= 0) {
|
|
|
- $kpCount = (int) ($agg['mastery_count'] ?? 0);
|
|
|
- }
|
|
|
-
|
|
|
- $moduleRows[] = [
|
|
|
- 'module_code' => $moduleCode,
|
|
|
- 'module_name' => $moduleNames[$moduleCode] ?? $moduleCode,
|
|
|
- 'mastery_level' => $masteryLevel !== null ? round($masteryLevel, 4) : null,
|
|
|
- 'mastery_score_5' => $masteryScore5,
|
|
|
- 'status' => $this->resolveMasteryStatus($masteryLevel),
|
|
|
- 'kp_count' => $kpCount,
|
|
|
- 'exam_max_score' => round($examMax, 2),
|
|
|
- 'exam_obtained_score' => round($examObtained, 2),
|
|
|
- 'exam_score_rate' => $examRate,
|
|
|
- 'ability_text' => $this->defaultModuleAbilityText($moduleCode, $moduleNames[$moduleCode] ?? $moduleCode),
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- $moduleMap = [];
|
|
|
- foreach ($moduleRows as $moduleRow) {
|
|
|
- $moduleMap[$moduleRow['module_code']] = $moduleRow;
|
|
|
- }
|
|
|
-
|
|
|
- $dimensions = [];
|
|
|
- foreach ($dimensionDefs as $def) {
|
|
|
- $masteryWeightedSum = 0.0;
|
|
|
- $weightSum = 0.0;
|
|
|
- $scoreMax = 0.0;
|
|
|
- $scoreObtained = 0.0;
|
|
|
- $kpCount = 0;
|
|
|
-
|
|
|
- foreach ($def['modules'] as $moduleCode) {
|
|
|
- $row = $moduleMap[$moduleCode] ?? null;
|
|
|
- if (! $row) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $weight = max(1, (int) ($row['kp_count'] ?? 0));
|
|
|
- if ($row['mastery_level'] !== null) {
|
|
|
- $masteryWeightedSum += ((float) $row['mastery_level']) * $weight;
|
|
|
- $weightSum += $weight;
|
|
|
- }
|
|
|
- $scoreMax += (float) ($row['exam_max_score'] ?? 0);
|
|
|
- $scoreObtained += (float) ($row['exam_obtained_score'] ?? 0);
|
|
|
- $kpCount += (int) ($row['kp_count'] ?? 0);
|
|
|
- }
|
|
|
-
|
|
|
- $mastery = $weightSum > 0 ? ($masteryWeightedSum / $weightSum) : null;
|
|
|
- $score5 = $mastery !== null ? round($mastery * 5, 2) : null;
|
|
|
- $scoreRate = $scoreMax > 0 ? round($scoreObtained / $scoreMax, 4) : null;
|
|
|
-
|
|
|
- $dimensions[] = [
|
|
|
- 'id' => $def['id'],
|
|
|
- 'name' => $def['name'],
|
|
|
- 'module_codes' => $def['modules'],
|
|
|
- 'mastery_level' => $mastery !== null ? round($mastery, 4) : null,
|
|
|
- 'score_5' => $score5,
|
|
|
- 'status' => $this->resolveMasteryStatus($mastery),
|
|
|
- 'kp_count' => $kpCount,
|
|
|
- 'exam_score_rate' => $scoreRate,
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- $allMax = 0.0;
|
|
|
- $allObtained = 0.0;
|
|
|
- foreach ($moduleRows as $moduleRow) {
|
|
|
- $allMax += (float) ($moduleRow['exam_max_score'] ?? 0);
|
|
|
- $allObtained += (float) ($moduleRow['exam_obtained_score'] ?? 0);
|
|
|
- }
|
|
|
- $scoreRate = $allMax > 0 ? round($allObtained / $allMax, 4) : null;
|
|
|
- $avgMastery = isset($overallSummary['average_mastery'])
|
|
|
- ? (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);
|
|
|
-
|
|
|
- // 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
|
|
|
- // 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
|
|
|
- $radar = [];
|
|
|
- $hitParentMap = $templateData['parent_mastery_levels'] ?? [];
|
|
|
- if (! is_array($hitParentMap)) {
|
|
|
- $hitParentMap = [];
|
|
|
- }
|
|
|
-
|
|
|
- // 第二块雷达图补充:父轴下子知识点扩散(突出本卷掌握度变化)
|
|
|
- $radarChildrenByModule = [];
|
|
|
- foreach ($kpRows as $row) {
|
|
|
- $kpId = trim((string) ($row['kp_id'] ?? ''));
|
|
|
- if ($kpId === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
|
|
|
- if (! $moduleCode || ! in_array($moduleCode, $moduleCodes, true)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $parentCode = (string) ($kpMeta[$kpId]['parent_kp_code'] ?? '');
|
|
|
- $depth = $this->resolveDepthToModule($kpId, $moduleCode, $kpMeta);
|
|
|
- $change = $row['change'] ?? null;
|
|
|
- $radarChildrenByModule[$moduleCode][] = [
|
|
|
- 'code' => $kpId,
|
|
|
- 'name' => $kpMeta[$kpId]['name'] ?? $kpId,
|
|
|
- 'parent_code' => $parentCode,
|
|
|
- 'depth' => $depth,
|
|
|
- 'change' => $change !== null ? round((float) $change, 4) : null,
|
|
|
- 'mastery_level' => isset($row['mastery_level']) ? round((float) $row['mastery_level'], 4) : null,
|
|
|
- 'changed' => $change !== null && abs((float) $change) > 0.0001,
|
|
|
- ];
|
|
|
- }
|
|
|
- foreach ($radarChildrenByModule as $moduleCode => $items) {
|
|
|
- usort($items, static function ($a, $b) {
|
|
|
- $da = (int) ($a['depth'] ?? 1);
|
|
|
- $db = (int) ($b['depth'] ?? 1);
|
|
|
- if ($da !== $db) {
|
|
|
- return $da <=> $db;
|
|
|
- }
|
|
|
- $ca = abs((float) ($a['change'] ?? 0));
|
|
|
- $cb = abs((float) ($b['change'] ?? 0));
|
|
|
- if ($ca === $cb) {
|
|
|
- return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
|
|
|
- }
|
|
|
- return $cb <=> $ca;
|
|
|
- });
|
|
|
- $radarChildrenByModule[$moduleCode] = $items;
|
|
|
- }
|
|
|
-
|
|
|
- foreach ($moduleCodes as $moduleCode) {
|
|
|
- $hitParentRow = $hitParentMap[$moduleCode] ?? null;
|
|
|
- $hitAvg = null;
|
|
|
- if (is_array($hitParentRow) && isset($hitParentRow['children_hit_avg_mastery'])) {
|
|
|
- $hitAvg = (float) $hitParentRow['children_hit_avg_mastery'];
|
|
|
- }
|
|
|
-
|
|
|
- $fullParentRow = $fullParentMap[$moduleCode] ?? null;
|
|
|
- $fullMastery = null;
|
|
|
- if (is_array($fullParentRow) && isset($fullParentRow['mastery_level'])) {
|
|
|
- $fullMastery = (float) $fullParentRow['mastery_level'];
|
|
|
- }
|
|
|
-
|
|
|
- $mastery = $hitAvg ?? $fullMastery;
|
|
|
- $hasMastery = $mastery !== null;
|
|
|
- $score5 = $hasMastery ? round($mastery * 5, 2) : 0.0;
|
|
|
- $status = $hasMastery ? $this->resolveMasteryStatus($mastery) : '未涉及';
|
|
|
-
|
|
|
- $radar[] = [
|
|
|
- 'code' => $moduleCode,
|
|
|
- 'name' => $moduleNames[$moduleCode] ?? $moduleCode,
|
|
|
- 'value' => $score5,
|
|
|
- 'status' => $status,
|
|
|
- 'has_mastery' => $hasMastery,
|
|
|
- 'children' => $radarChildrenByModule[$moduleCode] ?? [],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- $foundation = $paths['keep'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '核心模块');
|
|
|
- $breakthrough = $paths['boost'][0]['name'] ?? ($paths['key'][0]['name'] ?? '重点模块');
|
|
|
- $minimum = $paths['key'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '薄弱模块');
|
|
|
-
|
|
|
- return [
|
|
|
- 'summary' => [
|
|
|
- 'score_obtained' => round($allObtained, 1),
|
|
|
- 'score_total' => round($allMax, 1),
|
|
|
- 'score_rate' => $scoreRate,
|
|
|
- 'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
|
|
|
- 'overall_label' => $overallLabel,
|
|
|
- 'stage' => $stage,
|
|
|
- ],
|
|
|
- 'radar' => $radar,
|
|
|
- 'modules' => $moduleRows,
|
|
|
- 'dimensions' => $dimensions,
|
|
|
- 'paths' => $paths,
|
|
|
- 'overall_plan' => [
|
|
|
- "保基础(60%):稳住{$foundation}",
|
|
|
- "拉中档(30%):突破{$breakthrough}",
|
|
|
- "冲压轴(10%):优先补齐{$minimum}",
|
|
|
- ],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- private function buildV3LearningPaths(array $dimensions): array
|
|
|
- {
|
|
|
- $items = [];
|
|
|
- foreach ($dimensions as $dimension) {
|
|
|
- if (! isset($dimension['mastery_level']) || $dimension['mastery_level'] === null) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $items[] = [
|
|
|
- 'name' => (string) ($dimension['name'] ?? ''),
|
|
|
- 'mastery_level' => (float) $dimension['mastery_level'],
|
|
|
- 'score_5' => (float) ($dimension['score_5'] ?? 0),
|
|
|
- 'status' => $dimension['status'] ?? '一般',
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- usort($items, fn ($a, $b) => $a['mastery_level'] <=> $b['mastery_level']);
|
|
|
- $lowToHigh = $items;
|
|
|
- $highToLow = array_reverse($items);
|
|
|
-
|
|
|
- $keep = array_values(array_filter($highToLow, fn ($i) => $i['mastery_level'] >= 0.75));
|
|
|
- $boost = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] >= 0.5 && $i['mastery_level'] < 0.75));
|
|
|
- $key = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] < 0.5));
|
|
|
-
|
|
|
- if (empty($keep) && ! empty($highToLow)) {
|
|
|
- $keep[] = $highToLow[0];
|
|
|
- }
|
|
|
- if (empty($boost) && count($lowToHigh) > 1) {
|
|
|
- $boost[] = $lowToHigh[(int) floor((count($lowToHigh) - 1) / 2)];
|
|
|
- }
|
|
|
- if (empty($key) && ! empty($lowToHigh)) {
|
|
|
- $key[] = $lowToHigh[0];
|
|
|
- }
|
|
|
-
|
|
|
- return [
|
|
|
- 'keep' => array_slice($keep, 0, 2),
|
|
|
- 'boost' => array_slice($boost, 0, 2),
|
|
|
- 'key' => array_slice($key, 0, 2),
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- private function resolveMasteryStatus(?float $mastery): string
|
|
|
- {
|
|
|
- if ($mastery === null) {
|
|
|
- return '暂无';
|
|
|
- }
|
|
|
- if ($mastery >= 0.8) {
|
|
|
- return '良好';
|
|
|
- }
|
|
|
- if ($mastery >= 0.6) {
|
|
|
- return '一般';
|
|
|
- }
|
|
|
-
|
|
|
- return '薄弱';
|
|
|
- }
|
|
|
-
|
|
|
- private function resolveOverallPerformanceLabel(?float $avgMastery): string
|
|
|
- {
|
|
|
- if ($avgMastery === null) {
|
|
|
- return '待评估';
|
|
|
- }
|
|
|
- if ($avgMastery >= 0.85) {
|
|
|
- return '优秀';
|
|
|
- }
|
|
|
- if ($avgMastery >= 0.7) {
|
|
|
- return '良好';
|
|
|
- }
|
|
|
- if ($avgMastery >= 0.55) {
|
|
|
- return '一般';
|
|
|
- }
|
|
|
-
|
|
|
- return '需提升';
|
|
|
- }
|
|
|
-
|
|
|
- private function averageByKey(array $rows, string $key): ?float
|
|
|
- {
|
|
|
- if (empty($rows)) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- $sum = 0.0;
|
|
|
- $count = 0;
|
|
|
- foreach ($rows as $row) {
|
|
|
- if (! isset($row[$key])) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $sum += (float) $row[$key];
|
|
|
- $count++;
|
|
|
- }
|
|
|
-
|
|
|
- return $count > 0 ? ($sum / $count) : null;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 收集某学段根节点下的全量后代知识点(不含根节点本身)
|
|
|
- */
|
|
|
- private function collectStageKnowledgePoints(string $rootCode): array
|
|
|
- {
|
|
|
- if ($rootCode === '') {
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- $rows = DB::connection('mysql')
|
|
|
- ->table('knowledge_points')
|
|
|
- ->select('kp_code', 'parent_kp_code')
|
|
|
- ->get();
|
|
|
-
|
|
|
- $children = [];
|
|
|
- foreach ($rows as $row) {
|
|
|
- $code = trim((string) ($row->kp_code ?? ''));
|
|
|
- $parent = trim((string) ($row->parent_kp_code ?? ''));
|
|
|
- if ($code === '' || $parent === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $children[$parent][] = $code;
|
|
|
- }
|
|
|
-
|
|
|
- $result = [];
|
|
|
- $queue = [$rootCode];
|
|
|
- $visited = [];
|
|
|
- while (! empty($queue)) {
|
|
|
- $parent = array_shift($queue);
|
|
|
- if (isset($visited[$parent])) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $visited[$parent] = true;
|
|
|
- foreach (($children[$parent] ?? []) as $childCode) {
|
|
|
- if ($childCode === $rootCode) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $result[] = $childCode;
|
|
|
- $queue[] = $childCode;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return array_values(array_unique($result));
|
|
|
- } catch (\Throwable $e) {
|
|
|
- Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
|
|
|
- 'root_code' => $rootCode,
|
|
|
- 'error' => $e->getMessage(),
|
|
|
- ]);
|
|
|
-
|
|
|
- return [];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private function defaultModuleAbilityText(string $moduleCode, ?string $moduleName = null): string
|
|
|
- {
|
|
|
- return match ($moduleCode) {
|
|
|
- 'M01' => '数感与运算、代数式处理、基础建模',
|
|
|
- 'M02' => '方程化归、分类讨论、不等式推理',
|
|
|
- 'M03' => '图形性质识别、辅助线与逻辑证明',
|
|
|
- 'M04' => '图形变化、度量计算、坐标与变换',
|
|
|
- 'M05' => '相似迁移、比例推导、勾股应用',
|
|
|
- 'M06' => '数据分析、统计图表、概率判断',
|
|
|
- 'M07' => '函数建模、图像理解、函数性质迁移',
|
|
|
- 'S01_000' => '集合表示、集合运算、集合关系判断',
|
|
|
- 'S02_000' => '命题结构、逻辑推理、充分必要条件辨析',
|
|
|
- 'S03_000' => '不等式变形、比较法、恒成立问题处理',
|
|
|
- 'S04_000' => '函数模型、图像性质、函数迁移应用',
|
|
|
- 'S05_000' => '导数概念、单调最值、切线与优化问题',
|
|
|
- 'S06_000' => '三角函数图像、恒等变换、向量运算',
|
|
|
- 'S07_000' => '空间想象、立体几何证明与计算',
|
|
|
- 'S08_000' => '坐标法、轨迹方程、圆锥曲线综合',
|
|
|
- 'S09_000' => '递推与通项、求和技巧、数列建模',
|
|
|
- 'S10_000' => '概率模型、统计推断、随机变量分析',
|
|
|
- 'S11_000' => '复数运算、代数几何意义转换',
|
|
|
- default => '综合能力',
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- private function resolveRadarStage(string $grade, array $fullParentLevels = []): string
|
|
|
- {
|
|
|
- $g = trim($grade);
|
|
|
- if ($g !== '') {
|
|
|
- if (preg_match('/高一|高二|高三/u', $g)) {
|
|
|
- return 'high';
|
|
|
- }
|
|
|
- if (preg_match('/\d+/', $g, $m)) {
|
|
|
- $n = (int) $m[0];
|
|
|
- if ($n >= 10) {
|
|
|
- return 'high';
|
|
|
- }
|
|
|
- if ($n > 0 && $n <= 9) {
|
|
|
- return 'junior';
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- foreach (array_keys($fullParentLevels) as $kpCode) {
|
|
|
- if (str_starts_with((string) $kpCode, 'S')) {
|
|
|
- return 'high';
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return 'junior';
|
|
|
- }
|
|
|
-
|
|
|
- private function getRadarProfileByStage(string $stage): array
|
|
|
- {
|
|
|
- if ($stage === 'high') {
|
|
|
- $moduleNames = [
|
|
|
- 'S01_000' => '集合',
|
|
|
- 'S02_000' => '逻辑用语',
|
|
|
- 'S03_000' => '不等式',
|
|
|
- 'S04_000' => '函数',
|
|
|
- 'S05_000' => '导数',
|
|
|
- 'S06_000' => '三角函数与向量',
|
|
|
- 'S07_000' => '立体几何',
|
|
|
- 'S08_000' => '解析几何',
|
|
|
- 'S09_000' => '数列',
|
|
|
- 'S10_000' => '概率统计',
|
|
|
- 'S11_000' => '复数',
|
|
|
- ];
|
|
|
- $dimensionDefs = [];
|
|
|
- foreach ($moduleNames as $code => $name) {
|
|
|
- $dimensionDefs[] = [
|
|
|
- 'id' => strtolower(str_replace('_', '', $code)),
|
|
|
- 'name' => $name,
|
|
|
- 'modules' => [$code],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- return [
|
|
|
- 'root_code' => 'S000_000',
|
|
|
- 'module_codes' => array_keys($moduleNames),
|
|
|
- 'module_names' => $moduleNames,
|
|
|
- 'dimension_defs' => $dimensionDefs,
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- return [
|
|
|
- 'root_code' => 'M00',
|
|
|
- 'module_codes' => ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07'],
|
|
|
- 'module_names' => [
|
|
|
- 'M01' => '数与代数',
|
|
|
- 'M02' => '方程与不等式',
|
|
|
- 'M03' => '图形性质',
|
|
|
- 'M04' => '图形变化与度量',
|
|
|
- 'M05' => '相似与勾股',
|
|
|
- 'M06' => '统计与概率',
|
|
|
- 'M07' => '函数',
|
|
|
- ],
|
|
|
- 'dimension_defs' => [
|
|
|
- ['id' => 'number_expr', 'name' => '数与式', 'modules' => ['M01']],
|
|
|
- ['id' => 'equation', 'name' => '方程与不等式', 'modules' => ['M02']],
|
|
|
- ['id' => 'function', 'name' => '函数', 'modules' => ['M07']],
|
|
|
- ['id' => 'shape_change', 'name' => '图形的变化', 'modules' => ['M04']],
|
|
|
- ['id' => 'shape_property', 'name' => '图形的性质', 'modules' => ['M03', 'M05']],
|
|
|
- ['id' => 'stat_prob', 'name' => '统计与概率', 'modules' => ['M06']],
|
|
|
- ],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- private function getKnowledgePointMetaMap(): array
|
|
|
- {
|
|
|
- if ($this->knowledgePointMetaCache !== null) {
|
|
|
- return $this->knowledgePointMetaCache;
|
|
|
- }
|
|
|
-
|
|
|
- $rows = DB::connection('mysql')
|
|
|
- ->table('knowledge_points')
|
|
|
- ->select(['kp_code', 'name', 'parent_kp_code'])
|
|
|
- ->get();
|
|
|
-
|
|
|
- $map = [];
|
|
|
- foreach ($rows as $row) {
|
|
|
- $code = trim((string) ($row->kp_code ?? ''));
|
|
|
- if ($code === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $map[$code] = [
|
|
|
- 'name' => (string) ($row->name ?? $code),
|
|
|
- 'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- $this->knowledgePointMetaCache = $map;
|
|
|
-
|
|
|
- return $map;
|
|
|
- }
|
|
|
-
|
|
|
- private function mapKpToJuniorModule(string $kpCode, array $kpMeta): ?string
|
|
|
- {
|
|
|
- return $this->mapKpToStageModule($kpCode, $kpMeta, 'M00');
|
|
|
- }
|
|
|
-
|
|
|
- private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
|
|
|
- {
|
|
|
- $code = trim($kpCode);
|
|
|
- if ($code === '' || empty($kpMeta[$code])) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $cursor = $code;
|
|
|
- for ($depth = 0; $depth < 16; $depth++) {
|
|
|
- $node = $kpMeta[$cursor] ?? null;
|
|
|
- if (! $node) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $parent = (string) ($node['parent_kp_code'] ?? '');
|
|
|
- if ($parent === $rootCode) {
|
|
|
- return $cursor;
|
|
|
- }
|
|
|
- if ($parent === '' || $parent === $cursor) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $cursor = $parent;
|
|
|
- }
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- private function resolveDepthToModule(string $kpCode, string $moduleCode, array $kpMeta): int
|
|
|
- {
|
|
|
- $depth = 1;
|
|
|
- $cursor = trim($kpCode);
|
|
|
- for ($i = 0; $i < 24; $i++) {
|
|
|
- if ($cursor === '' || ! isset($kpMeta[$cursor])) {
|
|
|
- break;
|
|
|
- }
|
|
|
- if ($cursor === $moduleCode) {
|
|
|
- return max(1, $depth - 1);
|
|
|
- }
|
|
|
- $parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? ''));
|
|
|
- if ($parent === '' || $parent === $cursor) {
|
|
|
- break;
|
|
|
- }
|
|
|
- $cursor = $parent;
|
|
|
- $depth++;
|
|
|
- }
|
|
|
-
|
|
|
- return max(1, $depth);
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* 生成合并PDF(试卷 + 判卷)
|
|
|
* 先分别生成两个PDF,然后合并
|
|
|
@@ -1292,7 +678,7 @@ class ExamPdfExportService
|
|
|
'header_title' => $examCode,
|
|
|
'exam_pdf_title' => '试卷_'.$examCode,
|
|
|
'grading_pdf_title' => '判卷_'.$examCode,
|
|
|
- 'knowledge_pdf_title' => '知识点讲解_'.$examCode,
|
|
|
+ 'knowledge_pdf_title' => '知识点梳理_'.$examCode,
|
|
|
];
|
|
|
|
|
|
$html = view($viewName, [
|
|
|
@@ -1437,7 +823,6 @@ class ExamPdfExportService
|
|
|
// 【修复】优先使用analysisData中的knowledge_point_analysis数据
|
|
|
$masteryData = [];
|
|
|
$parentMasteryLevels = []; // 新增:父节点掌握度数据
|
|
|
- $allParentMasteryLevelsRaw = []; // 全量父节点掌握度(非本卷过滤)
|
|
|
Log::info('ExamPdfExportService: 开始处理掌握度数据', [
|
|
|
'student_id' => $studentId,
|
|
|
'analysisData_keys' => array_keys($analysisData),
|
|
|
@@ -1510,7 +895,6 @@ class ExamPdfExportService
|
|
|
// 获取所有父节点掌握度
|
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
|
$allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
|
|
|
- $allParentMasteryLevelsRaw = $allParentMasteryLevels;
|
|
|
$overviewDetails = $masteryOverview['details'] ?? [];
|
|
|
foreach ($overviewDetails as $detail) {
|
|
|
if (is_object($detail)) {
|
|
|
@@ -1605,7 +989,6 @@ class ExamPdfExportService
|
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
|
$masteryData = $masteryOverview['details'] ?? [];
|
|
|
$parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
|
|
|
- $allParentMasteryLevelsRaw = $parentMasteryLevels;
|
|
|
|
|
|
// 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
|
|
|
if (! empty($masteryData) && is_array($masteryData)) {
|
|
|
@@ -1731,12 +1114,6 @@ class ExamPdfExportService
|
|
|
$masteryMap,
|
|
|
$snapshotMasteryData
|
|
|
);
|
|
|
- $processedFullParentMastery = $this->buildParentMasteryFromAllParents(
|
|
|
- $allParentMasteryLevelsRaw,
|
|
|
- $kpNameMap,
|
|
|
- $masteryMap,
|
|
|
- $snapshotMasteryData
|
|
|
- );
|
|
|
|
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
|
'raw_count' => count($parentMasteryLevels),
|
|
|
@@ -1760,7 +1137,6 @@ class ExamPdfExportService
|
|
|
'mastery' => $masterySummary,
|
|
|
'exam_hit_kp_codes' => $examQuestionKpCodes,
|
|
|
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
|
|
|
- 'full_parent_mastery_levels' => $processedFullParentMastery,
|
|
|
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
|
|
|
'recommendations' => $recommendations,
|
|
|
'analysis_data' => $analysisData,
|
|
|
@@ -2548,11 +1924,11 @@ class ExamPdfExportService
|
|
|
'has_katex_local' => $hasKatexLocal,
|
|
|
]);
|
|
|
|
|
|
- // 如果既没有 CDN 也没有本地链接,跳过
|
|
|
+ // 如果既没有 CDN 也没有本地链接,仍尝试注入 KaTeX 关系符通用修复
|
|
|
if (! $hasKatexCdn && ! $hasKatexLocal) {
|
|
|
Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
|
|
|
|
- return $html;
|
|
|
+ return $this->applyKatexRelationGlyphFixes($html);
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
@@ -2652,6 +2028,10 @@ class ExamPdfExportService
|
|
|
'after_length' => $afterLength,
|
|
|
'size_change' => $afterLength - $beforeLength,
|
|
|
]);
|
|
|
+
|
|
|
+ // 通用修复:统一优化 KaTeX 关系运算符(mrel)字形
|
|
|
+ // 覆盖平行、垂直、等于、不等于等关系符,避免出现“平行符像 ||”的问题
|
|
|
+ $html = $this->applyKatexRelationGlyphFixes($html);
|
|
|
} else {
|
|
|
Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染');
|
|
|
}
|
|
|
@@ -2667,7 +2047,82 @@ class ExamPdfExportService
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
- return $html;
|
|
|
+ return $this->applyKatexRelationGlyphFixes($html);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 应用 KaTeX 关系符通用修复(先标记,再注入样式)
|
|
|
+ */
|
|
|
+ private function applyKatexRelationGlyphFixes(string $html): string
|
|
|
+ {
|
|
|
+ $html = $this->tagKatexParallelRelationSpans($html);
|
|
|
+
|
|
|
+ return $this->injectKatexRelationGlyphStyle($html);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 标记 KaTeX 中的平行关系符(∥),便于使用字体无关样式兜底
|
|
|
+ */
|
|
|
+ private function tagKatexParallelRelationSpans(string $html): string
|
|
|
+ {
|
|
|
+ if (strpos($html, '∥') === false || strpos($html, 'mrel') === false) {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ $pattern = '/<span(?P<attrs>[^>]*)class=(["\'])(?P<class>[^"\']*\bmrel\b[^"\']*)\2(?P<tail>[^>]*)>\s*∥\s*<\/span>/u';
|
|
|
+ $replaced = preg_replace_callback($pattern, static function (array $matches): string {
|
|
|
+ $attrs = $matches['attrs'] ?? '';
|
|
|
+ $quote = $matches[2] ?? '"';
|
|
|
+ $class = trim($matches['class'] ?? '');
|
|
|
+ $tail = $matches['tail'] ?? '';
|
|
|
+
|
|
|
+ if ($class === '') {
|
|
|
+ $class = 'mrel katex-rel-parallel';
|
|
|
+ } elseif (! preg_match('/\bkatex-rel-parallel\b/', $class)) {
|
|
|
+ $class .= ' katex-rel-parallel';
|
|
|
+ }
|
|
|
+
|
|
|
+ return '<span'.$attrs.'class='.$quote.$class.$quote.$tail.'>∥</span>';
|
|
|
+ }, $html);
|
|
|
+
|
|
|
+ return $replaced ?? $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 注入 KaTeX 关系运算符统一字形样式(全局、通用)
|
|
|
+ */
|
|
|
+ private function injectKatexRelationGlyphStyle(string $html): string
|
|
|
+ {
|
|
|
+ if (strpos($html, 'class="katex"') === false && strpos($html, 'class="katex-html"') === false) {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (strpos($html, 'id="katex-relation-glyph-style"') !== false) {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ $style = '<style id="katex-relation-glyph-style">'
|
|
|
+ .'.katex .mrel{'
|
|
|
+ .'font-family:"KaTeX_AMS","KaTeX_Main","NotoSerifCJKsc-Regular","NotoSerifCJKsc-Bold","NotoSans-Regular","NotoSans-Bold","NotoSansMonoCJKjp-Regular",serif !important;'
|
|
|
+ .'letter-spacing:.04em;'
|
|
|
+ .'}'
|
|
|
+ .'.katex .mrel .mord{font-family:inherit !important;}'
|
|
|
+ .'.katex .mrel.katex-rel-parallel{'
|
|
|
+ .'display:inline-block;'
|
|
|
+ .'font-size:1.22em;'
|
|
|
+ .'line-height:1.28;'
|
|
|
+ .'letter-spacing:.04em;'
|
|
|
+ .'vertical-align:0;'
|
|
|
+ .'transform:skewX(-14deg) scaleY(1.42);'
|
|
|
+ .'transform-origin:center;'
|
|
|
+ .'}'
|
|
|
+ .'</style>';
|
|
|
+
|
|
|
+ if (stripos($html, '</head>') !== false) {
|
|
|
+ return preg_replace('/<\/head>/i', $style.'</head>', $html, 1) ?? ($html.$style);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $html.$style;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -3545,86 +3000,6 @@ class ExamPdfExportService
|
|
|
return $processed;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 基于学生全量掌握度构建父节点列表(不按本卷命中过滤)
|
|
|
- */
|
|
|
- private function buildParentMasteryFromAllParents(
|
|
|
- array $allParentMasteryLevels,
|
|
|
- array $kpNameMap,
|
|
|
- array $masteryMap = [],
|
|
|
- array $snapshotMasteryData = []
|
|
|
- ): array {
|
|
|
- if (empty($allParentMasteryLevels)) {
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- $parentCodes = [];
|
|
|
- foreach ($allParentMasteryLevels as $code => $value) {
|
|
|
- $parentCode = trim((string) $code);
|
|
|
- if ($parentCode === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $parentCodes[] = $parentCode;
|
|
|
- }
|
|
|
- $parentCodes = array_values(array_unique($parentCodes));
|
|
|
- if (empty($parentCodes)) {
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- $parents = DB::connection('mysql')
|
|
|
- ->table('knowledge_points')
|
|
|
- ->whereIn('kp_code', $parentCodes)
|
|
|
- ->pluck('name', 'kp_code')
|
|
|
- ->toArray();
|
|
|
-
|
|
|
- $processed = [];
|
|
|
- foreach ($parentCodes as $parentCode) {
|
|
|
- $raw = $allParentMasteryLevels[$parentCode] ?? null;
|
|
|
- $rawMastery = is_array($raw)
|
|
|
- ? floatval($raw['mastery_level'] ?? 0)
|
|
|
- : (is_object($raw) ? floatval($raw->mastery_level ?? 0) : floatval($raw ?? 0));
|
|
|
-
|
|
|
- $childrenData = $this->getChildKnowledgePoints($parentCode, $kpNameMap, [], $masteryMap);
|
|
|
- $allChildren = $childrenData['all_children'] ?? [];
|
|
|
-
|
|
|
- $allLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren);
|
|
|
- $masteryLevel = ! empty($allLevels) ? (array_sum($allLevels) / count($allLevels)) : $rawMastery;
|
|
|
-
|
|
|
- $prevLevels = [];
|
|
|
- foreach ($allChildren as $child) {
|
|
|
- $childCode = (string) ($child['kp_code'] ?? '');
|
|
|
- if ($childCode === '') {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $currentChild = floatval($masteryMap[$childCode] ?? 0);
|
|
|
- $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null;
|
|
|
- $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null;
|
|
|
- $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild;
|
|
|
- $prevLevels[] = floatval($previousChild);
|
|
|
- }
|
|
|
- $masteryChange = ! empty($prevLevels) ? ($masteryLevel - (array_sum($prevLevels) / count($prevLevels))) : null;
|
|
|
-
|
|
|
- $processed[$parentCode] = [
|
|
|
- 'kp_code' => $parentCode,
|
|
|
- 'kp_name' => $parents[$parentCode] ?? ($kpNameMap[$parentCode] ?? $parentCode),
|
|
|
- 'mastery_level' => round(floatval($masteryLevel), 4),
|
|
|
- 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
|
|
|
- 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
|
|
|
- 'change_source' => 'full_mastery_overview',
|
|
|
- 'children' => [],
|
|
|
- 'children_all' => $allChildren,
|
|
|
- 'children_hit_count' => 0,
|
|
|
- 'children_total_count' => count($allChildren),
|
|
|
- 'children_hit_avg_mastery' => null,
|
|
|
- 'level' => $this->calculateKnowledgePointLevel($parentCode),
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- uasort($processed, fn ($a, $b) => $b['mastery_level'] <=> $a['mastery_level']);
|
|
|
-
|
|
|
- return $processed;
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* 计算知识点层级深度
|
|
|
*/
|