Explorar o código

feat(qc): 待入库质检与题量统计调整

- questions_tem 统一使用 stem 字段;解析步骤标记支持全角(1)(2)
- 质检页 loading 改为纯 CSS 小 spinner,避免异常大环
- 题库筛选/题量统计仅依据 questions.kp_code
- 新增 AnswerSolutionStepMarkerInjector 及单元测试

Made-with: Cursor
yemeishu hai 2 días
pai
achega
d5525b56f2

+ 1 - 1
app/Filament/Pages/QuestionTemQualityReview.php

@@ -86,7 +86,7 @@ class QuestionTemQualityReview extends Page
     /**
      * 左侧列表:按搜索词筛选知识点(代码、名称子串匹配,UTF-8)
      *
-     * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int}>
+     * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
      */
     #[Computed(cache: false)]
     public function filteredKpRows(): array

+ 9 - 2
app/Http/Controllers/ExamPdfController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Jobs\RegeneratePdfJob;
 use App\Models\Paper;
+use App\Support\AnswerSolutionStepMarkerInjector;
 use App\Support\PaperNaming;
 use App\Services\QuestionBankService;
 use Illuminate\Http\Request;
@@ -515,7 +516,7 @@ class ExamPdfController extends Controller
         $questionsData = [];
         foreach ($temRows as $index => $row) {
             $arr = is_array($row) ? $row : (array) $row;
-            $rawContent = (string) ($arr['stem'] ?? $arr['content'] ?? '');
+            $rawContent = (string) ($arr['stem'] ?? '');
             $decodedOptions = null;
             if (isset($arr['options']) && $arr['options'] !== null && $arr['options'] !== '') {
                 if (is_string($arr['options'])) {
@@ -529,13 +530,19 @@ class ExamPdfController extends Controller
             if (! empty($decodedOptions)) {
                 $apiOptions = $this->normalizeOptions($decodedOptions);
             }
+            $rawSolution = (string) ($arr['solution'] ?? '');
+            $solutionForPreview = AnswerSolutionStepMarkerInjector::enrichIfNeeded(
+                $rawSolution,
+                $arr['question_type'] ?? $arr['tags'] ?? ''
+            );
+
             $questionsData[] = [
                 'id' => isset($arr['id']) ? -abs((int) $arr['id']) : null,
                 'question_number' => $index + 1,
                 'stem' => $rawContent,
                 'content' => $rawContent,
                 'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
-                'solution' => (string) ($arr['solution'] ?? ''),
+                'solution' => $solutionForPreview,
                 'difficulty' => isset($arr['difficulty']) ? (float) $arr['difficulty'] : 0.5,
                 'kp_code' => (string) ($arr['kp_code'] ?? ''),
                 'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : '',

+ 4 - 7
app/Services/KnowledgePointQuestionStatsService.php

@@ -64,7 +64,7 @@ class KnowledgePointQuestionStatsService
     }
 
     /**
-     * @return array<string, int> kp_code => questions 题目数
+     * @return array<string, int> kp_code => questions.kp_code 题目数
      */
     public function questionsCountByKp(): array
     {
@@ -93,19 +93,16 @@ class KnowledgePointQuestionStatsService
             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 t.stem IS NOT NULL AND t.stem != ''
              AND NOT EXISTS (
                  SELECT 1 FROM questions AS q
                  WHERE q.kp_code = t.kp_code
-                 AND q.stem = ({$stemExpr})
+                 AND q.stem != ''
+                 AND q.stem = t.stem
              )
              GROUP BY t.kp_code"
         );

+ 63 - 5
app/Services/QuestionLocalService.php

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

+ 61 - 6
app/Services/QuestionTemReviewService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Models\Question;
+use App\Support\AnswerSolutionStepMarkerInjector;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Str;
@@ -39,7 +40,7 @@ class QuestionTemReviewService
      * 左侧:按 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}>
+     * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
      */
     public function listKnowledgePointsByQuestionsAsc(?int $limit = null): array
     {
@@ -83,6 +84,8 @@ class QuestionTemReviewService
             ->pluck('c', 'kp_code')
             ->toArray();
 
+        $importableMap = $this->importableTemCountsForKpCodes($temKps);
+
         $rows = [];
         foreach ($temKps as $kp) {
             $name = isset($kpNames[$kp]) ? trim((string) $kpNames[$kp]) : '';
@@ -91,6 +94,7 @@ class QuestionTemReviewService
                 'kp_name' => $name,
                 'questions_count' => (int) ($counts[$kp] ?? 0),
                 'tem_count' => (int) ($temCounts[$kp] ?? 0),
+                'tem_importable_count' => (int) ($importableMap[$kp] ?? 0),
             ];
         }
 
@@ -110,13 +114,58 @@ class QuestionTemReviewService
     }
 
     /**
-     * 与入库、判重一致:questions_tem 行用于比对的题干(stem 优先,否则 content)
+     * 与中间列表 {@see listTemQuestionsForKp}(excludeFormalDuplicates=true)同口径:本 KP 下、题干未与正式库重复、且题干非空的 questions_tem 行数。
+     *
+     * @param  list<string>  $kpCodes
+     * @return array<string, int>  kp_code => count
+     */
+    private function importableTemCountsForKpCodes(array $kpCodes): array
+    {
+        $kpCodes = array_values(array_unique(array_filter($kpCodes)));
+        if ($kpCodes === [] || ! Schema::hasTable('questions_tem')) {
+            return [];
+        }
+
+        if (! Schema::hasTable('questions')) {
+            return collect($kpCodes)
+                ->mapWithKeys(fn (string $k): array => [$k => (int) DB::table('questions_tem')->where('kp_code', $k)->count()])
+                ->all();
+        }
+
+        $placeholders = implode(',', array_fill(0, count($kpCodes), '?'));
+        $sql = "
+            SELECT t.kp_code AS kp_code, COUNT(*) AS c
+            FROM questions_tem t
+            WHERE t.kp_code IN ($placeholders)
+            AND t.stem IS NOT NULL AND t.stem != ''
+            AND NOT EXISTS (
+                SELECT 1 FROM questions q
+                WHERE q.kp_code = t.kp_code
+                AND q.stem != ''
+                AND q.stem = t.stem
+            )
+            GROUP BY t.kp_code
+        ";
+
+        $out = array_fill_keys($kpCodes, 0);
+        foreach (DB::select($sql, $kpCodes) as $row) {
+            $k = (string) ($row->kp_code ?? '');
+            if ($k !== '') {
+                $out[$k] = (int) ($row->c ?? 0);
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * 与入库、判重一致:questions_tem.stem(当前库表仅此 longtext 题干列,无 content)。
      */
     public function normalizedStemFromTemRow(object|array $row): string
     {
         $arr = is_array($row) ? $row : (array) $row;
 
-        return (string) ($arr['stem'] ?? $arr['content'] ?? '');
+        return (string) ($arr['stem'] ?? '');
     }
 
     /**
@@ -167,9 +216,12 @@ class QuestionTemReviewService
      */
     public function buildPdfStylePreviewFields(array $row): array
     {
-        $stem = (string) ($row['stem'] ?? $row['content'] ?? '');
+        $stem = (string) ($row['stem'] ?? '');
         $answer = (string) ($row['answer'] ?? $row['correct_answer'] ?? '');
-        $solution = (string) ($row['solution'] ?? '');
+        $solution = AnswerSolutionStepMarkerInjector::enrichIfNeeded(
+            (string) ($row['solution'] ?? ''),
+            $row['question_type'] ?? $row['tags'] ?? 'answer'
+        );
         $questionType = strtolower((string) ($row['question_type'] ?? $row['tags'] ?? 'answer'));
 
         $options = $row['options'] ?? null;
@@ -285,6 +337,9 @@ class QuestionTemReviewService
             ? max(0.0, min(0.9, round($difficultyOverride, 2)))
             : $this->defaultDifficultyForTemRow($arr);
 
+        $rawSolution = (string) ($arr['solution'] ?? '');
+        $solutionStored = AnswerSolutionStepMarkerInjector::enrichIfNeeded($rawSolution, $arr['question_type'] ?? $arr['tags'] ?? 'answer');
+
         $payload = [
             'question_code' => 'QT'.strtoupper(Str::random(12)),
             'question_type' => $questionType,
@@ -292,7 +347,7 @@ class QuestionTemReviewService
             'stem' => $stem,
             'options' => $options,
             'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
-            'solution' => (string) ($arr['solution'] ?? ''),
+            'solution' => $solutionStored,
             'difficulty' => $difficulty,
             'source' => 'questions_tem_review',
             'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : null,

+ 162 - 0
app/Support/AnswerSolutionStepMarkerInjector.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Support;
+
+/**
+ * 待入库解答题:若解析中尚无「步骤 n / 第 n 步」标记,但存在按顺序出现的小问 (1)→(2)→(3),
+ * 则在合法边界处插入「步骤一:」「步骤二:」…(与判卷 {@see resources/views/components/exam/paper-body.blade.php} 中加方框的规则同源)。
+ *
+ * 只对「从 (1) 起顺序递增」的第一条锚点链加前缀,避免文中再次出现 (1) 时被当成新步骤导致「步骤三:(1)」等错乱。
+ */
+final class AnswerSolutionStepMarkerInjector
+{
+    /** 与 paper-body 解答题 $stepPattern 对齐:已有则不再注入 */
+    private const STEP_HEAD_RE = '/步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::..]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::..]?/u';
+
+    private const MAX_ORDERED_SUBQUESTIONS = 5;
+
+    /**
+     * @param  mixed  $rawQuestionType  questions_tem.question_type / tags 等
+     */
+    public static function enrichIfNeeded(string $solution, mixed $rawQuestionType): string
+    {
+        $solution = trim($solution);
+        if ($solution === '') {
+            return $solution;
+        }
+
+        $t = strtolower(trim((string) $rawQuestionType));
+        if ($t !== '' && (str_contains($t, 'choice') || str_contains($t, '选择'))) {
+            return $solution;
+        }
+        if ($t !== '' && (str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空'))) {
+            return $solution;
+        }
+
+        if (preg_match(self::STEP_HEAD_RE, $solution)) {
+            return $solution;
+        }
+
+        return self::injectOrderedSubQuestionAnchors($solution);
+    }
+
+    private static function injectOrderedSubQuestionAnchors(string $solution): string
+    {
+        $offsets = self::collectOrderedSubQuestionByteOffsets($solution, self::MAX_ORDERED_SUBQUESTIONS);
+        if (count($offsets) < 2) {
+            return $solution;
+        }
+
+        $insertions = [];
+        foreach ($offsets as $i => $bytePos) {
+            $insertions[] = [$bytePos, '步骤'.self::chineseOrdinal($i + 1).':'];
+        }
+
+        usort($insertions, static fn (array $a, array $b): int => $b[0] <=> $a[0]);
+
+        $out = $solution;
+        foreach ($insertions as [$pos, $label]) {
+            $out = substr($out, 0, $pos).$label.substr($out, $pos);
+        }
+
+        return $out;
+    }
+
+    /**
+     * 严格按 1、2、3… 顺序在字符串中找第一条 (n) 或 (中文 n),且该位置须处于小问边界(段首或句末标点后)。
+     *
+     * @return list<int> UTF-8 字节偏移
+     */
+    private static function collectOrderedSubQuestionByteOffsets(string $s, int $maxN): array
+    {
+        $offsets = [];
+        $searchFrom = 0;
+        for ($n = 1; $n <= $maxN; $n++) {
+            $hit = self::findNextAnchoredSubQuestion($s, $searchFrom, $n);
+            if ($hit === null) {
+                break;
+            }
+            [$byteStart, $matchLen] = $hit;
+            $offsets[] = $byteStart;
+            $searchFrom = $byteStart + $matchLen;
+        }
+
+        return $offsets;
+    }
+
+    /**
+     * @return ?array{0: int, 1: int} [byteStart, matchByteLength]
+     */
+    private static function findNextAnchoredSubQuestion(string $s, int $searchFrom, int $n): ?array
+    {
+        $cn = self::chineseOrdinal($n);
+        // 全角括号 + 阿拉伯数字(1)(2)在解析/OCR 中极常见;原先仅支持(一)(二)会漏检整条小问链
+        $pattern = '/(?<![A-Za-z\'\x{2019}\x{2032}])(\(\s*'.$n.'\s*\)|(\s*'.$n.'\s*)|(\s*'.preg_quote($cn, '/').'\s*))\s*[、,;::..]?/u';
+
+        $len = strlen($s);
+        $pos = $searchFrom;
+        for ($guard = 0; $guard < 8000 && $pos < $len; $guard++) {
+            if (! preg_match($pattern, $s, $m, PREG_OFFSET_CAPTURE, $pos)) {
+                return null;
+            }
+            $byteStart = (int) ($m[0][1] ?? -1);
+            if ($byteStart < 0) {
+                return null;
+            }
+            $matched = (string) ($m[0][0] ?? '');
+            $mLen = strlen($matched);
+            if ($mLen < 1) {
+                $pos = $byteStart + 1;
+
+                continue;
+            }
+            if (self::isSubQuestionAnchorContext($s, $byteStart)) {
+                return [$byteStart, $mLen];
+            }
+            $pos = $byteStart + $mLen;
+        }
+
+        return null;
+    }
+
+    /**
+     * 小问编号须在段首、换行后或句末标点后,避免正文中的数值括号被当成小问。
+     */
+    private static function isSubQuestionAnchorContext(string $s, int $bytePos): bool
+    {
+        if ($bytePos <= 0) {
+            return true;
+        }
+
+        $before = substr($s, 0, $bytePos);
+        $before = preg_replace('/[ \t\x{3000}]+$/u', '', $before) ?? $before;
+        if ($before === '') {
+            return true;
+        }
+
+        if (preg_match('/\R\z/u', $before)) {
+            return true;
+        }
+
+        $last = mb_substr($before, mb_strlen($before, 'UTF-8') - 1, 1, 'UTF-8');
+
+        return $last !== '' && (bool) preg_match('/[。!?;:·….、,,\]\}】〉』」)]/u', $last);
+    }
+
+    private static function chineseOrdinal(int $n): string
+    {
+        static $map = [
+            1 => '一', 2 => '二', 3 => '三', 4 => '四', 5 => '五',
+            6 => '六', 7 => '七', 8 => '八', 9 => '九', 10 => '十',
+        ];
+
+        if (isset($map[$n])) {
+            return $map[$n];
+        }
+        if ($n > 10 && $n <= 19) {
+            return '十'.$map[$n - 10];
+        }
+
+        return (string) $n;
+    }
+}

+ 60 - 10
resources/views/filament/pages/question-tem-quality-review.blade.php

@@ -10,17 +10,53 @@
         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 class="qtr-page-loading-card">
+            <div class="qtr-page-loading-spinner" role="status" aria-hidden="true"></div>
+            <span class="qtr-page-loading-text">{{ __('加载中…') }}</span>
         </div>
     </div>
 
     {{-- Filament 主题自带 fi-* 样式;此处用内联布局避免依赖 Tailwind 工具类是否被打进 app.css --}}
     <style>
+        .qtr-page-loading-card {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 0.5rem;
+            padding: 1rem 1.5rem;
+            border-radius: 0.5rem;
+            border: 1px solid rgb(226 232 240);
+            background: #fff;
+            box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.08);
+        }
+        .dark .qtr-page-loading-card {
+            border-color: rgb(55 65 81);
+            background: rgb(17 24 39);
+            box-shadow: none;
+        }
+        .qtr-page-loading-text {
+            font-size: 0.875rem;
+            line-height: 1.25;
+            color: #475569;
+            margin: 0;
+        }
+        .dark .qtr-page-loading-text {
+            color: #94a3b8;
+        }
+        /* 纯 CSS 小环,固定 1.5rem;勿用依赖 Tailwind 的 SVG */
+        .qtr-page-loading-spinner {
+            box-sizing: border-box;
+            width: 1.5rem;
+            height: 1.5rem;
+            flex-shrink: 0;
+            border: 2px solid rgba(100, 116, 139, 0.25);
+            border-top-color: rgb(100, 116, 139);
+            border-radius: 50%;
+            animation: qtr-spin 0.7s linear infinite;
+        }
+        @@keyframes qtr-spin {
+            to { transform: rotate(360deg); }
+        }
         .qtr-shell {
             width: 100%;
             display: grid;
@@ -61,7 +97,7 @@
         <div class="qtr-sticky-side">
         <x-filament::section
             heading="知识点"
-            description="按 questions 表该 KP 题量升序;仅含 questions_tem 中出现过的 KP。待审数为 questions_tem 全量,中间列表会隐藏已与正式库同题干的重复题"
+           	description="按 questions 正式题量升序;仅含 questions_tem 出现过的 KP。数字含义:表内=questions_tem 本 KP 总行数;可入库=去掉与正式库同题干重复后、与中间列表一致的可提交条数"
             :compact="true"
         >
             <div class="mb-3 space-y-2">
@@ -103,7 +139,9 @@
                                 @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'] }}
+                                    正式 {{ $row['questions_count'] }}
+                                    · 表内 {{ $row['tem_count'] }}
+                                    · 可入库 {{ $row['tem_importable_count'] ?? 0 }}
                                 </span>
                             </div>
                         </x-filament::button>
@@ -118,16 +156,28 @@
         {{-- 中:与判卷页同源 components.exam.paper-body(一行一题,含选项布局/答案/解题思路) --}}
         <x-filament::section
             heading="待审题目(判卷页版式)"
-            description="与 pdf.exam-grading 同源;已自动隐藏「正式库同 KP 且题干一致」的题目。点击题目为勾选(不跑质检);右侧可一键批量入库,精细质检与单题入库在「高级」中展开"
+            description="与 pdf.exam-grading 同源;列表仅展示「可入库」题目=questions_tem 本 KP 去掉与正式库同题干重复后的条目(与左侧「可入库」同数)。点击题目勾选;右侧可批量入库,精细质检在「高级」。"
             :compact="true"
         >
             @if (! $this->selectedKpCode)
                 <p class="text-sm text-gray-600 dark:text-gray-400">请先在左侧选择一个知识点。</p>
             @else
+                @php
+                    $qtrKpMeta = collect($this->kpRows)->firstWhere('kp_code', $this->selectedKpCode);
+                @endphp
                 <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>
+                    <span class="text-xs text-gray-500">
+                        本列表 <span class="font-semibold text-gray-700 dark:text-gray-200">{{ count($this->temQuestions) }}</span> 道可入库预览
+                        @if ($qtrKpMeta)
+                            <span class="text-gray-400 dark:text-gray-500">
+                                (表内 {{ (int) ($qtrKpMeta['tem_count'] ?? 0) }} 道,已与正式库题干重复而隐藏
+                                {{ max(0, (int) ($qtrKpMeta['tem_count'] ?? 0) - (int) ($qtrKpMeta['tem_importable_count'] ?? 0)) }}
+                                道)
+                            </span>
+                        @endif
+                    </span>
                 </div>
 
                 {{-- KP 切换时整栏重绘;仅换选中题时 wire:ignore 阻止中间 DOM morph,右侧照常更新 --}}

+ 70 - 0
tests/Unit/AnswerSolutionStepMarkerInjectorTest.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Support\AnswerSolutionStepMarkerInjector;
+use PHPUnit\Framework\TestCase;
+
+class AnswerSolutionStepMarkerInjectorTest extends TestCase
+{
+    public function test_inserts_chinese_step_labels_before_two_subquestions(): void
+    {
+        $raw = '(1)因为 $f(x)=x$,所以 $f\'(x)=1$。(2)故结论成立。';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertStringStartsWith('步骤一:', $out);
+        $this->assertStringContainsString('步骤二:', $out);
+    }
+
+    public function test_skips_when_step_markers_already_present(): void
+    {
+        $raw = '步骤1:(1)略 (2)略';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertSame($raw, $out);
+    }
+
+    public function test_skips_single_subquestion(): void
+    {
+        $raw = '(1)唯一小问';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertSame($raw, $out);
+    }
+
+    public function test_skips_choice_type(): void
+    {
+        $raw = '(1)A (2)B';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'choice');
+        $this->assertSame($raw, $out);
+    }
+
+    public function test_does_not_trigger_on_function_value_f1(): void
+    {
+        $raw = '故$f(1)=0$,且 $g(2)=1$。';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertSame($raw, $out);
+    }
+
+    public function test_ordered_chain_skips_later_paren_one_so_no_step_three(): void
+    {
+        $raw = '(1)求$m$。(1)继续第一问。(2)第二问收尾。';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertStringStartsWith('步骤一:', $out);
+        $this->assertStringContainsString('步骤二:(2)', $out);
+        $this->assertStringNotContainsString('步骤三:', $out);
+    }
+
+    public function test_subquestion_on_newline_after_first(): void
+    {
+        $raw = "(1)第一问。\n(2)第二问。";
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertStringStartsWith('步骤一:', $out);
+        $this->assertStringContainsString('步骤二:(2)', $out);
+    }
+
+    public function test_fullwidth_paren_arabic_subquestions_get_step_labels(): void
+    {
+        $raw = '(1)当$a=2$时求得集合。(2)若选择①,则取值范围为……';
+        $out = AnswerSolutionStepMarkerInjector::enrichIfNeeded($raw, 'answer');
+        $this->assertStringStartsWith('步骤一:', $out);
+        $this->assertStringContainsString('步骤二:(2)', $out);
+    }
+}