| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- <?php
- namespace App\Filament\Pages;
- use App\Http\Controllers\ExamPdfController;
- use App\Services\ExamPdfExportService;
- use App\Services\QuestionQualityCheckService;
- use App\Services\QuestionsTemAssemblyService;
- use App\Services\QuestionTemReviewService;
- use BackedEnum;
- use Filament\Notifications\Notification;
- use Filament\Pages\Page;
- use Filament\Support\Enums\Width;
- use Illuminate\Support\Facades\Auth;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Schema;
- use Livewire\Attributes\Computed;
- use UnitEnum;
- class QuestionTemQualityReview extends Page
- {
- protected static ?string $title = '待入库题目质检';
- protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
- protected static ?string $navigationLabel = '待入库质检';
- protected static string|UnitEnum|null $navigationGroup = '题库管理';
- protected static ?int $navigationSort = 4;
- /**
- * 三栏布局需要占满主内容区,避免被默认 max-width 压成窄条导致样式像「纯文本」。
- */
- protected Width|string|null $maxContentWidth = Width::Full;
- protected string $view = 'filament.pages.question-tem-quality-review';
- public ?string $selectedKpCode = null;
- /** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
- public string $kpSearch = '';
- public ?int $selectedTemId = null;
- /** 中间区多选:questions_tem.id,点击题目切换勾选 */
- public array $selectedTemIds = [];
- /** 为 true 时才计算/展示高级区质检与重复提示(避免每次点击跑质检) */
- public bool $qcPanelExpanded = false;
- /** 单题入库难度(0.00–0.90,两位小数;切换题目时从 questions_tem 同步) */
- public string $importDifficultyInput = '0.50';
- /** 生成临时试卷后,判卷页预览 URL(与正式组卷同源路由) */
- public ?string $trialGradingUrl = null;
- /** 与 generateGradingPdf 同源导出的判卷 PDF 地址(可下载) */
- public ?string $trialGradingPdfUrl = null;
- public function mount(): void
- {
- if (! Schema::hasTable('questions_tem')) {
- Notification::make()
- ->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<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int}>
- */
- #[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<object> */
- #[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(<<<JS
- const set = new Set({$idsJson});
- document.querySelectorAll('.qtr-paper-shell .qtr-selectable').forEach((el) => {
- 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<string>, imported_question_ids?: list<int>} $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,
- ];
- }
- }
|