pdf-report-v3.blade.php 68 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386
  1. @php
  2. $v3 = $v3 ?? [];
  3. $summary = $v3['summary'] ?? [];
  4. $radar = $v3['radar'] ?? [];
  5. $modules = $v3['modules'] ?? [];
  6. $paths = $v3['paths'] ?? ['keep' => [], 'boost' => [], 'key' => []];
  7. $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
  8. preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
  9. $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
  10. $generateDateTime = now()->format('Y年m月d日 H:i:s');
  11. $scoreObtained = $summary['score_obtained'] ?? null;
  12. $scoreTotal = $summary['score_total'] ?? null;
  13. $scoreRate = $summary['score_rate'] ?? null;
  14. $averageMastery = $summary['average_mastery'] ?? null;
  15. $overallLabel = $summary['overall_label'] ?? '待评估';
  16. $difficultySummary = $summary['difficulty'] ?? [];
  17. $comparisonSummary = $summary['comparison'] ?? [];
  18. $overallLabelDetail = $summary['overall_label_detail'] ?? [];
  19. $historySummary = $comparisonSummary['history'] ?? [];
  20. $peerSummary = $comparisonSummary['peers'] ?? [];
  21. $overallScore = isset($overallLabelDetail['composite_score']) ? (float) $overallLabelDetail['composite_score'] : null;
  22. $overallGrade = (string) ($overallLabelDetail['grade'] ?? 'D');
  23. $currentPart = (float) ($overallLabelDetail['current_score'] ?? 0);
  24. $historyPart = (float) ($overallLabelDetail['history_score'] ?? 0);
  25. $peerPart = (float) ($overallLabelDetail['peer_score'] ?? 0);
  26. $adjustPart = (float) ($overallLabelDetail['difficulty_adjust'] ?? 0);
  27. $compositeFormulaResult = (0.50 * $currentPart) + (0.25 * $historyPart) + (0.25 * $peerPart) + $adjustPart;
  28. $overallBadge = function (string $grade): array {
  29. return match ($grade) {
  30. 'S' => ['bg' => '#f5f3ff', 'border' => '#6d28d9', 'text' => '#6d28d9', 'class' => 'badge-s'],
  31. 'A' => ['bg' => '#ecfdf3', 'border' => '#22c55e', 'text' => '#166534', 'class' => 'badge-excellent'],
  32. 'B' => ['bg' => '#eff6ff', 'border' => '#3b82f6', 'text' => '#1d4ed8', 'class' => 'badge-good'],
  33. 'C' => ['bg' => '#fff7ed', 'border' => '#f59e0b', 'text' => '#b45309', 'class' => 'badge-average'],
  34. default => ['bg' => '#fef2f2', 'border' => '#ef4444', 'text' => '#b91c1c', 'class' => 'badge-weak'],
  35. };
  36. };
  37. $overallVisual = $overallBadge((string) $overallGrade);
  38. $trendVisual = function (string $trend): array {
  39. return match ($trend) {
  40. '显著提升' => ['icon' => '▲', 'color' => '#16a34a'],
  41. '小幅提升' => ['icon' => '↗', 'color' => '#0ea5e9'],
  42. '基本持平' => ['icon' => '•', 'color' => '#64748b'],
  43. '小幅回落' => ['icon' => '↘', 'color' => '#f59e0b'],
  44. '明显回落' => ['icon' => '▼', 'color' => '#ef4444'],
  45. default => ['icon' => '•', 'color' => '#64748b'],
  46. };
  47. };
  48. $statusColor = function (string $status): string {
  49. return match ($status) {
  50. '良好' => '#16a34a',
  51. '一般' => '#f97316',
  52. '薄弱' => '#e11d48',
  53. default => '#64748b',
  54. };
  55. };
  56. $n = max(1, count($radar));
  57. $cx = 210;
  58. $cy = 155;
  59. $r = 108;
  60. $outer = [];
  61. $inner = [];
  62. for ($i = 0; $i < $n; $i++) {
  63. $angle = -M_PI / 2 + (2 * M_PI * $i / $n);
  64. $ox = $cx + $r * cos($angle);
  65. $oy = $cy + $r * sin($angle);
  66. $outer[] = [$ox, $oy];
  67. $value = isset($radar[$i]['value']) ? (float) $radar[$i]['value'] : 0.0;
  68. $ratio = max(0.0, min(1.0, $value / 5));
  69. $ix = $cx + $r * $ratio * cos($angle);
  70. $iy = $cy + $r * $ratio * sin($angle);
  71. $inner[] = [$ix, $iy];
  72. }
  73. $outerPoints = implode(' ', array_map(fn ($p) => round($p[0], 2).','.round($p[1], 2), $outer));
  74. $innerPoints = implode(' ', array_map(fn ($p) => round($p[0], 2).','.round($p[1], 2), $inner));
  75. $insightMap = [];
  76. foreach (($question_insights ?? []) as $insight) {
  77. $no = $insight['question_number'] ?? $insight['question_id'] ?? null;
  78. if ($no !== null) {
  79. $insightMap[$no] = $insight;
  80. }
  81. }
  82. $analysisWrongMap = [];
  83. foreach (($analysis_data['question_analysis'] ?? []) as $qa) {
  84. $qid = $qa['question_bank_id'] ?? $qa['question_id'] ?? null;
  85. if ($qid === null || $qid === '') {
  86. continue;
  87. }
  88. $rawCorrect = $qa['is_correct'] ?? null;
  89. $isWrongFromAnalysis = false;
  90. if (is_array($rawCorrect)) {
  91. $isWrongFromAnalysis = in_array(0, $rawCorrect, true);
  92. } elseif ($rawCorrect !== null) {
  93. $isWrongFromAnalysis = !boolval($rawCorrect);
  94. }
  95. if ($isWrongFromAnalysis) {
  96. $analysisWrongMap[(string) $qid] = true;
  97. }
  98. }
  99. $wrongQuestions = [];
  100. foreach (($questions ?? []) as $qItem) {
  101. $isCorrectProbe = $qItem['is_correct'] ?? null;
  102. $studentAnswerProbe = $qItem['student_answer'] ?? null;
  103. $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? null);
  104. if ($isCorrectProbe === null && !empty($studentAnswerProbe) && !empty($correctAnswerProbe)) {
  105. $isCorrectProbe = (trim((string) $studentAnswerProbe) === trim((string) $correctAnswerProbe)) ? 1 : 0;
  106. }
  107. $normalizedCorrect = $isCorrectProbe;
  108. if ($isCorrectProbe !== null) {
  109. $normalizedCorrect = is_bool($isCorrectProbe) ? ($isCorrectProbe ? 1 : 0) : intval($isCorrectProbe);
  110. }
  111. $qidProbe = (string) ($qItem['question_bank_id'] ?? $qItem['question_id'] ?? '');
  112. $isWrongByAnalysis = ($qidProbe !== '' && isset($analysisWrongMap[$qidProbe]));
  113. if ($normalizedCorrect === 0 || $isWrongByAnalysis) {
  114. $wrongQuestions[] = $qItem;
  115. }
  116. }
  117. $kpStats = [];
  118. foreach (($questions ?? []) as $qItem) {
  119. $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
  120. $kpName = $kpName === '' ? '未标注知识点' : $kpName;
  121. if (!isset($kpStats[$kpName])) {
  122. $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
  123. }
  124. $kpStats[$kpName]['total']++;
  125. }
  126. foreach ($wrongQuestions as $qItem) {
  127. $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
  128. $kpName = $kpName === '' ? '未标注知识点' : $kpName;
  129. if (!isset($kpStats[$kpName])) {
  130. $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
  131. }
  132. $kpStats[$kpName]['wrong']++;
  133. }
  134. $kpWrongStats = [];
  135. foreach ($kpStats as $kpName => $stat) {
  136. if (($stat['wrong'] ?? 0) <= 0) {
  137. continue;
  138. }
  139. $total = max(1, intval($stat['total'] ?? 0));
  140. $wrong = intval($stat['wrong'] ?? 0);
  141. $kpWrongStats[] = [
  142. 'kp_name' => $kpName,
  143. 'wrong' => $wrong,
  144. 'total' => $total,
  145. 'rate' => $wrong / $total,
  146. ];
  147. }
  148. usort($kpWrongStats, function ($a, $b) {
  149. if ($a['rate'] === $b['rate']) {
  150. return $b['wrong'] <=> $a['wrong'];
  151. }
  152. return $b['rate'] <=> $a['rate'];
  153. });
  154. $childMasteryStatus = function ($mastery): string {
  155. if ($mastery === null) {
  156. return '未学习';
  157. }
  158. $m = (float) $mastery * 100; // 与 math.client-pc 统一:0-100 阈值(85/60)
  159. if ($m >= 85) {
  160. return '已掌握';
  161. }
  162. if ($m >= 60) {
  163. return '薄弱';
  164. }
  165. return '未入门';
  166. };
  167. $childStatusColor = function ($status): string {
  168. return match ($status) {
  169. '已掌握' => '#52c41a',
  170. '薄弱' => '#faad14',
  171. '未入门' => '#f5222d',
  172. default => '#d9d9d9',
  173. };
  174. };
  175. $calcStats = function (array $points): array {
  176. $total = count($points);
  177. $learned = 0;
  178. $mastered = 0;
  179. $weak = 0;
  180. $beginner = 0;
  181. $unlearned = 0;
  182. foreach ($points as $p) {
  183. if (($p['mastery_level'] ?? null) !== null) {
  184. $learned++;
  185. }
  186. $status = (string) ($p['status'] ?? '未学习');
  187. if ($status === '已掌握') {
  188. $mastered++;
  189. } elseif ($status === '薄弱') {
  190. $weak++;
  191. } elseif ($status === '未入门') {
  192. $beginner++;
  193. } else {
  194. $unlearned++;
  195. }
  196. }
  197. return [
  198. 'total' => $total,
  199. 'learned' => $learned,
  200. 'mastered' => $mastered,
  201. 'weak' => $weak,
  202. 'beginner' => $beginner,
  203. 'unlearned' => $unlearned,
  204. ];
  205. };
  206. $clusterCards = [];
  207. $allClusterPoints = [];
  208. foreach ($radar as $moduleItem) {
  209. $children = is_array($moduleItem['children'] ?? null) ? $moduleItem['children'] : [];
  210. $greatMap = [];
  211. foreach ($children as $child) {
  212. $greatKey = trim((string) ($child['great_grand_parent_name'] ?? ''));
  213. $greatKey = $greatKey !== '' ? $greatKey : '未分组';
  214. $grandKey = trim((string) ($child['grand_parent_name'] ?? ''));
  215. $grandKey = $grandKey !== '' ? $grandKey : '未分组';
  216. $parentName = trim((string) ($child['parent_name'] ?? ''));
  217. if ($parentName === '') {
  218. $parentCode = trim((string) ($child['parent_code'] ?? ''));
  219. $parentName = $parentCode !== '' ? $parentCode : '未分组';
  220. }
  221. $mastery = isset($child['mastery_level']) ? (float) $child['mastery_level'] : null;
  222. $status = $childMasteryStatus($mastery);
  223. if (!isset($greatMap[$greatKey])) {
  224. $greatMap[$greatKey] = [];
  225. }
  226. if (!isset($greatMap[$greatKey][$grandKey])) {
  227. $greatMap[$greatKey][$grandKey] = [];
  228. }
  229. if (!isset($greatMap[$greatKey][$grandKey][$parentName])) {
  230. $greatMap[$greatKey][$grandKey][$parentName] = [];
  231. }
  232. $greatMap[$greatKey][$grandKey][$parentName][] = [
  233. 'code' => (string) ($child['code'] ?? ''),
  234. 'name' => (string) ($child['name'] ?? '未命名知识点'),
  235. 'path' => (string) ($child['path'] ?? ''),
  236. 'mastery_level' => $mastery,
  237. 'change' => isset($child['change']) ? (float) $child['change'] : null,
  238. 'status' => $status,
  239. 'color' => $childStatusColor($status),
  240. 'is_hit' => !empty($child['is_hit']),
  241. ];
  242. }
  243. $greatGroups = [];
  244. foreach ($greatMap as $greatName => $grandMap) {
  245. $grandGroups = [];
  246. foreach ($grandMap as $grandName => $parentMap) {
  247. $parentGroups = [];
  248. foreach ($parentMap as $parentName => $points) {
  249. usort($points, function ($a, $b) {
  250. $am = $a['mastery_level'] ?? -1;
  251. $bm = $b['mastery_level'] ?? -1;
  252. if ($am === $bm) {
  253. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  254. }
  255. return $bm <=> $am;
  256. });
  257. $parentGroups[] = [
  258. 'parent_name' => $parentName,
  259. 'points' => $points,
  260. 'stats' => $calcStats($points),
  261. ];
  262. }
  263. // 子模块级过滤:整行没有任何掌握度数字则不显示
  264. $parentGroups = array_values(array_filter($parentGroups, function ($pg) {
  265. return (($pg['stats']['learned'] ?? 0) > 0);
  266. }));
  267. if (empty($parentGroups)) {
  268. continue;
  269. }
  270. usort($parentGroups, function ($a, $b) {
  271. $sa = $a['stats'];
  272. $sb = $b['stats'];
  273. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  274. });
  275. $allGrandPoints = [];
  276. foreach ($parentGroups as $pg) {
  277. $allGrandPoints = array_merge($allGrandPoints, $pg['points']);
  278. }
  279. $grandGroups[] = [
  280. 'grand_name' => $grandName,
  281. 'parent_groups' => $parentGroups,
  282. 'stats' => $calcStats($allGrandPoints),
  283. ];
  284. }
  285. // 大块级过滤:整块没有任何掌握度数字则不显示
  286. $grandGroups = array_values(array_filter($grandGroups, function ($gg) {
  287. return (($gg['stats']['learned'] ?? 0) > 0);
  288. }));
  289. if (empty($grandGroups)) {
  290. continue;
  291. }
  292. usort($grandGroups, function ($a, $b) {
  293. $sa = $a['stats'];
  294. $sb = $b['stats'];
  295. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  296. });
  297. $allGreatPoints = [];
  298. foreach ($grandGroups as $gg) {
  299. foreach ($gg['parent_groups'] as $pg) {
  300. $allGreatPoints = array_merge($allGreatPoints, $pg['points']);
  301. }
  302. }
  303. $greatGroups[] = [
  304. 'great_name' => $greatName,
  305. 'grand_groups' => $grandGroups,
  306. 'stats' => $calcStats($allGreatPoints),
  307. ];
  308. }
  309. usort($greatGroups, function ($a, $b) {
  310. $sa = $a['stats'];
  311. $sb = $b['stats'];
  312. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  313. });
  314. // 严格参考 math.client-pc:扁平化为“grand 层卡片”(展示大块)
  315. foreach ($greatGroups as $great) {
  316. foreach (($great['grand_groups'] ?? []) as $grand) {
  317. $gStats = $grand['stats'] ?? ['learned' => 0, 'total' => 0];
  318. $clusterCards[] = [
  319. 'great_name' => $great['great_name'] ?? '未分组',
  320. 'grand_name' => $grand['grand_name'] ?? '未分组',
  321. 'parent_groups' => $grand['parent_groups'] ?? [],
  322. 'stats' => $gStats,
  323. ];
  324. }
  325. }
  326. }
  327. usort($clusterCards, function ($a, $b) {
  328. $sa = $a['stats'] ?? ['learned' => 0, 'total' => 0];
  329. $sb = $b['stats'] ?? ['learned' => 0, 'total' => 0];
  330. return (($sb['learned'] ?? 0) <=> ($sa['learned'] ?? 0))
  331. ?: (($sb['total'] ?? 0) <=> ($sa['total'] ?? 0));
  332. });
  333. foreach ($clusterCards as $card) {
  334. foreach (($card['parent_groups'] ?? []) as $pg) {
  335. foreach (($pg['points'] ?? []) as $p) {
  336. $allClusterPoints[] = $p;
  337. }
  338. }
  339. }
  340. $kpStatsTotal = [
  341. 'total' => count($allClusterPoints),
  342. 'mastered' => 0,
  343. 'weak' => 0,
  344. 'beginner' => 0,
  345. 'unlearned' => 0,
  346. ];
  347. foreach ($allClusterPoints as $p) {
  348. $st = (string) ($p['status'] ?? '未学习');
  349. if ($st === '已掌握') {
  350. $kpStatsTotal['mastered']++;
  351. } elseif ($st === '薄弱') {
  352. $kpStatsTotal['weak']++;
  353. } elseif ($st === '未入门') {
  354. $kpStatsTotal['beginner']++;
  355. } else {
  356. $kpStatsTotal['unlearned']++;
  357. }
  358. }
  359. $moduleRowsWithStatus = array_values(array_filter($modules, function ($m) {
  360. $status = trim((string) ($m['status'] ?? ''));
  361. $masteryLevel = $m['mastery_level'] ?? null;
  362. if ($masteryLevel !== null) {
  363. return true;
  364. }
  365. return $status !== '' && ! in_array($status, ['暂无', '-', '未涉及'], true);
  366. }));
  367. $pathTagByModuleName = [];
  368. foreach (['keep' => '保分不错', 'boost' => '需要加强', 'key' => '优先加强'] as $bucket => $tagName) {
  369. foreach (($paths[$bucket] ?? []) as $item) {
  370. $n = trim((string) ($item['name'] ?? ''));
  371. if ($n === '') {
  372. continue;
  373. }
  374. $pathTagByModuleName[$n] = $tagName;
  375. }
  376. }
  377. $impactedModules = array_values(array_filter($moduleRowsWithStatus, function ($m) {
  378. return ((int) ($m['question_count'] ?? 0)) > 0;
  379. }));
  380. $radarModuleMap = [];
  381. foreach ($radar as $moduleItem) {
  382. $code = (string) ($moduleItem['code'] ?? '');
  383. if ($code !== '') {
  384. $radarModuleMap[$code] = $moduleItem;
  385. }
  386. }
  387. $moduleImpactChangeMap = [];
  388. foreach ($radarModuleMap as $moduleCode => $moduleItem) {
  389. $hitChanges = [];
  390. foreach (($moduleItem['children'] ?? []) as $child) {
  391. if (empty($child['is_hit'])) {
  392. continue;
  393. }
  394. $change = $child['change'] ?? null;
  395. if ($change === null || ! is_numeric($change)) {
  396. continue;
  397. }
  398. $hitChanges[] = (float) $change;
  399. }
  400. if (empty($hitChanges)) {
  401. continue;
  402. }
  403. $moduleImpactChangeMap[$moduleCode] = array_sum($hitChanges) / count($hitChanges);
  404. }
  405. $questionTypeLabelMap = [
  406. 'choice' => '选择题',
  407. 'multiple_choice' => '选择题',
  408. 'single_choice' => '选择题',
  409. 'select' => '选择题',
  410. 'fill' => '填空题',
  411. 'blank' => '填空题',
  412. 'answer' => '解答题',
  413. 'solution' => '解答题',
  414. ];
  415. $kpQuestionTypeMap = [];
  416. foreach (($questions ?? []) as $qItem) {
  417. $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? ''));
  418. if ($kpName === '') {
  419. continue;
  420. }
  421. $rawType = strtolower(trim((string) ($qItem['question_type'] ?? '')));
  422. $typeLabel = $questionTypeLabelMap[$rawType] ?? ((string) ($qItem['question_type'] ?? '未知题型'));
  423. if ($typeLabel === '') {
  424. $typeLabel = '未知题型';
  425. }
  426. if (! isset($kpQuestionTypeMap[$kpName])) {
  427. $kpQuestionTypeMap[$kpName] = [];
  428. }
  429. $kpQuestionTypeMap[$kpName][$typeLabel] = true;
  430. }
  431. $moduleKpSuggestions = [];
  432. foreach ($moduleRowsWithStatus as $m) {
  433. $moduleCode = (string) ($m['module_code'] ?? '');
  434. $moduleName = (string) ($m['module_name'] ?? '-');
  435. $moduleChildren = $radarModuleMap[$moduleCode]['children'] ?? [];
  436. if (! is_array($moduleChildren) || empty($moduleChildren)) {
  437. continue;
  438. }
  439. $started = array_values(array_filter($moduleChildren, function ($c) {
  440. return isset($c['mastery_level']) && $c['mastery_level'] !== null;
  441. }));
  442. usort($started, function ($a, $b) {
  443. $am = (float) ($a['mastery_level'] ?? 0);
  444. $bm = (float) ($b['mastery_level'] ?? 0);
  445. if ($am === $bm) {
  446. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  447. }
  448. return $am <=> $bm;
  449. });
  450. $weakest = null;
  451. if (! empty($started)) {
  452. $lowestStarted = $started[0];
  453. $lowestStartedLevel = isset($lowestStarted['mastery_level']) ? (float) $lowestStarted['mastery_level'] : null;
  454. if ($lowestStartedLevel !== null && $lowestStartedLevel < 0.85) {
  455. // 规则1:已开始学习中掌握度最低
  456. $weakest = $lowestStarted;
  457. } else {
  458. // 规则2:若已开始学习均达标(>=85%),取“最近的未学习”
  459. $unlearned = array_values(array_filter($moduleChildren, function ($c) {
  460. return !isset($c['mastery_level']) || $c['mastery_level'] === null;
  461. }));
  462. if (! empty($unlearned)) {
  463. $anchorParent = (string) ($lowestStarted['parent_name'] ?? '');
  464. $anchorGrand = (string) ($lowestStarted['grand_parent_name'] ?? '');
  465. usort($unlearned, function ($a, $b) use ($anchorParent, $anchorGrand) {
  466. $score = function ($node) use ($anchorParent, $anchorGrand) {
  467. $parent = (string) ($node['parent_name'] ?? '');
  468. $grand = (string) ($node['grand_parent_name'] ?? '');
  469. if ($anchorParent !== '' && $parent === $anchorParent) {
  470. return 0;
  471. }
  472. if ($anchorGrand !== '' && $grand === $anchorGrand) {
  473. return 1;
  474. }
  475. return 2;
  476. };
  477. $sa = $score($a);
  478. $sb = $score($b);
  479. if ($sa === $sb) {
  480. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  481. }
  482. return $sa <=> $sb;
  483. });
  484. $weakest = $unlearned[0];
  485. } else {
  486. $weakest = $lowestStarted;
  487. }
  488. }
  489. } else {
  490. // 没有已开始学习数据时,回退到模块内任一未学习点
  491. $unlearned = array_values(array_filter($moduleChildren, function ($c) {
  492. return !isset($c['mastery_level']) || $c['mastery_level'] === null;
  493. }));
  494. if (! empty($unlearned)) {
  495. usort($unlearned, fn ($a, $b) => strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
  496. $weakest = $unlearned[0];
  497. }
  498. }
  499. if (! is_array($weakest)) {
  500. continue;
  501. }
  502. $kpName = (string) ($weakest['name'] ?? '');
  503. if ($kpName === '') {
  504. continue;
  505. }
  506. $types = array_keys($kpQuestionTypeMap[$kpName] ?? []);
  507. $moduleKpSuggestions[] = [
  508. 'module_name' => $moduleName,
  509. 'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
  510. 'kp_name' => $kpName,
  511. 'mastery_level' => $weakest['mastery_level'] ?? null,
  512. 'status' => $childMasteryStatus($weakest['mastery_level'] ?? null),
  513. 'question_types' => $types,
  514. ];
  515. }
  516. $moduleSuggestionByName = [];
  517. foreach ($moduleKpSuggestions as $sug) {
  518. $name = trim((string) ($sug['module_name'] ?? ''));
  519. if ($name !== '') {
  520. $moduleSuggestionByName[$name] = $sug;
  521. }
  522. }
  523. $kpChangeItems = array_values(array_filter($allClusterPoints, function ($p) {
  524. $change = $p['change'] ?? null;
  525. return $change !== null && is_numeric($change) && !empty($p['is_hit']);
  526. }));
  527. if (empty($kpChangeItems)) {
  528. $kpChangeItems = array_values(array_filter($allClusterPoints, function ($p) {
  529. $change = $p['change'] ?? null;
  530. return $change !== null && is_numeric($change);
  531. }));
  532. }
  533. usort($kpChangeItems, function ($a, $b) {
  534. return abs((float) ($b['change'] ?? 0)) <=> abs((float) ($a['change'] ?? 0));
  535. });
  536. $kpPct = function (int $count, int $total): string {
  537. if ($total <= 0) {
  538. return '0.0%';
  539. }
  540. return number_format(($count * 100.0) / $total, 1) . '%';
  541. };
  542. @endphp
  543. <!DOCTYPE html>
  544. <html lang="zh-CN">
  545. <head>
  546. <meta charset="UTF-8">
  547. <title>学情分析报告</title>
  548. <link rel="stylesheet" href="/css/katex/katex.min.css">
  549. <style>
  550. @page {
  551. size: A4;
  552. margin: 2.2cm 2cm 2.3cm 2cm;
  553. @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
  554. @top-center { content: "{{ $student['name'] ?? '-' }}"; font-size: 13px; color: #666; }
  555. @top-right {
  556. content: "{{ $reportCode }}";
  557. font-size: 19px;
  558. font-weight: 600;
  559. font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
  560. color: #222;
  561. }
  562. @bottom-left { content: "{{ $reportCode }}"; font-size: 11px; color: #666; }
  563. @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
  564. }
  565. * { box-sizing: border-box; }
  566. body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.65; }
  567. .page { page-break-after: auto; }
  568. .header { text-align: left; margin-bottom: 16px; }
  569. .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
  570. .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
  571. .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; }
  572. .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; position: relative; }
  573. .summary-list { margin: 0; padding-left: 18px; }
  574. .summary-list li { margin: 6px 0; font-size: 13px; }
  575. .overall-badge {
  576. position: absolute;
  577. right: 14px;
  578. top: 12px;
  579. border-radius: 12px;
  580. border: 0;
  581. padding: 9px 16px;
  582. min-width: 0;
  583. width: auto;
  584. text-align: center;
  585. position: absolute;
  586. overflow: hidden;
  587. display: inline-block;
  588. white-space: nowrap;
  589. background: transparent !important;
  590. }
  591. .overall-badge .level { font-size: 28px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; }
  592. .overall-badge .score { font-size: 13px; margin-top: 3px; }
  593. .overall-badge.badge-s {
  594. border: 5px solid #6d28d9;
  595. border-radius: 14px;
  596. box-shadow: none;
  597. transform: rotate(-7deg);
  598. }
  599. .overall-badge.badge-s::before {
  600. content: "";
  601. position: absolute;
  602. inset: 4px;
  603. border: 2px dashed rgba(109, 40, 217, 0.65);
  604. border-radius: 10px;
  605. pointer-events: none;
  606. }
  607. .overall-badge.badge-s .level {
  608. letter-spacing: 2px;
  609. text-shadow: 0 1px 0 rgba(109, 40, 217, 0.24);
  610. }
  611. .overall-badge.badge-excellent {
  612. border: 3px double #16a34a;
  613. border-radius: 999px;
  614. box-shadow: none;
  615. }
  616. .overall-badge.badge-good {
  617. border: 2px solid #2563eb;
  618. border-radius: 10px;
  619. clip-path: polygon(6% 0, 94% 0, 100% 50%, 94% 100%, 6% 100%, 0 50%);
  620. box-shadow: none;
  621. }
  622. .overall-badge.badge-average {
  623. border: 2px dashed #d97706;
  624. border-radius: 14px;
  625. box-shadow: none;
  626. }
  627. .overall-badge.badge-weak {
  628. border-left: 3px solid #ef4444;
  629. border-right: 0;
  630. border-top: 0;
  631. border-bottom: 2px solid #ef4444;
  632. border-radius: 0 10px 10px 0;
  633. box-shadow: none;
  634. }
  635. .overall-meta { margin-top: 8px; font-size: 9px; color: #64748b; line-height: 1.6; white-space: nowrap; }
  636. .radar-center { text-align: center; }
  637. .legend { margin-top: 8px; font-size: 12px; color: #475569; }
  638. .legend span { margin: 0 8px; }
  639. .dot { display: inline-block; width: 10px; height: 10px; border-radius: 999px; margin-right: 4px; vertical-align: middle; }
  640. /* PDF 版优先上下结构,避免左右分栏导致拥挤 */
  641. .radar-split { display: block; width: 100%; }
  642. .radar-left { width: 100%; text-align: center; }
  643. .radar-right { width: 100%; padding-left: 0; margin-top: 10px; }
  644. .radar-desc { border: 1px solid #dbeafe; background: #f8fbff; border-radius: 12px; padding: 12px; text-align: left; }
  645. .radar-item { display: block; margin: 6px 0; font-size: 12px; }
  646. .cluster-toolbar {
  647. margin-bottom: 8px;
  648. font-size: 12px;
  649. color: #475569;
  650. }
  651. .cluster-legend { display: inline-block; margin-right: 12px; }
  652. .cluster-grid {
  653. display: grid;
  654. grid-template-columns: 1fr 1fr;
  655. gap: 10px;
  656. }
  657. .cluster-card {
  658. border: 1px solid #e2e8f0;
  659. border-radius: 10px;
  660. padding: 10px;
  661. background: #fff;
  662. }
  663. .cluster-card-title {
  664. font-size: 14px;
  665. font-weight: 700;
  666. color: #0f172a;
  667. margin-bottom: 8px;
  668. }
  669. .cluster-subgroup {
  670. border-left: 2px solid #e5e7eb;
  671. padding-left: 8px;
  672. margin-bottom: 8px;
  673. }
  674. .cluster-subgroup:last-child { margin-bottom: 0; }
  675. .cluster-subgroup-title {
  676. font-size: 12px;
  677. font-weight: 600;
  678. color: #334155;
  679. margin-bottom: 4px;
  680. }
  681. .cluster-points {
  682. display: flex;
  683. flex-wrap: wrap;
  684. gap: 4px;
  685. }
  686. .cluster-point {
  687. width: 10px;
  688. height: 10px;
  689. border-radius: 2px;
  690. display: inline-block;
  691. border: 1px solid rgba(148, 163, 184, 0.35);
  692. }
  693. .cluster-empty {
  694. font-size: 12px;
  695. color: #64748b;
  696. background: #f8fafc;
  697. border: 1px dashed #cbd5e1;
  698. border-radius: 8px;
  699. padding: 10px;
  700. }
  701. .kp-stats-grid {
  702. display: grid;
  703. grid-template-columns: repeat(5, 1fr);
  704. border: 1px solid #e5e7eb;
  705. border-radius: 10px;
  706. overflow: hidden;
  707. margin-bottom: 10px;
  708. }
  709. .kp-stat-item {
  710. padding: 8px 10px;
  711. border-right: 1px solid #e5e7eb;
  712. background: #fff;
  713. }
  714. .kp-stat-item:last-child { border-right: none; }
  715. .kp-stat-label { font-size: 11px; color: #64748b; }
  716. .kp-stat-value { font-size: 18px; font-weight: 700; color: #111827; line-height: 1.2; margin-top: 2px; }
  717. .kp-stat-rate { font-size: 11px; margin-left: 4px; font-weight: 600; }
  718. .kp-change-box { margin-bottom: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; padding: 10px 12px; }
  719. .kp-change-list { margin: 4px 0 0 16px; padding: 0; }
  720. .kp-change-list li { margin: 2px 0; color: #334155; }
  721. .kp-burst-card { margin-top: 10px; border: 1px solid #dbeafe; border-radius: 12px; padding: 10px; background: #fff; }
  722. .kp-burst-title { font-size: 13px; font-weight: 700; margin-bottom: 6px; color: #0b3a75; }
  723. .kp-burst-meta { font-size: 12px; color: #334155; margin-top: 6px; line-height: 1.6; }
  724. .kp-burst-list { margin-top: 6px; font-size: 11px; color: #334155; line-height: 1.5; }
  725. .kp-burst-list span { display: inline-block; margin-right: 10px; margin-bottom: 3px; }
  726. table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
  727. th, td { border: 1px solid #d0d7e2; padding: 8px 10px; text-align: left; vertical-align: top; }
  728. th { background: #f1f5f9; color: #1e293b; font-weight: 700; }
  729. .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
  730. .module-table th { background: #edf2ff; color: #0f172a; }
  731. .module-table th { text-align: center; }
  732. .module-table td { line-height: 1.45; }
  733. .module-table th,
  734. .module-table td { vertical-align: middle; }
  735. .module-table th:nth-child(8),
  736. .module-table td:nth-child(8) { vertical-align: top; }
  737. .module-table th:nth-child(1),
  738. .module-table td:nth-child(1) { text-align: center; }
  739. .module-table td:nth-child(2),
  740. .module-table td:nth-child(3),
  741. .module-table td:nth-child(5),
  742. .module-table td:nth-child(6),
  743. .module-table td:nth-child(7) { text-align: center; white-space: nowrap; }
  744. .module-table td:nth-child(8) { font-size: 11px; color: #334155; }
  745. .module-table tbody tr:nth-child(even) td { background: #fcfdff; }
  746. .module-name { font-weight: 600; color: #0f172a; }
  747. .impact-yes { color:#2563eb; font-weight:600; }
  748. .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #334155; background: #e5e7eb; }
  749. .error-kp-tag { display: inline-block; margin: 0 6px 6px 0; padding: 1px 7px; border-radius: 999px; font-size: 10px; color: #334155; background: #f8fafc; border: 1px solid #d1d5db; }
  750. .error-kp-tag.high-risk { color: #b91c1c; border-color: #fca5a5; background: #fff; font-weight: 600; }
  751. .question-card { border:1px solid #e5e7eb; border-radius:8px; padding:6px 9px; margin-bottom:5px; background:#fff; page-break-inside:auto; break-inside:auto; }
  752. .question-block { margin-bottom: 5px; padding: 5px; border-radius: 4px; page-break-inside: auto; break-inside: auto; }
  753. .question-card,
  754. .question-card .math-content,
  755. .question-card .solution-content { font-size: 12px; line-height: 1.7; }
  756. .question-card .question-stem svg,
  757. .question-card .math-content svg { max-width: 100%; height: auto; display: block; shape-rendering: geometricPrecision; text-rendering: geometricPrecision; }
  758. .question-card .question-stem svg text {
  759. font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif !important;
  760. font-size: 13px !important;
  761. font-weight: bold;
  762. dominant-baseline: middle;
  763. text-anchor: middle;
  764. }
  765. .question-card .question-stem svg circle,
  766. .question-card .question-stem svg line,
  767. .question-card .question-stem svg polygon,
  768. .question-card .question-stem svg polyline { shape-rendering: geometricPrecision; }
  769. .question-card .question-stem img,
  770. .question-card .question-main img {
  771. display: block;
  772. max-width: 220px;
  773. max-height: 60mm;
  774. width: auto;
  775. height: auto;
  776. margin: 6px auto;
  777. box-sizing: border-box;
  778. object-fit: contain;
  779. -webkit-print-color-adjust: exact;
  780. print-color-adjust: exact;
  781. image-rendering: -webkit-optimize-contrast;
  782. }
  783. .question-card .question-stem .katex { font-size: 1em !important; vertical-align: 0; }
  784. .question-card .question-stem .katex-display { margin: 0.35em 0 !important; }
  785. .question-card .solution-content img,
  786. .question-card .report-answer-meta img {
  787. display: block;
  788. max-width: 220px;
  789. max-height: 60mm;
  790. width: auto;
  791. height: auto;
  792. margin: 6px auto;
  793. object-fit: contain;
  794. -webkit-print-color-adjust: exact;
  795. print-color-adjust: exact;
  796. }
  797. .solution-content {
  798. display: block;
  799. line-height: 1.75;
  800. white-space: normal;
  801. word-break: break-word;
  802. overflow-wrap: anywhere;
  803. page-break-inside: auto;
  804. break-inside: auto;
  805. }
  806. .report-options { margin-top: 6px; page-break-inside: auto; break-inside: auto; }
  807. .report-options.options-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px 12px; }
  808. .report-options.options-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; }
  809. .report-options.options-grid-1 { display: grid; grid-template-columns: 1fr; gap: 8px; }
  810. .report-options .option { display: flex; align-items: baseline; font-size: 12px; line-height: 1.6; page-break-inside: auto; break-inside: auto; }
  811. .report-options .option strong { margin-right: 4px; flex: 0 0 auto; }
  812. .report-options .option-value.option-short { white-space: nowrap; }
  813. .report-options .option-value.option-long { white-space: normal; word-break: break-word; }
  814. .report-options .option p, .report-options .option div { margin: 0; display: inline; }
  815. .report-options .option img { max-width: 100%; height: auto; vertical-align: middle; }
  816. .report-answer-meta { font-size: 12px; color: #2f2f2f; line-height: 1.75; margin-top: 6px; page-break-inside: auto; break-inside: auto; }
  817. .report-answer-meta .answer-line + .answer-line { margin-top: 4px; }
  818. .report-answer-meta .solution-content { display: inline; line-height: 1.75; }
  819. .muted { color: #6b7280; font-size: 12px; }
  820. </style>
  821. </head>
  822. <body>
  823. <div class="page">
  824. <div class="header">
  825. <h1 class="paper-title">学情分析报告</h1>
  826. </div>
  827. <div class="section">
  828. <div class="section-title">一、总体评估</div>
  829. <div class="card">
  830. <div class="overall-badge {{ $overallVisual['class'] ?? '' }}"
  831. style="border-color:{{ $overallVisual['border'] }}; color:{{ $overallVisual['text'] }};">
  832. <div class="level">{{ $overallGrade }}</div>
  833. </div>
  834. <ul class="summary-list">
  835. <li>本次诊断得分:
  836. @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
  837. {{ rtrim(rtrim(number_format((float) $scoreObtained, 1), '0'), '.') }}/{{ rtrim(rtrim(number_format((float) $scoreTotal, 1), '0'), '.') }}
  838. @else
  839. 暂无得分数据
  840. @endif
  841. </li>
  842. <li>得分率:{{ $scoreRate !== null ? number_format((float) $scoreRate * 100, 1) . '%' : '暂无得分率' }}</li>
  843. <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
  844. <li>
  845. 难度匹配:
  846. @if(!empty($difficultySummary['target_label']) && isset($difficultySummary['actual_average_difficulty']))
  847. 目标 {{ $difficultySummary['target_label'] }}
  848. @if(!empty($difficultySummary['target_range']))
  849. ({{ number_format((float)($difficultySummary['target_range']['min'] ?? 0), 2) }}~{{ number_format((float)($difficultySummary['target_range']['max'] ?? 0), 2) }})
  850. @endif
  851. ,实际 {{ number_format((float)($difficultySummary['actual_average_difficulty'] ?? 0), 3) }}
  852. ({{ $difficultySummary['status'] ?? '暂无' }})
  853. @else
  854. 暂无难度匹配数据
  855. @endif
  856. </li>
  857. @if(!empty($difficultySummary['explain']))
  858. <li>难度说明:{{ $difficultySummary['explain'] }}</li>
  859. @endif
  860. <li>
  861. 与历史自己对比:
  862. @if(!empty($historySummary['is_first_exam']))
  863. {{ $historySummary['message'] ?? '这是你的第一次分析报告,先积累样本再看趋势。' }}
  864. @elseif(!empty($historySummary['low_baseline_guard']))
  865. {{ $historySummary['message'] ?? '历史基线偏低,建议看连续趋势。' }}
  866. @elseif(!empty($historySummary['has_data']))
  867. @php
  868. $trendText = (string)($historySummary['trend'] ?? '—');
  869. $tVisual = $trendVisual($trendText);
  870. @endphp
  871. 近几次均值对比:
  872. {{ number_format((float)($historySummary['baseline_score_rate'] ?? 0) * 100, 1) }}%,
  873. 本次{{ ($historySummary['delta_score_rate'] ?? 0) >= 0 ? '提升' : '回落' }}
  874. {{ number_format(abs((float)($historySummary['delta_score_rate'] ?? 0)) * 100, 1) }}%
  875. (<span style="color:{{ $tVisual['color'] ?? '#64748b' }}; font-weight:600;">{{ $tVisual['icon'] ?? '•' }} {{ $trendText }}</span>)
  876. @else
  877. {{ $historySummary['message'] ?? '历史样本不足' }}
  878. @endif
  879. </li>
  880. @if(!empty($peerSummary['show_line']))
  881. <li>
  882. 与同群体对比:
  883. {{ $peerSummary['message'] ?? '' }}
  884. (<span style="color:{{ $peerSummary['band_color'] ?? '#64748b' }}; font-weight:600;">{{ $peerSummary['band_icon'] ?? '•' }} {{ $peerSummary['band'] ?? '—' }}</span>)
  885. </li>
  886. @endif
  887. <li>
  888. 整体水平:
  889. @if($overallScore !== null)
  890. {{ number_format($overallScore, 1) }} 分({{ $overallGrade }})
  891. @else
  892. 待计算
  893. @endif
  894. </li>
  895. </ul>
  896. <div class="overall-meta">
  897. 规则:综合分 = 当前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) }}
  898. </div>
  899. </div>
  900. </div>
  901. <div class="section">
  902. <div class="section-title">二、知识点掌握聚类视图</div>
  903. <div class="cluster-toolbar">
  904. <span class="cluster-legend"><i class="dot" style="background:#52c41a"></i>已掌握</span>
  905. <span class="cluster-legend"><i class="dot" style="background:#faad14"></i>薄弱</span>
  906. <span class="cluster-legend"><i class="dot" style="background:#f5222d"></i>未入门</span>
  907. <span class="cluster-legend"><i class="dot" style="background:#d9d9d9"></i>未学习</span>
  908. <span>按“模块 → 子模块 → 知识点”聚类展示</span>
  909. </div>
  910. <div class="cluster-grid">
  911. @foreach($clusterCards as $cluster)
  912. <div class="cluster-card">
  913. <div class="cluster-card-title">
  914. {{ $cluster['grand_name'] }}
  915. </div>
  916. @if(!empty($cluster['parent_groups']))
  917. @foreach($cluster['parent_groups'] as $parent)
  918. <div class="cluster-subgroup">
  919. <div class="cluster-subgroup-title">{{ $parent['parent_name'] }}</div>
  920. <div class="cluster-points">
  921. @foreach($parent['points'] as $point)
  922. <span class="cluster-point"
  923. style="background:{{ $point['color'] }}"
  924. title="{{ $point['name'] }} · {{ $point['status'] }}{{ $point['mastery_level'] !== null ? '(' . number_format((float)$point['mastery_level'] * 100, 1) . '%)' : '' }}{{ $point['path'] !== '' ? ' · ' . $point['path'] : '' }}"></span>
  925. @endforeach
  926. </div>
  927. </div>
  928. @endforeach
  929. @else
  930. <div class="cluster-empty">当前模块暂无可展示的子知识点。</div>
  931. @endif
  932. </div>
  933. @endforeach
  934. </div>
  935. <div style="margin-top:10px;">
  936. <div class="kp-stats-grid">
  937. <div class="kp-stat-item">
  938. <div class="kp-stat-label">总知识点数</div>
  939. <div class="kp-stat-value">{{ $kpStatsTotal['total'] }}</div>
  940. </div>
  941. <div class="kp-stat-item">
  942. <div class="kp-stat-label">已掌握</div>
  943. <div class="kp-stat-value" style="color:#52c41a;">
  944. {{ $kpStatsTotal['mastered'] }}<span class="kp-stat-rate" style="color:#52c41a;">({{ $kpPct($kpStatsTotal['mastered'], $kpStatsTotal['total']) }})</span>
  945. </div>
  946. </div>
  947. <div class="kp-stat-item">
  948. <div class="kp-stat-label">薄弱</div>
  949. <div class="kp-stat-value" style="color:#faad14;">
  950. {{ $kpStatsTotal['weak'] }}<span class="kp-stat-rate" style="color:#faad14;">({{ $kpPct($kpStatsTotal['weak'], $kpStatsTotal['total']) }})</span>
  951. </div>
  952. </div>
  953. <div class="kp-stat-item">
  954. <div class="kp-stat-label">未入门</div>
  955. <div class="kp-stat-value" style="color:#f5222d;">
  956. {{ $kpStatsTotal['beginner'] }}<span class="kp-stat-rate" style="color:#f5222d;">({{ $kpPct($kpStatsTotal['beginner'], $kpStatsTotal['total']) }})</span>
  957. </div>
  958. </div>
  959. <div class="kp-stat-item">
  960. <div class="kp-stat-label">未学习</div>
  961. <div class="kp-stat-value" style="color:#9ca3af;">
  962. {{ $kpStatsTotal['unlearned'] }}<span class="kp-stat-rate" style="color:#9ca3af;">({{ $kpPct($kpStatsTotal['unlearned'], $kpStatsTotal['total']) }})</span>
  963. </div>
  964. </div>
  965. </div>
  966. </div>
  967. </div>
  968. <div class="section">
  969. <div class="section-title">三、模块现状与提分路径(全局+本学案影响)</div>
  970. <div class="kp-change-box">
  971. <div style="font-size:12px;font-weight:700;color:#0f172a;">本学案知识点变化情况</div>
  972. @if(!empty($kpChangeItems))
  973. <ul class="kp-change-list">
  974. @foreach($kpChangeItems as $item)
  975. @php
  976. $delta = (float) ($item['change'] ?? 0);
  977. $deltaText = ($delta >= 0 ? '+' : '') . number_format($delta * 100, 1) . '%';
  978. $deltaColor = $delta > 0 ? '#16a34a' : ($delta < 0 ? '#dc2626' : '#64748b');
  979. $masteryText = isset($item['mastery_level']) && $item['mastery_level'] !== null
  980. ? number_format((float) $item['mastery_level'] * 100, 1) . '%'
  981. : '--';
  982. @endphp
  983. <li>
  984. {{ $item['name'] ?? '-' }}:
  985. <span style="color:{{ $deltaColor }};font-weight:600;">{{ $deltaText }}</span>
  986. (掌握度{{ $masteryText }},{{ $item['status'] ?? '未学习' }})
  987. </li>
  988. @endforeach
  989. </ul>
  990. @else
  991. <div class="muted" style="margin-top:4px;">暂无可用的知识点变化数据</div>
  992. @endif
  993. </div>
  994. <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">
  995. <div style="font-size:12px;color:#334155;">
  996. 本次学案影响模块:
  997. @if(!empty($impactedModules))
  998. @foreach($impactedModules as $idx => $im)
  999. @php $mName = $im['module_name'] ?? '-'; @endphp
  1000. <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#3730a3;margin-right:4px;">
  1001. {{ $mName }}({{ $im['question_count'] ?? 0 }}题)
  1002. </span>
  1003. @endforeach
  1004. @else
  1005. <span class="muted">暂无命中模块</span>
  1006. @endif
  1007. </div>
  1008. </div>
  1009. <table class="module-table">
  1010. <thead>
  1011. <tr>
  1012. <th style="width: 14%;">模块</th>
  1013. <th style="width: 10%; white-space: nowrap;">本次影响</th>
  1014. <th style="width: 18%;">掌握度<br>(学案影响)</th>
  1015. <th style="width: 12%;">掌握状态</th>
  1016. <th style="width: 9%;">题目数</th>
  1017. <th style="width: 11%;">得分率</th>
  1018. <th style="width: 11%;">路径建议</th>
  1019. <th style="width: 25%;">关注知识点</th>
  1020. </tr>
  1021. </thead>
  1022. <tbody>
  1023. @forelse($moduleRowsWithStatus as $m)
  1024. @php
  1025. $status = (string) ($m['status'] ?? '暂无');
  1026. $color = $statusColor($status);
  1027. $rate = $m['exam_score_rate'] ?? null;
  1028. $qCount = (int) ($m['question_count'] ?? 0);
  1029. $isImpacted = $qCount > 0;
  1030. $pathTag = $pathTagByModuleName[(string) ($m['module_name'] ?? '')] ?? '待观察';
  1031. $pathColor = match ($pathTag) {
  1032. '优先加强' => '#ef4444',
  1033. '需要加强' => '#f59e0b',
  1034. '保分不错' => '#16a34a',
  1035. default => '#64748b',
  1036. };
  1037. $moduleCode = (string) ($m['module_code'] ?? '');
  1038. $impactDelta = $moduleImpactChangeMap[$moduleCode] ?? null;
  1039. $impactArrow = $impactDelta === null
  1040. ? ''
  1041. : ($impactDelta > 0.0005 ? '↑' : ($impactDelta < -0.0005 ? '↓' : '→'));
  1042. $impactColor = $impactDelta === null
  1043. ? '#64748b'
  1044. : ($impactDelta > 0.0005 ? '#16a34a' : ($impactDelta < -0.0005 ? '#dc2626' : '#64748b'));
  1045. $impactText = $impactDelta === null ? '' : number_format(abs($impactDelta) * 100, 1) . '%';
  1046. $impactSuffix = $impactArrow === '→' ? '' : $impactText;
  1047. $moduleName = (string) ($m['module_name'] ?? '');
  1048. $focus = $moduleSuggestionByName[$moduleName] ?? null;
  1049. $focusText = '-';
  1050. if (is_array($focus)) {
  1051. $focusName = (string) ($focus['kp_name'] ?? '');
  1052. $focusTypes = !empty($focus['question_types']) ? implode('、', $focus['question_types']) : '';
  1053. $focusMastery = isset($focus['mastery_level']) && $focus['mastery_level'] !== null
  1054. ? number_format((float) $focus['mastery_level'] * 100, 1) . '%'
  1055. : '--';
  1056. $focusSuffix = $focusTypes !== '' ? (',' . $focusTypes) : '';
  1057. $focusText = $focusName !== ''
  1058. ? ($focusName . '(' . $focusMastery . $focusSuffix . ')')
  1059. : '-';
  1060. }
  1061. @endphp
  1062. <tr>
  1063. <td><span class="module-name">{{ $m['module_name'] ?? '-' }}</span></td>
  1064. <td>
  1065. @if($isImpacted)
  1066. <span class="impact-yes">是</span>
  1067. @else
  1068. <span class="muted">否</span>
  1069. @endif
  1070. </td>
  1071. <td>
  1072. {{ isset($m['mastery_level']) && $m['mastery_level'] !== null ? number_format((float) $m['mastery_level'] * 100, 1) . '%' : '-' }}
  1073. @if($impactArrow !== '')
  1074. <span style="margin-left:4px;color:{{ $impactColor }};font-weight:700;">
  1075. {{ $impactArrow }}{{ $impactSuffix }}
  1076. </span>
  1077. @endif
  1078. </td>
  1079. <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
  1080. <td>{{ $m['question_count'] ?? 0 }}</td>
  1081. <td>{{ $rate !== null ? number_format((float) $rate * 100, 1) . '%' : '-' }}</td>
  1082. <td><span style="color:{{ $pathColor }}; font-weight:700;">{{ $pathTag }}</span></td>
  1083. <td>{{ $focusText }}</td>
  1084. </tr>
  1085. @empty
  1086. <tr>
  1087. <td colspan="8" class="muted">暂无掌握状态数据</td>
  1088. </tr>
  1089. @endforelse
  1090. </tbody>
  1091. </table>
  1092. </div>
  1093. @if(!empty($wrongQuestions))
  1094. <div class="section" style="page-break-inside:auto; break-inside:auto;">
  1095. <div class="section-title">四、这次错题记录</div>
  1096. @if(!empty($kpWrongStats))
  1097. <div style="margin-bottom:8px; padding:8px; border:1px solid #e5e7eb; border-radius:6px; background:#f8fafc;">
  1098. <div style="font-size:12px; font-weight:600; margin-bottom:6px;">知识点错误率</div>
  1099. <div style="font-size:12px; color:#475569; line-height:1.7;">
  1100. @foreach($kpWrongStats as $item)
  1101. <span class="error-kp-tag {{ $item['rate'] > 0.5 ? 'high-risk' : '' }}">{{ $item['kp_name'] }}:{{ $item['wrong'] }}/{{ $item['total'] }}({{ number_format($item['rate'] * 100, 1) }}%)</span>
  1102. @endforeach
  1103. </div>
  1104. </div>
  1105. @endif
  1106. @foreach($wrongQuestions as $q)
  1107. @php
  1108. $studentAnswer = $q['student_answer'] ?? null;
  1109. $correctAnswer = $q['answer'] ?? $q['correct_answer'] ?? null;
  1110. $isCorrect = $q['is_correct'] ?? null;
  1111. if ($isCorrect === null && !empty($studentAnswer) && !empty($correctAnswer)) {
  1112. $isCorrect = (trim($studentAnswer) === trim($correctAnswer)) ? 1 : 0;
  1113. }
  1114. $statusText = '';
  1115. $statusColorValue = '';
  1116. if ($isCorrect === 1) {
  1117. $statusText = '正确';
  1118. $statusColorValue = '#10b981';
  1119. } elseif ($isCorrect === 0) {
  1120. $statusText = '错误';
  1121. $statusColorValue = '#ef4444';
  1122. }
  1123. $showStatus = $statusText !== '';
  1124. $insight = $insightMap[$q['question_number']] ?? ($insightMap[$q['display_number'] ?? null] ?? []);
  1125. $fullScore = $insight['full_score'] ?? ($q['score'] ?? null);
  1126. if ($isCorrect === 1) {
  1127. $score = $fullScore;
  1128. } elseif ($isCorrect === 0) {
  1129. $score = $q['score_obtained'] ?? 0;
  1130. } else {
  1131. $score = null;
  1132. }
  1133. $analysisRaw = $insight['analysis']
  1134. ?? $insight['thinking_process']
  1135. ?? $insight['feedback']
  1136. ?? $insight['suggestions']
  1137. ?? $insight['reason']
  1138. ?? ($insight['correct_solution'] ?? null);
  1139. if (empty($analysisRaw) && !empty($insight['next_steps'])) {
  1140. $analysisRaw = '后续建议:' . (is_array($insight['next_steps']) ? implode(';', $insight['next_steps']) : $insight['next_steps']);
  1141. }
  1142. $analysis = is_array($analysisRaw) ? json_encode($analysisRaw, JSON_UNESCAPED_UNICODE) : $analysisRaw;
  1143. if ($analysis === null || $analysis === '') {
  1144. $analysis = '暂无解题思路,待补充';
  1145. }
  1146. if (is_string($analysis)) {
  1147. $analysis = preg_replace('/^【?\s*解题思路\s*】?\s*[::]?\s*/u', '', $analysis);
  1148. }
  1149. $formatSolutionLikeGrading = function ($text) {
  1150. if (!is_string($text) || trim($text) === '') {
  1151. return $text;
  1152. }
  1153. $normalized = preg_replace('/\s*;\s*步骤\s*(\d+)/u', ";\n步骤$1", $text);
  1154. $normalized = preg_replace('/\s*。\s*步骤\s*(\d+)/u', "。\n步骤$1", $normalized);
  1155. $normalized = preg_replace('/(?<!^)(步骤\s*\d+\s*[::])/u', "\n$1", $normalized);
  1156. $normalized = preg_replace('/(?<!^)(第\s*\d+\s*步\s*[::]?)/u', "\n$1", $normalized);
  1157. $normalized = preg_replace('/\n{3,}/u', "\n\n", $normalized);
  1158. $normalized = preg_replace('/^[\h\x{3000}]+/mu', '', $normalized);
  1159. return trim($normalized);
  1160. };
  1161. $stepsRaw = $insight['steps'] ?? $insight['solution_steps'] ?? $insight['analysis_steps'] ?? null;
  1162. $steps = [];
  1163. if (is_array($stepsRaw)) {
  1164. $steps = $stepsRaw;
  1165. } elseif (is_string($stepsRaw) && trim($stepsRaw) !== '') {
  1166. $steps = preg_split('/[\r\n]+/', trim($stepsRaw));
  1167. }
  1168. $typeMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
  1169. $typeLabel = $typeMap[$q['question_type'] ?? ''] ?? ($q['question_type'] ?? '题型未标注');
  1170. $questionText = is_string($q['question_text']) ? $q['question_text'] : json_encode($q['question_text'], JSON_UNESCAPED_UNICODE);
  1171. $solution = $q['solution'] ?? null;
  1172. if (is_string($solution)) {
  1173. $solution = preg_replace('/^【?\s*解题思路\s*】?\s*[::]?\s*/u', '', $solution);
  1174. }
  1175. $solution = $formatSolutionLikeGrading($solution);
  1176. $analysis = $formatSolutionLikeGrading($analysis);
  1177. $renderLikeGrading = function ($text) {
  1178. if (is_array($text)) {
  1179. $text = json_encode($text, JSON_UNESCAPED_UNICODE);
  1180. }
  1181. $text = is_string($text) ? trim($text) : '';
  1182. if ($text === '') {
  1183. return '';
  1184. }
  1185. // 兼容题库里常见的转义写法:\$x\$、\$\frac{...}\$
  1186. $text = preg_replace('/\\\\\\$/u', '$', $text);
  1187. return \App\Services\MathFormulaProcessor::processFormulas($text);
  1188. };
  1189. $questionTextRendered = $renderLikeGrading($questionText);
  1190. $displayCorrectAnswer = is_array($correctAnswer) ? json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) : (string) $correctAnswer;
  1191. $questionTypeRaw = strtolower(trim((string) ($q['question_type'] ?? '')));
  1192. $isChoiceQuestion = in_array($questionTypeRaw, ['choice', 'multiple_choice', 'single_choice', '选择题', 'select'], true);
  1193. $normalizedOptions = [];
  1194. $correctAnswerLetters = [];
  1195. if ($isChoiceQuestion) {
  1196. $rawOptions = $q['options'] ?? [];
  1197. if (is_string($rawOptions)) {
  1198. $decodedOptions = json_decode($rawOptions, true);
  1199. $rawOptions = is_array($decodedOptions) ? $decodedOptions : [];
  1200. }
  1201. if (is_array($rawOptions)) {
  1202. foreach ($rawOptions as $optKey => $optValue) {
  1203. $letter = null;
  1204. if (is_string($optKey) && preg_match('/([A-H])/i', $optKey, $m)) {
  1205. $letter = strtoupper($m[1]);
  1206. } elseif (is_array($optValue)) {
  1207. $candidate = $optValue['label'] ?? $optValue['key'] ?? $optValue['option'] ?? null;
  1208. if (is_string($candidate) && preg_match('/([A-H])/i', $candidate, $m)) {
  1209. $letter = strtoupper($m[1]);
  1210. }
  1211. }
  1212. if ($letter === null) {
  1213. continue;
  1214. }
  1215. $content = is_array($optValue) ? ($optValue['content'] ?? $optValue['text'] ?? $optValue['value'] ?? '') : $optValue;
  1216. if (!is_string($content)) {
  1217. $content = json_encode($content, JSON_UNESCAPED_UNICODE);
  1218. }
  1219. $content = trim((string) $content);
  1220. if ($content !== '') {
  1221. $normalizedOptions[$letter] = $content;
  1222. }
  1223. }
  1224. }
  1225. if (trim((string) $correctAnswer) !== '') {
  1226. preg_match_all('/[A-H]/i', strtoupper((string) $correctAnswer), $answerMatches);
  1227. $correctAnswerLetters = array_values(array_unique($answerMatches[0] ?? []));
  1228. }
  1229. if (!empty($normalizedOptions) && !empty($correctAnswerLetters)) {
  1230. $mappedAnswers = [];
  1231. foreach ($correctAnswerLetters as $letter) {
  1232. if (isset($normalizedOptions[$letter])) {
  1233. $mappedAnswers[] = $letter . '. ' . $normalizedOptions[$letter];
  1234. }
  1235. }
  1236. if (!empty($mappedAnswers)) {
  1237. $displayCorrectAnswer = implode(';', $mappedAnswers);
  1238. }
  1239. }
  1240. }
  1241. $choiceOptionLetters = !empty($normalizedOptions) ? array_keys($normalizedOptions) : [];
  1242. sort($choiceOptionLetters);
  1243. $choiceLayoutClass = 'options-grid-1';
  1244. $layoutDecider = app(\App\Support\OptionLayoutDecider::class);
  1245. if (! empty($normalizedOptions) && ! empty($choiceOptionLetters)) {
  1246. $optValuesForLayout = [];
  1247. foreach ($choiceOptionLetters as $L) {
  1248. $optValuesForLayout[] = $normalizedOptions[$L];
  1249. }
  1250. $layoutMeta = $layoutDecider->decide($optValuesForLayout, 'grading');
  1251. $choiceLayoutClass = $layoutMeta['class'] ?? 'options-grid-1';
  1252. }
  1253. @endphp
  1254. <div class="question-card">
  1255. <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px;">
  1256. <div style="display:flex; align-items:center; gap:8px; font-weight:600;">
  1257. <span class="tag">题号 {{ $q['display_number'] ?? $q['question_number'] }} · {{ $typeLabel }}</span>
  1258. @php
  1259. $kpName = $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? null;
  1260. if (!empty($kpName) && $kpName !== '-' && $kpName !== '未标注') {
  1261. echo '<span class="tag" style="background: #eef2ff; color:#4338ca;">' . e($kpName) . '</span>';
  1262. }
  1263. @endphp
  1264. @if($showStatus)
  1265. <span class="tag" style="background: {{ $statusColorValue }}; color:#fff;">{{ $statusText }}</span>
  1266. @endif
  1267. </div>
  1268. @if($score !== null && $fullScore !== null)
  1269. <div class="muted">得分 {{ $score }} / {{ $fullScore }}</div>
  1270. @endif
  1271. </div>
  1272. <div class="question-stem math-content" style="margin-bottom:6px;">{!! $questionTextRendered !!}</div>
  1273. @if(!empty($isChoiceQuestion) && !empty($normalizedOptions))
  1274. <div class="report-options {{ $choiceLayoutClass }}">
  1275. @foreach($choiceOptionLetters as $optLetter)
  1276. @php
  1277. $isCorrectOpt = in_array($optLetter, $correctAnswerLetters ?? [], true);
  1278. $rawOpt = (string) ($normalizedOptions[$optLetter] ?? '');
  1279. $normalizedOpt = str_replace('\\dfrac', '\\frac', $rawOpt);
  1280. $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
  1281. $normalizedOpt = $layoutDecider->normalizeCompactMathForDisplay($normalizedOpt);
  1282. $rawOptPlain = html_entity_decode(strip_tags($rawOpt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  1283. $rawOptPlain = preg_replace('/\s+/u', '', $rawOptPlain ?? '');
  1284. $isShortOption = mb_strlen((string) $rawOptPlain, 'UTF-8') <= 8;
  1285. $valClass = $isShortOption ? 'option-short' : 'option-long';
  1286. $renderedOpt = $renderLikeGrading($normalizedOpt);
  1287. @endphp
  1288. <div class="option option-compact">
  1289. <strong>{{ $optLetter }}.</strong>
  1290. <span class="option-value {{ $valClass }}">{!! $renderedOpt !!}</span>
  1291. @if($isCorrectOpt)
  1292. <span style="margin-left:4px; font-size:13px; color:#15803d; font-weight:700;">✅</span>
  1293. @endif
  1294. </div>
  1295. @endforeach
  1296. </div>
  1297. @endif
  1298. @if(!empty($correctAnswer) && (!$isChoiceQuestion || empty($normalizedOptions)))
  1299. <div class="question-block" style="background:#f0fdf4; border-left:3px solid #10b981;">
  1300. <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:3px;">正确答案</div>
  1301. <div class="math-content" style="line-height:1.7; color:#374151;">
  1302. {!! $renderLikeGrading($displayCorrectAnswer) !!}
  1303. </div>
  1304. </div>
  1305. @endif
  1306. @if(!empty($solution))
  1307. <div class="report-answer-meta">
  1308. <div class="answer-line">
  1309. <strong>解题思路:</strong>
  1310. <span class="solution-content">{!! $renderLikeGrading($solution) !!}</span>
  1311. </div>
  1312. </div>
  1313. @elseif(!empty($analysis) && $analysis !== '暂无解题思路记录')
  1314. <div class="report-answer-meta">
  1315. <div class="answer-line">
  1316. <strong>解题思路:</strong>
  1317. <span class="solution-content">{!! $renderLikeGrading($analysis) !!}</span>
  1318. </div>
  1319. </div>
  1320. @endif
  1321. @if(!empty($steps))
  1322. <div style="margin-top:6px; font-size:12px;">
  1323. <div style="font-weight:600; margin-bottom:3px;">解题步骤</div>
  1324. <ol style="margin:0; padding-left:18px;">
  1325. @foreach($steps as $s)
  1326. @php
  1327. $stepText = is_array($s) ? json_encode($s, JSON_UNESCAPED_UNICODE) : (string) $s;
  1328. @endphp
  1329. <li style="margin-bottom:2px;">{!! nl2br($renderLikeGrading($stepText)) !!}</li>
  1330. @endforeach
  1331. </ol>
  1332. </div>
  1333. @endif
  1334. </div>
  1335. @endforeach
  1336. </div>
  1337. @endif
  1338. </div>
  1339. <script src="/js/katex.min.js"></script>
  1340. <script src="/js/auto-render.min.js"></script>
  1341. <script>
  1342. document.addEventListener('DOMContentLoaded', function() {
  1343. try {
  1344. renderMathInElement(document.body, {
  1345. delimiters: [
  1346. {left: "$$", right: "$$", display: true},
  1347. {left: "$", right: "$", display: false},
  1348. {left: "\\(", right: "\\)", display: false},
  1349. {left: "\\[", right: "\\]", display: true}
  1350. ],
  1351. throwOnError: false,
  1352. strict: false,
  1353. trust: true
  1354. });
  1355. } catch (e) {}
  1356. });
  1357. </script>
  1358. </body>
  1359. </html>