Przeglądaj źródła

feat: 优化题库质检审核流与异步入库体验

将题目质检页调整为先审后入库的高效操作流,补齐异步入库状态反馈与右侧对照展示,减少页面重绘并提升连续审核效率。

Made-with: Cursor
yemeishu 3 tygodni temu
rodzic
commit
f1f0967f7b

+ 0 - 3
.gitignore

@@ -49,6 +49,3 @@ scripts/vector_bold_fix/
 # Local planning/assistant workspace artifacts
 .planning/
 .claude/
-# 本地工具 / IDE(勿提交)
-/.cursor
-/.playwright-cli

+ 527 - 45
app/Filament/Pages/QuestionTemQualityReview.php

@@ -2,7 +2,7 @@
 
 namespace App\Filament\Pages;
 
-use App\Http\Controllers\ExamPdfController;
+use App\Jobs\ImportTemToQuestionsJob;
 use App\Services\ExamPdfExportService;
 use App\Services\QuestionQualityCheckService;
 use App\Services\QuestionsTemAssemblyService;
@@ -12,9 +12,11 @@ use Filament\Notifications\Notification;
 use Filament\Pages\Page;
 use Filament\Support\Enums\Width;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Livewire\Attributes\Computed;
+use Livewire\Attributes\Renderless;
 use UnitEnum;
 
 class QuestionTemQualityReview extends Page
@@ -40,12 +42,21 @@ class QuestionTemQualityReview extends Page
 
     /** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
     public string $kpSearch = '';
+    /** 左侧按年级筛选;空字符串=全部 */
+    public string $gradeFilter = '';
+    /** 左侧按学期筛选;空字符串=全部 */
+    public string $semesterFilter = '';
+    /** 中间卡片首屏渲染数量(性能优先,按需加载更多) */
+    public int $cardRenderLimit = 8;
 
     public ?int $selectedTemId = null;
 
     /** 中间区多选:questions_tem.id,点击题目切换勾选 */
     public array $selectedTemIds = [];
 
+    /** 逐题审核后加入「待入库」清单的 tem id 列表 */
+    public array $pendingImportTemIds = [];
+
     /** 为 true 时才计算/展示高级区质检与重复提示(避免每次点击跑质检) */
     public bool $qcPanelExpanded = false;
 
@@ -72,15 +83,70 @@ class QuestionTemQualityReview extends Page
     {
         $this->selectedTemId = null;
         $this->selectedTemIds = [];
+        $this->pendingImportTemIds = [];
+        $this->cardRenderLimit = 8;
         $this->importDifficultyInput = '0.50';
         $this->qcPanelExpanded = false;
-        $this->syncTemMultiSelectionJs();
     }
 
-    #[Computed(cache: false)]
+    public function updatedGradeFilter(): void
+    {
+        $this->semesterFilter = '';
+        $this->selectedKpCode = null;
+        $this->selectedTemId = null;
+        $this->selectedTemIds = [];
+        $this->pendingImportTemIds = [];
+        $this->cardRenderLimit = 8;
+        $this->importDifficultyInput = '0.50';
+        $this->qcPanelExpanded = false;
+    }
+
+    public function updatedSemesterFilter(): void
+    {
+        $this->selectedKpCode = null;
+        $this->selectedTemId = null;
+        $this->selectedTemIds = [];
+        $this->pendingImportTemIds = [];
+        $this->cardRenderLimit = 8;
+        $this->importDifficultyInput = '0.50';
+        $this->qcPanelExpanded = false;
+    }
+
+    #[Computed]
     public function kpRows(): array
     {
-        return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(null);
+        return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(
+            null,
+            $this->parseGradeFilter(),
+            $this->parseSemesterFilter()
+        );
+    }
+
+    /** @return list<array{value:string,label:string}> */
+    #[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<array{value:string,label:string}> */
+    #[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;
     }
 
     /**
@@ -88,10 +154,13 @@ class QuestionTemQualityReview extends Page
      *
      * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
      */
-    #[Computed(cache: false)]
+    #[Computed]
     public function filteredKpRows(): array
     {
-        $rows = $this->kpRows;
+        $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;
@@ -108,14 +177,61 @@ class QuestionTemQualityReview extends Page
         }));
     }
 
-    #[Computed(cache: false)]
+    #[Computed]
     public function temQuestions(): array
     {
         if (! $this->selectedKpCode) {
             return [];
         }
 
-        return app(QuestionTemReviewService::class)->listTemQuestionsForKp($this->selectedKpCode, 300);
+        $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<array{id:int, stem_preview:string, checks:array{stem:bool,answer:bool,solution:bool}, grouped_questions:array{choice:array<int,object>,fill:array<int,object>,answer:array<int,object>}}>
+     */
+    #[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 !== '',
+                    ],
+                    'grouped_questions' => $this->buildPreviewGroupedQuestionsFromTemRows([$arr]),
+                ];
+            }
+
+            return $cards;
+        });
     }
 
     /**
@@ -123,21 +239,18 @@ class QuestionTemQualityReview extends Page
      *
      * @return array{choice: array, fill: array, answer: array}
      */
-    #[Computed(cache: false)]
+    #[Computed]
     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 $this->buildPreviewGroupedQuestionsFromTemRows($this->temQuestions);
     }
 
     /** @return list<object> */
-    #[Computed(cache: false)]
+    #[Computed]
     public function assemblyQueueRows(): array
     {
         $uid = Auth::id();
@@ -148,7 +261,7 @@ class QuestionTemQualityReview extends Page
         return app(QuestionsTemAssemblyService::class)->queueForUser((int) $uid);
     }
 
-    #[Computed(cache: false)]
+    #[Computed]
     public function selectedRow(): ?object
     {
         if (! $this->selectedTemId) {
@@ -161,7 +274,7 @@ class QuestionTemQualityReview extends Page
     /**
      * @return array{passed: bool, errors: array, results: array}|null
      */
-    #[Computed(cache: false)]
+    #[Computed]
     public function qcResult(): ?array
     {
         if (! $this->qcPanelExpanded) {
@@ -183,7 +296,7 @@ class QuestionTemQualityReview extends Page
         ];
     }
 
-    #[Computed(cache: false)]
+    #[Computed]
     public function duplicateHint(): ?string
     {
         if (! $this->qcPanelExpanded) {
@@ -212,43 +325,253 @@ class QuestionTemQualityReview extends Page
         $this->selectedKpCode = $kpCode;
         $this->selectedTemId = null;
         $this->selectedTemIds = [];
+        $this->pendingImportTemIds = [];
+        $this->cardRenderLimit = 8;
         $this->importDifficultyInput = '0.50';
         $this->qcPanelExpanded = false;
-        $this->syncTemMultiSelectionJs();
+    }
+
+    public function loadMoreCards(): void
+    {
+        $this->cardRenderLimit += 8;
+        $this->dispatch('qtr-scroll-top');
+    }
+
+    #[Computed]
+    public function visibleTemQuestionCards(): array
+    {
+        return array_slice($this->temQuestionCards, 0, max(1, $this->cardRenderLimit));
+    }
+
+    /** @return list<object> */
+    #[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);
     }
 
     /**
-     * 中间题目区使用 wire:ignore,多选高亮由脚本根据 selectedTemIds 同步。
+     * 右侧人工判重参考:当前选中知识点在正式库 questions 的题目(编号+题干)。
+     *
+     * @return list<object>
      */
+    #[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'])
+                ->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();
+        });
+    }
+
     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
+        )));
 
-        $this->syncTemMultiSelectionJs();
+        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 toggleTemQuestion(int $id): void
+    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;
+    }
 
-        if (in_array($id, $this->selectedTemIds, true)) {
-            $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $id));
-        } else {
-            $this->selectedTemIds[] = $id;
+    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;
         }
-        $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);
+        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 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<int, array{state?:string,message?:string,question_id?:int,at?:string}> */
+    #[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
@@ -257,7 +580,6 @@ class QuestionTemQualityReview extends Page
         $this->selectedTemId = null;
         $this->importDifficultyInput = '0.50';
         $this->qcPanelExpanded = false;
-        $this->syncTemMultiSelectionJs();
     }
 
     public function importSelectedTemIdsFast(): void
@@ -276,22 +598,9 @@ class QuestionTemQualityReview extends Page
         $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 === []) {
@@ -499,7 +808,6 @@ class QuestionTemQualityReview extends Page
             } else {
                 $this->importDifficultyInput = '0.50';
             }
-            $this->syncTemMultiSelectionJs();
             $this->dispatch('$refresh');
         } else {
             Notification::make()->title($result['message'])->danger()->send();
@@ -560,6 +868,76 @@ class QuestionTemQualityReview extends Page
         return $value;
     }
 
+    /**
+     * 仅用于质检页面预览:不改组卷主链路的控制器/视图逻辑。
+     *
+     * @param  array<int, object|array>  $rows
+     * @return array{choice: array<int, object>, fill: array<int, object>, answer: array<int, object>}
+     */
+    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<int, string>|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'] ?? ''));
@@ -591,4 +969,108 @@ class QuestionTemQualityReview extends Page
             'options' => $options,
         ];
     }
+
+    private function normalizeStemPreview(string $stem): string
+    {
+        // 先去掉图片标签,避免界面出现一大串 <img ...>
+        $text = preg_replace('/<img\b[^>]*>/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 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<int, object|array<string,mixed>>  $rows
+     * @return array<int, object|array<string,mixed>>
+     */
+    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}",
+        };
+    }
 }

+ 128 - 192
app/Http/Controllers/ExamPdfController.php

@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
 
 use App\Jobs\RegeneratePdfJob;
 use App\Models\Paper;
-use App\Support\AnswerSolutionStepMarkerInjector;
 use App\Services\PaperIdGenerator;
 use App\Support\PaperNaming;
 use App\Services\QuestionBankService;
@@ -12,7 +11,6 @@ use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Schema;
 
 class ExamPdfController extends Controller
 {
@@ -506,173 +504,6 @@ class ExamPdfController extends Controller
         ];
     }
 
