yemeishu 1 週間 前
コミット
84491886e4
100 ファイル変更2479 行追加713 行削除
  1. 38 0
      app/Console/Commands/BackfillQuestionMetaCommand.php
  2. 30 0
      app/Console/Commands/ImportMarkdownCommand.php
  3. 23 0
      app/Console/Commands/ImportPdfCommand.php
  4. 35 0
      app/Console/Commands/RebuildKnowledgeStatsCommand.php
  5. 37 0
      app/Console/Commands/SyncQuestionAssetsCommand.php
  6. 25 0
      app/Console/Commands/SyncQuestionsFromQuestionBank.php
  7. 31 0
      app/Domain/Import/ImportPipeline.php
  8. 14 0
      app/Domain/Import/ImportResult.php
  9. 22 0
      app/Domain/Import/MarkdownSplitter.php
  10. 21 0
      app/Domain/Import/PdfSplitter.php
  11. 14 0
      app/Domain/Import/Stages/NormalizeMarkdown.php
  12. 21 0
      app/Domain/Import/Stages/SplitMarkdown.php
  13. 20 0
      app/Domain/Import/Stages/SplitPdf.php
  14. 24 0
      app/Domain/Questions/QuestionPipeline.php
  15. 22 0
      app/Domain/Questions/Stages/ClassifyQuestion.php
  16. 22 0
      app/Domain/Questions/Stages/EstimateDifficulty.php
  17. 21 0
      app/Domain/Questions/Stages/NormalizeQuestion.php
  18. 0 4
      app/Filament/AdminPanelProvider.php
  19. 150 0
      app/Filament/Pages/ApiCatalog.php
  20. 20 0
      app/Filament/Pages/ApiDebugHub.php
  21. 2 2
      app/Filament/Pages/ExamAnalysis.php
  22. 2 2
      app/Filament/Pages/ExamDetail.php
  23. 2 2
      app/Filament/Pages/ExamHistory.php
  24. 33 0
      app/Filament/Pages/ImportWizard.php
  25. 2 2
      app/Filament/Pages/Integrations/KnowledgeGraphExplorer.php
  26. 3 2
      app/Filament/Pages/Integrations/KnowledgeGraphIntegration.php
  27. 2 2
      app/Filament/Pages/IntelligentExamGeneration.php
  28. 2 2
      app/Filament/Pages/KnowledgeGraphManagement.php
  29. 3 2
      app/Filament/Pages/KnowledgeGraphVisualization.php
  30. 4 2
      app/Filament/Pages/KnowledgeMindmap.php
  31. 1 4
      app/Filament/Pages/KnowledgePointDetail.php
  32. 4 2
      app/Filament/Pages/KnowledgePoints.php
  33. 3 2
      app/Filament/Pages/KnowledgeRelationManagement.php
  34. 140 11
      app/Filament/Pages/MistakeBook.php
  35. 1 1
      app/Filament/Pages/OCRAnalysisView.php
  36. 2 1
      app/Filament/Pages/OCRPaperGrading.php
  37. 2 2
      app/Filament/Pages/OCRRecordList.php
  38. 27 49
      app/Filament/Pages/PromptManagement.php
  39. 2 2
      app/Filament/Pages/QuestionDetail.php
  40. 22 17
      app/Filament/Pages/QuestionGeneration.php
  41. 2 2
      app/Filament/Pages/QuestionManagement.php
  42. 12 97
      app/Filament/Pages/QuestionReview.php
  43. 33 0
      app/Filament/Pages/QuestionReviewWorkbench.php
  44. 2 1
      app/Filament/Pages/RecommendationList.php
  45. 47 71
      app/Filament/Pages/SimulatedGrading.php
  46. 3 2
      app/Filament/Pages/Statistics/KnowledgePointStats.php
  47. 1 1
      app/Filament/Pages/StudentAnalysis.php
  48. 2 2
      app/Filament/Pages/StudentDashboard.php
  49. 3 1
      app/Filament/Pages/StudentKnowledgeGraphPage.php
  50. 10 1
      app/Filament/Pages/StudentManagement.php
  51. 22 0
      app/Filament/Pages/StudentManagementQuickAccess.php
  52. 32 4
      app/Filament/Pages/TextbookImport/TextbookExcelImportPage.php
  53. 2 2
      app/Filament/Pages/UploadExamPaper.php
  54. 92 0
      app/Filament/Resources/KnowledgePointResource.php
  55. 11 0
      app/Filament/Resources/KnowledgePointResource/Pages/CreateKnowledgePoint.php
  56. 11 0
      app/Filament/Resources/KnowledgePointResource/Pages/EditKnowledgePoint.php
  57. 19 0
      app/Filament/Resources/KnowledgePointResource/Pages/ListKnowledgePoints.php
  58. 11 0
      app/Filament/Resources/KnowledgePointResource/Pages/ViewKnowledgePoint.php
  59. 17 0
      app/Filament/Resources/KnowledgePointResource/Widgets/KnowledgePointStats.php
  60. 3 3
      app/Filament/Resources/MarkdownImportResource.php
  61. 1 1
      app/Filament/Resources/MenuPermissionResource.php
  62. 2 2
      app/Filament/Resources/PaperPartResource.php
  63. 2 2
      app/Filament/Resources/PreQuestionCandidateResource.php
  64. 93 0
      app/Filament/Resources/QuestionAssetResource.php
  65. 11 0
      app/Filament/Resources/QuestionAssetResource/Pages/CreateQuestionAsset.php
  66. 11 0
      app/Filament/Resources/QuestionAssetResource/Pages/EditQuestionAsset.php
  67. 19 0
      app/Filament/Resources/QuestionAssetResource/Pages/ListQuestionAssets.php
  68. 11 0
      app/Filament/Resources/QuestionAssetResource/Pages/ViewQuestionAsset.php
  69. 19 0
      app/Filament/Resources/QuestionAssetResource/Widgets/QuestionAssetStats.php
  70. 126 0
      app/Filament/Resources/QuestionResource.php
  71. 11 0
      app/Filament/Resources/QuestionResource/Pages/CreateQuestion.php
  72. 11 0
      app/Filament/Resources/QuestionResource/Pages/EditQuestion.php
  73. 19 0
      app/Filament/Resources/QuestionResource/Pages/ListQuestions.php
  74. 11 0
      app/Filament/Resources/QuestionResource/Pages/ViewQuestion.php
  75. 19 0
      app/Filament/Resources/QuestionResource/Widgets/QuestionStats.php
  76. 2 2
      app/Filament/Resources/SourceFileResource.php
  77. 3 3
      app/Filament/Resources/SourcePaperResource.php
  78. 63 93
      app/Filament/Resources/TextbookCatalogResource.php
  79. 0 7
      app/Filament/Resources/TextbookCatalogResource/Pages/ManageTextbookCatalogs.php
  80. 26 42
      app/Filament/Resources/TextbookResource/Pages/EditTextbook.php
  81. 15 59
      app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php
  82. 25 81
      app/Filament/Resources/TextbookResource/Tables/TextbookTable.php
  83. 9 1
      app/Filament/Traits/HasUserRole.php
  84. 21 0
      app/Http/Controllers/Api/AbilityEvaluateController.php
  85. 18 96
      app/Http/Controllers/Api/IntelligentExamController.php
  86. 36 0
      app/Http/Controllers/Api/KnowledgeRecommendController.php
  87. 261 1
      app/Http/Controllers/Api/MistakeBookController.php
  88. 35 0
      app/Http/Controllers/Api/PaperAssembleController.php
  89. 39 0
      app/Http/Controllers/Api/PaperJsonController.php
  90. 33 0
      app/Http/Controllers/Api/QuestionRandomController.php
  91. 38 0
      app/Http/Controllers/Api/QuestionSearchController.php
  92. 21 0
      app/Http/Controllers/Api/QuestionSolutionController.php
  93. 44 0
      app/Jobs/GenerateSolutionJob.php
  94. 44 0
      app/Jobs/GenerateSvgJob.php
  95. 53 0
      app/Jobs/MatchKnowledgeJob.php
  96. 39 0
      app/Jobs/ProcessMarkdownJob.php
  97. 69 0
      app/Jobs/ProcessPdfJob.php
  98. 27 0
      app/Jobs/ProcessQuestionJob.php
  99. 6 15
      app/Livewire/Integrations/KnowledgeGraphComponent.php
  100. 2 8
      app/Livewire/Integrations/KnowledgePointDetails.php

+ 38 - 0
app/Console/Commands/BackfillQuestionMetaCommand.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Question;
+use App\Models\QuestionMeta;
+use Illuminate\Console\Command;
+
+class BackfillQuestionMetaCommand extends Command
+{
+    protected $signature = 'question:backfill-meta';
+    protected $description = 'Ensure question_meta rows exist for every question';
+
+    public function handle(): int
+    {
+        $questions = Question::query()->get(['id']);
+        $created = 0;
+
+        foreach ($questions as $question) {
+            $meta = QuestionMeta::firstOrCreate(
+                ['question_id' => $question->id],
+                [
+                    'abilities' => [],
+                    'generation_info' => [],
+                    'review_status' => 'pending',
+                ]
+            );
+
+            if ($meta->wasRecentlyCreated) {
+                $created++;
+            }
+        }
+
+        $this->info(sprintf('Question meta backfilled: %d created', $created));
+
+        return self::SUCCESS;
+    }
+}

+ 30 - 0
app/Console/Commands/ImportMarkdownCommand.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\ProcessMarkdownJob;
+use App\Services\QuestionImportService;
+use Illuminate\Console\Command;
+
+class ImportMarkdownCommand extends Command
+{
+    protected $signature = 'question:import-markdown {path}';
+    protected $description = 'Import markdown questions into pre_question_candidates';
+
+    public function handle(QuestionImportService $service): int
+    {
+        $path = (string) $this->argument('path');
+
+        $result = $service->importMarkdown($path);
+
+        ProcessMarkdownJob::dispatch($result->importId, $result->sourceFileId);
+
+        $this->info(sprintf(
+            'Queued markdown import. source_file_id=%d import_id=%d',
+            $result->sourceFileId,
+            $result->importId
+        ));
+
+        return self::SUCCESS;
+    }
+}

+ 23 - 0
app/Console/Commands/ImportPdfCommand.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\ProcessPdfJob;
+use Illuminate\Console\Command;
+
+class ImportPdfCommand extends Command
+{
+    protected $signature = 'question:import-pdf {path}';
+    protected $description = 'Import pdf questions into pre_question_candidates';
+
+    public function handle(): int
+    {
+        $path = (string) $this->argument('path');
+
+        ProcessPdfJob::dispatch($path);
+
+        $this->info(sprintf('Queued pdf import for %s', $path));
+
+        return self::SUCCESS;
+    }
+}

+ 35 - 0
app/Console/Commands/RebuildKnowledgeStatsCommand.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\KnowledgePoint;
+use App\Models\QuestionKpRelation;
+use Illuminate\Console\Command;
+
+class RebuildKnowledgeStatsCommand extends Command
+{
+    protected $signature = 'knowledge:rebuild-stats';
+    protected $description = 'Recalculate knowledge stats from questions';
+
+    public function handle(): int
+    {
+        $stats = QuestionKpRelation::query()
+            ->selectRaw('kp_code, COUNT(*) as question_count')
+            ->groupBy('kp_code')
+            ->get();
+
+        foreach ($stats as $row) {
+            KnowledgePoint::query()
+                ->where('kp_code', $row->kp_code)
+                ->update([
+                    'stats' => [
+                        'question_count' => (int) $row->question_count,
+                    ],
+                ]);
+        }
+
+        $this->info('Knowledge stats rebuilt.');
+
+        return self::SUCCESS;
+    }
+}

+ 37 - 0
app/Console/Commands/SyncQuestionAssetsCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Question;
+use App\Models\QuestionAsset;
+use App\Services\SvgConverterService;
+use Illuminate\Console\Command;
+
+class SyncQuestionAssetsCommand extends Command
+{
+    protected $signature = 'question:sync-assets';
+    protected $description = 'Extract and sync SVG/image assets for questions';
+
+    public function handle(SvgConverterService $svgService): int
+    {
+        $questions = Question::query()->get(['id', 'stem']);
+
+        foreach ($questions as $question) {
+            $assets = $svgService->extractSvgAssets($question->stem ?? '');
+
+            foreach ($assets as $asset) {
+                QuestionAsset::firstOrCreate([
+                    'question_id' => $question->id,
+                    'asset_type' => $asset['type'] ?? 'svg',
+                    'path' => $asset['path'] ?? '',
+                ], [
+                    'meta' => $asset['meta'] ?? [],
+                ]);
+            }
+        }
+
+        $this->info('Question assets synced.');
+
+        return self::SUCCESS;
+    }
+}

+ 25 - 0
app/Console/Commands/SyncQuestionsFromQuestionBank.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class SyncQuestionsFromQuestionBank extends Command
+{
+    protected $signature = 'question:sync-from-question-bank
+        {--dry-run : Preview only, do not write to MySQL}';
+
+    protected $description = 'Sync AI-generated questions from QuestionBankService into MySQL questions table';
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+
+        $this->info(sprintf(
+            '题库已完全迁移到本地数据库,question:sync-from-question-bank 已停用。dry_run=%s',
+            $dryRun ? 'true' : 'false'
+        ));
+
+        return self::SUCCESS;
+    }
+}

+ 31 - 0
app/Domain/Import/ImportPipeline.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Domain\Import;
+
+use Illuminate\Pipeline\Pipeline;
+
+class ImportPipeline
+{
+    public function __construct(private readonly Pipeline $pipeline)
+    {
+    }
+
+    public function run(string $type, array $payload): array
+    {
+        $stages = match ($type) {
+            'markdown' => [
+                \App\Domain\Import\Stages\NormalizeMarkdown::class,
+                \App\Domain\Import\Stages\SplitMarkdown::class,
+            ],
+            'pdf' => [
+                \App\Domain\Import\Stages\SplitPdf::class,
+            ],
+            default => [],
+        };
+
+        return $this->pipeline
+            ->send($payload)
+            ->through($stages)
+            ->thenReturn();
+    }
+}

+ 14 - 0
app/Domain/Import/ImportResult.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Domain\Import;
+
+class ImportResult
+{
+    public function __construct(
+        public int $sourceFileId,
+        public int $importId,
+        public int $candidates,
+        public array $meta = []
+    ) {
+    }
+}

+ 22 - 0
app/Domain/Import/MarkdownSplitter.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Domain\Import;
+
+use App\Services\AsyncMarkdownSplitter;
+
+class MarkdownSplitter
+{
+    public function __construct(private readonly AsyncMarkdownSplitter $splitter)
+    {
+    }
+
+    public function split(string $markdown): array
+    {
+        return $this->splitter->split($markdown);
+    }
+
+    public function validate(array $candidates): bool
+    {
+        return $this->splitter->validate($candidates);
+    }
+}

+ 21 - 0
app/Domain/Import/PdfSplitter.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Domain\Import;
+
+class PdfSplitter
+{
+    public function split(string $pdfPath): array
+    {
+        return [
+            [
+                'sequence' => 1,
+                'index' => 1,
+                'raw_markdown' => '',
+                'meta' => [
+                    'source' => 'pdf',
+                    'path' => $pdfPath,
+                ],
+            ],
+        ];
+    }
+}

+ 14 - 0
app/Domain/Import/Stages/NormalizeMarkdown.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Domain\Import\Stages;
+
+class NormalizeMarkdown
+{
+    public function handle(array $payload, \Closure $next): array
+    {
+        $markdown = (string) ($payload['markdown'] ?? '');
+        $payload['markdown'] = trim($markdown);
+
+        return $next($payload);
+    }
+}

+ 21 - 0
app/Domain/Import/Stages/SplitMarkdown.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Domain\Import\Stages;
+
+use App\Domain\Import\MarkdownSplitter;
+
+class SplitMarkdown
+{
+    public function __construct(private readonly MarkdownSplitter $splitter)
+    {
+    }
+
+    public function handle(array $payload, \Closure $next): array
+    {
+        $markdown = (string) ($payload['markdown'] ?? '');
+        $payload['blocks'] = $this->splitter->split($markdown);
+        $payload['is_valid'] = $this->splitter->validate($payload['blocks']);
+
+        return $next($payload);
+    }
+}

