pdf-report-v3.blade.php 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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. $examHitKpSet = array_fill_keys(array_map('strval', $exam_hit_kp_codes ?? []), true);
  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. '薄弱' => '#f59e0b',
  52. '未入门' => '#ef4444',
  53. default => '#64748b',
  54. };
  55. };
  56. $analysisWrongMap = [];
  57. foreach (($analysis_data['question_analysis'] ?? []) as $qa) {
  58. $qid = $qa['question_bank_id'] ?? $qa['question_id'] ?? null;
  59. if ($qid === null || $qid === '') {
  60. continue;
  61. }
  62. $rawCorrect = $qa['is_correct'] ?? null;
  63. $isWrongFromAnalysis = false;
  64. if (is_array($rawCorrect)) {
  65. $isWrongFromAnalysis = in_array(0, $rawCorrect, true);
  66. } elseif ($rawCorrect !== null) {
  67. $isWrongFromAnalysis = !boolval($rawCorrect);
  68. }
  69. if ($isWrongFromAnalysis) {
  70. $analysisWrongMap[(string) $qid] = true;
  71. }
  72. }
  73. $wrongQuestions = [];
  74. foreach (($questions ?? []) as $qItem) {
  75. $isCorrectProbe = $qItem['is_correct'] ?? null;
  76. $studentAnswerProbe = $qItem['student_answer'] ?? null;
  77. $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? null);
  78. if ($isCorrectProbe === null && !empty($studentAnswerProbe) && !empty($correctAnswerProbe)) {
  79. $isCorrectProbe = (trim((string) $studentAnswerProbe) === trim((string) $correctAnswerProbe)) ? 1 : 0;
  80. }
  81. $normalizedCorrect = $isCorrectProbe;
  82. if ($isCorrectProbe !== null) {
  83. $normalizedCorrect = is_bool($isCorrectProbe) ? ($isCorrectProbe ? 1 : 0) : intval($isCorrectProbe);
  84. }
  85. $qidProbe = (string) ($qItem['question_bank_id'] ?? $qItem['question_id'] ?? '');
  86. $isWrongByAnalysis = ($qidProbe !== '' && isset($analysisWrongMap[$qidProbe]));
  87. if ($normalizedCorrect === 0 || $isWrongByAnalysis) {
  88. $wrongQuestions[] = $qItem;
  89. }
  90. }
  91. $kpStats = [];
  92. foreach (($questions ?? []) as $qItem) {
  93. $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
  94. $kpName = $kpName === '' ? '未标注知识点' : $kpName;
  95. if (!isset($kpStats[$kpName])) {
  96. $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
  97. }
  98. $kpStats[$kpName]['total']++;
  99. }
  100. foreach ($wrongQuestions as $qItem) {
  101. $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
  102. $kpName = $kpName === '' ? '未标注知识点' : $kpName;
  103. if (!isset($kpStats[$kpName])) {
  104. $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
  105. }
  106. $kpStats[$kpName]['wrong']++;
  107. }
  108. $kpWrongStats = [];
  109. foreach ($kpStats as $kpName => $stat) {
  110. if (($stat['wrong'] ?? 0) <= 0) {
  111. continue;
  112. }
  113. $total = max(1, intval($stat['total'] ?? 0));
  114. $wrong = intval($stat['wrong'] ?? 0);
  115. $kpWrongStats[] = [
  116. 'kp_name' => $kpName,
  117. 'wrong' => $wrong,
  118. 'total' => $total,
  119. 'rate' => $wrong / $total,
  120. ];
  121. }
  122. usort($kpWrongStats, function ($a, $b) {
  123. if ($a['rate'] === $b['rate']) {
  124. return $b['wrong'] <=> $a['wrong'];
  125. }
  126. return $b['rate'] <=> $a['rate'];
  127. });
  128. $pcMasteryPercent = function ($mastery): ?int {
  129. if ($mastery === null) {
  130. return null;
  131. }
  132. // 与 PC 保持一致:API 先保留 2 位小数,前端再 Math.round 到 0-100。
  133. return (int) round(round((float) $mastery, 2) * 100);
  134. };
  135. $formatMasteryPct = function ($mastery) use ($pcMasteryPercent): string {
  136. $percent = $pcMasteryPercent($mastery);
  137. return $percent === null ? '-' : ($percent . '%');
  138. };
  139. $childMasteryStatus = function ($mastery) use ($pcMasteryPercent): string {
  140. $m = $pcMasteryPercent($mastery);
  141. if ($m === null) {
  142. return '未学习';
  143. }
  144. if ($m >= 85) {
  145. return '已掌握';
  146. }
  147. if ($m >= 60) {
  148. return '薄弱';
  149. }
  150. return '未入门';
  151. };
  152. $childStatusColor = function ($status): string {
  153. return match ($status) {
  154. '已掌握' => '#52c41a',
  155. '薄弱' => '#faad14',
  156. '未入门' => '#f5222d',
  157. default => '#d9d9d9',
  158. };
  159. };
  160. $calcStats = function (array $points): array {
  161. $total = count($points);
  162. $learned = 0;
  163. $mastered = 0;
  164. $weak = 0;
  165. $beginner = 0;
  166. $unlearned = 0;
  167. $hit = 0;
  168. foreach ($points as $p) {
  169. if (($p['mastery_level'] ?? null) !== null) {
  170. $learned++;
  171. }
  172. if (! empty($p['is_hit'])) {
  173. $hit++;
  174. }
  175. $status = (string) ($p['status'] ?? '未学习');
  176. if ($status === '已掌握') {
  177. $mastered++;
  178. } elseif ($status === '薄弱') {
  179. $weak++;
  180. } elseif ($status === '未入门') {
  181. $beginner++;
  182. } else {
  183. $unlearned++;
  184. }
  185. }
  186. return [
  187. 'total' => $total,
  188. 'learned' => $learned,
  189. 'mastered' => $mastered,
  190. 'weak' => $weak,
  191. 'beginner' => $beginner,
  192. 'unlearned' => $unlearned,
  193. 'hit' => $hit,
  194. ];
  195. };
  196. $clusterCards = [];
  197. $allStagePoints = [];
  198. foreach ($radar as $moduleItem) {
  199. $children = is_array($moduleItem['children'] ?? null) ? $moduleItem['children'] : [];
  200. $greatMap = [];
  201. foreach ($children as $child) {
  202. $greatKey = trim((string) ($child['great_grand_parent_name'] ?? ''));
  203. $greatKey = $greatKey !== '' ? $greatKey : '未分组';
  204. $grandKey = trim((string) ($child['grand_parent_name'] ?? ''));
  205. $grandKey = $grandKey !== '' ? $grandKey : '未分组';
  206. $parentName = trim((string) ($child['parent_name'] ?? ''));
  207. if ($parentName === '') {
  208. $parentCode = trim((string) ($child['parent_code'] ?? ''));
  209. $parentName = $parentCode !== '' ? $parentCode : '未分组';
  210. }
  211. $childMasteryLevel = isset($child['mastery_level']) ? (float) $child['mastery_level'] : null;
  212. $status = $childMasteryStatus($childMasteryLevel);
  213. if (!isset($greatMap[$greatKey])) {
  214. $greatMap[$greatKey] = [];
  215. }
  216. if (!isset($greatMap[$greatKey][$grandKey])) {
  217. $greatMap[$greatKey][$grandKey] = [];
  218. }
  219. if (!isset($greatMap[$greatKey][$grandKey][$parentName])) {
  220. $greatMap[$greatKey][$grandKey][$parentName] = [];
  221. }
  222. $childCode = (string) ($child['code'] ?? '');
  223. $childParentCode = (string) ($child['parent_code'] ?? '');
  224. $isHit = !empty($child['is_hit'])
  225. || ($childCode !== '' && isset($examHitKpSet[$childCode]))
  226. || ($childParentCode !== '' && isset($examHitKpSet[$childParentCode]));
  227. $greatMap[$greatKey][$grandKey][$parentName][] = [
  228. 'code' => $childCode,
  229. 'name' => (string) ($child['name'] ?? '未命名知识点'),
  230. 'parent_code' => $childParentCode,
  231. 'path' => (string) ($child['path'] ?? ''),
  232. 'mastery_level' => $childMasteryLevel,
  233. 'change' => isset($child['change']) ? (float) $child['change'] : null,
  234. 'status' => $status,
  235. 'color' => $childStatusColor($status),
  236. 'is_hit' => $isHit,
  237. ];
  238. $allStagePoints[] = [
  239. 'code' => $childCode,
  240. 'name' => (string) ($child['name'] ?? '未命名知识点'),
  241. 'parent_code' => $childParentCode,
  242. 'mastery_level' => $childMasteryLevel,
  243. 'status' => $status,
  244. 'change' => isset($child['change']) ? (float) $child['change'] : null,
  245. 'is_hit' => $isHit,
  246. ];
  247. }
  248. $greatGroups = [];
  249. foreach ($greatMap as $greatName => $grandMap) {
  250. $grandGroups = [];
  251. foreach ($grandMap as $grandName => $parentMap) {
  252. $parentGroups = [];
  253. foreach ($parentMap as $parentName => $points) {
  254. usort($points, function ($a, $b) {
  255. $am = $a['mastery_level'] ?? -1;
  256. $bm = $b['mastery_level'] ?? -1;
  257. if ($am === $bm) {
  258. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  259. }
  260. return $bm <=> $am;
  261. });
  262. $parentGroups[] = [
  263. 'parent_name' => $parentName,
  264. 'points' => $points,
  265. 'stats' => $calcStats($points),
  266. ];
  267. }
  268. // 子模块级过滤:整行没有任何掌握度数字则不显示
  269. $parentGroups = array_values(array_filter($parentGroups, function ($pg) {
  270. return (($pg['stats']['learned'] ?? 0) > 0) || (($pg['stats']['hit'] ?? 0) > 0);
  271. }));
  272. if (empty($parentGroups)) {
  273. continue;
  274. }
  275. usort($parentGroups, function ($a, $b) {
  276. $sa = $a['stats'];
  277. $sb = $b['stats'];
  278. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  279. });
  280. $allGrandPoints = [];
  281. foreach ($parentGroups as $pg) {
  282. $allGrandPoints = array_merge($allGrandPoints, $pg['points']);
  283. }
  284. $grandGroups[] = [
  285. 'grand_name' => $grandName,
  286. 'parent_groups' => $parentGroups,
  287. 'stats' => $calcStats($allGrandPoints),
  288. ];
  289. }
  290. // 大块级过滤:整块没有任何掌握度数字则不显示
  291. $grandGroups = array_values(array_filter($grandGroups, function ($gg) {
  292. return (($gg['stats']['learned'] ?? 0) > 0) || (($gg['stats']['hit'] ?? 0) > 0);
  293. }));
  294. if (empty($grandGroups)) {
  295. continue;
  296. }
  297. usort($grandGroups, function ($a, $b) {
  298. $sa = $a['stats'];
  299. $sb = $b['stats'];
  300. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  301. });
  302. $allGreatPoints = [];
  303. foreach ($grandGroups as $gg) {
  304. foreach ($gg['parent_groups'] as $pg) {
  305. $allGreatPoints = array_merge($allGreatPoints, $pg['points']);
  306. }
  307. }
  308. $greatGroups[] = [
  309. 'great_name' => $greatName,
  310. 'grand_groups' => $grandGroups,
  311. 'stats' => $calcStats($allGreatPoints),
  312. ];
  313. }
  314. usort($greatGroups, function ($a, $b) {
  315. $sa = $a['stats'];
  316. $sb = $b['stats'];
  317. return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
  318. });
  319. // 严格参考 math.client-pc:扁平化为“grand 层卡片”(展示大块)
  320. foreach ($greatGroups as $great) {
  321. foreach (($great['grand_groups'] ?? []) as $grand) {
  322. $gStats = $grand['stats'] ?? ['learned' => 0, 'total' => 0];
  323. $clusterCards[] = [
  324. 'module_name' => (string) ($moduleItem['name'] ?? '未分组'),
  325. 'great_name' => $great['great_name'] ?? '未分组',
  326. 'grand_name' => $grand['grand_name'] ?? '未分组',
  327. 'parent_groups' => $grand['parent_groups'] ?? [],
  328. 'stats' => $gStats,
  329. ];
  330. }
  331. }
  332. }
  333. usort($clusterCards, function ($a, $b) {
  334. $sa = $a['stats'] ?? ['learned' => 0, 'total' => 0];
  335. $sb = $b['stats'] ?? ['learned' => 0, 'total' => 0];
  336. return (($sb['learned'] ?? 0) <=> ($sa['learned'] ?? 0))
  337. ?: (($sb['total'] ?? 0) <=> ($sa['total'] ?? 0));
  338. });
  339. $kpStatsTotal = [
  340. 'total' => count($allStagePoints),
  341. 'mastered' => 0,
  342. 'weak' => 0,
  343. 'beginner' => 0,
  344. 'unlearned' => 0,
  345. ];
  346. foreach ($allStagePoints as $p) {
  347. $st = (string) ($p['status'] ?? '未学习');
  348. if ($st === '已掌握') {
  349. $kpStatsTotal['mastered']++;
  350. } elseif ($st === '薄弱') {
  351. $kpStatsTotal['weak']++;
  352. } elseif ($st === '未入门') {
  353. $kpStatsTotal['beginner']++;
  354. } else {
  355. $kpStatsTotal['unlearned']++;
  356. }
  357. }
  358. $moduleRowsWithStatus = array_values(array_filter($modules, function ($m) {
  359. $status = trim((string) ($m['status'] ?? ''));
  360. $masteryLevel = $m['mastery_level'] ?? null;
  361. $questionCount = (int) ($m['question_count'] ?? 0);
  362. if ($masteryLevel !== null) {
  363. return true;
  364. }
  365. return $questionCount > 0 && $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. $globalPathTagByMastery = function ($mastery) use ($pcMasteryPercent): string {
  378. if ($mastery === null || ! is_numeric($mastery)) {
  379. return '待观察';
  380. }
  381. $m = $pcMasteryPercent($mastery);
  382. if ($m >= 85) {
  383. return '保分不错';
  384. }
  385. if ($m >= 60) {
  386. return '需要加强';
  387. }
  388. return '优先加强';
  389. };
  390. $overallPathTag = function (string $tag): string {
  391. return match ($tag) {
  392. '优先加强' => '整体优先',
  393. '需要加强' => '整体加强',
  394. '保分不错' => '整体巩固',
  395. default => $tag,
  396. };
  397. };
  398. $impactedModules = array_values(array_filter($moduleRowsWithStatus, function ($m) {
  399. return ((int) ($m['question_count'] ?? 0)) > 0;
  400. }));
  401. $radarModuleMap = [];
  402. foreach ($radar as $moduleItem) {
  403. $code = (string) ($moduleItem['code'] ?? '');
  404. if ($code !== '') {
  405. $radarModuleMap[$code] = $moduleItem;
  406. }
  407. }
  408. $moduleKpSuggestions = [];
  409. foreach ($moduleRowsWithStatus as $m) {
  410. $moduleCode = (string) ($m['module_code'] ?? '');
  411. $moduleName = (string) ($m['module_name'] ?? '-');
  412. $moduleChildren = $radarModuleMap[$moduleCode]['children'] ?? [];
  413. if (! is_array($moduleChildren) || empty($moduleChildren)) {
  414. continue;
  415. }
  416. $moduleHitCandidates = [];
  417. foreach (($mastery['items'] ?? []) as $item) {
  418. $hitCode = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
  419. if ($hitCode === '') {
  420. continue;
  421. }
  422. if (! empty($examHitKpSet) && ! isset($examHitKpSet[$hitCode])) {
  423. continue;
  424. }
  425. $hitLevel = $item['mastery_level'] ?? null;
  426. if ($hitLevel === null || ! is_numeric($hitLevel)) {
  427. continue;
  428. }
  429. $matchedChild = null;
  430. foreach ($moduleChildren as $child) {
  431. $childCode = trim((string) ($child['code'] ?? ''));
  432. $parentCode = trim((string) ($child['parent_code'] ?? ''));
  433. if ($childCode === $hitCode || $parentCode === $hitCode) {
  434. $matchedChild = $child;
  435. break;
  436. }
  437. }
  438. if (! is_array($matchedChild)) {
  439. continue;
  440. }
  441. $moduleHitCandidates[$hitCode] = [
  442. 'code' => $hitCode,
  443. 'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($matchedChild['parent_name'] ?? $matchedChild['name'] ?? $hitCode)),
  444. 'parent_code' => (string) ($matchedChild['parent_code'] ?? ''),
  445. 'parent_name' => (string) ($matchedChild['parent_name'] ?? ''),
  446. 'grand_parent_name' => (string) ($matchedChild['grand_parent_name'] ?? ''),
  447. 'mastery_level' => (float) $hitLevel,
  448. 'is_hit' => true,
  449. ];
  450. }
  451. $startedByCode = $moduleHitCandidates;
  452. foreach (array_values(array_filter($moduleChildren, function ($c) {
  453. return isset($c['mastery_level']) && $c['mastery_level'] !== null;
  454. })) as $child) {
  455. $childCode = trim((string) ($child['code'] ?? ''));
  456. if ($childCode !== '' && ! isset($startedByCode[$childCode])) {
  457. $startedByCode[$childCode] = $child;
  458. }
  459. }
  460. $started = array_values($startedByCode);
  461. usort($started, function ($a, $b) {
  462. $am = (float) ($a['mastery_level'] ?? 0);
  463. $bm = (float) ($b['mastery_level'] ?? 0);
  464. if ($am === $bm) {
  465. $ah = !empty($a['is_hit']) ? 0 : 1;
  466. $bh = !empty($b['is_hit']) ? 0 : 1;
  467. if ($ah !== $bh) {
  468. return $ah <=> $bh;
  469. }
  470. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  471. }
  472. return $am <=> $bm;
  473. });
  474. $weakest = null;
  475. if (! empty($started)) {
  476. $lowestStarted = $started[0];
  477. $lowestStartedLevel = isset($lowestStarted['mastery_level']) ? (float) $lowestStarted['mastery_level'] : null;
  478. if ($lowestStartedLevel !== null && ($pcMasteryPercent($lowestStartedLevel) ?? 0) < 85) {
  479. // 规则1:已开始学习中掌握度最低
  480. $weakest = $lowestStarted;
  481. } else {
  482. // 规则2:若已开始学习均达标(>=85%),取“最近的未学习”
  483. $unlearned = array_values(array_filter($moduleChildren, function ($c) {
  484. return !isset($c['mastery_level']) || $c['mastery_level'] === null;
  485. }));
  486. if (! empty($unlearned)) {
  487. $anchorParent = (string) ($lowestStarted['parent_name'] ?? '');
  488. $anchorGrand = (string) ($lowestStarted['grand_parent_name'] ?? '');
  489. usort($unlearned, function ($a, $b) use ($anchorParent, $anchorGrand) {
  490. $score = function ($node) use ($anchorParent, $anchorGrand) {
  491. $parent = (string) ($node['parent_name'] ?? '');
  492. $grand = (string) ($node['grand_parent_name'] ?? '');
  493. if ($anchorParent !== '' && $parent === $anchorParent) {
  494. return 0;
  495. }
  496. if ($anchorGrand !== '' && $grand === $anchorGrand) {
  497. return 1;
  498. }
  499. return 2;
  500. };
  501. $sa = $score($a);
  502. $sb = $score($b);
  503. if ($sa === $sb) {
  504. return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
  505. }
  506. return $sa <=> $sb;
  507. });
  508. $weakest = $unlearned[0];
  509. }
  510. }
  511. } else {
  512. // 没有已开始学习数据时,回退到模块内任一未学习点
  513. $unlearned = array_values(array_filter($moduleChildren, function ($c) {
  514. return !isset($c['mastery_level']) || $c['mastery_level'] === null;
  515. }));
  516. if (! empty($unlearned)) {
  517. usort($unlearned, fn ($a, $b) => strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
  518. $weakest = $unlearned[0];
  519. }
  520. }
  521. if (! is_array($weakest)) {
  522. $moduleKpSuggestions[] = [
  523. 'module_name' => $moduleName,
  524. 'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
  525. 'kp_name' => '',
  526. 'kp_code' => '',
  527. 'mastery_level' => null,
  528. 'status' => '当前模块暂无需额外关注知识点',
  529. 'is_empty' => true,
  530. ];
  531. continue;
  532. }
  533. $kpName = (string) ($weakest['name'] ?? '');
  534. if ($kpName === '') {
  535. continue;
  536. }
  537. $kpCode = (string) ($weakest['code'] ?? '');
  538. $moduleKpSuggestions[] = [
  539. 'module_name' => $moduleName,
  540. 'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
  541. 'kp_name' => $kpName,
  542. 'kp_code' => $kpCode,
  543. 'mastery_level' => $weakest['mastery_level'] ?? null,
  544. 'status' => $childMasteryStatus($weakest['mastery_level'] ?? null),
  545. 'is_empty' => false,
  546. ];
  547. }
  548. $moduleSuggestionByName = [];
  549. foreach ($moduleKpSuggestions as $sug) {
  550. $name = trim((string) ($sug['module_name'] ?? ''));
  551. if ($name !== '') {
  552. $moduleSuggestionByName[$name] = $sug;
  553. }
  554. }
  555. $focusMarkerByCode = [];
  556. foreach ($moduleKpSuggestions as $sug) {
  557. if (! empty($sug['is_empty'])) {
  558. continue;
  559. }
  560. $code = trim((string) ($sug['kp_code'] ?? ''));
  561. $name = trim((string) ($sug['kp_name'] ?? ''));
  562. if ($code === '' || $name === '') {
  563. continue;
  564. }
  565. $focusMarkerByCode[$code] = [
  566. 'name' => $name,
  567. 'module_name' => (string) ($sug['module_name'] ?? ''),
  568. 'mastery_level' => $sug['mastery_level'] ?? null,
  569. ];
  570. }
  571. $renderedFocusMarkerCodes = [];
  572. $kpChangeItems = [];
  573. foreach (($mastery['items'] ?? []) as $item) {
  574. $code = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
  575. if ($code !== '' && ! empty($examHitKpSet) && ! isset($examHitKpSet[$code])) {
  576. continue;
  577. }
  578. $level = $item['mastery_level'] ?? null;
  579. if ($level === null || ! is_numeric($level)) {
  580. continue;
  581. }
  582. $change = $item['mastery_change'] ?? $item['change'] ?? 0.0;
  583. $kpChangeItems[] = [
  584. 'code' => $code,
  585. 'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($code !== '' ? $code : '-')),
  586. 'mastery_level' => (float) $level,
  587. 'change' => is_numeric($change) ? (float) $change : 0.0,
  588. 'status' => $childMasteryStatus((float) $level),
  589. 'is_hit' => true,
  590. ];
  591. }
  592. usort($kpChangeItems, function ($a, $b) {
  593. return abs((float) ($b['change'] ?? 0)) <=> abs((float) ($a['change'] ?? 0));
  594. });
  595. $kpPct = function (int $count, int $total): string {
  596. if ($total <= 0) {
  597. return '0.0%';
  598. }
  599. return number_format(($count * 100.0) / $total, 1) . '%';
  600. };
  601. $changeText = function ($change): string {
  602. if ($change === null || ! is_numeric($change)) {
  603. return '';
  604. }
  605. $delta = (float) $change;
  606. $points = number_format(abs($delta) * 100, 1);
  607. if ($delta > 0.0005) {
  608. return '较上次提升' . $points . '个百分点';
  609. }
  610. if ($delta < -0.0005) {
  611. return '较上次下降' . $points . '个百分点';
  612. }
  613. return '较上次基本持平';
  614. };
  615. @endphp
  616. <!DOCTYPE html>
  617. <html lang="zh-CN">
  618. <head>
  619. <meta charset="UTF-8">
  620. <title>学情分析报告</title>
  621. <link rel="stylesheet" href="/css/katex/katex.min.css">
  622. <style>
  623. @page {
  624. size: A4;
  625. margin: 2.2cm 2cm 2.3cm 2cm;
  626. @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
  627. @top-center { content: "{{ $student['name'] ?? '-' }}"; font-size: 13px; color: #666; }
  628. @top-right {
  629. content: "{{ $reportCode }}";
  630. font-size: 19px;
  631. font-weight: 600;
  632. font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
  633. color: #222;
  634. }
  635. @bottom-left { content: "{{ $reportCode }}"; font-size: 11px; color: #666; }
  636. @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
  637. }
  638. * { box-sizing: border-box; }
  639. body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.65; }
  640. .page { page-break-after: auto; }
  641. .header { text-align: left; margin-bottom: 16px; }
  642. .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
  643. .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
  644. .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; }
  645. .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; position: relative; }
  646. .summary-list { margin: 0; padding-left: 18px; }
  647. .summary-list li { margin: 6px 0; font-size: 13px; }
  648. .overall-badge {
  649. position: absolute;
  650. right: 14px;
  651. top: 12px;
  652. border-radius: 12px;
  653. border: 0;
  654. padding: 9px 16px;
  655. min-width: 0;
  656. width: auto;
  657. text-align: center;
  658. position: absolute;
  659. overflow: hidden;
  660. display: inline-block;
  661. white-space: nowrap;
  662. background: transparent !important;
  663. }
  664. .overall-badge .level { font-size: 28px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; }
  665. .overall-badge .score { font-size: 13px; margin-top: 3px; }
  666. .overall-badge.badge-s {
  667. border: 5px solid #6d28d9;
  668. border-radius: 14px;
  669. box-shadow: none;
  670. transform: rotate(-7deg);
  671. }
  672. .overall-badge.badge-s::before {
  673. content: "";
  674. position: absolute;
  675. inset: 4px;
  676. border: 2px dashed rgba(109, 40, 217, 0.65);
  677. border-radius: 10px;
  678. pointer-events: none;
  679. }
  680. .overall-badge.badge-s .level {
  681. letter-spacing: 2px;
  682. text-shadow: 0 1px 0 rgba(109, 40, 217, 0.24);
  683. }
  684. .overall-badge.badge-excellent {
  685. border: 3px double #16a34a;
  686. border-radius: 999px;
  687. box-shadow: none;
  688. }
  689. .overall-badge.badge-good {
  690. border: 2px solid #2563eb;
  691. border-radius: 10px;
  692. clip-path: polygon(6% 0, 94% 0, 100% 50%, 94% 100%, 6% 100%, 0 50%);
  693. box-shadow: none;
  694. }
  695. .overall-badge.badge-average {
  696. border: 2px dashed #d97706;
  697. border-radius: 14px;
  698. box-shadow: none;
  699. }
  700. .overall-badge.badge-weak {
  701. border-left: 3px solid #ef4444;
  702. border-right: 0;
  703. border-top: 0;
  704. border-bottom: 2px solid #ef4444;
  705. border-radius: 0 10px 10px 0;
  706. box-shadow: none;
  707. }
  708. .overall-meta { margin-top: 8px; font-size: 9px; color: #64748b; line-height: 1.6; white-space: nowrap; }
  709. .dot {
  710. display: inline-block;
  711. width: 10px;
  712. height: 10px;
  713. border-radius: 2px;
  714. margin-right: 4px;
  715. vertical-align: middle;
  716. border: 1px solid #374151;
  717. background: #fff;
  718. }
  719. .dot-mastered {
  720. background: #111827;
  721. border-style: solid;
  722. }
  723. .dot-weak {
  724. background: #9ca3af;
  725. border-style: solid;
  726. }
  727. .dot-beginner {
  728. background: #e5e7eb;
  729. border: 1px dashed #6b7280;
  730. }
  731. .dot-unlearned {
  732. background: #ffffff;
  733. border-style: solid;
  734. border-color: #9ca3af;
  735. }
  736. .cluster-toolbar {
  737. margin-bottom: 8px;
  738. font-size: 11px;
  739. color: #475569;
  740. white-space: nowrap;
  741. }
  742. .cluster-legend { display: inline-block; margin-right: 12px; }
  743. .cluster-grid {
  744. display: grid;
  745. grid-template-columns: 1fr 1fr;
  746. gap: 10px;
  747. }
  748. .cluster-card {
  749. border: 1px solid #e2e8f0;
  750. border-radius: 10px;
  751. padding: 10px;
  752. background: #fff;
  753. position: relative;
  754. overflow: visible;
  755. }
  756. .cluster-card-title {
  757. font-size: 14px;
  758. font-weight: 700;
  759. color: #0f172a;
  760. margin-bottom: 8px;
  761. }
  762. .cluster-subgroup {
  763. border-left: 2px solid #e5e7eb;
  764. padding-left: 8px;
  765. padding-right: 128px; /* 右侧空白区域再缩小 */
  766. margin-bottom: 8px;
  767. position: relative;
  768. }
  769. .cluster-subgroup:last-child { margin-bottom: 0; }
  770. .cluster-subgroup-title {
  771. font-size: 12px;
  772. font-weight: 600;
  773. color: #334155;
  774. margin-bottom: 4px;
  775. }
  776. .cluster-points {
  777. display: flex;
  778. flex-wrap: wrap;
  779. gap: 4px;
  780. }
  781. .cluster-point {
  782. width: 10px;
  783. height: 10px;
  784. border-radius: 2px;
  785. display: inline-block;
  786. border: 1px solid rgba(148, 163, 184, 0.35);
  787. position: relative;
  788. }
  789. .cluster-point.status-mastered {
  790. background: #111827;
  791. border: 1px solid #1f2937;
  792. }
  793. .cluster-point.status-weak {
  794. background: #9ca3af;
  795. border: 1px solid #1f2937;
  796. }
  797. .cluster-point.status-beginner {
  798. background: #e5e7eb !important;
  799. border: 1px dashed #6b7280;
  800. }
  801. .cluster-point.status-unlearned {
  802. background: #ffffff !important;
  803. border: 1px solid #9ca3af;
  804. }
  805. .cluster-point.focus-source {
  806. border-color: rgba(148, 163, 184, 0.35);
  807. box-shadow: 0 0 0 2px #fde68a, 0 0 0 4px rgba(251, 191, 36, 0.18);
  808. margin-right: 4px;
  809. margin-bottom: 4px;
  810. z-index: 2;
  811. overflow: visible;
  812. }
  813. .cluster-focus-connector {
  814. position: absolute;
  815. left: 0;
  816. top: -12px;
  817. width: 112px;
  818. height: 46px;
  819. overflow: visible;
  820. pointer-events: none;
  821. z-index: 2;
  822. }
  823. .cluster-focus-connector path {
  824. fill: none;
  825. stroke: #0f172a;
  826. stroke-width: 1;
  827. stroke-linecap: round;
  828. }
  829. .cluster-focus-connector.dense {
  830. width: 128px;
  831. height: 46px;
  832. }
  833. .cluster-focus-connector.bottom {
  834. width: 118px;
  835. height: 46px;
  836. }
  837. .cluster-point-focus-label {
  838. position: absolute;
  839. left: 102px; /* 放到右侧空白区,并远离最右侧方块 */
  840. top: 50%; /* 与点位在同一水平带,避免压住文字 */
  841. transform: translateY(-50%);
  842. display: inline-block;
  843. max-width: none;
  844. border: 1px solid #0f172a;
  845. border-radius: 6px;
  846. background: #fffbeb;
  847. color: #92400e;
  848. font-size: 9px;
  849. font-weight: 700;
  850. padding: 1px 6px;
  851. line-height: 1.25;
  852. white-space: nowrap;
  853. z-index: 3;
  854. overflow: visible;
  855. }
  856. .cluster-point-focus-label.focus-offset-a { top: 50%; left: 102px; transform: translateY(-50%); }
  857. .cluster-point-focus-label.focus-offset-b { top: 42%; left: 102px; transform: translateY(-50%); }
  858. .cluster-point-focus-label.focus-offset-c { top: 58%; left: 102px; transform: translateY(-50%); }
  859. .cluster-point-focus-label.dense { left: 116px; top: 50%; transform: translateY(-50%); }
  860. .cluster-point-focus-label.bottom { left: 108px; top: 44%; transform: translateY(-50%); }
  861. .cluster-empty {
  862. font-size: 12px;
  863. color: #64748b;
  864. background: #f8fafc;
  865. border: 1px dashed #cbd5e1;
  866. border-radius: 8px;
  867. padding: 10px;
  868. }
  869. .kp-stats-grid {
  870. display: grid;
  871. grid-template-columns: repeat(5, 1fr);
  872. border: 1px solid #e5e7eb;
  873. border-radius: 10px;
  874. overflow: hidden;
  875. margin-bottom: 10px;
  876. }
  877. .kp-stat-item {
  878. padding: 8px 10px;
  879. border-right: 1px solid #e5e7eb;
  880. background: #fff;
  881. }
  882. .kp-stat-item:last-child { border-right: none; }
  883. .kp-stat-label { font-size: 11px; color: #64748b; }
  884. .kp-stat-value { font-size: 18px; font-weight: 700; color: #111827; line-height: 1.2; margin-top: 2px; }
  885. .kp-stat-rate { font-size: 11px; margin-left: 4px; font-weight: 600; }
  886. .kp-change-box { margin-bottom: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; padding: 10px 12px; }
  887. .kp-change-list { margin: 4px 0 0 16px; padding: 0; }
  888. .kp-change-list li { margin: 2px 0; color: #334155; }
  889. .kp-burst-card { margin-top: 10px; border: 1px solid #dbeafe; border-radius: 12px; padding: 10px; background: #fff; }
  890. .kp-burst-title { font-size: 13px; font-weight: 700; margin-bottom: 6px; color: #0b3a75; }
  891. .kp-burst-meta { font-size: 12px; color: #334155; margin-top: 6px; line-height: 1.6; }
  892. .kp-burst-list { margin-top: 6px; font-size: 11px; color: #334155; line-height: 1.5; }
  893. .kp-burst-list span { display: inline-block; margin-right: 10px; margin-bottom: 3px; }
  894. table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
  895. th, td { border: 1px solid #d0d7e2; padding: 8px 10px; text-align: left; vertical-align: top; }
  896. th { background: #f1f5f9; color: #1e293b; font-weight: 700; }
  897. .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
  898. .module-table th { background: #edf2ff; color: #0f172a; }
  899. .module-table th { text-align: center; }
  900. .module-table td { line-height: 1.45; }
  901. .module-table th,
  902. .module-table td { vertical-align: middle; }
  903. .module-table th:nth-child(6) { vertical-align: middle; }
  904. .module-table td:nth-child(6) { vertical-align: middle; text-align: center; }
  905. .module-table th:nth-child(1),
  906. .module-table td:nth-child(1) { text-align: center; }
  907. .module-table td:nth-child(2),
  908. .module-table td:nth-child(3),
  909. .module-table td:nth-child(4),
  910. .module-table td:nth-child(5) { text-align: center; white-space: nowrap; }
  911. .module-table td:nth-child(6) { font-size: 11px; color: #334155; }
  912. .module-table tbody tr:nth-child(even) td { background: #fcfdff; }
  913. .module-name { font-weight: 600; color: #0f172a; }
  914. .impact-yes { color:#2563eb; font-weight:600; }
  915. .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #334155; background: #e5e7eb; }
  916. .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; }
  917. .error-kp-tag.high-risk { color: #b91c1c; border-color: #fca5a5; background: #fff; font-weight: 600; }
  918. .muted { color: #6b7280; font-size: 12px; }
  919. </style>
  920. </head>
  921. <body>
  922. <div class="page">
  923. <div class="header">
  924. <h1 class="paper-title">学情分析报告</h1>
  925. </div>
  926. <div class="section">
  927. <div class="section-title">一、总体评估</div>
  928. <div class="card">
  929. <div class="overall-badge {{ $overallVisual['class'] ?? '' }}"
  930. style="border-color:{{ $overallVisual['border'] }}; color:{{ $overallVisual['text'] }};">
  931. <div class="level">{{ $overallGrade }}</div>
  932. </div>
  933. <ul class="summary-list">
  934. <li>本次诊断得分:
  935. @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
  936. {{ rtrim(rtrim(number_format((float) $scoreObtained, 1), '0'), '.') }}/{{ rtrim(rtrim(number_format((float) $scoreTotal, 1), '0'), '.') }}
  937. @else
  938. 暂无得分数据
  939. @endif
  940. </li>
  941. <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
  942. <li>
  943. 难度匹配:
  944. @if(!empty($difficultySummary['target_label']) && isset($difficultySummary['actual_average_difficulty']))
  945. 目标 {{ $difficultySummary['target_label'] }}
  946. @if(!empty($difficultySummary['target_range']))
  947. ({{ number_format((float)($difficultySummary['target_range']['min'] ?? 0), 2) }}~{{ number_format((float)($difficultySummary['target_range']['max'] ?? 0), 2) }})
  948. @endif
  949. ,实际 {{ number_format((float)($difficultySummary['actual_average_difficulty'] ?? 0), 3) }}
  950. ({{ $difficultySummary['status'] ?? '暂无' }})
  951. @else
  952. 暂无难度匹配数据
  953. @endif
  954. </li>
  955. @if(!empty($difficultySummary['explain']))
  956. <li>难度说明:{{ $difficultySummary['explain'] }}</li>
  957. @endif
  958. <li>
  959. 与历史自己对比:
  960. @if(!empty($historySummary['is_first_exam']))
  961. {{ $historySummary['message'] ?? '这是你的第一次分析报告,先积累样本再看趋势。' }}
  962. @elseif(!empty($historySummary['low_baseline_guard']))
  963. {{ $historySummary['message'] ?? '历史基线偏低,建议看连续趋势。' }}
  964. @elseif(!empty($historySummary['has_data']))
  965. @php
  966. $trendText = (string)($historySummary['trend'] ?? '—');
  967. $tVisual = $trendVisual($trendText);
  968. @endphp
  969. 近几次均值对比:
  970. {{ number_format((float)($historySummary['baseline_score_rate'] ?? 0) * 100, 1) }}%,
  971. 本次{{ ($historySummary['delta_score_rate'] ?? 0) >= 0 ? '提升' : '回落' }}
  972. {{ number_format(abs((float)($historySummary['delta_score_rate'] ?? 0)) * 100, 1) }}%
  973. (<span style="color:{{ $tVisual['color'] ?? '#64748b' }}; font-weight:600;">{{ $tVisual['icon'] ?? '•' }} {{ $trendText }}</span>)
  974. @else
  975. {{ $historySummary['message'] ?? '历史样本不足' }}
  976. @endif
  977. </li>
  978. @if(!empty($peerSummary['show_line']))
  979. <li>
  980. 与同群体对比:
  981. {{ $peerSummary['message'] ?? '' }}
  982. (<span style="color:{{ $peerSummary['band_color'] ?? '#64748b' }}; font-weight:600;">{{ $peerSummary['band_icon'] ?? '•' }} {{ $peerSummary['band'] ?? '—' }}</span>)
  983. </li>
  984. @endif
  985. <li>
  986. 整体水平:
  987. @if($overallScore !== null)
  988. {{ number_format($overallScore, 1) }} 分({{ $overallGrade }})
  989. @else
  990. 待计算
  991. @endif
  992. </li>
  993. </ul>
  994. <div class="overall-meta">
  995. 规则:综合分 = 当前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) }}
  996. </div>
  997. </div>
  998. </div>
  999. <div class="section">
  1000. <div class="section-title">二、知识点掌握聚类视图</div>
  1001. <div class="cluster-toolbar">
  1002. <span class="cluster-legend"><i class="dot dot-mastered"></i>已掌握(深色实心)</span>
  1003. <span class="cluster-legend"><i class="dot dot-weak"></i>薄弱(浅灰实心)</span>
  1004. <span class="cluster-legend"><i class="dot dot-beginner"></i>未入门(浅灰虚线框)</span>
  1005. <span class="cluster-legend"><i class="dot dot-unlearned"></i>未学习(白色)</span>
  1006. <span>按“模块 → 子模块 → 知识点”聚类展示</span>
  1007. </div>
  1008. <div class="cluster-grid">
  1009. @foreach($clusterCards as $cluster)
  1010. <div class="cluster-card">
  1011. @php
  1012. $clusterModuleName = trim((string) ($cluster['module_name'] ?? '未分组'));
  1013. $clusterGrandName = trim((string) ($cluster['grand_name'] ?? ''));
  1014. $clusterTitle = ($clusterGrandName !== '' && $clusterGrandName !== $clusterModuleName)
  1015. ? ($clusterModuleName . ' / ' . $clusterGrandName)
  1016. : $clusterModuleName;
  1017. @endphp
  1018. <div class="cluster-card-title">
  1019. {{ $clusterTitle }}
  1020. </div>
  1021. @if(!empty($cluster['parent_groups']))
  1022. @foreach($cluster['parent_groups'] as $parent)
  1023. <div class="cluster-subgroup">
  1024. <div class="cluster-subgroup-title">{{ $parent['parent_name'] }}</div>
  1025. <div class="cluster-points">
  1026. @foreach($parent['points'] as $point)
  1027. @php
  1028. $pointCode = trim((string) ($point['code'] ?? ''));
  1029. $pointParentCode = trim((string) ($point['parent_code'] ?? ''));
  1030. $focusMarker = null;
  1031. $focusMarkerCode = '';
  1032. if ($pointCode !== '' && isset($focusMarkerByCode[$pointCode]) && empty($renderedFocusMarkerCodes[$pointCode])) {
  1033. $focusMarker = $focusMarkerByCode[$pointCode];
  1034. $focusMarkerCode = $pointCode;
  1035. } elseif ($pointParentCode !== '' && isset($focusMarkerByCode[$pointParentCode]) && empty($renderedFocusMarkerCodes[$pointParentCode])) {
  1036. $focusMarker = $focusMarkerByCode[$pointParentCode];
  1037. $focusMarkerCode = $pointParentCode;
  1038. }
  1039. if ($focusMarkerCode !== '') {
  1040. $renderedFocusMarkerCodes[$focusMarkerCode] = true;
  1041. }
  1042. $focusName = is_array($focusMarker) ? (string) ($focusMarker['name'] ?? '') : '';
  1043. $pointStatusClass = match ((string) ($point['status'] ?? '')) {
  1044. '已掌握' => 'status-mastered',
  1045. '薄弱' => 'status-weak',
  1046. '未入门' => 'status-beginner',
  1047. default => 'status-unlearned',
  1048. };
  1049. $focusLayoutClass = '';
  1050. if ($focusName === '幂与指数') {
  1051. $focusLayoutClass = 'dense';
  1052. } elseif (str_contains($clusterModuleName, '图形变化') || str_contains($clusterModuleName, '图形度量')) {
  1053. $focusLayoutClass = 'bottom';
  1054. }
  1055. @endphp
  1056. <span class="cluster-point {{ $pointStatusClass }}{{ $focusName !== '' ? ' focus-source' : '' }}"
  1057. title="{{ $point['name'] }} · {{ $point['status'] }}{{ $point['mastery_level'] !== null ? '(' . $formatMasteryPct($point['mastery_level']) . ')' : '' }}{{ $point['path'] !== '' ? ' · ' . $point['path'] : '' }}">
  1058. @if($focusName !== '')
  1059. @php
  1060. $focusOffsetClass = match ($loop->index % 3) {
  1061. 1 => 'focus-offset-b',
  1062. 2 => 'focus-offset-c',
  1063. default => 'focus-offset-a',
  1064. };
  1065. @endphp
  1066. <svg class="cluster-focus-connector {{ $focusLayoutClass }}" viewBox="0 0 150 64" preserveAspectRatio="none" aria-hidden="true">
  1067. @if($focusLayoutClass === 'dense')
  1068. <path d="M8,24 C18,24 24,18 38,16 C84,14 124,20 138,23 L146,24" />
  1069. @elseif($focusLayoutClass === 'bottom')
  1070. <path d="M8,23 C18,23 24,16 42,13 C86,11 114,20 132,23 L140,23" />
  1071. @else
  1072. <path d="M8,24 C18,24 24,18 42,15 C86,13 114,21 132,24 L142,24" />
  1073. @endif
  1074. </svg>
  1075. <span class="cluster-point-focus-label {{ $focusOffsetClass }} {{ $focusLayoutClass }}">{{ $focusName }}</span>
  1076. @endif
  1077. </span>
  1078. @endforeach
  1079. </div>
  1080. </div>
  1081. @endforeach
  1082. @else
  1083. <div class="cluster-empty">当前模块暂无可展示的子知识点。</div>
  1084. @endif
  1085. </div>
  1086. @endforeach
  1087. </div>
  1088. <div style="margin-top:10px;">
  1089. <div class="kp-stats-grid">
  1090. <div class="kp-stat-item">
  1091. <div class="kp-stat-label">总知识点数</div>
  1092. <div class="kp-stat-value">{{ $kpStatsTotal['total'] }}</div>
  1093. </div>
  1094. <div class="kp-stat-item">
  1095. <div class="kp-stat-label">已掌握</div>
  1096. <div class="kp-stat-value" style="color:#52c41a;">
  1097. {{ $kpStatsTotal['mastered'] }}<span class="kp-stat-rate" style="color:#52c41a;">({{ $kpPct($kpStatsTotal['mastered'], $kpStatsTotal['total']) }})</span>
  1098. </div>
  1099. </div>
  1100. <div class="kp-stat-item">
  1101. <div class="kp-stat-label">薄弱</div>
  1102. <div class="kp-stat-value" style="color:#faad14;">
  1103. {{ $kpStatsTotal['weak'] }}<span class="kp-stat-rate" style="color:#faad14;">({{ $kpPct($kpStatsTotal['weak'], $kpStatsTotal['total']) }})</span>
  1104. </div>
  1105. </div>
  1106. <div class="kp-stat-item">
  1107. <div class="kp-stat-label">未入门</div>
  1108. <div class="kp-stat-value" style="color:#f5222d;">
  1109. {{ $kpStatsTotal['beginner'] }}<span class="kp-stat-rate" style="color:#f5222d;">({{ $kpPct($kpStatsTotal['beginner'], $kpStatsTotal['total']) }})</span>
  1110. </div>
  1111. </div>
  1112. <div class="kp-stat-item">
  1113. <div class="kp-stat-label">未学习</div>
  1114. <div class="kp-stat-value" style="color:#9ca3af;">
  1115. {{ $kpStatsTotal['unlearned'] }}<span class="kp-stat-rate" style="color:#9ca3af;">({{ $kpPct($kpStatsTotal['unlearned'], $kpStatsTotal['total']) }})</span>
  1116. </div>
  1117. </div>
  1118. </div>
  1119. </div>
  1120. </div>
  1121. <div class="section">
  1122. <div class="section-title">三、模块现状与提分路径(全局+本学案影响)</div>
  1123. <div class="kp-change-box">
  1124. <div style="font-size:12px;font-weight:700;color:#0f172a;">本学案知识点变化情况</div>
  1125. @if(!empty($kpChangeItems))
  1126. <ul class="kp-change-list">
  1127. @foreach($kpChangeItems as $item)
  1128. @php
  1129. $delta = (float) ($item['change'] ?? 0);
  1130. $deltaColor = $delta > 0 ? '#16a34a' : ($delta < 0 ? '#dc2626' : '#64748b');
  1131. $deltaText = $changeText($delta);
  1132. $masteryText = isset($item['mastery_level']) && $item['mastery_level'] !== null
  1133. ? $formatMasteryPct($item['mastery_level'])
  1134. : '--';
  1135. @endphp
  1136. <li>
  1137. {{ $item['name'] ?? '-' }}:
  1138. 当前掌握度{{ $masteryText }}({{ $item['status'] ?? '未学习' }})
  1139. @if($deltaText !== '')
  1140. ,<span style="color:{{ $deltaColor }};font-weight:600;">{{ $deltaText }}</span>
  1141. @endif
  1142. </li>
  1143. @endforeach
  1144. </ul>
  1145. @else
  1146. <div class="muted" style="margin-top:4px;">
  1147. @if(!empty($kpWrongStats))
  1148. 暂无本学案命中知识点的掌握度数据,以下方知识点错误率作为本学案影响依据。
  1149. @else
  1150. 暂无可用的知识点变化数据
  1151. @endif
  1152. </div>
  1153. @endif
  1154. </div>
  1155. @if(!empty($kpWrongStats))
  1156. <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff7ed;">
  1157. <div style="font-size:12px; font-weight:700; color:#9a3412; margin-bottom:6px;">知识点错误率</div>
  1158. <div style="font-size:12px; color:#475569; line-height:1.7;">
  1159. @foreach($kpWrongStats as $item)
  1160. <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>
  1161. @endforeach
  1162. </div>
  1163. </div>
  1164. @endif
  1165. <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">
  1166. <div style="font-size:12px;color:#334155;">
  1167. 本次学案影响模块:
  1168. @if(!empty($impactedModules))
  1169. @foreach($impactedModules as $idx => $im)
  1170. @php
  1171. $mName = $im['module_name'] ?? '-';
  1172. $mQuestionCount = (int) ($im['question_count'] ?? 0);
  1173. $mScore = $im['exam_obtained_score'] ?? null;
  1174. @endphp
  1175. <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#3730a3;margin-right:4px;">
  1176. {{ $mName }}({{ $mQuestionCount }}题,得分{{ $mScore !== null ? number_format((float) $mScore, 1) : '-' }})
  1177. </span>
  1178. @endforeach
  1179. @else
  1180. <span class="muted">暂无命中模块</span>
  1181. @endif
  1182. </div>
  1183. </div>
  1184. <table class="module-table">
  1185. <thead>
  1186. <tr>
  1187. <th style="width: 18%;">模块</th>
  1188. <th style="width: 12%; white-space: nowrap;">本次影响</th>
  1189. <th style="width: 18%;">当前掌握度</th>
  1190. <th style="width: 14%;">掌握状态</th>
  1191. <th style="width: 14%;">路径建议</th>
  1192. <th style="width: 24%;">关注知识点</th>
  1193. </tr>
  1194. </thead>
  1195. <tbody>
  1196. @forelse($moduleRowsWithStatus as $m)
  1197. @php
  1198. $status = (string) ($m['status'] ?? '暂无');
  1199. $color = $statusColor($status);
  1200. $qCount = (int) ($m['question_count'] ?? 0);
  1201. $isImpacted = $qCount > 0;
  1202. $basePathTag = $pathTagByModuleName[(string) ($m['module_name'] ?? '')]
  1203. ?? $globalPathTagByMastery($m['mastery_level'] ?? null);
  1204. $pathTag = $isImpacted ? $basePathTag : $overallPathTag($basePathTag);
  1205. $pathColor = match ($pathTag) {
  1206. '优先加强', '整体优先' => '#ef4444',
  1207. '需要加强', '整体加强' => '#f59e0b',
  1208. '保分不错', '整体巩固' => '#16a34a',
  1209. default => '#64748b',
  1210. };
  1211. $moduleName = (string) ($m['module_name'] ?? '');
  1212. $focus = $moduleSuggestionByName[$moduleName] ?? null;
  1213. $focusText = '-';
  1214. if (is_array($focus)) {
  1215. if (!empty($focus['is_empty'])) {
  1216. $focusText = (string) ($focus['status'] ?? '当前模块暂无需额外关注知识点');
  1217. } else {
  1218. $focusName = (string) ($focus['kp_name'] ?? '');
  1219. $focusMastery = isset($focus['mastery_level']) && $focus['mastery_level'] !== null
  1220. ? $formatMasteryPct($focus['mastery_level'])
  1221. : '--';
  1222. $focusText = $focusName !== ''
  1223. ? ($focusName . '(' . $focusMastery . ')')
  1224. : '当前模块暂无需额外关注知识点';
  1225. }
  1226. }
  1227. @endphp
  1228. <tr>
  1229. <td><span class="module-name">{{ $m['module_name'] ?? '-' }}</span></td>
  1230. <td>
  1231. @if($isImpacted)
  1232. <span class="impact-yes">是</span>
  1233. @else
  1234. <span class="muted">否</span>
  1235. @endif
  1236. </td>
  1237. <td>{{ isset($m['mastery_level']) && $m['mastery_level'] !== null ? $formatMasteryPct($m['mastery_level']) : '-' }}</td>
  1238. <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
  1239. <td><span style="color:{{ $pathColor }}; font-weight:700;">{{ $pathTag }}</span></td>
  1240. <td>{{ $focusText }}</td>
  1241. </tr>
  1242. @empty
  1243. <tr>
  1244. <td colspan="6" class="muted">暂无掌握状态数据</td>
  1245. </tr>
  1246. @endforelse
  1247. </tbody>
  1248. </table>
  1249. </div>
  1250. </div>
  1251. <script src="/js/katex.min.js"></script>
  1252. <script src="/js/auto-render.min.js"></script>
  1253. <script>
  1254. document.addEventListener('DOMContentLoaded', function() {
  1255. try {
  1256. renderMathInElement(document.body, {
  1257. delimiters: [
  1258. {left: "$$", right: "$$", display: true},
  1259. {left: "$", right: "$", display: false},
  1260. {left: "\\(", right: "\\)", display: false},
  1261. {left: "\\[", right: "\\]", display: true}
  1262. ],
  1263. throwOnError: false,
  1264. strict: false,
  1265. trust: true
  1266. });
  1267. } catch (e) {}
  1268. });
  1269. </script>
  1270. </body>
  1271. </html>