-    /**
-     * questions_tem 待入库预览:转为与组卷/判卷页相同的 questionsData 形状(不经题库 API)
-     *
-     * @param  array<int, object|array>  $temRows
-     * @return array<int, array<string, mixed>>
-     */
-    public function prepareQuestionsDataFromTemRows(array $temRows): array
-    {
-        $questionsData = [];
-        foreach ($temRows as $index => $row) {
-            $arr = is_array($row) ? $row : (array) $row;
-            $rawContent = (string) ($arr['stem'] ?? '');
-            $decodedOptions = null;
-            if (isset($arr['options']) && $arr['options'] !== null && $arr['options'] !== '') {
-                if (is_string($arr['options'])) {
-                    $decoded = json_decode($arr['options'], true);
-                    $decodedOptions = is_array($decoded) ? $decoded : null;
-                } elseif (is_array($arr['options'])) {
-                    $decodedOptions = $arr['options'];
-                }
-            }
-            $apiOptions = null;
-            if (! empty($decodedOptions)) {
-                $apiOptions = $this->normalizeOptions($decodedOptions);
-            }
-            $rawSolution = (string) ($arr['solution'] ?? '');
-            $solutionForPreview = AnswerSolutionStepMarkerInjector::enrichIfNeeded(
-                $rawSolution,
-                $arr['question_type'] ?? $arr['tags'] ?? ''
-            );
-
-            $questionsData[] = [
-                'id' => isset($arr['id']) ? -abs((int) $arr['id']) : null,
-                'question_number' => $index + 1,
-                'stem' => $rawContent,
-                'content' => $rawContent,
-                'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
-                'solution' => $solutionForPreview,
-                'difficulty' => isset($arr['difficulty']) ? (float) $arr['difficulty'] : 0.5,
-                'kp_code' => (string) ($arr['kp_code'] ?? ''),
-                'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : '',
-                'question_type' => $arr['question_type'] ?? $arr['tags'] ?? '',
-                'options' => $apiOptions,
-                'score' => 5,
-            ];
-        }
-
-        return $questionsData;
-    }
-
-    /**
-     * paper_questions 中 question_bank_id 为负数(待入库 tem)时,从 questions_tem 还原完整题干与选项,避免判卷页只有 stem、无选项。
-     *
-     * @param  array<int, array<string, mixed>>  $questionsData
-     * @return array<int, array<string, mixed>>
-     */
-    private function hydratePaperQuestionsDataFromQuestionsTem(array $questionsData): array
-    {
-        if (! Schema::hasTable('questions_tem')) {
-            return $questionsData;
-        }
-
-        $out = [];
-        foreach ($questionsData as $q) {
-            $bid = (int) ($q['id'] ?? 0);
-            if ($bid >= 0) {
-                $out[] = $q;
-
-                continue;
-            }
-
-            $temId = abs($bid);
-            $temRow = DB::table('questions_tem')->where('id', $temId)->first();
-            if ($temRow) {
-                $merged = $this->prepareQuestionsDataFromTemRows([(array) $temRow])[0];
-                $merged['question_number'] = $q['question_number'] ?? $merged['question_number'];
-                if (! empty($q['question_type'])) {
-                    $merged['question_type'] = $q['question_type'];
-                }
-                if (isset($q['score'])) {
-                    $merged['score'] = $q['score'];
-                }
-                $out[] = $merged;
-            } else {
-                $out[] = $q;
-            }
-        }
-
-        return $out;
-    }
-
-    /**
-     * 与 show()/showGrading() 中题型分类、MathFormulaProcessor 处理完全一致
-     *
-     * @param  array<int, array<string, mixed>>  $questionsData
-     * @return array{choice: array<int, object>, fill: array<int, object>, answer: array<int, object>}
-     */
-    public function buildGroupedQuestionsForPaperBody(array $questionsData, ?string $paperIdForLog = null): array
-    {
-        $questions = ['choice' => [], 'fill' => [], 'answer' => []];
-        foreach ($questionsData as $q) {
-            $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
-            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
-            $options = $q['options'] ?? $extractedOptions;
-            $answer = $q['answer'] ?? '';
-            $solution = $q['solution'] ?? '';
-            $type = isset($q['question_type'])
-                ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
-                : $this->determineQuestionType($q);
-
-            if ($paperIdForLog !== null) {
-                Log::debug('题目类型判断', [
-                    'paper_id' => $paperIdForLog,
-                    'question_id' => $q['id'] ?? '',
-                    'has_question_type' => isset($q['question_type']),
-                    'question_type_value' => $q['question_type'] ?? null,
-                    'tags' => $q['tags'] ?? '',
-                    'stem_length' => mb_strlen($content),
-                    'stem_preview' => mb_substr($content, 0, 100),
-                    'has_extracted_options' => ! empty($extractedOptions),
-                    'extracted_options_count' => count($extractedOptions),
-                    'has_api_options' => isset($q['options']) && ! empty($q['options']),
-                    'api_options_count' => isset($q['options']) ? count($q['options']) : 0,
-                    'final_options_count' => count($options),
-                    'determined_type' => $type,
-                ]);
-            }
-
-            if (! isset($questions[$type])) {
-                $type = 'answer';
-            }
-
-            $questionData = [
-                'id' => $q['id'] ?? $q['question_bank_id'] ?? null,
-                'question_number' => $q['question_number'] ?? null,
-                'content' => $content,
-                'stem' => $content,
-                'answer' => $answer,
-                'solution' => $solution,
-                'difficulty' => $q['difficulty'] ?? 0.5,
-                'kp_code' => $q['kp_code'] ?? '',
-                'tags' => $q['tags'] ?? '',
-                'options' => $options,
-                'score' => $q['score'] ?? $this->getQuestionScore($type),
-                'question_type' => $type,
-            ];
-
-            $questionData = \App\Services\MathFormulaProcessor::processQuestionData($questionData);
-            $questionData['math_processed'] = true;
-
-            $questions[$type][] = (object) $questionData;
-        }
-
-        foreach (['choice', 'fill', 'answer'] as $type) {
-            if (! empty($questions[$type])) {
-                usort($questions[$type], function ($a, $b) {
-                    $aNum = $a->question_number ?? 0;
-                    $bNum = $b->question_number ?? 0;
-
-                    return $aNum <=> $bNum;
-                });
-            }
-        }
-
-        return $questions;
-    }
-
     public function show(Request $request, $paper_id)
     {
         // 获取是否显示答案的参数,默认为true
@@ -703,15 +534,11 @@ class ExamPdfController extends Controller
                 $questionsData = $cached['questions'] ?? [];
                 $totalQuestions = $cached['total_questions'] ?? count($questionsData);
                 $difficultyCategory = $cached['difficulty_category'] ?? '中等';
-                $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
 
                 // 为 demo 试卷获取完整的题目详情(包括选项)
                 if (! empty($questionsData)) {
                     $questionBankService = app(QuestionBankService::class);
-                    $questionIds = array_values(array_filter(
-                        array_column($questionsData, 'id'),
-                        static fn ($id) => (int) $id > 0
-                    ));
+                    $questionIds = array_column($questionsData, 'id');
                     $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                     $responseData = $questionsResponse['data'] ?? [];
 
@@ -807,8 +634,6 @@ class ExamPdfController extends Controller
                 ];
             }
 
-            $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
-
             Log::debug('paper_questions 获取题目', [
                 'paper_id' => $paper_id,
                 'question_count' => count($questionsData),
@@ -818,10 +643,7 @@ class ExamPdfController extends Controller
             // 但要严格限制只获取这8道题
             if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
-                $questionIds = array_values(array_filter(
-                    array_column($questionsData, 'id'),
-                    static fn ($id) => (int) $id > 0
-                ));
+                $questionIds = array_column($questionsData, 'id');
 
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
@@ -886,7 +708,81 @@ class ExamPdfController extends Controller
             }
         }
 
-        $questions = $this->buildGroupedQuestionsForPaperBody($questionsData, (string) $paper_id);
+        // 按题型分类(使用标准的中学数学试卷格式)
+        $questions = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($questionsData as $q) {
+            // 题库API返回的是 stem 字段,不是 content
+            $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
+
+            // 分离题干和选项
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+
+            // 如果从题库API获取了选项,优先使用
+            $options = $q['options'] ?? $extractedOptions;
+
+            $answer = $q['answer'] ?? '';
+            $solution = $q['solution'] ?? '';
+
+            // 优先使用 question_type 字段,如果没有则根据内容智能判断
+            $type = isset($q['question_type'])
+                ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
+                : $this->determineQuestionType($q);
+
+            // 详细调试:记录题目类型判断结果
+            Log::debug('题目类型判断', [
+                'question_id' => $q['id'] ?? '',
+                'has_question_type' => isset($q['question_type']),
+                'question_type_value' => $q['question_type'] ?? null,
+                'tags' => $q['tags'] ?? '',
+                'stem_length' => mb_strlen($content),
+                'stem_preview' => mb_substr($content, 0, 100),
+                'has_extracted_options' => ! empty($extractedOptions),
+                'extracted_options_count' => count($extractedOptions),
+                'has_api_options' => isset($q['options']) && ! empty($q['options']),
+                'api_options_count' => isset($q['options']) ? count($q['options']) : 0,
+                'final_options_count' => count($options),
+                'determined_type' => $type,
+            ]);
+
+            if (! isset($questions[$type])) {
+                $type = 'answer';
+            }
+
+            // 统一处理数学公式和选项数据
+            $questionData = [
+                'id' => $q['id'] ?? $q['question_bank_id'] ?? null,
+                'question_number' => $q['question_number'] ?? null, // 【关键】保留题目序号
+                'content' => $content,
+                'stem' => $content, // 同时提供stem字段
+                'answer' => $answer,
+                'solution' => $solution,
+                'difficulty' => $q['difficulty'] ?? 0.5,
+                'kp_code' => $q['kp_code'] ?? '',
+                'tags' => $q['tags'] ?? '',
+                'options' => $options, // 使用分离后的选项
+                'score' => $q['score'] ?? $this->getQuestionScore($type),
+                'question_type' => $type,
+            ];
+
+            // 统一处理数学公式 - 标记已处理,避免模板中重复处理
+            $questionData = \App\Services\MathFormulaProcessor::processQuestionData($questionData);
+            $questionData['math_processed'] = true; // 添加标记
+
+            $qData = (object) $questionData;
+            $questions[$type][] = $qData;
+        }
+
+        // 【关键】确保每个题型内的题目按 question_number 排序
+        foreach (['choice', 'fill', 'answer'] as $type) {
+            if (! empty($questions[$type])) {
+                usort($questions[$type], function ($a, $b) {
+                    $aNum = $a->question_number ?? 0;
+                    $bNum = $b->question_number ?? 0;
+
+                    return $aNum <=> $bNum;
+                });
+            }
+        }
 
         // 调试:记录最终分类结果
         Log::info('最终分类结果', [
@@ -943,13 +839,9 @@ class ExamPdfController extends Controller
             $questionsData = $cached['questions'] ?? [];
             $totalQuestions = $cached['total_questions'] ?? count($questionsData);
             $difficultyCategory = $cached['difficulty_category'] ?? '中等';
-            $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
             if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
-                $questionIds = array_values(array_filter(
-                    array_column($questionsData, 'id'),
-                    static fn ($id) => (int) $id > 0
-                ));
+                $questionIds = array_column($questionsData, 'id');
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
                 if (! empty($responseData)) {
@@ -1016,13 +908,9 @@ class ExamPdfController extends Controller
                     'content' => $pq->question_text ?? '',
                 ];
             }
-            $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
             if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
-                $questionIds = array_values(array_filter(
-                    array_column($questionsData, 'id'),
-                    static fn ($id) => (int) $id > 0
-                ));
+                $questionIds = array_column($questionsData, 'id');
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
                 if (! empty($responseData)) {
@@ -1074,7 +962,55 @@ class ExamPdfController extends Controller
             }
         }
 