+ 20 - 0
app/Domain/Import/Stages/SplitPdf.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Domain\Import\Stages;
+
+use App\Domain\Import\PdfSplitter;
+
+class SplitPdf
+{
+    public function __construct(private readonly PdfSplitter $splitter)
+    {
+    }
+
+    public function handle(array $payload, \Closure $next): array
+    {
+        $payload['blocks'] = $this->splitter->split((string) ($payload['path'] ?? ''));
+        $payload['is_valid'] = true;
+
+        return $next($payload);
+    }
+}

+ 24 - 0
app/Domain/Questions/QuestionPipeline.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Domain\Questions;
+
+use Illuminate\Pipeline\Pipeline;
+
+class QuestionPipeline
+{
+    public function __construct(private readonly Pipeline $pipeline)
+    {
+    }
+
+    public function run(array $payload): array
+    {
+        return $this->pipeline
+            ->send($payload)
+            ->through([
+                \App\Domain\Questions\Stages\NormalizeQuestion::class,
+                \App\Domain\Questions\Stages\ClassifyQuestion::class,
+                \App\Domain\Questions\Stages\EstimateDifficulty::class,
+            ])
+            ->thenReturn();
+    }
+}

+ 22 - 0
app/Domain/Questions/Stages/ClassifyQuestion.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Domain\Questions\Stages;
+
+use App\Services\AiQuestionStructService;
+
+class ClassifyQuestion
+{
+    public function __construct(private readonly AiQuestionStructService $service)
+    {
+    }
+
+    public function handle(array $payload, \Closure $next): array
+    {
+        $payload['question_type'] = $this->service->classifyQuestionType(
+            (string) ($payload['stem'] ?? ''),
+            $payload['options'] ?? []
+        );
+
+        return $next($payload);
+    }
+}

+ 22 - 0
app/Domain/Questions/Stages/EstimateDifficulty.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Domain\Questions\Stages;
+
+use App\Services\AiQuestionStructService;
+
+class EstimateDifficulty
+{
+    public function __construct(private readonly AiQuestionStructService $service)
+    {
+    }
+
+    public function handle(array $payload, \Closure $next): array
+    {
+        $payload['difficulty'] = $this->service->estimateDifficulty(
+            (string) ($payload['stem'] ?? ''),
+            $payload
+        );
+
+        return $next($payload);
+    }
+}

+ 21 - 0
app/Domain/Questions/Stages/NormalizeQuestion.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Domain\Questions\Stages;
+
+use App\Services\AiQuestionStructService;
+
+class NormalizeQuestion
+{
+    public function __construct(private readonly AiQuestionStructService $service)
+    {
+    }
+
+    public function handle(array $payload, \Closure $next): array
+    {
+        $raw = (string) ($payload['raw'] ?? '');
+        $fields = $this->service->extractFields($raw);
+        $payload = array_merge($payload, $fields);
+
+        return $next($payload);
+    }
+}

+ 0 - 4
app/Filament/AdminPanelProvider.php

@@ -32,10 +32,6 @@ class AdminPanelProvider extends PanelProvider
             ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
             ->pages([
                 \App\Filament\Pages\UploadExamPaper::class,
-                \App\Filament\Pages\OCRRecordList::class,
-                \App\Filament\Pages\OCRRecordView::class,
-                \App\Filament\Pages\OCRPaperGrading::class,
-                \App\Filament\Pages\OCRAnalysisView::class,
                 \App\Filament\Pages\QuestionDetail::class,
             ])
             ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')

+ 150 - 0
app/Filament/Pages/ApiCatalog.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use Filament\Pages\Page;
+use Illuminate\Support\Facades\Route;
+use UnitEnum;
+use BackedEnum;
+
+class ApiCatalog extends Page
+{
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
+
+    protected static ?string $navigationLabel = 'API 列表';
+
+    protected static UnitEnum|string|null $navigationGroup = 'API 管理';
+
+    protected static ?int $navigationSort = 1;
+
+    protected string $view = 'filament.pages.api-catalog';
+
+    public array $apiGroups = [];
+
+    public function mount(): void
+    {
+        $this->apiGroups = $this->buildFromRoutes();
+    }
+
+    private function buildFromRoutes(): array
+    {
+        $routes = Route::getRoutes();
+        $groups = [];
+        $detailsMap = $this->buildDetailsMap();
+        foreach ($routes as $route) {
+            $uri = $route->uri();
+            if (!str_starts_with($uri, 'api/')) {
+                continue;
+            }
+
+            $methods = array_values(array_filter($route->methods(), fn ($method) => $method !== 'HEAD'));
+            $path = '/' . $uri;
+            $groupName = $this->resolveGroupName($uri);
+            $tag = $this->resolveTag($route, $uri);
+
+            $params = $this->buildParamHint($uri, $methods);
+            $response = 'JSON';
+            $details = $detailsMap[$uri] ?? [];
+            $details['route_name'] = $route->getName();
+            $details['action'] = $route->getActionName();
+
+            foreach ($methods as $method) {
+                $groups[$groupName]['name'] = $groupName;
+                $groups[$groupName]['items'][] = [
+                    'method' => $method,
+                    'path' => $path,
+                    'params' => $params,
+                    'response' => $response,
+                    'tag' => $tag,
+                    'details' => $details,
+                ];
+            }
+        }
+
+        ksort($groups);
+
+        return array_values($groups);
+    }
+
+    private function buildDetailsMap(): array
+    {
+        return [
+            'api/papers/{paperId}/json' => [
+                'description' => '返回与智能出卷返回值中 exam_content 完全一致的试卷 JSON。',
+                'examples' => [
+                    'GET /api/papers/paper_1765788931_ce02f6a3/json',
+                    'GET /api/papers/paper_1765788931_ce02f6a3/json?download=1',
+                ],
+            ],
+        ];
+    }
+
+    private function resolveGroupName(string $uri): string
+    {
+        $map = [
+            'api/questions' => '题库核心 API',
+            'api/papers' => '题库核心 API',
+            'api/knowledge-mastery' => '知识点掌握',
+            'api/mistake-book' => '错题本 API',
+            'api/analytics' => '错题本 API',
+            'api/intelligent-exams' => '智能出卷与学情报告',
+            'api/exam-analysis' => '智能出卷与学情报告',
+            'api/textbooks' => '教材 API',
+            'api/mathrecsys' => 'MathRecSys 集成 API',
+            'api/knowledge' => '知识点与能力 API',
+            'api/knowledge-points' => '知识点与能力 API',
+        ];
+
+        foreach ($map as $prefix => $name) {
+            if (str_starts_with($uri, $prefix)) {
+                return $name;
+            }
+        }
+
+        if (str_starts_with($uri, 'api/ocr') || str_contains($uri, 'callback')) {
+            return '回调与内部 API';
+        }
+
+        if (str_contains($uri, 'test')) {
+            return '测试 API';
+        }
+
+        return '其他';
+    }
+
+    private function resolveTag(\Illuminate\Routing\Route $route, string $uri): ?string
+    {
+        $middleware = (array) $route->middleware();
+        if (in_array('internal.token', $middleware, true)) {
+            return 'internal';
+        }
+
+        if (str_contains($uri, 'test')) {
+            return 'test';
+        }
+
+        return null;
+    }
+
+    private function buildParamHint(string $uri, array $methods): string
+    {
+        preg_match_all('/\{([^}]+)\}/', $uri, $matches);
+        $pathParams = $matches[1] ?? [];
+
+        $parts = [];
+        if (!empty($pathParams)) {
+            $parts[] = 'path: ' . implode(', ', $pathParams);
+        }
+
+        $hasBody = array_intersect($methods, ['POST', 'PUT', 'PATCH']);
+        if ($hasBody) {
+            $parts[] = 'body: payload';
+        }
+
+        if (empty($parts)) {
+            return 'query/body: -';
+        }
+
+        return implode(' | ', $parts);
+    }
+}

+ 20 - 0
app/Filament/Pages/ApiDebugHub.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use Filament\Pages\Page;
+use UnitEnum;
+use BackedEnum;
+
+class ApiDebugHub extends Page
+{
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
+
+    protected static ?string $navigationLabel = '题库 API 调试';
+
+    protected static UnitEnum|string|null $navigationGroup = 'API 管理';
+
+    protected static ?int $navigationSort = 2;
+
+    protected string $view = 'filament.pages.api-debug-hub';
+}

+ 2 - 2
app/Filament/Pages/ExamAnalysis.php

@@ -18,8 +18,8 @@ class ExamAnalysis extends Page
     protected static ?string $title = '试卷分析详情';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
     protected static ?string $navigationLabel = '试卷分析';
-    protected static string|UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 15;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 8;
 
     #[Url]
     public ?string $recordId = null;  // OCR记录ID

+ 2 - 2
app/Filament/Pages/ExamDetail.php

@@ -14,8 +14,8 @@ class ExamDetail extends Page
     protected static ?string $title = '试卷详情';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
     protected static ?string $navigationLabel = '试卷详情';
-    protected static string|UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 15;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 7;
 
     protected string $view = 'filament.pages.exam-detail';
 

+ 2 - 2
app/Filament/Pages/ExamHistory.php

@@ -14,8 +14,8 @@ class ExamHistory extends Page
     protected static ?string $title = '卷子历史记录';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-duplicate';
     protected static ?string $navigationLabel = '卷子历史';
-    protected static string|UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 14;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 6;
 
     protected string $view = 'filament.pages.exam-history-simple';
 

+ 33 - 0
app/Filament/Pages/ImportWizard.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Services\QuestionImportService;
+use App\Jobs\ProcessMarkdownJob;
+use Filament\Pages\Page;
+use UnitEnum;
+
+class ImportWizard extends Page
+{
+    protected static bool $shouldRegisterNavigation = false;
+
+    protected static ?string $navigationLabel = 'Markdown/PDF 导入';
+
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
+
+    protected static ?int $navigationSort = 1;
+
+    protected string $view = 'filament.pages.import-wizard';
+
+    public ?string $filePath = null;
+
+    public function submitImport(QuestionImportService $service): void
+    {
+        if (!$this->filePath) {
+            return;
+        }
+
+        $result = $service->importMarkdown($this->filePath);
+        ProcessMarkdownJob::dispatch($result->importId, $result->sourceFileId);
+    }
+}

+ 2 - 2
app/Filament/Pages/Integrations/KnowledgeGraphExplorer.php

@@ -13,8 +13,8 @@ class KnowledgeGraphExplorer extends Page
     protected static ?string $title = '知识图谱浏览';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
     protected static ?string $navigationLabel = '知识图谱浏览';
-    protected static string|UnitEnum|null $navigationGroup = '整合视图';
-    protected static ?int $navigationSort = 10;
+    protected static string|UnitEnum|null $navigationGroup = '知识图谱管理';
+    protected static ?int $navigationSort = 6;
     protected string $view = 'filament.pages.integrations.knowledge-graph-explorer';
 
     public ?string $selectedKpCode = null;

+ 3 - 2
app/Filament/Pages/Integrations/KnowledgeGraphIntegration.php

@@ -17,8 +17,9 @@ class KnowledgeGraphIntegration extends Page
     protected static ?string $title = '知识图谱整合视图';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
     protected static ?string $navigationLabel = '知识图谱整合';
-    protected static string|UnitEnum|null $navigationGroup = '整合视图';
-    protected static ?int $navigationSort = 10;
+    protected static string|UnitEnum|null $navigationGroup = null;
+    protected static ?int $navigationSort = 7;
+    protected static bool $shouldRegisterNavigation = false;
     protected string $view = 'filament.pages.integrations.knowledge-graph-integration';
 
     public ?string $selectedKpCode = null;

+ 2 - 2
app/Filament/Pages/IntelligentExamGeneration.php

@@ -23,8 +23,8 @@ class IntelligentExamGeneration extends Page
     protected static ?string $title = '智能出卷';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-duplicate';
     protected static ?string $navigationLabel = '智能出卷';
-    protected static string|UnitEnum|null $navigationGroup = '操作';
-    protected static ?int $navigationSort = 2;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 1;
 
     protected string $view = 'filament.pages.intelligent-exam-generation-simple';
 

+ 2 - 2
app/Filament/Pages/KnowledgeGraphManagement.php

@@ -16,9 +16,9 @@ use Illuminate\Support\Facades\Storage;
 class KnowledgeGraphManagement extends Page
 {
     protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
-    protected static string|\UnitEnum|null $navigationGroup = '管理';
+    protected static string|\UnitEnum|null $navigationGroup = '知识图谱管理';
     protected static ?string $navigationLabel = '知识图谱管理';
-    protected static ?int $navigationSort = 17;
+    protected static ?int $navigationSort = 5;
     protected static ?string $title = '知识图谱管理';
     protected string $view = 'filament.pages.knowledge-graph-management';
 

+ 3 - 2
app/Filament/Pages/KnowledgeGraphVisualization.php

@@ -9,8 +9,9 @@ use Filament\Pages\Page;
 class KnowledgeGraphVisualization extends Page
 {
     protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-share';
-    protected static string|\UnitEnum|null $navigationGroup = '资源';
-    protected static ?int $navigationSort = 28;
+    protected static string|\UnitEnum|null $navigationGroup = null;
+    protected static ?int $navigationSort = 3;
+    protected static bool $shouldRegisterNavigation = false;
     protected static ?string $navigationLabel = '知识图谱可视化';
     protected static ?string $title = '知识图谱可视化';
     protected string $view = 'filament.pages.knowledge-graph-visualization-simple';

+ 4 - 2
app/Filament/Pages/KnowledgeMindmap.php

@@ -13,12 +13,14 @@ class KnowledgeMindmap extends Page
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-share';
 
-    protected static string|UnitEnum|null $navigationGroup = '资源';
+    protected static string|UnitEnum|null $navigationGroup = null;
 
-    protected static ?int $navigationSort = 29;
+    protected static ?int $navigationSort = 4;
 
     protected static ?string $navigationLabel = '知识图谱脑图';
 
+    protected static bool $shouldRegisterNavigation = false;
+
     protected static ?string $slug = 'knowledge-mindmap';
 
     protected static ?string $title = '知识图谱脑图';

+ 1 - 4
app/Filament/Pages/KnowledgePointDetail.php

@@ -98,10 +98,7 @@ class KnowledgePointDetail extends Page
             Action::make('back_to_list')
                 ->label('返回列表')
                 ->icon('heroicon-o-arrow-left')
-                ->url(route('filament.admin.pages.knowledge-points', [
-                    'phase' => $this->phaseFilter,
-                    'selected' => $this->kpCode
-                ]))
+                ->url(route('filament.admin.resources.knowledge-points.index'))
                 ->color('gray'),
         ];
     }

+ 4 - 2
app/Filament/Pages/KnowledgePoints.php

