title('缺少 questions_tem 表') ->danger() ->send(); } } public function updatedSelectedKpCode(): void { $this->selectedTemId = null; $this->selectedTemIds = []; $this->pendingImportTemIds = []; $this->cardRenderLimit = 20; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; } public function updatedGradeFilter(): void { $this->semesterFilter = ''; $this->selectedKpCode = null; $this->selectedTemId = null; $this->selectedTemIds = []; $this->pendingImportTemIds = []; $this->cardRenderLimit = 20; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; } public function updatedSemesterFilter(): void { $this->selectedKpCode = null; $this->selectedTemId = null; $this->selectedTemIds = []; $this->pendingImportTemIds = []; $this->cardRenderLimit = 20; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; } #[Computed] public function kpRows(): array { return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc( null, $this->parseGradeFilter(), $this->parseSemesterFilter() ); } /** @return list */ #[Computed] public function gradeOptions(): array { $vals = app(QuestionTemReviewService::class)->catalogGradeOptions($this->parseSemesterFilter()); $out = []; foreach ($vals as $g) { $v = (string) ((int) $g); $out[] = ['value' => $v, 'label' => $this->formatGradeLabel((int) $g)]; } return $out; } /** @return list */ #[Computed] public function semesterOptions(): array { $vals = app(QuestionTemReviewService::class)->catalogSemesterOptions($this->parseGradeFilter()); $out = []; foreach ($vals as $s) { $v = (string) ((int) $s); $out[] = ['value' => $v, 'label' => $this->formatSemesterLabel((int) $s)]; } return $out; } /** * 左侧列表:按搜索词筛选知识点(代码、名称子串匹配,UTF-8) * * @return list */ #[Computed] public function filteredKpRows(): array { $rows = array_values(array_filter( $this->kpRows, static fn (array $row): bool => (int) ($row['tem_importable_count'] ?? 0) > 0 )); $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] public function temQuestions(): array { if (! $this->selectedKpCode) { return []; } $key = $this->buildPageCacheKey('tem_questions'); return Cache::remember($key, now()->addMinutes(3), function (): array { $rows = app(QuestionTemReviewService::class)->listTemQuestionsForKp( $this->selectedKpCode, 300, true, $this->parseGradeFilter() ); return $this->sortRowsByQuestionType($rows); }); } /** * 独立审核卡片模型:清洗题干、给出三项快检,并附上该题完整渲染所需分组数据。 * * @return list,fill:array,answer:array}}> */ #[Computed] public function temQuestionCards(): array { $key = $this->buildPageCacheKey('tem_cards'); return Cache::remember($key, now()->addMinutes(3), function (): array { $cards = []; foreach ($this->temQuestions as $row) { $arr = is_array($row) ? $row : (array) $row; $id = (int) ($arr['id'] ?? 0); if ($id <= 0) { continue; } $stem = $this->normalizeStemPreview((string) ($arr['stem'] ?? '')); $answer = trim((string) ($arr['answer'] ?? $arr['correct_answer'] ?? '')); $solution = trim((string) ($arr['solution'] ?? '')); $cards[] = [ 'id' => $id, 'stem_preview' => $stem, 'checks' => [ 'stem' => $stem !== '', 'answer' => $answer !== '', 'solution' => $solution !== '', ], 'meta' => [ 'created_at' => $this->formatMetaDatetime($arr['created_at'] ?? null), 'updated_at' => $this->formatMetaDatetime($arr['updated_at'] ?? null), 'difficulty' => $this->formatMetaDifficulty($arr['difficulty'] ?? null), 'audit_reason' => $this->formatMetaAuditReason($arr['audit_reason'] ?? null), ], 'grouped_questions' => $this->buildPreviewGroupedQuestionsFromTemRows([$arr]), ]; } return $cards; }); } /** * 与判卷 PDF / pdf.exam-grading 使用同一套 components.exam.paper-body 数据管线 * * @return array{choice: array, fill: array, answer: array} */ #[Computed] public function groupedPaperBodyQuestions(): array { if (! $this->selectedKpCode) { return ['choice' => [], 'fill' => [], 'answer' => []]; } return $this->buildPreviewGroupedQuestionsFromTemRows($this->temQuestions); } /** @return list */ #[Computed] public function assemblyQueueRows(): array { $uid = Auth::id(); if (! $uid) { return []; } return app(QuestionsTemAssemblyService::class)->queueForUser((int) $uid); } #[Computed] 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] 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] 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->pendingImportTemIds = []; $this->cardRenderLimit = 20; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; $this->dispatch('qtr-scroll-top'); } public function loadMoreCards(): void { $this->cardRenderLimit += 20; $this->dispatch('qtr-scroll-top'); } #[Computed] public function visibleTemQuestionCards(): array { return array_slice($this->temQuestionCards, 0, max(1, $this->cardRenderLimit)); } /** @return list */ #[Computed] public function pendingImportRows(): array { if ($this->pendingImportTemIds === []) { return []; } $rows = DB::table('questions_tem') ->whereIn('id', $this->pendingImportTemIds) ->get() ->keyBy('id'); $ordered = []; foreach ($this->pendingImportTemIds as $id) { $row = $rows->get((int) $id); if ($row) { $ordered[] = $row; } } return $this->sortRowsByQuestionType($ordered); } /** * 右侧人工判重参考:当前选中知识点在正式库 questions 的题目(编号+题干)。 * * @return list */ #[Computed] public function currentKpQuestionStemRows(): array { if (! $this->selectedKpCode || ! Schema::hasTable('questions')) { return []; } $key = $this->buildPageCacheKey('kp_question_stems_v2'); return Cache::remember($key, now()->addMinutes(2), function (): array { $q = DB::table('questions') ->where('kp_code', $this->selectedKpCode) ->select(['id', 'stem', 'question_type', 'options', 'answer', 'solution']) ->orderByRaw(" CASE WHEN LOWER(COALESCE(question_type, '')) LIKE '%choice%' OR question_type LIKE '%选择%' THEN 1 WHEN LOWER(COALESCE(question_type, '')) LIKE '%fill%' OR LOWER(COALESCE(question_type, '')) LIKE '%blank%' OR question_type LIKE '%填空%' THEN 2 ELSE 3 END ASC ") ->orderBy('id'); // 右侧人工判重列表仅按当前选中知识点展示: // 年级/学期筛选已在左侧知识点范围内收敛,避免对 questions.grade 再次过滤导致误空。 return $q->limit(300)->get()->all(); }); } /** * 中间卡片:给每道 tem 题计算「当前知识点下最相似的正式库题目」综合相似度。 * * @return array */ #[Computed] public function temSimilarityHints(): array { if (! $this->selectedKpCode) { return []; } $temRows = $this->visibleTemQuestionCards; $questionRows = $this->currentKpQuestionStemRows; if ($temRows === [] || $questionRows === []) { return []; } $normalizedQuestions = []; foreach ($questionRows as $qr) { $qid = (int) ($qr->id ?? 0); if ($qid <= 0) { continue; } $normalizedQuestions[$qid] = [ 'type' => $this->normalizeSimilarityQuestionType((string) ($qr->question_type ?? '')), 'stem' => $this->normalizeSimilarityText((string) ($qr->stem ?? '')), 'options' => $this->normalizeSimilarityOptions($qr->options ?? null), 'answer' => $this->normalizeSimilarityText((string) ($qr->answer ?? '')), 'solution' => $this->normalizeSimilarityText((string) ($qr->solution ?? '')), ]; } $out = []; foreach ($temRows as $card) { $tid = (int) ($card['id'] ?? 0); if ($tid <= 0) { continue; } $grouped = $card['grouped_questions'] ?? ['choice' => [], 'fill' => [], 'answer' => []]; $q = $grouped['choice'][0] ?? $grouped['fill'][0] ?? $grouped['answer'][0] ?? null; $temPayload = [ 'type' => $this->normalizeSimilarityQuestionType((string) ($q->question_type ?? '')), 'stem' => $this->normalizeSimilarityText((string) ($q->stem ?? $q->content ?? $card['stem_preview'] ?? '')), 'options' => $this->normalizeSimilarityOptions($q->options ?? null), 'answer' => $this->normalizeSimilarityText((string) ($q->answer ?? '')), 'solution' => $this->normalizeSimilarityText((string) ($q->solution ?? '')), ]; if ($temPayload['stem'] === '') { continue; } $bestQid = 0; $bestScore = 0.0; foreach ($normalizedQuestions as $qid => $questionPayload) { if (($questionPayload['stem'] ?? '') === '') { continue; } if ($temPayload['type'] !== '' && $questionPayload['type'] !== '' && $temPayload['type'] !== $questionPayload['type']) { continue; } $score = $this->calculateCompositeSimilarityScore($temPayload, $questionPayload); if ($score > $bestScore) { $bestScore = $score; $bestQid = (int) $qid; } } if ($bestQid > 0) { $out[$tid] = [ 'question_id' => $bestQid, 'score' => round($bestScore, 1), 'score_text' => number_format($bestScore, 1).'%', ]; } } return $out; } public function updatedSelectedTemId(mixed $value): void { if ($this->selectedTemId) { $this->syncImportDifficultyFromSelectedRow(); if (! in_array((int) $this->selectedTemId, $this->selectedTemIds, true)) { $this->selectedTemIds[] = (int) $this->selectedTemId; } } else { $this->importDifficultyInput = '0.50'; } $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds))); } public function updatedSelectedTemIds(): void { $this->selectedTemIds = array_values(array_unique(array_filter( array_map('intval', $this->selectedTemIds), static fn (int $id) => $id > 0 ))); if ($this->selectedTemId && ! in_array((int) $this->selectedTemId, $this->selectedTemIds, true)) { $this->selectedTemId = $this->selectedTemIds[0] ?? null; } if (! $this->selectedTemId && $this->selectedTemIds !== []) { $this->selectedTemId = $this->selectedTemIds[0]; } if ($this->selectedTemId) { $this->syncImportDifficultyFromSelectedRow(); } else { $this->importDifficultyInput = '0.50'; } } public function focusTemQuestion(int $id): void { if ($id <= 0) { return; } $this->selectedTemId = $id; if (! in_array($id, $this->selectedTemIds, true)) { $this->selectedTemIds[] = $id; $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds))); } $this->qcPanelExpanded = false; } public function addToPendingImport(int $id): void { if ($id <= 0) { return; } if (! in_array($id, $this->pendingImportTemIds, true)) { $this->pendingImportTemIds[] = $id; $this->pendingImportTemIds = array_values(array_unique(array_map('intval', $this->pendingImportTemIds))); } if ((int) ($this->selectedTemId ?? 0) === $id) { $this->selectedTemId = null; $this->importDifficultyInput = '0.50'; } } #[Renderless] public function queueImportTem(int $id): void { if ($id <= 0) { return; } $status = Cache::get(ImportTemToQuestionsJob::statusKey($id)); $state = (string) ($status['state'] ?? ''); if (in_array($state, ['queued', 'running'], true)) { return; } Cache::put(ImportTemToQuestionsJob::statusKey($id), [ 'state' => 'queued', 'message' => '已加入队列,等待处理', 'at' => now()->toDateTimeString(), ], now()->addMinutes(30)); ImportTemToQuestionsJob::dispatch($id, Auth::id() ? (int) Auth::id() : null, $this->selectedKpCode); } public function markAsSimilarQuestion(int $temId, int $questionId): void { if ($temId <= 0 || $questionId <= 0) { Notification::make()->title('参数无效')->warning()->send(); return; } if (! Schema::hasTable('questions_tem')) { Notification::make()->title('questions_tem 表不存在')->danger()->send(); return; } $updates = [ 'audit_reason' => "相似题{$questionId}", 'updated_at' => now(), ]; if (Schema::hasColumn('questions_tem', 'audit_status')) { $updates['audit_status'] = -1; } DB::table('questions_tem') ->where('id', $temId) ->update($updates); $this->pendingImportTemIds = array_values(array_filter( $this->pendingImportTemIds, static fn ($x) => (int) $x !== $temId )); $this->forgetCurrentKpCaches(); Notification::make() ->title("已标记为相似题 #{$questionId}") ->success() ->send(); } public function removeFromPendingImport(int $id): void { $this->pendingImportTemIds = array_values(array_filter( $this->pendingImportTemIds, static fn ($x) => (int) $x !== $id )); } public function clearPendingImport(): void { $this->pendingImportTemIds = []; } public function importPendingTem(int $id): void { $this->queueImportTem($id); } public function importPendingAll(): void { if ($this->pendingImportTemIds === []) { Notification::make()->title('待入库为空')->warning()->send(); return; } foreach ($this->pendingImportTemIds as $id) { $this->queueImportTem((int) $id); } } public function syncAsyncImportStatuses(): void { if ($this->pendingImportTemIds === []) { return; } $remaining = []; $doneFound = false; foreach ($this->pendingImportTemIds as $id) { $status = Cache::get(ImportTemToQuestionsJob::statusKey((int) $id)); if (($status['state'] ?? null) === 'done') { $doneFound = true; continue; } $remaining[] = (int) $id; } if ($doneFound) { $this->pendingImportTemIds = array_values(array_unique($remaining)); $this->forgetCurrentKpCaches(); } } /** @return array */ #[Computed] public function importStatusMap(): array { $ids = []; foreach ($this->temQuestionCards as $card) { $id = (int) ($card['id'] ?? 0); if ($id > 0) { $ids[] = $id; } } foreach ($this->pendingImportTemIds as $id) { $id = (int) $id; if ($id > 0) { $ids[] = $id; } } $ids = array_values(array_unique($ids)); if ($ids === []) { return []; } $keys = []; foreach ($ids as $id) { $keys[$id] = ImportTemToQuestionsJob::statusKey($id); } $many = Cache::many(array_values($keys)); $map = []; foreach ($keys as $id => $key) { $v = $many[$key] ?? null; if (is_array($v) && isset($v['state'])) { $map[(int) $id] = $v; } } return $map; } public function clearTemSelection(): void { $this->selectedTemIds = []; $this->selectedTemId = null; $this->importDifficultyInput = '0.50'; $this->qcPanelExpanded = false; } 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->dispatch('$refresh'); } 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->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; } /** * 仅用于质检页面预览:不改组卷主链路的控制器/视图逻辑。 * * @param array $rows * @return array{choice: array, fill: array, answer: array} */ private function buildPreviewGroupedQuestionsFromTemRows(array $rows): array { $grouped = ['choice' => [], 'fill' => [], 'answer' => []]; foreach (array_values($rows) as $index => $row) { $arr = is_array($row) ? $row : (array) $row; $type = $this->normalizePreviewQuestionType((string) ($arr['question_type'] ?? $arr['tags'] ?? 'answer')); $options = $this->normalizePreviewOptions($arr['options'] ?? null); $questionData = [ 'id' => isset($arr['id']) ? -abs((int) $arr['id']) : null, 'question_number' => $index + 1, 'content' => (string) ($arr['stem'] ?? ''), 'stem' => (string) ($arr['stem'] ?? ''), 'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''), 'solution' => (string) ($arr['solution'] ?? ''), 'difficulty' => isset($arr['difficulty']) ? (float) $arr['difficulty'] : 0.5, 'kp_code' => (string) ($arr['kp_code'] ?? ''), 'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : '', 'options' => $options, 'score' => $type === 'answer' ? 10 : 5, 'question_type' => $type, ]; $grouped[$type][] = (object) $questionData; } return $grouped; } private function normalizePreviewQuestionType(string $raw): string { $t = mb_strtolower(trim($raw)); return match (true) { str_contains($t, 'choice'), str_contains($t, '选择') => 'choice', str_contains($t, 'fill'), str_contains($t, 'blank'), str_contains($t, '填空') => 'fill', default => 'answer', }; } /** * @return array|null */ private function normalizePreviewOptions(mixed $raw): ?array { if (is_string($raw)) { $decoded = json_decode($raw, true); $raw = is_array($decoded) ? $decoded : null; } if (! is_array($raw) || $raw === []) { return null; } if (! isset($raw[0])) { return array_values(array_map(static fn ($v) => (string) $v, $raw)); } if (isset($raw[0]) && is_array($raw[0])) { $out = []; foreach ($raw as $opt) { $out[] = (string) (($opt['content'] ?? $opt['text'] ?? $opt['value'] ?? '')); } return $out; } return array_values(array_map(static fn ($v) => (string) $v, $raw)); } 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, ]; } private function normalizeStemPreview(string $stem): string { // 先去掉图片标签,避免界面出现一大串 $text = preg_replace('/]*>/iu', ' [图] ', $stem) ?? $stem; $text = strip_tags($text); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } private function normalizeSimilarityText(string $text): string { if ($text === '') { return ''; } $text = MathFormulaProcessor::processFormulas($text); $text = preg_replace('/]*>/iu', ' ', $text) ?? $text; $text = strip_tags($text); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = mb_strtolower($text, 'UTF-8'); $text = preg_replace('/[[:punct:]\p{P}\p{S}]+/u', ' ', $text) ?? $text; $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } private function normalizeSimilarityOptions(mixed $raw): string { if (is_string($raw)) { $trimmed = trim($raw); if ($trimmed === '') { return ''; } $decoded = json_decode($trimmed, true); if (is_array($decoded)) { $raw = $decoded; } else { return $this->normalizeSimilarityText($trimmed); } } if (! is_array($raw) || $raw === []) { return ''; } $parts = []; foreach ($raw as $item) { if (is_array($item)) { $parts[] = (string) ($item['content'] ?? $item['text'] ?? $item['value'] ?? reset($item) ?? ''); } else { $parts[] = (string) $item; } } return $this->normalizeSimilarityText(implode(' | ', $parts)); } private function normalizeSimilarityQuestionType(string $raw): string { $type = mb_strtolower(trim($raw), 'UTF-8'); return match (true) { str_contains($type, 'choice'), str_contains($type, '选择') => 'choice', str_contains($type, 'fill'), str_contains($type, 'blank'), str_contains($type, '填空') => 'fill', str_contains($type, 'answer'), str_contains($type, '解答') => 'answer', default => '', }; } /** * @param array{type:string,stem:string,options:string,answer:string,solution:string} $tem * @param array{type:string,stem:string,options:string,answer:string,solution:string} $question */ private function calculateCompositeSimilarityScore(array $tem, array $question): float { $weights = [ 'stem' => 0.45, 'options' => 0.20, 'answer' => 0.20, 'solution' => 0.15, ]; $weightedSum = 0.0; $effectiveWeight = 0.0; foreach ($weights as $field => $weight) { $a = (string) ($tem[$field] ?? ''); $b = (string) ($question[$field] ?? ''); if ($a === '' || $b === '') { continue; } similar_text($a, $b, $pct); $score = max(0.0, min(100.0, (float) $pct)); $weightedSum += $score * $weight; $effectiveWeight += $weight; } if ($effectiveWeight <= 0.0) { return 0.0; } return round($weightedSum / $effectiveWeight, 1); } private function formatMetaDatetime(mixed $value): ?string { if ($value === null) { return null; } $raw = trim((string) $value); if ($raw === '' || $raw === '0000-00-00 00:00:00') { return null; } try { return \Carbon\Carbon::parse($raw)->format('Y-m-d H:i:s'); } catch (\Throwable) { return $raw; } } private function formatMetaDifficulty(mixed $value): string { if ($value === null || $value === '') { return '-'; } if (! is_numeric($value)) { return (string) $value; } return number_format((float) $value, 2, '.', ''); } private function formatMetaAuditReason(mixed $value): string { $text = trim((string) ($value ?? '')); return $text !== '' ? $text : '-'; } private function buildPageCacheKey(string $suffix): string { $uid = Auth::id() ?: 'guest'; $kp = $this->selectedKpCode ?: 'none'; $grade = $this->parseGradeFilter() ?: 'all'; $semester = $this->parseSemesterFilter() ?: 'all'; $version = (int) Cache::get("qtr:version:u{$uid}:kp{$kp}", 1); return "qtr:page:{$suffix}:u{$uid}:kp{$kp}:g{$grade}:s{$semester}:v{$version}"; } private function forgetCurrentKpCaches(): void { foreach (['tem_questions', 'tem_cards', 'kp_question_stems'] as $suffix) { Cache::forget($this->buildPageCacheKey($suffix)); } } /** * @param array> $rows * @return array> */ private function sortRowsByQuestionType(array $rows): array { usort($rows, function ($a, $b): int { $ar = is_array($a) ? $a : (array) $a; $br = is_array($b) ? $b : (array) $b; $aRank = $this->questionTypeRank((string) ($ar['question_type'] ?? $ar['tags'] ?? '')); $bRank = $this->questionTypeRank((string) ($br['question_type'] ?? $br['tags'] ?? '')); if ($aRank !== $bRank) { return $aRank <=> $bRank; } return ((int) ($ar['id'] ?? 0)) <=> ((int) ($br['id'] ?? 0)); }); return $rows; } private function questionTypeRank(string $raw): int { $t = mb_strtolower(trim($raw)); return match (true) { str_contains($t, 'choice'), str_contains($t, '选择') => 1, str_contains($t, 'fill'), str_contains($t, 'blank'), str_contains($t, '填空') => 2, default => 3, }; } private function parseGradeFilter(): ?int { $v = trim($this->gradeFilter); if ($v === '' || ! is_numeric($v)) { return null; } $i = (int) $v; return $i > 0 ? $i : null; } private function parseSemesterFilter(): ?int { $v = trim($this->semesterFilter); if ($v === '' || ! is_numeric($v)) { return null; } $i = (int) $v; return $i > 0 ? $i : null; } private function formatGradeLabel(int $grade): string { return match (true) { $grade >= 1 && $grade <= 6 => "小学{$grade}年级", $grade === 7 => '初一', $grade === 8 => '初二', $grade === 9 => '初三', $grade === 10 => '高一', $grade === 11 => '高二', $grade === 12 => '高三', $grade === 2 => '初中(学段)', $grade === 3 => '高中(学段)', default => "年级{$grade}", }; } private function formatSemesterLabel(int $semester): string { return match ($semester) { 1 => '上学期', 2 => '下学期', default => "学期{$semester}", }; } }