-        $questions = $this->buildGroupedQuestionsForPaperBody($questionsData, (string) $paper_id);
+        $questions = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($questionsData as $q) {
+            $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
+            $options = $q['options'] ?? $extractedOptions;
+            $answer = $q['answer'] ?? '';
+            $solution = $q['solution'] ?? '';
+            $type = isset($q['question_type'])
+                ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
+                : $this->determineQuestionType($q);
+            if (! isset($questions[$type])) {
+                $type = 'answer';
+            }
+
+            // 统一处理数学公式和选项数据
+            $questionData = [
+                'id' => $q['id'] ?? $q['question_bank_id'] ?? null,
+                'question_number' => $q['question_number'] ?? null, // 【关键】保留题目序号
+                'content' => $content,
+                'stem' => $content, // 同时提供stem字段
+                'answer' => $answer,
+                'solution' => $solution,
+                'difficulty' => $q['difficulty'] ?? 0.5,
+                'kp_code' => $q['kp_code'] ?? '',
+                'tags' => $q['tags'] ?? '',
+                'options' => $options,
+                'score' => $q['score'] ?? $this->getQuestionScore($type),
+                'question_type' => $type,
+            ];
+
+            // 统一处理数学公式 - 标记已处理,避免模板中重复处理
+            $questionData = \App\Services\MathFormulaProcessor::processQuestionData($questionData);
+            $questionData['math_processed'] = true; // 添加标记
+
+            $qData = (object) $questionData;
+            $questions[$type][] = $qData;
+        }
+
+        // 【关键】确保每个题型内的题目按 question_number 排序
+        foreach (['choice', 'fill', 'answer'] as $type) {
+            if (! empty($questions[$type])) {
+                usort($questions[$type], function ($a, $b) {
+                    $aNum = $a->question_number ?? 0;
+                    $bNum = $b->question_number ?? 0;
+
+                    return $aNum <=> $bNum;
+                });
+            }
+        }
 
         $studentInfo = $this->getStudentInfo($paper->student_id);
         $teacherInfo = $this->getTeacherInfo($paper->teacher_id);

+ 79 - 0
app/Jobs/ImportTemToQuestionsJob.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\QuestionTemReviewService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+
+class ImportTemToQuestionsJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public int $timeout = 120;
+
+    public function __construct(
+        public int $temId,
+        public ?int $userId = null,
+        public ?string $kpCode = null
+    ) {
+    }
+
+    public function handle(QuestionTemReviewService $service): void
+    {
+        $statusKey = self::statusKey($this->temId);
+        Cache::put($statusKey, ['state' => 'running', 'at' => now()->toDateTimeString()], now()->addMinutes(30));
+
+        $result = $service->importTemRowToQuestions($this->temId);
+        if (! empty($result['ok'])) {
+            Cache::put($statusKey, [
+                'state' => 'done',
+                'question_id' => $result['question_id'] ?? null,
+                'message' => $result['message'] ?? '已入库',
+                'at' => now()->toDateTimeString(),
+            ], now()->addMinutes(30));
+            $this->bumpPageVersion();
+            return;
+        }
+
+        Cache::put($statusKey, [
+            'state' => 'failed',
+            'message' => $result['message'] ?? '入库失败',
+            'at' => now()->toDateTimeString(),
+        ], now()->addMinutes(30));
+    }
+
+    public function failed(\Throwable $e): void
+    {
+        Cache::put(self::statusKey($this->temId), [
+            'state' => 'failed',
+            'message' => $e->getMessage(),
+            'at' => now()->toDateTimeString(),
+        ], now()->addMinutes(30));
+        Log::error('ImportTemToQuestionsJob failed', ['tem_id' => $this->temId, 'error' => $e->getMessage()]);
+    }
+
+    public static function statusKey(int $temId): string
+    {
+        return "qtr:import:tem:{$temId}";
+    }
+
+    private function bumpPageVersion(): void
+    {
+        if (! $this->userId || ! $this->kpCode) {
+            return;
+        }
+        $versionKey = "qtr:version:u{$this->userId}:kp{$this->kpCode}";
+        Cache::add($versionKey, 1, now()->addHours(2));
+        Cache::increment($versionKey);
+    }
+}
+

+ 5 - 63
app/Services/QuestionLocalService.php

@@ -131,75 +131,17 @@ class QuestionLocalService
 
     public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array
     {
-        $kpCode = trim($kpCode);
-        $query = Question::query()->where(function ($q) use ($kpCode): void {
-            $q->where('kp_code', $kpCode);
-            if ($kpCode !== '' && Schema::hasTable('question_kp_relations')) {
-                $q->orWhereIn('id', function ($sub) use ($kpCode): void {
-                    $sub->select('question_id')
-                        ->from('question_kp_relations')
-                        ->where('kp_code', $kpCode);
-                });
-            }
-        });
-
-        $questions = $query->orderByDesc('id')->limit($limit)->get();
+        $questions = Question::query()
+            ->where('kp_code', $kpCode)
+            ->orderByDesc('id')
+            ->limit($limit)
+            ->get();
 
         return [
             'data' => $this->mapQuestions($questions),
         ];
     }
 