@@ -13,13 +13,15 @@ use UnitEnum;
 
 class KnowledgePoints extends Page
 {
+    protected static ?string $slug = 'knowledge-points-overview';
+
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-map';
 
-    protected static string|UnitEnum|null $navigationGroup = '资源';
+    protected static string|UnitEnum|null $navigationGroup = '知识图谱管理';
 
     protected static ?string $navigationLabel = '知识点总览';
 
-    protected static ?int $navigationSort = 30;
+    protected static ?int $navigationSort = 9;
 
     protected string $view = 'filament.pages.knowledge-points';
 

+ 3 - 2
app/Filament/Pages/KnowledgeRelationManagement.php

@@ -8,8 +8,9 @@ use Filament\Pages\Page;
 class KnowledgeRelationManagement extends Page
 {
     protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-link';
-    protected static string|\UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 18;
+    protected static string|\UnitEnum|null $navigationGroup = null;
+    protected static ?int $navigationSort = 2;
+    protected static bool $shouldRegisterNavigation = false;
     protected static ?string $navigationLabel = '关联关系管理';
     protected static ?string $title = '关联关系管理';
     protected string $view = 'filament.pages.knowledge-relation-management';

+ 140 - 11
app/Filament/Pages/MistakeBook.php

@@ -27,9 +27,9 @@ class MistakeBook extends Page
 
     protected static ?string $navigationLabel = '错题本';
 
-    protected static string|UnitEnum|null $navigationGroup = '操作';
+    protected static string|UnitEnum|null $navigationGroup = '学生管理';
 
-    protected static ?int $navigationSort = 5;
+    protected static ?int $navigationSort = 3;
 
     protected static ?string $slug = 'mistake-book';
 
@@ -359,20 +359,148 @@ class MistakeBook extends Page
         }
 
         $service = app(MistakeBookService::class);
-        $successCount = 0;
+        $result = $service->batchOperation($this->selectedMistakeIds, 'reviewed');
 
-        foreach ($this->selectedMistakeIds as $mistakeId) {
-            if ($service->markReviewed($mistakeId)) {
-                $this->updateMistakeField($mistakeId, 'reviewed', true);
-                $successCount++;
-            }
+        if ($result['success'] ?? false) {
+            $this->selectedMistakeIds = [];
+            $this->notify("已标记 {$result['success_count']} 道题为已复习");
+            $this->loadMistakeData(); // 重新加载数据
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
+        }
+    }
+
+    /**
+     * 批量标记为已掌握
+     */
+    public function batchMarkMastered(): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'mastered');
+
+        if ($result['success'] ?? false) {
+            $this->selectedMistakeIds = [];
+            $this->notify("已标记 {$result['success_count']} 道题为重点掌握");
+            $this->loadMistakeData();
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
+        }
+    }
+
+    /**
+     * 批量加入重练清单
+     */
+    public function batchAddToRetryList(): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'retry_list');
+
+        if ($result['success'] ?? false) {
+            $this->notify("已加入 {$result['success_count']} 道题到重练清单");
+            $this->loadMistakeData();
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
+        }
+    }
+
+    /**
+     * 批量从重练清单移除
+     */
+    public function batchRemoveFromRetryList(): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'remove_retry_list');
+
+        if ($result['success'] ?? false) {
+            $this->notify("已从重练清单移除 {$result['success_count']} 道题");
+            $this->loadMistakeData();
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
         }
+    }
+
+    /**
+     * 批量设置错误类型
+     */
+    public function batchSetErrorType(string $errorType): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'set_error_type', [
+            'error_type' => $errorType,
+        ]);
 
-        if ($successCount > 0) {
+        if ($result['success'] ?? false) {
             $this->selectedMistakeIds = [];
-            $this->notify("已标记 {$successCount} 道题为已复习");
+            $this->notify("已为 {$result['success_count']} 道题设置错误类型");
+            $this->loadMistakeData();
         } else {
-            $this->notify('操作失败,请稍后再试', 'danger');
+            $this->notify($result['error'] ?? '操作失败', 'danger');
+        }
+    }
+
+    /**
+     * 批量设置重要程度
+     */
+    public function batchSetImportance(int $importance): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'set_importance', [
+            'importance' => $importance,
+        ]);
+
+        if ($result['success'] ?? false) {
+            $this->selectedMistakeIds = [];
+            $this->notify("已为 {$result['success_count']} 道题设置重要程度");
+            $this->loadMistakeData();
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
+        }
+    }
+
+    /**
+     * 批量切换收藏状态
+     */
+    public function batchToggleFavorite(): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $service = app(MistakeBookService::class);
+        $result = $service->batchOperation($this->selectedMistakeIds, 'favorite');
+
+        if ($result['success'] ?? false) {
+            $this->selectedMistakeIds = [];
+            $this->notify("已为 {$result['success_count']} 道题切换收藏状态");
+            $this->loadMistakeData();
+        } else {
+            $this->notify($result['error'] ?? '操作失败', 'danger');
         }
     }
 
@@ -682,4 +810,5 @@ class MistakeBook extends Page
         $this->actionMessage = $message;
         $this->actionMessageType = $type;
     }
+
 }

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

@@ -21,7 +21,7 @@ class OCRAnalysisView extends Page
 
     public static function getNavigationGroup(): ?string
     {
-        return 'OCR+AI系统';
+        return '卷子生成管理';
     }
 
     public static function getSlug(?\Filament\Panel $panel = null): string

+ 2 - 1
app/Filament/Pages/OCRPaperGrading.php

@@ -19,6 +19,7 @@ class OCRPaperGrading extends Page
     protected static ?string $navigationLabel = 'OCR智能阅卷';
     protected static ?string $title = 'OCR试卷智能阅卷系统';
     protected static ?string $slug = 'ocr-paper-grading';
