QuestionDetail.php 12 KB

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