title('缺少 questions_tem 表') ->danger() ->send(); } } public function updatedSelectedKpCode(): void { $this->selectedTemId = null; $this->selectedTemIds = []; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; $this->syncTemMultiSelectionJs(); } #[Computed(cache: false)] public function kpRows(): array { return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(null); } /** * 左侧列表:按搜索词筛选知识点(代码、名称子串匹配,UTF-8) * * @return list */ #[Computed(cache: false)] public function filteredKpRows(): array { $rows = $this->kpRows; $raw = trim($this->kpSearch); if ($raw === '') { return $rows; } $needle = mb_strtolower($raw, 'UTF-8'); return array_values(array_filter($rows, function (array $row) use ($needle): bool { $code = mb_strtolower((string) ($row['kp_code'] ?? ''), 'UTF-8'); $name = mb_strtolower((string) ($row['kp_name'] ?? ''), 'UTF-8'); return mb_strpos($code, $needle, 0, 'UTF-8') !== false || mb_strpos($name, $needle, 0, 'UTF-8') !== false; })); } #[Computed(cache: false)] public function temQuestions(): array { if (! $this->selectedKpCode) { return []; } return app(QuestionTemReviewService::class)->listTemQuestionsForKp($this->selectedKpCode, 300); } /** * 与判卷 PDF / pdf.exam-grading 使用同一套 components.exam.paper-body 数据管线 * * @return array{choice: array, fill: array, answer: array} */ #[Computed(cache: false)] public function groupedPaperBodyQuestions(): array { if (! $this->selectedKpCode) { return ['choice' => [], 'fill' => [], 'answer' => []]; } $pdf = app(ExamPdfController::class); $data = $pdf->prepareQuestionsDataFromTemRows($this->temQuestions); return $pdf->buildGroupedQuestionsForPaperBody($data, null); } /** @return list */ #[Computed(cache: false)] public function assemblyQueueRows(): array { $uid = Auth::id(); if (! $uid) { return []; } return app(QuestionsTemAssemblyService::class)->queueForUser((int) $uid); } #[Computed(cache: false)] public function selectedRow(): ?object { if (! $this->selectedTemId) { return null; } return DB::table('questions_tem')->where('id', $this->selectedTemId)->first(); } /** * @return array{passed: bool, errors: array, results: array}|null */ #[Computed(cache: false)] public function qcResult(): ?array { if (! $this->qcPanelExpanded) { return null; } $row = $this->selectedRow; if (! $row) { return null; } $mapped = $this->mapQuestionRowForQc((array) $row); $qc = app(QuestionQualityCheckService::class)->runAutoCheck($mapped, (int) $row->id, null); return [ 'passed' => $qc['passed'], 'errors' => $qc['errors'], 'results' => $qc['results'], ]; } #[Computed(cache: false)] public function duplicateHint(): ?string { if (! $this->qcPanelExpanded) { return null; } $row = $this->selectedRow; if (! $row) { return null; } $svc = app(QuestionTemReviewService::class); $stem = $svc->normalizedStemFromTemRow($row); $kp = (string) ($row->kp_code ?? ''); if ($stem === '' || $kp === '') { return null; } if ($svc->existsDuplicateInQuestions($kp, $stem)) { return '正式库已存在同知识点、同题干题目'; } return null; } public function selectKp(string $kpCode): void { $this->selectedKpCode = $kpCode; $this->selectedTemId = null; $this->selectedTemIds = []; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; $this->syncTemMultiSelectionJs(); } /** * 中间题目区使用 wire:ignore,多选高亮由脚本根据 selectedTemIds 同步。 */ public function updatedSelectedTemId(mixed $value): void { if ($this->selectedTemId) { $this->syncImportDifficultyFromSelectedRow(); } else { $this->importDifficultyInput = '0.50'; } $this->syncTemMultiSelectionJs(); } public function toggleTemQuestion(int $id): void { if ($id <= 0) { return; } $this->qcPanelExpanded = false; if (in_array($id, $this->selectedTemIds, true)) { $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $id)); } else { $this->selectedTemIds[] = $id; } $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds))); $this->selectedTemId = in_array($id, $this->selectedTemIds, true) ? $id : ($this->selectedTemIds[count($this->selectedTemIds) - 1] ?? null); } public function clearTemSelection(): void { $this->selectedTemIds = []; $this->selectedTemId = null; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; $this->syncTemMultiSelectionJs(); } public function importSelectedTemIdsFast(): void { if ($this->selectedTemIds === []) { Notification::make()->title('请先勾选题目')->warning()->send(); return; } $svc = app(QuestionTemReviewService::class); $result = $svc->importTemIdsToQuestions($this->selectedTemIds); QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []); $this->notifyBulkImportResult($result); $this->selectedTemIds = []; $this->selectedTemId = null; $this->qcPanelExpanded = false; $this->syncTemMultiSelectionJs(); $this->dispatch('$refresh'); } private function syncTemMultiSelectionJs(): void { $idsJson = json_encode(array_values($this->selectedTemIds)); $this->js(<< { const tid = parseInt(el.getAttribute('data-tem-id') || '0', 10); el.classList.toggle('qtr-is-selected', set.has(tid)); }); JS); } public function addSelectionToAssemblyQueue(): void { if ($this->selectedTemIds === []) { Notification::make()->title('请先勾选题目')->warning()->send(); return; } $uid = Auth::id(); if (! $uid) { return; } $svc = app(QuestionsTemAssemblyService::class); foreach ($this->selectedTemIds as $tid) { $svc->add((int) $uid, (int) $tid); } Notification::make() ->title('已加入待组卷队列(未写入 questions)') ->body('共 '.count($this->selectedTemIds).' 道') ->success() ->send(); } public function removeFromAssemblyQueue(int $temId): void { $uid = Auth::id(); if (! $uid) { return; } app(QuestionsTemAssemblyService::class)->remove((int) $uid, $temId); } public function clearAssemblyQueue(): void { $uid = Auth::id(); if (! $uid) { return; } app(QuestionsTemAssemblyService::class)->clear((int) $uid); $this->trialGradingUrl = null; $this->trialGradingPdfUrl = null; Notification::make()->title('已清空待组卷队列')->success()->send(); } public function generateTrialGradingPdf(): void { $uid = Auth::id(); if (! $uid) { Notification::make()->title('未登录')->danger()->send(); return; } $svc = app(QuestionsTemAssemblyService::class); if (! $svc->tableExists()) { Notification::make()->title('请先执行迁移:questions_tem_assembly_queue')->danger()->send(); return; } $paperId = $svc->createTrialPaperForQueue((int) $uid); if (! $paperId) { Notification::make()->title('待组卷队列为空,请先「加入待组卷」')->warning()->send(); return; } $this->trialGradingUrl = route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]); $this->trialGradingPdfUrl = null; $pdfBody = ''; try { // 与正式组卷一致:学生卷 + 答案详解/判卷段 + 判题卡(强制追加扫描卡,不依赖 .env) $pdfUrl = app(ExamPdfExportService::class)->generateUnifiedPdf($paperId, false, true); $this->trialGradingPdfUrl = $pdfUrl ?: null; $pdfBody = $this->trialGradingPdfUrl ? '完整卷 PDF(学生卷 + 答案详解 + 判题卡)已生成,可下载核对。' : '完整卷 PDF 未返回地址,请仅用判卷页预览。'; } catch (\Throwable $e) { $pdfBody = '完整卷 PDF 导出异常:'.$e->getMessage(); } if (str_contains($pdfBody, '异常')) { Notification::make() ->title('已生成临时试卷(判卷页可预览)') ->body($pdfBody) ->warning() ->send(); } else { Notification::make() ->title('已生成临时试卷') ->body('可打开判卷页预览。'.$pdfBody) ->success() ->send(); } } /** * 将当前左侧选中知识点下,中间列表中的全部 questions_tem 写入 questions(与单题入库规则一致) */ public function importAllCurrentKpToQuestions(): void { if (! $this->selectedKpCode) { Notification::make()->title('请先选择左侧知识点')->warning()->send(); return; } $ids = []; foreach ($this->temQuestions as $row) { $ids[] = (int) ($row->id ?? 0); } if ($ids === []) { Notification::make()->title('当前知识点下没有待审题目')->warning()->send(); return; } $svc = app(QuestionTemReviewService::class); $result = $svc->importTemIdsToQuestions($ids); QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []); $this->notifyBulkImportResult($result); $this->dispatch('$refresh'); } /** * 将右侧「待组卷」队列中的全部 questions_tem 写入 questions */ public function importAssemblyQueueToQuestions(): void { $uid = Auth::id(); if (! $uid) { return; } $ids = []; foreach ($this->assemblyQueueRows as $row) { $ids[] = (int) ($row->id ?? 0); } if ($ids === []) { Notification::make()->title('待组卷队列为空')->warning()->send(); return; } $svc = app(QuestionTemReviewService::class); $result = $svc->importTemIdsToQuestions($ids); QuestionTemReviewService::mergeQuestionIdsIntoTuningSession($result['imported_question_ids'] ?? []); $this->notifyBulkImportResult($result); $this->dispatch('$refresh'); } /** * @param array{imported: int, skipped: int, failed: int, lines: list, imported_question_ids?: list} $result */ private function notifyBulkImportResult(array $result): void { $body = sprintf( '成功 %d 道,跳过 %d 道(重复或缺字段),失败 %d 道。', $result['imported'], $result['skipped'], $result['failed'] ); if ($result['lines'] !== []) { $body .= "\n\n".implode("\n", $result['lines']); } $title = $result['imported'] > 0 ? '批量入库完成' : '批量入库结束'; Notification::make() ->title($title) ->body($body) ->success() ->send(); } public function importSelected(): void { if (! $this->selectedTemId) { Notification::make()->title('请先选择一道题目')->warning()->send(); return; } $difficulty = $this->parseImportDifficultyValidated(); if ($difficulty === null) { return; } $svc = app(QuestionTemReviewService::class); $result = $svc->importTemRowToQuestions($this->selectedTemId, $difficulty); if ($result['ok']) { if (! empty($result['question_id'])) { QuestionTemReviewService::mergeQuestionIdsIntoTuningSession([(int) $result['question_id']]); } $importedTemId = (int) $this->selectedTemId; Notification::make() ->title($result['message']) ->body(sprintf('question_id: %s · difficulty: %s', (string) $result['question_id'], number_format($difficulty, 2, '.', ''))) ->success() ->send(); $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $importedTemId)); $this->selectedTemId = $this->selectedTemIds[count($this->selectedTemIds) - 1] ?? null; if ($this->selectedTemId) { $this->syncImportDifficultyFromSelectedRow(); } else { $this->importDifficultyInput = '0.50'; } $this->syncTemMultiSelectionJs(); $this->dispatch('$refresh'); } else { Notification::make()->title($result['message'])->danger()->send(); } } private function syncImportDifficultyFromSelectedRow(): void { $row = $this->selectedRow; if (! $row) { $this->importDifficultyInput = '0.50'; return; } $d = app(QuestionTemReviewService::class)->defaultDifficultyForTemRow($row); $this->importDifficultyInput = number_format($d, 2, '.', ''); } /** * @return ?float 合法难度,或 null(已弹通知) */ private function parseImportDifficultyValidated(): ?float { $raw = trim($this->importDifficultyInput); if ($raw === '') { Notification::make() ->title('请填写难度系数') ->body('范围为 0.00~0.90,最多两位小数。') ->warning() ->send(); return null; } if (! is_numeric($raw)) { Notification::make() ->title('难度系数格式不正确') ->body('请输入数字,例如 0.35。') ->danger() ->send(); return null; } $value = round((float) $raw, 2); if ($value < 0.0 || $value > 0.9) { Notification::make() ->title('难度系数超出范围') ->body('仅允许 0.00~0.90(保留两位小数)。') ->danger() ->send(); return null; } $this->importDifficultyInput = number_format($value, 2, '.', ''); return $value; } private function mapQuestionRowForQc(array $row): array { $stem = trim((string) ($row['stem'] ?? '')); if ($stem === '') { $stem = trim((string) ($row['content'] ?? '')); } $options = $row['options'] ?? null; if (is_string($options) && trim($options) !== '') { $decoded = json_decode($options, true); $options = is_array($decoded) ? $decoded : null; } $qtRaw = (string) ($row['question_type'] ?? $row['tags'] ?? ''); $qtLower = strtolower(trim($qtRaw)); $explicitNonChoice = in_array($qtLower, ['fill', '填空', '填空题', 'answer', '解答', '解答题'], true); if (! $explicitNonChoice && is_array($options) && count($options) >= 2) { $qtForCheck = 'choice'; } else { $qtForCheck = $qtRaw; } return [ 'stem' => $stem, 'answer' => $row['answer'] ?? $row['correct_answer'] ?? '', 'solution' => $row['solution'] ?? '', 'question_type' => $qtForCheck, 'options' => $options, ]; } }