Răsfoiți Sursa

feat(question-bank-qc): 题库质检、tem 预览与 PDF/判卷链路对齐

- ExamPdfController: questions_tem hydrate、buildGroupedQuestionsForPaperBody、题库仅请求正数 id
- paper-body: tem 质检多选与 wire/Alpine 交互
- Filament: tem 质检/JSON 导入/知识点统计/难度微调等页面与服务
- 迁移、SQL 辅助脚本、文档与 Blade 单测
- 题库质检命令与服务、ExamPdfExport、ai 配置与 PDF 报告模板
- .gitignore: 排除 .cursor 与 .playwright-cli

Made-with: Cursor
yemeishu 3 zile în urmă
părinte
comite
8ca4b76911
38 a modificat fișierele cu 6024 adăugiri și 185 ștergeri
  1. 4 0
      .gitignore
  2. 50 0
      app/Console/Commands/CreateAdminUserCommand.php
  3. 43 0
      app/Console/Commands/KnowledgePointQuestionStatsMarkdownCommand.php
  4. 58 0
      app/Console/Commands/QuestionsExportSqlCommand.php
  5. 87 0
      app/Console/Commands/QuestionsImportCommand.php
  6. 36 0
      app/Console/Commands/ResetUserPasswordCommand.php
  7. 213 19
      app/Console/Commands/RunQuestionQualityCheckCommand.php
  8. 40 0
      app/Filament/Pages/KnowledgePointQuestionStats.php
  9. 134 0
      app/Filament/Pages/QuestionImportedDifficultyTune.php
  10. 594 0
      app/Filament/Pages/QuestionTemQualityReview.php
  11. 147 0
      app/Filament/Pages/QuestionsJsonImportPage.php
  12. 185 128
      app/Http/Controllers/ExamPdfController.php
  13. 34 24
      app/Services/ExamPdfExportService.php
  14. 226 0
      app/Services/KnowledgePointQuestionStatsService.php
  15. 446 0
      app/Services/QuestionBulkImportService.php
  16. 289 13
      app/Services/QuestionQualityCheckService.php
  17. 405 0
      app/Services/QuestionTemReviewService.php
  18. 163 0
      app/Services/QuestionsTemAssemblyService.php
  19. 25 0
      config/ai.php
  20. 30 0
      database/migrations/2026_03_25_120000_create_questions_tem_assembly_queue_table.php
  21. 106 0
      database/sql/copy_questions_tem_to_questions.sql
  22. 865 0
      docs/knowledge_point_question_stats.md
  23. 100 0
      docs/paper_132736388400529_根因分析.md
  24. 118 0
      docs/paper_132736538400759_全盘分析.md
  25. 88 0
      docs/paper_132736648500556_分析_修复后.md
  26. 172 0
      docs/组卷流程与知识点题目不足应对措施分析.md
  27. 220 0
      docs/组卷验证案例与流程说明.md
  28. 6 1
      docs/题库质检方案.md
  29. 34 0
      resources/views/components/exam/paper-body-grading-pdf-page-styles.blade.php
  30. 302 0
      resources/views/components/exam/paper-body-grading-styles.blade.php
  31. 98 0
      resources/views/components/exam/paper-body.blade.php
  32. 1 0
      resources/views/exam-analysis/pdf-report.blade.php
  33. 50 0
      resources/views/filament/pages/knowledge-point-question-stats.blade.php
  34. 41 0
      resources/views/filament/pages/partials/question-tem-paper-body.blade.php
  35. 114 0
      resources/views/filament/pages/question-imported-difficulty-tune.blade.php
  36. 435 0
      resources/views/filament/pages/question-tem-quality-review.blade.php
  37. 46 0
      resources/views/filament/pages/questions-json-import.blade.php
  38. 19 0
      tests/Unit/QuestionTemQualityReviewBladeTest.php

+ 4 - 0
.gitignore

@@ -45,3 +45,7 @@ ansible/
 # 向量加粗修复脚本(独立工具)
 scripts/vector_bold_fix/
 .serena/
+
+# 本地工具 / IDE(勿提交)
+/.cursor
+/.playwright-cli

+ 50 - 0
app/Console/Commands/CreateAdminUserCommand.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\User;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Hash;
+
+class CreateAdminUserCommand extends Command
+{
+    protected $signature = 'user:create-admin
+        {username=17689974321 : 登录手机号(与后台「手机号」一致,对应 users.username)}
+        {password=123qwe#0 : 明文密码;含 # 时请用单引号包裹整个参数}';
+
+    protected $description = '创建或更新管理员账号(role=admin,is_active=1),用于登录 /admin';
+
+    public function handle(): int
+    {
+        $username = (string) $this->argument('username');
+        $password = (string) $this->argument('password');
+
+        if ($username === '') {
+            $this->error('username 不能为空');
+
+            return self::FAILURE;
+        }
+
+        $user = User::where('username', $username)->first();
+
+        if (! $user) {
+            $user = new User;
+            $user->username = $username;
+            $user->full_name = '系统管理员';
+            $user->email = null;
+        }
+
+        $user->password_hash = Hash::make($password);
+        $user->role = 'admin';
+        $user->is_active = true;
+        if ($user->full_name === '' || $user->full_name === null) {
+            $user->full_name = '系统管理员';
+        }
+
+        $user->save();
+
+        $this->info("已就绪:username={$user->username} role=admin 登录 /admin 使用手机号+密码");
+
+        return self::SUCCESS;
+    }
+}

+ 43 - 0
app/Console/Commands/KnowledgePointQuestionStatsMarkdownCommand.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\KnowledgePointQuestionStatsService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class KnowledgePointQuestionStatsMarkdownCommand extends Command
+{
+    protected $signature = 'stats:kp-questions-md
+                            {--output= : 写入文件路径(绝对路径、或项目根下 docs/xxx、或相对于 storage/app)}';
+
+    protected $description = '输出知识点题量统计 Markdown 表(questions / questions_tem 不重复)';
+
+    public function handle(KnowledgePointQuestionStatsService $svc): int
+    {
+        $rows = $svc->buildRows();
+        $md = $svc->toMarkdownTable($rows);
+
+        $path = $this->option('output');
+        if ($path) {
+            $p = ltrim((string) $path, '/');
+            if (str_starts_with($p, 'docs/')) {
+                $full = base_path($p);
+            } elseif (str_starts_with((string) $path, '/') || (strlen((string) $path) > 2 && preg_match('/^[A-Za-z]:\\\\/', (string) $path))) {
+                $full = (string) $path;
+            } else {
+                $full = storage_path('app/'.$p);
+            }
+            $dir = dirname($full);
+            if (! is_dir($dir)) {
+                File::makeDirectory($dir, 0755, true);
+            }
+            file_put_contents($full, $md);
+            $this->info('已写入:'.$full);
+        }
+
+        $this->line($md);
+
+        return self::SUCCESS;
+    }
+}

+ 58 - 0
app/Console/Commands/QuestionsExportSqlCommand.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\QuestionBulkImportService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class QuestionsExportSqlCommand extends Command
+{
+    protected $signature = 'questions:export-sql
+        {--ids=* : 题目 id,可写多个 --ids=1 --ids=2 或 --ids=1,2,3}
+        {--output= : SQL 输出路径;默认 storage/app/exports/questions_export_YYYYMMDDHHmmss.sql}
+        {--with-id : 生成的 SQL 含 id 列(仅当必须在目标库对齐原主键时使用;默认不含 id,由目标库自增)}';
+
+    protected $description = '将本地 questions 表中指定 id 导出为可复制到服务器的 MySQL 脚本';
+
+    public function handle(QuestionBulkImportService $service): int
+    {
+        $idsOpt = $this->option('ids');
+        $ids = [];
+        if (is_array($idsOpt)) {
+            foreach ($idsOpt as $chunk) {
+                if (! is_string($chunk) && ! is_int($chunk)) {
+                    continue;
+                }
+                foreach (preg_split('/\s*,\s*/', (string) $chunk) ?: [] as $part) {
+                    if ($part === '') {
+                        continue;
+                    }
+                    $ids[] = (int) $part;
+                }
+            }
+        }
+        $ids = array_values(array_unique(array_filter($ids)));
+
+        if ($ids === []) {
+            $this->error('请至少指定一个 id,例如:php artisan questions:export-sql --ids=1,2,3');
+
+            return self::FAILURE;
+        }
+
+        $includeId = (bool) $this->option('with-id');
+
+        $sql = $service->exportIdsToMysqlScript($ids, $includeId);
+
+        $outPath = $this->option('output');
+        if (! is_string($outPath) || $outPath === '') {
+            $outPath = storage_path('app/exports/questions_export_'.date('YmdHis').'.sql');
+        }
+
+        File::ensureDirectoryExists(dirname($outPath));
+        File::put($outPath, $sql);
+        $this->info('已导出 '.count($ids).' 个 id 的 SQL:'.$outPath);
+
+        return self::SUCCESS;
+    }
+}

+ 87 - 0
app/Console/Commands/QuestionsImportCommand.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\QuestionBulkImportService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class QuestionsImportCommand extends Command
+{
+    protected $signature = 'questions:import
+        {file : JSON 文件路径(UTF-8,支持整段数组或 NDJSON 每行一题)}
+        {--dry-run : 仅校验与统计,不写 MySQL}
+        {--sql-only : 只生成 SQL 脚本,不写入本地 questions 表}
+        {--output= : SQL 输出路径;默认 storage/app/exports/questions_import_YYYYMMDDHHmmss.sql}
+        {--with-id : SQL 中含 id 列(需目标表结构与 id 不冲突;默认不含 id,按 question_code 去重)}';
+
+    protected $description = '从 JSON 批量导入 questions,并生成可复制到服务器 MySQL 的 INSERT…ON DUPLICATE KEY UPDATE 脚本';
+
+    public function handle(QuestionBulkImportService $service): int
+    {
+        $path = $this->argument('file');
+        if (! is_string($path) || ! File::isFile($path)) {
+            $this->error('文件不存在:'.$path);
+
+            return self::FAILURE;
+        }
+
+        try {
+            $rawRows = $service->loadRowsFromFile($path);
+        } catch (\Throwable $e) {
+            $this->error($e->getMessage());
+
+            return self::FAILURE;
+        }
+
+        if ($rawRows === []) {
+            $this->warn('未解析到任何题目行。');
+
+            return self::FAILURE;
+        }
+
+        $rows = [];
+        foreach ($rawRows as $i => $raw) {
+            if (! is_array($raw)) {
+                continue;
+            }
+            $rows[] = $service->normalizeImportRow($raw, $i + 1);
+        }
+
+        $includeId = (bool) $this->option('with-id');
+        $sql = $service->renderMysqlScript($rows, $includeId);
+
+        $outPath = $this->option('output');
+        if (! is_string($outPath) || $outPath === '') {
+            $outPath = storage_path('app/exports/questions_import_'.date('YmdHis').'.sql');
+        }
+
+        File::ensureDirectoryExists(dirname($outPath));
+        File::put($outPath, $sql);
+        $this->info('SQL 已写入:'.$outPath);
+
+        $dryRun = (bool) $this->option('dry-run');
+        $sqlOnly = (bool) $this->option('sql-only');
+
+        if ($sqlOnly) {
+            $this->line('已使用 --sql-only,未写入本地 questions 表。');
+
+            return self::SUCCESS;
+        }
+
+        $result = $service->importToDatabase($rows, $dryRun);
+        $this->info(sprintf(
+            '本地库:新建 %d 条,更新 %d 条,跳过 %d 条(dry-run=%s)',
+            $result['created'],
+            $result['updated'],
+            $result['skipped'],
+            $dryRun ? 'true' : 'false'
+        ));
+
+        foreach ($result['errors'] as $err) {
+            $this->warn($err);
+        }
+
+        return self::SUCCESS;
+    }
+}

+ 36 - 0
app/Console/Commands/ResetUserPasswordCommand.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\User;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Hash;
+
+class ResetUserPasswordCommand extends Command
+{
+    protected $signature = 'user:reset-password
+        {username : 登录用户名(本站为手机号,对应 users.username)}
+        {password : 新密码(含 # 等字符时请用单引号包裹)}';
+
+    protected $description = '重置 users.password_hash(Filament 使用此字段校验密码)';
+
+    public function handle(): int
+    {
+        $username = $this->argument('username');
+        $password = $this->argument('password');
+
+        $user = User::where('username', $username)->first();
+        if (! $user) {
+            $this->error("未找到 username = {$username} 的用户(请检查 .env 是否连到正确数据库)");
+
+            return self::FAILURE;
+        }
+
+        $user->password_hash = Hash::make($password);
+        $user->save();
+
+        $this->info("已重置密码:id={$user->id} username={$user->username} role=".($user->role ?? 'null'));
+
+        return self::SUCCESS;
+    }
+}

+ 213 - 19
app/Console/Commands/RunQuestionQualityCheckCommand.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Services\ExamPdfExportService;
 use App\Services\QuestionQualityCheckService;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
@@ -15,12 +16,23 @@ class RunQuestionQualityCheckCommand extends Command
         {--limit=100 : 质检题目数量上限}
         {--textbook= : 教材 ID}
         {--semester=2 : 学期 1=上 2=下}
-        {--dry-run : 仅输出筛选结果,不执行质检}';
+        {--dry-run : 仅输出筛选结果,不执行质检}
+        {--list-kps : 第一步:仅罗列前10个题少的 KP(按下学期章节),不质检}
+        {--export-pdf : 按知识点分组生成 PDF(使用组卷规范,含试卷+判卷)}
+        {--ai-check : 启用 AI 校验(答案正确性、答案与题目匹配,会请求 AI 接口)}';
 
     protected $description = '题目自动质检:从 questions_tem 按下学期题少 KP 筛选题目并执行校验';
 
-    public function handle(QuestionQualityCheckService $qcService): int
+    public function handle(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
     {
+        if ($this->option('list-kps')) {
+            return $this->listKpsWithFewQuestions($qcService, 10);
+        }
+
+        if ($this->option('export-pdf')) {
+            return $this->exportPdfByKp($qcService, $pdfService);
+        }
+
         $table = $this->option('table');
 
         if (! Schema::hasTable($table)) {
@@ -32,16 +44,17 @@ class RunQuestionQualityCheckCommand extends Command
         $limit = (int) $this->option('limit');
         $dryRun = $this->option('dry-run');
 
+        $excludeFromQuestions = $table === 'questions_tem' && Schema::hasTable('questions')
+            && Schema::hasColumn($table, 'question_code')
+            && Schema::hasColumn('questions', 'question_code');
+
         if ($kp) {
-            $questions = DB::table($table)
-                ->where('kp_code', $kp)
-                ->limit($limit)
-                ->get();
+            $query = DB::table($table)->where('kp_code', $kp);
         } else {
             $kps = $qcService->getKpsWithFewQuestions(
                 $this->option('textbook') ? (int) $this->option('textbook') : null,
                 (int) $this->option('semester'),
-                20
+                10
             );
 
             if (empty($kps)) {
@@ -49,20 +62,28 @@ class RunQuestionQualityCheckCommand extends Command
                 return 0;
             }
 
-            $this->info('按下学期题少 KP 筛选,前 5 个:');
-            foreach (array_slice($kps, 0, 5) as $r) {
-                $this->line("  {$r['kp_code']}: {$r['question_count']} 题");
+            $this->info('按下学期题少 KP 筛选(前 10 个):');
+            foreach ($kps as $i => $r) {
+                $this->line("  " . ($i + 1) . ". {$r['kp_code']} — {$r['question_count']} 题");
             }
 
             $kpCodes = array_column($kps, 'kp_code');
-            $questions = DB::table($table)
-                ->whereIn('kp_code', $kpCodes)
-                ->limit($limit)
-                ->get();
+            $query = DB::table($table)->whereIn('kp_code', $kpCodes);
         }
 
+        if ($excludeFromQuestions) {
+            $query->whereNotIn('question_code', DB::table('questions')->select('question_code'));
+            $this->info('已排除 questions 中已有的题目(按 question_code 去重)');
+        }
+
+        $questions = $query->limit($limit)->get();
+        $aiCheck = $this->option('ai-check');
+
         $total = $questions->count();
         $this->info("待质检题目数: {$total}");
+        if ($aiCheck) {
+            $this->info('已启用 AI 校验(答案正确性、与题目匹配)');
+        }
 
         if ($dryRun) {
             $this->info('[dry-run] 不执行质检');
@@ -81,7 +102,8 @@ class RunQuestionQualityCheckCommand extends Command
             $result = $qcService->runAutoCheck(
                 $mapped,
                 $row['id'] ?? null,
-                null
+                null,
+                ['ai_check' => $aiCheck]
             );
 
             if ($result['passed']) {
@@ -89,7 +111,11 @@ class RunQuestionQualityCheckCommand extends Command
             } else {
                 $failed++;
                 $this->newLine();
-                $this->warn("  [{$row['id']}] " . implode(', ', $result['errors']));
+                $labels = array_map(
+                    fn ($code) => QuestionQualityCheckService::RULES[$code]['name'] ?? $code,
+                    $result['errors']
+                );
+                $this->warn("  [{$row['id']}] " . implode('、', $labels));
             }
             $bar->advance();
         }
@@ -101,19 +127,187 @@ class RunQuestionQualityCheckCommand extends Command
         return 0;
     }
 
+    /**
+     * 第一步:罗列按顺序题数最少的 10 个知识点(下学期章节、题少优先)
+     */
+    private function listKpsWithFewQuestions(QuestionQualityCheckService $qcService, int $top): int
+    {
+        $kps = $qcService->getKpsWithFewQuestions(
+            $this->option('textbook') ? (int) $this->option('textbook') : null,
+            (int) $this->option('semester'),
+            $top
+        );
+
+        if (empty($kps)) {
+            $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
+            return 0;
+        }
+
+        $this->info("按下学期章节、题少优先,前 {$top} 个知识点:");
+        $this->newLine();
+
+        foreach ($kps as $i => $r) {
+            $rank = $i + 1;
+            $this->line("  {$rank}. {$r['kp_code']} — {$r['question_count']} 题");
+        }
+
+        return 0;
+    }
+
+    /**
+     * 按知识点分组生成 PDF,使用组卷和 PDF 生成规范(ExamPdfExportService::generateByQuestions)
+     */
+    private function exportPdfByKp(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
+    {
+        $table = $this->option('table');
+        $kp = $this->option('kp');
+        $grouped = $qcService->getQcQuestionsGroupedByKp(
+            $table,
+            $this->option('textbook') ? (int) $this->option('textbook') : null,
+            (int) $this->option('semester'),
+            10,
+            (int) $this->option('limit') ?: null,
+            $kp ?: null
+        );
+
+        if (empty($grouped)) {
+            $this->warn('未找到待导出的题目,请检查数据');
+            return 0;
+        }
+
+        $this->info('按知识点分组生成 PDF(试卷 + 判卷):');
+        $this->newLine();
+
+        $student = ['name' => '________', 'grade' => '________'];
+        $teacher = ['name' => '________'];
+        $totalPdf = 0;
+        $errors = [];
+
+        foreach ($grouped as $kpCode => $questions) {
+            $count = $questions->count();
+            $this->line("  [{$kpCode}] {$count} 题");
+
+            $groupedQuestions = $this->buildGroupedQuestionsForPdf($questions);
+            $totalQ = count($groupedQuestions['choice'] ?? []) + count($groupedQuestions['fill'] ?? []) + count($groupedQuestions['answer'] ?? []);
+            if ($totalQ === 0) {
+                $this->warn("    跳过:无可导出题目");
+                continue;
+            }
+
+            $paperName = "质检题目_{$kpCode}";
+            $paperId = 'qc_kp_' . $kpCode . '_' . time() . '_' . uniqid();
+            $paper = $this->buildVirtualPaperForPdf($paperId, $paperName, $groupedQuestions);
+
+            try {
+                $result = $pdfService->generateByQuestions($paper, $groupedQuestions, $student, $teacher, true);
+                if (! empty($result['pdf_url'])) {
+                    $this->line('');
+                    $this->line($result['pdf_url']);
+                    $this->line('');
+                    $totalPdf++;
+                }
+            } catch (\Throwable $e) {
+                $errors[] = "{$kpCode}: {$e->getMessage()}";
+                $this->error("    失败: {$e->getMessage()}");
+            }
+        }
+
+        $this->newLine();
+        $this->info("完成: 共 {$totalPdf} 个知识点生成 PDF" . (empty($errors) ? '' : ',' . count($errors) . ' 个失败'));
+
+        return empty($errors) ? 0 : 1;
+    }
+
+    /**
+     * 将题目集合转为 PDF 规范格式:choice / fill / answer
+     */
+    private function buildGroupedQuestionsForPdf($questions): array
+    {
+        $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
+        $typeMap = [
+            'choice' => 'choice', '选择题' => 'choice', 'single_choice' => 'choice', 'multiple_choice' => 'choice',
+            'fill' => 'fill', '填空题' => 'fill', 'blank' => 'fill',
+            'answer' => 'answer', '解答题' => 'answer', 'subjective' => 'answer', 'calculation' => 'answer', 'proof' => 'answer',
+        ];
+        $scoreMap = ['choice' => 5, 'fill' => 5, 'answer' => 10];
+
+        $num = 1;
+        foreach ($questions as $q) {
+            $rawType = strtolower(trim((string) ($q->question_type ?? $q->tags ?? 'answer')));
+            $type = $typeMap[$rawType] ?? 'answer';
+            $score = $scoreMap[$type] ?? 5;
+
+            $opts = $q->options ?? null;
+            if (is_string($opts)) {
+                $opts = json_decode($opts, true);
+            }
+
+            $grouped[$type][] = (object) [
+                'id' => $q->id ?? 0,
+                'question_number' => $num++,
+                'content' => $q->stem ?? $q->content ?? '',
+                'options' => is_array($opts) ? $opts : [],
+                'answer' => $q->answer ?? $q->correct_answer ?? '',
+                'solution' => $q->solution ?? '',
+                'score' => $score,
+                'difficulty' => $q->difficulty ?? 0.5,
+                'kp_code' => $q->kp_code ?? '',
+            ];
+        }
+
+        return $grouped;
+    }
+
+    /**
+     * 构建虚拟试卷对象(符合 ExamPdfExportService 规范)
+     */
+    private function buildVirtualPaperForPdf(string $paperId, string $paperName, array $groupedQuestions): object
+    {
+        $totalScore = 0;
+        $totalQuestions = 0;
+        foreach ($groupedQuestions as $qs) {
+            foreach ($qs as $q) {
+                $totalScore += $q->score ?? 5;
+                $totalQuestions++;
+            }
+        }
+
+        return (object) [
+            'paper_id' => $paperId,
+            'paper_name' => $paperName,
+            'total_score' => $totalScore,
+            'total_questions' => $totalQuestions,
+            'created_at' => now()->toDateTimeString(),
+        ];
+    }
+
     /**
      * 将 questions_tem 行映射为质检服务所需格式
      */
     private function mapQuestionRow(array $row): array
     {
+        $optionsRaw = $row['options'] ?? null;
+        $options = null;
+        $optionsJsonInvalid = false;
+
+        if (is_string($optionsRaw) && trim($optionsRaw) !== '') {
+            $decoded = json_decode($optionsRaw, true);
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                $optionsJsonInvalid = true;
+            } else {
+                $options = $decoded;
+            }
+        } elseif (is_array($optionsRaw)) {
+            $options = $optionsRaw;
+        }
+
         return [
             'stem' => $row['stem'] ?? $row['content'] ?? '',
             'answer' => $row['answer'] ?? $row['correct_answer'] ?? '',
             'solution' => $row['solution'] ?? '',
             'question_type' => $row['question_type'] ?? $row['tags'] ?? '',
-            'options' => is_string($row['options'] ?? null)
-                ? json_decode($row['options'], true)
-                : ($row['options'] ?? null),
+            'options' => $options,
+            'options_json_invalid' => $optionsJsonInvalid,
         ];
     }
 }

+ 40 - 0
app/Filament/Pages/KnowledgePointQuestionStats.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Services\KnowledgePointQuestionStatsService;
+use BackedEnum;
+use Filament\Pages\Page;
+use Filament\Support\Enums\Width;
+use Livewire\Attributes\Computed;
+use UnitEnum;
+
+class KnowledgePointQuestionStats extends Page
+{
+    protected static ?string $title = '知识点题量统计';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
+
+    protected static ?string $navigationLabel = '知识点题量统计';
+
+    protected static string|UnitEnum|null $navigationGroup = '题库管理';
+
+    protected static ?int $navigationSort = 6;
+
+    protected Width|string|null $maxContentWidth = Width::Full;
+
+    protected string $view = 'filament.pages.knowledge-point-question-stats';
+
+    /** @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_non_duplicate_count: int, sort_order: int}> */
+    #[Computed(cache: false)]
+    public function statRows(): array
+    {
+        return app(KnowledgePointQuestionStatsService::class)->buildRows();
+    }
+
+    #[Computed(cache: false)]
+    public function markdownExport(): string
+    {
+        return app(KnowledgePointQuestionStatsService::class)->toMarkdownTable($this->statRows);
+    }
+}

+ 134 - 0
app/Filament/Pages/QuestionImportedDifficultyTune.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Models\Question;
+use App\Services\QuestionTemReviewService;
+use BackedEnum;
+use Filament\Notifications\Notification;
+use Filament\Pages\Page;
+use Filament\Support\Enums\Width;
+use Illuminate\Support\Facades\Schema;
+use Livewire\Attributes\Computed;
+use UnitEnum;
+
+/**
+ * 展示本会话内从待入库页成功写入的题目,便于事后统一微调 difficulty(0.00~0.90)。
+ */
+class QuestionImportedDifficultyTune extends Page
+{
+    protected static ?string $title = '入库题目调难度';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
+
+    protected static ?string $navigationLabel = '入库题目调难度';
+
+    protected static string|UnitEnum|null $navigationGroup = '题库管理';
+
+    protected static ?int $navigationSort = 5;
+
+    protected Width|string|null $maxContentWidth = Width::Full;
+
+    protected string $view = 'filament.pages.question-imported-difficulty-tune';
+
+    public ?int $selectedQuestionId = null;
+
+    public string $difficultyInput = '0.50';
+
+    #[Computed(cache: false)]
+    public function tuningQuestionIds(): array
+    {
+        $raw = session(QuestionTemReviewService::SESSION_TUNING_QUESTION_IDS, []);
+
+        return is_array($raw) ? array_values(array_unique(array_filter(array_map('intval', $raw)))) : [];
+    }
+
+    #[Computed(cache: false)]
+    public function selectedQuestion(): ?Question
+    {
+        if (! $this->selectedQuestionId || ! Schema::hasTable('questions')) {
+            return null;
+        }
+
+        return Question::query()->find($this->selectedQuestionId);
+    }
+
+    public function selectQuestion(int $id): void
+    {
+        if ($id <= 0) {
+            return;
+        }
+        if (! in_array($id, $this->tuningQuestionIds, true)) {
+            Notification::make()->title('该题不在当前列表中')->warning()->send();
+
+            return;
+        }
+        $this->selectedQuestionId = $id;
+        $q = Question::query()->find($id);
+        if ($q) {
+            $d = max(0.0, min(0.9, round((float) ($q->difficulty ?? 0.5), 2)));
+            $this->difficultyInput = number_format($d, 2, '.', '');
+        }
+    }
+
+    public function saveDifficulty(): void
+    {
+        if (! $this->selectedQuestionId) {
+            Notification::make()->title('请先选择题目')->warning()->send();
+
+            return;
+        }
+        if (! in_array($this->selectedQuestionId, $this->tuningQuestionIds, true)) {
+            Notification::make()->title('该题不在当前列表中')->warning()->send();
+
+            return;
+        }
+
+        $raw = trim($this->difficultyInput);
+        if ($raw === '' || ! is_numeric($raw)) {
+            Notification::make()->title('难度格式不正确')->danger()->send();
+
+            return;
+        }
+        $value = round((float) $raw, 2);
+        if ($value < 0.0 || $value > 0.9) {
+            Notification::make()->title('难度须在 0.00~0.90 之间')->danger()->send();
+
+            return;
+        }
+
+        Question::query()->where('id', $this->selectedQuestionId)->update([
+            'difficulty' => $value,
+        ]);
+
+        $this->difficultyInput = number_format($value, 2, '.', '');
+
+        Notification::make()
+            ->title('已更新 difficulty')
+            ->body(sprintf('question_id: %d · difficulty: %s', $this->selectedQuestionId, $this->difficultyInput))
+            ->success()
+            ->send();
+
+        $this->dispatch('$refresh');
+    }
+
+    public function clearTuningList(): void
+    {
+        session()->forget(QuestionTemReviewService::SESSION_TUNING_QUESTION_IDS);
+        $this->selectedQuestionId = null;
+        $this->difficultyInput = '0.50';
+        Notification::make()->title('已清空列表记录(仅本会话)')->success()->send();
+        $this->dispatch('$refresh');
+    }
+
+    public function removeFromList(int $questionId): void
+    {
+        $ids = array_values(array_filter($this->tuningQuestionIds, fn (int $x) => $x !== $questionId));
+        session([QuestionTemReviewService::SESSION_TUNING_QUESTION_IDS => $ids]);
+        if ($this->selectedQuestionId === $questionId) {
+            $this->selectedQuestionId = null;
+            $this->difficultyInput = '0.50';
+        }
+        $this->dispatch('$refresh');
+    }
+}

+ 594 - 0
app/Filament/Pages/QuestionTemQualityReview.php

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

+ 147 - 0
app/Filament/Pages/QuestionsJsonImportPage.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Services\QuestionBulkImportService;
+use BackedEnum;
+use Filament\Forms;
+use Filament\Notifications\Notification;
+use Filament\Pages\Page;
+use Filament\Schemas\Components\Section;
+use Filament\Schemas\Schema;
+use Filament\Support\Enums\Width;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Storage;
+use UnitEnum;
+
+class QuestionsJsonImportPage extends Page implements Forms\Contracts\HasForms
+{
+    use Forms\Concerns\InteractsWithForms;
+
+    /**
+     * 主入口在「待入库质检」页右侧;本页仅保留直达 URL,不出现在侧栏。
+     */
+    protected static bool $shouldRegisterNavigation = false;
+
+    protected static ?string $title = '题库 JSON 一键导入';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-up-tray';
+
+    protected static ?string $navigationLabel = 'JSON 一键导入';
+
+    protected static string|UnitEnum|null $navigationGroup = '题库管理';
+
+    protected static ?int $navigationSort = 6;
+
+    /**
+     * 固定路径,便于收藏与文档:/admin/questions-json-import
+     */
+    protected static ?string $slug = 'questions-json-import';
+
+    protected Width|string|null $maxContentWidth = Width::Full;
+
+    protected string $view = 'filament.pages.questions-json-import';
+
+    /** @var array<string, mixed> */
+    public array $data = [
+        'dry_run' => false,
+        'sql_only' => false,
+        'with_id' => false,
+    ];
+
+    public ?string $lastSqlPath = null;
+
+    public ?string $lastMessage = null;
+
+    public function mount(): void
+    {
+        $this->form->fill($this->data);
+    }
+
+    public function form(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                Section::make('从 JSON 导入 questions')
+                    ->description('支持整段 JSON 数组或 NDJSON(每行一题)。左侧选文件与选项,右侧点击「一键导入」执行。')
+                    ->schema([
+                        Forms\Components\FileUpload::make('json_file')
+                            ->label('JSON 文件')
+                            ->disk('local')
+                            ->directory('imports/questions')
+                            ->acceptedFileTypes(['application/json', 'text/plain', 'text/json'])
+                            ->maxSize(51200)
+                            ->helperText('最大约 50MB;字段含 question_code、kp_code、stem、options、answer、solution 等'),
+                        Forms\Components\Toggle::make('dry_run')
+                            ->label('仅校验(dry-run,不写库)')
+                            ->default(false),
+                        Forms\Components\Toggle::make('sql_only')
+                            ->label('仅生成 SQL(不写入本地 questions)')
+                            ->default(false),
+                        Forms\Components\Toggle::make('with_id')
+                            ->label('SQL 中含 id(仅当目标库必须对齐原主键时勾选;默认不含 id)')
+                            ->default(false),
+                    ])
+                    ->columns(1),
+            ])
+            ->statePath('data');
+    }
+
+    public function runImport(): void
+    {
+        $this->lastMessage = null;
+        $this->lastSqlPath = null;
+
+        $data = $this->data;
+        $relative = $data['json_file'] ?? null;
+        if (! is_string($relative) || $relative === '') {
+            Notification::make()->title('请选择 JSON 文件')->warning()->send();
+
+            return;
+        }
+
+        $fullPath = Storage::disk('local')->path($relative);
+        if (! File::isFile($fullPath)) {
+            Notification::make()->title('文件不存在')->body($fullPath)->danger()->send();
+
+            return;
+        }
+
+        $dryRun = (bool) ($data['dry_run'] ?? false);
+        $sqlOnly = (bool) ($data['sql_only'] ?? false);
+        $withId = (bool) ($data['with_id'] ?? false);
+
+        $service = app(QuestionBulkImportService::class);
+        $pipeline = $service->runImportPipeline($fullPath, $dryRun, $sqlOnly, $withId);
+
+        if (! $pipeline['ok']) {
+            Notification::make()->title('导入失败')->body($pipeline['error'] ?? '')->danger()->send();
+
+            return;
+        }
+
+        $this->lastSqlPath = $pipeline['sql_path'] ?? null;
+        $this->lastMessage = $pipeline['message'] ?? null;
+
+        if ($sqlOnly) {
+            Notification::make()->title('SQL 已生成')->body($this->lastSqlPath ?? '')->success()->send();
+
+            return;
+        }
+
+        $body = (string) $this->lastMessage;
+        $stats = $pipeline['stats'] ?? null;
+        if (is_array($stats) && $stats['errors'] !== []) {
+            $body .= "\n".implode("\n", array_slice($stats['errors'], 0, 8));
+            if (count($stats['errors']) > 8) {
+                $body .= "\n… 另有 ".(count($stats['errors']) - 8).' 条错误';
+            }
+        }
+
+        Notification::make()
+            ->title($dryRun ? '校验完成(dry-run)' : '导入完成')
+            ->body($body)
+            ->success()
+            ->send();
+    }
+}

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

@@ -10,6 +10,7 @@ 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
 {
@@ -503,6 +504,167 @@ 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'] ?? $arr['content'] ?? '');
+            $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);
+            }
+            $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' => (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'] : '',
+                '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
@@ -533,11 +695,15 @@ 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_column($questionsData, 'id');
+                    $questionIds = array_values(array_filter(
+                        array_column($questionsData, 'id'),
+                        static fn ($id) => (int) $id > 0
+                    ));
                     $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                     $responseData = $questionsResponse['data'] ?? [];
 
@@ -633,6 +799,8 @@ class ExamPdfController extends Controller
                 ];
             }
 
