QuestionTemQualityReview.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Http\Controllers\ExamPdfController;
  4. use App\Services\ExamPdfExportService;
  5. use App\Services\QuestionQualityCheckService;
  6. use App\Services\QuestionsTemAssemblyService;
  7. use App\Services\QuestionTemReviewService;
  8. use BackedEnum;
  9. use Filament\Notifications\Notification;
  10. use Filament\Pages\Page;
  11. use Filament\Support\Enums\Width;
  12. use Illuminate\Support\Facades\Auth;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Schema;
  15. use Livewire\Attributes\Computed;
  16. use UnitEnum;
  17. class QuestionTemQualityReview extends Page
  18. {
  19. protected static ?string $title = '待入库题目质检';
  20. protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
  21. protected static ?string $navigationLabel = '待入库质检';
  22. protected static string|UnitEnum|null $navigationGroup = '题库管理';
  23. protected static ?int $navigationSort = 4;
  24. /**
  25. * 三栏布局需要占满主内容区,避免被默认 max-width 压成窄条导致样式像「纯文本」。
  26. */
  27. protected Width|string|null $maxContentWidth = Width::Full;
  28. protected string $view = 'filament.pages.question-tem-quality-review';
  29. public ?string $selectedKpCode = null;
  30. /** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
  31. public string $kpSearch = '';
  32. public ?int $selectedTemId = null;
  33. /** 中间区多选:questions_tem.id,点击题目切换勾选 */
  34. public array $selectedTemIds = [];
  35. /** 为 true 时才计算/展示高级区质检与重复提示(避免每次点击跑质检) */
  36. public bool $qcPanelExpanded = false;
  37. /** 单题入库难度(0.00–0.90,两位小数;切换题目时从 questions_tem 同步) */
  38. public string $importDifficultyInput = '0.50';
  39. /** 生成临时试卷后,判卷页预览 URL(与正式组卷同源路由) */
  40. public ?string $trialGradingUrl = null;
  41. /** 与 generateGradingPdf 同源导出的判卷 PDF 地址(可下载) */
  42. public ?string $trialGradingPdfUrl = null;
  43. public function mount(): void
  44. {
  45. if (! Schema::hasTable('questions_tem')) {
  46. Notification::make()
  47. ->title('缺少 questions_tem 表')
  48. ->danger()
  49. ->send();
  50. }
  51. }
  52. public function updatedSelectedKpCode(): void
  53. {
  54. $this->selectedTemId = null;
  55. $this->selectedTemIds = [];
  56. $this->importDifficultyInput = '0.50';
  57. $this->qcPanelExpanded = false;
  58. $this->syncTemMultiSelectionJs();
  59. }
  60. #[Computed(cache: false)]
  61. public function kpRows(): array
  62. {
  63. return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(null);
  64. }
  65. /**
  66. * 左侧列表:按搜索词筛选知识点(代码、名称子串匹配,UTF-8)
  67. *
  68. * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int}>
  69. */
  70. #[Computed(cache: false)]
  71. public function filteredKpRows(): array
  72. {
  73. $rows = $this->kpRows;
  74. $raw = trim($this->kpSearch);
  75. if ($raw === '') {
  76. return $rows;
  77. }
  78. $needle = mb_strtolower($raw, 'UTF-8');
  79. return array_values(array_filter($rows, function (array $row) use ($needle): bool {
  80. $code = mb_strtolower((string) ($row['kp_code'] ?? ''), 'UTF-8');
  81. $name = mb_strtolower((string) ($row['kp_name'] ?? ''), 'UTF-8');
  82. return mb_strpos($code, $needle, 0, 'UTF-8') !== false
  83. || mb_strpos($name, $needle, 0, 'UTF-8') !== false;
  84. }));
  85. }
  86. #[Computed(cache: false)]
  87. public function temQuestions(): array
  88. {
  89. if (! $this->selectedKpCode) {
  90. return [];
  91. }
  92. return app(QuestionTemReviewService::class)->listTemQuestionsForKp($this->selectedKpCode, 300);
  93. }
  94. /**
  95. * 与判卷 PDF / pdf.exam-grading 使用同一套 components.exam.paper-body 数据管线
  96. *
  97. * @return array{choice: array, fill: array, answer: array}
  98. */
  99. #[Computed(cache: false)]
  100. public function groupedPaperBodyQuestions(): array
  101. {
  102. if (! $this->selectedKpCode) {
  103. return ['choice' => [], 'fill' => [], 'answer' => []];
  104. }
  105. $pdf = app(ExamPdfController::class);
  106. $data = $pdf->prepareQuestionsDataFromTemRows($this->temQuestions);
  107. return $pdf->buildGroupedQuestionsForPaperBody($data, null);
  108. }
  109. /** @return list<object> */
  110. #[Computed(cache: false)]
  111. public function assemblyQueueRows(): array
  112. {
  113. $uid = Auth::id();
  114. if (! $uid) {
  115. return [];
  116. }
  117. return app(QuestionsTemAssemblyService::class)->queueForUser((int) $uid);
  118. }
  119. #[Computed(cache: false)]
  120. public function selectedRow(): ?object
  121. {
  122. if (! $this->selectedTemId) {
  123. return null;
  124. }
  125. return DB::table('questions_tem')->where('id', $this->selectedTemId)->first();
  126. }
  127. /**
  128. * @return array{passed: bool, errors: array, results: array}|null
  129. */
  130. #[Computed(cache: false)]
  131. public function qcResult(): ?array
  132. {
  133. if (! $this->qcPanelExpanded) {
  134. return null;
  135. }
  136. $row = $this->selectedRow;
  137. if (! $row) {
  138. return null;
  139. }
  140. $mapped = $this->mapQuestionRowForQc((array) $row);
  141. $qc = app(QuestionQualityCheckService::class)->runAutoCheck($mapped, (int) $row->id, null);
  142. return [
  143. 'passed' => $qc['passed'],
  144. 'errors' => $qc['errors'],
  145. 'results' => $qc['results'],
  146. ];
  147. }
  148. #[Computed(cache: false)]
  149. public function duplicateHint(): ?string
  150. {
  151. if (! $this->qcPanelExpanded) {
  152. return null;
  153. }
  154. $row = $this->selectedRow;
  155. if (! $row) {
  156. return null;
  157. }
  158. $svc = app(QuestionTemReviewService::class);
  159. $stem = $svc->normalizedStemFromTemRow($row);
  160. $kp = (string) ($row->kp_code ?? '');
  161. if ($stem === '' || $kp === '') {
  162. return null;
  163. }
  164. if ($svc->existsDuplicateInQuestions($kp, $stem)) {
  165. return '正式库已存在同知识点、同题干题目';
  166. }
  167. return null;
  168. }
  169. public function selectKp(string $kpCode): void
  170. {
  171. $this->selectedKpCode = $kpCode;
  172. $this->selectedTemId = null;
  173. $this->selectedTemIds = [];
  174. $this->importDifficultyInput = '0.50';
  175. $this->qcPanelExpanded = false;
  176. $this->syncTemMultiSelectionJs();
  177. }
  178. /**
  179. * 中间题目区使用 wire:ignore,多选高亮由脚本根据 selectedTemIds 同步。
  180. */
  181. public function updatedSelectedTemId(mixed $value): void
  182. {
  183. if ($this->selectedTemId) {
  184. $this->syncImportDifficultyFromSelectedRow();
  185. } else {
  186. $this->importDifficultyInput = '0.50';
  187. }
  188. $this->syncTemMultiSelectionJs();
  189. }
  190. public function toggleTemQuestion(int $id): void
  191. {
  192. if ($id <= 0) {
  193. return;
  194. }
  195. $this->qcPanelExpanded = false;
  196. if (in_array($id, $this->selectedTemIds, true)) {
  197. $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $id));
  198. } else {
  199. $this->selectedTemIds[] = $id;
  200. }
  201. $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds)));
  202. $this->selectedTemId = in_array($id, $this->selectedTemIds, true)
  203. ? $id
  204. : ($this->selectedTemIds[count($this->selectedTemIds) - 1] ?? null);
  205. }
  206. public function clearTemSelection(): void
  207. {
  208. $this->selectedTemIds = [];
  209. $this->selectedTemId = null;
  210. $this->importDifficultyInput = '0.50';
  211. $this->qcPanelExpanded = false;
  212. $this->syncTemMultiSelectionJs();
  213. }
  214. public function importSelectedTemIdsFast(): void
  215. {
  216. if ($this->selectedTemIds === []) {
  217. Notification::make()->title('请先勾选题目')->warning()->send();
  218. return;
  219. }
  220. $svc = app(QuestionTemReviewService::class);
  221. $result = $svc->importTemIdsToQuestions($this->selectedTemIds);
  222. QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []);
  223. $this->notifyBulkImportResult($result);
  224. $this->selectedTemIds = [];
  225. $this->selectedTemId = null;
  226. $this->qcPanelExpanded = false;
  227. $this->syncTemMultiSelectionJs();
  228. $this->dispatch('$refresh');
  229. }
  230. private function syncTemMultiSelectionJs(): void
  231. {
  232. $idsJson = json_encode(array_values($this->selectedTemIds));
  233. $this->js(<<<JS
  234. const set = new Set({$idsJson});
  235. document.querySelectorAll('.qtr-paper-shell .qtr-selectable').forEach((el) => {
  236. const tid = parseInt(el.getAttribute('data-tem-id') || '0', 10);
  237. el.classList.toggle('qtr-is-selected', set.has(tid));
  238. });
  239. JS);
  240. }
  241. public function addSelectionToAssemblyQueue(): void
  242. {
  243. if ($this->selectedTemIds === []) {
  244. Notification::make()->title('请先勾选题目')->warning()->send();
  245. return;
  246. }
  247. $uid = Auth::id();
  248. if (! $uid) {
  249. return;
  250. }
  251. $svc = app(QuestionsTemAssemblyService::class);
  252. foreach ($this->selectedTemIds as $tid) {
  253. $svc->add((int) $uid, (int) $tid);
  254. }
  255. Notification::make()
  256. ->title('已加入待组卷队列(未写入 questions)')
  257. ->body('共 '.count($this->selectedTemIds).' 道')
  258. ->success()
  259. ->send();
  260. }
  261. public function removeFromAssemblyQueue(int $temId): void
  262. {
  263. $uid = Auth::id();
  264. if (! $uid) {
  265. return;
  266. }
  267. app(QuestionsTemAssemblyService::class)->remove((int) $uid, $temId);
  268. }
  269. public function clearAssemblyQueue(): void
  270. {
  271. $uid = Auth::id();
  272. if (! $uid) {
  273. return;
  274. }
  275. app(QuestionsTemAssemblyService::class)->clear((int) $uid);
  276. $this->trialGradingUrl = null;
  277. $this->trialGradingPdfUrl = null;
  278. Notification::make()->title('已清空待组卷队列')->success()->send();
  279. }
  280. public function generateTrialGradingPdf(): void
  281. {
  282. $uid = Auth::id();
  283. if (! $uid) {
  284. Notification::make()->title('未登录')->danger()->send();
  285. return;
  286. }
  287. $svc = app(QuestionsTemAssemblyService::class);
  288. if (! $svc->tableExists()) {
  289. Notification::make()->title('请先执行迁移:questions_tem_assembly_queue')->danger()->send();
  290. return;
  291. }
  292. $paperId = $svc->createTrialPaperForQueue((int) $uid);
  293. if (! $paperId) {
  294. Notification::make()->title('待组卷队列为空,请先「加入待组卷」')->warning()->send();
  295. return;
  296. }
  297. $this->trialGradingUrl = route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]);
  298. $this->trialGradingPdfUrl = null;
  299. $pdfBody = '';
  300. try {
  301. // 与正式组卷一致:学生卷 + 答案详解/判卷段 + 判题卡(强制追加扫描卡,不依赖 .env)
  302. $pdfUrl = app(ExamPdfExportService::class)->generateUnifiedPdf($paperId, false, true);
  303. $this->trialGradingPdfUrl = $pdfUrl ?: null;
  304. $pdfBody = $this->trialGradingPdfUrl
  305. ? '完整卷 PDF(学生卷 + 答案详解 + 判题卡)已生成,可下载核对。'
  306. : '完整卷 PDF 未返回地址,请仅用判卷页预览。';
  307. } catch (\Throwable $e) {
  308. $pdfBody = '完整卷 PDF 导出异常:'.$e->getMessage();
  309. }
  310. if (str_contains($pdfBody, '异常')) {
  311. Notification::make()
  312. ->title('已生成临时试卷(判卷页可预览)')
  313. ->body($pdfBody)
  314. ->warning()
  315. ->send();
  316. } else {
  317. Notification::make()
  318. ->title('已生成临时试卷')
  319. ->body('可打开判卷页预览。'.$pdfBody)
  320. ->success()
  321. ->send();
  322. }
  323. }
  324. /**
  325. * 将当前左侧选中知识点下,中间列表中的全部 questions_tem 写入 questions(与单题入库规则一致)
  326. */
  327. public function importAllCurrentKpToQuestions(): void
  328. {
  329. if (! $this->selectedKpCode) {
  330. Notification::make()->title('请先选择左侧知识点')->warning()->send();
  331. return;
  332. }
  333. $ids = [];
  334. foreach ($this->temQuestions as $row) {
  335. $ids[] = (int) ($row->id ?? 0);
  336. }
  337. if ($ids === []) {
  338. Notification::make()->title('当前知识点下没有待审题目')->warning()->send();
  339. return;
  340. }
  341. $svc = app(QuestionTemReviewService::class);
  342. $result = $svc->importTemIdsToQuestions($ids);
  343. QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []);
  344. $this->notifyBulkImportResult($result);
  345. $this->dispatch('$refresh');
  346. }
  347. /**
  348. * 将右侧「待组卷」队列中的全部 questions_tem 写入 questions
  349. */
  350. public function importAssemblyQueueToQuestions(): void
  351. {
  352. $uid = Auth::id();
  353. if (! $uid) {
  354. return;
  355. }
  356. $ids = [];
  357. foreach ($this->assemblyQueueRows as $row) {
  358. $ids[] = (int) ($row->id ?? 0);
  359. }
  360. if ($ids === []) {
  361. Notification::make()->title('待组卷队列为空')->warning()->send();
  362. return;
  363. }
  364. $svc = app(QuestionTemReviewService::class);
  365. $result = $svc->importTemIdsToQuestions($ids);
  366. QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []);
  367. $this->notifyBulkImportResult($result);
  368. $this->dispatch('$refresh');
  369. }
  370. /**
  371. * @param array{imported: int, skipped: int, failed: int, lines: list<string>, imported_question_ids?: list<int>} $result
  372. */
  373. private function notifyBulkImportResult(array $result): void
  374. {
  375. $body = sprintf(
  376. '成功 %d 道,跳过 %d 道(重复或缺字段),失败 %d 道。',
  377. $result['imported'],
  378. $result['skipped'],
  379. $result['failed']
  380. );
  381. if ($result['lines'] !== []) {
  382. $body .= "\n\n".implode("\n", $result['lines']);
  383. }
  384. $title = $result['imported'] > 0 ? '批量入库完成' : '批量入库结束';
  385. Notification::make()
  386. ->title($title)
  387. ->body($body)
  388. ->success()
  389. ->send();
  390. }
  391. public function importSelected(): void
  392. {
  393. if (! $this->selectedTemId) {
  394. Notification::make()->title('请先选择一道题目')->warning()->send();
  395. return;
  396. }
  397. $difficulty = $this->parseImportDifficultyValidated();
  398. if ($difficulty === null) {
  399. return;
  400. }
  401. $svc = app(QuestionTemReviewService::class);
  402. $result = $svc->importTemRowToQuestions($this->selectedTemId, $difficulty);
  403. if ($result['ok']) {
  404. if (! empty($result['question_id'])) {
  405. QuestionTemReviewService::mergeQuestionIdsIntoTuningSession([(int) $result['question_id']]);
  406. }
  407. $importedTemId = (int) $this->selectedTemId;
  408. Notification::make()
  409. ->title($result['message'])
  410. ->body(sprintf('question_id: %s · difficulty: %s', (string) $result['question_id'], number_format($difficulty, 2, '.', '')))
  411. ->success()
  412. ->send();
  413. $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $importedTemId));
  414. $this->selectedTemId = $this->selectedTemIds[count($this->selectedTemIds) - 1] ?? null;
  415. if ($this->selectedTemId) {
  416. $this->syncImportDifficultyFromSelectedRow();
  417. } else {
  418. $this->importDifficultyInput = '0.50';
  419. }
  420. $this->syncTemMultiSelectionJs();
  421. $this->dispatch('$refresh');
  422. } else {
  423. Notification::make()->title($result['message'])->danger()->send();
  424. }
  425. }
  426. private function syncImportDifficultyFromSelectedRow(): void
  427. {
  428. $row = $this->selectedRow;
  429. if (! $row) {
  430. $this->importDifficultyInput = '0.50';
  431. return;
  432. }
  433. $d = app(QuestionTemReviewService::class)->defaultDifficultyForTemRow($row);
  434. $this->importDifficultyInput = number_format($d, 2, '.', '');
  435. }
  436. /**
  437. * @return ?float 合法难度,或 null(已弹通知)
  438. */
  439. private function parseImportDifficultyValidated(): ?float
  440. {
  441. $raw = trim($this->importDifficultyInput);
  442. if ($raw === '') {
  443. Notification::make()
  444. ->title('请填写难度系数')
  445. ->body('范围为 0.00~0.90,最多两位小数。')
  446. ->warning()
  447. ->send();
  448. return null;
  449. }
  450. if (! is_numeric($raw)) {
  451. Notification::make()
  452. ->title('难度系数格式不正确')
  453. ->body('请输入数字,例如 0.35。')
  454. ->danger()
  455. ->send();
  456. return null;
  457. }
  458. $value = round((float) $raw, 2);
  459. if ($value < 0.0 || $value > 0.9) {
  460. Notification::make()
  461. ->title('难度系数超出范围')
  462. ->body('仅允许 0.00~0.90(保留两位小数)。')
  463. ->danger()
  464. ->send();
  465. return null;
  466. }
  467. $this->importDifficultyInput = number_format($value, 2, '.', '');
  468. return $value;
  469. }
  470. private function mapQuestionRowForQc(array $row): array
  471. {
  472. $stem = trim((string) ($row['stem'] ?? ''));
  473. if ($stem === '') {
  474. $stem = trim((string) ($row['content'] ?? ''));
  475. }
  476. $options = $row['options'] ?? null;
  477. if (is_string($options) && trim($options) !== '') {
  478. $decoded = json_decode($options, true);
  479. $options = is_array($decoded) ? $decoded : null;
  480. }
  481. $qtRaw = (string) ($row['question_type'] ?? $row['tags'] ?? '');
  482. $qtLower = strtolower(trim($qtRaw));
  483. $explicitNonChoice = in_array($qtLower, ['fill', '填空', '填空题', 'answer', '解答', '解答题'], true);
  484. if (! $explicitNonChoice && is_array($options) && count($options) >= 2) {
  485. $qtForCheck = 'choice';
  486. } else {
  487. $qtForCheck = $qtRaw;
  488. }
  489. return [
  490. 'stem' => $stem,
  491. 'answer' => $row['answer'] ?? $row['correct_answer'] ?? '',
  492. 'solution' => $row['solution'] ?? '',
  493. 'question_type' => $qtForCheck,
  494. 'options' => $options,
  495. ];
  496. }
  497. }