-    /**
-     * 按 kp 统计正式库题目数:主表 kp_code 与 question_kp_relations 合并,同一题同一 kp 只计一次。
-     *
-     * @param  list<string>|null  $onlyKpCodes  非空时仅返回这些 kp 的计数
-     * @return array<string, int>
-     */
-    public function questionCountsGroupedByKp(?array $onlyKpCodes = null): array
-    {
-        if (! Schema::hasTable('questions')) {
-            return [];
-        }
-
-        $onlyKpCodes = $onlyKpCodes !== null
-            ? array_values(array_unique(array_filter(array_map('strval', $onlyKpCodes))))
-            : null;
-
-        if ($onlyKpCodes === []) {
-            return [];
-        }
-
-        $relationUnion = '';
-        if (Schema::hasTable('question_kp_relations')) {
-            $relationUnion = " UNION ALL SELECT question_id AS qid, kp_code FROM question_kp_relations WHERE kp_code IS NOT NULL AND kp_code != ''";
-        }
-
-        $sql = "SELECT kp_code, COUNT(DISTINCT qid) AS c FROM (
-            SELECT id AS qid, kp_code FROM questions WHERE kp_code IS NOT NULL AND kp_code != ''
-            {$relationUnion}
-        ) merged";
-
-        $bindings = [];
-        if ($onlyKpCodes !== null) {
-            $placeholders = implode(',', array_fill(0, count($onlyKpCodes), '?'));
-            $sql .= " WHERE kp_code IN ({$placeholders})";
-            $bindings = $onlyKpCodes;
-        }
-
-        $sql .= ' GROUP BY kp_code';
-
-        $out = [];
-        foreach (DB::select($sql, $bindings) as $row) {
-            $k = (string) ($row->kp_code ?? '');
-            if ($k !== '') {
-                $out[$k] = (int) ($row->c ?? 0);
-            }
-        }
-
-        return $out;
-    }
-
     public function getStatistics(array $filters = []): array
     {
         $baseQuery = $this->applyFilters(Question::query(), $filters);

+ 31 - 1
app/Services/QuestionQualityCheckService.php

@@ -15,6 +15,31 @@ use Illuminate\Support\Facades\Schema;
  */
 class QuestionQualityCheckService
 {
+    private const DEFAULT_ANSWER_VALIDATION_PROMPT = <<<'PROMPT'
+你是数学题目质检专家。请校验以下题目的答案是否正确、是否与题目匹配。
+
+校验要求:
+1. 答案正确性:答案是否在数学/逻辑上正确?(选择题则答案选项内容应对应正确选项)
+2. 答案与题目匹配:答案是否针对题干所问、是否解答了题目要求?
+
+题目类型:{question_type}
+题干:
+{stem}
+选项(选择题):
+{options}
+参考答案:{answer}
+解析(若有):
+{solution}
+
+请只输出 JSON,格式:
+{
+  "answer_correct": true|false,
+  "answer_matches_question": true|false,
+  "confidence": 0.0~1.0,
+  "reason": "简要说明(若有问题)"
+}
+PROMPT;
+
     private ?AiClientService $aiClient = null;
 
     public function __construct(?AiClientService $aiClient = null)
@@ -341,10 +366,15 @@ class QuestionQualityCheckService
             $optionsStr = mb_substr($options, 0, 500);
         }
 
+        $promptTemplate = (string) config('ai.answer_validation_prompt', '');
+        if (trim($promptTemplate) === '') {
+            $promptTemplate = self::DEFAULT_ANSWER_VALIDATION_PROMPT;
+        }
+
         $prompt = str_replace(
             ['{question_type}', '{stem}', '{options}', '{answer}', '{solution}'],
             [$questionType, mb_substr((string) $stem, 0, 1500), $optionsStr, (string) $answer, mb_substr((string) $solution, 0, 800)],
-            config('ai.answer_validation_prompt', '')
+            $promptTemplate
         );
 
         if ($prompt === '') {

+ 121 - 15
app/Services/QuestionTemReviewService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\Question;
 use App\Support\AnswerSolutionStepMarkerInjector;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Str;
@@ -42,18 +43,27 @@ class QuestionTemReviewService
      * @param  ?int  $limit  为 null 时不截断(质检页需完整列表 + 搜索,否则题量大的 KP 如 B01 会落在 500 条之后而无法检索)
      * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
      */
-    public function listKnowledgePointsByQuestionsAsc(?int $limit = null): array
+    public function listKnowledgePointsByQuestionsAsc(?int $limit = null, ?int $gradeFilter = null, ?int $semesterFilter = null): array
     {
         if (! Schema::hasTable('questions_tem')) {
             return [];
         }
 
-        $temKps = DB::table('questions_tem')
+        $gradeKpWhitelist = null;
+        if ($gradeFilter !== null || $semesterFilter !== null) {
+            $gradeKpWhitelist = $this->kpCodesFromCatalogFilter($gradeFilter, $semesterFilter);
+            if ($gradeKpWhitelist === []) {
+                return [];
+            }
+        }
+
+        $temQuery = DB::table('questions_tem')
             ->whereNotNull('kp_code')
-            ->where('kp_code', '!=', '')
-            ->distinct()
-            ->pluck('kp_code')
-            ->all();
+            ->where('kp_code', '!=', '');
+        if ($gradeKpWhitelist !== null) {
+            $temQuery->whereIn('kp_code', $gradeKpWhitelist);
+        }
+        $temKps = $temQuery->distinct()->pluck('kp_code')->all();
 
         if ($temKps === []) {
             return [];
@@ -69,22 +79,22 @@ class QuestionTemReviewService
 
         $counts = [];
         if (Schema::hasTable('questions')) {
-            $counts = DB::table('questions')
-                ->whereIn('kp_code', $temKps)
+            $countQuery = DB::table('questions')->whereIn('kp_code', $temKps);
+            $counts = $countQuery
                 ->selectRaw('kp_code, COUNT(*) as c')
                 ->groupBy('kp_code')
                 ->pluck('c', 'kp_code')
                 ->toArray();
         }
 
-        $temCounts = DB::table('questions_tem')
-            ->whereIn('kp_code', $temKps)
+        $temCountQuery = DB::table('questions_tem')->whereIn('kp_code', $temKps);
+        $temCounts = $temCountQuery
             ->selectRaw('kp_code, COUNT(*) as c')
             ->groupBy('kp_code')
             ->pluck('c', 'kp_code')
             ->toArray();
 
-        $importableMap = $this->importableTemCountsForKpCodes($temKps);
+        $importableMap = $this->importableTemCountsForKpCodes($temKps, $gradeFilter);
 
         $rows = [];
         foreach ($temKps as $kp) {
@@ -119,7 +129,7 @@ class QuestionTemReviewService
      * @param  list<string>  $kpCodes
      * @return array<string, int>  kp_code => count
      */
-    private function importableTemCountsForKpCodes(array $kpCodes): array
+    private function importableTemCountsForKpCodes(array $kpCodes, ?int $gradeFilter = null): array
     {
         $kpCodes = array_values(array_unique(array_filter($kpCodes)));
         if ($kpCodes === [] || ! Schema::hasTable('questions_tem')) {
@@ -128,7 +138,10 @@ class QuestionTemReviewService
 
         if (! Schema::hasTable('questions')) {
             return collect($kpCodes)
-                ->mapWithKeys(fn (string $k): array => [$k => (int) DB::table('questions_tem')->where('kp_code', $k)->count()])
+                ->mapWithKeys(function (string $k): array {
+                    $q = DB::table('questions_tem')->where('kp_code', $k);
+                    return [$k => (int) $q->count()];
+                })
                 ->all();
         }
 
@@ -174,7 +187,7 @@ class QuestionTemReviewService
      * @param  bool  $excludeFormalDuplicates  为 true 时排除「正式库 questions 已存在同 kp_code + 同 stem」的待审行,与 {@see existsDuplicateInQuestions} 一致,减少无效质检
      * @return list<object>
      */
-    public function listTemQuestionsForKp(string $kpCode, int $limit = 300, bool $excludeFormalDuplicates = true): array
+    public function listTemQuestionsForKp(string $kpCode, int $limit = 300, bool $excludeFormalDuplicates = true, ?int $gradeFilter = null): array
     {
         if (! Schema::hasTable('questions_tem') || $kpCode === '') {
             return [];
@@ -182,7 +195,8 @@ class QuestionTemReviewService
 
         $formalStemSet = [];
         if ($excludeFormalDuplicates && Schema::hasTable('questions')) {
-            foreach (DB::table('questions')->where('kp_code', $kpCode)->pluck('stem') as $stem) {
+            $qFormal = DB::table('questions')->where('kp_code', $kpCode);
+            foreach ($qFormal->pluck('stem') as $stem) {
                 if ($stem === null || $stem === '') {
                     continue;
                 }
@@ -445,6 +459,98 @@ class QuestionTemReviewService
         ];
     }
 
+    /**
+     * 年级下拉项来自教材章节-知识点关联链路(非 questions_tem.grade)。
+     *
+     * @return list<int>
+     */
+    public function catalogGradeOptions(?int $semesterFilter = null): array
+    {
+        if (! Schema::hasTable('textbooks') || ! Schema::hasTable('textbook_catalog_nodes') || ! Schema::hasTable('textbook_chapter_knowledge_relation')) {
+            return [];
+        }
+
+        $cacheKey = 'qtr:catalog-grades:' . ($semesterFilter ?? 'all');
+        return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($semesterFilter) {
+            return DB::table('textbooks as t')
+                ->join('textbook_catalog_nodes as n', 'n.textbook_id', '=', 't.id')
+                ->join('textbook_chapter_knowledge_relation as r', 'r.catalog_chapter_id', '=', 'n.id')
+                ->whereNotNull('t.grade')
+                ->where('n.node_type', 'section')
+                ->when($semesterFilter !== null, fn ($q) => $q->where('t.semester', $semesterFilter))
+                ->when(Schema::hasColumn('r', 'is_deleted'), fn ($q) => $q->where(function ($x) {
+                    $x->where('r.is_deleted', 0)->orWhereNull('r.is_deleted');
+                }))
+                ->distinct()
+                ->orderBy('t.grade')
+                ->pluck('t.grade')
+                ->map(fn ($g) => (int) $g)
+                ->filter(fn ($g) => $g > 0)
+                ->values()
+                ->all();
+        });
+    }
+
+    /**
+     * 指定年级在教材章节关联中出现过的 kp_code(含子知识点扩展)。
+     *
+     * @return list<string>
+     */
+    /** @return list<int> */
+    public function catalogSemesterOptions(?int $gradeFilter = null): array
+    {
+        if (! Schema::hasTable('textbooks') || ! Schema::hasTable('textbook_catalog_nodes') || ! Schema::hasTable('textbook_chapter_knowledge_relation')) {
+            return [];
+        }
+
+        $cacheKey = 'qtr:catalog-semesters:' . ($gradeFilter ?? 'all');
+        return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($gradeFilter) {
+            return DB::table('textbooks as t')
+                ->join('textbook_catalog_nodes as n', 'n.textbook_id', '=', 't.id')
+                ->join('textbook_chapter_knowledge_relation as r', 'r.catalog_chapter_id', '=', 'n.id')
+                ->whereNotNull('t.semester')
+                ->where('n.node_type', 'section')
+                ->when($gradeFilter !== null, fn ($q) => $q->where('t.grade', $gradeFilter))
+                ->when(Schema::hasColumn('r', 'is_deleted'), fn ($q) => $q->where(function ($x) {
+                    $x->where('r.is_deleted', 0)->orWhereNull('r.is_deleted');
+                }))
+                ->distinct()
+                ->orderBy('t.semester')
+                ->pluck('t.semester')
+                ->map(fn ($s) => (int) $s)
+                ->filter(fn ($s) => $s > 0)
+                ->values()
+                ->all();
+        });
+    }
+
+    private function kpCodesFromCatalogFilter(?int $grade, ?int $semester): array
+    {
+        if (! Schema::hasTable('textbooks') || ! Schema::hasTable('textbook_catalog_nodes') || ! Schema::hasTable('textbook_chapter_knowledge_relation')) {
+            return [];
+        }
+        $cacheKey = sprintf('qtr:catalog-kps:g%s:s%s', $grade ?? 'all', $semester ?? 'all');
+        return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($grade, $semester) {
+            $q = DB::table('textbooks as t')
+                ->join('textbook_catalog_nodes as n', 'n.textbook_id', '=', 't.id')
+                ->join('textbook_chapter_knowledge_relation as r', 'r.catalog_chapter_id', '=', 'n.id')
+                ->where('n.node_type', 'section')
+                ->whereNotNull('r.kp_code')
+                ->where('r.kp_code', '!=', '')
+                ->when($grade !== null && $grade > 0, fn ($x) => $x->where('t.grade', $grade))
+                ->when($semester !== null && $semester > 0, fn ($x) => $x->where('t.semester', $semester))
+                ->when(Schema::hasColumn('r', 'is_deleted'), fn ($x) => $x->where(function ($w) {
+                    $w->where('r.is_deleted', 0)->orWhereNull('r.is_deleted');
+                }));
+
+            return $q->distinct()->pluck('r.kp_code')
+                ->map(fn ($v) => trim((string) $v))
+                ->filter(fn ($v) => $v !== '')
+                ->values()
+                ->all();
+        });
+    }
+
     private function normalizeQuestionTypeForDb(mixed $raw): string
     {
         $t = strtolower(trim((string) $raw));

+ 0 - 25
config/ai.php

@@ -155,30 +155,5 @@ PROMPT,
   ],
   "abilities": ["计算能力", "分析能力"]
 }
-PROMPT,
-
-    'answer_validation_prompt' => <<<'PROMPT'
-你是数学题目质检专家。请校验以下题目的答案是否正确、是否与题目匹配。
-
-校验要求:
-1. **答案正确性**:答案是否在数学/逻辑上正确?(选择题则答案选项内容应对应正确选项)
-2. **答案与题目匹配**:答案是否针对题干所问、是否解答了题目要求?
-
-题目类型:{question_type}
-题干:
-{stem}
-选项(选择题):
-{options}
-参考答案:{answer}
-解析(若有):
-{solution}
-
-请只输出 JSON,格式:
-{
-  "answer_correct": true|false,
-  "answer_matches_question": true|false,
-  "confidence": 0.0~1.0,
-  "reason": "简要说明(若有问题)"
-}
 PROMPT,
 ];

+ 0 - 98
resources/views/components/exam/paper-body.blade.php

@@ -72,13 +72,6 @@
     // 与判题卡共用同一计数规则,避免方框数量不一致
     $countBlanks = fn($text) => $boxCounter->countFillBlanks($text);
 
-    /** 待入库质检页:整题点击选中(questions_tem 题为负 id,用 abs 对应 tem 主键) */
-    $interactiveTemSelect = $interactiveTemSelect ?? false;
-    $selectedTemIdForSelect = $selectedTemIdForSelect ?? null;
-    /** 多选模式:点击切换,右侧批量入库 */
-    $interactiveTemMultiSelect = $interactiveTemMultiSelect ?? false;
-    $selectedTemIdsForMultiSelect = is_array($selectedTemIdsForMultiSelect ?? null) ? $selectedTemIdsForMultiSelect : [];
-
     $renderBoxes = function($num) {
         // 判卷方框放大 1.2 倍,保持单行布局
         if ($num == 2) {
@@ -161,39 +154,8 @@
             // 选择题:句尾不保留句号。
             $renderedStem = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStem, 'remove');
             $renderedStem = $mathProcessed ? $renderedStem : \App\Services\MathFormulaProcessor::processFormulas($renderedStem);
-            $qTemIdSelect = $interactiveTemSelect ? abs((int) ($q->id ?? 0)) : 0;
-            if ($interactiveTemSelect && $interactiveTemMultiSelect) {
-                $qTemSelected = $qTemIdSelect > 0 && in_array($qTemIdSelect, $selectedTemIdsForMultiSelect, true);
-            } else {
-                $qTemSelected = $interactiveTemSelect && (int) ($selectedTemIdForSelect ?? 0) === $qTemIdSelect;
-            }
         @endphp
-        @if($interactiveTemSelect)
-            @if($interactiveTemMultiSelect)
-                {{-- 多选:本地立即切换高亮;服务端返回后 syncTemMultiSelectionJs 会校正 --}}
-                <div
-                    x-on:click.prevent="$el.classList.toggle('qtr-is-selected'); $wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @else
-                <div
-                    x-on:click.prevent="$wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @endif
-        @else
         <div class="question">
-        @endif
             <div class="question-grid">
                 <div class="question-lead">
                     @if($gradingMode)
@@ -356,38 +318,8 @@
             $renderedContent = \App\Support\BlankPlaceholderRenderer::normalizePeriodBeforeTrailingParentheticalNote($renderedContent, '.');
             $renderedContent = \App\Support\BlankPlaceholderRenderer::appendTerminalPunctuationIfMissing($renderedContent, '.');
             $renderedContent = $mathProcessed ? $renderedContent : \App\Services\MathFormulaProcessor::processFormulas($renderedContent);
-            $qTemIdSelect = $interactiveTemSelect ? abs((int) ($q->id ?? 0)) : 0;
-            if ($interactiveTemSelect && $interactiveTemMultiSelect) {
-                $qTemSelected = $qTemIdSelect > 0 && in_array($qTemIdSelect, $selectedTemIdsForMultiSelect, true);
-            } else {
-                $qTemSelected = $interactiveTemSelect && (int) ($selectedTemIdForSelect ?? 0) === $qTemIdSelect;
-            }
         @endphp
-        @if($interactiveTemSelect)
-            @if($interactiveTemMultiSelect)
-                <div
-                    x-on:click.prevent="$el.classList.toggle('qtr-is-selected'); $wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-fill-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @else
-                <div
-                    x-on:click.prevent="$wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-fill-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @endif
-        @else
         <div class="question">
-        @endif
             <div class="question-grid">
                 <div class="question-lead">
                     @if($gradingMode)
@@ -452,12 +384,6 @@
         @php
             // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
             $questionNumber = $q->question_number ?? (count($choiceQuestions) + count($fillQuestions) + $index + 1);
-            $qTemIdSelect = $interactiveTemSelect ? abs((int) ($q->id ?? 0)) : 0;
-            if ($interactiveTemSelect && $interactiveTemMultiSelect) {
-                $qTemSelected = $qTemIdSelect > 0 && in_array($qTemIdSelect, $selectedTemIdsForMultiSelect, true);
-            } else {
-                $qTemSelected = $interactiveTemSelect && (int) ($selectedTemIdForSelect ?? 0) === $qTemIdSelect;
-            }
             // 解答题小题排版优化(仅在小题编号语境下换行,避免误伤 f(1) 这类函数表达)
             $answerStem = (string) ($q->content ?? '');
             preg_match_all('/[((][1-9][0-9]*[))]/u', $answerStem, $subQuestionMatches);
@@ -477,31 +403,7 @@
                 ? $answerStem
                 : \App\Services\MathFormulaProcessor::processFormulas($answerStem);
         @endphp
-        @if($interactiveTemSelect)
-            @if($interactiveTemMultiSelect)
-                <div
-                    x-on:click.prevent="$el.classList.toggle('qtr-is-selected'); $wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-ans-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @else
-                <div
-                    x-on:click.prevent="$wire.toggleTemQuestion({{ $qTemIdSelect }})"
-                    wire:key="qtr-tem-ans-{{ $qTemIdSelect }}"
-                    data-tem-id="{{ $qTemIdSelect }}"
-                    class="question qtr-selectable{{ $qTemSelected ? ' qtr-is-selected' : '' }}"
-                    style="cursor:pointer;"
-                    tabindex="0"
-                    role="button"
-                >
-            @endif
-        @else
         <div class="question">
-        @endif
             <div class="question-grid">
                 <div class="question-lead">
                     <span class="question-number">{{ $gradingMode ? '题目 ' : '' }}{{ $questionNumber }}.</span>

+ 0 - 23
resources/views/filament/pages/partials/question-tem-paper-body.blade.php

@@ -1,7 +1,5 @@
 @props([
     'questions' => ['choice' => [], 'fill' => [], 'answer' => []],
-    'selectedTemId' => null,
-    'selectedTemIdsForMulti' => [],
 ])
 
 <link rel="stylesheet" href="/css/katex/katex.min.css">
@@ -10,32 +8,11 @@
         max-width: 100%;
         overflow-x: auto;
     }
-    .qtr-paper-shell .question.qtr-selectable {
-        border-radius: 0.375rem;
-        transition: box-shadow 0.15s ease, background-color 0.15s ease;
-    }
-    .qtr-paper-shell .question.qtr-selectable:hover {
-        background-color: rgba(14, 165, 233, 0.06);
-    }
-    .dark .qtr-paper-shell .question.qtr-selectable:hover {
-        background-color: rgba(255, 255, 255, 0.05);
-    }
-    .qtr-paper-shell .question.qtr-is-selected {
-        box-shadow: 0 0 0 2px var(--fi-color-primary-500, #0ea5e9);
-        background-color: rgba(14, 165, 233, 0.08);
-    }
-    .dark .qtr-paper-shell .question.qtr-is-selected {
-        background-color: rgba(14, 165, 233, 0.12);
-    }
     @include('components.exam.paper-body-grading-styles')
 </style>
 <div class="qtr-paper-shell math-render">
     @include('components.exam.paper-body', [
         'questions' => $questions,
         'grading' => true,
-        'interactiveTemSelect' => true,
-        'selectedTemIdForSelect' => $selectedTemId,
-        'interactiveTemMultiSelect' => true,
-        'selectedTemIdsForMultiSelect' => $selectedTemIdsForMulti,
     ])
 </div>

+ 44 - 0
resources/views/filament/pages/partials/question-tem-question-check-preview.blade.php

@@ -0,0 +1,44 @@
+@props([
+    'questions' => ['choice' => [], 'fill' => [], 'answer' => []],
+    'student' => ['name' => '________', 'grade' => '________'],
+    'teacher' => ['name' => '________'],
+    'pdfMeta' => [],
+])
+
+@php
+    $gradingCode = $pdfMeta['exam_code'] ?? 'tem_preview';
+    $studentName = $pdfMeta['student_name'] ?? ($student['name'] ?? '________');
+    $paperHeaderTitle = $pdfMeta['header_title'] ?? ($studentName . '|' . $gradingCode . '|题目质检');
+@endphp
+
+@once
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    <style>
+        .qtr-qc-preview {
+            max-width: 100%;
+            overflow-x: auto;
+        }
+        .qtr-qc-preview .qc-page {
+            width: 100%;
+            max-width: none;
+            margin: 0 0 1rem 0;
+            padding: 0;
+            background: #fff;
+            color: #000;
+        }
+        @include('pdf.partials.answer-detail-styles')
+        @include('pdf.partials.grading-scan-sheet-styles')
+        @include('pdf.partials.paper-body-core-styles')
+        @include('pdf.partials.paper-exam-shared-image-styles')
+    </style>
+@endonce
+
+<div class="qtr-qc-preview math-render">
+    <div class="qc-page">
+        @include('components.exam.paper-body', ['questions' => $questions, 'grading' => false])
+    </div>
+
+    <div class="qc-page">
+        @include('pdf.partials.answer-detail-page', ['questions' => $questions])
+    </div>
+</div>

+ 26 - 0
resources/views/filament/pages/partials/question-tem-single-full-preview.blade.php

@@ -0,0 +1,26 @@
+@props([
+    'questions' => ['choice' => [], 'fill' => [], 'answer' => []],
+])
+
+<style>
+    .qtr-single-full-preview {
+        max-width: 100%;
+        overflow-x: auto;
+    }
+    .qtr-single-full-preview .section-title {
+        font-size: 13px;
+        margin-bottom: 0.35rem;
+    }
+    .qtr-single-full-preview .question {
+        margin-bottom: 0.6rem;
+    }
+    @include('pdf.partials.paper-body-core-styles')
+    @include('pdf.partials.paper-exam-shared-image-styles')
+</style>
+
+<div class="qtr-single-full-preview math-render">
+    @include('components.exam.paper-body', [
+        'questions' => $questions,
+        'grading' => true,
+    ])
+</div>

+ 256 - 267
resources/views/filament/pages/question-tem-quality-review.blade.php

@@ -3,8 +3,11 @@
     $btnSm = \Filament\Support\Enums\Size::Small;
 @endphp
 
-<x-filament::page>
-    {{-- 勾选题目用 Alpine 即时高亮 + $wire.toggleTemQuestion,勿把 toggleTemQuestion 放进 target,避免出现全屏大图标一闪 --}}
+<x-filament::page
+    x-data="{}"
+    x-on:qtr-scroll-top.window="window.scrollTo({ top: 0, behavior: 'smooth' })"
+>
+    {{-- 仅监听服务端动作,避免对现有组卷视图组件产生耦合副作用 --}}
     <div
         wire:loading.delay.shortest
         wire:target="selectKp, updatedSelectedKpCode, importSelected, importSelectedTemIdsFast, importAllCurrentKpToQuestions, importAssemblyQueueToQuestions, addSelectionToAssemblyQueue, clearAssemblyQueue, clearTemSelection, removeFromAssemblyQueue, generateTrialGradingPdf"
@@ -101,6 +104,26 @@
             :compact="true"
         >
             <div class="mb-3 space-y-2">
+                @if (count($this->gradeOptions) > 0)
+                    <x-filament::input.wrapper>
+                        <x-filament::input.select wire:model.live="gradeFilter">
+                            <option value="">全部年级</option>
+                            @foreach ($this->gradeOptions as $opt)
+                                <option value="{{ $opt['value'] }}">{{ $opt['label'] }}</option>
+                            @endforeach
+                        </x-filament::input.select>
+                    </x-filament::input.wrapper>
+                @endif
+                @if (count($this->semesterOptions) > 0)
+                    <x-filament::input.wrapper>
+                        <x-filament::input.select wire:model.live="semesterFilter">
+                            <option value="">全部学期</option>
+                            @foreach ($this->semesterOptions as $opt)
+                                <option value="{{ $opt['value'] }}">{{ $opt['label'] }}</option>
+                            @endforeach
+                        </x-filament::input.select>
+                    </x-filament::input.wrapper>
+                @endif
                 <x-filament::input.wrapper
                     inline-prefix
                     prefix-icon="heroicon-m-magnifying-glass"
@@ -117,6 +140,22 @@
                         显示 {{ count($this->filteredKpRows) }} / 共 {{ count($this->kpRows) }} 个知识点
                     </p>
                 @endif
+                @if (filled($this->gradeFilter))
+                    @php
+                        $gradeLabel = collect($this->gradeOptions)->firstWhere('value', (string) $this->gradeFilter)['label'] ?? $this->gradeFilter;
+                    @endphp
+                    <p class="text-xs text-gray-500 dark:text-gray-400">
+                        已按年级筛选:{{ $gradeLabel }}
+                    </p>
+                @endif
+                @if (filled($this->semesterFilter))
+                    @php
+                        $semesterLabel = collect($this->semesterOptions)->firstWhere('value', (string) $this->semesterFilter)['label'] ?? $this->semesterFilter;
+                    @endphp
+                    <p class="text-xs text-gray-500 dark:text-gray-400">
+                        已按学期筛选:{{ $semesterLabel }}
+                    </p>
+                @endif
             </div>
             <div class="qtr-kp-scroll">
                 @forelse($this->filteredKpRows as $row)
@@ -153,10 +192,10 @@
         </x-filament::section>
         </div>
 
-        {{-- 中:与判卷页同源 components.exam.paper-body(一行一题,含选项布局/答案/解题思路) --}}
+        {{-- 中:按 /api/questions/pdf 同源版式展示(交互独立) --}}
         <x-filament::section
-            heading="待审题目(判卷页版式)"
-            description="与 pdf.exam-grading 同源;列表仅展示「可入库」题目=questions_tem 本 KP 去掉与正式库同题干重复后的条目(与左侧「可入库」同数)。点击题目勾选;右侧可批量入库,精细质检在「高级」。"
+            heading="待审题目(题目质检PDF同源版式)"
+            description="中间区按 /api/questions/pdf(题目质检)同源布局展示;勾选/聚焦在独立控件中完成。"
             :compact="true"
         >
             @if (! $this->selectedKpCode)
@@ -180,305 +219,255 @@
                     </span>
                 </div>
 
-                {{-- KP 切换时整栏重绘;仅换选中题时 wire:ignore 阻止中间 DOM morph,右侧照常更新 --}}
-                <div wire:key="qtr-paper-kp-{{ $this->selectedKpCode }}">
-                    <div wire:ignore>
-                        @include('filament.pages.partials.question-tem-paper-body', [
-                            'questions' => $this->groupedPaperBodyQuestions,
-                            'selectedTemId' => $this->selectedTemId,
-                            'selectedTemIdsForMulti' => $this->selectedTemIds,
-                        ])
-                    </div>
+                <div class="mb-3 rounded-lg border border-gray-100 p-3 dark:border-white/10">
+                    <p class="mb-2 text-xs text-gray-500">独立勾选区(每题三项快检:题干/答案/解题思路)</p>
+                    @if (count($this->temQuestionCards) === 0)
+                        <p class="text-sm text-gray-600 dark:text-gray-400">当前知识点暂无可入库题目。</p>
+                    @else
+                        <style>
+                            .qtr-card-list { max-height: none; overflow: visible; display: grid; gap: 0.75rem; }
+                            .qtr-card { border: 1px solid rgb(229 231 235); border-radius: 0.5rem; padding: 0.5rem; }
+                            .dark .qtr-card { border-color: rgb(75 85 99); }
+                            .qtr-badge { font-size: 11px; border-radius: 999px; padding: 1px 8px; border: 1px solid transparent; }
+                            .qtr-badge-ok { color: #166534; background: #dcfce7; border-color: #86efac; }
+                            .qtr-badge-no { color: #991b1b; background: #fee2e2; border-color: #fca5a5; }
+                            .dark .qtr-badge-ok { color: #86efac; background: rgba(22,101,52,0.25); border-color: rgba(134,239,172,0.35); }
+                            .dark .qtr-badge-no { color: #fca5a5; background: rgba(127,29,29,0.25); border-color: rgba(252,165,165,0.35); }
+                            .qtr-full-preview-wrap { margin-top: 0.5rem; border-top: 1px dashed rgb(209 213 219); padding-top: 0.5rem; }
+                            .dark .qtr-full-preview-wrap { border-top-color: rgb(75 85 99); }
+                        </style>
+                        <div class="qtr-card-list">
+                            @foreach ($this->visibleTemQuestionCards as $card)
+                                @php
+                                    $tid = (int) $card['id'];
+                                    $checks = $card['checks'];
+                                    $grouped = $card['grouped_questions'] ?? ['choice' => [], 'fill' => [], 'answer' => []];
+                                    $q = $grouped['choice'][0] ?? $grouped['fill'][0] ?? $grouped['answer'][0] ?? null;
+                                @endphp
+                                <div class="qtr-card" wire:key="tem-card-{{ $tid }}">
+                                    <div class="mb-1 flex items-start gap-2">
+                                        <button type="button" wire:click="focusTemQuestion({{ $tid }})" class="text-left hover:underline">
+                                            <span class="font-mono text-xs">tem #{{ $tid }}</span>
+                                        </button>
+                                        @if ((int) ($this->selectedTemId ?? 0) === $tid)
+                                            <span class="qtr-badge qtr-badge-ok">当前聚焦</span>
+                                        @endif
+                                        @if (in_array($tid, $this->pendingImportTemIds, true))
+                                            <span class="qtr-badge qtr-badge-ok">待入库</span>
+                                        @endif
+                                    </div>
+                                    <div class="flex flex-wrap gap-1.5">
+                                        <span class="qtr-badge {{ $checks['stem'] ? 'qtr-badge-ok' : 'qtr-badge-no' }}">题干</span>
+                                        <span class="qtr-badge {{ $checks['answer'] ? 'qtr-badge-ok' : 'qtr-badge-no' }}">答案</span>
+                                        <span class="qtr-badge {{ $checks['solution'] ? 'qtr-badge-ok' : 'qtr-badge-no' }}">解题思路</span>
+                                    </div>
+                                    <div class="qtr-full-preview-wrap">
+                                        @if ($q)
+                                            @include('filament.pages.partials.question-tem-question-check-preview', [
+                                                'questions' => $card['grouped_questions'],
+                                                'student' => ['name' => '________', 'grade' => '________'],
+                                                'teacher' => ['name' => '________'],
+                                                'pdfMeta' => [
+                                                    'exam_code' => 'tem_'.$tid,
+                                                    'student_name' => '________',
+                                                    'header_title' => '________|tem_'.$tid.'|题目质检',
+                                                    'grading_pdf_title' => '题目质检_tem_'.$tid,
+                                                ],
+                                            ])
+                                        @else
+                                            <div class="text-xs text-gray-500">该题暂无可展示内容</div>
+                                        @endif
+                                    </div>
+                                    <div class="mt-3 border-t border-gray-100 pt-2 dark:border-white/10" x-data="{ operated: false }">
+                                        @php $st = $this->importStatusMap[$tid] ?? null; @endphp
+                                        @if ($st)
+                                            <div class="mb-1 text-[11px]">
+                                                @if (($st['state'] ?? '') === 'queued')
+                                                    <span class="text-primary-600">状态:排队中</span>
+                                                @elseif (($st['state'] ?? '') === 'running')
+                                                    <span class="text-primary-600">状态:入库中</span>
+                                                @elseif (($st['state'] ?? '') === 'done')
+                                                    <span class="text-success-600">状态:已入库(question #{{ (int) ($st['question_id'] ?? 0) }})</span>
+                                                @elseif (($st['state'] ?? '') === 'failed')
+                                                    <span class="text-danger-600">状态:失败({{ (string) ($st['message'] ?? '未知错误') }})</span>
+                                                @endif
+                                            </div>
+                                        @endif
+                                        <div class="flex flex-wrap items-center gap-2">
+                                            <x-filament::button
+                                                size="xs"
+                                                color="{{ in_array($tid, $this->pendingImportTemIds, true) ? 'gray' : 'primary' }}"
+                                                wire:click="addToPendingImport({{ $tid }})"
+                                                :disabled="in_array($tid, $this->pendingImportTemIds, true)"
+                                            >
+                                                {{ in_array($tid, $this->pendingImportTemIds, true) ? '已在待入库' : '加入待入库' }}
+                                            </x-filament::button>
+                                            @if (in_array($tid, $this->pendingImportTemIds, true))
+                                                <x-filament::button size="xs" color="danger" wire:click="removeFromPendingImport({{ $tid }})">
+                                                    移除待入库
+                                                </x-filament::button>
+                                            @endif
+                                            @php
+                                                $opLocked = in_array(($st['state'] ?? ''), ['queued', 'running', 'done'], true);
+                                            @endphp
+                                            <button
+                                                type="button"
+                                                class="fi-btn fi-color-danger fi-size-sm inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold"
+                                                :disabled="operated || {{ $opLocked ? 'true' : 'false' }}"
+                                                :class="{ 'opacity-80 cursor-not-allowed': operated || {{ $opLocked ? 'true' : 'false' }} }"
+                                                x-on:click.prevent="if (operated || {{ $opLocked ? 'true' : 'false' }}) return; operated = true; $wire.queueImportTem({{ $tid }})"
+                                            >
+                                                <span x-show="!(operated || {{ $opLocked ? 'true' : 'false' }})">直接入库</span>
+                                                <span x-show="operated || {{ $opLocked ? 'true' : 'false' }}">已操作</span>
+                                            </button>
+                                            <span class="text-xs text-success-600" x-show="operated">已入库</span>
+                                        </div>
+                                    </div>
+                                </div>
+                            @endforeach
+                        </div>
+                        @if (count($this->temQuestionCards) > count($this->visibleTemQuestionCards))
+                            <div class="mt-3">
+                                <x-filament::button size="sm" color="gray" wire:click="loadMoreCards">
+                                    加载更多(已显示 {{ count($this->visibleTemQuestionCards) }} / {{ count($this->temQuestionCards) }})
+                                </x-filament::button>
+                            </div>
+                        @endif
+                    @endif
                 </div>
             @endif
         </x-filament::section>
 
-        {{-- 右:快速批量入库 → 可选高级质检/单题入库 → 待组卷 / 批量 --}}
+        {{-- 右:人工判重 + 待入库 --}}
         <div
             class="qtr-sticky-side"
             wire:key="qtr-right-stack"
-            wire:loading.class="opacity-70"
-            wire:target="importSelectedTemIdsFast, importSelected, importAllCurrentKpToQuestions, importAssemblyQueueToQuestions, selectKp, updatedSelectedKpCode, addSelectionToAssemblyQueue, clearAssemblyQueue, clearTemSelection, removeFromAssemblyQueue, generateTrialGradingPdf"
+            wire:loading.class=""
+            wire:target="addToPendingImport,removeFromPendingImport,queueImportTem,importPendingTem,importPendingAll,clearPendingImport,selectKp,updatedSelectedKpCode,updatedGradeFilter,updatedSemesterFilter"
         >
             <x-filament::section
-                heading="快速入库"
-                description="勾选中间题目后此处列出 tem 编号;不跑页面质检,与批量入库相同服务端规则(重复题跳过)"
+                heading="同知识点正式库题目(人工判重)"
+                description="当前筛选 + 当前知识点下,列出 questions 中已有题目(编号+题干)用于人工核对是否重复。"
                 :compact="true"
             >
-                @if ($this->selectedTemIds === [])
-                    <p class="text-sm text-gray-600 dark:text-gray-400">在中间列表点击题目加入勾选。</p>
+                @if (! $this->selectedKpCode)
+                    <p class="text-sm text-gray-600 dark:text-gray-400">先在左侧选择知识点。</p>
+                @elseif ($this->currentKpQuestionStemRows === [])
+                    <p class="text-sm text-gray-600 dark:text-gray-400">正式库当前知识点暂无题目。</p>
                 @else
-                    <div class="mb-2 text-xs text-gray-500">
-                        已选 <span class="font-semibold text-gray-800 dark:text-gray-200">{{ count($this->selectedTemIds) }}</span> 道 ·
-                        最后聚焦 tem #<span class="font-mono">{{ $this->selectedTemId ?? '—' }}</span>
-                    </div>
-                    <div class="mb-3 max-h-36 overflow-y-auto rounded-lg border border-gray-100 p-2 font-mono text-xs dark:border-white/10">
-                        {{ implode(', ', array_map('intval', $this->selectedTemIds)) }}
+                    <div class="max-h-64 overflow-y-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-white/10 space-y-2">
+                        @foreach ($this->currentKpQuestionStemRows as $qr)
+                            @php
+                                $qid = (int) ($qr->id ?? 0);
+                                $qStem = (string) ($qr->stem ?? '');
+                                $qTypeRaw = mb_strtolower((string) ($qr->question_type ?? ''));
+
+                                // 这里用内联样式,避免 Tailwind 动态 class 被清理后“看起来没变化”。
+                                $noPalette = [
+                                    'background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;',
+                                    'background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;',
+                                    'background:#f5f3ff;color:#6d28d9;border:1px solid #ddd6fe;',
+                                    'background:#fffbeb;color:#b45309;border:1px solid #fde68a;',
+                                    'background:#fff1f2;color:#be123c;border:1px solid #fecdd3;',
+                                ];
+                                $noStyle = $noPalette[$qid % count($noPalette)];
+
+                                $typeLabel = '简答';
+                                $typeStyle = 'background:#f5f3ff;color:#6d28d9;border:1px solid #ddd6fe;';
+                                if (str_contains($qTypeRaw, 'choice') || str_contains($qTypeRaw, '选择')) {
+                                    $typeLabel = '选择';
+                                    $typeStyle = 'background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;';
+                                } elseif (str_contains($qTypeRaw, 'fill') || str_contains($qTypeRaw, 'blank') || str_contains($qTypeRaw, '填空')) {
+                                    $typeLabel = '填空';
+                                    $typeStyle = 'background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;';
+                                }
+                            @endphp
+                            <div class="rounded border border-gray-100 p-2 dark:border-white/10" wire:key="kp-q-{{ (int) ($qr->id ?? 0) }}">
+                                <div class="mb-1 flex items-center gap-1.5">
+                                    <span class="inline-flex items-center rounded-full px-1.5 py-0.5 font-mono text-[10px]" style="{{ $noStyle }}">
+                                        question #{{ $qid }}
+                                    </span>
+                                    <span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px]" style="{{ $typeStyle }}">
+                                        {{ $typeLabel }}
+                                    </span>
+                                </div>
+                                <div style="font-size:11px;line-height:1.3;color:#4b5563;">
+                                    {{ $qStem }}
+                                </div>
+                            </div>
+                        @endforeach
                     </div>
                 @endif
-                <div class="flex flex-wrap gap-2">
-                    <x-filament::button
-                        color="success"
-                        wire:click="importSelectedTemIdsFast"
-                        wire:loading.attr="disabled"
-                        :disabled="$this->selectedTemIds === []"
-                    >
-                        一键入库已选题目
-                    </x-filament::button>
-                    <x-filament::button
-                        color="gray"
-                        wire:click="clearTemSelection"
-                        wire:loading.attr="disabled"
-                        :disabled="$this->selectedTemIds === []"
-                    >
-                        清空勾选
-                    </x-filament::button>
-                </div>
-            </x-filament::section>
-
-            <x-filament::section heading="入库后调难度" :compact="true" class="mt-4">
-                <p class="text-xs text-gray-600 dark:text-gray-400">
-                    自本会话起成功写入 <span class="font-mono">questions</span> 的题目会进入列表,可集中修改难度系数。
-                </p>
-                <a
-                    href="{{ \App\Filament\Pages\QuestionImportedDifficultyTune::getUrl() }}"
-                    class="mt-2 inline-flex text-sm font-medium text-primary-600 underline"
-                >
-                    打开「已入库题目 · 难度调整」
-                </a>
             </x-filament::section>
 
             <x-filament::section
-                heading="高级:质检与单题入库"
-                description="需要逐题看清规则、自定义难度再入库时展开;展开后会为当前聚焦的题目跑质检"
+                heading="待入库"
+                description="仅显示待入库题目 id。"
                 :compact="true"
                 class="mt-4"
             >
-                @if (! $this->qcPanelExpanded)
-                    <x-filament::button color="gray" wire:click="$set('qcPanelExpanded', true)">
-                        展开质检与单题入库
-                    </x-filament::button>
+                @if ($this->pendingImportRows === [])
+                    <p class="text-sm text-gray-600 dark:text-gray-400">先在中间卡片点击「加入待入库」。</p>
                 @else
-                    <div class="mb-3">
-                        <x-filament::button color="gray" size="sm" wire:click="$set('qcPanelExpanded', false)">
-                            收起
+                    <div class="mb-2">
+                        <x-filament::button size="xs" color="gray" wire:click="syncAsyncImportStatuses">
+                            同步异步状态
                         </x-filament::button>
                     </div>
-
-                    {{-- 质检 --}}
-                    <div class="rounded-lg border border-gray-100 p-3 dark:border-white/10">
-                        <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">质检结果</p>
-                        @if (! $this->selectedTemId)
-                            <p class="text-sm text-gray-600 dark:text-gray-400">请先在中间点击一道题作为当前聚焦。</p>
-                        @else
-                            @php $qc = $this->qcResult; @endphp
-                            <div class="mb-2 text-xs text-gray-500">
-                                questions_tem.id = <span class="font-mono">{{ $this->selectedTemId }}</span>
-                            </div>
-
-                            @if ($this->duplicateHint)
-                                <div class="mb-3 rounded-lg bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
-                                    {{ $this->duplicateHint }}
-                                </div>
-                            @endif
-
-                            @if ($qc)
-                                <div
-                                    class="mb-3 rounded-lg border p-3 {{ $qc['passed'] ? 'border-success-600/40 bg-success-50 dark:bg-success-950/30' : 'border-danger-600/40 bg-danger-50 dark:bg-danger-950/30' }}"
-                                >
-                                    <div class="text-sm font-medium">
-                                        {{ $qc['passed'] ? '质检通过' : '质检未通过' }}
-                                    </div>
-                                    @if (! empty($qc['errors']))
-                                        <ul class="mt-2 list-inside list-disc text-xs">
-                                            @foreach ($qc['errors'] as $code)
-                                                <li>{{ $rules[$code]['name'] ?? $code }}</li>
-                                            @endforeach
-                                        </ul>
-                                    @endif
-                                </div>
-
-                                <div class="max-h-48 overflow-y-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-white/10">
-                                    @foreach ($qc['results'] as $r)
-                                        @if (($r['auto_result'] ?? '') !== 'skip')
-                                            <div class="flex justify-between gap-2 border-b border-gray-100 py-1 last:border-0 dark:border-white/10">
-                                                <span>{{ $r['rule_name'] ?? $r['rule_code'] }}</span>
-                                                <span class="{{ ($r['passed'] ?? false) ? 'text-success-600' : 'text-danger-600' }}">
-                                                    {{ ($r['passed'] ?? false) ? 'OK' : '×' }}
-                                                </span>
-                                            </div>
+                    <div class="mb-2 text-xs text-gray-500">
+                        待入库 <span class="font-semibold text-gray-800 dark:text-gray-200">{{ count($this->pendingImportRows) }}</span> 道
+                    </div>
+                    <div class="mb-3 max-h-44 overflow-y-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-white/10 space-y-2">
+                        @foreach ($this->pendingImportRows as $pr)
+                            <div class="rounded border border-gray-100 p-2 dark:border-white/10" wire:key="pending-{{ (int) ($pr->id ?? 0) }}">
+                                <div class="mb-1 font-mono">tem #{{ (int) ($pr->id ?? 0) }}</div>
+                                @php $st = $this->importStatusMap[(int) ($pr->id ?? 0)] ?? null; @endphp
+                                @if ($st)
+                                    <div class="mb-1 text-[11px]">
+                                        @if (($st['state'] ?? '') === 'queued')
+                                            <span class="text-primary-600">排队中</span>
+                                        @elseif (($st['state'] ?? '') === 'running')
+                                            <span class="text-primary-600">入库中</span>
+                                        @elseif (($st['state'] ?? '') === 'done')
+                                            <span class="text-success-600">已入库 #{{ (int) ($st['question_id'] ?? 0) }}</span>
+                                        @elseif (($st['state'] ?? '') === 'failed')
+                                            <span class="text-danger-600">失败:{{ (string) ($st['message'] ?? '未知错误') }}</span>
                                         @endif
-                                    @endforeach
+                                    </div>
+                                @endif
+                                <div class="flex flex-wrap gap-1">
+                                    <x-filament::button size="xs" color="success" wire:click="queueImportTem({{ (int) ($pr->id ?? 0) }})">
+                                        入库
+                                    </x-filament::button>
+                                    <x-filament::button size="xs" color="gray" wire:click="focusTemQuestion({{ (int) ($pr->id ?? 0) }})">
+                                        审核
+                                    </x-filament::button>
+                                    <x-filament::button size="xs" color="danger" wire:click="removeFromPendingImport({{ (int) ($pr->id ?? 0) }})">
+                                        移除
+                                    </x-filament::button>
                                 </div>
-                            @endif
-                        @endif
-                    </div>
-
-                    {{-- 入库难度 --}}
-                    <div class="mt-3 rounded-lg border border-gray-100 p-3 dark:border-white/10">
-                        <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">单题入库难度</p>
-                        @if (! $this->selectedTemId)
-                            <p class="text-xs text-gray-500">先选择题目标</p>
-                        @else
-                            <x-filament::input.wrapper>
-                                <x-filament::input
-                                    type="number"
-                                    min="0"
-                                    max="0.9"
-                                    step="0.01"
-                                    placeholder="例如 0.35"
-                                    wire:model.live.debounce.400ms="importDifficultyInput"
-                                />
-                            </x-filament::input.wrapper>
-                            <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
-                                将写入 <span class="font-mono">questions.difficulty</span>;批量/快速入库使用各题 tem 原始值(规整到 0~0.90)。
-                            </p>
-                        @endif
-                    </div>
-
-                    {{-- 单题入库 --}}
-                    <div class="mt-3 rounded-lg border border-gray-100 p-3 dark:border-white/10">
-                        <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">单题入库</p>
-                        @if (! $this->selectedTemId)
-                            <p class="text-xs text-gray-500">先选择题目标</p>
-                        @else
-                            @php
-                                $qc = $this->qcResult;
-                                $canImport = $qc && ($qc['passed'] ?? false) && ! $this->duplicateHint;
-                            @endphp
-                            <x-filament::button
-                                color="success"
-                                wire:click="importSelected"
-                                wire:loading.attr="disabled"
-                                :disabled="! $canImport"
-                            >
-                                入库到 questions
-                            </x-filament::button>
-                            @if (! $canImport && $qc)
-                                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
-                                    @if ($this->duplicateHint)
-                                        与正式库重复时不可入库。
-                                    @elseif (! ($qc['passed'] ?? false))
-                                        质检未通过时不可入库,请先修正题目数据。
-                                    @endif
-                                </p>
-                            @else
-                                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
-                                    audit_status=0(若表含该字段);同 KP + 同题干重复会拒绝。
-                                </p>
-                            @endif
-                        @endif
-                    </div>
-                @endif
-            </x-filament::section>
-
-            {{-- 待组卷与判卷 PDF --}}
-            <x-filament::section
-                heading="待组卷验 PDF"
-                description="将题目加入队列后生成临时试卷;判卷页与 PDF 同源"
-                :compact="true"
-                class="mt-4"
-            >
-                <div class="mb-3 space-y-2 text-xs text-gray-600 dark:text-gray-400">
-                    @if ($this->selectedTemIds !== [])
-                        <p>当前勾选:<span class="font-mono">{{ count($this->selectedTemIds) }} 道 tem</span></p>
-                    @endif
-                    <p class="text-gray-500">队列共 {{ count($this->assemblyQueueRows) }} 道</p>
-                </div>
-                @if (count($this->assemblyQueueRows) > 0)
-                    <ul class="mb-3 max-h-32 list-inside list-decimal overflow-y-auto rounded border border-gray-100 p-2 text-xs dark:border-white/10">
-                        @foreach ($this->assemblyQueueRows as $qr)
-                            <li class="flex items-center justify-between gap-2 py-0.5">
-                                <span class="truncate">tem #{{ (int) ($qr->id ?? 0) }}</span>
-                                <button
-                                    type="button"
-                                    class="shrink-0 text-danger-600 underline"
-                                    wire:click="removeFromAssemblyQueue({{ (int) ($qr->id ?? 0) }})"
-                                >
-                                    移除
-                                </button>
-                            </li>
+                            </div>
                         @endforeach
-                    </ul>
+                    </div>
                 @endif
-                <div class="flex flex-col gap-2">
-                    <x-filament::button
-                        color="gray"
-                        wire:click="addSelectionToAssemblyQueue"
-                        :disabled="$this->selectedTemIds === []"
-                    >
-                        将当前勾选加入待组卷
-                    </x-filament::button>
-                    <x-filament::button
-                        color="warning"
-                        wire:click="clearAssemblyQueue"
-                        :disabled="count($this->assemblyQueueRows) === 0"
-                    >
-                        清空队列
-                    </x-filament::button>
-                    <x-filament::button
-                        color="primary"
-                        wire:click="generateTrialGradingPdf"
-                        :disabled="count($this->assemblyQueueRows) === 0"
-                    >
-                        生成完整卷 PDF 并打开判卷页
-                    </x-filament::button>
-                    @if ($this->trialGradingUrl)
-                        <a
-                            href="{{ $this->trialGradingUrl }}"
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            class="text-sm text-primary-600 underline"
-                        >
-                            判卷页预览(新标签)
-                        </a>
-                    @endif
-                    @if ($this->trialGradingPdfUrl)
-                        <a
-                            href="{{ $this->trialGradingPdfUrl }}"
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            class="text-sm text-primary-600 underline"
-                            download
-                        >
-                            下载完整卷 PDF
-                        </a>
-                    @endif
-                </div>
-            </x-filament::section>
-
-            {{-- 5. 批量:questions_tem → questions(与单题入库同一套规则,不经 JSON) --}}
-            <x-filament::section
-                heading="批量入库"
-                description="直接从 questions_tem 写入 questions;规则与单题「入库」一致(同 KP + 同题干已存在则跳过)"
-                :compact="true"
-                class="mt-4"
-            >
-                <div class="flex flex-col gap-2">
+                <div class="flex flex-wrap gap-2">
                     <x-filament::button
-                        color="gray"
-                        wire:click="importAllCurrentKpToQuestions"
+                        color="success"
+                        wire:click="importPendingAll"
                         wire:loading.attr="disabled"
-                        wire:target="importAllCurrentKpToQuestions"
-                        wire:confirm="确定将当前知识点下列表中的全部题目写入 questions?(正式库已存在的同题干会跳过)"
-                        :disabled="! $this->selectedKpCode || count($this->temQuestions) === 0"
+                        :disabled="$this->pendingImportRows === []"
                     >
-                        一键入库当前知识点全部
+                        一键入库待入库全部
                     </x-filament::button>
                     <x-filament::button
                         color="gray"
-                        wire:click="importAssemblyQueueToQuestions"
+                        wire:click="clearPendingImport"
                         wire:loading.attr="disabled"
-                        wire:target="importAssemblyQueueToQuestions"
-                        wire:confirm="确定将上方待组卷队列中的全部题目写入 questions?(已存在的同题干会跳过)"
-                        :disabled="count($this->assemblyQueueRows) === 0"
+                        :disabled="$this->pendingImportRows === []"
                     >
-                        一键入库待组卷队列全部
+                        清空待入库
                     </x-filament::button>
                 </div>
-                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
-                    与单题入库相同的数据写入逻辑;批量时不强制质检通过,仅做重复与必填判断。
-                </p>
             </x-filament::section>
         </div>
     </div>