+            $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
+
             Log::debug('paper_questions 获取题目', [
                 'paper_id' => $paper_id,
                 'question_count' => count($questionsData),
@@ -642,7 +810,10 @@ class ExamPdfController extends Controller
             // 但要严格限制只获取这8道题
             if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
-                $questionIds = array_column($questionsData, 'id');
+                $questionIds = array_values(array_filter(
+                    array_column($questionsData, 'id'),
+                    static fn ($id) => (int) $id > 0
+                ));
 
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
@@ -707,81 +878,7 @@ class ExamPdfController extends Controller
             }
         }
 
-        // 按题型分类(使用标准的中学数学试卷格式)
-        $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;
-                });
-            }
-        }
+        $questions = $this->buildGroupedQuestionsForPaperBody($questionsData, (string) $paper_id);
 
         // 调试:记录最终分类结果
         Log::info('最终分类结果', [
@@ -838,9 +935,13 @@ 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_column($questionsData, 'id');
+                $questionIds = array_values(array_filter(
+                    array_column($questionsData, 'id'),
+                    static fn ($id) => (int) $id > 0
+                ));
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
                 if (! empty($responseData)) {
@@ -907,9 +1008,13 @@ class ExamPdfController extends Controller
                     'content' => $pq->question_text ?? '',
                 ];
             }
+            $questionsData = $this->hydratePaperQuestionsDataFromQuestionsTem($questionsData);
             if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
-                $questionIds = array_column($questionsData, 'id');
+                $questionIds = array_values(array_filter(
+                    array_column($questionsData, 'id'),
+                    static fn ($id) => (int) $id > 0
+                ));
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
                 if (! empty($responseData)) {
@@ -961,55 +1066,7 @@ class ExamPdfController extends Controller
             }
         }
 
-        $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;
-                });
-            }
-        }
+        $questions = $this->buildGroupedQuestionsForPaperBody($questionsData, (string) $paper_id);
 
         $studentInfo = $this->getStudentInfo($paper->student_id);
         $teacherInfo = $this->getTeacherInfo($paper->teacher_id);

+ 34 - 24
app/Services/ExamPdfExportService.php

@@ -2939,7 +2939,7 @@ class ExamPdfExportService
      * @param  array  $student  学生信息 ['name' => '', 'grade' => '']
      * @param  array  $teacher  教师信息 ['name' => '']
      * @param  bool  $includeGrading  是否包含判卷版本
