|
@@ -28,6 +28,7 @@ use Symfony\Component\Process\Process;
|
|
|
class ExamPdfExportService
|
|
class ExamPdfExportService
|
|
|
{
|
|
{
|
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
|
|
|
+ private ?array $knowledgePointMetaCache = null;
|
|
|
|
|
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
private readonly LearningAnalyticsService $learningAnalyticsService,
|
|
private readonly LearningAnalyticsService $learningAnalyticsService,
|
|
@@ -314,8 +315,11 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 渲染HTML
|
|
|
|
|
- $html = view('exam-analysis.pdf-report', $templateData)->render();
|
|
|
|
|
|
|
+ // 组装V3报告展示数据(模块化)
|
|
|
|
|
+ $templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染HTML(V3模板)
|
|
|
|
|
+ $html = view('exam-analysis.pdf-report-v3', $templateData)->render();
|
|
|
if (! $html) {
|
|
if (! $html) {
|
|
|
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
|
|
|
|
|
@@ -362,6 +366,616 @@ 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(试卷 + 判卷)
|
|
|
* 先分别生成两个PDF,然后合并
|
|
* 先分别生成两个PDF,然后合并
|
|
@@ -670,7 +1284,7 @@ class ExamPdfExportService
|
|
|
'header_title' => $examCode,
|
|
'header_title' => $examCode,
|
|
|
'exam_pdf_title' => '试卷_'.$examCode,
|
|
'exam_pdf_title' => '试卷_'.$examCode,
|
|
|
'grading_pdf_title' => '判卷_'.$examCode,
|
|
'grading_pdf_title' => '判卷_'.$examCode,
|
|
|
- 'knowledge_pdf_title' => '知识点梳理_'.$examCode,
|
|
|
|
|
|
|
+ 'knowledge_pdf_title' => '知识点讲解_'.$examCode,
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
$html = view($viewName, [
|
|
$html = view($viewName, [
|
|
@@ -815,6 +1429,7 @@ class ExamPdfExportService
|
|
|
// 【修复】优先使用analysisData中的knowledge_point_analysis数据
|
|
// 【修复】优先使用analysisData中的knowledge_point_analysis数据
|
|
|
$masteryData = [];
|
|
$masteryData = [];
|
|
|
$parentMasteryLevels = []; // 新增:父节点掌握度数据
|
|
$parentMasteryLevels = []; // 新增:父节点掌握度数据
|
|
|
|
|
+ $allParentMasteryLevelsRaw = []; // 全量父节点掌握度(非本卷过滤)
|
|
|
Log::info('ExamPdfExportService: 开始处理掌握度数据', [
|
|
Log::info('ExamPdfExportService: 开始处理掌握度数据', [
|
|
|
'student_id' => $studentId,
|
|
'student_id' => $studentId,
|
|
|
'analysisData_keys' => array_keys($analysisData),
|
|
'analysisData_keys' => array_keys($analysisData),
|
|
@@ -887,6 +1502,7 @@ class ExamPdfExportService
|
|
|
// 获取所有父节点掌握度
|
|
// 获取所有父节点掌握度
|
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
|
$allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
|
|
$allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
|
|
|
|
|
+ $allParentMasteryLevelsRaw = $allParentMasteryLevels;
|
|
|
$overviewDetails = $masteryOverview['details'] ?? [];
|
|
$overviewDetails = $masteryOverview['details'] ?? [];
|
|
|
foreach ($overviewDetails as $detail) {
|
|
foreach ($overviewDetails as $detail) {
|
|
|
if (is_object($detail)) {
|
|
if (is_object($detail)) {
|
|
@@ -981,6 +1597,7 @@ class ExamPdfExportService
|
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
$masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
|
|
|
$masteryData = $masteryOverview['details'] ?? [];
|
|
$masteryData = $masteryOverview['details'] ?? [];
|
|
|
$parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
|
|
$parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
|
|
|
|
|
+ $allParentMasteryLevelsRaw = $parentMasteryLevels;
|
|
|
|
|
|
|
|
// 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
|
|
// 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
|
|
|
if (! empty($masteryData) && is_array($masteryData)) {
|
|
if (! empty($masteryData) && is_array($masteryData)) {
|
|
@@ -1106,6 +1723,12 @@ class ExamPdfExportService
|
|
|
$masteryMap,
|
|
$masteryMap,
|
|
|
$snapshotMasteryData
|
|
$snapshotMasteryData
|
|
|
);
|
|
);
|
|
|
|
|
+ $processedFullParentMastery = $this->buildParentMasteryFromAllParents(
|
|
|
|
|
+ $allParentMasteryLevelsRaw,
|
|
|
|
|
+ $kpNameMap,
|
|
|
|
|
+ $masteryMap,
|
|
|
|
|
+ $snapshotMasteryData
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
|
'raw_count' => count($parentMasteryLevels),
|
|
'raw_count' => count($parentMasteryLevels),
|
|
@@ -1129,6 +1752,7 @@ class ExamPdfExportService
|
|
|
'mastery' => $masterySummary,
|
|
'mastery' => $masterySummary,
|
|
|
'exam_hit_kp_codes' => $examQuestionKpCodes,
|
|
'exam_hit_kp_codes' => $examQuestionKpCodes,
|
|
|
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
|
|
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
|
|
|
|
|
+ 'full_parent_mastery_levels' => $processedFullParentMastery,
|
|
|
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
|
|
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
|
|
|
'recommendations' => $recommendations,
|
|
'recommendations' => $recommendations,
|
|
|
'analysis_data' => $analysisData,
|
|
'analysis_data' => $analysisData,
|
|
@@ -1704,11 +2328,11 @@ class ExamPdfExportService
|
|
|
'has_katex_local' => $hasKatexLocal,
|
|
'has_katex_local' => $hasKatexLocal,
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
- // 如果既没有 CDN 也没有本地链接,仍尝试注入 KaTeX 关系符通用修复
|
|
|
|
|
|
|
+ // 如果既没有 CDN 也没有本地链接,跳过
|
|
|
if (! $hasKatexCdn && ! $hasKatexLocal) {
|
|
if (! $hasKatexCdn && ! $hasKatexLocal) {
|
|
|
Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
|
|
|
|
|
|
- return $this->applyKatexRelationGlyphFixes($html);
|
|
|
|
|
|
|
+ return $html;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
@@ -1808,10 +2432,6 @@ class ExamPdfExportService
|
|
|
'after_length' => $afterLength,
|
|
'after_length' => $afterLength,
|
|
|
'size_change' => $afterLength - $beforeLength,
|
|
'size_change' => $afterLength - $beforeLength,
|
|
|
]);
|
|
]);
|
|
|
-
|
|
|
|
|
- // 通用修复:统一优化 KaTeX 关系运算符(mrel)字形
|
|
|
|
|
- // 覆盖平行、垂直、等于、不等于等关系符,避免出现“平行符像 ||”的问题
|
|
|
|
|
- $html = $this->applyKatexRelationGlyphFixes($html);
|
|
|
|
|
} else {
|
|
} else {
|
|
|
Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染');
|
|
Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染');
|
|
|
}
|
|
}
|
|
@@ -1827,82 +2447,7 @@ class ExamPdfExportService
|
|
|
]);
|
|
]);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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;
|
|
|
|
|
|
|
+ return $html;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -2778,6 +3323,86 @@ class ExamPdfExportService
|
|
|
return $processed;
|
|
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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 计算知识点层级深度
|
|
* 计算知识点层级深度
|
|
|
*/
|
|
*/
|