QuestionDetail.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Services\QuestionServiceApi;
  4. use App\Services\MistakeBookService;
  5. use App\Services\KnowledgeGraphService;
  6. use App\Models\Student;
  7. use Filament\Notifications\Notification;
  8. use Filament\Pages\Page;
  9. use Illuminate\Support\Facades\Request;
  10. use Illuminate\Support\Facades\Log;
  11. class QuestionDetail extends Page
  12. {
  13. protected static ?string $title = '题目详情';
  14. protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
  15. protected static ?string $navigationLabel = '题目详情';
  16. protected static string|\UnitEnum|null $navigationGroup = '题库管理';
  17. protected static ?int $navigationSort = 4;
  18. protected static ?string $slug = 'question-detail/{questionId?}';
  19. protected string $view = 'filament.pages.question-detail';
  20. public ?string $questionId = null;
  21. public ?string $sourceType = 'question';
  22. public ?string $mistakeId = null;
  23. public ?string $studentId = null;
  24. public array $questionData = [];
  25. public array $mistakeData = [];
  26. public array $relatedQuestions = [];
  27. public ?string $studentName = null;
  28. public array $historySummary = [];
  29. public function mount(?string $questionId = null): void
  30. {
  31. // 检查是否是错题本来源(同时有mistake_id和student_id)
  32. $this->mistakeId = Request::get('mistake_id');
  33. $this->studentId = Request::get('student_id');
  34. $this->questionId = $questionId ?: Request::get('question_id');
  35. if ($this->mistakeId && $this->studentId) {
  36. $this->sourceType = 'mistake';
  37. $this->loadFromMistake();
  38. } else {
  39. $this->sourceType = 'question';
  40. $this->loadFromQuestionBank();
  41. }
  42. if (empty($this->questionData)) {
  43. $this->notifyAndRedirect(
  44. '未能加载到题目数据,请稍后重试',
  45. $this->sourceType === 'mistake'
  46. ? route('filament.admin.pages.mistake-book')
  47. : route('filament.admin.pages.question-management')
  48. );
  49. }
  50. }
  51. protected function loadFromQuestionBank(): void
  52. {
  53. if (!$this->questionId) {
  54. $this->notifyAndRedirect('缺少题目ID参数', route('filament.admin.pages.question-management'));
  55. return;
  56. }
  57. $question = $this->fetchQuestion($this->questionId);
  58. if ($question) {
  59. $this->questionData = $this->prepareQuestionDisplay($question);
  60. $this->attachGlobalAccuracy();
  61. }
  62. }
  63. protected function loadFromMistake(): void
  64. {
  65. $mistakeService = app(MistakeBookService::class);
  66. $detail = $mistakeService->getMistakeDetail($this->mistakeId, $this->studentId);
  67. if (empty($detail)) {
  68. $this->notifyAndRedirect('未能获取错题详情', route('filament.admin.pages.mistake-book'));
  69. return;
  70. }
  71. $this->mistakeData = $detail;
  72. $this->questionId = $detail['question_id'] ?? $this->questionId;
  73. $this->studentName = $detail['student_name'] ?? null;
  74. $bankQuestion = $this->fetchQuestion($this->questionId);
  75. $fallbackQuestion = $detail['question'] ?? [];
  76. if (!$bankQuestion && empty($fallbackQuestion)) {
  77. $this->notifyAndRedirect('题目不存在或已被删除', route('filament.admin.pages.mistake-book'));
  78. return;
  79. }
  80. $question = array_merge($fallbackQuestion, $bankQuestion ?? []);
  81. $question = $this->prepareQuestionDisplay($question);
  82. $this->questionData = array_merge($question, [
  83. 'mistake_info' => [
  84. 'student_answer' => $detail['student_answer'] ?? '',
  85. 'correct' => (bool) ($detail['correct'] ?? false),
  86. 'score' => $detail['score'] ?? null,
  87. 'full_score' => $detail['full_score'] ?? null,
  88. 'partial_score_ratio' => $detail['partial_score_ratio'] ?? null,
  89. 'error_type' => $detail['error_type'] ?? '',
  90. 'mistake_category' => $detail['mistake_category'] ?? '',
  91. 'ai_analysis' => $detail['ai_analysis'] ?? [],
  92. 'created_at' => $detail['created_at'] ?? '',
  93. ],
  94. ]);
  95. // 尝试从本地学生表补充姓名,避免仅展示ID
  96. if (!$this->studentName && $this->studentId) {
  97. try {
  98. $student = Student::find($this->studentId);
  99. if ($student && $student->name) {
  100. $this->studentName = $student->name;
  101. }
  102. } catch (\Throwable $e) {
  103. Log::warning('Load student name failed', [
  104. 'student_id' => $this->studentId,
  105. 'error' => $e->getMessage(),
  106. ]);
  107. }
  108. }
  109. $this->loadHistorySummary();
  110. $this->attachGlobalAccuracy();
  111. }
  112. protected function fetchQuestion(?string $questionId): ?array
  113. {
  114. if (!$questionId) {
  115. return null;
  116. }
  117. try {
  118. $service = app(QuestionServiceApi::class);
  119. $response = $service->getQuestionDetail($questionId);
  120. return $response['data'] ?? null;
  121. } catch (\Throwable $e) {
  122. Log::error('Failed to load question detail', [
  123. 'question_id' => $questionId,
  124. 'error' => $e->getMessage(),
  125. ]);
  126. return null;
  127. }
  128. }
  129. protected function notifyAndRedirect(string $message, string $route): void
  130. {
  131. Notification::make()
  132. ->title('提示')
  133. ->body($message)
  134. ->danger()
  135. ->send();
  136. $this->redirect($route);
  137. }
  138. public function getTitle(): string
  139. {
  140. if ($this->sourceType === 'mistake') {
  141. $label = $this->questionData['question_number'] ?? ('#' . ($this->mistakeId ?? ''));
  142. return '错题详情 - ' . $label;
  143. }
  144. if (!empty($this->questionData)) {
  145. return '题目详情 - ' . ($this->questionData['question_code'] ?? $this->questionId);
  146. }
  147. return '题目详情';
  148. }
  149. public function getBreadcrumbs(): array
  150. {
  151. $breadcrumbs = [
  152. [
  153. 'name' => '题库管理',
  154. 'url' => route('filament.admin.pages.question-management'),
  155. ],
  156. ];
  157. if ($this->sourceType === 'mistake') {
  158. $breadcrumbs[] = [
  159. 'name' => '错题本',
  160. 'url' => route('filament.admin.pages.mistake-book'),
  161. ];
  162. }
  163. $breadcrumbs[] = [
  164. 'name' => '题目详情',
  165. 'url' => '',
  166. ];
  167. return $breadcrumbs;
  168. }
  169. public function getDifficultyColor(): string
  170. {
  171. if (!isset($this->questionData['difficulty'])) {
  172. return 'bg-gray-100 text-gray-700';
  173. }
  174. $difficulty = (float) $this->questionData['difficulty'];
  175. if ($difficulty < 0.4) {
  176. return 'bg-green-100 text-green-700';
  177. } elseif ($difficulty < 0.7) {
  178. return 'bg-yellow-100 text-yellow-700';
  179. }
  180. return 'bg-red-100 text-red-700';
  181. }
  182. public function getDifficultyLabel(): string
  183. {
  184. if (!isset($this->questionData['difficulty'])) {
  185. return '未知';
  186. }
  187. $difficulty = (float) $this->questionData['difficulty'];
  188. if ($difficulty < 0.4) {
  189. return '简单';
  190. } elseif ($difficulty < 0.7) {
  191. return '中等';
  192. }
  193. return '困难';
  194. }
  195. public function getKnowledgePointName(): string
  196. {
  197. if (!isset($this->questionData['kp_code'])) {
  198. return '';
  199. }
  200. $kpCode = $this->questionData['kp_code'];
  201. static $kpMap = null;
  202. if ($kpMap === null) {
  203. $kpMap = [];
  204. try {
  205. $service = app(KnowledgeGraphService::class);
  206. $resp = $service->listKnowledgePoints(1, 1000);
  207. foreach ($resp['data'] ?? [] as $kp) {
  208. $code = $kp['kp_code'] ?? $kp['id'] ?? null;
  209. if (!$code) {
  210. continue;
  211. }
  212. $kpMap[$code] = $kp['cn_name'] ?? $kp['name'] ?? $code;
  213. }
  214. } catch (\Throwable $e) {
  215. Log::warning('Load knowledge point names failed', [
  216. 'error' => $e->getMessage(),
  217. ]);
  218. }
  219. }
  220. return $kpMap[$kpCode] ?? $kpCode;
  221. }
  222. public function getSkillNames(): array
  223. {
  224. $skillCodes = $this->questionData['skills'] ?? [];
  225. if (empty($skillCodes) || !is_array($skillCodes)) {
  226. return [];
  227. }
  228. static $skillMap = null;
  229. if ($skillMap === null) {
  230. $skillMap = [];
  231. try {
  232. $service = app(KnowledgeGraphService::class);
  233. // 优先拉全量技能映射
  234. $resp = $service->listSkills(1, 1000);
  235. $skills = $resp['data'] ?? $resp ?? [];
  236. if (is_array($skills)) {
  237. foreach ($skills as $skill) {
  238. $code = $skill['code'] ?? null;
  239. if (!$code) {
  240. continue;
  241. }
  242. $skillMap[$code] = $skill['name'] ?? $code;
  243. }
  244. }
  245. // 如果仍为空,尝试按知识点单独获取
  246. if (empty($skillMap) && !empty($this->questionData['kp_code'])) {
  247. $kpSkills = $service->getSkillsByKnowledgePoint($this->questionData['kp_code']);
  248. foreach ($kpSkills as $skill) {
  249. $code = $skill['code'] ?? null;
  250. if (!$code) {
  251. continue;
  252. }
  253. $skillMap[$code] = $skill['name'] ?? $code;
  254. }
  255. }
  256. } catch (\Throwable $e) {
  257. Log::warning('Load skills failed', [
  258. 'error' => $e->getMessage(),
  259. ]);
  260. }
  261. }
  262. return array_map(function ($code) use ($skillMap) {
  263. return $skillMap[$code] ?? $code;
  264. }, $skillCodes);
  265. }
  266. protected function loadHistorySummary(): void
  267. {
  268. if (!$this->studentId || !$this->questionId) {
  269. return;
  270. }
  271. try {
  272. $service = app(MistakeBookService::class);
  273. $list = $service->listMistakes([
  274. 'student_id' => $this->studentId,
  275. 'per_page' => 100,
  276. ]);
  277. $data = $list['data'] ?? [];
  278. $filtered = array_values(array_filter($data, function ($item) {
  279. $qid = $item['question_id'] ?? ($item['question']['id'] ?? null);
  280. if ($qid === null) {
  281. return false;
  282. }
  283. return (string) $qid === (string) $this->questionId;
  284. }));
  285. if (empty($filtered)) {
  286. return;
  287. }
  288. $total = count($filtered);
  289. $correct = count(array_filter($filtered, fn ($item) => !empty($item['correct'])));
  290. $latest = collect($filtered)->sortByDesc('created_at')->first();
  291. $this->historySummary = [
  292. 'total' => $total,
  293. 'correct' => $correct,
  294. 'last_correct' => (bool) ($latest['correct'] ?? false),
  295. 'last_time' => $latest['created_at'] ?? null,
  296. ];
  297. } catch (\Throwable $e) {
  298. Log::warning('Load history summary failed', [
  299. 'student_id' => $this->studentId,
  300. 'question_id' => $this->questionId,
  301. 'error' => $e->getMessage(),
  302. ]);
  303. }
  304. }
  305. /**
  306. * 对题干/选项做展示友好化处理:拆分嵌入式选项,保留 display_stem 与 display_options
  307. */
  308. protected function prepareQuestionDisplay(array $question): array
  309. {
  310. $question['display_stem'] = $question['stem'] ?? '';
  311. // 规范化选项
  312. $options = $question['options'] ?? [];
  313. if (is_string($options)) {
  314. $decoded = json_decode($options, true);
  315. if (is_array($decoded)) {
  316. $options = $decoded;
  317. }
  318. }
  319. // 如果没有单独选项,但题干里嵌入了 A./B./C./D.,尝试提取
  320. if (empty($options) && !empty($question['stem']) && is_string($question['stem'])) {
  321. $stem = $question['stem'];
  322. $parsedOptions = [];
  323. $pattern = '/(?<![A-Za-z0-9])([A-D])[\\..、,,\\s]+\\s*(.+?)(?=(?<![A-Za-z0-9])[A-D][\\..、,,\\s]+|$)/su';
  324. if (preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER)) {
  325. foreach ($matches as $match) {
  326. $text = trim($match[2]);
  327. if ($text !== '') {
  328. $parsedOptions[] = $text;
  329. }
  330. }
  331. // 只接受 2-4 个选项,避免误切分
  332. if (count($parsedOptions) >= 2 && count($parsedOptions) <= 4) {
  333. $options = $parsedOptions;
  334. $firstMatchPos = mb_strpos($stem, $matches[0][0]);
  335. if ($firstMatchPos !== false) {
  336. $question['display_stem'] = trim(mb_substr($stem, 0, $firstMatchPos));
  337. }
  338. }
  339. }
  340. }
  341. $question['display_options'] = $options;
  342. return $question;
  343. }
  344. protected function attachGlobalAccuracy(): void
  345. {
  346. if (!$this->questionId) {
  347. return;
  348. }
  349. try {
  350. $service = app(MistakeBookService::class);
  351. $accuracy = $service->getQuestionAccuracy($this->questionId);
  352. if ($accuracy !== null) {
  353. $this->questionData['global_accuracy'] = $accuracy;
  354. }
  355. } catch (\Throwable $e) {
  356. Log::warning('Attach global accuracy failed', [
  357. 'question_id' => $this->questionId,
  358. 'error' => $e->getMessage(),
  359. ]);
  360. }
  361. }
  362. }