-     * @return array 返回 ['pdf_url' => '...', 'grading_pdf_url' => '...']
+     * @return array 返回 ['pdf_url' => '...'] 或 ['pdf_url' => '...', 'grading_pdf_url' => '...'](includeGrading 时返回完整 PDF,pdf_url 与 grading_pdf_url 为同一份)
      */
     public function generateByQuestions(
         object $paper,
@@ -2961,13 +2961,31 @@ class ExamPdfExportService
         try {
             $result = [];
 
-            // 1. 生成试卷PDF(不含答案)
             $examHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, false);
-            if ($examHtml) {
-                // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式
-                if ($this->katexRenderer) {
-                    $examHtml = $this->katexRenderer->renderHtml($examHtml);
+            if (! $examHtml) {
+                return $result;
+            }
+            if ($this->katexRenderer) {
+                $examHtml = $this->katexRenderer->renderHtml($examHtml);
+            }
+
+            if ($includeGrading) {
+                // 生成完整一份 PDF:试卷 + 判卷(分页合并)
+                $gradingHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, true);
+                if ($gradingHtml && $this->katexRenderer) {
+                    $gradingHtml = $this->katexRenderer->renderHtml($gradingHtml);
+                }
+                $unifiedHtml = $gradingHtml ? $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, null) : $examHtml;
+                $pdfBinary = $this->buildPdf($unifiedHtml ?? $examHtml);
+                if ($pdfBinary) {
+                    $path = "custom_exams/{$paper->paper_id}.pdf";
+                    $url = $this->pdfStorageService->put($path, $pdfBinary);
+                    $result['pdf_url'] = $url;
+                    $result['grading_pdf_url'] = $url; // 兼容:完整 PDF 含试卷+判卷,与 pdf_url 同一份
+                    Log::info('generateByQuestions: 完整 PDF(试卷+判卷)生成成功', ['url' => $url]);
                 }
+            } else {
+                // 仅试卷
                 $examPdf = $this->buildPdf($examHtml);
                 if ($examPdf) {
                     $examPath = "custom_exams/{$paper->paper_id}_exam.pdf";
@@ -2977,24 +2995,6 @@ class ExamPdfExportService
                 }
             }
 
-            // 2. 如果需要,生成判卷PDF(含答案)
-            if ($includeGrading) {
-                $gradingHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, true);
-                if ($gradingHtml) {
-                    // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式
-                    if ($this->katexRenderer) {
-                        $gradingHtml = $this->katexRenderer->renderHtml($gradingHtml);
-                    }
-                    $gradingPdf = $this->buildPdf($gradingHtml);
-                    if ($gradingPdf) {
-                        $gradingPath = "custom_exams/{$paper->paper_id}_grading.pdf";
-                        $gradingUrl = $this->pdfStorageService->put($gradingPath, $gradingPdf);
-                        $result['grading_pdf_url'] = $gradingUrl;
-                        Log::info('判卷PDF生成成功', ['url' => $gradingUrl]);
-                    }
-                }
-            }
-
             return $result;
 
         } catch (\Throwable $e) {
@@ -3019,6 +3019,15 @@ class ExamPdfExportService
     ): ?string {
         try {
             $viewName = $this->resolveExamViewName($grading);
+            $examCode = $paper->paper_id ?? 'unknown';
+            $studentName = $student['name'] ?? '________';
+            $pdfMeta = [
+                'student_name' => $studentName,
+                'exam_code' => $examCode,
+                'header_title' => $studentName . '|' . $examCode . '|质检',
+                'exam_pdf_title' => '试卷_' . $examCode,
+                'grading_pdf_title' => '判卷_' . $examCode,
+            ];
 
             $html = view($viewName, [
                 'paper' => $paper,
@@ -3027,6 +3036,7 @@ class ExamPdfExportService
                 'teacher' => $teacher,
                 'grading' => $grading,
                 'includeAnswer' => false, // exam-paper 视图需要这个变量
+                'pdfMeta' => $pdfMeta,
             ])->render();
 
             if (empty(trim($html))) {

+ 226 - 0
app/Services/KnowledgePointQuestionStatsService.php

@@ -0,0 +1,226 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Textbook;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 知识点题量统计:正式库 questions、待入库 questions_tem(剔除与 questions 题干重复)
+ */
+class KnowledgePointQuestionStatsService
+{
+    /** 下学期教材 semester 值(与 textbooks.semester 一致,默认 2) */
+    public static function textbookSemesterForOrdering(): int
+    {
+        return (int) config('question_bank.kp_stats_semester', 2);
+    }
+
+    /**
+     * 按年级下→教材章节顺序得到 kp_code → 排序权重(越小越靠前)
+     *
+     * @return array<string, int>
+     */
+    public function buildKpOrderFromTextbooks(): array
+    {
+        if (! Schema::hasTable('textbooks') || ! Schema::hasTable('textbook_catalog_nodes')) {
+            return [];
+        }
+
+        $semester = self::textbookSemesterForOrdering();
+        $textbookIds = Textbook::query()
+            ->where('semester', $semester)
+            ->orderBy('grade')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->pluck('id')
+            ->all();
+
+        if ($textbookIds === []) {
+            return [];
+        }
+
+        $diagnostic = app(DiagnosticChapterService::class);
+        $order = 0;
+        $map = [];
+        $seen = [];
+
+        foreach ($textbookIds as $tid) {
+            $codes = $diagnostic->getTextbookKnowledgePointsInOrder((int) $tid);
+            foreach ($codes as $kp) {
+                if ($kp === '' || $kp === null) {
+                    continue;
+                }
+                if (isset($seen[$kp])) {
+                    continue;
+                }
+                $seen[$kp] = true;
+                $map[$kp] = $order++;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * @return array<string, int> kp_code => questions 表题目数
+     */
+    public function questionsCountByKp(): array
+    {
+        if (! Schema::hasTable('questions')) {
+            return [];
+        }
+
+        return DB::table('questions')
+            ->selectRaw('kp_code, COUNT(*) as c')
+            ->whereNotNull('kp_code')
+            ->where('kp_code', '!=', '')
+            ->groupBy('kp_code')
+            ->pluck('c', 'kp_code')
+            ->map(fn ($c) => (int) $c)
+            ->toArray();
+    }
+
+    /**
+     * questions_tem 中「与 questions 同 kp + 同题干」不重复的题目数,按 kp_code
+     *
+     * @return array<string, int>
+     */
+    public function temNonDuplicateCountByKp(): array
+    {
+        if (! Schema::hasTable('questions_tem') || ! Schema::hasTable('questions')) {
+            return [];
+        }
+
+        $hasContent = Schema::hasColumn('questions_tem', 'content');
+        $stemExpr = $hasContent
+            ? 'IFNULL(NULLIF(TRIM(t.stem), \'\'), t.content)'
+            : 'TRIM(t.stem)';
+
+        $rows = DB::select(
+            "SELECT t.kp_code AS kp_code, COUNT(*) AS c
+             FROM questions_tem AS t
+             WHERE t.kp_code IS NOT NULL AND t.kp_code != ''
+             AND NOT EXISTS (
+                 SELECT 1 FROM questions AS q
+                 WHERE q.kp_code = t.kp_code
+                 AND q.stem = ({$stemExpr})
+             )
+             GROUP BY t.kp_code"
+        );
+
+        $out = [];
+        foreach ($rows as $row) {
+            $out[(string) $row->kp_code] = (int) $row->c;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return list<array{
+     *   kp_code: string,
+     *   kp_name: string,
+     *   questions_count: int,
+     *   tem_non_duplicate_count: int,
+     *   sort_order: int
+     * }>
+     */
+    public function buildRows(): array
+    {
+        $orderMap = $this->buildKpOrderFromTextbooks();
+        $qCounts = $this->questionsCountByKp();
+        $temCounts = $this->temNonDuplicateCountByKp();
+
+        $kpCodes = array_unique(array_merge(
+            array_keys($qCounts),
+            array_keys($temCounts),
+            Schema::hasTable('knowledge_points')
+                ? DB::table('knowledge_points')->pluck('kp_code')->all()
+                : []
+        ));
+        sort($kpCodes);
+
+        $names = [];
+        if (Schema::hasTable('knowledge_points')) {
+            $names = DB::table('knowledge_points')->pluck('name', 'kp_code')->toArray();
+        }
+
+        $unmappedBase = 1_000_000;
+        $rows = [];
+
+        foreach ($kpCodes as $kp) {
+            if ($kp === '' || $kp === null) {
+                continue;
+            }
+            $kp = (string) $kp;
+            $qc = (int) ($qCounts[$kp] ?? 0);
+            $tc = (int) ($temCounts[$kp] ?? 0);
+            if ($qc === 0 && $tc === 0) {
+                continue;
+            }
+
+            $rows[] = [
+                'kp_code' => $kp,
+                'kp_name' => trim((string) ($names[$kp] ?? '')),
+                'questions_count' => $qc,
+                'tem_non_duplicate_count' => $tc,
+                'sort_order' => $orderMap[$kp] ?? ($unmappedBase + (crc32($kp) % 100_000)),
+            ];
+        }
+
+        usort($rows, function ($a, $b) {
+            if ($a['sort_order'] !== $b['sort_order']) {
+                return $a['sort_order'] <=> $b['sort_order'];
+            }
+            if ($a['questions_count'] !== $b['questions_count']) {
+                return $a['questions_count'] <=> $b['questions_count'];
+            }
+
+            return strcmp($a['kp_code'], $b['kp_code']);
+        });
+
+        return $rows;
+    }
+
+    /**
+     * Markdown 表格(含标题与说明)
+     */
+    public function toMarkdownTable(array $rows): string
+    {
+        $sem = self::textbookSemesterForOrdering();
+        $lines = [];
+        $lines[] = '# 知识点题量统计';
+        $lines[] = '';
+        $lines[] = sprintf(
+            '- 排序:教材 **semester=%d**(默认下学期)按年级与章节关联知识点顺序优先,其次 **questions 题量升序**。',
+            $sem
+        );
+        $lines[] = '- **tem 待入库(不重复)**:`questions_tem` 中与 `questions` 同 `kp_code` 且题干一致者不重复计数。';
+        $lines[] = '';
+
+        $lines[] = '| 知识点 ID | 知识点名称 | questions 题目数 | questions_tem 待入库(不含与 questions 重复) |';
+        $lines[] = '| --- | --- | ---: | ---: |';
+
+        foreach ($rows as $r) {
+            $name = str_replace('|', '\\|', $r['kp_name'] !== '' ? $r['kp_name'] : '—');
+            $lines[] = sprintf(
+                '| `%s` | %s | %d | %d |',
+                $r['kp_code'],
+                $name,
+                $r['questions_count'],
+                $r['tem_non_duplicate_count']
+            );
+        }
+
+        $lines[] = '';
+
+        $sumQ = array_sum(array_column($rows, 'questions_count'));
+        $sumT = array_sum(array_column($rows, 'tem_non_duplicate_count'));
+        $lines[] = sprintf('**合计**:questions %d 题;questions_tem(不重复)%d 题。', $sumQ, $sumT);
+        $lines[] = '';
+
+        return implode("\n", $lines);
+    }
+}

+ 446 - 0
app/Services/QuestionBulkImportService.php

@@ -0,0 +1,446 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Question;
+use DateTimeInterface;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
+use Throwable;
+
+/**
+ * 从 JSON 批量导入 questions,并生成可在 MySQL 客户端执行的 INSERT…ON DUPLICATE KEY UPDATE 脚本。
+ */
+class QuestionBulkImportService
+{
+    /** @var array<int, string>|null */
+    private ?array $questionsColumns = null;
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    public function loadRowsFromFile(string $path): array
+    {
+        if (! is_readable($path)) {
+            throw new \InvalidArgumentException('文件不可读:'.$path);
+        }
+
+        $raw = file_get_contents($path);
+        if ($raw === false || trim($raw) === '') {
+            throw new \InvalidArgumentException('文件为空:'.$path);
+        }
+
+        $trimmed = ltrim($raw);
+        if ($trimmed === '') {
+            return [];
+        }
+
+        if ($trimmed[0] === '[') {
+            $decoded = json_decode($raw, true);
+            if (! is_array($decoded)) {
+                throw new \InvalidArgumentException('JSON 解析失败(应为对象数组):'.json_last_error_msg());
+            }
+
+            return array_values(array_filter($decoded, static fn ($row) => is_array($row)));
+        }
+
+        // NDJSON:每行一个 JSON 对象
+        $lines = preg_split('/\r\n|\n|\r/', $raw) ?: [];
+        $rows = [];
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if ($line === '' || str_starts_with($line, '#')) {
+                continue;
+            }
+            $one = json_decode($line, true);
+            if (! is_array($one)) {
+                throw new \InvalidArgumentException('NDJSON 解析失败:'.$line);
+            }
+            $rows[] = $one;
+        }
+
+        return $rows;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @return array<string, mixed>
+     */
+    public function normalizeImportRow(array $row, int $lineIndex): array
+    {
+        $stem = (string) ($row['stem'] ?? $row['content'] ?? '');
+        $kp = isset($row['kp_code']) ? (string) $row['kp_code'] : '';
+
+        $code = isset($row['question_code']) ? trim((string) $row['question_code']) : '';
+        if ($code === '') {
+            $code = 'QI'.strtoupper(Str::random(12));
+        }
+
+        $options = $row['options'] ?? null;
+        if (is_string($options) && trim($options) !== '') {
+            $decoded = json_decode($options, true);
+            $options = is_array($decoded) ? $decoded : null;
+        }
+
+        $meta = $row['meta'] ?? null;
+        if (is_string($meta) && trim($meta) !== '') {
+            $decoded = json_decode($meta, true);
+            $meta = is_array($decoded) ? $decoded : null;
+        }
+
+        $out = [
+            'question_code' => $code,
+            'kp_code' => $kp !== '' ? $kp : null,
+            'stem' => $stem,
+            'options' => is_array($options) ? $options : null,
+            'answer' => isset($row['answer']) ? (string) $row['answer'] : null,
+            'solution' => isset($row['solution']) ? (string) $row['solution'] : null,
+            'difficulty' => isset($row['difficulty']) ? (float) $row['difficulty'] : null,
+            'question_type' => isset($row['question_type']) ? (string) $row['question_type'] : (isset($row['type']) ? (string) $row['type'] : null),
+            'source' => isset($row['source']) ? (string) $row['source'] : 'json_bulk_import',
+            'tags' => isset($row['tags']) ? (string) $row['tags'] : null,
+            'meta' => is_array($meta) ? $meta : null,
+            'textbook_id' => isset($row['textbook_id']) ? (int) $row['textbook_id'] : null,
+            'source_file_id' => isset($row['source_file_id']) ? (int) $row['source_file_id'] : null,
+            'source_paper_id' => isset($row['source_paper_id']) ? (int) $row['source_paper_id'] : null,
+            'paper_part_id' => isset($row['paper_part_id']) ? (int) $row['paper_part_id'] : null,
+            'kp_id' => isset($row['kp_id']) ? (string) $row['kp_id'] : null,
+            'kp_name' => isset($row['kp_name']) ? (string) $row['kp_name'] : null,
+            'kp_reference' => isset($row['kp_reference']) ? (string) $row['kp_reference'] : null,
+            'grade' => isset($row['grade']) ? (int) $row['grade'] : null,
+            'audit_status' => isset($row['audit_status']) ? (int) $row['audit_status'] : null,
+            'audit_reason' => isset($row['audit_reason']) ? (string) $row['audit_reason'] : null,
+            'title_1' => isset($row['title_1']) ? (string) $row['title_1'] : null,
+            'title_2' => isset($row['title_2']) ? (string) $row['title_2'] : null,
+            'title_3' => isset($row['title_3']) ? (string) $row['title_3'] : null,
+            'create_by' => isset($row['create_by']) ? (string) $row['create_by'] : null,
+            'textbook_catalog_nodes_id' => isset($row['textbook_catalog_nodes_id']) ? (int) $row['textbook_catalog_nodes_id'] : null,
+            'question_category' => isset($row['question_category']) ? (int) $row['question_category'] : null,
+            'step_num' => isset($row['step_num']) ? (int) $row['step_num'] : null,
+            'solution_temp' => isset($row['solution_temp']) ? (string) $row['solution_temp'] : null,
+            'solution_temp2' => isset($row['solution_temp2']) ? (string) $row['solution_temp2'] : null,
+        ];
+
+        if (isset($row['id'])) {
+            $out['_import_id'] = (int) $row['id'];
+        }
+
+        $out['_line'] = $lineIndex;
+
+        return $out;
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $rows  normalizeImportRow 的输出
+     * @return array{created: int, updated: int, skipped: int, errors: array<int, string>}
+     */
+    public function importToDatabase(array $rows, bool $dryRun = false): array
+    {
+        $created = 0;
+        $updated = 0;
+        $skipped = 0;
+        $errors = [];
+
+        if (! Schema::hasTable('questions')) {
+            return ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => ['questions 表不存在']];
+        }
+
+        foreach ($rows as $row) {
+            $line = (int) ($row['_line'] ?? 0);
+            try {
+                $stem = (string) ($row['stem'] ?? '');
+                $kp = (string) ($row['kp_code'] ?? '');
+                if ($stem === '' || $kp === '') {
+                    $skipped++;
+                    $errors[] = "第 {$line} 行:题干或 kp_code 为空,已跳过";
+
+                    continue;
+                }
+
+                $code = (string) ($row['question_code'] ?? '');
+                $payload = $this->filterToTableColumns($row);
+                unset($payload['_line'], $payload['_import_id']);
+
+                if ($dryRun) {
+                    $exists = Question::query()->where('question_code', $code)->exists();
+                    $exists ? $updated++ : $created++;
+
+                    continue;
+                }
+
+                $existing = Question::query()->where('question_code', $code)->first();
+                $question = Question::query()->firstOrNew(['question_code' => $code]);
+                $question->forceFill($payload);
+                $question->save();
+
+                if ($existing) {
+                    $updated++;
+                } else {
+                    $created++;
+                }
+            } catch (Throwable $e) {
+                $skipped++;
+                $errors[] = "第 {$line} 行:".$e->getMessage();
+            }
+        }
+
+        return compact('created', 'updated', 'skipped', 'errors');
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $rows
+     */
+    public function renderMysqlScript(array $rows, bool $includeId = false): string
+    {
+        $lines = [];
+        $lines[] = '-- 由 math_cms QuestionBulkImportService 生成';
+        $lines[] = '-- 在目标库执行前请确认 `questions` 表结构一致,并已备份。';
+        $lines[] = '-- 冲突键:`question_code`(UNIQUE),使用 ON DUPLICATE KEY UPDATE 合并更新。';
+        $lines[] = 'SET NAMES utf8mb4;';
+        $lines[] = '';
+
+        $cols = $this->questionsColumnList();
+        foreach ($rows as $row) {
+            $stem = (string) ($row['stem'] ?? '');
+            if ($stem === '') {
+                continue;
+            }
+
+            $filtered = $this->filterToTableColumns($row);
+            if ($includeId && isset($row['_import_id'])) {
+                $filtered['id'] = (int) $row['_import_id'];
+            }
+
+            $sql = $this->buildSingleUpsertSql($filtered, $cols, $includeId);
+
+            if ($sql !== '') {
+                $lines[] = $sql;
+                $lines[] = '';
+            }
+        }
+
+        return rtrim(implode("\n", $lines))."\n";
+    }
+
+    /**
+     * @param  array<int>  $ids
+     */
+    public function exportIdsToMysqlScript(array $ids, bool $includeId = false): string
+    {
+        if ($ids === []) {
+            return "-- 无 id\n";
+        }
+
+        $questions = Question::query()->whereIn('id', $ids)->orderBy('id')->get();
+        $rows = [];
+        foreach ($questions as $q) {
+            $arr = $q->toArray();
+            $arr['_line'] = 0;
+            $rows[] = $this->normalizeImportRow($arr, 0);
+        }
+
+        return $this->renderMysqlScript($rows, $includeId);
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  array<int, string>  $tableColumns
+     */
+    private function buildSingleUpsertSql(array $row, array $tableColumns, bool $includeId): string
+    {
+        $assign = [];
+        foreach ($row as $key => $value) {
+            if (str_starts_with($key, '_')) {
+                continue;
+            }
+            if (! in_array($key, $tableColumns, true)) {
+                continue;
+            }
+            if ($key === 'id' && ! $includeId) {
+                continue;
+            }
+            $assign[$key] = $value;
+        }
+
+        if (! isset($assign['question_code']) || $assign['question_code'] === '') {
+            return '';
+        }
+
+        $assign = array_filter($assign, static fn ($v) => $v !== null);
+
+        $now = date('Y-m-d H:i:s');
+        if (! array_key_exists('created_at', $assign)) {
+            $assign['created_at'] = $now;
+        }
+        if (! array_key_exists('updated_at', $assign)) {
+            $assign['updated_at'] = $now;
+        }
+
+        $columns = array_keys($assign);
+        $values = [];
+        foreach ($columns as $col) {
+            $values[] = $this->quoteSqlValue($assign[$col], $col);
+        }
+
+        $colList = implode('`, `', $columns);
+        $valList = implode(', ', $values);
+
+        $updates = [];
+        foreach ($columns as $col) {
+            if ($col === 'question_code' || $col === 'id' || $col === 'created_at') {
+                continue;
+            }
+            $updates[] = '`'.$col.'` = VALUES(`'.$col.'`)';
+        }
+
+        if ($updates === []) {
+            $updates[] = '`updated_at` = VALUES(`updated_at`)';
+        }
+
+        return 'INSERT INTO `questions` (`'.$colList.'`) VALUES ('.$valList.') ON DUPLICATE KEY UPDATE '.implode(', ', $updates).';';
+    }
+
+    /**
+     * @param  mixed  $value
+     */
+    private function quoteSqlValue($value, string $column): string
+    {
+        if ($value === null) {
+            return 'NULL';
+        }
+
+        if ($value instanceof DateTimeInterface) {
+            return $this->pdoQuote($value->format('Y-m-d H:i:s'));
+        }
+
+        $jsonCols = ['options', 'meta'];
+        if (in_array($column, $jsonCols, true)) {
+            if (is_array($value)) {
+                $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+            }
+
+            return $this->pdoQuote((string) $value);
+        }
+
+        if (is_bool($value)) {
+            return $value ? '1' : '0';
+        }
+
+        if (is_int($value) || is_float($value)) {
+            return (string) $value;
+        }
+
+        return $this->pdoQuote((string) $value);
+    }
+
+    private function pdoQuote(string $value): string
+    {
+        $pdo = DB::connection()->getPdo();
+
+        return $pdo->quote($value);
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @return array<string, mixed>
+     */
+    private function filterToTableColumns(array $row): array
+    {
+        $cols = array_flip($this->questionsColumnList());
+        $out = [];
+        foreach ($row as $k => $v) {
+            if ($k === '_line' || $k === '_import_id') {
+                continue;
+            }
+            if (! isset($cols[$k])) {
+                continue;
+            }
+            if ($k === 'id') {
+                continue;
+            }
+            $out[$k] = $v;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function questionsColumnList(): array
+    {
+        if ($this->questionsColumns !== null) {
+            return $this->questionsColumns;
+        }
+
+        if (! Schema::hasTable('questions')) {
+            $this->questionsColumns = [];
+
+            return $this->questionsColumns;
+        }
+
+        $this->questionsColumns = Schema::getColumnListing('questions');
+
+        return $this->questionsColumns;
+    }
+
+    /**
+     * 从本地可读文件路径执行:解析 JSON → 写 SQL → 可选写入 questions。
+     *
+     * @return array{ok: bool, sql_path?: string, message?: string, stats?: array{created: int, updated: int, skipped: int, errors: array<int, string>}, error?: string}
+     */
+    public function runImportPipeline(
+        string $absolutePath,
+        bool $dryRun,
+        bool $sqlOnly,
+        bool $withId
+    ): array {
+        try {
+            $rawRows = $this->loadRowsFromFile($absolutePath);
+        } catch (\Throwable $e) {
+            return ['ok' => false, 'error' => $e->getMessage()];
+        }
+
+        $rows = [];
+        foreach ($rawRows as $i => $raw) {
+            if (! is_array($raw)) {
+                continue;
+            }
+            $rows[] = $this->normalizeImportRow($raw, $i + 1);
+        }
+
+        if ($rows === []) {
+            return ['ok' => false, 'error' => '未解析到题目'];
+        }
+
+        $outPath = storage_path('app/exports/questions_import_'.date('YmdHis').'.sql');
+        File::ensureDirectoryExists(dirname($outPath));
+        File::put($outPath, $this->renderMysqlScript($rows, $withId));
+
+        if ($sqlOnly) {
+            return [
+                'ok' => true,
+                'sql_path' => $outPath,
+                'message' => '已生成 SQL(未写本地库)。',
+            ];
+        }
+
+        $stats = $this->importToDatabase($rows, $dryRun);
+        $message = sprintf(
+            '新建 %d,更新 %d,跳过 %d。SQL:%s',
+            $stats['created'],
+            $stats['updated'],
+            $stats['skipped'],
+            $outPath
+        );
+
+        return [
+            'ok' => true,
+            'sql_path' => $outPath,
+            'message' => $message,
+            'stats' => $stats,
+        ];
+    }
+}

+ 289 - 13
app/Services/QuestionQualityCheckService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Schema;
@@ -9,35 +10,51 @@ use Illuminate\Support\Facades\Schema;
 /**
  * 题目质检服务
  *
- * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
+ * 校验规则:题干、答案、解析、选项、公式、PDF 呈现、AI 答案校验
  * 结果由命令输出,不落库(避免本地库覆盖)
  */
 class QuestionQualityCheckService
 {
+    private ?AiClientService $aiClient = null;
+
+    public function __construct(?AiClientService $aiClient = null)
+    {
+        $this->aiClient = $aiClient ?? (app()->bound(AiClientService::class) ? app(AiClientService::class) : null);
+    }
     public const RULES = [
         'STEM_EMPTY' => ['name' => '题干为空', 'severity' => 'error'],
         'ANSWER_EMPTY' => ['name' => '答案为空', 'severity' => 'error'],
         'SOLUTION_EMPTY' => ['name' => '解析为空', 'severity' => 'warning'],
         'CHOICE_OPTIONS_MISSING' => ['name' => '选择题缺选项', 'severity' => 'error'],
+        'CHOICE_OPTIONS_JSON_INVALID' => ['name' => '选择题选项JSON无效', 'severity' => 'error'],
+        'CHOICE_OPTION_TEXT_EMPTY' => ['name' => '选择题存在空白选项', 'severity' => 'warning'],
+        'ANSWER_OPTION_MISMATCH' => ['name' => '选择题答案不在选项中', 'severity' => 'error'],
         'FORMULA_INVALID' => ['name' => '公式异常', 'severity' => 'error'],
         'CONTENT_TOO_SHORT' => ['name' => '题干过短', 'severity' => 'warning'],
+        'AI_ANSWER_INVALID' => ['name' => 'AI 判定答案错误', 'severity' => 'error'],
+        'AI_ANSWER_MISMATCH' => ['name' => 'AI 判定答案与题目不匹配', 'severity' => 'error'],
     ];
 
     /**
      * 对单道题目执行自动质检
      *
-     * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options
+     * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options;
+     *                         可选 options_json_invalid=true(options 字段为字符串但 JSON 解析失败)
      * @param int|null $questionTemId questions_tem 表 ID
      * @param int|null $questionId questions 表 ID
+     * @param array $options ['ai_check' => bool] 是否启用 AI 校验(答案正确性、与题目匹配)
      * @return array ['passed' => bool, 'results' => array, 'errors' => array]
      */
-    public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null): array
+    public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null, array $options = []): array
     {
         $stem = $question['stem'] ?? $question['content'] ?? '';
         $answer = $question['answer'] ?? '';
         $solution = $question['solution'] ?? '';
-        $questionType = $question['question_type'] ?? ($question['tags'] ?? '');
+        $questionType = $this->normalizeQuestionType(
+            (string) ($question['question_type'] ?? $question['tags'] ?? '')
+        );
         $options = $question['options'] ?? null;
+        $optionsJsonInvalid = ! empty($question['options_json_invalid']);
 
         $results = [];
         $errors = [];
@@ -63,7 +80,7 @@ class QuestionQualityCheckService
         }
 
         // SOLUTION_EMPTY(解答题强校验)
-        $isAnswerType = in_array(strtolower((string) $questionType), ['answer', '解答题', '解答'], true);
+        $isAnswerType = $questionType === 'answer';
         if ($isAnswerType && trim((string) $solution) === '') {
             $results[] = $this->recordCheck('SOLUTION_EMPTY', false, '解答题解析为空');
             $errors[] = 'SOLUTION_EMPTY';
@@ -71,21 +88,59 @@ class QuestionQualityCheckService
             $results[] = $this->recordCheck('SOLUTION_EMPTY', true);
         }
 
-        // CHOICE_OPTIONS_MISSING
-        $isChoice = in_array(strtolower((string) $questionType), ['choice', '选择题', 'select'], true);
+        // CHOICE_OPTIONS_*(与 PDF 导出口径:选项 JSON、非空文案数量)
+        $isChoice = $questionType === 'choice';
         if ($isChoice) {
-            $optsOk = is_array($options) && count($options) >= 2;
-            if (!$optsOk) {
-                $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题选项为空或不足2个');
-                $errors[] = 'CHOICE_OPTIONS_MISSING';
+            if ($optionsJsonInvalid) {
+                $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', false, 'options 字段不是合法 JSON');
+                $errors[] = 'CHOICE_OPTIONS_JSON_INVALID';
+                $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
+                $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip');
             } else {
-                $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true);
+                $optionTexts = $this->extractOptionTexts(is_array($options) ? $options : null);
+                $nonEmpty = array_values(array_filter($optionTexts, fn ($t) => mb_strlen(trim((string) $t)) > 0));
+
+                if (count($nonEmpty) < 2) {
+                    $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题有效选项不足2个');
+                    $errors[] = 'CHOICE_OPTIONS_MISSING';
+                } else {
+                    $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true);
+                }
+
+                $hasEmptySlot = count($optionTexts) > 0
+                    && count($nonEmpty) < count($optionTexts);
+                if ($hasEmptySlot) {
+                    $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', false, '存在空白选项项');
+                    $errors[] = 'CHOICE_OPTION_TEXT_EMPTY';
+                } else {
+                    $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true);
+                }
             }
         } else {
             $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
+            $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', true, null, 'skip');
+            $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip');
         }
 
-        // FORMULA_INVALID(尝试处理公式,捕获异常)
+        // ANSWER_OPTION_MISMATCH:选择题答案必须在选项中
+        if ($isChoice && ! $optionsJsonInvalid && is_array($options)) {
+            $answerLetter = $this->extractChoiceAnswerLetter((string) $answer);
+            $optionKeysRaw = array_keys($options);
+            $optionLetters = array_map(fn ($k) => strtoupper(substr((string) $k, 0, 1)), $optionKeysRaw);
+            if ($answerLetter !== null && ! in_array($answerLetter, $optionLetters, true)) {
+                $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, "答案 {$answerLetter} 不在选项 " . implode(',', $optionLetters) . " 中");
+                $errors[] = 'ANSWER_OPTION_MISMATCH';
+            } elseif ($answerLetter === null && trim((string) $answer) !== '') {
+                $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, '答案格式无法识别为选项(应为 A/B/C/D)');
+                $errors[] = 'ANSWER_OPTION_MISMATCH';
+            } else {
+                $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', true);
+            }
+        } else {
+            $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', true, null, 'skip');
+        }
+
+        // FORMULA_INVALID(题干、答案、解析、选择题各选项;捕获异常)
         try {
             $processed = MathFormulaProcessor::processFormulas($stem);
             $processedAnswer = MathFormulaProcessor::processFormulas($answer);
@@ -95,6 +150,19 @@ class QuestionQualityCheckService
                 || $this->detectFormulaError($processedAnswer)
                 || $this->detectFormulaError($processedSolution);
 
+            if ($isChoice && ! $optionsJsonInvalid && is_array($options)) {
+                foreach ($this->extractOptionTexts($options) as $optText) {
+                    if (trim((string) $optText) === '') {
+                        continue;
+                    }
+                    $po = MathFormulaProcessor::processFormulas((string) $optText);
+                    if ($this->detectFormulaError($po)) {
+                        $hasError = true;
+                        break;
+                    }
+                }
+            }
+
             if ($hasError) {
                 $results[] = $this->recordCheck('FORMULA_INVALID', false, '公式定界符不匹配或存在异常');
                 $errors[] = 'FORMULA_INVALID';
@@ -106,6 +174,38 @@ class QuestionQualityCheckService
             $errors[] = 'FORMULA_INVALID';
         }
 
+        // AI 校验:答案正确性、答案与题目匹配(需开启 ai_check,且基础校验通过时再调 AI)
+        $aiCheck = $options['ai_check'] ?? false;
+        if ($aiCheck && $this->aiClient && empty($errors) && trim((string) $answer) !== '') {
+            try {
+                $aiResult = $this->runAiAnswerCheck($question);
+                if (is_array($aiResult)) {
+                    if (! ($aiResult['answer_correct'] ?? true)) {
+                        $results[] = $this->recordCheck('AI_ANSWER_INVALID', false, $aiResult['reason'] ?? 'AI 判定答案错误');
+                        $errors[] = 'AI_ANSWER_INVALID';
+                    } else {
+                        $results[] = $this->recordCheck('AI_ANSWER_INVALID', true);
+                    }
+                    if (! ($aiResult['answer_matches_question'] ?? true)) {
+                        $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', false, $aiResult['reason'] ?? 'AI 判定答案与题目不匹配');
+                        $errors[] = 'AI_ANSWER_MISMATCH';
+                    } else {
+                        $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true);
+                    }
+                } else {
+                    $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
+                    $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
+                }
+            } catch (\Throwable $e) {
+                Log::warning('QuestionQualityCheckService: AI 校验异常', ['error' => $e->getMessage()]);
+                $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
+                $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
+            }
+        } else {
+            $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
+            $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
+        }
+
         $passed = empty($errors);
 
         return [
@@ -115,6 +215,65 @@ class QuestionQualityCheckService
         ];
     }
 
+    /**
+     * 与组卷/PDF 口径一致:归一化为 choice | fill | answer
+     */
+    private function normalizeQuestionType(string $raw): string
+    {
+        $t = strtolower(trim($raw));
+        if ($t === '') {
+            return 'answer';
+        }
+
+        return match (true) {
+            str_contains($t, 'choice') || str_contains($t, '选择') => 'choice',
+            str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill',
+            in_array($t, ['single_choice', 'multiple_choice', 'select'], true) => 'choice',
+            in_array($t, ['fill_in_the_blank'], true) => 'fill',
+            in_array($t, ['answer', 'calculation', 'word_problem', 'proof', '解答', '简答'], true) => 'answer',
+            default => 'answer',
+        };
+    }
+
+    /**
+     * 将 options 转为选项文案列表(与 ExamPdfController::normalizeOptions 语义对齐)
+     *
+     * @param array|null $options
+     * @return list<string>
+     */
+    private function extractOptionTexts(?array $options): array
+    {
+        if ($options === null || $options === []) {
+            return [];
+        }
+
+        if (! isset($options[0]) && $options !== []) {
+            return array_values(array_map(static fn ($v) => (string) $v, $options));
+        }
+
+        if (isset($options[0]) && is_array($options[0])) {
+            $normalized = [];
+            foreach ($options as $opt) {
+                if (! is_array($opt)) {
+                    $normalized[] = (string) $opt;
+
+                    continue;
+                }
+                if (isset($opt['text'])) {
+                    $normalized[] = (string) $opt['text'];
+                } elseif (isset($opt['value'])) {
+                    $normalized[] = (string) $opt['value'];
+                } else {
+                    $normalized[] = (string) reset($opt);
+                }
+            }
+
+            return $normalized;
+        }
+
+        return array_values(array_map(static fn ($v) => (string) $v, $options));
+    }
+
     /**
      * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $)
      */
@@ -137,6 +296,66 @@ class QuestionQualityCheckService
         return ($dollarCount % 2) !== 0;
     }
 
+    /**
+     * 提取选择题答案字母(A/B/C/D)
+     */
+    private function extractChoiceAnswerLetter(string $answer): ?string
+    {
+        $answer = trim($answer);
+        if (preg_match('/^([A-D])$/i', $answer, $m)) {
+            return strtoupper($m[1]);
+        }
+        if (preg_match('/答案[::]\s*([A-D])/iu', $answer, $m)) {
+            return strtoupper($m[1]);
+        }
+        if (preg_match('/([A-D])[\.、.:]/u', $answer, $m)) {
+            return strtoupper($m[1]);
+        }
+        if (preg_match('/([A-D])/i', $answer, $m)) {
+            return strtoupper($m[1]);
+        }
+
+        return null;
+    }
+
+    /**
+     * AI 校验:答案是否正确、是否与题目匹配
+     */
+    private function runAiAnswerCheck(array $question): ?array
+    {
+        $stem = $question['stem'] ?? $question['content'] ?? '';
+        $answer = $question['answer'] ?? '';
+        $solution = $question['solution'] ?? '';
+        $questionType = $question['question_type'] ?? ($question['tags'] ?? 'answer');
+        $options = $question['options'] ?? null;
+
+        $optionsStr = '无';
+        if (is_array($options)) {
+            $parts = [];
+            foreach ($options as $k => $v) {
+                $v = is_string($v) ? $v : json_encode($v);
+                $parts[] = "{$k}: " . mb_substr($v, 0, 200);
+            }
+            $optionsStr = implode("\n", $parts);
+        } elseif (is_string($options)) {
+            $optionsStr = mb_substr($options, 0, 500);
+        }
+
+        $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', '')
+        );
+
+        if ($prompt === '') {
+            return null;
+        }
+
+        $data = $this->aiClient->callJson($prompt);
+
+        return is_array($data) && isset($data['answer_correct']) ? $data : null;
+    }
+
     private function recordCheck(string $ruleCode, bool $passed, ?string $detail = null, string $result = 'pass'): array
     {
         $info = self::RULES[$ruleCode] ?? ['name' => $ruleCode, 'severity' => 'error'];
@@ -209,4 +428,61 @@ class QuestionQualityCheckService
         usort($result, fn ($a, $b) => $a['question_count'] <=> $b['question_count']);
         return array_slice($result, 0, $limit);
     }
+
+    /**
+     * 获取待质检题目(与 questions 不重复),按知识点分组
+     * 用于按 KP 生成 PDF
+     *
+     * @param string $table 待质检表名,默认 questions_tem
+     * @param int|null $textbookId 教材 ID
+     * @param int $semesterCode 学期 1=上 2=下
+     * @param int $kpLimit 取前 N 个题少的 KP(当 $singleKp 为 null 时生效)
+     * @param int|null $perKpLimit 每个 KP 最多取题数,null 不限制
+     * @param string|null $singleKp 指定单个 KP,则只返回该 KP
+     * @return array<string, Collection> [kp_code => Collection of question rows]
+     */
+    public function getQcQuestionsGroupedByKp(
+        string $table = 'questions_tem',
+        ?int $textbookId = null,
+        int $semesterCode = 2,
+        int $kpLimit = 10,
+        ?int $perKpLimit = null,
+        ?string $singleKp = null
+    ): array {
+        if (! Schema::hasTable($table)) {
+            return [];
+        }
+
+        $kpCodes = $singleKp
+            ? [$singleKp]
+            : array_column($this->getKpsWithFewQuestions($textbookId, $semesterCode, $kpLimit), 'kp_code');
+        if (empty($kpCodes)) {
+            return [];
+        }
+        $query = DB::table($table)->whereIn('kp_code', $kpCodes);
+
+        if ($table === 'questions_tem'
+            && Schema::hasTable('questions')
+            && Schema::hasColumn($table, 'question_code')
+            && Schema::hasColumn('questions', 'question_code')
+        ) {
+            $query->whereNotIn('question_code', DB::table('questions')->select('question_code'));
+        }
+
+        $all = $query->orderBy('kp_code')->orderBy('id')->get();
+        $grouped = [];
+        foreach ($all as $row) {
+            $kp = $row->kp_code ?? '';
+            if ($kp === '') {
+                continue;
+            }
+            if ($perKpLimit !== null && isset($grouped[$kp]) && $grouped[$kp]->count() >= $perKpLimit) {
+                continue;
+            }
+            $grouped[$kp] ??= new Collection;
+            $grouped[$kp]->push($row);
+        }
+
+        return $grouped;
+    }
 }

+ 405 - 0
app/Services/QuestionTemReviewService.php

@@ -0,0 +1,405 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Question;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
+
+/**
+ * questions_tem 质检入库页:知识点排序、PDF 口径预览、写入 questions
+ */
+class QuestionTemReviewService
+{
+    /** Session 键:供「入库题目调难度」页列举的本轮已入库 question.id */
+    public const SESSION_TUNING_QUESTION_IDS = 'import_difficulty_tune_question_ids';
+
+    /**
+     * 将成功入库的正式题 ID 合并进会话,供调难度页使用。
+     *
+     * @param  list<int>  $questionIds
+     */
+    public static function mergeQuestionIdsIntoTuningSession(array $questionIds): void
+    {
+        $questionIds = array_values(array_unique(array_filter(array_map('intval', $questionIds))));
+        if ($questionIds === []) {
+            return;
+        }
+        $existing = session(self::SESSION_TUNING_QUESTION_IDS, []);
+        if (! is_array($existing)) {
+            $existing = [];
+        }
+        session([
+            self::SESSION_TUNING_QUESTION_IDS => array_values(array_unique(array_merge($existing, $questionIds))),
+        ]);
+    }
+
+    /**
+     * 左侧:按 questions 表中该知识点正式题数量升序(题少的在前),仅包含 questions_tem 中出现过的 kp_code
+     *
+     * @param  ?int  $limit  为 null 时不截断(质检页需完整列表 + 搜索,否则题量大的 KP 如 B01 会落在 500 条之后而无法检索)
+     * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int}>
+     */
+    public function listKnowledgePointsByQuestionsAsc(?int $limit = null): array
+    {
+        if (! Schema::hasTable('questions_tem')) {
+            return [];
+        }
+
+        $temKps = DB::table('questions_tem')
+            ->whereNotNull('kp_code')
+            ->where('kp_code', '!=', '')
+            ->distinct()
+            ->pluck('kp_code')
+            ->all();
+
+        if ($temKps === []) {
+            return [];
+        }
+
+        $kpNames = [];
+        if (Schema::hasTable('knowledge_points')) {
+            $kpNames = DB::table('knowledge_points')
+                ->whereIn('kp_code', $temKps)
+                ->pluck('name', 'kp_code')
+                ->toArray();
+        }
+
+        $counts = [];
+        if (Schema::hasTable('questions')) {
+            $counts = DB::table('questions')
+                ->whereIn('kp_code', $temKps)
+                ->selectRaw('kp_code, COUNT(*) as c')
+                ->groupBy('kp_code')
+                ->pluck('c', 'kp_code')
+                ->toArray();
+        }
+
+        $temCounts = DB::table('questions_tem')
+            ->whereIn('kp_code', $temKps)
+            ->selectRaw('kp_code, COUNT(*) as c')
+            ->groupBy('kp_code')
+            ->pluck('c', 'kp_code')
+            ->toArray();
+
+        $rows = [];
+        foreach ($temKps as $kp) {
+            $name = isset($kpNames[$kp]) ? trim((string) $kpNames[$kp]) : '';
+            $rows[] = [
+                'kp_code' => $kp,
+                'kp_name' => $name,
+                'questions_count' => (int) ($counts[$kp] ?? 0),
+                'tem_count' => (int) ($temCounts[$kp] ?? 0),
+            ];
+        }
+
+        usort($rows, function ($a, $b) {
+            if ($a['questions_count'] === $b['questions_count']) {
+                return strcmp($a['kp_code'], $b['kp_code']);
+            }
+
+            return $a['questions_count'] <=> $b['questions_count'];
+        });
+
+        if ($limit !== null && $limit > 0) {
+            return array_slice($rows, 0, $limit);
+        }
+
+        return $rows;
+    }
+
+    /**
+     * 与入库、判重一致:questions_tem 行用于比对的题干(stem 优先,否则 content)
+     */
+    public function normalizedStemFromTemRow(object|array $row): string
+    {
+        $arr = is_array($row) ? $row : (array) $row;
+
+        return (string) ($arr['stem'] ?? $arr['content'] ?? '');
+    }
+
+    /**
+     * 中间:某知识点下 questions_tem 题目(限制条数)
+     *
+     * @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
+    {
+        if (! Schema::hasTable('questions_tem') || $kpCode === '') {
+            return [];
+        }
+
+        $formalStemSet = [];
+        if ($excludeFormalDuplicates && Schema::hasTable('questions')) {
+            foreach (DB::table('questions')->where('kp_code', $kpCode)->pluck('stem') as $stem) {
+                if ($stem === null || $stem === '') {
+                    continue;
+                }
+                $formalStemSet[(string) $stem] = true;
+            }
+        }
+
+        $out = [];
+        $q = DB::table('questions_tem')->where('kp_code', $kpCode)->orderBy('id');
+        foreach ($q->lazyById(200) as $row) {
+            if ($excludeFormalDuplicates && $formalStemSet !== []) {
+                $stem = $this->normalizedStemFromTemRow($row);
+                if ($stem !== '' && isset($formalStemSet[$stem])) {
+                    continue;
+                }
+            }
+            $out[] = $row;
+            if (count($out) >= $limit) {
+                break;
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * 与 ExamPdfExportService::renderPreviewHtml 一致:公式预处理 + 解析换行,供页面 KaTeX 渲染
+     *
+     * @param  array<string, mixed>  $row  questions_tem 一行转数组
+     * @return array{stem: string, options: ?array, answer: string, solution: string, question_type: string}
+     */
+    public function buildPdfStylePreviewFields(array $row): array
+    {
+        $stem = (string) ($row['stem'] ?? $row['content'] ?? '');
+        $answer = (string) ($row['answer'] ?? $row['correct_answer'] ?? '');
+        $solution = (string) ($row['solution'] ?? '');
+        $questionType = strtolower((string) ($row['question_type'] ?? $row['tags'] ?? 'answer'));
+
+        $options = $row['options'] ?? null;
+        if (is_string($options) && trim($options) !== '') {
+            $decoded = json_decode($options, true);
+            $options = is_array($decoded) ? $decoded : null;
+        }
+
+        $processedStem = MathFormulaProcessor::processFormulas($stem);
+        $processedAnswer = MathFormulaProcessor::processFormulas($answer);
+        $processedSolution = MathFormulaProcessor::processFormulas($this->formatNewlinesForPdf($solution));
+
+        $processedOptions = null;
+        if (is_array($options)) {
+            $processedOptions = [];
+            foreach ($options as $key => $value) {
+                if (is_array($value)) {
+                    $text = (string) ($value['text'] ?? $value['value'] ?? reset($value) ?? '');
+                    $processedOptions[$key] = MathFormulaProcessor::processFormulas($text);
+                } else {
+                    $processedOptions[$key] = MathFormulaProcessor::processFormulas((string) $value);
+                }
+            }
+        }
+
+        return [
+            'stem' => $processedStem,
+            'options' => $processedOptions,
+            'answer' => $processedAnswer,
+            'solution' => $processedSolution,
+            'question_type' => $questionType,
+        ];
+    }
+
+    private function formatNewlinesForPdf(?string $text): string
+    {
+        if ($text === null || $text === '') {
+            return '';
+        }
+        $text = preg_replace('/\\\\n(?![a-zA-Z])/', '<br>', $text);
+
+        return (string) preg_replace('/(<br>\s*){3,}/', '<br><br>', $text);
+    }
+
+    /**
+     * 是否已在 questions 中存在(同 kp + 题干完全一致则视为重复)
+     */
+    public function existsDuplicateInQuestions(string $kpCode, string $stem): bool
+    {
+        if ($stem === '' || ! Schema::hasTable('questions')) {
+            return false;
+        }
+
+        return Question::query()
+            ->where('kp_code', $kpCode)
+            ->where('stem', $stem)
+            ->exists();
+    }
+
+    /**
+     * 待审行默认难度:与入库写入规则一致,限制在 [0.00, 0.90] 并保留两位小数
+     *
+     * @param  object|array<string, mixed>  $row
+     */
+    public function defaultDifficultyForTemRow(object|array $row): float
+    {
+        $arr = is_array($row) ? $row : (array) $row;
+        $d = 0.5;
+        if (array_key_exists('difficulty', $arr) && $arr['difficulty'] !== null && $arr['difficulty'] !== '') {
+            $d = (float) $arr['difficulty'];
+        }
+
+        return max(0.0, min(0.9, round($d, 2)));
+    }
+
+    /**
+     * 将 questions_tem 一行写入 questions(入库)
+     *
+     * @param  ?float  $difficultyOverride  若传入则作为 questions.difficulty(仍限制 0.00–0.90、两位小数);null 时按表内字段或默认 0.5
+     * @return array{ok: bool, message: string, question_id: ?int}
+     */
+    public function importTemRowToQuestions(int $temId, ?float $difficultyOverride = null): array
+    {
+        if (! Schema::hasTable('questions_tem')) {
+            return ['ok' => false, 'message' => 'questions_tem 表不存在', 'question_id' => null];
+        }
+
+        $row = DB::table('questions_tem')->where('id', $temId)->first();
+        if (! $row) {
+            return ['ok' => false, 'message' => '待入库题目不存在', 'question_id' => null];
+        }
+
+        $arr = (array) $row;
+        $stem = $this->normalizedStemFromTemRow($arr);
+        $kp = (string) ($arr['kp_code'] ?? '');
+        if ($stem === '' || $kp === '') {
+            return ['ok' => false, 'message' => '题干或知识点为空', 'question_id' => null];
+        }
+
+        if ($this->existsDuplicateInQuestions($kp, $stem)) {
+            return ['ok' => false, 'message' => '正式库已存在相同知识点且题干一致的题目', 'question_id' => null];
+        }
+
+        $options = $arr['options'] ?? null;
+        if (is_string($options) && trim($options) !== '') {
+            $decoded = json_decode($options, true);
+            $options = is_array($decoded) ? $decoded : null;
+        }
+
+        $questionType = $this->normalizeQuestionTypeForDb($arr['question_type'] ?? $arr['tags'] ?? 'answer');
+
+        $difficulty = $difficultyOverride !== null
+            ? max(0.0, min(0.9, round($difficultyOverride, 2)))
+            : $this->defaultDifficultyForTemRow($arr);
+
+        $payload = [
+            'question_code' => 'QT'.strtoupper(Str::random(12)),
+            'question_type' => $questionType,
+            'kp_code' => $kp,
+            'stem' => $stem,
+            'options' => $options,
+            'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
+            'solution' => (string) ($arr['solution'] ?? ''),
+            'difficulty' => $difficulty,
+            'source' => 'questions_tem_review',
+            'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : null,
+            'meta' => [
+                'imported_from' => 'questions_tem',
+                'questions_tem_id' => $temId,
+            ],
+        ];
+
+        if (isset($arr['textbook_id'])) {
+            $payload['textbook_id'] = (int) $arr['textbook_id'];
+        }
+
+        try {
+            $question = Question::query()->create($payload);
+
+            $updates = [];
+            if (Schema::hasColumn('questions', 'audit_status')) {
+                $updates['audit_status'] = 0;
+            }
+            if (Schema::hasColumn('questions', 'grade') && isset($arr['grade'])) {
+                $updates['grade'] = (int) $arr['grade'];
+            }
+            if ($updates !== []) {
+                DB::table('questions')->where('id', $question->id)->update($updates);
+            }
+
+            return [
+                'ok' => true,
+                'message' => '已入库',
+                'question_id' => (int) $question->id,
+            ];
+        } catch (\Throwable $e) {
+            return [
+                'ok' => false,
+                'message' => '入库失败:'.$e->getMessage(),
+                'question_id' => null,
+            ];
+        }
+    }
+
+    /**
+     * 批量将 questions_tem 行写入 questions(每行逻辑与 importTemRowToQuestions 相同)
+     *
+     * @param  list<int>  $temIds
+     * @return array{imported: int, skipped: int, failed: int, lines: list<string>}
+     */
+    /**
+     * @return array{imported: int, skipped: int, failed: int, lines: list<string>, imported_question_ids: list<int>}
+     */
+    public function importTemIdsToQuestions(array $temIds): array
+    {
+        $imported = 0;
+        $skipped = 0;
+        $failed = 0;
+        $lines = [];
+        $importedQuestionIds = [];
+
+        foreach (array_unique(array_filter(array_map('intval', $temIds))) as $id) {
+            if ($id <= 0) {
+                continue;
+            }
+
+            $r = $this->importTemRowToQuestions($id);
+            if ($r['ok']) {
+                $imported++;
+                if (! empty($r['question_id'])) {
+                    $importedQuestionIds[] = (int) $r['question_id'];
+                }
+
+                continue;
+            }
+
+            $msg = $r['message'];
+            if (
+                str_contains($msg, '正式库已存在')
+                || str_contains($msg, '题干或知识点为空')
+            ) {
+                $skipped++;
+            } else {
+                $failed++;
+            }
+
+            if (count($lines) < 30) {
+                $lines[] = "#{$id}: {$msg}";
+            }
+        }
+
+        return [
+            'imported' => $imported,
+            'skipped' => $skipped,
+            'failed' => $failed,
+            'lines' => $lines,
+            'imported_question_ids' => $importedQuestionIds,
+        ];
+    }
+
+    private function normalizeQuestionTypeForDb(mixed $raw): string
+    {
+        $t = strtolower(trim((string) $raw));
+        if (str_contains($t, 'choice') || str_contains($t, '选择')) {
+            return 'choice';
+        }
+        if (str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空')) {
+            return 'fill';
+        }
+
+        return 'answer';
+    }
+}

+ 163 - 0
app/Services/QuestionsTemAssemblyService.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Services;
+
+use App\Http\Controllers\ExamPdfController;
+use App\Models\Paper;
+use App\Models\PaperQuestion;
+use App\Models\User;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
+
+/**
+ * 待入库题目加入组卷队列(不写 questions 表),生成临时试卷以走判卷 PDF 链路验版式
+ */
+class QuestionsTemAssemblyService
+{
+    public function tableExists(): bool
+    {
+        return Schema::hasTable('questions_tem_assembly_queue');
+    }
+
+    public function queueForUser(int $userId): array
+    {
+        if (! $this->tableExists() || ! Schema::hasTable('questions_tem')) {
+            return [];
+        }
+
+        $rows = DB::table('questions_tem as q')
+            ->join('questions_tem_assembly_queue as aq', 'q.id', '=', 'aq.questions_tem_id')
+            ->where('aq.user_id', $userId)
+            ->orderBy('aq.sort_order')
+            ->orderBy('aq.id')
+            ->select('q.*')
+            ->get();
+
+        return $rows->all();
+    }
+
+    public function add(int $userId, int $temId): void
+    {
+        if (! $this->tableExists() || ! Schema::hasTable('questions_tem')) {
+            return;
+        }
+
+        if (! DB::table('questions_tem')->where('id', $temId)->exists()) {
+            return;
+        }
+
+        if (DB::table('questions_tem_assembly_queue')
+            ->where('user_id', $userId)
+            ->where('questions_tem_id', $temId)
+            ->exists()) {
+            return;
+        }
+
+        $max = (int) DB::table('questions_tem_assembly_queue')->where('user_id', $userId)->max('sort_order');
+
+        DB::table('questions_tem_assembly_queue')->insert([
+            'user_id' => $userId,
+            'questions_tem_id' => $temId,
+            'sort_order' => $max + 1,
+            'created_at' => now(),
+            'updated_at' => now(),
+        ]);
+    }
+
+    public function remove(int $userId, int $temId): void
+    {
+        if (! $this->tableExists()) {
+            return;
+        }
+
+        DB::table('questions_tem_assembly_queue')
+            ->where('user_id', $userId)
+            ->where('questions_tem_id', $temId)
+            ->delete();
+    }
+
+    public function clear(int $userId): void
+    {
+        if (! $this->tableExists()) {
+            return;
+        }
+
+        DB::table('questions_tem_assembly_queue')->where('user_id', $userId)->delete();
+    }
+
+    /**
+     * 创建临时试卷并写入 paper_questions(question_bank_id 为负 tem id,判卷页会从 questions_tem 还原选项)
+     */
+    public function createTrialPaperForQueue(int $userId): ?string
+    {
+        $rows = $this->queueForUser($userId);
+        if ($rows === []) {
+            return null;
+        }
+
+        $user = User::query()->find($userId);
+        $studentKey = (string) ($user?->username ?? 'preview');
+        $teacherKey = (string) ($user?->username ?? 'preview');
+
+        $paperId = 'TEMQ-'.Str::upper(Str::random(12));
+
+        $pdf = app(ExamPdfController::class);
+        $questionsData = $pdf->prepareQuestionsDataFromTemRows($rows);
+        $grouped = $pdf->buildGroupedQuestionsForPaperBody($questionsData, null);
+
+        $order = 1;
+        $totalScore = 0.0;
+        $flat = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
+        usort($flat, fn ($a, $b) => ($a->question_number ?? 0) <=> ($b->question_number ?? 0));
+
+        foreach ($flat as $obj) {
+            $totalScore += (float) ($obj->score ?? 5);
+        }
+
+        Paper::query()->create([
+            'paper_id' => $paperId,
+            'student_id' => (string) $studentKey,
+            'teacher_id' => (string) $teacherKey,
+            'paper_name' => '待入库组卷验真',
+            'paper_type' => 0,
+            'total_questions' => count($flat),
+            'total_score' => $totalScore,
+            'status' => 'tem_preview',
+            'difficulty_category' => '中等',
+        ]);
+
+        foreach ($flat as $obj) {
+            $type = (string) ($obj->question_type ?? 'answer');
+            $stem = (string) ($obj->stem ?? $obj->content ?? '');
+            $bankId = (int) ($obj->id ?? 0);
+            PaperQuestion::query()->create([
+                'paper_id' => $paperId,
+                'question_bank_id' => $bankId,
+                'question_id' => 'tem-'.$order,
+                'knowledge_point' => (string) ($obj->kp_code ?? ''),
+                'question_type' => $type,
+                'question_text' => $stem,
+                'correct_answer' => (string) ($obj->answer ?? ''),
+                'solution' => (string) ($obj->solution ?? ''),
+                'difficulty' => (float) ($obj->difficulty ?? 0.5),
+                'score' => (float) ($obj->score ?? 5),
+                'question_number' => (int) ($obj->question_number ?? $order),
+            ]);
+            $order++;
+        }
+
+        return $paperId;
+    }
+
+    public static function currentUserId(): ?int
+    {
+        $u = Auth::user();
+        if ($u instanceof User) {
+            return (int) $u->id;
+        }
+
+        return null;
+    }
+}

+ 25 - 0
config/ai.php

@@ -155,5 +155,30 @@ 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,
 ];

+ 30 - 0
database/migrations/2026_03_25_120000_create_questions_tem_assembly_queue_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (Schema::hasTable('questions_tem_assembly_queue')) {
+            return;
+        }
+
+        Schema::create('questions_tem_assembly_queue', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('user_id')->index();
+            $table->unsignedBigInteger('questions_tem_id')->index();
+            $table->unsignedInteger('sort_order')->default(0);
+            $table->timestamps();
+
+            $table->unique(['user_id', 'questions_tem_id'], 'uq_tem_assembly_user_tem');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('questions_tem_assembly_queue');
+    }
+};

+ 106 - 0
database/sql/copy_questions_tem_to_questions.sql

@@ -0,0 +1,106 @@
+-- =============================================================================
+-- questions 与 questions_tem 除 id 外结构一致时:从 tem 插入 questions
+--
+-- 说明:
+--   · id 不拷贝,由 questions 自增。
+--   · question_code 在 questions 上唯一:下面用新码避免与已有行冲突。
+--     若你确认 tem 的 question_code 与 questions 绝不重复,可把 SELECT 里第一列改成 t.question_code。
+--   · 其余列与 tem 一一对应(结构完全一致时可直接照抄)。
+--   · 去重:与业务一致,同 kp_code + stem 已在 questions 存在则跳过。
+--
+-- 执行前备份;建议先 START TRANSACTION,核对行数后再 COMMIT。
+-- 需要 MySQL 8+(UUID())。
+-- =============================================================================
+
+START TRANSACTION;
+
+INSERT INTO `questions` (
+    `question_code`,
+    `kp_id`,
+    `textbook_catalog_nodes_id`,
+    `stem`,
+    `options`,
+    `answer`,
+    `solution`,
+    `difficulty`,
+    `question_category`,
+    `source`,
+    `tags`,
+    `question_type`,
+    `source_file_id`,
+    `source_paper_id`,
+    `paper_part_id`,
+    `textbook_id`,
+    `meta`,
+    `created_at`,
+    `updated_at`,
+    `audit_status`,
+    `audit_reason`,
+    `title_1`,
+    `title_2`,
+    `title_3`,
+    `create_by`,
+    `kp_code`,
+    `kp_name`,
+    `kp_reference`,
+    `grade`,
+    `step_num`,
+    `solution_temp`,
+    `solution_temp2`
+)
+SELECT
+    CONCAT('QT', UPPER(SUBSTRING(REPLACE(UUID(), '-', ''), 1, 12))) AS `question_code`,
+    t.`kp_id`,
+    t.`textbook_catalog_nodes_id`,
+    t.`stem`,
+    t.`options`,
+    t.`answer`,
+    t.`solution`,
+    t.`difficulty`,
+    t.`question_category`,
+    t.`source`,
+    t.`tags`,
+    t.`question_type`,
+    t.`source_file_id`,
+    t.`source_paper_id`,
+    t.`paper_part_id`,
+    t.`textbook_id`,
+    t.`meta`,
+    t.`created_at`,
+    t.`updated_at`,
+    t.`audit_status`,
+    t.`audit_reason`,
+    t.`title_1`,
+    t.`title_2`,
+    t.`title_3`,
+    t.`create_by`,
+    t.`kp_code`,
+    t.`kp_name`,
+    t.`kp_reference`,
+    t.`grade`,
+    t.`step_num`,
+    t.`solution_temp`,
+    t.`solution_temp2`
+FROM `questions_tem` AS t
+WHERE
+    t.`kp_code` IS NOT NULL
+    AND TRIM(t.`kp_code`) <> ''
+    AND t.`stem` IS NOT NULL
+    AND TRIM(t.`stem`) <> ''
+    AND NOT EXISTS (
+        SELECT 1
+        FROM `questions` AS q
+        WHERE q.`kp_code` = t.`kp_code`
+          AND q.`stem` = t.`stem`
+    );
+
+COMMIT;
+
+-- -----------------------------------------------------------------------------
+-- 若两表列名/顺序有出入,可先查出 questions 除 id 外的列名再改 INSERT/SELECT:
+-- SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'questions' AND COLUMN_NAME <> 'id'
+-- ORDER BY ORDINAL_POSITION;
+-- -----------------------------------------------------------------------------
+
+-- 仅复制部分 tem 行:在 WHERE 末尾增加 AND t.id IN (1,2,3);

+ 865 - 0
docs/knowledge_point_question_stats.md

@@ -0,0 +1,865 @@
+# 知识点题量统计
+
+- 排序:教材 **semester=2**(默认下学期)按年级与章节关联知识点顺序优先,其次 **questions 题量升序**。
+- **tem 待入库(不重复)**:`questions_tem` 中与 `questions` 同 `kp_code` 且题干一致者不重复计数。
+
+| 知识点 ID | 知识点名称 | questions 题目数 | questions_tem 待入库(不含与 questions 重复) |
+| --- | --- | ---: | ---: |
+| `S06_006_001` | 向量的概念与表示 | 48 | 14 |
+| `S06_006_004` | 向量的加法、减法与数乘运算 | 28 | 85 |
+| `S06_008` | 平面向量的坐标运算 | 0 | 44 |
+| `S06_008_001` | 平面向量基本定理 | 74 | 18 |
+| `S06_008_002` | 平面向量的正交分解与坐标表示 | 19 | 6 |
+| `S06_008_003` | 平面向量线性运算的坐标表示 | 55 | 33 |
+| `S06_008_004` | 平面向量数量积的坐标表示 | 58 | 7 |
+| `S06_008_005` | 向量平行与垂直的坐标表示 | 14 | 12 |
+| `S06_008_006` | 定比分点坐标公式 | 10 | 0 |
+| `S06_007_006` | 向量数量积在平面几何中的应用 | 182 | 49 |
+| `S11_001` | 复数的概念 | 0 | 58 |
+| `S11_001_001` | 复数的引入与相关概念(虚数单位i、实部、虚部) | 75 | 28 |
+| `S11_001_002` | 复数的分类(实数、虚数、纯虚数) | 13 | 26 |
+| `S11_001_003` | 复数相等的充要条件 | 16 | 11 |
+| `S11_001_004` | 复数的几何意义(复平面、对应点) | 47 | 67 |
+| `S11_001_005` | 复数的模 | 20 | 41 |
+| `S11_001_006` | 共轭复数 | 14 | 15 |
+| `S11_001_007` | 利用复数相等求参数 | 10 | 6 |
+| `S11_001_008` | 复数模的计算 | 13 | 13 |
+| `S11_002` | 复数的四则运算 | 16 | 69 |
+| `S11_002_001` | 复数的加法与减法 | 17 | 68 |
+| `S11_002_002` | 复数的乘法 | 10 | 28 |
+| `S11_002_003` | 复数的除法 | 7 | 25 |
+| `S11_002_004` | i^n(n∈N)的周期性 | 14 | 8 |
+| `S11_002_005` | 共轭复数的运算性质 | 36 | 7 |
+| `S11_002_006` | \|z-z₀\|的几何意义 | 29 | 0 |
+| `S11_002_007` | 复数模的性质 | 10 | 0 |
+| `S11_002_008` | 实系数一元二次方程在复数范围内的根 | 97 | 19 |
+| `S11_003` | 复数的三角表示 | 4 | 29 |
+| `S11_003_001` | 复数的三角表示式 | 8 | 2 |
+| `S11_003_002` | 复数的辐角与辐角主值 | 20 | 6 |
+| `S11_003_003` | 复数代数形式与三角形式的互化 | 23 | 7 |
+| `S11_003_004` | 复数乘法的三角表示及其几何意义 | 12 | 2 |
+| `S11_003_005` | 复数除法的三角表示及其几何意义 | 10 | 0 |
+| `S11_003_006` | 棣莫弗定理(n个复数乘法的三角表示) | 17 | 0 |
+| `S11_003_007` | 复数的n次方根 | 7 | 1 |
+| `S07_001_004` | 空间几何体的结构特征 | 25 | 17 |
+| `S07_002_001` | 画空间几何体的直观图 | 43 | 29 |
+| `S07_003_003` | 用公式法求表面积和体积 | 10 | 0 |
+| `S07_004_002` | 空间点、直线、平面之间的位置关系 | 39 | 102 |
+| `S07_005_004` | 线线平行、线面平行、面面平行的判定方法 | 33 | 13 |
+| `S07_006_004` | 线线垂直、线面垂直、面面垂直的判定方法 | 5 | 4 |
+| `S10_001_002` | 简单随机抽样与系统抽样 | 14 | 32 |
+| `S10_002_004` | 总体集中趋势的估计 | 10 | 33 |
+| `S10_002_006` | 样本数字特征的计算与应用 | 14 | 15 |
+| `S10_003_001` | 随机试验、样本点与样本空间 | 42 | 11 |
+| `S10_003_008` | 相互独立事件 | 18 | 109 |
+| `S10_003_005` | 频率与概率的关系 | 22 | 61 |
+| `S04_007_009` | 指数函数的概念 | 8 | 28 |
+| `S04_008_001` | 对数的概念 | 9 | 22 |
+| `S04_008_025` | 指数、对数函数的增长速度比较 | 11 | 7 |
+| `S04_006_001` | 幂函数的概念 | 11 | 20 |
+| `S04_002_015` | 函数性质的综合应用 | 78 | 87 |
+| `S10_001_001` | 统计中的基本概念 | 26 | 8 |
+| `S10_003_004` | 概率的基本性质 | 73 | 35 |
+| `S10_003_006` | 古典概型 | 10 | 144 |
+| `S06_006_005` | 向量的线性运算及其应用 | 29 | 68 |
+| `S04_003_007` | 分段函数的周期性 | 10 | 0 |
+| `S06_001_001` | 角的概念与终边相同的角 | 8 | 29 |
+| `S06_001_003` | 角度制与弧度制的互化 | 15 | 13 |
+| `S06_002_001` | 三角函数的概念与定义 | 12 | 34 |
+| `S06_003_001` | 正弦、余弦、正切函数的图象 | 10 | 38 |
+| `S06_005_004` | 函数 y=Asin(ωx+φ) 的性质 | 26 | 6 |
+| `S06_010_011` | 测量与实际应用 | 45 | 20 |
+| `S06_007_001` | 向量数量积的定义与投影 | 52 | 11 |
+| `S03_004_003` | 不等式的实际应用与数学建模 | 21 | 25 |
+| `S06_002_005` | 同角三角函数的基本关系 | 8 | 79 |
+| `S06_004_001` | 两角和与差的正弦、余弦、正切公式 | 23 | 59 |
+| `S06_004_002` | 二倍角公式与半角公式 | 9 | 2 |
+| `S07_001_001` | 多面体(棱柱、棱锥、棱台) | 38 | 47 |
+| `S07_005_001` | 直线与直线平行的判定与性质 | 28 | 33 |
+| `S07_006_005` | 利用垂直关系证明 | 0 | 12 |
+| `S07_001_005` | 几何体中基本量的计算 | 51 | 1 |
+| `S09_001_001` | 数列的概念与分类 | 12 | 9 |
+| `S09_002_001` | 等差数列的概念与通项公式 | 62 | 64 |
+| `S09_003_001` | 等比数列的概念与通项公式 | 10 | 73 |
+| `S09_007_001` | 归纳法与数学归纳法 | 8 | 5 |
+| `S05_001_003` | 函数在某点处的导数 | 30 | 16 |
+| `S05_002_001` | 基本初等函数的导数公式 | 10 | 18 |
+| `S05_004` | 导数在研究函数中的应用 | 6 | 100 |
+| `S05_004_001` | 函数单调性与导数的关系 | 22 | 27 |
+| `S05_004_002` | 用导数求函数的单调区间 | 71 | 47 |
+| `S05_004_003` | 利用单调性求参数范围 | 30 | 40 |
+| `S05_004_004` | 函数的极值(概念、求法、应用) | 29 | 55 |
+| `S05_004_005` | 利用极值求参数 | 20 | 51 |
+| `S05_004_006` | 函数的最值(闭区间最值、含参最值) | 52 | 68 |
+| `S05_004_007` | 利用导数解决优化问题 | 11 | 0 |
+| `S05_004_008` | 原函数与导函数的图象关系 | 19 | 30 |
+| `S06_010_001` | 正弦定理及其应用 | 21 | 23 |
+| `S06_010_005` | 利用正余弦定理解三角形 | 146 | 36 |
+| `S07_004_001` | 平面的基本性质(三个公理、三个推论) | 9 | 38 |
+| `S07_006_001` | 直线与直线垂直的判定与性质 | 48 | 12 |
+| `S09_001_004` | 数列与函数的关系 | 7 | 0 |
+| `S09_003_009` | 等比数列的实际应用 | 12 | 41 |
+| `S05_001_002` | 函数的平均变化率 | 34 | 24 |
+| `S05_001_005` | 导数的几何意义 | 18 | 44 |
+| `S05_002_002` | 导数的四则运算法则 | 8 | 21 |
+| `S05_002_003` | 简单复合函数的导数 | 19 | 24 |
+| `S10_008_001` | 排列与排列数 | 43 | 55 |
+| `S10_009_001` | 二项式定理与二项式系数 | 88 | 31 |
+| `S10_003_009` | 条件概率与概率的乘法公式 | 60 | 11 |
+| `S10_004_001` | 随机变量与离散型随机变量 | 17 | 34 |
+| `S10_006_003` | 一元线性回归模型 | 19 | 0 |
+| `S10_006_008` | 独立性检验 | 12 | 8 |
+| `S09_002_010` | 等差数列的实际应用 | 12 | 37 |
+| `S10_006_001` | 变量的相关关系 | 44 | 7 |
+| `G01A` | 点线面基本关系 | 35 | 63 |
+| `G01B` | 角的概念与分类 | 11 | 58 |
+| `G10` | 多边形 | 51 | 0 |
+| `G05A` | 圆的基本性质 | 11 | 0 |
+| `E01A` | 一元一次方程-方程基本概念 | 30 | 2 |
+| `E01B` | 一元一次方程-移项与合并同类项 | 30 | 1 |
+| `E01D` | 一元一次方程的应用 | 28 | 13 |
+| `G09A` | 两条直线相交 | 6 | 12 |
+| `G09B` | 两条直线垂直 | 7 | 14 |
+| `G02B` | 平行线性质 | 20 | 17 |
+| `A10` | 整式的除法 | 16 | 7 |
+| `R05` | 幂与指数 | 174 | 1 |
+| `A05` | 整式乘法 | 154 | 20 |
+| `A06` | 特殊乘法公式 | 19 | 0 |
+| `R09` | 实数 | 179 | 1 |
+| `G06B` | 数形结合思想 | 10 | 0 |
+| `M04A3` | 体积单位与换算 | 9 | 0 |
+| `ST01B` | 数据分类整理 | 10 | 0 |
+| `ST06C` | 统计与概率融合应用 | 9 | 8 |
+| `G02A` | 平行线判定 | 60 | 44 |
+| `G09` | 相交线 | 13 | 1 |
+| `G09C` | 两条直线被第三条直线所截 | 6 | 0 |
+| `G01E` | 定义、命题、定理 | 19 | 7 |
+| `M04E1` | 平移 | 8 | 0 |
+| `RS01` | 平方根 | 107 | 89 |
+| `RS02` | 立方根 | 66 | 4 |
+| `M04G2` | 坐标方法的简单应用 | 116 | 15 |
+| `M04G1` | 用坐标描述平面内点的位置 | 120 | 22 |
+| `E07A` | 二元一次方程组的概念 | 66 | 11 |
+| `E07B` | 代入消元法-解二元一次方程组 | 16 | 12 |
+| `E07C` | 加减消元法-解二元一次方程组 | 53 | 13 |
+| `E07D` | 实际问题与二元一次方程组 | 188 | 43 |
+| `E07E` | 三元一次方程组的解法 | 54 | 28 |
+| `M02` | 方程与不等式 | 0 | 4 |
+| `E03` | 不等式与不等式组 | 0 | 116 |
+| `E04` | 分式方程 | 0 | 84 |
+| `E05` | 一元二次方程 | 205 | 0 |
+| `E06` | 方程思想与实际应用 | 47 | 12 |
+| `E07` | 二元一次方程组 | 0 | 166 |
+| `E03A` | 不等式性质 | 23 | 48 |
+| `E03B` | 一元一次不等式 | 7 | 78 |
+| `E03C` | 数轴表示解集 | 22 | 16 |
+| `E03D` | 不等式组 | 18 | 19 |
+| `E03E` | 一元一次不等式的应用 | 19 | 54 |
+| `E03F` | 一元一次不等式组 | 35 | 106 |
+| `ST04` | 统计图表示与分析 | 0 | 8 |
+| `ST04A` | 条形图 | 8 | 15 |
+| `ST04B` | 折线图 | 7 | 8 |
+| `ST04C` | 扇形图 | 10 | 7 |
+| `ST04D` | 统计图综合解读 | 30 | 24 |
+| `ST01A` | 数据的获取与抽样 | 15 | 16 |
+| `A02` | 整式的概念 | 23 | 40 |
+| `G06C` | 几何证明基础 | 14 | 4 |
+| `A08` | 因式分解 | 110 | 4 |
+| `A09` | 因式分解综合应用 | 73 | 5 |
+| `ST02A` | 平均数(均值) | 8 | 11 |
+| `ST02C` | 众数 | 9 | 2 |
+| `ST01` | 数据收集与整理 | 0 | 48 |
+| `ST01C` | 频数与频率 | 8 | 4 |
+| `ST02B` | 中位数 | 11 | 16 |
+| `ST05B` | 古典概率(等可能模型) | 15 | 0 |
+| `G03A` | 三角形分类 | 10 | 5 |
+| `G03F` | 三角形全等(核心) | 216 | 45 |
+| `G03G` | 全等三角形判定综合应用 | 132 | 16 |
+| `M04E3` | 轴对称 | 29 | 46 |
+| `G03` | 三角形 | 422 | 126 |
+| `G03B` | 三角形基本性质 | 90 | 13 |
+| `G03C` | 三角形稳定性 | 18 | 0 |
+| `G03D` | 角平分线性质 | 73 | 38 |
+| `G03E` | 中线与垂心 | 45 | 5 |
+| `G03H` | 三角形尺规作图 | 10 | 65 |
+| `M04E2` | 旋转 | 32 | 1 |
+| `F01` | 分式概念 | 11 | 8 |
+| `F02` | 分式约分 | 15 | 0 |
+| `F03` | 分式通分 | 6 | 4 |
+| `F04` | 分式加减法 | 36 | 21 |
+| `F05` | 分式乘除法 | 21 | 51 |
+| `F06` | 分式综合化简 | 54 | 41 |
+| `E04A` | 分式方程概念 | 27 | 0 |
+| `E04B` | 分式方程去分母 | 19 | 0 |
+| `E04D` | 分式方程的应用 | 39 | 40 |
+| `R06` | 科学记数法 | 44 | 0 |
+| `B01C` | 一次函数与方程、不等式 | 13 | 43 |
+| `M06` | 统计与概率 | 1 | 12 |
+| `ST02` | 数据的统计量(集中趋势) | 0 | 9 |
+| `ST03` | 数据的离散程度(新课标 2022) | 0 | 22 |
+| `ST05` | 概率初步 | 0 | 5 |
+| `ST06` | 统计与概率综合应用(中考核心) | 0 | 1 |
+| `S02` | 代数式与整式运算 | 72 | 120 |
+| `A01` | 代数式基本概念 | 129 | 17 |
+| `A03` | 同类项合并 | 17 | 16 |
+| `A04` | 去括号与加减法 | 63 | 1 |
+| `M04E4` | 图形变换综合 | 14 | 2 |
+| `E01C` | 一元一次方程-方程解的检验 | 24 | 0 |
+| `G01C` | 角的度量 | 13 | 0 |
+| `G01D` | 对顶角与邻补角 | 28 | 8 |
+| `G02` | 平行线与角 | 179 | 97 |
+| `G02C` | 平移与图形性质 | 52 | 21 |
+| `M04G` | 平面直角坐标系 | 154 | 74 |
+| `R11` | 数轴 | 77 | 26 |
+| `S01` | 数的认识与运算 | 77 | 22 |
+| `R01` | 整数与自然数 | 13 | 5 |
+| `R02` | 有理数分类 | 89 | 37 |
+| `R03` | 有理数加减法 | 260 | 61 |
+| `R04` | 有理数乘除法 | 237 | 2 |
+| `R07` | 平方与平方根 | 122 | 65 |
+| `R08` | 立方与立方根 | 50 | 2 |
+| `R10` | 有理数大小比较 | 106 | 30 |
+| `R12` | 无理数 | 23 | 2 |
+| `R13` | 绝对值 | 41 | 2 |
+| `G01` | 基本几何知识 | 5 | 61 |
+| `ST03B` | 方差概念(初步) | 7 | 5 |
+| `RS03` | 根式的基本性质 | 15 | 8 |
+| `RS05` | 根式混合运算 | 85 | 49 |
+| `RS04` | 根式化简 | 23 | 11 |
+| `S04` | 实数与根式运算 | 163 | 26 |
+| `M05` | 相似与勾股 | 218 | 61 |
+| `PY01` | 勾股定理与直角三角形 | 50 | 0 |
+| `PY02` | 勾股定理应用 | 58 | 57 |
+| `SIM01` | 相似三角形判定 | 41 | 0 |
+| `SIM02` | 相似三角形性质 | 38 | 0 |
+| `G04` | 四边形与平行四边形 | 375 | 96 |
+| `G04A` | 四边形分类 | 17 | 1 |
+| `G04B` | 平行四边形判定 | 26 | 61 |
+| `G04C` | 平行四边形性质 | 86 | 293 |
+| `G04D` | 特殊四边形:矩形 | 65 | 70 |
+| `G04E` | 特殊四边形:菱形 | 51 | 36 |
+| `G04F` | 特殊四边形:正方形 | 120 | 46 |
+| `M07` | 函数 | 0 | 37 |
+| `B01` | 一次函数 | 15 | 130 |
+| `B04` | 二次函数 | 117 | 39 |
+| `B05` | 反比例函数 | 30 | 39 |
+| `B06` | 锐角三角函数 | 68 | 154 |
+| `B01A` | 正比例函数 | 10 | 10 |
+| `B01B` | 一次函数的应用 | 9 | 26 |
+| `B01D` | 一次函数的求解 | 10 | 17 |
+| `ST03A` | 极差 | 10 | 0 |
+| `ST03C` | 标准差(认识) | 9 | 11 |
+| `E05A` | 一元二次方程-配方法 | 63 | 0 |
+| `E05B` | 一元二次方程-求根公式 | 50 | 9 |
+| `E05C` | 一元二次方程-因式分解解法 | 38 | 2 |
+| `E05D` | 一元二次方程-根与系数关系 | 107 | 3 |
+| `E05E` | 一元二次方程-方程根的分布 | 45 | 10 |
+| `E05F` | 一元二次方程-换元法 | 20 | 0 |
+| `PY01A` | 勾股定理 | 43 | 4 |
+| `PY01B` | 勾股逆定理 | 37 | 10 |
+| `SIM02A` | 对应边成比例 | 14 | 0 |
+| `SIM02D` | 线段比例分割(平行线) | 12 | 18 |
+| `SIM01A` | 相似三角形概念 | 31 | 0 |
+| `SIM01C` | SAS 比例判定 | 9 | 0 |
+| `SIM01D` | SS 比例判定 | 4 | 0 |
+| `SIM01E` | 相似三角形判定综合应用 | 122 | 57 |
+| `SIM02B` | 面积比 | 11 | 0 |
+| `SIM02C` | 体积比基础 | 10 | 1 |
+| `SIM02E` | 相似性质综合应用 | 7 | 1 |
+| `B05A` | 反比例函数的概念 | 35 | 5 |
+| `B05B` | 反比例函数的图象和性质 | 111 | 95 |
+| `B05C` | 反比例函数的应用 | 50 | 19 |
+| `M04C2` | 四边形面积 | 9 | 9 |
+| `PY01D` | 直角三角形性质 | 25 | 18 |
+| `PY01C` | 勾股数 | 22 | 1 |
+| `M04D4` | 立体几何展开图 | 20 | 0 |
+| `B06A` | 三角函数的计算 | 32 | 2 |
+| `B06B` | 三角函数的应用 | 36 | 16 |
+| `B04A` | 二次函数y=ax²的图象和性质 | 25 | 5 |
+| `B04B` | 二次函数y=a(x-h)²+k的图象和性质 | 49 | 12 |
+| `B04C` | 二次函数y=ax²+bx+c的图象和性质 | 69 | 18 |
+| `B04D` | 二次函数与一元二次方程 | 91 | 58 |
+| `B04E` | 二次函数的应用 | 153 | 45 |
+| `B04F` | 二次函数的概念 | 28 | 1 |
+| `G05D` | 圆周角定理(核心) | 21 | 0 |
+| `G05E` | 弧长与扇形面积 | 25 | 0 |
+| `G05C` | 切线性质 | 14 | 0 |
+| `G05` | 圆与相关性质 | 109 | 30 |
+| `G05B` | 弦与圆心距 | 47 | 1 |
+| `G07` | 投影 | 1 | 39 |
+| `G07A` | 平行投影与中心投影 | 25 | 3 |
+| `G07B` | 正投影 | 11 | 2 |
+| `ST05A` | 随机事件 | 12 | 5 |
+| `ST05C` | 实验概率 | 11 | 0 |
+| `ST05D` | 简单树状图与列表法 | 6 | 7 |
+| `M04C1` | 三角形面积 | 11 | 9 |
+| `M04C3` | 圆与扇形面积 | 16 | 0 |
+| `M04C4` | 组合图形面积 | 16 | 4 |
+| `G08` | 三视图 | 0 | 52 |
+| `G08A` | 几何体的三视图 | 47 | 5 |
+| `G08B` | 根据三视图确定几何体 | 26 | 10 |
+| `G08C` | 与三视图有关的计算 | 9 | 0 |
+| `SIM03A` | 辅助线构造策略 | 8 | 0 |
+| `PY02D` | 勾股+相似综合(中考核心) | 9 | 0 |
+| `PY02A` | 平面距离计算 | 37 | 2 |
+| `S03_001` | 不等式的性质 | 0 | 7 |
+| `S05_003_001` | 求曲线在某点处的切线方程 | 23 | 33 |
+| `S04_001_003` | 函数的值域及其求法 | 14 | 131 |
+| `S07_008_001` | 空间向量的概念与线性运算 | 45 | 28 |
+| `S06_002_004` | 单位圆与三角函数线 | 19 | 9 |
+| `S01_003_003` | 德·摩根定律 | 9 | 0 |
+| `S04_001_008` | 图象法表示函数 | 17 | 27 |
+| `S09_003_004` | 等比数列的判定与证明 | 13 | 23 |
+| `S10_006_004` | 经验回归方程与最小二乘法 | 16 | 9 |
+| `S06_010_008` | 三角形中的不等式与边长范围 | 59 | 22 |
+| `S05_005_007` | 最值定位法 | 0 | 5 |
+| `S04_007_007` | 指数方程的求解 | 15 | 14 |
+| `S08_001_008` | 直线系方程 | 9 | 0 |
+| `S06_006` | 平面向量的概念与运算 | 10 | 48 |
+| `M00` | 初中数学知识体系 | 0 | 98 |
+| `S01_001_004` | 集合的表示方法(列举法、描述法、图示法) | 17 | 36 |
+| `S04_003_005` | 分段函数的图象 | 21 | 17 |
+| `S05_002_004` | 求函数的导数 | 14 | 15 |
+| `S10_004_005` | 离散型随机变量的均值 | 35 | 2 |
+| `S10_004_004` | 求离散型随机变量的分布列 | 27 | 4 |
+| `S04_008_007` | 对数的换底公式 | 25 | 3 |
+| `S03_002_003` | 利用基本不等式求最值 | 53 | 204 |
+| `S09_006` | 数列求和 | 0 | 31 |
+| `S08_002_006` | 两平行线间距离公式 | 8 | 43 |
+| `S08_004_007` | 与圆有关的最值 | 17 | 72 |
+| `S07_008_002` | 空间向量的共线与共面 | 101 | 25 |
+| `S04_008_023` | 利用对数函数解不等式 | 53 | 17 |
+| `S09_003_007` | 等比数列前n项和的性质 | 28 | 50 |
+| `S10_007` | 两个计数原理 | 0 | 131 |
+| `S07_000` | 立体几何 | 62 | 144 |
+| `S08_001_005` | 直线方程的一般式 | 13 | 68 |
+| `S09_007_004` | 用数学归纳法证明几何问题 | 10 | 8 |
+| `S04_008_022` | 利用对数函数比较大小 | 34 | 16 |
+| `S08_006_002` | 椭圆的对称性与顶点 | 1 | 8 |
+| `S09_006_004` | 裂项相消法 | 10 | 19 |
+| `S07_003_001` | 棱柱、棱锥、棱台的表面积和体积 | 34 | 72 |
+| `APP_E4` | 几何方程建模 | 58 | 29 |
+| `S04_007_017` | 指数型复合函数的图象 | 12 | 0 |
+| `S06_010_007` | 三角形中的角平分线与中线 | 33 | 68 |
+| `S07_008` | 空间向量及其运算 | 0 | 303 |
+| `S08_004_006` | 中点弦 | 9 | 11 |
+| `S05_002_005` | 抽象函数的导数的奇偶性 | 15 | 2 |
+| `S06_007_008` | 向量新定义 | 0 | 6 |
+| `S08_004` | 直线与圆的位置关系 | 0 | 91 |
+| `S05_005_009` | 等价转化法 | 0 | 15 |
+| `S07_005_003` | 平面与平面平行的判定与性质 | 29 | 26 |
+| `S09_005_006` | 取倒数法与取对数法 | 11 | 0 |
+| `S05_005_004` | 二次求导法 | 9 | 0 |
+| `S08_008_001` | 抛物线的定义与标准方程 | 5 | 81 |
+| `S06_001_005` | 区域角的表示与象限角的判断 | 33 | 11 |
+| `S04_004_012` | 抽象函数的轴对称 | 11 | 0 |
+| `S04_004_008` | 抽象函数奇偶性的应用 | 22 | 3 |
+| `S04_010_014` | 函数零点与对称性 | 12 | 0 |
+| `S10_005_008` | 正态分布的应用 | 25 | 0 |
+| `S09_005_005` | 换元法与代入法 | 5 | 0 |
+| `S04_005_009` | 二次函数与一元二次不等式的关系 | 60 | 1 |
+| `S06_004_004` | 积化和差与和差化积公式 | 11 | 0 |
+| `S06_004_006` | 角的变换技巧与1的代换 | 11 | 1 |
+| `S06_010_006` | 判断三角形的形状 | 42 | 18 |
+| `S10_005_001` | 伯努利试验与独立重复试验 | 11 | 0 |
+| `S09_005_003` | 构造等差数列或等比数列 | 20 | 49 |
+| `S01_002_001` | Venn图与集合关系的表示 | 18 | 14 |
+| `S09_006_003` | 倒序相加法 | 4 | 14 |
+| `S08_005_002` | 两圆相切 | 12 | 31 |
+| `S04_008_019` | 对数型函数的定义域 | 10 | 19 |
+| `S04_007_008` | 指数幂连等式 | 17 | 0 |
+| `S08_007_008` | 双曲线的第二定义 | 3 | 0 |
+| `S04_010_008` | 数形结合法判断零点个数 | 9 | 17 |
+| `S08_008_003` | 抛物线的焦点与准线 | 10 | 37 |
+| `S06_006_003` | 相等向量与共线向量 | 61 | 21 |
+| `S06_001_004` | 扇形的弧长公式和面积公式 | 15 | 26 |
+| `S04_008_006` | 对数运算性质 | 9 | 49 |
+| `S07_007_001` | 异面直线所成的角 | 2 | 26 |
+| `S09_006_007` | 分段求和与奇偶分析法 | 26 | 18 |
+| `S08_001_009` | 利用两直线平行和垂直判断图形形状 | 37 | 5 |
+| `S06_007_002` | 向量数量积的性质和运算律 | 26 | 12 |
+| `S06_005_005` | 三角函数模型的综合应用 | 55 | 40 |
+| `S08_006` | 椭圆 | 7 | 315 |
+| `S02_002_004` | 已知量词命题的真假求参数 | 14 | 40 |
+| `S06_010_012` | 正余弦定理在四边形中的应用 | 59 | 12 |
+| `S02_001` | 充分条件与必要条件 | 1 | 11 |
+| `S04_007_018` | 指数型复合函数的单调性 | 33 | 26 |
+| `S06_007_007` | 向量的三角不等式与奔驰定理 | 19 | 0 |
+| `S09_005_002` | 累乘法 | 8 | 4 |
+| `S06_005_001` | 参数 A、ω、φ 对函数图象的影响 | 15 | 1 |
+| `S04_001_004` | 区间与无穷大 | 11 | 9 |
+| `S04_008_017` | 底数对对数函数图象的影响 | 12 | 0 |
+| `S04_001_012` | 数形结合法求值域 | 11 | 4 |
+| `S05_005_006` | 赋值构造法 | 5 | 0 |
+| `M01` | 数与代数 | 1 | 15 |
+| `S02_001_003` | 充要条件的证明与探求 | 21 | 15 |
+| `S10_006_006` | 非线性回归方程 | 11 | 3 |
+| `S03_003` | 一元二次不等式及其变式 | 46 | 77 |
+| `S04_008_010` | 对数式的求值 | 10 | 26 |
+| `S04_002` | 函数的基本性质 | 0 | 85 |
+| `S04_008_026` | 根据实际问题选择函数模型 | 17 | 32 |
+| `S02_002` | 全称量词与存在量词 | 0 | 9 |
+| `S10_005` | 常见分布 | 0 | 20 |
+| `S04_010_010` | 二次函数零点的分布 | 22 | 12 |
+| `S07_003_006` | 几何体表面上的最短距离 | 58 | 3 |
+| `S10_006_007` | 分类变量与列联表 | 13 | 1 |
+| `S06_000` | 三角函数与向量 | 0 | 31 |
+| `S09_002_009` | 等差数列前n项和的最值 | 20 | 22 |
+| `S04_008_009` | 对数式的化简 | 17 | 8 |
+| `S02_003_001` | 命题的四种形式及其等价关系(原命题、逆命题、否命题、逆否命题) | 7 | 0 |
+| `S03_002_006` | 整体处理、凑系数、凑项 | 15 | 22 |
+| `S04_004_010` | 替换法求抽象函数的周期 | 5 | 0 |
+| `S10_003_010` | 全概率公式与贝叶斯公式 | 29 | 6 |
+| `S07_006_006` | 垂直关系的探索性 | 7 | 0 |
+| `S04_003_009` | 分段函数与不等式 | 23 | 5 |
+| `S10_001` | 统计抽样 | 1 | 70 |
+| `S07_004_005` | 确定平面与平面的交线 | 8 | 2 |
+| `S01_000` | 集合 | 0 | 3 |
+| `S04_008_011` | 对数式的条件求值 | 14 | 4 |
+| `S04_007_002` | 分数指数幂 | 9 | 22 |
+| `S06_005_003` | 五点法作图与由图象求解析式 | 13 | 10 |
+| `S08_006_005` | 椭圆的焦点三角形 | 21 | 46 |
+| `S04_008_015` | 对数函数的图象 | 8 | 17 |
+| `S06_005_006` | 简谐运动与物理应用 | 9 | 10 |
+| `S08_004_001` | 直线与圆的位置关系判定 | 45 | 66 |
+| `S10_008_006` | 特殊元素和特殊位置优先法 | 8 | 0 |
+| `S03_002` | 基本不等式 | 0 | 31 |
+| `S09_002_003` | 等差数列与一次函数的关系 | 22 | 3 |
+| `S07_005` | 空间的平行关系 | 8 | 40 |
+| `S09_002_006` | 等差数列的前n项和公式 | 79 | 50 |
+| `SIM03C` | 直角结构构造 | 5 | 1 |
+| `S02_002_002` | 量词命题的真假判断 | 32 | 39 |
+| `S04_008_012` | 对数方程 | 11 | 10 |
+| `S08_002_008` | 线段垂直平分线方程 | 6 | 0 |
+| `M04B1` | 三角形周长 | 13 | 0 |
+| `S03_002_009` | 基本不等式的拓展与实际应用 | 28 | 10 |
+| `S01_003_006` | 集合运算中的数形结合与分类讨论 | 127 | 10 |
+| `S10_008_009` | 分组分配问题 | 29 | 0 |
+| `S05_005_005` | 分类讨论思想 | 9 | 9 |
+| `S06_006_002` | 零向量、单位向量、相反向量 | 12 | 2 |
+| `S10_009_004` | 多项展开式 | 12 | 9 |
+| `S08_008_006` | 直线与抛物线的位置关系 | 10 | 89 |
+| `APP_E2` | 工程问题方程建模 | 34 | 0 |
+| `S06_007_009` | 平面向量数量积的运算 | 0 | 29 |
+| `S07_006_003` | 平面与平面垂直的判定与性质 | 24 | 80 |
+| `S10_004_002` | 离散型随机变量的分布列 | 25 | 1 |
+| `S10_001_003` | 分层随机抽样 | 17 | 69 |
+| `S10_009_002` | 杨辉三角 | 7 | 10 |
+| `S04_008_020` | 对数型函数的值域 | 9 | 22 |
+| `S08_001_002` | 两条直线平行与垂直的判定 | 8 | 73 |
+| `S04_008_004` | 常用对数 | 3 | 0 |
+| `S01_003_002` | 集合运算的方法与求参数 | 34 | 58 |
+| `S06_004_003` | 辅助角公式 | 10 | 4 |
+| `S01_001_001` | 元素与集合的概念及关系 | 59 | 48 |
+| `S09_007_002` | 用数学归纳法证明恒等式 | 8 | 16 |
+| `M04B2` | 四边形周长 | 5 | 1 |
+| `S09_007_003` | 用数学归纳法证明不等式 | 8 | 16 |
+| `S04_007_010` | 指数函数的图象 | 0 | 37 |
+| `S07_008_005` | 空间直角坐标系与向量的坐标表示 | 18 | 59 |
+| `S03_003_008` | 一元二次方程根的分布 | 21 | 5 |
+| `S03_004` | 不等式的综合应用 | 0 | 73 |
+| `S09_001_006` | 求数列通项公式的基本方法 | 103 | 7 |
+| `S04_001_015` | 单调性法求值域 | 9 | 0 |
+| `S07_008_008` | 空间向量的夹角、模长与距离公式 | 68 | 64 |
+| `S04_010_003` | 直接求解法求函数零点 | 8 | 16 |
+| `S04_007_005` | 指数幂的求值 | 14 | 32 |
+| `S08_003` | 圆的方程 | 0 | 75 |
+| `S04_002_002` | 函数单调性的判断 | 14 | 53 |
+| `S08_002_001` | 两直线的交点坐标 | 13 | 43 |
+| `S08_002_005` | 点到直线距离公式 | 8 | 51 |
+| `S09_005` | 根据递推公式求通项公式 | 0 | 3 |
+| `S07_002` | 空间几何体的直观图与三视图 | 0 | 22 |
+| `S03_002_004` | 利用基本不等式比较大小与证明不等式 | 21 | 26 |
+| `S10_008_002` | 全排列和阶乘 | 9 | 1 |
+| `S09_004_001` | 等差与等比数列的公共项 | 56 | 2 |
+| `S04_006` | 幂函数 | 0 | 111 |
+| `S10_000` | 概率统计 | 7 | 218 |
+| `S07_001_007` | 正多面体 | 6 | 0 |
+| `S04_001` | 函数的概念与表示 | 1 | 87 |
+| `S08_004_005` | 直线与圆相交的弦长 | 21 | 29 |
+| `M04D2` | 棱柱与棱锥初步 | 10 | 0 |
+| `S03_004_004` | 数形结合解不等式 | 7 | 0 |
+| `S06_010_003` | 正弦定理与余弦定理的边角互化 | 44 | 11 |
+| `S08_008_004` | 抛物线标准方程的求解 | 9 | 18 |
+| `S04_007_011` | 指数函数的性质 | 11 | 65 |
+| `S04_006_002` | 五个常见幂函数的图象与性质 | 13 | 7 |
+| `S02_003_002` | 四种命题的真假判断 | 0 | 12 |
+| `S03_003_002` | 图象法解一元二次不等式 | 14 | 1 |
+| `S08_007_007` | 双曲线的离心率与渐近线关系 | 14 | 1 |
+| `S07_004_004` | 空间中的共线、共点、共面 | 18 | 23 |
+| `S07_007_004` | 空间中的距离(点到点、点到线、点到面、线到面) | 40 | 87 |
+| `PY02C` | 复杂图形中的直角关系 | 200 | 168 |
+| `S04_008_013` | 带附加条件的对数运算 | 9 | 11 |
+| `S06_004` | 三角恒等变换 | 12 | 22 |
+| `S04_004_004` | 抽象函数单调性的证明 | 15 | 0 |
+| `S09_003` | 等比数列 | 0 | 45 |
+| `S06_010` | 正余弦定理与解三角形 | 0 | 15 |
+| `SIM03D` | 几何综合(压轴结构) | 11 | 4 |
+| `S07_008_003` | 空间向量的数量积及其应用 | 43 | 58 |
+| `S03_001_005` | 比较实数或代数式的大小 | 23 | 35 |
+| `S06_007` | 平面向量的数量积 | 0 | 58 |
+| `S01_002_002` | 子集、真子集与空集 | 23 | 16 |
+| `S07_004_006` | 平面划分空间 | 10 | 12 |
+| `S08_005_004` | 圆系方程及其应用 | 10 | 29 |
+| `S03_001_003` | 等式与不等式的基本性质 | 27 | 106 |
+| `S08_003_005` | 待定系数法求圆的方程 | 8 | 2 |
+| `S04_005_004` | 区动轴定型的最值 | 10 | 6 |
+| `S04_007_003` | 实数指数幂 | 10 | 39 |
+| `S10_008_003` | 组合与组合数 | 57 | 69 |
+| `S04_003_001` | 分段函数的概念与表示 | 8 | 7 |
+| `S10_007_002` | 分步乘法计数原理 | 38 | 14 |
+| `S08_006_003` | 椭圆的焦点与离心率 | 19 | 35 |
+| `S10_004_008` | 均值与方差的应用 | 22 | 7 |
+| `S04_005_005` | 二次函数在闭区间上的最值 | 72 | 0 |
+| `S03_001_004` | 倒数法则 | 9 | 0 |
+| `S04_010_002` | 零点存在定理 | 7 | 14 |
+| `S04_001_002` | 函数的定义域及其求法 | 22 | 155 |
+| `S03_002_001` | 重要不等式 a²+b²≥2ab | 21 | 8 |
+| `M03` | 几何图形与性质 | 297 | 68 |
+| `M04` | 图形度量 | 0 | 4 |
+| `S09_002_002` | 等差中项 | 19 | 11 |
+| `S06_005_007` | 解三角方程与图象交点问题 | 19 | 0 |
+| `S06_002_006` | 诱导公式及其应用(求值、化简、证明) | 10 | 175 |
+| `S05_006_007` | 三次函数的性质及应用 | 15 | 6 |
+| `S10_008_005` | 排列组合的基本应用 | 47 | 14 |
+| `S04_004_014` | 对称性、奇偶性与周期性的综合应用 | 16 | 17 |
+| `S08_007_002` | 双曲线的对称性、顶点与焦点 | 14 | 18 |
+| `S10_003` | 概率基础 | 14 | 135 |
+| `S01_003_001` | 并集、交集、补集的概念与运算 | 105 | 79 |
+| `S10_003_002` | 事件的分类与运算 | 15 | 15 |
+| `M04A1` | 长度与单位换算 | 9 | 0 |
+| `S08_003_007` | 与圆有关的轨迹问题 | 19 | 24 |
+| `APP_E1` | 行程问题方程建模 | 36 | 2 |
+| `S08_007_009` | 直线与双曲线的位置关系 | 24 | 89 |
+| `S05_006_006` | 极值点偏移 | 10 | 49 |
+| `S07_001_003` | 简单组合体 | 13 | 13 |
+| `S04_005_001` | 二次函数的概念与解析式 | 22 | 0 |
+| `S08_004_003` | 过圆外一点的切线方程 | 4 | 7 |
+| `S05_003_005` | 已知切线方程求参数 | 46 | 19 |
+| `S04_002_008` | 函数奇偶性的定义 | 9 | 6 |
+| `S06_010_010` | 三角形中的最值 | 88 | 10 |
+| `ST06B` | 概率综合题 | 74 | 1 |
+| `S01_003_005` | 补集思想的应用(正难则反) | 9 | 0 |
+| `S10_006_002` | 样本相关系数 | 11 | 1 |
+| `S04_001_013` | 判别式法求值域 | 16 | 1 |
+| `S05_001_006` | 利用导数定义解题 | 11 | 19 |
+| `S04_005_002` | 区定轴定型的最值 | 9 | 5 |
+| `S08_006_007` | 椭圆的第二定义 | 6 | 1 |
+| `SIM03B` | 比例关系链 | 25 | 0 |
+| `S10_007_003` | 两个计数原理的综合应用 | 19 | 31 |
+| `S07_008_009` | 定比分点与三角形重心坐标公式 | 66 | 0 |
+| `S03_004_002` | 不等式的整数解 | 30 | 11 |
+| `S08_001` | 直线的方程 | 24 | 91 |
+| `S10_008_010` | 容斥原理与圆排列 | 11 | 0 |
+| `M01A` | 代数式表示现实问题 | 18 | 2 |
+| `S09_002_008` | 等差数列前n项和的性质 | 110 | 47 |
+| `A07` | — | 0 | 2 |
+| `S10_001_004` | 样本均值与总体均值 | 12 | 2 |
+| `S02_002_001` | 全称量词命题与存在量词命题的概念 | 18 | 19 |
+| `S04_004_001` | 抽象函数的定义域 | 8 | 0 |
+| `S10_006_005` | 残差分析与模型拟合效果检验 | 14 | 0 |
+| `S04_010_005` | 换元法求函数零点 | 8 | 0 |
+| `S07_002_002` | 平面图形与其直观图的面积关系 | 26 | 12 |
+| `S04_010_011` | 复合函数的零点 | 9 | 4 |
+| `S04_001_007` | 列表法表示函数 | 8 | 4 |
+| `S10_005_006` | 正态分布与正态曲线 | 14 | 36 |
+| `S07_005_002` | 直线与平面平行的判定与性质 | 87 | 78 |
+| `S04_002_006` | 利用单调性求参数 | 6 | 68 |
+| `M04D3` | 圆柱体积与表面积 | 13 | 0 |
+| `S09_006_005` | 错位相减法 | 10 | 20 |
+| `M04F` | 图形度量综合应用 | 0 | 16 |
+| `S08_001_006` | 求直线方程的方法 | 31 | 44 |
+| `M04D1` | 长方体与正方体 | 9 | 1 |
+| `S04_008_018` | 反函数 | 10 | 1 |
+| `S07_003_008` | 祖暅原理 | 6 | 0 |
+| `S04_003_006` | 分段函数的单调性 | 35 | 0 |
+| `S02_001_002` | 充分、必要、充要条件的判断方法 | 35 | 52 |
+| `S01_003` | 集合的基本运算 | 5 | 25 |
+| `S04_010_009` | 由零点个数求参数范围 | 8 | 21 |
+| `M01B` | 数量关系建模 | 10 | 0 |
+| `S08_002_004` | 两点间距离公式 | 9 | 62 |
+| `S04_003_003` | 分段函数的值域 | 10 | 6 |
+| `S10_002_001` | 频率分布表与频率分布直方图 | 20 | 68 |
+| `S04_002_012` | 奇函数最值的对称性 | 13 | 1 |
+| `S08_007_005` | 双曲线标准方程的求解 | 10 | 17 |
+| `S05_003_002` | 求过某点的曲线切线方程 | 29 | 3 |
+| `M04F2` | 中考图形计算综合题 | 137 | 57 |
+| `S02_002_003` | 量词命题的否定(全称命题↔存在命题) | 64 | 29 |
+| `S09_004_002` | 等差与等比数列的综合 | 96 | 73 |
+| `S07_004` | 空间点、直线、平面的位置关系 | 0 | 42 |
+| `S10_005_002` | 二项分布 | 26 | 11 |
+| `S10_002_005` | 总体离散程度的估计 | 11 | 50 |
+| `S10_008` | 排列与组合 | 0 | 73 |
+| `S03_003_005` | 指数、对数不等式的解法 | 19 | 1 |
+| `S04_002_004` | 利用单调性比较大小 | 10 | 28 |
+| `S04_010_007` | 利用零点存在性定理判断零点个数 | 7 | 9 |
+| `S03_002_005` | 幂指式内隐和积互化 | 8 | 0 |
+| `E02C` | — | 8 | 0 |
+| `S05_003_003` | 两曲线的公切线 | 13 | 13 |
+| `S03_003_004` | 高次不等式、分式不等式的解法 | 31 | 25 |
+| `S06_010_002` | 余弦定理及其应用 | 7 | 23 |
+| `S04_008_008` | 换底公式的应用 | 10 | 13 |
+| `S09_007` | 数学归纳法 | 0 | 7 |
+| `S03_000` | 不等式 | 0 | 25 |
+| `S02_003_004` | 命题的否定与否命题的区分 | 7 | 0 |
+| `S08_001_004` | 两点式与截距式直线方程 | 15 | 48 |
+| `S06_004_008` | 三角恒等变换与函数性质的综合 | 75 | 22 |
+| `S08_002_011` | 利用对称求最值 | 34 | 12 |
+| `S10_004` | 离散型随机变量 | 0 | 69 |
+| `S04_001_017` | 函数图象的对称变换 | 17 | 0 |
+| `S03_003_003` | 三个"二次"之间的关系 | 10 | 0 |
+| `S04_001_014` | 分离常数法求值域 | 6 | 0 |
+| `S04_004_013` | 抽象函数的中心对称 | 9 | 0 |
+| `S06_003_006` | 三角函数的定义域、值域与最值 | 12 | 58 |
+| `S06_010_004` | 三角形面积公式 | 50 | 34 |
+| `S09_003_002` | 等比中项 | 10 | 17 |
+| `S05_006_005` | 隐零点代换与导函数因式分解 | 7 | 2 |
+| `S08_007_003` | 双曲线的渐近线 | 11 | 12 |
+| `S08_007_006` | 双曲线的焦点三角形 | 11 | 48 |
+| `S07_007_002` | 直线与平面所成的角 | 23 | 48 |
+| `S09_004_003` | 数列中的任意三项成等差或等比 | 14 | 0 |
+| `S10_009_003` | 求展开式的特定项 | 4 | 13 |
+| `S04_007_019` | 指数函数在实际生活中的应用 | 13 | 16 |
+| `S04_003_010` | 存在递推关系的分段函数 | 10 | 0 |
+| `S06_003_002` | 五点法作三角函数图象 | 10 | 14 |
+| `S09_002` | 等差数列 | 25 | 23 |
+| `S08_003_001` | 圆的定义与标准方程 | 8 | 59 |
+| `S07_001` | 空间几何体的结构 | 0 | 11 |
+| `S06_007_004` | 向量的模与夹角的计算 | 101 | 61 |
+| `S03_003_007` | 一元二次不等式的恒成立 | 28 | 31 |
+| `S01_002_005` | 子集、真子集的个数 | 10 | 19 |
+| `S04_007_014` | 利用指数函数解不等式 | 33 | 40 |
+| `S05_005_008` | 值域法 | 0 | 4 |
+| `S10_004_003` | 两点分布 | 23 | 10 |
+| `S03_004_001` | 恒成立与存在性 | 14 | 54 |
+| `S07_002_004` | 空间几何体的三视图 | 8 | 0 |
+| `S06_010_009` | 判断锐角三角形与三角形解的个数 | 25 | 4 |
+| `S08_002_003` | 直线过定点 | 9 | 12 |
+| `S08_008` | 抛物线 | 0 | 242 |
+| `S04_002_013` | 函数图象的轴对称 | 9 | 4 |
+| `S09_001_002` | 数列的通项公式与递推公式 | 52 | 65 |
+| `S06_003_003` | 三角函数图象的变换(平移、伸缩、对称) | 23 | 4 |
+| `S09_003_005` | 等比数列的性质与单调性 | 34 | 57 |
+| `S08_002_002` | 经过两直线交点的直线方程 | 6 | 18 |
+| `S09_002_005` | 等差数列的性质 | 22 | 56 |
+| `S09_006_006` | 并项求和法 | 8 | 12 |
+| `S10_009_006` | 二项式系数与展开式系数 | 13 | 5 |
+| `S05_001` | 导数的概念及其意义 | 0 | 11 |
+| `S08_001_007` | 两直线的位置关系(平行、垂直、相交) | 30 | 41 |
+| `S04_002_003` | 复合函数的单调性 | 12 | 7 |
+| `S04_006_004` | 幂函数单调性的应用 | 37 | 14 |
+| `S07_007` | 空间的角与距离 | 0 | 46 |
+| `S10_001_005` | 抽样方案的设计 | 33 | 3 |
+| `S07_003_005` | 用等体积法求体积与距离 | 9 | 0 |
+| `S08_002_007` | 中点坐标公式 | 31 | 0 |
+| `S08_004_004` | 切线长 | 0 | 20 |
+| `S05_006_001` | 导数法研究不等式 | 17 | 37 |
+| `S07_005_027` | 数量积的运算 | 1 | 2 |
+| `S04_007_001` | 根式的概念与性质 | 10 | 37 |
+| `S04_002_014` | 函数图象的中心对称 | 13 | 4 |
+| `G06` | 几何综合与辅助线 | 13 | 16 |
+| `S07_003_004` | 用分割法求表面积和体积 | 11 | 0 |
+| `S10_004_006` | 离散型随机变量的方差与标准差 | 23 | 0 |
+| `S01_003_004` | 容斥原理与元素个数 | 10 | 0 |
+| `S03` | 分式与分式运算 | 126 | 109 |
+| `S09_006_001` | 公式法求和 | 6 | 2 |
+| `S04_004_007` | 抽象函数奇偶性的判定 | 18 | 0 |
+| `S04_005_003` | 区定轴动型的最值 | 8 | 4 |
+| `M04F1` | 几何量与代数结合 | 16 | 4 |
+| `S04_001_011` | 换元配凑法求函数解析式 | 9 | 24 |
+| `S03_001_002` | 实数大小比较的依据 | 17 | 8 |
+| `S10_005_005` | 连续型随机变量 | 14 | 0 |
+| `S04_001_006` | 解析法表示函数 | 9 | 15 |
+| `S04_004_009` | 迭代法求抽象函数的周期 | 2 | 0 |
+| `S04_010_004` | 二分法求函数零点的近似值 | 10 | 68 |
+| `S05_003` | 导数的几何应用——切线问题 | 4 | 37 |
+| `S01_001_003` | 集合的分类与常用数集记法 | 29 | 2 |
+| `S05_006_003` | 分离参数法 | 6 | 9 |
+| `S10_008_008` | 隔板法 | 8 | 0 |
+| `S09_007_006` | 用归纳、猜想及数学归纳法解决递推问题 | 41 | 10 |
+| `S04_007_013` | 利用指数函数比较大小 | 9 | 26 |
+| `S02_001_001` | 充分条件、必要条件与充要条件的概念 | 10 | 4 |
+| `S04_004_006` | 利用抽象函数单调性解不等式 | 51 | 2 |
+| `S06_003_008` | 利用性质比较大小与求参数 | 10 | 25 |
+| `S01_001` | 集合的概念 | 0 | 40 |
+| `S07_007_003` | 二面角 | 20 | 57 |
+| `S10_009_005` | 赋值法求系数和 | 7 | 13 |
+| `S07_008_004` | 空间向量基本定理与基底表示 | 28 | 72 |
+| `S09_003_006` | 等比数列的前n项和公式 | 30 | 64 |
+| `PY02B` | 最短路径问题 | 79 | 18 |
+| `S09_001_005` | 数列的单调性与周期性 | 19 | 46 |
+| `S04_008_021` | 对数型函数的单调性 | 19 | 20 |
+| `S04_010_001` | 函数零点的概念 | 8 | 0 |
+| `S08_002_009` | 点关于点对称 | 11 | 21 |
+| `S10_002_002` | 茎叶图、条形图、折线图、扇形图 | 11 | 49 |
+| `S04_002_001` | 函数单调性的定义 | 8 | 17 |
+| `S04_007_015` | 利用指数函数求参数 | 10 | 41 |
+| `S09_005_001` | 累加法 | 8 | 4 |
+| `S01_002_004` | 由集合间关系求参数的值或范围 | 77 | 35 |
+| `S09_003_003` | 等比数列与指数函数的关系 | 24 | 0 |
+| `S10_008_007` | 捆绑法与插空法 | 9 | 0 |
+| `S04_008_005` | 自然对数 | 10 | 0 |
+| `S06_003_005` | 三角函数的对称性 | 12 | 30 |
+| `S06_002_003` | 三角函数值在各象限内的符号 | 38 | 12 |
+| `S07_007_005` | 平移法、补形法求异面直线所成角 | 53 | 0 |
+| `S05_006_008` | 放缩法证明不等式 | 6 | 2 |
+| `S10_009` | 二项式定理 | 0 | 65 |
+| `S04_007_016` | 指数型复合函数的值域 | 15 | 22 |
+| `S04_002_007` | 函数的最值与值域 | 17 | 64 |
+| `S04_002_011` | 奇偶性的应用 | 9 | 215 |
+| `S05_005_002` | 利用函数单调性构造函数 | 9 | 24 |
+| `S09_002_004` | 等差数列的判定与证明 | 23 | 17 |
+| `S04_001_010` | 方程组法求函数解析式 | 37 | 9 |
+| `S07_004_003` | 异面直线的概念与判定 | 7 | 6 |
+| `S000_000` | 高中数学知识体系 | 0 | 12 |
+| `S03_001_007` | 利用不等式性质证明不等式 | 16 | 14 |
+| `S09_007_005` | 用数学归纳法证明整除问题 | 15 | 13 |
+| `S08_000` | 解析几何 | 13 | 488 |
+| `S06_006_006` | 向量共线定理及其几何应用 | 238 | 25 |
+| `S04_007_012` | 底数对指数函数图象的影响 | 17 | 0 |
+| `S01_001_005` | 集合相等与求参数 | 26 | 38 |
+| `S06_003` | 三角函数的图象与性质 | 1 | 271 |
+| `M04A2` | 面积单位与换算 | 8 | 0 |
+| `S04_001_009` | 待定系数法求函数解析式 | 10 | 6 |
+| `S04_004_003` | 抽象函数的函数值 | 10 | 0 |
+| `S05_005_001` | 利用导数运算法则构造函数 | 15 | 21 |
+| `M04B3` | 圆的周长 | 9 | 0 |
+| `S07_007_006` | 定义法、等体积法求线面角 | 10 | 0 |
+| `S05_006_004` | 函数零点(个数、存在性、共零点) | 10 | 81 |
+| `S04_001_016` | 函数图象的平移变换 | 12 | 4 |
+| `S10_008_004` | 组合数的性质 | 25 | 0 |
+| `S07_002_003` | 由直观图还原平面图形 | 11 | 15 |
+| `S08_005` | 圆与圆的位置关系 | 0 | 30 |
+| `S03_001_001` | 不等关系与不等式 | 19 | 24 |
+| `S06_004_007` | 三角函数式的化简、求值与证明 | 78 | 48 |
+| `S09_004` | 等差与等比数列的综合 | 0 | 5 |
+| `S04_008_002` | 对数的性质 | 10 | 0 |
+| `S09_006_002` | 分组求和法 | 10 | 20 |
+| `S09_005_007` | 因式分解与递推消元 | 11 | 0 |
+| `S08_002` | 直线的交点与距离 | 0 | 79 |
+| `S05_006_010` | 洛必达法则的应用 | 8 | 2 |
+| `S08_007_001` | 双曲线的定义与标准方程 | 24 | 94 |
+| `S04_004` | 抽象函数 | 1 | 11 |
+| `S08_006_006` | 椭圆的离心率 | 16 | 236 |
+| `S07_005_005` | 利用平行关系的性质解题 | 21 | 14 |
+| `S04_005_007` | 分段研究法求二次相关函数值域 | 8 | 0 |
+| `S07_006_002` | 直线与平面垂直的判定与性质 | 110 | 77 |
+| `S01_002_003` | 集合间关系的判断方法 | 25 | 30 |
+| `S05_000` | 导数 | 0 | 143 |
+| `S06_001` | 任意角和弧度制 | 0 | 181 |
+| `S09_001` | 数列的概念 | 4 | 24 |
+| `S08_005_003` | 两圆相交的公共弦 | 12 | 36 |
+| `S08_003_002` | 圆的一般方程 | 8 | 69 |
+| `S08_001_001` | 直线的倾斜角与斜率 | 51 | 119 |
+| `S05_003_004` | 曲线上两点处切线垂直 | 7 | 3 |
+| `S04_006_003` | 幂函数图象的应用 | 5 | 4 |
+| `S07_007_007` | 定义法、三垂线法、射影面积法求二面角 | 46 | 1 |
+| `S06_005_002` | 由 y=sin x 的图象得到 y=Asin(ωx+φ) 的图象 | 14 | 3 |
+| `S08_006_001` | 椭圆的定义与标准方程 | 8 | 98 |
+| `S04_010_012` | 分段函数的零点 | 16 | 0 |
+| `S10_006` | 统计推断 | 0 | 5 |
+| `S06_007_003` | 向量数量积的运算方法(定义法、基底法、坐标法) | 9 | 15 |
+| `S07_001_006` | 几何体的截面 | 29 | 37 |
+| `S04_001_005` | 同一函数的判定 | 2 | 17 |
+| `S08_003_004` | 几何法求圆的方程 | 19 | 7 |
+| `S01_001_002` | 集合中元素的三个特性(确定性、互异性、无序性) | 13 | 9 |
+| `S04_003_004` | 分段函数的解析式 | 12 | 7 |
+| `S09_001_003` | 数列的前n项和 | 23 | 4 |
+| `S08_005_001` | 两圆的位置关系判定 | 8 | 32 |
+| `S08_006_004` | 椭圆标准方程的求解 | 17 | 11 |
+| `S10_009_007` | 系数的最值问题 | 9 | 11 |
+| `S09_003_008` | 等比数列的前n项积 | 11 | 1 |
+| `S07_003` | 空间几何体的表面积与体积 | 10 | 43 |
+| `S04_007_004` | 指数幂的化简 | 28 | 36 |
+| `S04_005_006` | 换元法求二次相关函数值域 | 6 | 0 |
+| `S04_008_016` | 对数函数的性质 | 13 | 6 |
+| `S03_003_001` | 一元二次不等式的概念与解法 | 77 | 106 |
+| `S05_001_001` | 瞬时速度与抛物线切线斜率(导数的引入) | 17 | 40 |
+| `S05_006_002` | 不等式的恒成立 | 12 | 106 |
+| `S04_002_005` | 利用单调性解不等式 | 11 | 32 |
+| `S08_003_008` | 阿波罗尼斯圆 | 9 | 1 |
+| `S07_002_005` | 利用三视图探究空间几何体 | 60 | 2 |
+| `S11_000` | 复数 | 0 | 92 |
+| `S04_007` | 指数与指数函数 | 0 | 132 |
+| `S03_002_008` | 变用公式(对数变换、三角变换、常数代换) | 17 | 0 |
+| `S06_007_005` | 向量夹角的充要条件(锐角、直角、钝角) | 41 | 3 |
+| `S04_008_024` | 利用对数函数求参数 | 13 | 16 |
+| `S10_007_001` | 分类加法计数原理 | 28 | 13 |
+| `S08_006_008` | 直线与椭圆的位置关系 | 26 | 62 |
+| `S04_005` | 二次函数 | 1 | 48 |
+| `S02_003` | 四种命题与逻辑联结词 | 0 | 4 |
+| `S04_004_005` | 抽象函数单调性的应用 | 12 | 0 |
+| `S04_001_001` | 函数的概念与定义 | 8 | 66 |
+| `S03_001_006` | 利用不等式性质求代数式的取值范围 | 192 | 26 |
+| `ST06A` | 统计综合题 | 44 | 53 |
+| `S04_003_002` | 分段函数的求值 | 18 | 33 |
+| `S04_002_010` | 函数奇偶性的性质 | 9 | 13 |
+| `S04_010` | 函数的零点 | 0 | 75 |
+| `S06_005` | 函数 y=Asin(ωx+φ) 及其应用 | 3 | 90 |
+| `S08_001_003` | 点斜式与斜截式直线方程 | 30 | 47 |
+| `E02A` | — | 8 | 0 |
+| `S05_002` | 导数的运算 | 15 | 10 |
+| `S08_004_008` | 切割线定理 | 14 | 0 |
+| `S01_002` | 集合间的基本关系 | 2 | 24 |
+| `S05_005_003` | 设而不求的思想 | 10 | 0 |
+| `S01_002_006` | 区间的概念与区间长度 | 11 | 0 |
+| `S03_002_007` | 分离常数法与连续使用基本不等式 | 13 | 12 |
+| `S06_002_002` | 三角函数的定义域和值域 | 11 | 1 |
+| `S04_001_018` | 函数图象的伸缩变换 | 10 | 0 |
+| `S08_007_004` | 双曲线的离心率 | 20 | 250 |
+| `S06_003_004` | 三角函数的周期性、单调性、奇偶性 | 24 | 102 |
+| `S10_004_007` | 均值与方差的性质 | 15 | 3 |
+| `S10_005_003` | 超几何分布 | 15 | 25 |
+| `S04_003_008` | 分段函数的奇偶性 | 8 | 0 |
+| `S07_008_006` | 空间向量运算的坐标表示 | 28 | 18 |
+| `S10_003_003` | 互斥事件与对立事件 | 23 | 6 |
+| `S10_002` | 用样本估计总体 | 0 | 49 |
+| `S06_004_005` | 和差角公式与二倍角公式的正用、逆用、变用 | 13 | 28 |
+| `S04_005_008` | 平方法求二次相关函数值域 | 10 | 0 |
+| `S04_002_009` | 函数奇偶性的判断 | 9 | 41 |
+| `S05_006` | 导数中的重点问题 | 0 | 29 |
+| `S08_003_006` | 圆的对称圆方程 | 11 | 13 |
+| `S10_005_004` | 二项分布与超几何分布的应用 | 73 | 1 |
+| `S04_008_014` | 对数函数的概念 | 10 | 14 |
+| `S06_001_002` | 象限角与轴线角 | 8 | 10 |
+| `S07_001_002` | 旋转体(圆柱、圆锥、圆台、球) | 27 | 76 |
+| `S04_007_006` | 指数幂的条件求值 | 24 | 14 |
+| `S09_005_004` | 待定系数法 | 6 | 0 |
+| `S07_003_002` | 圆柱、圆锥、圆台、球的表面积和体积 | 98 | 73 |
+| `S07_008_007` | 空间向量平行与垂直的坐标表示 | 36 | 71 |
+| `S06_003_007` | 三角函数的零点 | 1 | 18 |
+| `S08_008_005` | 抛物线的焦点弦 | 5 | 44 |
+| `S08_007` | 双曲线 | 0 | 339 |
+| `M01C` | 复杂代数式结构分析 | 21 | 0 |
+| `S04_004_011` | 做差化归法求抽象函数的周期 | 1 | 0 |
+| `S05_006_009` | 多变量不等式的证明 | 11 | 13 |
+| `S02_003_003` | 含有逻辑联结词的命题(且、或、非) | 11 | 1 |
+| `S04_008_003` | 对数恒等式 | 11 | 5 |
+| `S04_003` | 分段函数 | 0 | 45 |
+| `S05_001_004` | 导函数的定义 | 13 | 19 |
+| `S04_002_016` | 特殊函数 f(x)=x+a/x (a≠0) 的图象与性质 | 46 | 0 |
+| `S10_002_003` | 总体百分位数的估计 | 34 | 49 |
+| `S08_003_003` | 圆的参数方程 | 5 | 0 |
+| `S04_000` | 函数 | 2 | 323 |
+| `S07_003_007` | 几何体的外接球与内切球 | 68 | 72 |
+| `S07_007_008` | 定义法、等体积法、转化法求空间距离 | 8 | 0 |
+| `S08_008_002` | 抛物线的对称性与顶点 | 10 | 2 |
+| `S05_005` | 导数中的构造与方法 | 5 | 45 |
+| `S09_002_007` | 等差数列前n项和与二次函数的关系 | 44 | 11 |
+| `S03_003_006` | 绝对值不等式的解法(几何意义、平方法、零点分区讨论) | 30 | 1 |
+| `S04_010_013` | 函数零点与等高线 | 9 | 0 |
+| `S09_005_008` | 迭代法与观察配凑法 | 6 | 0 |
+| `S04_008` | 对数与对数函数 | 0 | 144 |
+| `S08_004_002` | 过圆上一点的切线方程 | 11 | 30 |
+| `S04_004_002` | 抽象函数的值域 | 5 | 2 |
+| `S07_006` | 空间的垂直关系 | 0 | 91 |
+| `S03_002_002` | 基本不等式及最值定理 | 22 | 7 |
+| `S08_002_010` | 点关于直线对称 | 21 | 56 |
+| `G06A` | 常用辅助线方法 | 6 | 0 |
+| `S02_001_004` | 应用充分必要条件求参数的值或范围 | 19 | 40 |
+| `S06_002` | 三角函数的定义与诱导公式 | 0 | 231 |
+| `E02B` | — | 6 | 0 |
+| `S09_000` | 数列 | 0 | 98 |
+| `S10_003_007` | 样本空间的求法 | 4 | 0 |
+
+**合计**:questions 23297 题;questions_tem(不重复)25100 题。

+ 100 - 0
docs/paper_132736388400529_根因分析.md

@@ -0,0 +1,100 @@
+# paper_132736388400529 题目不足根因分析
+
+## 一、现象
+
+- 请求:知识点组卷,20 题,kp_code_list 含 7 个知识点
+- 实际:仅产出 7 题
+
+## 二、日志链路复盘
+
+### 1. ExamTypeStrategy 阶段
+
+```
+QuestionExpansionService: Step1 - 获取直接关联知识点题目
+  added_count: 20, total_count: 20(expansion 未用 exclude,拿到 20 个 ID)
+
+知识点组卷题池评估: pool_count_after_expand: 20, total_questions: 20
+  → basePoolCount >= totalQuestions,mistake_question_ids = [](不传扩展结果)
+
+从 paper_questions 获取学生已做题目: exclude_count: 219
+```
+
+### 2. getQuestionsFromBank 阶段
+
+```
+应用知识点筛选: kp_codes 含 7 个(含 typo S06-002_003)
+应用学段筛选: grade=11 → stage_grade=3(高中)
+应用排除筛选: exclude_count=219
+
+getQuestionsFromBank: 查询完成
+  raw_count: 7, total_needed: 0, database_query_count: 7
+```
+
+### 3. selectQuestionsByMastery 阶段
+
+```
+题目数量不足,将使用所有可用题目 (available: 7, requested: 20)
+题型分配: choice 7, fill 0, answer 0(全部为选择题,填空/解答无题)
+题型缺口补充: deficit=13, 最终仍为 7(无更多题目可补)
+```
+
+---
+
+## 三、根因归纳
+
+### 根因 1:poolLimit=0 导致智能补充不触发(主因)
+
+| 现象 | 原因 |
+|------|------|
+| total_needed: 0 | 调用 getQuestionsFromBank 时传入的第 5 参为 poolLimit=0 |
+| 智能补充未执行 | 条件 `totalNeeded > 0 && count < totalNeeded && grade !== null`,因 totalNeeded=0 不成立 |
+
+**代码位置**:`LearningAnalyticsService.php` 约 1456 行
+
+```php
+$additionalQuestions = $this->getQuestionsFromBank(
+    ...
+    $poolLimit,  // ← 传入 0,导致 totalNeeded=0
+    ...
+);
+```
+
+智能补充条件(约 1801 行):
+
+```php
+if ($totalNeeded > 0 && count($selectedQuestions) < $totalNeeded && $grade !== null) {
+    // 智能补充逻辑
+}
+```
+
+当 `totalNeeded=0` 时,永远不进入智能补充。
+
+### 根因 2:指定知识点下可用题目仅 7 道
+
+- 指定 7 个 kp 下,审核通过的题目约 60 道(grade=3)
+- 排除学生已做 219 题后,剩余 7 道(约 53 道与 exclude 重合)
+- 即该学生在这些知识点上已做过大部分题,题池接近耗尽
+
+### 根因 3:参数 typo 导致 1 个知识点无题
+
+- 输入为 `S06-002_003`(连字符),应为 `S06_002_003`(下划线)
+- 该 typo 导致该知识点匹配 0 题(对本次 7 题结果影响有限,因其他知识点仍有题)
+
+---
+
+## 四、修复建议
+
+### 建议 1:修正 totalNeeded 传参(已修复)
+
+已修改:调用 getQuestionsFromBank 时传入 `$needCount = $totalQuestions - count($priorityQuestions)`,使 `totalNeeded > 0`,从而在题目不足时触发智能补充。
+
+### 建议 2:知识点组卷传入 grade(必须)
+
+- 当前 textbook_id=null,但 grade=11 已传入
+- 智能补充只需 grade 即可从同年级其他知识点补题
+- 需确认 params 中 grade 正确传到 getQuestionsFromBank(日志显示已传入)
+
+### 建议 3:前端参数校验(可选)
+
+- 对 kp_code 格式做基础校验,避免 `S06-002_003` 类 typo
+- 或在后端对 `S06-002_003` 自动规范为 `S06_002_003`

+ 118 - 0
docs/paper_132736538400759_全盘分析.md

@@ -0,0 +1,118 @@
+# paper_132736538400759 全盘分析
+
+## 一、卷子基本信息
+
+| 项目 | 值 |
+|------|-----|
+| 卷子 ID | paper_132736538400759 |
+| 卷名 | 测试006_132736538400759_知识点组题_20260323165343QY16 |
+| 学生 | 测试006 (1764913911) |
+| 组卷类型 | 2(知识点组卷) |
+| 请求题量 | 20 |
+| 实际题量 | **20** ✅ |
+| 创建时间 | 2026-03-23 16:53:32 |
+| 重复校验 | **0** 与已做题目重复 |
+
+---
+
+## 二、生成流程(日志复盘)
+
+| 阶段 | 结果 |
+|------|------|
+| 指定 KP | S06_002 系列(7 个,含 typo S06-002_003) |
+| 主查询 | raw_count=**0**(指定 KP 题池已耗尽) |
+| 智能补充 | deficit=20,从同年级 **58 个知识点** 中补足 20 题 |
+| 题型分配 | choice 10,fill 5,answer 5 |
+
+---
+
+## 三、题目列表与知识点分布
+
+### 3.1 题目明细
+
+| 题号 | 题目ID | 知识点 | 知识点名称 | 题型 | 难度 |
+|------|--------|--------|------------|------|------|
+| 1 | 23879 | S10_008_003 | 组合与组合数 | choice | 0.02 |
+| 2 | 37943 | S05_001_005 | 导数的几何意义 | choice | 0.11 |
+| 3 | 26206 | S06_010_005 | 利用正余弦定理解三角形 | choice | 0.20 |
+| 4 | 27295 | S10_009_001 | 二项式定理与二项式系数 | choice | 0.20 |
+| 5 | 34104 | S07_005_001 | 直线与直线平行的判定与性质 | choice | 0.20 |
+| 6 | 33533 | S09_002_001 | 等差数列的概念与通项公式 | choice | 0.21 |
+| 7 | 27900 | S10_008_003 | 组合与组合数 | choice | 0.40 |
+| 8 | 28319 | S10_003_009 | 条件概率与概率的乘法公式 | choice | 0.40 |
+| 9 | 35923 | S08_001_001 | 直线的倾斜角与斜率 | choice | 0.70 |
+| 10 | 38910 | S09_002_001 | 等差数列的概念与通项公式 | choice | 0.70 |
+| 11 | 23946 | S10_009_001 | 二项式定理与二项式系数 | fill | 0.01 |
+| 12 | 28502 | S09_002_001 | 等差数列的概念与通项公式 | fill | 0.20 |
+| 13 | 36861 | S04_002_015 | 函数性质的综合应用 | fill | 0.20 |
+| 14 | 36707 | S06_003_004 | 三角函数的周期性、单调性、奇偶性 | fill | 0.40 |
+| 15 | 38257 | S05_004 | 导数在研究函数中的应用 | fill | 0.43 |
+| 16 | 23840 | S10_007_001 | 分类加法计数原理 | answer | 0.06 |
+| 17 | 28679 | S07_006_001 | 直线与直线垂直的判定与性质 | answer | 0.20 |
+| 18 | 37696 | S06_010_005 | 利用正余弦定理解三角形 | answer | 0.20 |
+| 19 | 38174 | S06_010_011 | 测量与实际应用 | answer | 0.20 |
+| 20 | 29266 | S06_010_005 | 利用正余弦定理解三角形 | answer | 0.70 |
+
+### 3.2 知识点分布统计
+
+| 知识点 | 名称 | 题数 |
+|--------|------|------|
+| S06_010_005 | 利用正余弦定理解三角形 | 3 |
+| S09_002_001 | 等差数列的概念与通项公式 | 3 |
+| S10_008_003 | 组合与组合数 | 2 |
+| S10_009_001 | 二项式定理与二项式系数 | 2 |
+| S10_007_001 | 分类加法计数原理 | 1 |
+| S10_003_009 | 条件概率与概率的乘法公式 | 1 |
+| S07_006_001 | 直线与直线垂直的判定与性质 | 1 |
+| S07_005_001 | 直线与直线平行的判定与性质 | 1 |
+| S08_001_001 | 直线的倾斜角与斜率 | 1 |
+| S06_003_004 | 三角函数的周期性、单调性、奇偶性 | 1 |
+| S04_002_015 | 函数性质的综合应用 | 1 |
+| S05_001_005 | 导数的几何意义 | 1 |
+| S06_010_011 | 测量与实际应用 | 1 |
+| S05_004 | 导数在研究函数中的应用 | 1 |
+
+**共 14 个知识点,题型:选择 10 / 填空 5 / 解答 5,难度约 0.01~0.70**
+
+---
+
+## 四、知识点 → 章节关联(来自 textbook_chapter_knowledge_relation)
+
+| 知识点 | 知识点名称 | 关联章节(教材·章节名) |
+|--------|------------|---------------------------|
+| S06_010_005 | 利用正余弦定理解三角形 | 11年级下册 · 正弦定理与余弦定理的应用 |
+| S06_010_011 | 测量与实际应用 | 10年级下册 · 三角函数的简单应用;11年级下册 · 数学探究活动:得到不可达两点之间的距离 |
+| S06_003_004 | 三角函数的周期性、单调性、奇偶性 | 11年级上册 · 三角函数的性质与图像 |
+| S09_002_001 | 等差数列的概念与通项公式 | 11年级下册 · 等差数列;12年级下册 · 等差数列 |
+| S10_007_001 | 分类加法计数原理 | 11年级上册 · 基本计数原理 |
+| S10_008_003 | 组合与组合数 | 11年级上册 · 组合问题 |
+| S10_009_001 | 二项式定理与二项式系数 | 11年级上册 · 二项式定理;12年级下册 · 二项式定理与杨辉三角 |
+| S10_003_009 | 条件概率与概率的乘法公式 | 11年级上册 · 随机事件的条件概率;12年级上册 · 条件概率与全概率公式;12年级下册 · 条件概率与事件的独立性 |
+| S07_005_001 | 直线与直线平行的判定与性质 | 10年级下册 · 平行关系;11年级下册 · 空间中的平行关系 |
+| S07_006_001 | 直线与直线垂直的判定与性质 | 11年级下册 · 空间中的垂直关系 |
+| S08_001_001 | 直线的倾斜角与斜率 | 11年级上册 · 直线的倾斜角与斜率 |
+| S04_002_015 | 函数性质的综合应用 | 10年级上册 · 函数的应用(一)(二);11年级下册 · 数学探究活动(二):探究函数性质 |
+| S05_001_005 | 导数的几何意义 | 11年级下册 · 导数的概念及其几何意义 |
+| S05_004 | 导数在研究函数中的应用 | 11年级下册 · 导数在研究函数中的应用 |
+
+---
+
+## 五、章节覆盖概览(按教材年级)
+
+| 教材 | 涉及章节 |
+|------|----------|
+| **11年级上册** | 三角函数的性质与图像、基本计数原理、组合问题、二项式定理、随机事件的条件概率、直线的倾斜角与斜率 |
+| **11年级下册** | 正弦定理与余弦定理的应用、等差数列、空间中的平行关系、空间中的垂直关系、导数的概念及其几何意义、导数在研究函数中的应用、数学探究活动 |
+| **10年级上册** | 函数的应用(一)(二) |
+| **10年级下册** | 三角函数的简单应用、平行关系 |
+| **12年级上册** | 条件概率与全概率公式 |
+| **12年级下册** | 二项式定理与杨辉三角、等差数列、条件概率与事件的独立性 |
+
+---
+
+## 六、结论
+
+1. **智能补充已生效**:指定 S06_002 系列题池为 0 时,从同年级(11 年级)其他 58 个知识点中补足 20 题。
+2. **无重复出题**:20 题均不在该生 226 道已做题目中。
+3. **知识点来源**:全部来自「同年级其他知识点」,覆盖 11 年级上/下册及少量 10、12 年级内容。
+4. **题型比例**:接近 4:2:4(选择:填空:解答),填空略少系补题池中填空较少所致。

+ 88 - 0
docs/paper_132736648500556_分析_修复后.md

@@ -0,0 +1,88 @@
+# paper_132736648500556 分析(智能补题修复后)
+
+## 一、卷子基本信息
+
+| 项目 | 值 |
+|------|-----|
+| 卷子 ID | paper_132736648500556 |
+| 卷名 | 测试006_132736648500556_知识点组题_20260323 |
+| 学生 | 测试006 (1764913911) |
+| 组卷类型 | 2(知识点组卷) |
+| 请求题量 | 20 |
+| 实际题量 | **20** ✅ |
+| 创建时间 | 2026-03-23 17:04:34 |
+| textbook_id | null(无教材) |
+
+---
+
+## 二、生成流程(日志复盘)
+
+| 阶段 | 结果 |
+|------|------|
+| 指定 KP | S06_002 系列(7 个,含 typo S06-002_003) |
+| 主查询 | raw_count=**0**(指定 KP 题池已耗尽) |
+| 智能补充 | deficit=20,**从学生已学知识点** 补充 ✅ |
+| 已学 KP 数 | 31(getStudentLearnedKpCodes) |
+| 可用补充 KP | 31(排除已选 0 个后) |
+| 实际补充 | 20 题,来自 9 个知识点 |
+
+### 日志关键行
+
+```
+strategy: "从学生已学知识点补充"
+getStudentLearnedKpCodes: 已学知识点 {"student_id":"1764913911","kp_count":31}
+getSupplementaryQuestionsForGrade: 无教材模式,使用学生已学知识点 {"learned_count":31,"after_exclude_existing":31}
+getSupplementaryQuestionsForGrade: 补充完成 {"supplementary_count":20,"kp_distribution":{...}}
+```
+
+---
+
+## 三、题目知识点分布(修复后)
+
+### 3.1 智能补充的 9 个知识点
+
+| 知识点 | 名称 | 题数 |
+|--------|------|------|
+| S07_006_001 | 直线与直线垂直的判定与性质 | 4 |
+| S10_003_009 | 条件概率与概率的乘法公式 | 3 |
+| S04_002_015 | 函数性质的综合应用 | 3 |
+| S10_007_001 | 分类加法计数原理 | 2 |
+| S10_009_001 | 二项式定理与二项式系数 | 2 |
+| S06_010_011 | 测量与实际应用 | 2 |
+| S06_010_005 | 利用正余弦定理解三角形 | 2 |
+| S09_002_001 | 等差数列的概念与通项公式 | 1 |
+| S10_008_003 | 组合与组合数 | 1 |
+
+**共 9 个知识点,题型:choice 13 / fill 3 / answer 4**(填空偏少系补题池中填空较少所致)
+
+### 3.2 题目 ID 列表
+
+| 题号 | question_bank_id | 知识点 |
+|------|------------------|--------|
+| 1 | 22330 | S06_010_011(圆的周长) |
+| 2 | 22331 | S10_003_009 |
+| ... | 26037,26839,28079,28238,36657,36889,35825,37045,37635,35774,38895,27114,36919,28140,22559,27053,27351,34779 | ... |
+
+---
+
+## 四、与修复前(paper_132736538400759)对比
+
+| 对比项 | 修复前 paper_132736538400759 | 修复后 paper_132736648500556 |
+|--------|------------------------------|------------------------------|
+| 补充策略 | 从同年级 58 个知识点 | **从学生已学 31 个知识点** |
+| 补充 KP 数量 | 14 个 | **9 个** |
+| 可能未学内容 | 有(10/12 年级、未学章节) | **无**(仅来自做题历史) |
+| 导数的几何意义 S05_001_005 | 有 | **无** |
+| 直线与直线平行 S07_005_001 | 有 | **无** |
+| 直线的倾斜角与斜率 S08_001_001 | 有 | **无** |
+| 三角函数的周期性/单调性 S06_003_004 | 有 | **无** |
+| 导数在研究函数中的应用 S05_004 | 有 | **无** |
+
+---
+
+## 五、结论
+
+1. **修复逻辑已生效**:无教材时,智能补充改用 `getStudentLearnedKpCodes`,仅从学生做过的题目对应的 31 个知识点中补充。
+2. **无未学内容**:补充的 9 个知识点均来自学生的做题历史,不再出现「同年级教材范围内但学生尚未学过」的内容。
+3. **知识点收缩**:相比修复前的 14 个 KP,修复后仅 9 个,全部在学生已学范围内,符合「往学过的找」的预期。
+4. **题型分布**:选择 13 / 填空 3 / 解答 4,填空略少系已学知识点中填空题目不足所致,属可接受范围。

+ 172 - 0
docs/组卷流程与知识点题目不足应对措施分析.md

@@ -0,0 +1,172 @@
+# 组卷流程与知识点题目不足应对措施分析
+
+## 一、整体组卷流程概览
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│  ExamTypeStrategy::buildParams() - 根据 assembleType 构建组卷参数              │
+├─────────────────────────────────────────────────────────────────────────────┤
+│  0,9 摸底 → buildChapterDiagnosticParams()                                   │
+│  1,8 智能组卷 → buildChapterIntelligentParams()                              │
+│  2 知识点组卷 → buildKnowledgePointAssembleParams() ← 含 QuestionExpansionService │
+│  3 教材组卷 → buildTextbookAssembleParams()                                 │
+│  4 通用 / 5 错题本 → buildGeneralParams / buildMistakeParams                 │
+└─────────────────────────────────────────────────────────────────────────────┘
+                                    ↓
+┌─────────────────────────────────────────────────────────────────────────────┐
+│  LearningAnalyticsService::generateIntelligentExam()                        │
+│  1. 获取 kp_codes、exclude_question_ids                                     │
+│  2. 优先获取错题(如有)                                                       │
+│  3. getQuestionsFromBank() - 从题库按知识点/章节/难度选题,应用 exclude_question_ids │
+│  4. selectQuestionsByMastery() - 按掌握度/题型/难度二次筛选                     │
+│  5. applyTypeAwareDifficultyDistribution() - 题型感知难度分布                │
+└─────────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 二、「知识点题目不足」涉及的层级
+
+| 层级 | 含义 | 典型触发场景 |
+|------|------|--------------|
+| **整体不足** | 指定 kp_codes 下题目总数 < total_questions | 知识点组卷、教材组卷、智能组卷 |
+| **某难度范围不足** | 基础/中等/拔高某一档题目不够 | QuestionLocalService 难度分布 |
+| **某题型不足** | 选择/填空/解答某一题型不够 | IntelligentExamGeneration、applyTypeAwareDifficultyDistribution |
+| **去重后不足** | selectQuestionsByMastery 去重后数量变少 | 重复题目被去除 |
+
+---
+
+## 三、各环节的应对措施(现状)
+
+### 3.1 ExamTypeStrategy(参数构建)
+
+| 措施 | 所在位置 | 说明 |
+|------|----------|------|
+| **QuestionExpansionService 扩展** | `buildKnowledgePointAssembleParams`(assembleType=2) | 仅在**知识点组卷**时,基础题池不足会扩展:Step1 直接知识点 → Step2 同知识点 → Step3 子知识点(1层) → Step4 薄弱点 → Step5 子知识点(2层) |
+| **教材组卷** | `buildTextbookAssembleParams` | **不做扩展**,严格按章节关联知识点 |
+| **摸底/智能组卷** | `buildChapterDiagnosticParams` / `buildChapterIntelligentParams` | 最终调用 `buildKnowledgePointAssembleParams`,依赖扩展逻辑 |
+
+### 3.2 LearningAnalyticsService::getQuestionsFromBank
+
+| 措施 | 条件 | 说明 |
+|------|------|------|
+| **智能补充** `getSupplementaryQuestionsForGrade` | `totalNeeded > 0 && count < totalNeeded && grade !== null` | 从**同年级同教材**的其他知识点补充题目,避免超纲 |
+| **返回所有可用题目** | 无条件 | 不再做 limit 截断,把所有符合条件的题目交给上层 |
+
+**⚠️ 重要限制**:`grade === null` 时**不会触发智能补充**,只返回当前知识点能查到的题目。
+
+### 3.3 LearningAnalyticsService::selectQuestionsByMastery
+
+| 措施 | 说明 |
+|------|------|
+| 数量不足时全用 | `count(questions) < totalQuestions` 时打 warning,但仍使用所有可用题目 |
+| 去重后不足时补充 | 从 `selectedQuestions` 中选未选过的题目补齐到目标数量 |
+
+### 3.4 QuestionLocalService(难度分层选题)
+
+| 措施 | 说明 |
+|------|------|
+| 难度范围不足时记录 | 某难度档题目不够时打 warning,不截断,继续选 |
+| 从剩余题目补充 | 按 `getSupplementOrder` 的次级桶顺序补充 |
+| 次级桶仍不足 | 从未使用题目中随机补齐 |
+
+### 3.5 applyTypeAwareDifficultyDistribution(题型感知难度分布)
+
+| 措施 | 说明 |
+|------|------|
+| 题型内补充 | 某题型难度分布不满足目标时,从该题型剩余题目补充 |
+| 跨题型补充 | 总数不足时,按 fill → choice → answer 优先级跨题型补足 |
+
+### 3.6 IntelligentExamGeneration(Filament 页面)
+
+| 措施 | 说明 |
+|------|------|
+| 批量生成题目 | 题库不足时调用 `batchGenerateQuestions()` 生成题目 |
+| 题型不足时跨题型补充 | 某题型可用数量不足,从 choice/fill/answer 其他题型补充 |
+
+---
+
+## 四、当前缺口与建议
+
+### 4.1 grade 未传入时无法智能补充 ✅ 部分实现
+
+**实现**:
+- **教材组卷**:`buildTextbookAssembleParams` 在 `grade` 缺失时从 `textbooks` 表根据 `textbook_id` 推断 `grade`。
+- **智能组卷**(buildIntelligentAssembleParams):`grade` 缺失时从教材推断。
+- **章节摸底 / 按知识点顺序学习**:按需求保持不补充,刻意不传 `grade`。
+
+### 4.2 教材组卷智能补充 ✅ 已实现
+
+**实现**:教材组卷在题目不足时触发 `getSupplementaryQuestionsForGrade` 智能补充,且**仅从同教材前章节**(`textbookCatalogNodeIds` + `textbook_id`)补充,未学章节不会补充。
+
+### 4.3 按知识点维度的题目不足
+
+**现状**:`selectQuestionsByMastery` 按知识点分组、按掌握度排序,但没有「某知识点题目不足时」的显式策略。
+
+**影响**:当希望每个知识点都有一定题量时,若某知识点题目很少,可能被其他知识点挤占,导致该知识点覆盖不足。
+
+**建议**:
+
+- 如需「知识点均匀覆盖」,可在 `selectQuestionsByMastery` 中增加按知识点最小题数的保障逻辑(例如每知识点至少 1 题,再按掌握度/题型等补足总题量)。
+
+### 4.4 补充来源与 exclude 的一致性 ✅ 已实现
+
+**实现**:`getSupplementaryQuestionsForGrade` 已新增 `exclude_question_ids` 参数,智能补充时排除学生已做题目。
+
+### 4.5 仅从前章节补充(未学章节不补充)✅ 已实现
+
+**实现**:当传入 `textbookCatalogNodeIds` 时,通过 `getEarlierChapterNodeIds` 限制补充范围为同教材中「选中章节及之前」的节点(`sort_order <= max(选中章节)`),避免补充未学章节的题目。
+
+### 4.6 是否允许「重复选题」的配置
+
+**现状**:组卷一律使用 `exclude_question_ids` 排除已做题目,没有「允许重复」的开关。
+
+**建议**:
+
+- 若业务允许(如复习模式、薄弱点强化),可增加 `allow_repeat` 之类参数;当 `allow_repeat=true` 时,不传或清空 `exclude_question_ids`。
+
+---
+
+## 五、流程汇总(按题型)
+
+```
+知识点组卷(assembleType=2):
+  ExamTypeStrategy
+    → QuestionExpansionService 扩展(直接 KP → 子 KP → 薄弱点)
+    → getStudentAnsweredQuestionIds → exclude_question_ids
+  LearningAnalyticsService
+    → getQuestionsFromBank(有 grade 时触发智能补充)
+    → selectQuestionsByMastery(不足时用全部可用题目 + 去重后补充)
+    → applyTypeAwareDifficultyDistribution(题型/难度不足时跨题型/跨难度补充)
+
+教材组卷(assembleType=3):
+  ExamTypeStrategy
+    → 按章节获取 kp_codes,无扩展
+    → getStudentAnsweredQuestionIds → exclude_question_ids
+  LearningAnalyticsService
+    → getQuestionsFromBank(有 grade/textbook_id 时触发智能补充)
+    → 若 grade 缺失,智能补充不生效
+
+摸底 / 智能组卷(0/9, 1/8):
+  最终走 buildKnowledgePointAssembleParams
+    → 有 assembleType=2 时走 QuestionExpansionService
+    → 其余同上
+```
+
+---
+
+## 六、结论
+
+当前组卷流程对「题目不足」已有较完整的应对链路:
+
+1. **参数构建层**:知识点组卷有 QuestionExpansionService 扩展。
+2. **题库查询层**:`getQuestionsFromBank` 在 `grade` 有值时,会从同年级同教材其他知识点智能补充。
+3. **选题层**:`selectQuestionsByMastery` 在数量不足时使用全部可用题目,并在去重后补齐。
+4. **难度/题型层**:QuestionLocalService 和 applyTypeAwareDifficultyDistribution 都有跨难度、跨题型的补充逻辑。
+
+主要改进方向:
+
+- 保证出卷入口尽量传入 `grade` / `textbook_id`,以启用智能补充。
+- 在智能补充中引入 `exclude_question_ids`,避免补充已做题目。
+- 视业务需要,为教材组卷增加「同教材其他知识点补充」逻辑。
+- 视业务需要,在选题逻辑中增加「知识点最小覆盖」或「允许重复」等策略。

+ 220 - 0
docs/组卷验证案例与流程说明.md

@@ -0,0 +1,220 @@
+# 组卷验证案例与流程说明
+
+## 一、验证案例:教材组卷(assemble_type=3)
+
+### 1.1 前置条件
+
+- 存在学生、教师、教材及章节数据
+- 学生已有作答历史(用于验证「排除已做题目」生效)
+- 选中章节的题目数量少于 `total_questions`(用于验证「智能补充」生效)
+
+### 1.2 请求示例
+
+**接口**:`POST /api/intelligent-exams`
+
+**请求体**(JSON)- 以七年级下册为例:
+
+```json
+{
+  "student_id": "1764913638",
+  "teacher_id": "1764913637",
+  "student_name": "张三",
+  "teacher_name": "李老师",
+  "grade": 7,
+  "assemble_type": 3,
+  "series_id": 1,
+  "semester_code": 2,
+  "chapter_id_list": [244, 248],
+  "total_questions": 20,
+  "difficulty_category": 1,
+  "paper_name": "七年级下册第1-2章测试"
+}
+```
+
+> **说明**:`semester_code: 2` 表示下学期;`chapter_id_list: [244, 248]` 为教材 `textbook_id=2`(七年级下册)的前 2 个章节节点 ID。
+
+**参数说明**:
+
+| 参数 | 必填 | 说明 |
+|------|------|------|
+| student_id | 是 | 学生ID |
+| teacher_id | 是 | 教师ID |
+| student_name | 是 | 学生姓名 |
+| teacher_name | 是 | 教师姓名 |
+| grade | 是 | 年级(如 7=初一) |
+| assemble_type | 否 | 3=教材组卷,默认为 4(通用) |
+| series_id | 教材组卷必填 | 教材系列ID |
+| semester_code | 教材组卷必填 | 1=上册,2=下册(当前学期示例用 2) |
+| chapter_id_list | 教材组卷推荐 | 章节节点ID 列表;不传则自动选该教材下有题目的章节 |
+| total_questions | 否 | 题目数量,默认 20 |
+| difficulty_category | 否 | 难度类别 1-4 |
+| paper_name | 否 | 试卷名称 |
+
+**说明**:API 通过 `series_id` + `semester_code` + `grade` 解析出 `textbook_id`。内部调用(如 Filament 页面)可直接传 `textbook_id`。
+
+### 1.3 预期行为
+
+1. 不会出现学生已做过的题目(去重生效)
+2. 选中章节题目不足时,会从同教材**前章节**补充(未学章节不补)
+3. 日志中出现 `getSupplementaryQuestionsForGrade`、`排除学生已做过的题目`、`限制为前章节` 等字样
+
+---
+
+## 二、流程说明:从请求到生效的每一步
+
+### Step 0:入口
+
+```
+POST /api/intelligent-exams
+  → IntelligentExamController::store()
+  → 校验参数,resolveTextbookId(若用 series_id)
+  → $params 传入 LearningAnalyticsService::generateIntelligentExam()
+```
+
+### Step 1:组卷类型策略(ExamTypeStrategy)
+
+```
+generateIntelligentExam($params)
+  → $params['assemble_type'] = 3(教材组卷)
+  → ExamTypeStrategy::buildParams($params, 3)
+  → buildTextbookAssembleParams($params)
+```
+
+**buildTextbookAssembleParams 主要逻辑**:
+
+1. 解析 `chapter_id_list`,若无则从教材下自动选有题目的章节  
+2. 根据章节获取 `kp_codes`(知识点)  
+3. **排除已做题目**:`getStudentAnsweredQuestionIds($studentId, $kpCodes)`  
+   - 从 `paper_questions` 查学生试卷  
+   - 取 `question_bank_id`(对应 `questions.id`)  
+   - 作为 `exclude_question_ids` 写入 `params`  
+4. **补全 grade**:若 `grade` 缺失,从 `textbooks` 表按 `textbook_id` 推断  
+5. 输出增强后参数:`kp_codes`、`textbook_catalog_node_ids`、`exclude_question_ids`、`grade`、`textbook_id`
+
+### Step 2:智能出卷主体(LearningAnalyticsService)
+
+```
+generateIntelligentExam 继续
+  → 获取 kp_codes、exclude_question_ids 等
+  → 若有错题优先获取错题
+  → getQuestionsFromBank($kpCodes, ..., $excludeQuestionIds, $textbookCatalogNodeIds, $grade, $textbookId, ...)
+```
+
+### Step 3:题库选题(getQuestionsFromBank)
+
+1. **主查询**  
+   - 按 `kp_codes`、`grade`、难度等筛选  
+   - `whereNotIn('id', $excludeQuestionIds)`,排除已做题目  
+   - 若配置了 `textbook_catalog_node_ids`,再按章节节点筛选  
+
+2. **检查题目是否足够**  
+   - 若 `count($selectedQuestions) < $totalNeeded` 且 `$grade !== null`  
+   - 调用智能补充  
+
+3. **智能补充**  
+   ```php
+   getSupplementaryQuestionsForGrade(
+       $grade,
+       array_column($selectedQuestions, 'kp_code'),  // 排除已选知识点
+       $deficit,
+       $difficultyCategory,
+       $textbookId,
+       $excludeQuestionIds,           // ← 排除已做题目
+       $textbookCatalogNodeIds        // ← 用于前章节限制
+   )
+   ```
+
+### Step 4:智能补充(getSupplementaryQuestionsForGrade)
+
+1. **排除已做题目**  
+   ```php
+   if (!empty($excludeQuestionIds)) {
+       $query->whereNotIn('id', $excludeQuestionIds);
+   }
+   ```
+   - 补充题同样不会包含学生已做过的题  
+
+2. **同年级同教材**  
+   - `getGradeKnowledgePoints($grade, $textbookId)` 获取该教材知识点  
+   - `whereIn('kp_code', $gradeKpCodes)`  
+
+3. **仅从前章节补充(未学章节不补)**  
+   - 若同时传入 `$textbookId` 和 `$textbookCatalogNodeIds`  
+   - 调用 `getEarlierChapterNodeIds($textbookId, $textbookCatalogNodeIds)`  
+   - 取选中章节的 `max(sort_order)`,得到「前章节」节点 ID  
+   - `whereIn('textbook_catalog_nodes_id', $allowedNodeIds)`  
+   - 只从当前章节及之前章节补充,不包含后续未学章节  
+
+4. 按难度、题型等筛选后返回补充题目  
+
+### Step 5:筛选与难度分布
+
+```
+getQuestionsFromBank 返回
+  → selectQuestionsByMastery():按掌握度、题型配比筛选
+  → applyTypeAwareDifficultyDistribution():题型感知难度分布
+  → 返回最终题目列表
+```
+
+### Step 6:持久化与返回
+
+- 创建 Paper、PaperQuestion  
+- 生成 PDF、判卷链接等  
+- 返回任务 ID 或试卷信息  
+
+---
+
+## 三、生效点汇总
+
+| 能力 | 生效位置 | 实现方式 |
+|------|----------|----------|
+| 排除已做题目 | `getStudentAnsweredQuestionIds` | 从 `paper_questions` 取 `question_bank_id`,写入 `exclude_question_ids` |
+| 主选题排除 | `getQuestionsFromBank` 主查询 | `whereNotIn('id', $excludeQuestionIds)` |
+| 补充时排除 | `getSupplementaryQuestionsForGrade` | 传入 `excludeQuestionIds`,`whereNotIn('id', $excludeQuestionIds)` |
+| grade 推断 | `buildTextbookAssembleParams` | 无 grade 时从 `textbooks` 按 `textbook_id` 查 grade |
+| 前章节限制 | `getEarlierChapterNodeIds` | 按 `max(sort_order)` 取前章节节点,`whereIn('textbook_catalog_nodes_id', ...)` |
+
+---
+
+## 四、如何验证
+
+### 4.1 日志验证
+
+在 `storage/logs/laravel.log` 中可看到类似日志:
+
+```
+ExamTypeStrategy: 教材组卷从教材推断 grade
+ExamTypeStrategy: 获取学生已答题目 ... answered_count: N
+getQuestionsFromBank: 指定知识点题目不足,尝试智能补充
+getSupplementaryQuestionsForGrade: 开始智能补充 ... exclude_count: N, has_chapter_scope: true
+getSupplementaryQuestionsForGrade: 应用排除筛选 (或 排除学生已做过的题目)
+getSupplementaryQuestionsForGrade: 限制为前章节 ... allowed_node_count: M
+getQuestionsFromBank: 智能补充完成 ... supplementary_count: K
+```
+
+### 4.2 数据验证
+
+1. **排除已做**:对比 `paper_questions` 中该学生的 `question_bank_id`,与本次组卷题目 ID,应无交集。  
+2. **前章节**:补充题目对应的 `textbook_catalog_nodes_id`,其 `sort_order` 应 ≤ 选中章节的最大 `sort_order`。
+
+### 4.3 curl 快速测试
+
+```bash
+curl -X POST 'http://localhost/api/intelligent-exams' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "student_id": "1764913638",
+    "teacher_id": "1764913637",
+    "student_name": "张三",
+    "teacher_name": "李老师",
+    "grade": 7,
+    "assemble_type": 3,
+    "series_id": 1,
+    "semester_code": 2,
+    "chapter_id_list": [244, 248],
+    "total_questions": 20,
+    "paper_name": "七年级下册教材组卷验证测试"
+  }'
+```
+
+将 `student_id`、`teacher_id` 替换为环境中的真实数据。`series_id=1, semester_code=2, grade=7` 对应 **七年级下册**(textbook_id=2)。

+ 6 - 1
docs/题库质检方案.md

@@ -39,7 +39,6 @@
 ### 2.3 结果记录
 
 - 质检结果由命令输出,不落库(避免本地库覆盖导致表丢失)
-- 后续可接入 question_qc_results 等表做持久化
 
 ## 三、校验规则清单(可扩展)
 
@@ -49,7 +48,10 @@
 | ANSWER_EMPTY | 答案为空 | answer 为空 |
 | SOLUTION_EMPTY | 解析为空 | solution 为空(解答题强校验) |
 | CHOICE_OPTIONS_MISSING | 选择题缺选项 | question_type=choice 时 options 为空或非数组 |
+| ANSWER_OPTION_MISMATCH | 选择题答案不在选项中 | 答案字母(A/B/C/D)须在选项中存在 |
 | FORMULA_INVALID | 公式异常 | LaTeX 定界符不匹配、无法解析 |
+| AI_ANSWER_INVALID | AI 判定答案错误 | 使用 AI 校验答案正确性(需 --ai-check) |
+| AI_ANSWER_MISMATCH | AI 判定答案与题目不匹配 | 使用 AI 校验答案是否匹配题干(需 --ai-check) |
 | PDF_RENDER_FAIL | PDF 渲染失败 | 导出 PDF 后检测乱码/空白 |
 
 ## 四、筛选逻辑(从下学期章节开始)
@@ -69,6 +71,9 @@ php artisan question:qc
 # 指定知识点
 php artisan question:qc --kp=S01
 
+# 启用 AI 校验(答案正确性、与题目匹配,会请求 AI 接口)
+php artisan question:qc --ai-check --limit=20
+
 # 仅筛选不质检
 php artisan question:qc --dry-run
 

+ 34 - 0
resources/views/components/exam/paper-body-grading-pdf-page-styles.blade.php

@@ -0,0 +1,34 @@
+        @page {
+            size: A4;
+            margin: 2.2cm 2cm 2.3cm 2cm;
+            @top-left {
+                content: "知了数学·{{ $generateDateTime }}";
+                font-size: 13px;
+                color: #666;
+            }
+            @top-center {
+                content: "{{ $studentName }}";
+                font-size: 13px;
+                color: #666;
+            }
+            @top-right {
+                content: "{{ $gradingCode }}";
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                letter-spacing: 0;
+                padding-right: 3mm;
+                padding-top: 1.8mm;
+                color: #222;
+            }
+            @bottom-left {
+                content: "{{ $paperHeaderTitle }}";
+                font-size: 11px;
+                color: #666;
+            }
+            @bottom-right {
+                content: counter(page) "/" counter(pages);
+                font-size: 13px;
+                color: #666;
+            }
+        }

+ 302 - 0
resources/views/components/exam/paper-body-grading-styles.blade.php

@@ -0,0 +1,302 @@
+        :root {
+            --question-gap: 6px;
+        }
+        body {
+            font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif;
+            line-height: 1.65;
+            color: #000;
+            background: #fff;
+            font-size: 14px;
+        }
+        .page {
+            max-width: 720px;
+            margin: 0 auto;
+            padding: 0 12px;
+        }
+        .header { text-align: center; margin-bottom: 1.5rem; border-bottom: 2px solid #000; padding-bottom: 1rem; }
+        /* 大题标题:不与后面内容分开 */
+        .section-title {
+            font-size: 16px;
+            font-weight: bold;
+            margin-top: 16px;
+            margin-bottom: 10px;
+            page-break-after: avoid;
+            break-after: avoid;
+        }
+        /* 题目整体:不分页 */
+        .question {
+            margin-bottom: 14px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+            -webkit-column-break-inside: avoid;
+        }
+        /* 题目网格:不分页 */
+        .question-grid {
+            display: grid;
+            grid-template-columns: auto 1fr;
+            column-gap: 4px;
+            row-gap: 6px;
+            align-items: flex-start;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .question-lead {
+            display: flex;
+            gap: 4px;
+            align-items: flex-start;
+            font-weight: 600;
+            font-size: 14px;
+            line-height: 1.65;
+            margin-top: 1px;
+        }
+        .question-lead.spacer { visibility: hidden; }
+        .question-number { white-space: nowrap; margin-right: 2px; }
+        .grading-boxes { gap: 4px; flex-wrap: wrap; align-items: center; }
+        .grading-boxes span { vertical-align: middle; }
+        /* 题目内容:防止孤行 */
+        .question-main {
+            font-size: 14px;
+            line-height: 1.65;
+            font-family: inherit;
+            display: block;
+            orphans: 3;
+            widows: 3;
+        }
+        .question-score { margin-right: 6px; font-weight: 600; }
+        .question-stem {
+            display: inline-block;
+            font-size: 14px;
+            font-family: inherit;
+            orphans: 3;
+            widows: 3;
+        }
+        /* 选项容器:不分页 */
+        .options {
+            display: grid;
+            row-gap: 8px;
+            margin-top: 8px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .options-grid-4 {
+            display: grid;
+            grid-template-columns: repeat(4, 1fr);
+            gap: 8px 12px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .options-grid-2 {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 8px 20px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .options-grid-1 {
+            display: grid;
+            grid-template-columns: 1fr;
+            gap: 8px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        /* 单个选项:不分页 */
+        .option {
+            display: flex;
+            align-items: baseline;
+            font-size: 13.2px;
+            line-height: 1.6;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+        .option-value { display: inline; }
+        .option-short { white-space: nowrap; }
+        .option-long { white-space: normal; word-break: break-word; }
+        .option-compact { line-height: inherit; }
+        .option p, .option div { margin: 0; display: inline; }
+        .option .katex {
+            font-size: 1em !important;
+            vertical-align: 0;
+        }
+        /* 仅提升分式可读性(不放大整行选项) */
+        .option .katex .mfrac {
+            font-size: 1em !important;
+        }
+        /* 选项里的分子分母保持可读且不挤压横线 */
+        .option .katex .mfrac .mtight {
+            font-size: 1em !important;
+        }
+        /* 分数线稍加粗 */
+        .option .katex .frac-line {
+            border-bottom-width: 0.055em !important;
+        }
+        /* 自动化实测后的分式微调:分母下移、分子上移,避免贴线 */
+        .option .katex .mfrac .vlist > span:nth-child(1) {
+            transform: translateY(0.24em) !important;
+        }
+        .option .katex .mfrac .vlist > span:nth-child(3) {
+            transform: translateY(-0.16em) !important;
+        }
+        .option .katex-display {
+            display: inline;
+            margin: 0 !important;
+            vertical-align: baseline;
+        }
+        /* 答案区域:不分页 */
+        .answer-area {
+            position: relative;
+            margin-top: 4px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .answer-area.boxy {
+            min-height: 150px;
+            border: 1.5px solid #444;
+            border-radius: 6px;
+            padding: 14px;
+        }
+        .answer-label {
+            position: absolute;
+            top: -10px;
+            left: 10px;
+            font-size: 10px;
+            background: #fff;
+            padding: 0 4px;
+            color: #555;
+            letter-spacing: 1px;
+        }
+        /* 答案元信息:不分页 */
+        .answer-meta {
+            font-size: 12px;
+            color: #2f2f2f;
+            line-height: 1.75;
+            margin-top: 4px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .answer-line + .answer-line { margin-top: 4px; }
+        .solution-step {
+            align-items: center;
+            gap: 6px;
+        }
+        .step-box { display: inline-block; }
+        .step-label { white-space: nowrap; }
+        .solution-heading { font-weight: 700; }
+        .solution-content { display: inline-block; line-height: 1.75; }
+        /* 解析区域:不分页 */
+        .solution-section {
+            margin-top: 8px;
+            padding: 6px 8px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .solution-section strong {
+            font-size: 13px;
+        }
+        .solution-parsed {
+            margin-top: 6px;
+            line-height: 1.8;
+        }
+        svg, .math-render svg {
+            max-width: 100%;
+            height: auto;
+            display: block;
+            /* 确保SVG中的文字和图形元素正确对齐 */
+            shape-rendering: geometricPrecision;
+            text-rendering: geometricPrecision;
+        }
+        /* 优化SVG中文字标签的显示 */
+        svg text {
+            font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif !important;
+            font-size: 13px !important;
+            font-weight: bold;
+            font-style: normal;
+            dominant-baseline: middle;
+            text-anchor: middle;
+            alignment-baseline: central;
+            /* 确保文字在点的正中央 */
+            paint-order: stroke fill;
+            stroke: none;
+            fill: #000;
+        }
+        /* SVG中点标签的精确对齐 */
+        svg text.label-point {
+            font-size: 14px;
+            font-weight: bold;
+            dx: 0;
+            dy: 0;
+        }
+        /* 确保SVG中的圆形和线条正确渲染 */
+        svg circle, svg line, svg polygon, svg polyline {
+            shape-rendering: geometricPrecision;
+        }
+        /* PDF图片容器:防止图片跨页分割 - 增强版 */
+        .pdf-figure {
+            break-inside: avoid;
+            page-break-inside: avoid;
+            -webkit-column-break-inside: avoid;
+            break-before: avoid;
+            break-after: avoid;
+            page-break-before: avoid;
+            page-break-after: avoid;
+            margin: 8px 0;
+            display: block;
+            /* 确保图片不会在页面底部被截断 */
+            min-height: 30px;
+            /* 限制独立图块尺寸,避免图片压过文字 */
+            max-height: 82mm;
+        }
+        .pdf-figure img {
+            max-width: min(84%, 420px);
+            max-height: 82mm;
+            width: auto;
+            height: auto;
+            display: block;
+            margin: 0 auto;
+            object-fit: contain;
+            box-sizing: border-box;
+            -webkit-print-color-adjust: exact;
+            print-color-adjust: exact;
+            image-rendering: -webkit-optimize-contrast;
+        }
+        /* 题干中的图片样式(向后兼容) */
+        .question-stem img,
+        .question-main img,
+        .question-content img,
+        .answer-meta img,
+        .answer-line img,
+        .solution-content img,
+        .solution-section img,
+        .solution-parsed img {
+            display: block;
+            max-width: 220px;
+            max-height: 60mm;
+            width: auto;
+            height: auto;
+            margin: 6px auto;
+            box-sizing: border-box;
+            object-fit: contain;
+            -webkit-print-color-adjust: exact;
+            print-color-adjust: exact;
+            image-rendering: -webkit-optimize-contrast;
+        }
+        /* 选项中的图片样式 - 防止超出容器 */
+        .option img {
+            max-width: 100%;
+            height: auto;
+            vertical-align: middle;
+        }
+        .question-stem .katex,
+        .question-main .katex,
+        .question-content .katex {
+            font-size: 1em !important;
+            /* 避免题干/解析中的行内公式整体下沉 */
+            vertical-align: 0;
+        }
+        .question-stem .katex-display,
+        .question-main .katex-display,
+        .question-content .katex-display {
+            margin: 0.35em 0 !important;
+        }
+
+        @include('pdf.partials.grading-scan-sheet-styles')

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

@@ -72,6 +72,13 @@
     // 与判题卡共用同一计数规则,避免方框数量不一致
     $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) {
@@ -181,8 +188,39 @@
                 $renderedStem .= ' ' . $blankSpan;
             }
             $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)
@@ -341,8 +379,38 @@
                 $renderedContent .= ' ' . $blankSpan;
             }
             $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)
@@ -407,8 +475,38 @@
         @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;
+            }
         @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>

+ 1 - 0
resources/views/exam-analysis/pdf-report.blade.php

@@ -808,6 +808,7 @@
                     return \App\Services\MathFormulaProcessor::processFormulas($text);
                 };
                 // 选择题:选项来自服务端 questions.options / 题干解析;正确答案字母用于打 ✅
+                // 展示时将 B / BC 映射为具体选项内容,减少来回翻题干。
                 $displayCorrectAnswer = is_array($correctAnswer)
                     ? json_encode($correctAnswer, JSON_UNESCAPED_UNICODE)
                     : (string) $correctAnswer;

+ 50 - 0
resources/views/filament/pages/knowledge-point-question-stats.blade.php

@@ -0,0 +1,50 @@
+<x-filament::page>
+    <div class="space-y-6">
+        <x-filament::section
+            heading="说明"
+            description="排序:各年级下学期教材(textbooks.semester={{ \App\Services\KnowledgePointQuestionStatsService::textbookSemesterForOrdering() }})章节关联知识点顺序优先,其次 questions 题量升序。tem 列为 questions_tem 中不与正式库同题干重复的数量。"
+            :compact="true"
+        />
+
+        <x-filament::section heading="数据表" :compact="true">
+            <div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
+                <table class="w-full text-left text-sm">
+                    <thead class="bg-gray-50 dark:bg-white/5">
+                        <tr>
+                            <th class="px-3 py-2 font-medium">知识点 ID</th>
+                            <th class="px-3 py-2 font-medium">知识点名称</th>
+                            <th class="px-3 py-2 font-medium text-right">questions</th>
+                            <th class="px-3 py-2 font-medium text-right">questions_tem(不重复)</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        @forelse ($this->statRows as $r)
+                            <tr class="border-t border-gray-100 dark:border-white/10" wire:key="kp-stat-{{ $r['kp_code'] }}">
+                                <td class="px-3 py-2 font-mono text-xs">{{ $r['kp_code'] }}</td>
+                                <td class="px-3 py-2">{{ $r['kp_name'] !== '' ? $r['kp_name'] : '—' }}</td>
+                                <td class="px-3 py-2 text-right tabular-nums">{{ $r['questions_count'] }}</td>
+                                <td class="px-3 py-2 text-right tabular-nums">{{ $r['tem_non_duplicate_count'] }}</td>
+                            </tr>
+                        @empty
+                            <tr>
+                                <td colspan="4" class="px-3 py-6 text-center text-gray-500">暂无数据(需 questions 或 questions_tem 中有题目)</td>
+                            </tr>
+                        @endforelse
+                    </tbody>
+                </table>
+            </div>
+        </x-filament::section>
+
+        <x-filament::section
+            heading="Markdown 全文(直接输出)"
+            description="与命令 php artisan stats:kp-questions-md 一致;点击区域可全选复制"
+            :compact="true"
+        >
+            <pre
+                class="fi-input max-h-[min(70vh,48rem)] cursor-text overflow-auto whitespace-pre-wrap break-words rounded-lg border border-gray-200 bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-900 dark:border-white/10 dark:bg-white/5 dark:text-gray-100"
+                tabindex="0"
+                onclick="const s=window.getSelection(),r=document.createRange();r.selectNodeContents(this);s.removeAllRanges();s.addRange(r);"
+            >{{ $this->markdownExport }}</pre>
+        </x-filament::section>
+    </div>
+</x-filament::page>

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

@@ -0,0 +1,41 @@
+@props([
+    'questions' => ['choice' => [], 'fill' => [], 'answer' => []],
+    'selectedTemId' => null,
+    'selectedTemIdsForMulti' => [],
+])
+
+<link rel="stylesheet" href="/css/katex/katex.min.css">
+<style>
+    .qtr-paper-shell {
+        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>

+ 114 - 0
resources/views/filament/pages/question-imported-difficulty-tune.blade.php

@@ -0,0 +1,114 @@
+<x-filament::page>
+    <style>
+        .qidt-shell {
+            display: grid;
+            grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem);
+            gap: 1rem;
+            align-items: start;
+        }
+        @media (max-width: 1024px) {
+            .qidt-shell {
+                grid-template-columns: 1fr;
+            }
+        }
+    </style>
+
+    <x-filament::section
+        heading="说明"
+        description="列表来自「待入库质检」页在本次登录会话中成功写入 questions 的题目 ID(快速/批量入库均会累积)。在此可逐题查看并修改难度系数 0.00~0.90(两位小数)。"
+        :compact="true"
+    >
+        <div class="flex flex-wrap items-center gap-2">
+            <x-filament::button
+                color="gray"
+                size="sm"
+                wire:click="clearTuningList"
+                wire:confirm="确定清空会话中的 ID 列表?(不会删除题库题目)"
+            >
+                清空列表记录
+            </x-filament::button>
+            <a
+                href="{{ \App\Filament\Pages\QuestionTemQualityReview::getUrl() }}"
+                class="text-sm text-primary-600 underline"
+            >
+                返回待入库质检
+            </a>
+        </div>
+        <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
+            当前列表共 <strong>{{ count($this->tuningQuestionIds) }}</strong> 道题。
+        </p>
+    </x-filament::section>
+
+    <div class="qidt-shell mt-4">
+        <x-filament::section heading="题目列表" :compact="true">
+            @if (count($this->tuningQuestionIds) === 0)
+                <p class="text-sm text-gray-600 dark:text-gray-400">暂无记录,请先在待入库质检页执行入库。</p>
+            @else
+                <ul class="max-h-[min(70vh,40rem)] space-y-1 overflow-y-auto text-sm">
+                    @foreach ($this->tuningQuestionIds as $qid)
+                        @php
+                            $row = \App\Models\Question::query()->find($qid);
+                            $stemSnippet = $row ? \Illuminate\Support\Str::limit(strip_tags((string) $row->stem), 80) : '(无正文)';
+                            $diffLabel = $row ? number_format(max(0, min(0.9, (float) $row->difficulty)), 2, '.', '') : '—';
+                        @endphp
+                        <li
+                            wire:key="qidt-row-{{ $qid }}"
+                            class="flex items-start justify-between gap-2 rounded-lg border border-gray-100 p-2 dark:border-white/10 {{ (int) $this->selectedQuestionId === (int) $qid ? 'ring-2 ring-primary-500' : '' }}"
+                        >
+                            <button
+                                type="button"
+                                class="min-w-0 flex-1 text-left"
+                                wire:click="selectQuestion({{ (int) $qid }})"
+                            >
+                                <span class="font-mono text-xs text-primary-600">#{{ $qid }}</span>
+                                <span class="ml-2 text-xs text-gray-500">{{ $row?->kp_code }}</span>
+                                <span class="ml-2 text-xs text-gray-400">难度 {{ $diffLabel }}</span>
+                                <div class="mt-1 line-clamp-2 text-xs text-gray-700 dark:text-gray-300">
+                                    {{ $stemSnippet }}
+                                </div>
+                            </button>
+                            <button
+                                type="button"
+                                class="shrink-0 text-xs text-danger-600 underline"
+                                wire:click="removeFromList({{ (int) $qid }})"
+                            >
+                                移除
+                            </button>
+                        </li>
+                    @endforeach
+                </ul>
+            @endif
+        </x-filament::section>
+
+        <x-filament::section
+            heading="调整难度"
+            description="选中题目后修改 difficulty,点确定写回 questions 表"
+            :compact="true"
+        >
+            @if (! $this->selectedQuestionId)
+                <p class="text-xs text-gray-500">请从左侧选择一道题。</p>
+            @else
+                <p class="mb-2 text-xs text-gray-500">
+                    当前 <span class="font-mono">question_id = {{ $this->selectedQuestionId }}</span>
+                </p>
+                <x-filament::input.wrapper>
+                    <x-filament::input
+                        type="number"
+                        min="0"
+                        max="0.9"
+                        step="0.01"
+                        wire:model.live.debounce.300ms="difficultyInput"
+                    />
+                </x-filament::input.wrapper>
+                <x-filament::button
+                    class="mt-3"
+                    color="primary"
+                    wire:click="saveDifficulty"
+                    wire:loading.attr="disabled"
+                >
+                    确定更新
+                </x-filament::button>
+            @endif
+        </x-filament::section>
+    </div>
+</x-filament::page>

+ 435 - 0
resources/views/filament/pages/question-tem-quality-review.blade.php

@@ -0,0 +1,435 @@
+@php
+    $rules = \App\Services\QuestionQualityCheckService::RULES;
+    $btnSm = \Filament\Support\Enums\Size::Small;
+@endphp
+
+<x-filament::page>
+    {{-- 勾选题目用 Alpine 即时高亮 + $wire.toggleTemQuestion,勿把 toggleTemQuestion 放进 target,避免出现全屏大图标一闪 --}}
+    <div
+        wire:loading.delay.shortest
+        wire:target="selectKp, updatedSelectedKpCode, importSelected, importSelectedTemIdsFast, importAllCurrentKpToQuestions, importAssemblyQueueToQuestions, addSelectionToAssemblyQueue, clearAssemblyQueue, clearTemSelection, removeFromAssemblyQueue, generateTrialGradingPdf"
+        class="fixed inset-0 z-[130] flex items-center justify-center bg-white/70 dark:bg-gray-950/70"
+    >
+        <div class="flex flex-col items-center gap-2 rounded-lg border border-slate-200 bg-white px-6 py-4 shadow dark:border-gray-700 dark:bg-gray-900">
+            <svg class="h-8 w-8 animate-spin text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+            <span class="text-sm text-slate-600 dark:text-slate-300">{{ __('加载中…') }}</span>
+        </div>
+    </div>
+
+    {{-- Filament 主题自带 fi-* 样式;此处用内联布局避免依赖 Tailwind 工具类是否被打进 app.css --}}
+    <style>
+        .qtr-shell {
+            width: 100%;
+            display: grid;
+            grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr) minmax(15rem, 22rem);
+            gap: 1rem;
+            align-items: start;
+        }
+        /* 左右栏:滚动主内容时保持吸顶悬浮(中间栏正常滚动) */
+        .qtr-sticky-side {
+            position: sticky;
+            top: 0.75rem;
+            align-self: start;
+            max-height: calc(100vh - 1.25rem);
+            overflow-y: auto;
+            overflow-x: hidden;
+            z-index: 2;
+            -webkit-overflow-scrolling: touch;
+        }
+        @@media (max-width: 1024px) {
+            .qtr-shell {
+                grid-template-columns: 1fr;
+            }
+            .qtr-sticky-side {
+                position: static;
+                max-height: none;
+                overflow: visible;
+            }
+        }
+        .qtr-kp-scroll {
+            display: flex;
+            flex-direction: column;
+            gap: 0.375rem;
+        }
+    </style>
+
+    <div class="qtr-shell">
+        {{-- 左:知识点 --}}
+        <div class="qtr-sticky-side">
+        <x-filament::section
+            heading="知识点"
+            description="按 questions 表该 KP 题量升序;仅含 questions_tem 中出现过的 KP。待审数为 questions_tem 全量,中间列表会隐藏已与正式库同题干的重复题"
+            :compact="true"
+        >
+            <div class="mb-3 space-y-2">
+                <x-filament::input.wrapper
+                    inline-prefix
+                    prefix-icon="heroicon-m-magnifying-glass"
+                >
+                    <x-filament::input
+                        type="search"
+                        wire:model.live.debounce.300ms="kpSearch"
+                        placeholder="搜索代码或名称…"
+                        autocomplete="off"
+                    />
+                </x-filament::input.wrapper>
+                @if (filled($this->kpSearch))
+                    <p class="text-xs text-gray-500 dark:text-gray-400">
+                        显示 {{ count($this->filteredKpRows) }} / 共 {{ count($this->kpRows) }} 个知识点
+                    </p>
+                @endif
+            </div>
+            <div class="qtr-kp-scroll">
+                @forelse($this->filteredKpRows as $row)
+                    <div
+                        class="w-full"
+                        wire:key="kp-row-{{ $row['kp_code'] }}"
+                        x-data="{ kp: @js($row['kp_code']) }"
+                    >
+                        <x-filament::button
+                            x-on:click.prevent="$wire.selectKp(kp)"
+                            :outlined="$this->selectedKpCode !== $row['kp_code']"
+                            :color="$this->selectedKpCode === $row['kp_code'] ? 'primary' : 'gray'"
+                            :size="$btnSm"
+                            class="w-full"
+                            style="justify-content: flex-start;"
+                        >
+                            <div class="flex w-full flex-col items-stretch gap-1 text-left">
+                                @if (! empty($row['kp_name'] ?? ''))
+                                    <span class="text-sm font-medium leading-snug line-clamp-2">{{ $row['kp_name'] }}</span>
+                                @endif
+                                <span class="font-mono text-xs break-all opacity-90">{{ $row['kp_code'] }}</span>
+                                <span class="text-xs opacity-80">
+                                    正式 {{ $row['questions_count'] }} · 待审 {{ $row['tem_count'] }}
+                                </span>
+                            </div>
+                        </x-filament::button>
+                    </div>
+                @empty
+                    <p class="text-sm text-gray-600 dark:text-gray-400">暂无数据(检查 questions_tem)</p>
+                @endforelse
+            </div>
+        </x-filament::section>
+        </div>
+
+        {{-- 中:与判卷页同源 components.exam.paper-body(一行一题,含选项布局/答案/解题思路) --}}
+        <x-filament::section
+            heading="待审题目(判卷页版式)"
+            description="与 pdf.exam-grading 同源;已自动隐藏「正式库同 KP 且题干一致」的题目。点击题目为勾选(不跑质检);右侧可一键批量入库,精细质检与单题入库在「高级」中展开"
+            :compact="true"
+        >
+            @if (! $this->selectedKpCode)
+                <p class="text-sm text-gray-600 dark:text-gray-400">请先在左侧选择一个知识点。</p>
+            @else
+                <div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
+                    <span>当前 KP</span>
+                    <code class="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-white/10">{{ $this->selectedKpCode }}</code>
+                    <span class="text-xs text-gray-500">共 {{ count($this->temQuestions) }} 道</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>
+            @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"
+        >
+            <x-filament::section
+                heading="快速入库"
+                description="勾选中间题目后此处列出 tem 编号;不跑页面质检,与批量入库相同服务端规则(重复题跳过)"
+                :compact="true"
+            >
+                @if ($this->selectedTemIds === [])
+                    <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>
+                @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="需要逐题看清规则、自定义难度再入库时展开;展开后会为当前聚焦的题目跑质检"
+                :compact="true"
+                class="mt-4"
+            >
+                @if (! $this->qcPanelExpanded)
+                    <x-filament::button color="gray" wire:click="$set('qcPanelExpanded', true)">
+                        展开质检与单题入库
+                    </x-filament::button>
+                @else
+                    <div class="mb-3">
+                        <x-filament::button color="gray" size="sm" wire:click="$set('qcPanelExpanded', false)">
+                            收起
+                        </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>
+                                        @endif
+                                    @endforeach
+                                </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>
+                        @endforeach
+                    </ul>
+                @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">
+                    <x-filament::button
+                        color="gray"
+                        wire:click="importAllCurrentKpToQuestions"
+                        wire:loading.attr="disabled"
+                        wire:target="importAllCurrentKpToQuestions"
+                        wire:confirm="确定将当前知识点下列表中的全部题目写入 questions?(正式库已存在的同题干会跳过)"
+                        :disabled="! $this->selectedKpCode || count($this->temQuestions) === 0"
+                    >
+                        一键入库当前知识点全部
+                    </x-filament::button>
+                    <x-filament::button
+                        color="gray"
+                        wire:click="importAssemblyQueueToQuestions"
+                        wire:loading.attr="disabled"
+                        wire:target="importAssemblyQueueToQuestions"
+                        wire:confirm="确定将上方待组卷队列中的全部题目写入 questions?(已存在的同题干会跳过)"
+                        :disabled="count($this->assemblyQueueRows) === 0"
+                    >
+                        一键入库待组卷队列全部
+                    </x-filament::button>
+                </div>
+                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
+                    与单题入库相同的数据写入逻辑;批量时不强制质检通过,仅做重复与必填判断。
+                </p>
+            </x-filament::section>
+        </div>
+    </div>
+</x-filament::page>

+ 46 - 0
resources/views/filament/pages/questions-json-import.blade.php

@@ -0,0 +1,46 @@
+<x-filament::page>
+    <div class="space-y-6">
+        <x-filament::section
+            description="上传题目 JSON 后,点击页面右上角「一键导入」或下方绿色按钮,即可写入本地 questions 并生成可复制到服务器的 SQL 文件。"
+        >
+            {{ $this->form }}
+
+            <div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:flex-wrap">
+                <x-filament::button
+                    type="button"
+                    wire:click="runImport"
+                    color="success"
+                    size="lg"
+                    class="w-full sm:w-auto font-semibold"
+                >
+                    一键导入
+                </x-filament::button>
+                <span class="text-sm text-gray-600 dark:text-gray-400">
+                    与右上角「一键导入」相同操作
+                </span>
+            </div>
+        </x-filament::section>
+
+        <x-filament::section heading="命令行(可选)">
+            <pre class="overflow-x-auto rounded-lg bg-gray-950 p-4 text-xs text-gray-100 dark:bg-gray-900"><code># 本地:从文件导入并生成 SQL
+php artisan questions:import /绝对路径/题目.json
+
+# 仅生成 SQL、不写本地库(适合只在服务器执行)
+php artisan questions:import /path/to/x.json --sql-only
+
+# 从本地库按 id 导出 SQL(默认不含 id,由服务器自增主键)
+php artisan questions:export-sql --ids=101,102,103
+# 若必须在目标库对齐原 id:
+php artisan questions:export-sql --ids=101,102 --with-id</code></pre>
+        </x-filament::section>
+
+        @if ($this->lastSqlPath)
+            <x-filament::section heading="本次生成的 SQL 文件">
+                <p class="break-all font-mono text-sm text-gray-700 dark:text-gray-300">{{ $this->lastSqlPath }}</p>
+                @if ($this->lastMessage)
+                    <p class="mt-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-400">{{ $this->lastMessage }}</p>
+                @endif
+            </x-filament::section>
+        @endif
+    </div>
+</x-filament::page>

+ 19 - 0
tests/Unit/QuestionTemQualityReviewBladeTest.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Tests\Unit;
+
+use Tests\TestCase;
+
+class QuestionTemQualityReviewBladeTest extends TestCase
+{
+    public function test_blade_file_compiles_to_php_without_syntax_error(): void
+    {
+        $path = resource_path('views/filament/pages/question-tem-quality-review.blade.php');
+        $this->assertFileExists($path);
+
+        $compiler = app('blade.compiler');
+        $compiled = $compiler->compileString((string) file_get_contents($path));
+
+        $this->assertNotSame('', $compiled);
+    }
+}