+    protected static ?int $navigationSort = 4;
 
     public static function getNavigationIcon(): ?string
     {
@@ -27,7 +28,7 @@ class OCRPaperGrading extends Page
 
     public static function getNavigationGroup(): ?string
     {
-        return 'OCR+AI系统';
+        return '卷子生成管理';
     }
 
     protected string $view = 'filament.pages.ocr-paper-grading';

+ 2 - 2
app/Filament/Pages/OCRRecordList.php

@@ -19,8 +19,8 @@ class OCRRecordList extends Page
     protected static ?string $title = 'OCR识别记录';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
     protected static ?string $navigationLabel = 'OCR识别记录';
-    protected static string|UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 13;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 3;
     protected static ?string $slug = 'ocr-records';
     protected string $view = 'filament.pages.ocr-record-list';
 

+ 27 - 49
app/Filament/Pages/PromptManagement.php

@@ -2,7 +2,7 @@
 
 namespace App\Filament\Pages;
 
-use App\Services\QuestionServiceApi;
+use App\Services\PromptService;
 use BackedEnum;
 use Filament\Actions;
 use Filament\Actions\Action;
@@ -11,17 +11,16 @@ use Filament\Pages\Page;
 use UnitEnum;
 use Livewire\Attributes\Computed;
 use Livewire\Attributes\On;
-use Illuminate\Support\Facades\Http;
 
 class PromptManagement extends Page
 {
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chat-bubble-left-right';
 
-    protected static string|UnitEnum|null $navigationGroup = '管理';
+    protected static string|UnitEnum|null $navigationGroup = '题库管理';
 
     protected static ?string $navigationLabel = '提示词管理';
 
-    protected static ?int $navigationSort = 12;
+    protected static ?int $navigationSort = 6;
 
     protected ?string $heading = '提示词管理';
 
@@ -56,7 +55,7 @@ class PromptManagement extends Page
      */
     public function getPrompts(): array
     {
-        $service = app(QuestionServiceApi::class);
+        $service = app(PromptService::class);
 
         try {
             // 获取所有提示词
@@ -102,7 +101,7 @@ class PromptManagement extends Page
      */
     public function getTypeStats(): array
     {
-        $service = app(QuestionServiceApi::class);
+        $service = app(PromptService::class);
 
         try {
             $prompts = $service->listPrompts();
@@ -222,7 +221,7 @@ class PromptManagement extends Page
     public function deletePrompt(string $promptName): void
     {
         try {
-            $this->request('DELETE', "/prompts/{$promptName}");
+            app(PromptService::class)->deletePrompt($promptName);
             Notification::make()
                 ->title('删除成功')
                 ->body("提示词 {$promptName} 已删除")
@@ -248,9 +247,11 @@ class PromptManagement extends Page
         }
 
         try {
-            $this->request('PUT', "/prompts/{$promptName}", [
-                'is_active' => $isActive ? 'no' : 'yes',
-            ]);
+            $current = app(PromptService::class)->getPrompt($promptName);
+            if ($current) {
+                $current['is_active'] = $isActive ? 'no' : 'yes';
+                app(PromptService::class)->savePrompt($current);
+            }
             Notification::make()
                 ->title(($isActive ? '已禁用 ' : '已启用 ') . $promptName)
                 ->success()
@@ -272,7 +273,7 @@ class PromptManagement extends Page
     {
         $newName = ($prompt['template_name'] ?? 'template') . '_copy';
         try {
-            $this->request('POST', '/prompts', [
+            app(PromptService::class)->savePrompt([
                 'template_name' => $newName,
                 'template_type' => $prompt['template_type'] ?? 'question_generation',
                 'template_content' => $this->fetchPromptContent($prompt['template_name'] ?? '') ?? '',
@@ -339,25 +340,22 @@ class PromptManagement extends Page
         ])['form'];
 
         try {
+            $payload = [
+                'template_name' => $this->isEditing && $this->editingName
+                    ? $this->editingName
+                    : $data['template_name'],
+                'template_content' => $data['template_content'],
+                'template_type' => $data['template_type'],
+                'variables' => $data['variables'] ?? '[]',
+                'description' => $data['description'] ?? '',
+                'tags' => $data['tags'] ?? '',
+                'is_active' => $data['is_active'] ?? 'yes',
+            ];
+
             if ($this->isEditing && $this->editingName) {
-                $payload = [
-                    'template_content' => $data['template_content'],
-                    'template_type' => $data['template_type'],
-                    'variables' => $data['variables'] ?? '[]',
-                    'description' => $data['description'] ?? '',
-                    'tags' => $data['tags'] ?? '',
-                    'is_active' => $data['is_active'] ?? 'yes',
-                ];
-                $this->request('PUT', "/prompts/{$this->editingName}", $payload);
+                app(PromptService::class)->savePrompt($payload);
             } else {
-                $this->request('POST', '/prompts', [
-                    'template_name' => $data['template_name'],
-                    'template_type' => $data['template_type'],
-                    'template_content' => $data['template_content'],
-                    'variables' => $data['variables'] ?? '[]',
-                    'description' => $data['description'] ?? '',
-                    'tags' => $data['tags'] ?? '',
-                ]);
+                app(PromptService::class)->savePrompt($payload);
             }
 
             $this->showPromptModal = false;
@@ -376,33 +374,13 @@ class PromptManagement extends Page
         }
     }
 
-    protected function request(string $method, string $path, array $payload = []): mixed
-    {
-        $baseUrl = rtrim(config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')), '/');
-        $url = $baseUrl . $path;
-
-        $response = Http::timeout(10)->send($method, $url, [
-            'json' => $payload,
-        ]);
-
-        if (!$response->successful()) {
-            throw new \Exception("API 请求失败: {$response->status()} - " . ($response->json('detail') ?? $response->body()));
-        }
-
-        return $response->json();
-    }
-
     protected function fetchPromptContent(string $templateName): ?string
     {
         if (!$templateName) {
             return null;
         }
         try {
-            $baseUrl = rtrim(config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')), '/');
-            $resp = Http::timeout(8)->get($baseUrl . "/prompts/{$templateName}");
-            if ($resp->successful()) {
-                return $resp->json('template_content');
-            }
+            return app(PromptService::class)->getPromptContent($templateName);
         } catch (\Exception $e) {
             \Log::warning('获取提示词内容失败: ' . $e->getMessage());
         }

+ 2 - 2
app/Filament/Pages/QuestionDetail.php

@@ -16,8 +16,8 @@ class QuestionDetail extends Page
     protected static ?string $title = '题目详情';
     protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
     protected static ?string $navigationLabel = '题目详情';
-    protected static string|\UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 12;
+    protected static string|\UnitEnum|null $navigationGroup = '题库管理';
+    protected static ?int $navigationSort = 4;
     protected string $view = 'filament.pages.question-detail';
 
     public ?string $questionId = null;

+ 22 - 17
app/Filament/Pages/QuestionGeneration.php

@@ -2,23 +2,21 @@
 
 namespace App\Filament\Pages;
 
-use App\Services\QuestionBankService;
 use App\Services\KnowledgeGraphService;
+use App\Services\QuestionServiceApi;
 use BackedEnum;
 use Filament\Notifications\Notification;
 use Filament\Pages\Page;
 use UnitEnum;
 use Livewire\Attributes\Computed;
-use Illuminate\Support\Facades\Session;
-use Illuminate\Support\Facades\Http;
 
 class QuestionGeneration extends Page
 {
     protected static ?string $title = '题目生成';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-sparkles';
     protected static ?string $navigationLabel = '题目生成';
-    protected static string|UnitEnum|null $navigationGroup = '资源';
-    protected static ?int $navigationSort = 21;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 9;
     protected string $view = 'filament.pages.question-generation';
 
     public ?string $generateKpCode = null;
@@ -149,12 +147,8 @@ class QuestionGeneration extends Page
         $this->currentTaskId = null;
 
         try {
-            $service = app(QuestionBankService::class);
-            $callbackUrl = route('api.questions.callback');
+            $service = app(QuestionServiceApi::class);
 
-            \Log::info("[QuestionGen] 开始异步生成,callback URL: " . $callbackUrl);
-
-            // 生成参数(交由服务发送,带超时与重试)
             $params = [
                 'kp_code' => $this->generateKpCode,
                 'skills' => $this->selectedSkills,
@@ -164,20 +158,31 @@ class QuestionGeneration extends Page
                 'prompt_template' => $this->promptTemplate ?? null
             ];
 
-            // 添加回调 URL
-            $params['callback_url'] = $callbackUrl;
-
-            // 通过服务请求,期望快速返回 task_id(超时与重试由服务配置)
-            $response = $service->generateIntelligentQuestions($params, $callbackUrl);
+            $response = $service->generateQuestions($params);
 
-            \Log::info("[QuestionGen] 生成请求已发送", [
+            \Log::info("[QuestionGen] 生成请求完成", [
                 'response' => $response,
                 'kp_code' => $this->generateKpCode,
                 'skills' => $this->selectedSkills,
                 'count' => $this->questionCount,
             ]);
 
-            // 快速跳转到题目管理页,等待回调提示
+            if (!($response['success'] ?? false)) {
+                $this->isGenerating = false;
+                Notification::make()
+                    ->title('生成失败')
+                    ->body($response['message'] ?? '生成失败')
+                    ->danger()
+                    ->send();
+                return;
+            }
+
+            Notification::make()
+                ->title('生成完成')
+                ->body('已生成题目并入库')
+                ->success()
+                ->send();
+
             $redirectUrl = "/admin/question-management";
             $this->js("window.location.href = '{$redirectUrl}';");
             return;

+ 2 - 2
app/Filament/Pages/QuestionManagement.php

@@ -19,8 +19,8 @@ class QuestionManagement extends Page
     protected static ?string $title = '题库管理';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
     protected static ?string $navigationLabel = '题库管理';
-    protected static string|UnitEnum|null $navigationGroup = '管理';
-    protected static ?int $navigationSort = 11;
+    protected static string|UnitEnum|null $navigationGroup = '题库管理';
+    protected static ?int $navigationSort = 3;
     protected string $view = 'filament.pages.question-management-simple';
 
     public ?string $search = null;

+ 12 - 97
app/Filament/Pages/QuestionReview.php

@@ -6,8 +6,6 @@ use Filament\Forms;
 use Filament\Pages\Page;
 use Filament\Actions\Action;
 use Filament\Schemas\Schema;
-use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\Log;
 use Filament\Notifications\Notification;
 
 class QuestionReview extends Page implements Forms\Contracts\HasForms
@@ -15,8 +13,9 @@ class QuestionReview extends Page implements Forms\Contracts\HasForms
     use Forms\Concerns\InteractsWithForms;
 
     protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
-    protected static string|\UnitEnum|null $navigationGroup = '题库校验';
+    protected static string|\UnitEnum|null $navigationGroup = '题库管理';
     protected static ?string $title = '题目校验';
+    protected static ?int $navigationSort = 4;
     protected string $view = 'filament.pages.question-review';
 
     public string $book = '';
@@ -147,107 +146,23 @@ class QuestionReview extends Page implements Forms\Contracts\HasForms
 
     public function loadPage(): void
     {
-        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
-        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
-
-        try {
-            // 重置,避免读取失败时残留旧数据
-            $this->mineru = [];
-            $this->builder = [];
-            $this->builderJson = '';
-            $this->pagePngBase64 = null;
-            $this->paths = [];
-
-            $resp = Http::timeout(10)->get($url);
-            if (!$resp->ok()) {
-                $this->message = "加载失败: {$resp->status()} {$resp->body()} (检查 QuestionBankService /review/{book}/{page} 是否可用,或该页是否已生成)";
-                return;
-            }
-            $data = $resp->json();
-            $this->mineru = $data['mineru'] ?? [];
-            $this->builder = $data['builder'] ?? [];
-            $this->builderJson = json_encode($this->builder, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
-            $this->pagePngBase64 = $data['page_png_base64'] ?? null;
-            $this->paths = $data['paths'] ?? [];
-            $this->message = '加载成功';
-        } catch (\Throwable $e) {
-            Log::warning('[QuestionReview] load failed: ' . $e->getMessage());
-            $this->message = '加载异常: ' . $e->getMessage();
-        }
+        $this->mineru = [];
+        $this->builder = [];
+        $this->builderJson = '';
+        $this->pagePngBase64 = null;
+        $this->paths = [];
+        $this->message = '题目校验已迁移到“题目审核工作台”,请使用新流程。';
     }
 
     public function saveDraft(): void
     {
-        $payloadBuilder = $this->builder;
-        if ($this->builderJson) {
-            try {
-                $decoded = json_decode($this->builderJson, true, 512, JSON_THROW_ON_ERROR);
-                if (is_array($decoded)) {
-                    $payloadBuilder = $decoded;
-                }
-            } catch (\Throwable $e) {
-                $this->message = '保存异常: 题目 JSON 解析失败 - ' . $e->getMessage();
-                Notification::make()->title('保存异常')->body($this->message)->danger()->send();
-                return;
-            }
-        }
-        if (empty($payloadBuilder)) {
-            $this->message = '无可保存的数据,请先加载';
-            return;
-        }
-        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
-        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
-        $payload = [
-            'status' => 'reviewed',
-            'questions' => $payloadBuilder['questions'] ?? $payloadBuilder,
-            'source_type' => 'workbook',
-        ];
-        try {
-            $this->saving = true;
-            $resp = Http::timeout(10)->post($url, $payload);
-            $this->saving = false;
-            if (!$resp->ok()) {
-                $this->message = "保存失败: {$resp->status()} {$resp->body()}";
-                Notification::make()->title('保存失败')->body($this->message)->danger()->send();
-                return;
-            }
-            $this->message = '保存成功';
-            Notification::make()->title('保存成功')->success()->send();
-        } catch (\Throwable $e) {
-            $this->saving = false;
-            Log::warning('[QuestionReview] save failed: ' . $e->getMessage());
-            $this->message = '保存异常: ' . $e->getMessage();
-            Notification::make()->title('保存异常')->body($this->message)->danger()->send();
-        }
+        $this->message = '题目校验已迁移到“题目审核工作台”,该入口不再写入数据。';
+        Notification::make()->title('已迁移')->body($this->message)->warning()->send();
     }
 
     public function saveQuestion(int $index): void
     {
-        $list = $this->builder['questions'] ?? [];
-        if (!isset($list[$index])) {
-            $this->message = '未找到该题目';
-            Notification::make()->title('保存失败')->body($this->message)->danger()->send();
-            return;
-        }
-        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
-        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
-        $payload = [
-            'status' => 'reviewed',
-            'questions' => [$list[$index]],
-            'source_type' => 'workbook',
-        ];
-        try {
-            $resp = Http::timeout(10)->post($url, $payload);
-            if (!$resp->ok()) {
-                $this->message = "保存失败: {$resp->status()} {$resp->body()}";
-                Notification::make()->title('保存失败')->body($this->message)->danger()->send();
-                return;
-            }
-            $this->message = '单题保存成功';
-            Notification::make()->title('保存成功')->success()->send();
-        } catch (\Throwable $e) {
-            $this->message = '保存异常: ' . $e->getMessage();
-            Notification::make()->title('保存异常')->body($this->message)->danger()->send();
-        }
+        $this->message = '题目校验已迁移到“题目审核工作台”,该入口不再写入数据。';
+        Notification::make()->title('已迁移')->body($this->message)->warning()->send();
     }
 }

+ 33 - 0
app/Filament/Pages/QuestionReviewWorkbench.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Models\PreQuestionCandidate;
+use App\Services\QuestionReviewService;
+use Filament\Pages\Page;
+use UnitEnum;
+
+class QuestionReviewWorkbench extends Page
+{
+    protected static bool $shouldRegisterNavigation = false;
+
+    protected static ?string $navigationLabel = '题目审核工作台';
+
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
+
+    protected static ?int $navigationSort = 3;
+
+    protected string $view = 'filament.pages.question-review-workbench';
+
+    public function approve(int $candidateId, QuestionReviewService $service): void
+    {
+        $service->promoteCandidateToQuestion($candidateId);
+    }
+
+    public function reject(int $candidateId): void
+    {
+        PreQuestionCandidate::where('id', $candidateId)->update([
+            'status' => PreQuestionCandidate::STATUS_REJECTED,
+        ]);
+    }
+}

+ 2 - 1
app/Filament/Pages/RecommendationList.php

@@ -11,6 +11,7 @@ use UnitEnum;
 
 class RecommendationList extends Page
 {
+    protected static bool $shouldRegisterNavigation = false;
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-sparkles';
     protected static ?string $title = '智能推荐';
     protected static ?string $navigationLabel = '智能推荐';
@@ -159,4 +160,4 @@ class RecommendationList extends Page
 
         return '智能推荐';
     }
-}
+}

+ 47 - 71
app/Filament/Pages/SimulatedGrading.php

@@ -20,11 +20,11 @@ class SimulatedGrading extends Page
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-check';
 
-    protected static string|UnitEnum|null $navigationGroup = '资源';
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
 
     protected static ?string $navigationLabel = '专题测试';
 
-    protected static ?int $navigationSort = 23;
+    protected static ?int $navigationSort = 5;
 
     protected ?string $heading = '专题测试';
 
@@ -717,10 +717,6 @@ class SimulatedGrading extends Page
     private function fetchMultipleQuestionsFromBank(int $count): array
     {
         try {
-            $questionBankApiBase = config('services.question_bank_api.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015'));
-
-            // 调用题库API一次性获取所有题目
-            // 如果用户选择了知识点,传入知识点参数
             $params = [
                 'limit' => $count,
                 'type' => 'factorization'
@@ -736,87 +732,67 @@ class SimulatedGrading extends Page
                 }
             }
 
-            $response = Http::timeout(10)
-                ->get($questionBankApiBase . '/questions', $params);
-
-            if ($response->successful()) {
-                $data = $response->json();
-                $questions = $data['data'] ?? [];
+            $data = app(\App\Services\QuestionServiceApi::class)->listQuestions(1, $count, $params);
+            $questions = $data['data'] ?? [];
 
-                $processedQuestions = [];
-                foreach ($questions as $question) {
-                    $kpCode = $question['kp_code'] ?? null;
-                    $knowledgePointId = $question['knowledge_point_id'] ?? null;
+            $processedQuestions = [];
+            foreach ($questions as $question) {
+                $kpCode = $question['kp_code'] ?? null;
+                $knowledgePointId = $question['knowledge_point_id'] ?? null;
 
-                    // 如果用户选择了知识点,优先使用选中的知识点
-                    if ($this->selectedKnowledgePoint) {
-                        // 查找选中的知识点的kp_code
-                        $selectedKpCode = null;
-                        foreach ($this->availableKnowledgePoints as $kp) {
-                            if ($kp['id'] === $this->selectedKnowledgePoint) {
-                                $selectedKpCode = $kp['code'];
-                                break;
-                            }
-                        }
-                        if ($selectedKpCode) {
-                            $kpCode = $selectedKpCode;
-                            $knowledgePointId = (string) $this->selectedKnowledgePoint;
-                        }
-                    } else {
-                        // 如果没有选择知识点,使用API返回的或查找
-                        if ($kpCode && !$knowledgePointId) {
-                            $knowledgePointId = $this->findKnowledgePointIdByCode($kpCode);
-                        }
-
-                        if (!$kpCode && $knowledgePointId) {
-                            $kpCode = $this->findKnowledgePointCodeById((string) $knowledgePointId);
-                        }
-
-                        if (!$kpCode) {
-                            $kpCode = $this->getDefaultKnowledgePointMeta()['code'];
+                if ($this->selectedKnowledgePoint) {
+                    $selectedKpCode = null;
+                    foreach ($this->availableKnowledgePoints as $kp) {
+                        if ($kp['id'] === $this->selectedKnowledgePoint) {
+                            $selectedKpCode = $kp['code'];
+                            break;
                         }
+                    }
+                    if ($selectedKpCode) {
+                        $kpCode = $selectedKpCode;
+                        $knowledgePointId = (string) $this->selectedKnowledgePoint;
+                    }
+                } else {
+                    if ($kpCode && !$knowledgePointId) {
+                        $knowledgePointId = $this->findKnowledgePointIdByCode($kpCode);
+                    }
 
-                        if (!$knowledgePointId && $kpCode) {
-                            $knowledgePointId = $this->findKnowledgePointIdByCode($kpCode);
-                        }
+                    if (!$kpCode && $knowledgePointId) {
+                        $kpCode = $this->findKnowledgePointCodeById((string) $knowledgePointId);
                     }
 
-                    $processedQuestions[] = [
-                        'id' => $question['question_code'] ?? 'Q_' . time() . '_' . uniqid(),
-                        'content' => $question['stem'] ?? '',
-                        'answer' => $question['solution'] ?? '',
-                        'type' => '因式分解',
-                        'difficulty' => $question['difficulty'] ?? rand(1, 5),
-                        'kp_code' => $kpCode ?? 'KP_UNKNOWN',
-                        'knowledge_point_id' => $knowledgePointId,
-                        'skill' => $question['skill'] ?? 'unknown'
-                    ];
+                    if (!$kpCode) {
+                        $kpCode = $this->getDefaultKnowledgePointMeta()['code'];
+                    }
 
-                    // 达到指定数量就停止处理
-                    if (count($processedQuestions) >= $count) {
-                        break;
+                    if (!$knowledgePointId && $kpCode) {
+                        $knowledgePointId = $this->findKnowledgePointIdByCode($kpCode);
                     }
                 }
 
-                // 如果API返回的题目不足,补充模拟题目
-                while (count($processedQuestions) < $count) {
-                    $mockQuestion = $this->generateMockQuestion();
-                    // 重新生成唯一ID
-                    $mockQuestion['id'] = 'Q_' . time() . '_' . uniqid();
-                    $processedQuestions[] = $mockQuestion;
-                }
+                $processedQuestions[] = [
+                    'id' => $question['question_code'] ?? 'Q_' . time() . '_' . uniqid(),
+                    'content' => $question['stem'] ?? '',
+                    'answer' => $question['solution'] ?? '',
+                    'type' => '因式分解',
+                    'difficulty' => $question['difficulty'] ?? rand(1, 5),
+                    'kp_code' => $kpCode ?? 'KP_UNKNOWN',
+                    'knowledge_point_id' => $knowledgePointId,
+                    'skill' => $question['skill'] ?? 'unknown'
+                ];
 
-                return $processedQuestions;
+                if (count($processedQuestions) >= $count) {
+                    break;
+                }
             }
 
-            // 如果API失败,返回模拟题目
-            $mockQuestions = [];
-            for ($i = 0; $i < $count; $i++) {
+            while (count($processedQuestions) < $count) {
                 $mockQuestion = $this->generateMockQuestion();
                 $mockQuestion['id'] = 'Q_' . time() . '_' . uniqid();
-                $mockQuestions[] = $mockQuestion;
+                $processedQuestions[] = $mockQuestion;
             }
-            return $mockQuestions;
+
+            return $processedQuestions;
 
         } catch (\Exception $e) {
             Log::warning('题库API调用失败,使用模拟题目', ['error' => $e->getMessage()]);

+ 3 - 2
app/Filament/Pages/Statistics/KnowledgePointStats.php

@@ -14,8 +14,9 @@ class KnowledgePointStats extends Page
     protected static ?string $title = '知识点题目统计';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
     protected static ?string $navigationLabel = '知识点统计';
-    protected static string|UnitEnum|null $navigationGroup = '统计报表';
-    protected static ?int $navigationSort = 21;
+    protected static string|UnitEnum|null $navigationGroup = null;
+    protected static ?int $navigationSort = 8;
+    protected static bool $shouldRegisterNavigation = false;
     protected string $view = 'filament.pages.knowledge-point-stats';
 
     public ?string $selectedKpCode = null;

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

@@ -13,7 +13,7 @@ class StudentAnalysis extends Page
     protected static ?string $title = '学生掌握度分析';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
     protected static ?string $navigationLabel = '学生分析';
-    protected static string|UnitEnum|null $navigationGroup = '资源';
+    protected static string|UnitEnum|null $navigationGroup = '其他';
     protected static ?int $navigationSort = 24;
 
     protected string $view = 'filament.pages.student-analysis-simple';

+ 2 - 2
app/Filament/Pages/StudentDashboard.php

@@ -29,11 +29,11 @@ class StudentDashboard extends Page
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
 
-    protected static string|UnitEnum|null $navigationGroup = '操作';
+    protected static string|UnitEnum|null $navigationGroup = '学生管理';
 
     protected static ?string $navigationLabel = '学生仪表板';
 
-    protected static ?int $navigationSort = 4;
+    protected static ?int $navigationSort = 2;
 
     protected ?string $heading = '学生仪表板';
 

+ 3 - 1
app/Filament/Pages/StudentKnowledgeGraphPage.php

@@ -17,10 +17,12 @@ class StudentKnowledgeGraphPage extends Page
 
     protected static ?string $navigationLabel = '学生知识图谱';
 
-    protected static UnitEnum | string | null $navigationGroup = '资源';
+    protected static UnitEnum | string | null $navigationGroup = null;
 
     protected static ?int $navigationSort = 25;
 
+    protected static bool $shouldRegisterNavigation = false;
+
     protected string $view = 'filament.pages.student-knowledge-graph-page';
 
     public bool $showNodeDetails = true;

+ 10 - 1
app/Filament/Pages/StudentManagement.php

@@ -26,7 +26,7 @@ class StudentManagement extends Page implements HasTable
     protected static ?string $title = '师生管理';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-academic-cap';
     protected static ?string $navigationLabel = '师生管理';
-    protected static string|UnitEnum|null $navigationGroup = '操作';
+    protected static string|UnitEnum|null $navigationGroup = '学生管理';
     protected static ?int $navigationSort = 1;
 
     protected string $view = 'filament.pages.student-management';
@@ -82,6 +82,15 @@ class StudentManagement extends Page implements HasTable
                     })
             )
             ->columns([
+                Tables\Columns\TextColumn::make('id')
+                    ->label('ID')
+                    ->sortable()
+                    ->badge()
+                    ->color('gray')
+                    ->copyable()
+                    ->copyMessage('ID已复制')
+                    ->copyMessageDuration(1500),
+
                 Tables\Columns\TextColumn::make('student_id')
                     ->label('学生ID')
                     ->searchable()

+ 22 - 0
app/Filament/Pages/StudentManagementQuickAccess.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use BackedEnum;
+use Filament\Pages\Page;
+use UnitEnum;
+
+class StudentManagementQuickAccess extends Page
+{
+    protected static ?string $title = '学习管理';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
+
+    protected static ?string $navigationLabel = '学习管理';
+
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+
+    protected static ?int $navigationSort = 10;
+
+    protected string $view = 'filament.pages.student-management-quick-access';
+}

+ 32 - 4
app/Filament/Pages/TextbookImport/TextbookExcelImportPage.php

@@ -152,15 +152,43 @@ class TextbookExcelImportPage extends Page
                 $sheet = $spreadsheet->getActiveSheet();
                 $data = $sheet->toArray();
 
-                // 获取第一行数据中的教材ID
-                $firstRow = $data[1] ?? [];
-                $textbookId = (int)($firstRow[0] ?? 0);
+                $header = $data[0] ?? [];
+                $textbookIdIndex = 0;
+                $seriesIdIndex = null;
+                foreach ($header as $index => $label) {
+                    $label = trim((string) $label);
+                    if ($label !== '' && str_contains($label, '教材ID')) {
+                        $textbookIdIndex = $index;
+                    }
+                    if ($label !== '' && str_contains($label, '系列ID')) {
+                        $seriesIdIndex = $index;
+                    }
+                }
+
+                $textbookId = 0;
+                $seriesId = null;
+                foreach (array_slice($data, 1) as $row) {
+                    $candidate = (int) ($row[$textbookIdIndex] ?? 0);
+                    if ($candidate > 0) {
+                        $textbookId = $candidate;
+                        break;
+                    }
+                }
+                if ($seriesIdIndex !== null) {
+                    foreach (array_slice($data, 1) as $row) {
+                        $candidate = (int) ($row[$seriesIdIndex] ?? 0);
+                        if ($candidate > 0) {
+                            $seriesId = $candidate;
+                            break;
+                        }
+                    }
+                }
 
                 if ($textbookId <= 0) {
                     throw new \Exception('Excel文件中未找到有效的教材ID,请确保第一列包含教材ID');
                 }
 
-                $result = $importer->importTextbookCatalog($temporaryPath, $textbookId);
+                $result = $importer->importTextbookCatalog($temporaryPath, $textbookId, $seriesId);
             }
 
             $this->importResult = $result;

+ 2 - 2
app/Filament/Pages/UploadExamPaper.php

@@ -20,8 +20,8 @@ class UploadExamPaper extends Page
     protected static ?string $title = '上传试卷';
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud-arrow-up';
     protected static ?string $navigationLabel = '上传试卷';
-    protected static string|UnitEnum|null $navigationGroup = '操作';
-    protected static ?int $navigationSort = 3;
+    protected static string|UnitEnum|null $navigationGroup = '卷子生成管理';
+    protected static ?int $navigationSort = 2;
     protected static ?string $slug = 'upload-exam-paper';
     protected string $view = 'filament.pages.upload-exam-paper';
 

+ 92 - 0
app/Filament/Resources/KnowledgePointResource.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\KnowledgePointResource\Pages;
+use App\Models\KnowledgePoint;
+use BackedEnum;
+use Filament\Actions\Action;
+use Filament\Actions\BulkActionGroup;
+use Filament\Actions\EditAction;
+use Filament\Schemas\Components\Section;
+use Filament\Forms\Components\TextInput;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Filters\SelectFilter;
+use UnitEnum;
+
+class KnowledgePointResource extends Resource
+{
+    protected static ?string $model = KnowledgePoint::class;
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-share';
+
+    protected static ?string $navigationLabel = '知识点';
+
+    protected static ?string $modelLabel = '知识点';
+
+    protected static ?string $pluralModelLabel = '知识点';
+
+    protected static UnitEnum|string|null $navigationGroup = null;
+
+    protected static ?int $navigationSort = 1;
+
+    protected static bool $shouldRegisterNavigation = false;
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                Section::make('基础信息')
+                    ->schema([
+                        TextInput::make('kp_code')->required()->maxLength(64),
+                        TextInput::make('name')->required()->maxLength(255),
+                        TextInput::make('subject')->maxLength(64),
+                        TextInput::make('grade')->maxLength(32),
+                        TextInput::make('parent_kp_code')->maxLength(64),
+                    ])
+                    ->columns(2),
+            ]);
+    }
+
+    public static function table(Tables\Table $table): Tables\Table
+    {
+        return $table
+            ->columns([
+                TextColumn::make('kp_code')->label('编码')->searchable(),
+                TextColumn::make('name')->label('名称')->searchable(),
+                TextColumn::make('subject')->label('学科')->sortable(),
+                TextColumn::make('grade')->label('年级')->sortable(),
+                TextColumn::make('updated_at')->label('更新')->dateTime(),
+            ])
+            ->filters([
+                SelectFilter::make('subject')
+                    ->options([
+                        'math' => '数学',
+                        'physics' => '物理',
+                    ]),
+            ])
+            ->actions([
+                Action::make('view')
+                    ->label('查看')
+                    ->icon('heroicon-o-eye')
+                    ->url(fn (KnowledgePoint $record) => static::getUrl('view', ['record' => $record])),
+                EditAction::make(),
+            ])
+            ->bulkActions([
+                BulkActionGroup::make([]),
+            ]);
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListKnowledgePoints::route('/'),
+            'create' => Pages\CreateKnowledgePoint::route('/create'),
+            'view' => Pages\ViewKnowledgePoint::route('/{record}'),
+            'edit' => Pages\EditKnowledgePoint::route('/{record}/edit'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/KnowledgePointResource/Pages/CreateKnowledgePoint.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\KnowledgePointResource\Pages;
+
+use App\Filament\Resources\KnowledgePointResource;
+use Filament\Resources\Pages\CreateRecord;
+
+class CreateKnowledgePoint extends CreateRecord
+{
+    protected static string $resource = KnowledgePointResource::class;
+}

+ 11 - 0
app/Filament/Resources/KnowledgePointResource/Pages/EditKnowledgePoint.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\KnowledgePointResource\Pages;
+
+use App\Filament\Resources\KnowledgePointResource;
+use Filament\Resources\Pages\EditRecord;
+
+class EditKnowledgePoint extends EditRecord
+{
+    protected static string $resource = KnowledgePointResource::class;
+}

+ 19 - 0
app/Filament/Resources/KnowledgePointResource/Pages/ListKnowledgePoints.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Filament\Resources\KnowledgePointResource\Pages;
+
+use App\Filament\Resources\KnowledgePointResource;
+use App\Filament\Resources\KnowledgePointResource\Widgets\KnowledgePointStats;
+use Filament\Resources\Pages\ListRecords;
+
+class ListKnowledgePoints extends ListRecords
+{
+    protected static string $resource = KnowledgePointResource::class;
+
+    protected function getHeaderWidgets(): array
+    {
+        return [
+            KnowledgePointStats::class,
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/KnowledgePointResource/Pages/ViewKnowledgePoint.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\KnowledgePointResource\Pages;
+
+use App\Filament\Resources\KnowledgePointResource;
+use Filament\Resources\Pages\ViewRecord;
+
+class ViewKnowledgePoint extends ViewRecord
+{
+    protected static string $resource = KnowledgePointResource::class;
+}

+ 17 - 0
app/Filament/Resources/KnowledgePointResource/Widgets/KnowledgePointStats.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Filament\Resources\KnowledgePointResource\Widgets;
+
+use App\Models\KnowledgePoint;
+use Filament\Widgets\StatsOverviewWidget as BaseWidget;
+use Filament\Widgets\StatsOverviewWidget\Stat;
+
+class KnowledgePointStats extends BaseWidget
+{
+    protected function getStats(): array
+    {
+        return [
+            Stat::make('知识点总数', KnowledgePoint::query()->count()),
+        ];
+    }
+}

+ 3 - 3
app/Filament/Resources/MarkdownImportResource.php

@@ -14,7 +14,7 @@ use Filament\Notifications\Notification;
 use Filament\Forms\Components\FileUpload;
 use Filament\Forms\Components\Hidden;
 use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Section;
+use Filament\Schemas\Components\Section;
 use Filament\Forms\Components\Select;
 use Filament\Forms\Components\Toggle;
 use Filament\Forms\Components\TextInput;
@@ -38,7 +38,7 @@ class MarkdownImportResource extends Resource
 {
     protected static ?string $model = MarkdownImport::class;
 
-    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-arrow-down';
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-arrow-up-tray';
 
     protected static ?string $navigationLabel = 'Markdown 导入';
 
@@ -46,7 +46,7 @@ class MarkdownImportResource extends Resource
 
     protected static ?string $pluralModelLabel = 'Markdown 导入';
 
-    protected static UnitEnum|string|null $navigationGroup = '题库管理';
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
 
     protected static ?int $navigationSort = 1;
 

+ 1 - 1
app/Filament/Resources/MenuPermissionResource.php

@@ -25,7 +25,7 @@ class MenuPermissionResource extends Resource
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
     protected static ?string $navigationLabel = '菜单权限管理';
-    protected static string|UnitEnum|null $navigationGroup = '系统管理';
+    protected static string|UnitEnum|null $navigationGroup = '其他';
     protected static ?int $navigationSort = 100;
 
     public static function table(Table $table): Table

+ 2 - 2
app/Filament/Resources/PaperPartResource.php

@@ -21,11 +21,11 @@ class PaperPartResource extends Resource
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
 
-    protected static UnitEnum|string|null $navigationGroup = '卷子管理';
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
 
     protected static ?string $navigationLabel = '题型区块';
 
-    protected static ?int $navigationSort = 3;
+    protected static ?int $navigationSort = 4;
 
     public static function canCreate(): bool
     {

+ 2 - 2
app/Filament/Resources/PreQuestionCandidateResource.php

@@ -24,7 +24,7 @@ class PreQuestionCandidateResource extends Resource
 {
     protected static ?string $model = PreQuestionCandidate::class;
 
-    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-check-circle';
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
 
     protected static ?string $navigationLabel = '题目校对';
 
@@ -32,7 +32,7 @@ class PreQuestionCandidateResource extends Resource
 
     protected static ?string $pluralModelLabel = '题目候选';
 
-    protected static UnitEnum|string|null $navigationGroup = '题库管理';
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
 
     protected static ?int $navigationSort = 2;
 

+ 93 - 0
app/Filament/Resources/QuestionAssetResource.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\QuestionAssetResource\Pages;
+use App\Models\QuestionAsset;
+use BackedEnum;
+use Filament\Actions\BulkActionGroup;
+use Filament\Actions\EditAction;
+use Filament\Actions\ViewAction;
+use Filament\Schemas\Components\Section;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TextInput;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Filters\SelectFilter;
+use UnitEnum;
+
+class QuestionAssetResource extends Resource
+{
+    protected static ?string $model = QuestionAsset::class;
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
+
+    protected static ?string $navigationLabel = '素材管理';
+
+    protected static ?string $modelLabel = '素材';
+
+    protected static ?string $pluralModelLabel = '素材';
+
+    protected static UnitEnum|string|null $navigationGroup = '题库管理';
+
+    protected static ?int $navigationSort = 5;
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                Section::make('素材信息')
+                    ->schema([
+                        TextInput::make('question_id')->numeric()->required(),
+                        Select::make('asset_type')
+                            ->options([
+                                'image' => '图片',
+                                'svg' => 'SVG',
+                                'latex' => 'LaTeX',
+                            ])
+                            ->required(),
+                        TextInput::make('path')->required()->maxLength(255),
+                    ])
+                    ->columns(2),
+            ]);
+    }
+
+    public static function table(Tables\Table $table): Tables\Table
+    {
+        return $table
+            ->columns([
+                TextColumn::make('id')->label('ID')->sortable(),
+                TextColumn::make('question_id')->label('题目ID')->sortable(),
+                TextColumn::make('asset_type')->label('类型')->sortable(),
+                TextColumn::make('path')->label('路径')->limit(60)->wrap(),
+                TextColumn::make('updated_at')->label('更新')->dateTime(),
+            ])
+            ->filters([
+                SelectFilter::make('asset_type')
+                    ->options([
+                        'image' => '图片',
+                        'svg' => 'SVG',
+                        'latex' => 'LaTeX',
+                    ]),
+            ])
+            ->actions([
+                ViewAction::make(),
+                EditAction::make(),
+            ])
+            ->bulkActions([
+                BulkActionGroup::make([]),
+            ]);
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListQuestionAssets::route('/'),
+            'create' => Pages\CreateQuestionAsset::route('/create'),
+            'view' => Pages\ViewQuestionAsset::route('/{record}'),
+            'edit' => Pages\EditQuestionAsset::route('/{record}/edit'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/QuestionAssetResource/Pages/CreateQuestionAsset.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionAssetResource\Pages;
+
+use App\Filament\Resources\QuestionAssetResource;
+use Filament\Resources\Pages\CreateRecord;
+
+class CreateQuestionAsset extends CreateRecord
+{
+    protected static string $resource = QuestionAssetResource::class;
+}

+ 11 - 0
app/Filament/Resources/QuestionAssetResource/Pages/EditQuestionAsset.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionAssetResource\Pages;
+
+use App\Filament\Resources\QuestionAssetResource;
+use Filament\Resources\Pages\EditRecord;
+
+class EditQuestionAsset extends EditRecord
+{
+    protected static string $resource = QuestionAssetResource::class;
+}

+ 19 - 0
app/Filament/Resources/QuestionAssetResource/Pages/ListQuestionAssets.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Filament\Resources\QuestionAssetResource\Pages;
+
+use App\Filament\Resources\QuestionAssetResource;
+use App\Filament\Resources\QuestionAssetResource\Widgets\QuestionAssetStats;
+use Filament\Resources\Pages\ListRecords;
+
+class ListQuestionAssets extends ListRecords
+{
+    protected static string $resource = QuestionAssetResource::class;
+
+    protected function getHeaderWidgets(): array
+    {
+        return [
+            QuestionAssetStats::class,
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/QuestionAssetResource/Pages/ViewQuestionAsset.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionAssetResource\Pages;
+
+use App\Filament\Resources\QuestionAssetResource;
+use Filament\Resources\Pages\ViewRecord;
+
+class ViewQuestionAsset extends ViewRecord
+{
+    protected static string $resource = QuestionAssetResource::class;
+}

+ 19 - 0
app/Filament/Resources/QuestionAssetResource/Widgets/QuestionAssetStats.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Filament\Resources\QuestionAssetResource\Widgets;
+
+use App\Models\QuestionAsset;
+use Filament\Widgets\StatsOverviewWidget as BaseWidget;
+use Filament\Widgets\StatsOverviewWidget\Stat;
+
+class QuestionAssetStats extends BaseWidget
+{
+    protected function getStats(): array
+    {
+        return [
+            Stat::make('素材总数', QuestionAsset::query()->count()),
+            Stat::make('SVG', QuestionAsset::query()->where('asset_type', 'svg')->count()),
+            Stat::make('图片', QuestionAsset::query()->where('asset_type', 'image')->count()),
+        ];
+    }
+}

+ 126 - 0
app/Filament/Resources/QuestionResource.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\QuestionResource\Pages;
+use App\Jobs\GenerateSolutionJob;
+use App\Jobs\GenerateSvgJob;
+use App\Jobs\MatchKnowledgeJob;
+use App\Models\Question;
+use BackedEnum;
+use Filament\Actions\BulkAction;
+use Filament\Actions\BulkActionGroup;
+use Filament\Actions\EditAction;
+use Filament\Actions\ViewAction;
+use Filament\Schemas\Components\Section;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\Textarea;
+use Filament\Forms\Components\TextInput;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Filters\SelectFilter;
+use Filament\Tables\Filters\Filter;
+use Illuminate\Database\Eloquent\Builder;
+use UnitEnum;
+
+class QuestionResource extends Resource
+{
+    protected static bool $shouldRegisterNavigation = false;
+    protected static ?string $model = Question::class;
+
+    protected static ?string $navigationLabel = '正式题库';
+
+    protected static ?string $modelLabel = '题目';
+
+    protected static ?string $pluralModelLabel = '题目';
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
+
+    protected static UnitEnum|string|null $navigationGroup = '题库管理';
+
+    protected static ?int $navigationSort = 1;
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                Section::make('题目内容')
+                    ->schema([
+                        TextInput::make('question_code')->required()->maxLength(128),
+                        Select::make('question_type')
+                            ->options([
+                                'choice' => '选择题',
+                                'fill' => '填空题',
+                                'short' => '解答题',
+                                'calc' => '计算题',
+                            ])
+                            ->required(),
+                        Textarea::make('stem')->required()->rows(6),
+                        Textarea::make('answer')->rows(3),
+                        Textarea::make('solution')->rows(6),
+                        TextInput::make('difficulty')->numeric()->minValue(1)->maxValue(5),
+                    ])
+                    ->columns(2),
+            ]);
+    }
+
+    public static function table(Tables\Table $table): Tables\Table
+    {
+        return $table
+            ->columns([
+                TextColumn::make('question_code')->label('编码')->searchable(),
+                TextColumn::make('question_type')->label('题型')->sortable(),
+                TextColumn::make('stem')->label('题干')->limit(60)->wrap()->searchable(),
+                TextColumn::make('difficulty')->label('难度')->sortable(),
+                TextColumn::make('updated_at')->label('更新')->dateTime(),
+            ])
+            ->filters([
+                SelectFilter::make('question_type')
+                    ->options([
+                        'choice' => '选择题',
+                        'fill' => '填空题',
+                        'short' => '解答题',
+                        'calc' => '计算题',
+                    ]),
+                Filter::make('difficulty_range')
+                    ->form([
+                        TextInput::make('min')->numeric(),
+                        TextInput::make('max')->numeric(),
+                    ])
+                    ->query(function (Builder $query, array $data) {
+                        if ($data['min'] !== null && $data['max'] !== null) {
+                            $query->whereBetween('difficulty', [$data['min'], $data['max']]);
+                        }
+                    }),
+            ])
+            ->actions([
+                ViewAction::make(),
+                EditAction::make(),
+            ])
+            ->bulkActions([
+                BulkActionGroup::make([
+                    BulkAction::make('queue_solution')
+                        ->label('生成解析')
+                        ->action(fn ($records) => $records->each(fn ($record) => GenerateSolutionJob::dispatch($record->id))),
+                    BulkAction::make('match_knowledge')
+                        ->label('匹配知识点')
+                        ->action(fn ($records) => $records->each(fn ($record) => MatchKnowledgeJob::dispatch($record->id))),
+                    BulkAction::make('generate_svg')
+                        ->label('生成SVG')
+                        ->action(fn ($records) => $records->each(fn ($record) => GenerateSvgJob::dispatch($record->id))),
+                ]),
+            ]);
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListQuestions::route('/'),
+            'create' => Pages\CreateQuestion::route('/create'),
+            'view' => Pages\ViewQuestion::route('/{record}'),
+            'edit' => Pages\EditQuestion::route('/{record}/edit'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/QuestionResource/Pages/CreateQuestion.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionResource\Pages;
+
+use App\Filament\Resources\QuestionResource;
+use Filament\Resources\Pages\CreateRecord;
+
+class CreateQuestion extends CreateRecord
+{
+    protected static string $resource = QuestionResource::class;
+}

+ 11 - 0
app/Filament/Resources/QuestionResource/Pages/EditQuestion.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionResource\Pages;
+
+use App\Filament\Resources\QuestionResource;
+use Filament\Resources\Pages\EditRecord;
+
+class EditQuestion extends EditRecord
+{
+    protected static string $resource = QuestionResource::class;
+}

+ 19 - 0
app/Filament/Resources/QuestionResource/Pages/ListQuestions.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Filament\Resources\QuestionResource\Pages;
+
+use App\Filament\Resources\QuestionResource;
+use App\Filament\Resources\QuestionResource\Widgets\QuestionStats;
+use Filament\Resources\Pages\ListRecords;
+
+class ListQuestions extends ListRecords
+{
+    protected static string $resource = QuestionResource::class;
+
+    protected function getHeaderWidgets(): array
+    {
+        return [
+            QuestionStats::class,
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/QuestionResource/Pages/ViewQuestion.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Filament\Resources\QuestionResource\Pages;
+
+use App\Filament\Resources\QuestionResource;
+use Filament\Resources\Pages\ViewRecord;
+
+class ViewQuestion extends ViewRecord
+{
+    protected static string $resource = QuestionResource::class;
+}

+ 19 - 0
app/Filament/Resources/QuestionResource/Widgets/QuestionStats.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Filament\Resources\QuestionResource\Widgets;
+
+use App\Models\Question;
+use Filament\Widgets\StatsOverviewWidget as BaseWidget;
+use Filament\Widgets\StatsOverviewWidget\Stat;
+
+class QuestionStats extends BaseWidget
+{
+    protected function getStats(): array
+    {
+        return [
+            Stat::make('题目总数', Question::query()->count()),
+            Stat::make('选择题', Question::query()->where('question_type', 'choice')->count()),
+            Stat::make('解答题', Question::query()->where('question_type', 'short')->count()),
+        ];
+    }
+}

+ 2 - 2
app/Filament/Resources/SourceFileResource.php

@@ -21,11 +21,11 @@ class SourceFileResource extends Resource
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
 
-    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
 
     protected static ?string $navigationLabel = '源文件';
 
-    protected static ?int $navigationSort = 1;
+    protected static ?int $navigationSort = 5;
 
     public static function canCreate(): bool
     {

+ 3 - 3
app/Filament/Resources/SourcePaperResource.php

@@ -26,11 +26,11 @@ class SourcePaperResource extends Resource
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text';
 
-    protected static UnitEnum|string|null $navigationGroup = '卷子管理';
+    protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
 
-    protected static ?string $navigationLabel = '源卷子';
+    protected static ?string $navigationLabel = '源卷子列表';
 
-    protected static ?int $navigationSort = 2;
+    protected static ?int $navigationSort = 3;
 
     public static function canCreate(): bool
     {

+ 63 - 93
app/Filament/Resources/TextbookCatalogResource.php

@@ -3,11 +3,13 @@
 namespace App\Filament\Resources;
 
 use App\Filament\Resources\TextbookCatalogResource\Pages;
+use App\Models\Textbook;
 use App\Models\TextbookCatalog;
-use App\Services\TextbookApiService;
 use BackedEnum;
 use UnitEnum;
 use Filament\Resources\Resource;
+use Filament\Forms\Components;
+use Filament\Schemas\Schema;
 use Filament\Tables;
 use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Columns\BadgeColumn;
@@ -18,7 +20,7 @@ use Illuminate\Database\Eloquent\Model;
 
 class TextbookCatalogResource extends Resource
 {
-    protected static ?string $model = ApiTextbookCatalog::class;
+    protected static ?string $model = TextbookCatalog::class;
 
     protected static ?string $recordTitleAttribute = 'title';
 
@@ -30,26 +32,62 @@ class TextbookCatalogResource extends Resource
 
     protected static ?int $navigationSort = 3;
 
-    protected static ?TextbookApiService $apiService = null;
-
-    public static function boot()
-    {
-        parent::boot();
-        static::$apiService = app(TextbookApiService::class);
-    }
-
-    protected static function getApiService(): TextbookApiService
+    public static function form(Schema $schema): Schema
     {
-        if (!static::$apiService) {
-            static::$apiService = app(TextbookApiService::class);
-        }
-        return static::$apiService;
+        return $schema->components([
+            Components\Select::make('textbook_id')
+                ->label('教材')
+                ->options(
+                    Textbook::query()
+                        ->orderBy('id')
+                        ->pluck('official_title', 'id')
+                        ->toArray()
+                )
+                ->searchable()
+                ->required(),
+            Components\TextInput::make('title')
+                ->label('目录标题')
+                ->required(),
+            Components\TextInput::make('display_no')
+                ->label('编号'),
+            Components\Select::make('node_type')
+                ->label('类型')
+                ->options([
+                    'chapter' => '章',
+                    'section' => '节',
+                    'subsection' => '小节',
+                    'item' => '条目',
+                    'project' => '项目学习',
+                    'reading' => '阅读材料',
+                    'practice' => '综合实践',
+                    'summary' => '复习',
+                    'appendix' => '附录',
+                ])
+                ->required(),
+            Components\TextInput::make('depth')
+                ->label('层级')
+                ->numeric(),
+            Components\TextInput::make('sort_order')
+                ->label('排序')
+                ->numeric(),
+            Components\TextInput::make('page_start')
+                ->label('起始页码')
+                ->numeric(),
+            Components\TextInput::make('page_end')
+                ->label('结束页码')
+                ->numeric(),
+        ])->columns(2);
     }
 
     public static function table(Tables\Table $table): Tables\Table
     {
         return $table
             ->columns([
+                TextColumn::make('textbook.series_name')
+                    ->label('教材系列')
+                    ->searchable()
+                    ->wrap(),
+
                 TextColumn::make('textbook.official_title')
                     ->label('教材')
                     ->searchable()
@@ -102,12 +140,10 @@ class TextbookCatalogResource extends Resource
                 Tables\Filters\SelectFilter::make('textbook_id')
                     ->label('教材')
                     ->options(function () {
-                        $textbooks = static::getApiService()->getTextbooks();
-                        $options = [];
-                        foreach ($textbooks['data'] ?? [] as $t) {
-                            $options[$t['id']] = $t['official_title'];
-                        }
-                        return $options;
+                        return Textbook::query()
+                            ->orderBy('id')
+                            ->pluck('official_title', 'id')
+                            ->toArray();
                     })
                     ->searchable()
                     ->preload(),
@@ -139,63 +175,18 @@ class TextbookCatalogResource extends Resource
                     ->modalHeading('删除教材目录')
                     ->modalDescription('确定要删除这个教材目录吗?此操作无法撤销。')
                     ->action(function (Model $record) {
-                        $apiService = app(\App\Services\TextbookApiService::class);
-                        $deleted = $apiService->deleteTextbookCatalog($record->id);
-
-                        if ($deleted) {
-                            // 刷新页面
-                            return redirect()->refresh();
-                        } else {
-                            // 显示错误消息
-                            \Filament\Notifications\Notification::make()
-                                ->title('错误')
-                                ->body('删除失败,请重试。')
-                                ->danger()
-                                ->send();
-                        }
+                        $record->delete();
+                        return redirect()->refresh();
                     }),
             ])
             ->paginated([10, 25, 50, 100])
             ->poll(null);  // 禁用自动刷新
     }
 
-    public static function getEloquentQuery(): \Illuminate\Database\Eloquent\builder
-    {
-        // 完全不使用数据库查询,所有数据通过 API 获取
-        // 强制使用 migrations 表,这个表肯定存在
-        return parent::getEloquentQuery()->from('migrations')->whereRaw('1=0');
-    }
-
-    public static function getRecord(?string $key): ?Model
-    {
-        // 教材目录是嵌套数据,需要根据 textbook_id 获取
-        $textbookId = request()->get('textbook_id');
-        if (!$textbookId) {
-            return null;
-        }
-
-        $catalog = static::getApiService()->getTextbookCatalog((int) $textbookId, 'flat');
-        foreach ($catalog as $node) {
-            if ($node['id'] == $key) {
-                return new ApiTextbookCatalog($node);
-            }
-        }
-        return null;
-    }
-
-    public static function getRecords(): array
+    public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
     {
-        $textbookId = request()->get('tableFilters.textbook_id.value');
-        if (!$textbookId) {
-            return [];
-        }
-
-        $catalog = static::getApiService()->getTextbookCatalog((int) $textbookId, 'flat');
-        $records = [];
-        foreach ($catalog as $node) {
-            $records[] = new ApiTextbookCatalog($node);
-        }
-        return $records;
+        return parent::getEloquentQuery()
+            ->with(['textbook', 'textbook.series']);
     }
 
     public static function getPages(): array
@@ -237,27 +228,6 @@ class TextbookCatalogResource extends Resource
 
     protected static function deleteRecord(Model $record): bool
     {
-        // 删除记录时,同时通过 API 删除题库服务中的数据
-        return static::getApiService()->deleteTextbookCatalog($record->id);
-    }
-}
-
-/**
- * API 教材目录模型 - 完全通过 API 获取数据
- * 这个类继承自 Model 但不执行任何数据库查询
- */
-class ApiTextbookCatalog extends \Illuminate\Database\Eloquent\Model
-{
-    protected $table = 'migrations';  // 使用肯定存在的表
-
-    // 禁用时间戳
-    public $timestamps = false;
-
-    // 禁用所有fillable检查
-    protected $guarded = [];
-
-    public function __construct(array $attributes = [])
-    {
-        parent::__construct($attributes);
+        return (bool) $record->delete();
     }
 }

+ 0 - 7
app/Filament/Resources/TextbookCatalogResource/Pages/ManageTextbookCatalogs.php

@@ -1,7 +1,6 @@
 <?php
 
 namespace App\Filament\Resources\TextbookCatalogResource\Pages;
-use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\TextbookCatalogResource;
 use Filament\Resources\Pages\ManageRecords;
@@ -24,10 +23,4 @@ class ManageTextbookCatalogs extends ManageRecords
         ];
     }
 
-    protected function mutateTableQueryUsing(Builder $query): Builder
-    {
-        // 由于数据在 PostgreSQL 中,这里返回空查询
-        // 实际数据通过 API 获取
-        return $query->whereRaw('1=0');
-    }
 }

+ 26 - 42
app/Filament/Resources/TextbookResource/Pages/EditTextbook.php

@@ -4,9 +4,11 @@ namespace App\Filament\Resources\TextbookResource\Pages;
 
 use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
+use App\Services\TextbookCoverStorageService;
 use App\Models\Textbook;
 use Filament\Forms;
 use Filament\Actions;
+use Filament\Schemas\Components\Section;
 use Filament\Resources\Pages\Page;
 use Illuminate\Http\Request;
 use Livewire\WithFileUploads;
@@ -108,13 +110,13 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
         return 'filament.resources.textbook-resource.edit';
     }
 
-    public function form(\Filament\Forms\Form $form): \Filament\Forms\Form
+    public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
     {
-        return $form
+        return $schema
             ->schema([
-                Forms\Components\Section::make('基本信息')
+                Section::make('基本信息')
                     ->schema([
-                        Forms\Components\Select::make('data.series_id')
+                        Forms\Components\Select::make('series_id')
                             ->label('教材系列')
                             ->options(function () {
                                 $apiService = app(TextbookApiService::class);
@@ -128,7 +130,7 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
                             ->required()
                             ->searchable(),
 
-                        Forms\Components\Select::make('data.stage')
+                        Forms\Components\Select::make('stage')
                             ->label('学段')
                             ->options([
                                 'primary' => '小学',
@@ -138,12 +140,12 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
                             ->required()
                             ->reactive(),
 
-                        Forms\Components\TextInput::make('data.grade')
+                        Forms\Components\TextInput::make('grade')
                             ->label('年级')
                             ->numeric()
                             ->helperText('例如:7表示七年级'),
 
-                        Forms\Components\Select::make('data.semester')
+                        Forms\Components\Select::make('semester')
                             ->label('学期')
                             ->options([
                                 1 => '上学期',
@@ -151,16 +153,16 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
                             ])
                             ->helperText('选择学期'),
 
-                        Forms\Components\TextInput::make('data.official_title')
+                        Forms\Components\TextInput::make('official_title')
                             ->label('教材名称')
                             ->required()
                             ->maxLength(255),
 
-                        Forms\Components\TextInput::make('data.isbn')
+                        Forms\Components\TextInput::make('isbn')
                             ->label('ISBN')
                             ->maxLength(255),
 
-                        Forms\Components\Select::make('data.status')
+                        Forms\Components\Select::make('status')
                             ->label('状态')
                             ->options([
                                 'draft' => '草稿',
@@ -170,42 +172,24 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
                             ->required(),
                     ])
                     ->columns(2),
-                Forms\Components\Section::make('版本与系列')
+                Section::make('封面上传')
                     ->schema([
-                        Forms\Components\Select::make('data.naming_scheme')
-                            ->label('命名体系')
-                            ->options([
-                                'new' => '新体系',
-                                'old' => '旧体系',
-                            ]),
-                        Forms\Components\Select::make('data.track')
-                            ->label('方向')
-                            ->options([
-                                'science' => '理科',
-                                'liberal_arts' => '文科',
-                            ]),
-                        Forms\Components\Select::make('data.module_type')
-                            ->label('模块类型')
-                            ->options([
-                                'compulsory' => '必修',
-                                'selective_compulsory' => '选择性必修',
-                                'elective' => '选修',
-                            ]),
-                        Forms\Components\TextInput::make('data.volume_no')
-                            ->label('册次')
-                            ->numeric(),
-                        Forms\Components\TextInput::make('data.legacy_code')
-                            ->label('旧版代号'),
-                        Forms\Components\TextInput::make('data.edition_label')
-                            ->label('版本标识'),
-                    ])
-                    ->columns(2),
-                Forms\Components\Section::make('封面上传')
-                    ->schema([
-                        Forms\Components\FileUpload::make('data.cover_path')
+                        Forms\Components\FileUpload::make('cover_path')
                             ->label('封面图片')
                             ->image()
                             ->directory('textbook-covers')
+                            ->saveUploadedFileUsing(function ($component, $file) {
+                                $uploader = app(TextbookCoverStorageService::class);
+                                $url = $uploader->uploadCover($file, $this->recordId ? (string) $this->recordId : null);
+                                if ($url) {
+                                    return $url;
+                                }
+                                return $file->storePubliclyAs(
+                                    $component->getDirectory(),
+                                    $component->getUploadedFileNameForStorage($file),
+                                    $component->getDiskName(),
+                                );
+                            })
                             ->helperText('建议尺寸 600x800,JPG/PNG'),
                     ])
                     ->extraAttributes(['id' => 'cover']),

+ 15 - 59
app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php

@@ -4,9 +4,10 @@ namespace App\Filament\Resources\TextbookResource\Schemas;
 
 use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
+use App\Services\TextbookCoverStorageService;
 use Filament\Forms\Components\FileUpload;
 use Filament\Forms\Components\Select;
-use Filament\Forms\Components\Section;
+use Filament\Schemas\Components\Section;
 use Filament\Forms\Components\TextInput;
 use Filament\Forms\Components\Textarea;
 use Filament\Schemas\Schema;
@@ -88,70 +89,25 @@ class TextbookFormSchema
                     ])
                     ->columns(2),
 
-                Section::make('版本与系列')
-                    ->schema([
-                        Select::make('naming_scheme')
-                            ->label('命名体系')
-                            ->options([
-                                'new' => '新体系',
-                                'old' => '旧体系',
-                            ])
-                            ->default('new')
-                            ->required(),
-                        Select::make('track')
-                            ->label('方向')
-                            ->options([
-                                'science' => '理科',
-                                'liberal_arts' => '文科',
-                            ])
-                            ->visible(fn ($get): bool => $get('stage') === 'senior'),
-                        Select::make('module_type')
-                            ->label('模块类型')
-                            ->options([
-                                'compulsory' => '必修',
-                                'selective_compulsory' => '选择性必修',
-                                'elective' => '选修',
-                            ])
-                            ->visible(fn ($get): bool => $get('stage') === 'senior'),
-                        TextInput::make('volume_no')
-                            ->label('册次')
-                            ->numeric()
-                            ->minValue(1)
-                            ->maxValue(3)
-                            ->helperText('数字1-3,例:第一册填1'),
-                        TextInput::make('legacy_code')
-                            ->label('旧版代号')
-                            ->helperText('旧教材编号,如:上册-1'),
-                        TextInput::make('curriculum_standard_year')
-                            ->label('课程标准年份')
-                            ->numeric()
-                            ->minValue(2000)
-                            ->maxValue(2099),
-                        TextInput::make('curriculum_revision_year')
-                            ->label('课程修订年份')
-                            ->numeric()
-                            ->minValue(2000)
-                            ->maxValue(2099),
-                        TextInput::make('approval_authority')
-                            ->label('审批机构')
-                            ->default('教育部'),
-                        TextInput::make('approval_year')
-                            ->label('审批年份')
-                            ->numeric()
-                            ->minValue(2000)
-                            ->maxValue(2099),
-                        TextInput::make('edition_label')
-                            ->label('版本标识')
-                            ->helperText('如:第一版、第二版'),
-                    ])
-                    ->columns(2),
 
                 Section::make('封面上传')
                     ->schema([
                         FileUpload::make('cover_path')
                             ->label('封面图片')
                             ->image()
-                            ->directory('textbook-covers'),
+                            ->directory('textbook-covers')
+                            ->saveUploadedFileUsing(function ($component, $file) {
+                                $uploader = app(TextbookCoverStorageService::class);
+                                $url = $uploader->uploadCover($file, null);
+                                if ($url) {
+                                    return $url;
+                                }
+                                return $file->storePubliclyAs(
+                                    $component->getDirectory(),
+                                    $component->getUploadedFileNameForStorage($file),
+                                    $component->getDiskName(),
+                                );
+                            }),
                     ]),
 
                 Section::make('发布与排序')

+ 25 - 81
app/Filament/Resources/TextbookResource/Tables/TextbookTable.php

@@ -11,8 +11,6 @@ use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Filters\SelectFilter;
 use Filament\Tables\Table;
 use Illuminate\Database\Eloquent\Model;
-use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
 use Filament\Tables\Enums\FiltersLayout;
 use Filament\Tables\Filters\Filter;
 use Filament\Forms\Components\TextInput;
@@ -23,85 +21,37 @@ class TextbookTable
     {
         // 直接返回配置好的表格,不使用query()
         return $table
+            ->defaultSort('id', 'asc')
             ->columns([
-                TextColumn::make('official_title')
-                    ->label('教材信息')
-                    ->html()
-                    ->formatStateUsing(function ($state, Model $record): string {
-                        $cover = $record->cover_path ?? null;
-                        $coverUrl = null;
-                        if ($cover) {
-                            $coverUrl = Str::startsWith($cover, ['http://', 'https://', '/'])
-                                ? $cover
-                                : Storage::disk('public')->url($cover);
-                        }
-
-                        $seriesName = $record->series->name ?? '未归类系列';
-                        $stage = match ($record->stage) {
-                            'primary' => '小学',
-                            'junior' => '初中',
-                            'senior' => '高中',
-                            default => $record->stage ?: '未标注',
-                        };
-                        $semester = match ($record->semester) {
-                            1 => '上学期',
-                            2 => '下学期',
-                            default => '未标注',
-                        };
-                        $naming = match ($record->naming_scheme) {
-                            'new' => '新体系',
-                            'old' => '旧体系',
-                            default => $record->naming_scheme ?: '未标注',
-                        };
-                        $status = match ($record->status) {
-                            'draft' => '草稿',
-                            'published' => '已发布',
-                            'archived' => '已归档',
-                            default => $record->status ?: '未知',
-                        };
-
-                        $badgeTone = match ($record->status) {
-                            'published' => 'text-emerald-600 bg-emerald-50 border-emerald-100',
-                            'draft' => 'text-amber-600 bg-amber-50 border-amber-100',
-                            'archived' => 'text-slate-500 bg-slate-100 border-slate-200',
-                            default => 'text-slate-500 bg-slate-100 border-slate-200',
-                        };
-
-                        $title = e($state ?: '未命名教材');
-
-                        $coverHtml = $coverUrl
-                            ? "<img src=\"{$coverUrl}\" alt=\"封面\" class=\"h-16 w-12 rounded-md border border-slate-200 object-cover\" />"
-                            : "<div class=\"flex h-16 w-12 items-center justify-center rounded-md border border-dashed border-slate-200 bg-slate-50 text-xs text-slate-400\">封面</div>";
-
-                        $gradeLabel = $record->grade ? "{$record->grade}年级" : '年级未标注';
-                        $isbnLabel = $record->isbn ?: '未填写';
-
-                        return <<<HTML
-<div class="flex items-start gap-4">
-    {$coverHtml}
-    <div class="min-w-0 flex-1">
-        <div class="flex flex-wrap items-center gap-2">
-            <div class="font-semibold text-slate-900">{$title}</div>
-            <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs {$badgeTone}">{$status}</span>
-        </div>
-        <div class="mt-1 text-xs text-slate-500">{$seriesName} · {$stage} · {$gradeLabel} · {$semester}</div>
-        <div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
-            <span class="ui-tag">体系:{$naming}</span>
-            <span class="ui-tag">ISBN:{$isbnLabel}</span>
-            <span class="ui-tag">ID:{$record->id}</span>
-        </div>
-    </div>
-</div>
-HTML;
-                    })
-                    ->wrap(),
-
+                TextColumn::make('id')->label('ID')->sortable(),
+                TextColumn::make('official_title')->label('教材名称')->searchable()->wrap(),
+                TextColumn::make('series_name')->label('教材系列')->sortable(),
+                TextColumn::make('stage')
+                    ->label('学段')
+                    ->sortable()
+                    ->formatStateUsing(fn ($state) => match ($state) {
+                        'primary' => '小学',
+                        'junior' => '初中',
+                        'senior' => '高中',
+                        default => $state ?: '未标注',
+                    }),
+                TextColumn::make('grade')->label('年级')->sortable(),
+                TextColumn::make('semester')->label('学期')->sortable(),
+                TextColumn::make('isbn')->label('ISBN')->toggleable(isToggledHiddenByDefault: true),
+                TextColumn::make('status')
+                    ->label('状态')
+                    ->sortable()
+                    ->formatStateUsing(fn ($state) => match ($state) {
+                        'draft' => '草稿',
+                        'published' => '已发布',
+                        'archived' => '已归档',
+                        default => $state ?: '未知',
+                    }),
                 TextColumn::make('created_at')
                     ->label('创建时间')
                     ->dateTime()
                     ->sortable()
                     ->toggleable(isToggledHiddenByDefault: true),
-
                 TextColumn::make('updated_at')
                     ->label('更新时间')
                     ->dateTime()
@@ -128,12 +78,6 @@ HTML;
                         2 => '下学期',
                     ]),
 
-                SelectFilter::make('naming_scheme')
-                    ->label('教材体系')
-                    ->options([
-                        'new' => '新体系',
-                        'old' => '旧体系',
-                    ]),
 
                 SelectFilter::make('status')
                     ->label('发布状态')

+ 9 - 1
app/Filament/Traits/HasUserRole.php

@@ -25,13 +25,21 @@ trait HasUserRole
     }
 
     /**
-     * 获取当前老师ID
+     * 获取当前老师ID(业务逻辑用)
      */
     public function getCurrentTeacherId(): ?string
     {
         return Auth::user()?->teacher?->teacher_id;
     }
 
+    /**
+     * 获取当前老师管理ID(管理界面用)
+     */
+    public function getCurrentTeacherManagementId(): ?int
+    {
+        return Auth::user()?->teacher?->id;
+    }
+
     /**
      * 判断当前用户是否是管理员
      */

+ 21 - 0
app/Http/Controllers/Api/AbilityEvaluateController.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class AbilityEvaluateController extends Controller
+{
+    public function __invoke(Request $request): JsonResponse
+    {
+        $payload = $request->all();
+
+        return response()->json([
+            'input' => $payload,
+            'score' => null,
+            'details' => [],
+        ]);
+    }
+}

+ 18 - 96
app/Http/Controllers/Api/IntelligentExamController.php

@@ -8,6 +8,7 @@ use App\Models\PaperQuestion;
 use App\Services\LearningAnalyticsService;
 use App\Services\ExamPdfExportService;
 use App\Services\QuestionBankService;
+use App\Services\PaperPayloadService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Http;
@@ -19,15 +20,18 @@ class IntelligentExamController extends Controller
     private LearningAnalyticsService $learningAnalyticsService;
     private QuestionBankService $questionBankService;
     private ExamPdfExportService $pdfExportService;
+    private PaperPayloadService $paperPayloadService;
 
     public function __construct(
         LearningAnalyticsService $learningAnalyticsService,
         QuestionBankService $questionBankService,
-        ExamPdfExportService $pdfExportService
+        ExamPdfExportService $pdfExportService,
+        PaperPayloadService $paperPayloadService
     ) {
         $this->learningAnalyticsService = $learningAnalyticsService;
         $this->questionBankService = $questionBankService;
         $this->pdfExportService = $pdfExportService;
+        $this->paperPayloadService = $paperPayloadService;
     }
 
     /**
@@ -126,10 +130,13 @@ class IntelligentExamController extends Controller
             $taskId = $this->createAsyncTask($paperId, $data);
 
             // 生成识别码
-            $codes = $this->generatePaperCodes($paperId);
+            $codes = $this->paperPayloadService->generatePaperCodes($paperId);
 
             // 立即返回完整的试卷数据(不等待PDF生成)
-            $examContent = $this->buildCompleteExamContent($paperId);
+            $paperModel = Paper::with('questions')->find($paperId);
+            $examContent = $paperModel
+                ? $this->paperPayloadService->buildExamContent($paperModel)
+                : [];
             $payload = [
                 'success' => true,
                 'message' => '智能试卷创建成功,PDF正在后台生成...',
@@ -271,7 +278,10 @@ class IntelligentExamController extends Controller
                 ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
 
             // 构建完整的试卷内容
-            $examContent = $this->buildCompleteExamContent($paperId);
+            $paperModel = Paper::with('questions')->find($paperId);
+            $examContent = $paperModel
+                ? $this->paperPayloadService->buildExamContent($paperModel)
+                : [];
 
             // 更新任务状态为完成
             $this->updateTaskStatus($taskId, [
@@ -552,99 +562,11 @@ class IntelligentExamController extends Controller
     private function buildCompleteExamContent(string $paperId): array
     {
         $paper = Paper::with('questions')->find($paperId);
-        $questions = $paper ? $paper->questions : collect();
-
-        // 生成13位识别码
-        $codes = $this->generatePaperCodes($paperId);
-
-        return [
-            // 试卷基本信息
-            'paper_info' => [
-                'paper_id' => $paperId,
-                'paper_name' => $paper?->paper_name ?? '',
-                'student_id' => $paper?->student_id ?? '',
-                'teacher_id' => $paper?->teacher_id ?? '',
-                'total_questions' => $questions->count(),
-                'total_score' => $paper?->total_score ?? 0,
-                'difficulty_category' => $paper?->difficulty_category ?? '基础',
-                'created_at' => $paper?->created_at?->toISOString(),
-                'updated_at' => $paper?->updated_at?->toISOString(),
-                // 识别码
-                'exam_code' => $codes['exam_code'],      // 试卷识别码 (1+12位)
-                'grading_code' => $codes['grading_code'], // 判卷识别码 (2+12位)
-                'paper_id_num' => $codes['paper_id_num'], // 12位数字ID
-            ],
-
-            // 完整题目信息
-            'questions' => $questions->map(function (PaperQuestion $q) {
-                // 构建选择题选项(如果适用)
-                $options = [];
-                if ($q->question_type === 'choice') {
-                    // 从题目文本中提取选项
-                    $questionText = $q->question_text ?? '';
-                    preg_match_all('/([A-D])\s*[\.\、\:]\s*([^A-D]+?)(?=[A-D]\s*[\.\、\:]|$)/u', $questionText, $matches, PREG_SET_ORDER);
-                    foreach ($matches as $match) {
-                        $options[] = [
-                            'label' => $match[1],
-                            'content' => trim($match[2]),
-                        ];
-                    }
-                }
+        if (!$paper) {
+            return [];
+        }
 
-                return [
-                    // 基本信息
-                    'question_number' => $q->question_number,
-                    'question_id' => $q->question_id,
-                    'question_bank_id' => $q->question_bank_id,
-                    'question_type' => $q->question_type,
-                    'knowledge_point' => $q->knowledge_point,
-                    'difficulty' => $q->difficulty,
-                    'score' => $q->score,
-                    'estimated_time' => $q->estimated_time,
-
-                    // 题目内容
-                    'stem' => $q->question_text ?? '',
-                    'options' => $options,
-
-                    // 答案和解析
-                    'correct_answer' => $q->correct_answer ?? '',
-                    'solution' => $q->solution ?? '',
-
-                    // 元数据
-                    'student_answer' => $q->student_answer,
-                    'is_correct' => $q->is_correct,
-                    'score_obtained' => $q->score_obtained,
-                    'score_ratio' => $q->score_ratio,
-                    'teacher_comment' => $q->teacher_comment,
-                    'graded_at' => $q->graded_at?->toISOString(),
-                    'graded_by' => $q->graded_by,
-
-                    // 题目属性
-                    'metadata' => [
-                        'has_solution' => !empty($q->solution),
-                        'is_choice' => $q->question_type === 'choice',
-                        'is_fill' => $q->question_type === 'fill',
-                        'is_answer' => $q->question_type === 'answer',
-                        'difficulty_label' => $this->getDifficultyLabel($q->difficulty),
-                        'question_type_label' => $this->getQuestionTypeLabel($q->question_type),
-                    ],
-                ];
-            })->toArray(),
-
-            // 统计信息
-            'statistics' => [
-                'type_distribution' => $this->getTypeDistribution($questions),
-                'difficulty_distribution' => $this->getDifficultyDistribution($questions),
-                'knowledge_point_distribution' => $this->getKnowledgePointDistribution($questions),
-                'total_score' => $questions->sum('score'),
-                'average_difficulty' => $questions->avg('difficulty'),
-                'total_estimated_time' => $questions->sum('estimated_time'),
-            ],
-
-            // 知识点和技能标签
-            'knowledge_points' => $questions->pluck('knowledge_point')->unique()->filter()->values()->toArray(),
-            'skills' => $this->extractSkillsFromQuestions($questions),
-        ];
+        return $this->paperPayloadService->buildExamContent($paper);
     }
 
     /**

+ 36 - 0
app/Http/Controllers/Api/KnowledgeRecommendController.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\KnowledgePoint;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class KnowledgeRecommendController extends Controller
+{
+    public function __invoke(Request $request): JsonResponse
+    {
+        $kpCode = $request->string('kp_code')->toString();
+        if ($kpCode !== '') {
+            $kp = KnowledgePoint::query()->where('kp_code', $kpCode)->first();
+            if ($kp) {
+                return response()->json([
+                    'kp_code' => $kp->kp_code,
+                    'related_kps' => $kp->related_kp_codes ?? [],
+                    'prerequisite_kps' => $kp->prerequisite_kp_codes ?? [],
+                    'post_kps' => $kp->dependent_kp_codes ?? [],
+                ]);
+            }
+        }
+
+        $fallback = KnowledgePoint::query()->limit(10)->get(['kp_code', 'name']);
+
+        return response()->json([
+            'kp_code' => $kpCode,
+            'related_kps' => $fallback->pluck('kp_code'),
+            'prerequisite_kps' => [],
+            'post_kps' => [],
+        ]);
+    }
+}

+ 261 - 1
app/Http/Controllers/Api/MistakeBookController.php

@@ -81,6 +81,49 @@ class MistakeBookController extends Controller
         }
     }
 
+    /**
+     * 新增错题
+     */
+    public function addMistake(Request $request): JsonResponse
+    {
+        $payload = $request->only([
+            'student_id',
+            'question_id',
+            'my_answer',
+            'correct_answer',
+            'source',
+            'happened_at',
+            'idempotency_key',
+        ]);
+
+        if (empty($payload['student_id']) || empty($payload['question_id'])) {
+            return response()->json([
+                'success' => false,
+                'message' => '缺少必要参数:student_id, question_id',
+            ], 400);
+        }
+
+        try {
+            $result = $this->mistakeBookService->createMistake($payload);
+
+            return response()->json([
+                'success' => true,
+                'data' => $result,
+                'duplicate' => $result['duplicate'] ?? false,
+            ]);
+        } catch (\Exception $e) {
+            Log::error('新增错题失败', [
+                'error' => $e->getMessage(),
+                'payload' => $payload,
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '新增错题失败: ' . $e->getMessage(),
+            ], 500);
+        }
+    }
+
     /**
      * 获取单条错题详情
      *
@@ -643,4 +686,221 @@ class MistakeBookController extends Controller
             ], 500);
         }
     }
-}
+
+    /**
+     * 批量操作错题
+     */
+    public function batchOperation(Request $request): JsonResponse
+    {
+        try {
+            $validated = $request->validate([
+                'mistake_ids' => 'required|array|min:1',
+                'mistake_ids.*' => 'string',
+                'operation' => 'required|string|in:favorite,reviewed,mastered,retry_list,remove_retry_list,set_error_type,set_importance',
+                'params' => 'array',
+            ]);
+
+            $mistakeIds = $validated['mistake_ids'];
+            $operation = $validated['operation'];
+            $params = $validated['params'] ?? [];
+
+            $result = $this->mistakeBookService->batchOperation($mistakeIds, $operation, $params);
+
+            if ($result['success'] ?? false) {
+                return response()->json([
+                    'success' => true,
+                    'data' => $result,
+                    'message' => "批量操作成功,成功处理 {$result['success_count']} 条记录",
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '批量操作失败',
+            ], 400);
+        } catch (\Throwable $e) {
+            Log::error('Batch operation error', [
+                'error' => $e->getMessage(),
+                'request' => $request->all(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '批量操作失败',
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 批量标记为已复习
+     */
+    public function batchMarkReviewed(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'reviewed'
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已标记 {$result['success_count']} 道题为已复习"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量标记为已掌握
+     */
+    public function batchMarkMastered(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'mastered'
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已标记 {$result['success_count']} 道题为已掌握"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量加入重练清单
+     */
+    public function batchAddToRetryList(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'retry_list'
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已加入 {$result['success_count']} 道题到重练清单"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量从重练清单移除
+     */
+    public function batchRemoveFromRetryList(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'remove_retry_list'
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已从重练清单移除 {$result['success_count']} 道题"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量设置错误类型
+     */
+    public function batchSetErrorType(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+            'error_type' => 'required|string|in:concept,calculation,careless,logic,other',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'set_error_type',
+            ['error_type' => $validated['error_type']]
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已为 {$result['success_count']} 道题设置错误类型"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量设置重要程度
+     */
+    public function batchSetImportance(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+            'importance' => 'required|integer|min:1|max:10',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'set_importance',
+            ['importance' => $validated['importance']]
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已为 {$result['success_count']} 道题设置重要程度"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+
+    /**
+     * 批量切换收藏状态
+     */
+    public function batchToggleFavorite(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'mistake_ids' => 'required|array|min:1',
+            'mistake_ids.*' => 'string',
+        ]);
+
+        $result = $this->mistakeBookService->batchOperation(
+            $validated['mistake_ids'],
+            'favorite'
+        );
+
+        return response()->json([
+            'success' => $result['success'] ?? false,
+            'data' => $result,
+            'message' => $result['success'] ?? false
+                ? "已为 {$result['success_count']} 道题切换收藏状态"
+                : ($result['error'] ?? '操作失败'),
+        ]);
+    }
+}

+ 35 - 0
app/Http/Controllers/Api/PaperAssembleController.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Question;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class PaperAssembleController extends Controller
+{
+    public function __invoke(Request $request): JsonResponse
+    {
+        $count = max(1, min(200, (int) $request->input('count', 10)));
+        $kpCodes = (array) $request->input('kp_codes', []);
+        $types = (array) $request->input('question_types', []);
+
+        $query = Question::query();
+
+        if (!empty($kpCodes)) {
+            $query->whereIn('kp_code', $kpCodes);
+        }
+
+        if (!empty($types)) {
+            $query->whereIn('question_type', $types);
+        }
+
+        $questions = $query->inRandomOrder()->limit($count)->get();
+
+        return response()->json([
+            'count' => $questions->count(),
+            'data' => $questions,
+        ]);
+    }
+}

+ 39 - 0
app/Http/Controllers/Api/PaperJsonController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Paper;
+use App\Services\PaperPayloadService;
+use Illuminate\Http\Request;
+
+class PaperJsonController extends Controller
+{
+    public function show(Request $request, string $paperId)
+    {
+        $paper = Paper::with('questions')->find($paperId);
+        if (!$paper) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Paper not found',
+            ], 404);
+        }
+
+        $payload = app(PaperPayloadService::class)->buildPaperApiPayload($paper);
+
+        if ($request->boolean('download')) {
+            $filename = sprintf('paper_%s.json', $paperId);
+            return response(
+                json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
+                200,
+                [
+                    'Content-Type' => 'application/json; charset=utf-8',
+                    'Content-Disposition' => 'attachment; filename="' . $filename . '"',
+                ]
+            );
+        }
+
+        return response()->json($payload);
+    }
+
+}

+ 33 - 0
app/Http/Controllers/Api/QuestionRandomController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Question;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class QuestionRandomController extends Controller
+{
+    public function __invoke(Request $request): JsonResponse
+    {
+        $limit = max(1, min(50, (int) $request->input('limit', 10)));
+
+        $query = Question::query();
+
+        if ($kpCode = $request->string('kp_code')->toString()) {
+            $query->where('kp_code', $kpCode);
+        }
+
+        if ($type = $request->string('question_type')->toString()) {
+            $query->where('question_type', $type);
+        }
+
+        $questions = $query->inRandomOrder()->limit($limit)->get();
+
+        return response()->json([
+            'count' => $questions->count(),
+            'data' => $questions,
+        ]);
+    }
+}

+ 38 - 0
app/Http/Controllers/Api/QuestionSearchController.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Question;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class QuestionSearchController extends Controller
+{
+    public function __invoke(Request $request): JsonResponse
+    {
+        $query = Question::query();
+
+        if ($search = $request->string('q')->toString()) {
+            $query->search($search);
+        }
+
+        if ($kpCode = $request->string('kp_code')->toString()) {
+            $query->where('kp_code', $kpCode);
+        }
+
+        if ($type = $request->string('question_type')->toString()) {
+            $query->where('question_type', $type);
+        }
+
+        $min = $request->float('difficulty_min');
+        $max = $request->float('difficulty_max');
+        if ($min !== null && $max !== null) {
+            $query->whereBetween('difficulty', [$min, $max]);
+        }
+
+        $perPage = max(1, min(50, (int) $request->input('per_page', 20)));
+
+        return response()->json($query->paginate($perPage));
+    }
+}

+ 21 - 0
app/Http/Controllers/Api/QuestionSolutionController.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Question;
+use Illuminate\Http\JsonResponse;
+
+class QuestionSolutionController extends Controller
+{
+    public function __invoke(int $id): JsonResponse
+    {
+        $question = Question::findOrFail($id);
+
+        return response()->json([
+            'id' => $question->id,
+            'solution' => $question->solution,
+            'answer' => $question->answer,
+        ]);
+    }
+}

+ 44 - 0
app/Jobs/GenerateSolutionJob.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\Question;
+use App\Services\AiSolutionService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class GenerateSolutionJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(public readonly int $questionId)
+    {
+    }
+
+    public function handle(AiSolutionService $service): void
+    {
+        $question = Question::find($this->questionId);
+        if (!$question) {
+            return;
+        }
+
+        $result = $service->generateSolution($question->stem ?? '', [
+            'question_type' => $question->question_type,
+            'kp_code' => $question->kp_code,
+        ]);
+
+        $question->solution = $result['solution'] ?? $question->solution;
+        if (!empty($result['steps'])) {
+            $meta = $question->meta ?? [];
+            $meta['solution_steps'] = $result['steps'];
+            $question->meta = $meta;
+        }
+        $question->save();
+    }
+}

+ 44 - 0
app/Jobs/GenerateSvgJob.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\Question;
+use App\Models\QuestionAsset;
+use App\Services\SvgConverterService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class GenerateSvgJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(public readonly int $questionId)
+    {
+    }
+
+    public function handle(SvgConverterService $service): void
+    {
+        $question = Question::find($this->questionId);
+        if (!$question) {
+            return;
+        }
+
+        $assets = $service->extractSvgAssets($question->stem ?? '');
+
+        foreach ($assets as $asset) {
+            QuestionAsset::firstOrCreate([
+                'question_id' => $question->id,
+                'asset_type' => $asset['type'] ?? 'svg',
+                'path' => $asset['path'] ?? '',
+            ], [
+                'meta' => $asset['meta'] ?? [],
+            ]);
+        }
+    }
+}

+ 53 - 0
app/Jobs/MatchKnowledgeJob.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\Question;
+use App\Models\QuestionKpRelation;
+use App\Services\AiKnowledgeService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class MatchKnowledgeJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(public readonly int $questionId)
+    {
+    }
+
+    public function handle(AiKnowledgeService $service): void
+    {
+        $question = Question::find($this->questionId);
+        if (!$question) {
+            return;
+        }
+
+        $matches = $service->matchKnowledgePoints($question->stem ?? '');
+        $hasValidKp = $service->isValidKnowledgePoint($question->kp_code);
+
+        if (empty($matches) || !$hasValidKp) {
+            $matches = $service->matchKnowledgePointsByAi($question->stem ?? '');
+        }
+
+        if (!empty($matches)) {
+            $question->kp_code = $matches[0]['kp_code'] ?? $question->kp_code;
+            $question->save();
+        }
+
+        foreach ($matches as $match) {
+            QuestionKpRelation::updateOrCreate([
+                'question_id' => $question->id,
+                'kp_code' => $match['kp_code'] ?? '',
+            ], [
+                'weight' => $match['weight'] ?? 1,
+            ]);
+        }
+    }
+}

+ 39 - 0
app/Jobs/ProcessMarkdownJob.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\MarkdownImport;
+use App\Services\QuestionImportService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class ProcessMarkdownJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(
+        public readonly int $importId,
+        public readonly int $sourceFileId
+    ) {
+    }
+
+    public function handle(QuestionImportService $service): void
+    {
+        $import = MarkdownImport::find($this->importId);
+        if (!$import) {
+            return;
+        }
+
+        $service->processMarkdownImport(
+            $this->importId,
+            $this->sourceFileId,
+            (string) $import->original_markdown
+        );
+    }
+}

+ 69 - 0
app/Jobs/ProcessPdfJob.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Domain\Import\ImportPipeline;
+use App\Models\MarkdownImport;
+use App\Models\PreQuestionCandidate;
+use App\Models\SourceFile;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Str;
+
+class ProcessPdfJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(public readonly string $path)
+    {
+    }
+
+    public function handle(ImportPipeline $pipeline): void
+    {
+        $sourceFile = SourceFile::create([
+            'uuid' => (string) Str::uuid(),
+            'original_filename' => basename($this->path),
+            'normalized_filename' => basename($this->path),
+            'extension' => 'pdf',
+            'storage_path' => $this->path,
+            'raw_markdown' => '',
+            'source_type' => 'pdf',
+            'filename' => basename($this->path),
+            'path' => $this->path,
+            'status' => 'pending',
+        ]);
+
+        $import = MarkdownImport::create([
+            'file_name' => $sourceFile->original_filename ?: $sourceFile->filename ?: 'pdf',
+            'original_markdown' => '',
+            'source_type' => 'pdf',
+            'source_name' => $this->path,
+            'status' => MarkdownImport::STATUS_PROCESSING,
+            'progress_stage' => MarkdownImport::STAGE_SPLITTING,
+        ]);
+
+        $payload = $pipeline->run('pdf', [
+            'path' => $this->path,
+        ]);
+
+        foreach ($payload['blocks'] ?? [] as $block) {
+            PreQuestionCandidate::create([
+                'import_id' => $import->id,
+                'source_file_id' => $sourceFile->id,
+                'order_index' => (int) ($block['sequence'] ?? 0),
+                'index' => (int) ($block['index'] ?? 0),
+                'raw_markdown' => (string) ($block['raw_markdown'] ?? ''),
+                'raw_text' => '',
+                'is_question_candidate' => false,
+                'status' => PreQuestionCandidate::STATUS_PENDING,
+                'meta' => $block['meta'] ?? [],
+            ]);
+        }
+    }
+}

+ 27 - 0
app/Jobs/ProcessQuestionJob.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\QuestionReviewService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class ProcessQuestionJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(public readonly int $candidateId)
+    {
+    }
+
+    public function handle(QuestionReviewService $reviewService): void
+    {
+        $reviewService->promoteCandidateToQuestion($this->candidateId);
+    }
+}

+ 6 - 15
app/Livewire/Integrations/KnowledgeGraphComponent.php

@@ -4,7 +4,6 @@ namespace App\Livewire\Integrations;
 
 use Livewire\Component;
 use App\Services\KnowledgeServiceApi;
-use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 
 class KnowledgeGraphComponent extends Component
@@ -139,21 +138,13 @@ class KnowledgeGraphComponent extends Component
     private function loadStatsData()
     {
         try {
-            $questionBankBase = config('services.question_bank.base_url', 'http://localhost:5015');
-            $response = Http::timeout(10)
-                ->get($questionBankBase . '/api/questions/statistics');
+            $stats = app(\App\Services\QuestionServiceApi::class)->getStatistics();
+            $this->statsData = $stats['by_kp'] ?? [];
 
-            if ($response->successful()) {
-                $data = $response->json();
-                $this->statsData = $data['by_kp'] ?? [];
-
-                // 为节点添加题目数量信息
-                foreach ($this->graphData['nodes'] as &$node) {
-                    $kpCode = $node['kp_code'];
-                    $stat = collect($this->statsData)->firstWhere('kp_code', $kpCode);
-                    $node['question_count'] = $stat['question_count'] ?? 0;
-                    $node['skills_list'] = $stat['skills_list'] ?? [];
-                }
+            foreach ($this->graphData['nodes'] as &$node) {
+                $kpCode = $node['kp_code'];
+                $node['question_count'] = $this->statsData[$kpCode] ?? 0;
+                $node['skills_list'] = [];
             }
         } catch (\Exception $e) {
             Log::error('获取统计数据失败', ['error' => $e->getMessage()]);

+ 2 - 8
app/Livewire/Integrations/KnowledgePointDetails.php

@@ -62,14 +62,8 @@ class KnowledgePointDetails extends Component
                 $detail['upstream_nodes'] = $upstreamResponse->successful() ? $upstreamResponse->json()['nodes'] ?? [] : [];
                 $detail['downstream_nodes'] = $downstreamResponse->successful() ? $downstreamResponse->json()['nodes'] ?? [] : [];
 
-                // 获取题目统计数据
-                $questionApiBase = config('services.question_bank_api.base_url', 'http://localhost:5015');
-                $statsResponse = Http::timeout(10)
-                    ->get($questionApiBase . "/api/questions/statistics", ['kp_code' => $kpCode]);
-
-                if ($statsResponse->successful()) {
-                    $detail['question_stats'] = $statsResponse->json();
-                }
+                $detail['question_stats'] = app(\App\Services\QuestionServiceApi::class)
+                    ->getStatistics(['kp_code' => $kpCode]);
 
                 $this->nodeDetails = $detail;
                 $this->activeTab = 'overview';

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません