yemeishu 1 週間 前
コミット
4d69692578
51 ファイル変更4237 行追加653 行削除
  1. 60 25
      app/Filament/Pages/IntelligentExamGeneration.php
  2. 1 1
      app/Filament/Pages/KnowledgeGraphManagement.php
  3. 79 0
      app/Filament/Resources/PaperPartResource.php
  4. 11 0
      app/Filament/Resources/PaperPartResource/Pages/ListPaperParts.php
  5. 11 0
      app/Filament/Resources/PaperPartResource/Pages/ViewPaperPart.php
  6. 33 0
      app/Filament/Resources/PaperPartResource/RelationManagers/PreQuestionCandidatesRelationManager.php
  7. 83 0
      app/Filament/Resources/SourceFileResource.php
  8. 11 0
      app/Filament/Resources/SourceFileResource/Pages/ListSourceFiles.php
  9. 11 0
      app/Filament/Resources/SourceFileResource/Pages/ViewSourceFile.php
  10. 31 0
      app/Filament/Resources/SourceFileResource/RelationManagers/SourcePapersRelationManager.php
  11. 84 0
      app/Filament/Resources/SourcePaperResource.php
  12. 11 0
      app/Filament/Resources/SourcePaperResource/Pages/ListSourcePapers.php
  13. 11 0
      app/Filament/Resources/SourcePaperResource/Pages/ViewSourcePaper.php
  14. 30 0
      app/Filament/Resources/SourcePaperResource/RelationManagers/PaperPartsRelationManager.php
  15. 24 2
      app/Filament/Resources/TextbookCatalogResource.php
  16. 93 253
      app/Filament/Resources/TextbookResource.php
  17. 35 0
      app/Filament/Resources/TextbookResource/Actions/DeleteTextbookAction.php
  18. 29 0
      app/Filament/Resources/TextbookResource/Pages/CreateTextbook.php
  19. 178 3
      app/Filament/Resources/TextbookResource/Pages/EditTextbook.php
  20. 36 2
      app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php
  21. 182 0
      app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php
  22. 186 0
      app/Filament/Resources/TextbookResource/Tables/TextbookTable.php
  23. 36 0
      app/Http/Controllers/Api/IntelligentExamController.php
  24. 170 0
      app/Http/Controllers/Api/KnowledgeMasteryController.php
  25. 646 0
      app/Http/Controllers/Api/MistakeBookController.php
  26. 2 2
      app/Http/Controllers/Api/TextbookApiController.php
  27. 77 0
      app/Http/Controllers/TextbookController.php
  28. 234 0
      app/Livewire/TextbookTable.php
  29. 1 1
      app/Models/ApiTextbook.php
  30. 46 0
      app/Models/PaperPart.php
  31. 46 0
      app/Models/PaperQuestionRef.php
  32. 46 0
      app/Models/SourceFile.php
  33. 57 0
      app/Models/SourcePaper.php
  34. 0 1
      app/Models/Textbook.php
  35. 29 15
      app/Services/Import/TextbookExcelImporter.php
  36. 385 0
      app/Services/KnowledgeMasteryService.php
  37. 93 30
      app/Services/LearningAnalyticsService.php
  38. 107 0
      app/Services/PaperPartExtractorService.php
  39. 60 20
      app/Services/QuestionBankService.php
  40. 51 0
      app/Services/QuestionBankSyncService.php
  41. 173 0
      app/Services/QuestionExtractorService.php
  42. 158 0
      app/Services/SourceFileParserService.php
  43. 123 0
      app/Services/SourcePaperExtractorService.php
  44. 179 0
      app/Services/Storage/ChunsunUploader.php
  45. 114 279
      app/Services/TextbookApiService.php
  46. 1 7
      resources/views/filament/pages/knowledge-graph-management.blade.php
  47. 87 0
      resources/views/filament/resources/textbook-resource/edit.blade.php
  48. 3 0
      resources/views/livewire/textbook-table.blade.php
  49. 37 6
      resources/views/pdf/exam-grading.blade.php
  50. 36 6
      resources/views/pdf/exam-paper.blade.php
  51. 10 0
      routes/web.php

+ 60 - 25
app/Filament/Pages/IntelligentExamGeneration.php

@@ -581,14 +581,21 @@ class IntelligentExamGeneration extends Page
             'difficulty_levels' => $this->selectedDifficultyLevels,
         ];
 
-            // 调用智能出卷API
-            $result = $learningAnalyticsService->generateIntelligentExam($examParams);
+        // 调用智能出卷API
+        $result = $learningAnalyticsService->generateIntelligentExam($examParams);
+
+        \Illuminate\Support\Facades\Log::info('智能出卷API返回结果', [
+            'success' => $result['success'] ?? false,
+            'message' => $result['message'] ?? '未知错误',
+            'questions_count' => count($result['questions'] ?? []),
+            'target_count' => $this->totalQuestions
+        ]);
 
-            if (!$result['success']) {
-                throw new \Exception($result['message']);
-            }
+        if (!$result['success']) {
+            throw new \Exception($result['message']);
+        }
 
-            $questions = $result['questions'];
+        $questions = $result['questions'];
 
             if (count($questions) < $this->totalQuestions) {
                 // 题库不足时,批量生成题目(使用题库的多AI模型并行生成功能)
@@ -627,7 +634,9 @@ class IntelligentExamGeneration extends Page
                     $existingIds = array_column($questions, 'id');
                     foreach ($newResponse['data'] as $newQ) {
                         // 只添加有解题思路的题目
-                        if (!in_array($newQ['id'], $existingIds) && !empty(trim($newQ['solution'] ?? ''))) {
+                        $sol = $newQ['solution'] ?? '';
+                        if (is_array($sol)) $sol = json_encode($sol, JSON_UNESCAPED_UNICODE);
+                        if (!in_array($newQ['id'], $existingIds) && !empty(trim($sol))) {
                             $questions[] = $newQ;
                         }
                     }
@@ -689,7 +698,9 @@ class IntelligentExamGeneration extends Page
                 if (!empty($newResponse['data'])) {
                     // 只保留有解题思路的题目
                     $questionsWithSolution = array_filter($newResponse['data'], function($q) {
-                        return !empty(trim($q['solution'] ?? ''));
+                        $sol = $q['solution'] ?? '';
+                        if (is_array($sol)) $sol = json_encode($sol, JSON_UNESCAPED_UNICODE);
+                        return !empty(trim($sol));
                     });
                     $questions = array_merge($questions, array_values($questionsWithSolution));
                 }
@@ -707,7 +718,12 @@ class IntelligentExamGeneration extends Page
 
             // 2. 最终过滤:确保所有题目都有解题思路
             $questions = array_filter($questions, function($q) {
-                return !empty(trim($q['solution'] ?? ''));
+                $solution = $q['solution'] ?? '';
+                // 处理 solution 可能是数组的情况
+                if (is_array($solution)) {
+                    $solution = json_encode($solution, JSON_UNESCAPED_UNICODE);
+                }
+                return !empty(trim($solution));
             });
             $questions = array_values($questions); // 重新索引数组
 
@@ -776,15 +792,14 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
                 ->send();
 
         } catch (\Exception $e) {
-            // 记录错误并提供回退的试卷 ID,防止 UI 无显示
+            // 记录错误,生成失败时不创建 paper_id,避免生成无效的 PDF 链接
             \Illuminate\Support\Facades\Log::error('生成试卷失败', ['error' => $e->getMessage()]);
-            $fallbackId = 'demo_' . $this->selectedStudentId . '_' . now()->format('YmdHis');
-            $this->generatedPaperId = $fallbackId;
+            $this->generatedPaperId = null; // 不设置 paper_id,避免显示无效链接
             $this->generatedQuestions = [];
             Notification::make()
-                ->title('试卷生成失败,使用默认试卷')
-                ->body('错误: ' . $e->getMessage() . "\n已生成默认试卷 ID: $fallbackId")
-                ->warning()
+                ->title('试卷生成失败')
+                ->body('错误: ' . $e->getMessage() . "\n\n请检查题目数量、知识点选择或网络连接后重试")
+                ->danger()
                 ->send();
         } finally {
             $this->isGenerating = false;
@@ -922,7 +937,9 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         foreach ($questions as $q) {
             $id = $q['id'] ?? $q['question_id'] ?? null;
             // 只保留有解题思路且不重复的题目
-            if ($id && !isset($uniqueQuestions[$id]) && !empty(trim($q['solution'] ?? ''))) {
+            $sol = $q['solution'] ?? '';
+            if (is_array($sol)) $sol = json_encode($sol, JSON_UNESCAPED_UNICODE);
+            if ($id && !isset($uniqueQuestions[$id]) && !empty(trim($sol))) {
                 $uniqueQuestions[$id] = $q;
             }
         }
@@ -1378,7 +1395,15 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
     {
         // 优先根据题目内容判断(而不是数据库字段)
         $stem = $question['stem'] ?? $question['content'] ?? '';
+        // 处理 stem 可能是数组的情况
+        if (is_array($stem)) {
+            $stem = json_encode($stem, JSON_UNESCAPED_UNICODE);
+        }
         $tags = $question['tags'] ?? '';
+        // 处理 tags 可能是数组的情况
+        if (is_array($tags)) {
+            $tags = json_encode($tags, JSON_UNESCAPED_UNICODE);
+        }
         $skills = $question['skills'] ?? [];
 
         // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
@@ -1403,22 +1428,26 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         }
 
         // 2. 根据技能点判断
-        if (is_array($skills)) {
-            $skillsStr = implode(',', $skills);
-            if (strpos($skillsStr, '选择题') !== false) return 'choice';
-            if (strpos($skillsStr, '填空题') !== false) return 'fill';
-            if (strpos($skillsStr, '解答题') !== false) return 'answer';
+        if (is_array($skills) && !empty($skills)) {
+            // 过滤非字符串元素,避免 implode 报错
+            $skillsFiltered = array_filter($skills, 'is_string');
+            if (!empty($skillsFiltered)) {
+                $skillsStr = implode(',', $skillsFiltered);
+                if (strpos($skillsStr, '选择题') !== false) return 'choice';
+                if (strpos($skillsStr, '填空题') !== false) return 'fill';
+                if (strpos($skillsStr, '解答题') !== false) return 'answer';
+            }
         }
 
         // 3. 根据题目已有类型字段判断(作为后备)
-        if (!empty($question['question_type'])) {
+        if (!empty($question['question_type']) && is_string($question['question_type'])) {
             $type = strtolower(trim($question['question_type']));
             if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
             if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
             if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
         }
 
-        if (!empty($question['type'])) {
+        if (!empty($question['type']) && is_string($question['type'])) {
             $type = strtolower(trim($question['type']));
             if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
             if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
@@ -1524,7 +1553,8 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         // 分配分数
         $assignedTotal = 0;
         foreach ($questions as $index => &$question) {
-            $type = $this->determineQuestionType($question);
+            // 题目类型应该在之前确定并存储,避免重复调用
+            $type = $question['question_type'] ?? $this->determineQuestionType($question);
 
             if ($type === 'choice') {
                 $question['score'] = $choiceScore;
@@ -1560,7 +1590,12 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         }
 
         // 最终验证
-        $finalTotal = array_sum(array_column($questions, 'score'));
+        $scores = array_column($questions, 'score');
+        // 确保所有分数都是数字
+        $scores = array_map(function($s) {
+            return is_numeric($s) ? (float)$s : 0;
+        }, $scores);
+        $finalTotal = array_sum($scores);
         \Illuminate\Support\Facades\Log::info("分数分配完成", [
             'target_score' => $totalScore,
             'final_total' => $finalTotal,

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

@@ -22,7 +22,7 @@ class KnowledgeGraphManagement extends Page
     protected static ?string $title = '知识图谱管理';
     protected string $view = 'filament.pages.knowledge-graph-management';
 
-    protected function getActions(): array
+    protected function getHeaderActions(): array
     {
         return [
             Action::make('create')

+ 79 - 0
app/Filament/Resources/PaperPartResource.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\PaperPartResource\Pages;
+use App\Filament\Resources\PaperPartResource\RelationManagers\PreQuestionCandidatesRelationManager;
+use App\Models\PaperPart;
+use Filament\Forms;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+use Illuminate\Database\Eloquent\Model;
+use BackedEnum;
+use UnitEnum;
+
+class PaperPartResource extends Resource
+{
+    protected static ?string $model = PaperPart::class;
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
+
+    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+
+    protected static ?string $navigationLabel = '题型区块';
+
+    protected static ?int $navigationSort = 3;
+
+    public static function canCreate(): bool
+    {
+        return false;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        return false;
+    }
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema->schema([
+            Forms\Components\TextInput::make('title')->label('标题')->disabled(),
+            Forms\Components\TextInput::make('type')->label('题型')->disabled(),
+            Forms\Components\TextInput::make('question_count')->label('题量')->disabled(),
+            Forms\Components\Textarea::make('raw_markdown')->label('区块 Markdown')->rows(12)->disabled(),
+        ]);
+    }
+
+    public static function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('paper.title')->label('卷子'),
+                Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
+                Tables\Columns\TextColumn::make('title')->label('区块标题')->searchable(),
+                Tables\Columns\TextColumn::make('type')->label('题型'),
+                Tables\Columns\TextColumn::make('question_count')->label('题量'),
+            ])
+            ->actions([
+                ViewAction::make(),
+            ]);
+    }
+
+    public static function getRelations(): array
+    {
+        return [
+            PreQuestionCandidatesRelationManager::class,
+        ];
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListPaperParts::route('/'),
+            'view' => Pages\ViewPaperPart::route('/{record}'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/PaperPartResource/Pages/ListPaperParts.php

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

+ 11 - 0
app/Filament/Resources/PaperPartResource/Pages/ViewPaperPart.php

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

+ 33 - 0
app/Filament/Resources/PaperPartResource/RelationManagers/PreQuestionCandidatesRelationManager.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Filament\Resources\PaperPartResource\RelationManagers;
+
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+
+class PreQuestionCandidatesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'candidates';
+
+    protected static ?string $recordTitleAttribute = 'question_number';
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('question_number')->label('题号')->sortable(),
+                Tables\Columns\TextColumn::make('status')->label('状态')->badge(),
+                Tables\Columns\TextColumn::make('ai_confidence')->label('AI 置信度')->formatStateUsing(
+                    fn ($state) => $state ? number_format($state * 100, 1) . '%' : '—'
+                ),
+                Tables\Columns\TextColumn::make('created_at')->label('创建时间')->dateTime(),
+            ])
+            ->actions([
+                ViewAction::make()
+                    ->url(fn ($record) => route('filament.admin.resources.pre-question-candidates.edit', $record)),
+            ])
+            ->headerActions([]);
+    }
+}

+ 83 - 0
app/Filament/Resources/SourceFileResource.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\SourceFileResource\Pages;
+use App\Filament\Resources\SourceFileResource\RelationManagers\SourcePapersRelationManager;
+use App\Models\SourceFile;
+use Filament\Forms;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+use Illuminate\Database\Eloquent\Model;
+use BackedEnum;
+use UnitEnum;
+
+class SourceFileResource extends Resource
+{
+    protected static ?string $model = SourceFile::class;
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
+
+    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+
+    protected static ?string $navigationLabel = '源文件';
+
+    protected static ?int $navigationSort = 1;
+
+    public static function canCreate(): bool
+    {
+        // 由后台脚本/服务创建,不允许手工创建
+        return false;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        return false;
+    }
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema->schema([
+            Forms\Components\TextInput::make('original_filename')->label('原始文件名')->disabled(),
+            Forms\Components\TextInput::make('normalized_filename')->label('标准化文件名')->disabled(),
+            Forms\Components\KeyValue::make('extracted_metadata')->label('文件元数据')->disabled(),
+            Forms\Components\Textarea::make('raw_markdown')
+                ->label('原始 Markdown')
+                ->rows(12)
+                ->disabled(),
+        ]);
+    }
+
+    public static function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('original_filename')->label('文件名')->searchable(),
+                Tables\Columns\TextColumn::make('extracted_metadata.grade')->label('年级')->sortable(),
+                Tables\Columns\TextColumn::make('extracted_metadata.term')->label('学期')->sortable(),
+                Tables\Columns\TextColumn::make('extracted_metadata.chapter')->label('章节'),
+                Tables\Columns\TextColumn::make('created_at')->label('导入时间')->dateTime(),
+            ])
+            ->actions([
+                ViewAction::make(),
+            ]);
+    }
+
+    public static function getRelations(): array
+    {
+        return [
+            SourcePapersRelationManager::class,
+        ];
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListSourceFiles::route('/'),
+            'view' => Pages\ViewSourceFile::route('/{record}'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/SourceFileResource/Pages/ListSourceFiles.php

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

+ 11 - 0
app/Filament/Resources/SourceFileResource/Pages/ViewSourceFile.php

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

+ 31 - 0
app/Filament/Resources/SourceFileResource/RelationManagers/SourcePapersRelationManager.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Filament\Resources\SourceFileResource\RelationManagers;
+
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+
+class SourcePapersRelationManager extends RelationManager
+{
+    protected static string $relationship = 'papers';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
+                Tables\Columns\TextColumn::make('title')->label('卷标题')->searchable(),
+                Tables\Columns\TextColumn::make('chapter')->label('章节'),
+                Tables\Columns\TextColumn::make('grade')->label('年级'),
+                Tables\Columns\TextColumn::make('term')->label('学期'),
+            ])
+            ->actions([
+                ViewAction::make(),
+            ])
+            ->headerActions([]);
+    }
+}

+ 84 - 0
app/Filament/Resources/SourcePaperResource.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\SourcePaperResource\Pages;
+use App\Filament\Resources\SourcePaperResource\RelationManagers\PaperPartsRelationManager;
+use App\Models\SourcePaper;
+use Filament\Forms;
+use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+use Illuminate\Database\Eloquent\Model;
+use BackedEnum;
+use UnitEnum;
+
+class SourcePaperResource extends Resource
+{
+    protected static ?string $model = SourcePaper::class;
+
+    protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text';
+
+    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+
+    protected static ?string $navigationLabel = '源卷子';
+
+    protected static ?int $navigationSort = 2;
+
+    public static function canCreate(): bool
+    {
+        return false;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        return false;
+    }
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema->schema([
+            Forms\Components\TextInput::make('title')->label('标题')->disabled(),
+            Forms\Components\TextInput::make('full_title')->label('完整标题')->disabled(),
+            Forms\Components\TextInput::make('chapter')->label('章节')->disabled(),
+            Forms\Components\TextInput::make('grade')->label('年级')->disabled(),
+            Forms\Components\TextInput::make('term')->label('学期')->disabled(),
+            Forms\Components\Textarea::make('raw_markdown')
+                ->label('卷子原始 Markdown')
+                ->rows(12)
+                ->disabled(),
+        ]);
+    }
+
+    public static function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
+                Tables\Columns\TextColumn::make('title')->label('卷标题')->searchable(),
+                Tables\Columns\TextColumn::make('grade')->label('年级'),
+                Tables\Columns\TextColumn::make('term')->label('学期'),
+                Tables\Columns\TextColumn::make('source_type')->label('类型'),
+            ])
+            ->actions([
+                ViewAction::make(),
+            ]);
+    }
+
+    public static function getRelations(): array
+    {
+        return [
+            PaperPartsRelationManager::class,
+        ];
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ListSourcePapers::route('/'),
+            'view' => Pages\ViewSourcePaper::route('/{record}'),
+        ];
+    }
+}

+ 11 - 0
app/Filament/Resources/SourcePaperResource/Pages/ListSourcePapers.php

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

+ 11 - 0
app/Filament/Resources/SourcePaperResource/Pages/ViewSourcePaper.php

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

+ 30 - 0
app/Filament/Resources/SourcePaperResource/RelationManagers/PaperPartsRelationManager.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Filament\Resources\SourcePaperResource\RelationManagers;
+
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Actions\ViewAction;
+
+class PaperPartsRelationManager extends RelationManager
+{
+    protected static string $relationship = 'parts';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->columns([
+                Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
+                Tables\Columns\TextColumn::make('title')->label('区块标题')->searchable(),
+                Tables\Columns\TextColumn::make('type')->label('题型'),
+                Tables\Columns\TextColumn::make('question_count')->label('题量'),
+            ])
+            ->actions([
+                ViewAction::make(),
+            ])
+            ->headerActions([]);
+    }
+}

+ 24 - 2
app/Filament/Resources/TextbookCatalogResource.php

@@ -13,6 +13,7 @@ use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Columns\BadgeColumn;
 use Filament\Actions\EditAction;
 use Filament\Actions\DeleteAction;
+use Filament\Actions\Action;
 use Illuminate\Database\Eloquent\Model;
 
 class TextbookCatalogResource extends Resource
@@ -130,8 +131,29 @@ class TextbookCatalogResource extends Resource
                 EditAction::make()
                     ->label('编辑'),
 
-                DeleteAction::make()
-                    ->label('删除'),
+                Action::make('delete')
+                    ->label('删除')
+                    ->color('danger')
+                    ->icon('heroicon-o-trash')
+                    ->requiresConfirmation()
+                    ->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();
+                        }
+                    }),
             ])
             ->paginated([10, 25, 50, 100])
             ->poll(null);  // 禁用自动刷新

+ 93 - 253
app/Filament/Resources/TextbookResource.php

@@ -3,29 +3,17 @@
 namespace App\Filament\Resources;
 
 use App\Filament\Resources\TextbookResource\Pages;
+use App\Filament\Resources\TextbookResource\Schemas\TextbookFormSchema;
+use App\Filament\Resources\TextbookResource\Tables\TextbookTable;
+use App\Filament\Resources\TextbookResource\Actions\DeleteTextbookAction;
 use App\Models\Textbook;
 use App\Services\TextbookApiService;
-use App\Services\PdfStorageService;
 use BackedEnum;
 use UnitEnum;
-use Filament\Facades\Filament;
-use Filament\Schemas\Schema;
-use Filament\Forms\Components\TextInput;
-use Filament\Forms\Components\Select;
-use Filament\Forms\Components\Toggle;
-use Filament\Forms\Components\Textarea;
-use Filament\Forms\Components\FileUpload;
 use Filament\Resources\Resource;
+use Filament\Schemas\Schema;
 use Filament\Tables;
-use Filament\Tables\Columns\TextColumn;
-use Filament\Tables\Columns\BadgeColumn;
-use Filament\Tables\Columns\ToggleColumn;
-use Filament\Tables\Columns\ImageColumn;
-use Filament\Actions\EditAction;
-use Filament\Actions\DeleteAction;
-use Filament\Actions\Action;
 use Illuminate\Database\Eloquent\Model;
-use Illuminate\Database\Eloquent\Collection;
 
 class TextbookResource extends Resource
 {
@@ -59,297 +47,128 @@ class TextbookResource extends Resource
 
     public static function form(Schema $schema): Schema
     {
-        return $schema
-            ->schema([
-                Select::make('series_id')
-                    ->label('教材系列')
-                    ->options(function () {
-                        $series = static::getApiService()->getTextbookSeries();
-                        $options = [];
-                        foreach ($series['data'] as $s) {
-                            $displayName = $s['name'];
-                            if (!empty($s['publisher'])) {
-                                $displayName .= ' (' . $s['publisher'] . ')';
-                            }
-                            $options[$s['id']] = $displayName;
-                        }
-                        return $options;
-                    })
-                    ->required()
-                    ->searchable()
-                    ->preload(),
-
-                Select::make('stage')
-                    ->label('学段')
-                    ->options([
-                        'primary' => '小学',
-                        'junior' => '初中',
-                        'senior' => '高中',
-                    ])
-                    ->default('junior')
-                    ->required()
-                    ->reactive(),
-
-                Select::make('schooling_system')
-                    ->label('学制')
-                    ->options([
-                        '63' => '六三学制',
-                        '54' => '五四学制',
-                    ])
-                    ->default('63')
-                    ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
-
-                TextInput::make('grade')
-                    ->label('年级')
-                    ->numeric()
-                    ->helperText('小学1-6、初中7-9、高中10-12'),
-
-                Select::make('semester')
-                    ->label('学期')
-                    ->options([
-                        1 => '上册',
-                        2 => '下册',
-                    ])
-                    ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
-
-                Select::make('naming_scheme')
-                    ->label('命名体系')
-                    ->options([
-                        'new' => '新体系',
-                        'old' => '旧体系',
-                    ])
-                    ->visible(fn ($get): bool => $get('stage') === 'senior')
-                    ->reactive(),
-
-                Select::make('track')
-                    ->label('版本')
-                    ->options([
-                        'A' => 'A版',
-                        'B' => 'B版',
-                    ])
-                    ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'new'),
-
-                Select::make('module_type')
-                    ->label('模块类型')
-                    ->options([
-                        'compulsory' => '必修',
-                        'selective_compulsory' => '选择性必修',
-                        'elective' => '选修',
-                    ])
-                    ->visible(fn ($get): bool => $get('stage') === 'senior'),
-
-                TextInput::make('volume_no')
-                    ->label('册次')
-                    ->numeric()
-                    ->helperText('如:1、2、3')
-                    ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'new'),
-
-                TextInput::make('legacy_code')
-                    ->label('旧体系编码')
-                    ->placeholder('如:必修1、选修1-1')
-                    ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'old'),
-
-                TextInput::make('curriculum_standard_year')
-                    ->label('课标年代')
-                    ->numeric()
-                    ->helperText('义务教育:2011/2022,高中:2017'),
-
-                TextInput::make('curriculum_revision_year')
-                    ->label('修订年份')
-                    ->numeric()
-                    ->helperText('高中:2020'),
-
-                TextInput::make('approval_year')
-                    ->label('审定年份')
-                    ->numeric()
-                    ->helperText('如:2024'),
-
-                TextInput::make('edition_label')
-                    ->label('版次标识')
-                    ->placeholder('如:2024秋版、修订版'),
-
-                TextInput::make('isbn')
-                    ->label('ISBN')
-                    ->maxLength(32),
-
-                FileUpload::make('cover_path')
-                    ->label('封面图片')
-                    ->image()
-                    ->imageEditor()
-                    ->imageResizeTargetWidth(400)
-                    ->imageResizeTargetHeight(600)
-                    ->imageCropAspectRatio('2:3')
-                    ->imagePreviewHeight('200')
-                    ->directory('textbook-covers')
-                    ->maxSize(5120) // 5MB
-                    ->hint('支持 JPG、PNG、WebP 格式,最大 5MB')
-                    ->preserveFilenames()
-                    ->multiple(false)
-                    ->downloadable(false)
-                    ->afterStateHydrated(function ($component, $state) {
-                        // 确保状态是字符串或 null,而不是数组
-                        if (is_array($state)) {
-                            $component->state(!empty($state) ? $state[0] : null);
-                        }
-                    }),
-
-                TextInput::make('official_title')
-                    ->label('官方书名')
-                    ->maxLength(512)
-                    ->helperText('自动生成,可手动覆盖'),
-
-                TextInput::make('display_title')
-                    ->label('展示名称')
-                    ->maxLength(512)
-                    ->helperText('站内显示名称'),
-
-                Textarea::make('aliases')
-                    ->label('别名')
-                    ->helperText('JSON 格式,如:["别名1", "别名2"]')
-                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
-                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
-                    ->columnSpanFull(),
-
-                Select::make('status')
-                    ->label('状态')
-                    ->options([
-                        'draft' => '草稿',
-                        'published' => '已发布',
-                        'archived' => '已归档',
-                    ])
-                    ->default('draft')
-                    ->required(),
-
-                Textarea::make('meta')
-                    ->label('扩展信息')
-                    ->placeholder('JSON 格式')
-                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
-                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
-                    ->columnSpanFull(),
-            ]);
+        return TextbookFormSchema::make($schema);
     }
 
     public static function table(Tables\Table $table): Tables\Table
     {
         return $table
             ->columns([
-                ImageColumn::make('cover_path')
-                    ->label('封面')
-                    ->square()
-                    ->defaultImageUrl(url('/images/no-image.png'))
-                    ->disk('public')
-                    ->size(60),
-
-                TextColumn::make('series.name')
+                \Filament\Tables\Columns\TextColumn::make('id')
+                    ->label('ID')
+                    ->sortable(),
+
+                \Filament\Tables\Columns\TextColumn::make('series.name')
                     ->label('系列')
+                    ->searchable(),
+
+                \Filament\Tables\Columns\TextColumn::make('official_title')
+                    ->label('官方书名')
                     ->searchable()
-                    ->sortable(),
+                    ->wrap(),
 
-                BadgeColumn::make('stage')
+                \Filament\Tables\Columns\TextColumn::make('stage')
                     ->label('学段')
                     ->formatStateUsing(function ($state): string {
-                        // 确保返回字符串,即使输入是数组
-                        $state = is_array($state) ? ($state[0] ?? '') : $state;
-                        return match ((string) $state) {
+                        return match ($state) {
                             'primary' => '小学',
                             'junior' => '初中',
                             'senior' => '高中',
-                            default => (string) $state,
+                            default => $state,
                         };
                     })
-                    ->color('success'),
+                    ->badge()
+                    ->color('info'),
 
-                TextColumn::make('grade')
+                \Filament\Tables\Columns\TextColumn::make('grade')
                     ->label('年级')
-                    ->sortable(),
+                    ->formatStateUsing(function ($state): string {
+                        return $state ? "{$state}年级" : '-';
+                    }),
 
-                TextColumn::make('semester')
+                \Filament\Tables\Columns\TextColumn::make('semester')
                     ->label('学期')
                     ->formatStateUsing(function ($state): string {
-                        // 确保返回字符串,即使输入是数组
-                        $state = is_array($state) ? ($state[0] ?? null) : $state;
-                        return match ((int) $state) {
-                            1 => '上册',
-                            2 => '下册',
-                            default => '',
+                        return match ($state) {
+                            1 => '上学期',
+                            2 => '下学期',
+                            default => '-',
                         };
-                    }),
+                    })
+                    ->badge()
+                    ->color('success'),
 
-                TextColumn::make('official_title')
-                    ->label('官方书名')
-                    ->searchable()
-                    ->wrap(),
+                \Filament\Tables\Columns\TextColumn::make('naming_scheme')
+                    ->label('体系')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'new' => '新体系',
+                            'old' => '旧体系',
+                            default => $state,
+                        };
+                    })
+                    ->badge(),
 
-                BadgeColumn::make('status')
+                \Filament\Tables\Columns\TextColumn::make('status')
                     ->label('状态')
                     ->formatStateUsing(function ($state): string {
-                        // 确保返回字符串,即使输入是数组
-                        $state = is_array($state) ? ($state[0] ?? '') : $state;
-                        return match ((string) $state) {
+                        return match ($state) {
                             'draft' => '草稿',
                             'published' => '已发布',
                             'archived' => '已归档',
-                            default => (string) $state,
+                            default => $state,
                         };
                     })
+                    ->badge()
                     ->color(function ($state): string {
-                        // 确保返回字符串,即使输入是数组
-                        $state = is_array($state) ? ($state[0] ?? '') : $state;
-                        return match ((string) $state) {
-                            'draft' => 'warning',
+                        return match ($state) {
+                            'draft' => 'gray',
                             'published' => 'success',
-                            'archived' => 'gray',
+                            'archived' => 'danger',
                             default => 'gray',
                         };
                     }),
 
-                TextColumn::make('approval_year')
-                    ->label('审定年份')
-                    ->sortable(),
-
-                TextColumn::make('created_at')
+                \Filament\Tables\Columns\TextColumn::make('created_at')
                     ->label('创建时间')
-                    ->dateTime('Y-m-d H:i')
+                    ->dateTime()
+                    ->sortable()
+                    ->toggleable(isToggledHiddenByDefault: true),
+
+                \Filament\Tables\Columns\TextColumn::make('updated_at')
+                    ->label('更新时间')
+                    ->dateTime()
                     ->sortable()
-                    ->toggleable(),
+                    ->toggleable(isToggledHiddenByDefault: true),
             ])
             ->filters([
-                Tables\Filters\SelectFilter::make('stage')
+                \Filament\Tables\Filters\SelectFilter::make('stage')
                     ->label('学段')
                     ->options([
                         'primary' => '小学',
                         'junior' => '初中',
                         'senior' => '高中',
-                    ])
-                    ->query(function ($query, $data) {
-                        if ($data['value']) {
-                            // API 过滤
-                            return $query;
-                        }
-                    }),
+                    ]),
 
-                Tables\Filters\SelectFilter::make('status')
+                \Filament\Tables\Filters\SelectFilter::make('status')
                     ->label('状态')
                     ->options([
                         'draft' => '草稿',
                         'published' => '已发布',
                         'archived' => '已归档',
-                    ])
-                    ->query(function ($query, $data) {
-                        if ($data['value']) {
-                            // API 过滤
-                            return $query;
-                        }
-                    }),
+                    ]),
             ])
             ->actions([
-                EditAction::make()
-                    ->label('编辑'),
+                \Filament\Actions\EditAction::make()
+                    ->label('编辑')
+                    ->url(fn($record): string =>
+                        route('filament.admin.resources.textbooks.edit', ['record' => $record->id])
+                    ),
 
-                DeleteAction::make()
+                DeleteTextbookAction::make()
                     ->label('删除'),
 
-                Action::make('view_catalog')
+                \Filament\Actions\Action::make('view_catalog')
                     ->label('查看目录')
                     ->icon('heroicon-o-list-bullet')
                     ->url(fn(Model $record): string =>
@@ -362,9 +181,8 @@ class TextbookResource extends Resource
                         ->label('批量删除'),
                 ]),
             ])
-            ->defaultSort('id', 'desc')
-            ->paginated([10, 25, 50, 100])
-            ->poll('30s');
+            ->defaultSort('id')
+            ->paginated([10, 25, 50, 100]);
     }
 
     public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
@@ -373,6 +191,28 @@ class TextbookResource extends Resource
         return parent::getEloquentQuery()->whereRaw('1=0');
     }
 
+    public static function getRecords(): array
+    {
+        // 从 API 获取教材数据
+        $apiService = static::getApiService();
+        $result = $apiService->getTextbooks();
+
+        \Log::info('TextbookResource::getRecords called', [
+            'count' => count($result['data'] ?? []),
+            'has_data' => !empty($result['data'])
+        ]);
+
+        $records = [];
+        foreach ($result['data'] ?? [] as $item) {
+            $model = new \App\Models\Textbook($item);
+            $model->exists = true;
+            $model->id = $item['id'];
+            $records[] = $model;
+        }
+
+        return $records;
+    }
+
     public static function resolveRecordRouteBinding(int | string $key, ?\Closure $modifyQuery = null): ?\Illuminate\Database\Eloquent\Model
     {
         $record = static::getApiService()->getTextbook((int) $key);

+ 35 - 0
app/Filament/Resources/TextbookResource/Actions/DeleteTextbookAction.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Actions;
+
+use App\Services\TextbookApiService;
+use Filament\Actions\Action;
+use Filament\Notifications\Notification;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redirect;
+
+class DeleteTextbookAction extends Action
+{
+    public static function getDefaultName(): ?string
+    {
+        return 'delete_textbook';
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this
+            ->label('删除')
+            ->color('danger')
+            ->icon('heroicon-o-trash')
+            ->requiresConfirmation()
+            ->modalHeading('删除教材')
+            ->modalDescription('确定要删除这个教材吗?此操作无法撤销。')
+            ->url(function (Model $record) {
+                // 通过URL重定向传递ID,完全绕过Action的$record传递问题
+                return route('filament.admin.resources.textbooks.delete', $record->id);
+            });
+    }
+}

+ 29 - 0
app/Filament/Resources/TextbookResource/Pages/CreateTextbook.php

@@ -4,8 +4,37 @@ namespace App\Filament\Resources\TextbookResource\Pages;
 
 use App\Filament\Resources\TextbookResource;
 use Filament\Resources\Pages\CreateRecord;
+use Illuminate\Database\Eloquent\Model;
 
 class CreateTextbook extends CreateRecord
 {
     protected static string $resource = TextbookResource::class;
+
+    /**
+     * 禁用面包屑,避免重复导航
+     */
+    public function getBreadcrumbs(): array
+    {
+        return [];
+    }
+
+    /**
+     * 处理记录创建
+     */
+    protected function handleRecordCreation(array $data): Model
+    {
+        // 获取TextbookResource实例
+        $resource = static::getResource();
+
+        // 调用API创建
+        $apiService = $resource::getApiService();
+        $createdData = $apiService->createTextbook($data);
+
+        // 创建模型实例
+        $record = new static::$model($createdData);
+        $record->exists = true;
+        $record->id = $createdData['id'] ?? null;
+
+        return $record;
+    }
 }

+ 178 - 3
app/Filament/Resources/TextbookResource/Pages/EditTextbook.php

@@ -3,11 +3,186 @@
 namespace App\Filament\Resources\TextbookResource\Pages;
 
 use App\Filament\Resources\TextbookResource;
-use Filament\Resources\Pages\EditRecord;
+use App\Services\TextbookApiService;
+use App\Models\Textbook;
+use Filament\Actions;
+use Filament\Resources\Pages\Page;
+use Illuminate\Http\Request;
 
-class EditTextbook extends EditRecord
+class EditTextbook extends Page
 {
     protected static string $resource = TextbookResource::class;
 
-    protected string $view = 'filament.resources.textbook-resource.edit-record';
+    public array $data = [];
+
+    public ?int $recordId = null;
+
+    public ?\Filament\Forms\Form $form = null;
+
+    public function mount(Request $request): void
+    {
+        // 从路由参数获取教材ID,避免Livewire的隐式绑定
+        $this->recordId = (int) $request->route('record');
+
+        if (!$this->recordId) {
+            abort(404);
+        }
+
+        // 从API获取教材数据
+        $apiService = app(TextbookApiService::class);
+        $textbookData = $apiService->getTextbook($this->recordId);
+
+        if (!$textbookData) {
+            abort(404);
+        }
+
+        // 初始化表单数据
+        $this->data = $textbookData;
+    }
+
+    public function save(): void
+    {
+        // 验证数据
+        $this->validate([
+            'data.series_id' => 'required|integer',
+            'data.stage' => 'required|string',
+            'data.grade' => 'nullable|integer',
+            'data.semester' => 'nullable|integer',
+            'data.official_title' => 'required|string|max:255',
+            'data.isbn' => 'nullable|string|max:255',
+            'data.status' => 'required|string|in:draft,published,archived',
+        ]);
+
+        // 只传递标量字段到API,跳过关系字段和嵌套对象
+        $allowedFields = [
+            'series_id', 'stage', 'grade', 'semester', 'naming_scheme',
+            'track', 'module_type', 'volume_no', 'legacy_code',
+            'curriculum_standard_year', 'curriculum_revision_year',
+            'approval_authority', 'approval_year', 'edition_label',
+            'official_title', 'aliases', 'isbn',
+            'cover_path', 'status', 'sort_order', 'meta'
+        ];
+
+        $updateData = [];
+        foreach ($allowedFields as $field) {
+            if (isset($this->data[$field]) && !is_array($this->data[$field]) && !is_object($this->data[$field])) {
+                $updateData[$field] = $this->data[$field];
+            }
+        }
+
+        // 调用API更新
+        $apiService = app(TextbookApiService::class);
+        $updatedData = $apiService->updateTextbook($this->recordId, $updateData);
+
+        // 更新本地数据
+        $this->data = array_merge($this->data, $updatedData);
+
+        // 显示成功消息
+        \Filament\Notifications\Notification::make()
+            ->title('教材更新成功')
+            ->success()
+            ->send();
+    }
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Actions\Action::make('back')
+                ->label('返回列表')
+                ->url(static::$resource::getUrl('index'))
+                ->color('gray'),
+        ];
+    }
+
+    public function getTitle(): string
+    {
+        return '编辑教材';
+    }
+
+    public function getView(): string
+    {
+        return 'filament.resources.textbook-resource.edit';
+    }
+
+    public function form(\Filament\Forms\Form $form): \Filament\Forms\Form
+    {
+        return $form
+            ->schema([
+                \Filament\Forms\Components\Section::make('基本信息')
+                    ->schema([
+                        \Filament\Forms\Components\Select::make('data.series_id')
+                            ->label('教材系列')
+                            ->options(function () {
+                                $apiService = app(TextbookApiService::class);
+                                $series = $apiService->getTextbookSeries();
+                                $options = [];
+                                foreach ($series['data'] as $s) {
+                                    $options[$s['id']] = $s['name'];
+                                }
+                                return $options;
+                            })
+                            ->required()
+                            ->searchable(),
+
+                        \Filament\Forms\Components\Select::make('data.stage')
+                            ->label('学段')
+                            ->options([
+                                'primary' => '小学',
+                                'junior' => '初中',
+                                'senior' => '高中',
+                            ])
+                            ->required()
+                            ->reactive(),
+
+                        \Filament\Forms\Components\TextInput::make('data.grade')
+                            ->label('年级')
+                            ->numeric()
+                            ->helperText('例如:7表示七年级'),
+
+                        \Filament\Forms\Components\Select::make('data.semester')
+                            ->label('学期')
+                            ->options([
+                                1 => '上学期',
+                                2 => '下学期',
+                            ])
+                            ->helperText('选择学期'),
+
+                        \Filament\Forms\Components\TextInput::make('data.official_title')
+                            ->label('教材名称')
+                            ->required()
+                            ->maxLength(255),
+
+                        \Filament\Forms\Components\TextInput::make('data.isbn')
+                            ->label('ISBN')
+                            ->maxLength(255),
+
+                        \Filament\Forms\Components\Select::make('data.status')
+                            ->label('状态')
+                            ->options([
+                                'draft' => '草稿',
+                                'published' => '已发布',
+                                'archived' => '已归档',
+                            ])
+                            ->required(),
+                    ])
+                    ->columns(2),
+            ])
+            ->statePath('data');
+    }
+
+    /**
+     * 获取表单实例供视图使用
+     */
+    public function getFormProperty(): string
+    {
+        // 直接渲染表单HTML
+        ob_start();
+        try {
+            $form = $this->form();
+            $form->render();
+        } catch (\Exception $e) {
+            // 忽略表单渲染错误
+        }
+        return ob_get_clean();
+    }
 }

+ 36 - 2
app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php

@@ -21,19 +21,53 @@ class ManageTextbooks extends ListRecords
         ];
     }
 
-    protected function paginateTableQuery(\Illuminate\Database\Eloquent\Builder $query): Paginator
+    protected function paginateTableQuery(\Illuminate\Database\Eloquent\builder $query): Paginator
+    {
+        return $this->getTableRecords();
+    }
+
+    public function getTableRecords(): Paginator
     {
         $apiService = app(TextbookApiService::class);
         $page = request()->get('page', 1);
         $perPage = $this->getTableRecordsPerPage();
 
+        \Log::info('ManageTextbooks::getTableRecords called', [
+            'page' => $page,
+            'perPage' => $perPage
+        ]);
+
         $result = $apiService->getTextbooks([
             'page' => $page,
             'per_page' => $perPage,
         ]);
 
+        \Log::info('API result', [
+            'total' => $result['meta']['total'] ?? 0,
+            'count' => count($result['data'] ?? [])
+        ]);
+
         $records = collect($result['data'] ?? [])->map(function ($item) {
-            return new \App\Models\Textbook($item);
+            $model = new \App\Models\Textbook();
+            // 直接设置属性
+            foreach ($item as $key => $value) {
+                $model->setAttribute($key, $value);
+            }
+            $model->exists = true;
+            // 确保ID被正确设置
+            if (isset($item['id'])) {
+                $model->id = $item['id'];
+            }
+            // 设置关联
+            if (isset($item['series'])) {
+                $seriesModel = new \App\Models\TextbookSeries();
+                foreach ($item['series'] as $key => $value) {
+                    $seriesModel->setAttribute($key, $value);
+                }
+                $seriesModel->exists = true;
+                $model->setRelation('series', $seriesModel);
+            }
+            return $model;
         });
 
         return new LengthAwarePaginator(

+ 182 - 0
app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php

@@ -0,0 +1,182 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Schemas;
+
+use App\Filament\Resources\TextbookResource;
+use App\Services\TextbookApiService;
+use Filament\Forms\Components\FileUpload;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TextInput;
+use Filament\Forms\Components\Textarea;
+use Filament\Forms\Components\Toggle;
+use Filament\Schemas\Schema;
+
+class TextbookFormSchema
+{
+    public static function make(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                Select::make('series_id')
+                    ->label('教材系列')
+                    ->options(function () {
+                        $series = app(TextbookApiService::class)->getTextbookSeries();
+                        $options = [];
+                        foreach ($series['data'] as $s) {
+                            $displayName = $s['name'];
+                            if (!empty($s['publisher'])) {
+                                $displayName .= ' (' . $s['publisher'] . ')';
+                            }
+                            $options[$s['id']] = $displayName;
+                        }
+                        return $options;
+                    })
+                    ->required()
+                    ->searchable()
+                    ->preload(),
+
+                Select::make('stage')
+                    ->label('学段')
+                    ->options([
+                        'primary' => '小学',
+                        'junior' => '初中',
+                        'senior' => '高中',
+                    ])
+                    ->default('junior')
+                    ->required()
+                    ->reactive(),
+
+                Select::make('schooling_system')
+                    ->label('学制')
+                    ->options([
+                        '63' => '六三学制',
+                        '54' => '五四学制',
+                    ])
+                    ->default('63')
+                    ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
+
+                TextInput::make('grade')
+                    ->label('年级')
+                    ->numeric()
+                    ->minValue(1)
+                    ->maxValue(12)
+                    ->helperText('数字1-12,例:1年级填1'),
+
+                Select::make('semester')
+                    ->label('学期')
+                    ->options([
+                        1 => '上学期',
+                        2 => '下学期',
+                    ])
+                    ->required(),
+
+                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('如:第一版、第二版'),
+
+                TextInput::make('official_title')
+                    ->label('教材名称')
+                    ->maxLength(512)
+                    ->helperText('教材名称(用户输入)'),
+
+                Textarea::make('aliases')
+                    ->label('别名')
+                    ->helperText('JSON 格式,如:["别名1", "别名2"]')
+                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
+                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
+                    ->columnSpanFull(),
+
+                Select::make('status')
+                    ->label('状态')
+                    ->options([
+                        'draft' => '草稿',
+                        'published' => '已发布',
+                        'archived' => '已归档',
+                    ])
+                    ->default('draft')
+                    ->required(),
+
+                TextInput::make('isbn')
+                    ->label('ISBN')
+                    ->maxLength(20),
+
+                FileUpload::make('cover_path')
+                    ->label('封面图片')
+                    ->image()
+                    ->directory('textbook-covers'),
+
+                TextInput::make('sort_order')
+                    ->label('排序')
+                    ->numeric()
+                    ->default(0)
+                    ->helperText('数字越小排序越靠前'),
+
+                Textarea::make('meta')
+                    ->label('扩展信息')
+                    ->helperText('JSON 格式')
+                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
+                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
+                    ->columnSpanFull(),
+            ])
+            ->columns(2);
+    }
+}

+ 186 - 0
app/Filament/Resources/TextbookResource/Tables/TextbookTable.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Tables;
+
+use App\Filament\Resources\TextbookResource;
+use Filament\Actions\EditAction;
+use Filament\Actions\Action;
+use Filament\Tables;
+use Filament\Tables\Columns\BadgeColumn;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Filters\SelectFilter;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookTable
+{
+    public static function make(Table $table): Table
+    {
+        // 直接返回配置好的表格,不使用query()
+        return $table
+            ->columns([
+                TextColumn::make('id')
+                    ->label('ID')
+                    ->sortable(),
+
+                TextColumn::make('series.name')
+                    ->label('系列')
+                    ->searchable(),
+
+                TextColumn::make('official_title')
+                    ->label('官方书名')
+                    ->searchable()
+                    ->wrap(),
+
+                TextColumn::make('stage')
+                    ->label('学段')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'primary' => '小学',
+                            'junior' => '初中',
+                            'senior' => '高中',
+                            default => $state,
+                        };
+                    })
+                    ->badge()
+                    ->color('info'),
+
+                TextColumn::make('grade')
+                    ->label('年级')
+                    ->formatStateUsing(function ($state): string {
+                        return $state ? "{$state}年级" : '-';
+                    }),
+
+                TextColumn::make('semester')
+                    ->label('学期')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            1 => '上学期',
+                            2 => '下学期',
+                            default => '-',
+                        };
+                    })
+                    ->badge()
+                    ->color('success'),
+
+                TextColumn::make('naming_scheme')
+                    ->label('体系')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'new' => '新体系',
+                            'old' => '旧体系',
+                            default => $state,
+                        };
+                    })
+                    ->badge(),
+
+                TextColumn::make('status')
+                    ->label('状态')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'draft' => '草稿',
+                            'published' => '已发布',
+                            'archived' => '已归档',
+                            default => $state,
+                        };
+                    })
+                    ->badge()
+                    ->color(function ($state): string {
+                        return match ($state) {
+                            'draft' => 'gray',
+                            'published' => 'success',
+                            'archived' => 'danger',
+                            default => 'gray',
+                        };
+                    }),
+
+                TextColumn::make('created_at')
+                    ->label('创建时间')
+                    ->dateTime()
+                    ->sortable()
+                    ->toggleable(isToggledHiddenByDefault: true),
+
+                TextColumn::make('updated_at')
+                    ->label('更新时间')
+                    ->dateTime()
+                    ->sortable()
+                    ->toggleable(isToggledHiddenByDefault: true),
+            ])
+            ->filters([
+                SelectFilter::make('stage')
+                    ->label('学段')
+                    ->options([
+                        'primary' => '小学',
+                        'junior' => '初中',
+                        'senior' => '高中',
+                    ]),
+
+                SelectFilter::make('status')
+                    ->label('状态')
+                    ->options([
+                        'draft' => '草稿',
+                        'published' => '已发布',
+                        'archived' => '已归档',
+                    ]),
+            ])
+            ->actions([
+                EditAction::make()
+                    ->label('编辑'),
+
+                Action::make('delete')
+                    ->label('删除')
+                    ->color('danger')
+                    ->icon('heroicon-o-trash')
+                    ->requiresConfirmation()
+                    ->modalHeading('删除教材')
+                    ->modalDescription('确定要删除这个教材吗?此操作无法撤销。')
+                    ->action(function (Model $record) {
+                        // 添加调试日志
+                        \Log::info('Deleting textbook', ['id' => $record->id, 'record' => $record]);
+
+                        if (!$record || !$record->id) {
+                            \Filament\Notifications\Notification::make()
+                                ->title('错误')
+                                ->body('无效的教材记录。')
+                                ->danger()
+                                ->send();
+                            return;
+                        }
+
+                        $apiService = app(\App\Services\TextbookApiService::class);
+                        $deleted = $apiService->deleteTextbook($record->id);
+
+                        \Log::info('Delete result', ['deleted' => $deleted]);
+
+                        if ($deleted) {
+                            \Filament\Notifications\Notification::make()
+                                ->title('成功')
+                                ->body('教材删除成功。')
+                                ->success()
+                                ->send();
+                        } else {
+                            \Filament\Notifications\Notification::make()
+                                ->title('错误')
+                                ->body('删除失败,请重试。')
+                                ->danger()
+                                ->send();
+                        }
+                    }),
+
+                Action::make('view_catalog')
+                    ->label('查看目录')
+                    ->icon('heroicon-o-list-bullet')
+                    ->url(fn(Model $record): string =>
+                        route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
+                    ),
+            ])
+            ->bulkActions([
+                \Filament\Actions\BulkActionGroup::make([
+                    \Filament\Actions\DeleteBulkAction::make()
+                        ->label('批量删除'),
+                ]),
+            ])
+            ->defaultSort('sort_order')
+            ->paginated([10, 25, 50, 100]);
+    }
+}

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

@@ -125,6 +125,9 @@ class IntelligentExamController extends Controller
             // 第三步:创建异步任务(异步)
             $taskId = $this->createAsyncTask($paperId, $data);
 
+            // 生成识别码
+            $codes = $this->generatePaperCodes($paperId);
+
             // 立即返回完整的试卷数据(不等待PDF生成)
             $examContent = $this->buildCompleteExamContent($paperId);
             $payload = [
@@ -134,6 +137,10 @@ class IntelligentExamController extends Controller
                     'task_id' => $taskId,
                     'paper_id' => $paperId,
                     'status' => 'processing',
+                    // 识别码
+                    'exam_code' => $codes['exam_code'],       // 试卷识别码 (1+12位)
+                    'grading_code' => $codes['grading_code'], // 判卷识别码 (2+12位)
+                    'paper_id_num' => $codes['paper_id_num'], // 12位数字ID
                     'exam_content' => $examContent,
                     'urls' => [
                         // 通过paper_id获取HTML预览
@@ -547,6 +554,9 @@ class IntelligentExamController extends Controller
         $paper = Paper::with('questions')->find($paperId);
         $questions = $paper ? $paper->questions : collect();
 
+        // 生成13位识别码
+        $codes = $this->generatePaperCodes($paperId);
+
         return [
             // 试卷基本信息
             'paper_info' => [
@@ -559,6 +569,10 @@ class IntelligentExamController extends Controller
                 '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
             ],
 
             // 完整题目信息
@@ -732,4 +746,26 @@ class IntelligentExamController extends Controller
         }
         return array_unique(array_filter($skills));
     }
+
+    /**
+     * 生成试卷识别码
+     * 格式:试卷码 = 1 + 12位数字,判卷码 = 2 + 12位数字
+     */
+    private function generatePaperCodes(string $paperId): array
+    {
+        // 从 paper_id 提取12位数字部分(格式: paper_xxxxxxxxxxxx)
+        if (preg_match('/paper_(\d{12})/', $paperId, $matches)) {
+            $paperIdNum = $matches[1];
+        } else {
+            // 兼容旧格式,取数字部分或生成哈希
+            $paperIdNum = preg_replace('/[^0-9]/', '', $paperId);
+            $paperIdNum = str_pad(substr($paperIdNum, 0, 12), 12, '0', STR_PAD_LEFT);
+        }
+
+        return [
+            'paper_id_num' => $paperIdNum,
+            'exam_code' => '1' . $paperIdNum,     // 试卷识别码:1 + 12位数字
+            'grading_code' => '2' . $paperIdNum,  // 判卷识别码:2 + 12位数字
+        ];
+    }
 }

+ 170 - 0
app/Http/Controllers/Api/KnowledgeMasteryController.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Services\KnowledgeMasteryService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * 知识点掌握情况 API 控制器
+ *
+ * 提供:
+ * 1. 获取学生知识点掌握情况统计
+ * 2. 获取知识点图谱数据(考试快照)
+ * 3. 获取知识点图谱快照列表
+ */
+class KnowledgeMasteryController extends Controller
+{
+    protected KnowledgeMasteryService $service;
+
+    public function __construct(KnowledgeMasteryService $service)
+    {
+        $this->service = $service;
+    }
+
+    /**
+     * 获取学生知识点掌握情况统计
+     *
+     * GET /api/knowledge-mastery/stats/{studentId}
+     *
+     * @param string $studentId 学生ID
+     * @return JsonResponse
+     */
+    public function stats(string $studentId): JsonResponse
+    {
+        $result = $this->service->getStats($studentId);
+
+        if (!$result['success']) {
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '获取知识点掌握情况失败',
+            ], 500);
+        }
+
+        return response()->json([
+            'success' => true,
+            'data' => $result['data'],
+        ]);
+    }
+
+    /**
+     * 获取学生知识点掌握摘要(简化版)
+     *
+     * GET /api/knowledge-mastery/summary/{studentId}
+     *
+     * @param string $studentId 学生ID
+     * @return JsonResponse
+     */
+    public function summary(string $studentId): JsonResponse
+    {
+        $result = $this->service->getSummary($studentId);
+
+        if (!$result['success']) {
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '获取知识点掌握摘要失败',
+            ], 500);
+        }
+
+        return response()->json([
+            'success' => true,
+            'data' => $result['data'],
+        ]);
+    }
+
+    /**
+     * 获取学生知识点图谱数据
+     *
+     * GET /api/knowledge-mastery/graph/{studentId}
+     *
+     * @param Request $request
+     * @param string $studentId 学生ID
+     * @return JsonResponse
+     */
+    public function graph(Request $request, string $studentId): JsonResponse
+    {
+        $examId = $request->query('exam_id');
+
+        $result = $this->service->getGraph($studentId, $examId);
+
+        if (!$result['success']) {
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '获取知识点图谱失败',
+            ], 500);
+        }
+
+        return response()->json([
+            'success' => true,
+            'data' => $result['data'],
+        ]);
+    }
+
+    /**
+     * 获取学生知识点图谱快照列表
+     *
+     * GET /api/knowledge-mastery/graph/snapshots/{studentId}
+     *
+     * @param Request $request
+     * @param string $studentId 学生ID
+     * @return JsonResponse
+     */
+    public function graphSnapshots(Request $request, string $studentId): JsonResponse
+    {
+        $limit = (int) $request->query('limit', 10);
+
+        $result = $this->service->getGraphSnapshots($studentId, $limit);
+
+        if (!$result['success']) {
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '获取知识点图谱快照列表失败',
+            ], 500);
+        }
+
+        return response()->json([
+            'success' => true,
+            'data' => $result['data'],
+        ]);
+    }
+
+    /**
+     * 创建知识点掌握度快照
+     *
+     * POST /api/knowledge-mastery/snapshot/{studentId}
+     *
+     * @param Request $request
+     * @param string $studentId 学生ID
+     * @return JsonResponse
+     */
+    public function createSnapshot(Request $request, string $studentId): JsonResponse
+    {
+        $snapshotType = $request->input('snapshot_type', 'report');
+        $sourceId = $request->input('source_id');
+        $sourceName = $request->input('source_name');
+        $notes = $request->input('notes');
+
+        $result = $this->service->createSnapshot(
+            $studentId,
+            $snapshotType,
+            $sourceId,
+            $sourceName,
+            $notes
+        );
+
+        if (!$result['success']) {
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '创建知识点掌握度快照失败',
+            ], 500);
+        }
+
+        return response()->json([
+            'success' => true,
+            'message' => '快照创建成功',
+            'data' => $result['data'],
+        ]);
+    }
+}

+ 646 - 0
app/Http/Controllers/Api/MistakeBookController.php

@@ -0,0 +1,646 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Services\MistakeBookService;
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Log;
+
+class MistakeBookController extends Controller
+{
+    protected MistakeBookService $mistakeBookService;
+
+    public function __construct(MistakeBookService $mistakeBookService)
+    {
+        $this->mistakeBookService = $mistakeBookService;
+    }
+
+    /**
+     * 获取错题列表
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function listMistakes(Request $request): JsonResponse
+    {
+        try {
+            $params = $request->only([
+                'student_id',
+                'start_time',
+                'end_time',
+                'kp_ids',
+                'skill_ids',
+                'error_types',
+                'time_range',
+                'page',
+                'per_page',
+                'sort_by'
+            ]);
+
+            // 设置默认值
+            $params['page'] = (int) ($params['page'] ?? 1);
+            $params['per_page'] = (int) ($params['per_page'] ?? 20);
+            $params['sort_by'] = $params['sort_by'] ?? 'created_at_desc';
+
+            // 调用服务层获取错题列表
+            $result = $this->mistakeBookService->listMistakes($params);
+
+            Log::info('获取错题列表成功', [
+                'student_id' => $params['student_id'] ?? 'N/A',
+                'page' => $params['page'],
+                'per_page' => $params['per_page'],
+                'total' => $result['meta']['total'] ?? 0
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => $result['data'] ?? [],
+                'meta' => $result['meta'] ?? [],
+                'statistics' => $result['statistics'] ?? null
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取错题列表失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+                'params' => $request->all()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取错题列表失败: ' . $e->getMessage(),
+                'data' => [],
+                'meta' => [
+                    'total' => 0,
+                    'page' => (int) ($request->get('page', 1)),
+                    'per_page' => (int) ($request->get('per_page', 20))
+                ]
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取单条错题详情
+     *
+     * @param string $mistakeId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getMistakeDetail(string $mistakeId, Request $request): JsonResponse
+    {
+        try {
+            $studentId = $request->get('student_id');
+
+            $mistake = $this->mistakeBookService->getMistakeDetail($mistakeId, $studentId);
+
+            if (empty($mistake)) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '错题记录不存在'
+                ], 404);
+            }
+
+            Log::info('获取错题详情成功', [
+                'mistake_id' => $mistakeId,
+                'student_id' => $studentId ?? 'N/A'
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => $mistake
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取错题详情失败', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取错题详情失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取错题统计概要
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getSummary(Request $request): JsonResponse
+    {
+        try {
+            $studentId = $request->get('student_id');
+
+            if (!$studentId) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '缺少必要参数:student_id'
+                ], 400);
+            }
+
+            $startTime = $request->get('start_time');
+            $endTime = $request->get('end_time');
+
+            // 构建查询参数
+            $params = ['student_id' => $studentId];
+            if ($startTime) {
+                $params['start_time'] = $startTime;
+            }
+            if ($endTime) {
+                $params['end_time'] = $endTime;
+            }
+
+            // 获取错题列表(用于计算统计信息)
+            $mistakesResult = $this->mistakeBookService->listMistakes($params);
+
+            // 获取概要统计
+            $summary = $this->mistakeBookService->summarize($studentId);
+
+            Log::info('获取错题概要统计成功', [
+                'student_id' => $studentId,
+                'total_mistakes' => $summary['total'] ?? 0
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => [
+                    'total' => $summary['total'] ?? 0,
+                    'this_week' => $summary['this_week'] ?? 0,
+                    'pending_review' => $summary['pending_review'] ?? 0,
+                    'mastery_rate' => $summary['mastery_rate'] ?? 0.0,
+                    'statistics' => $mistakesResult['statistics'] ?? null
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取错题概要统计失败', [
+                'student_id' => $request->get('student_id'),
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取错题概要统计失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取错误模式分析
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getMistakePatterns(Request $request): JsonResponse
+    {
+        try {
+            $studentId = $request->get('student_id');
+
+            if (!$studentId) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '缺少必要参数:student_id'
+                ], 400);
+            }
+
+            $patterns = $this->mistakeBookService->getMistakePatterns($studentId);
+
+            Log::info('获取错误模式分析成功', [
+                'student_id' => $studentId,
+                'top_kps_count' => count($patterns['top_kps'] ?? [])
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => $patterns
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取错误模式分析失败', [
+                'student_id' => $request->get('student_id'),
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取错误模式分析失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 收藏/取消收藏错题
+     *
+     * @param string $mistakeId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function toggleFavorite(string $mistakeId, Request $request): JsonResponse
+    {
+        try {
+            $data = $request->only(['favorite']);
+            $favorite = $data['favorite'] ?? true;
+
+            $result = $this->mistakeBookService->toggleFavorite($mistakeId, $favorite);
+
+            Log::info(($favorite ? '收藏' : '取消收藏') . '错题', [
+                'mistake_id' => $mistakeId,
+                'favorite' => $favorite,
+                'result' => $result
+            ]);
+
+            return response()->json([
+                'success' => $result,
+                'message' => $result ? '操作成功' : '操作失败',
+                'data' => [
+                    'mistake_id' => $mistakeId,
+                    'favorite' => $favorite
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('收藏错题失败', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '收藏错题失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 标记错题已复习
+     *
+     * @param string $mistakeId
+     * @return JsonResponse
+     */
+    public function markReviewed(string $mistakeId): JsonResponse
+    {
+        try {
+            $result = $this->mistakeBookService->markReviewed($mistakeId);
+
+            Log::info('标记错题已复习', [
+                'mistake_id' => $mistakeId,
+                'result' => $result
+            ]);
+
+            return response()->json([
+                'success' => $result,
+                'message' => $result ? '操作成功' : '操作失败',
+                'data' => [
+                    'mistake_id' => $mistakeId,
+                    'reviewed' => $result,
+                    'reviewed_at' => $result ? now()->toISOString() : null
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('标记错题已复习失败', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '标记错题已复习失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 加入重练清单
+     *
+     * @param string $mistakeId
+     * @return JsonResponse
+     */
+    public function addToRetryList(string $mistakeId): JsonResponse
+    {
+        try {
+            $result = $this->mistakeBookService->addToRetryList($mistakeId);
+
+            Log::info('加入重练清单', [
+                'mistake_id' => $mistakeId,
+                'result' => $result
+            ]);
+
+            return response()->json([
+                'success' => $result,
+                'message' => $result ? '操作成功' : '操作失败',
+                'data' => [
+                    'mistake_id' => $mistakeId,
+                    'added' => $result
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('加入重练清单失败', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '加入重练清单失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取题目正确率
+     *
+     * @param string $questionId
+     * @return JsonResponse
+     */
+    public function getQuestionAccuracy(string $questionId): JsonResponse
+    {
+        try {
+            $accuracy = $this->mistakeBookService->getQuestionAccuracy($questionId);
+
+            Log::info('获取题目正确率成功', [
+                'question_id' => $questionId,
+                'accuracy' => $accuracy
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => [
+                    'question_id' => $questionId,
+                    'accuracy' => $accuracy,
+                    'correct_attempts' => null // LearningAnalytics不返回具体次数
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取题目正确率失败', [
+                'question_id' => $questionId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取题目正确率失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 推荐练习题
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function recommendPractice(Request $request): JsonResponse
+    {
+        try {
+            $studentId = $request->get('student_id');
+            $kpIds = $request->get('kp_ids', []);
+            $skillIds = $request->get('skill_ids', []);
+
+            if (!$studentId) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '缺少必要参数:student_id'
+                ], 400);
+            }
+
+            $recommendations = $this->mistakeBookService->recommendPractice(
+                $studentId,
+                $kpIds,
+                $skillIds
+            );
+
+            Log::info('获取推荐练习题成功', [
+                'student_id' => $studentId,
+                'kp_ids' => $kpIds,
+                'recommendations_count' => count($recommendations['data'] ?? [])
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => $recommendations['data'] ?? []
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取推荐练习题失败', [
+                'student_id' => $request->get('student_id'),
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取推荐练习题失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取错题本快照数据(用于仪表板)
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getSnapshot(Request $request): JsonResponse
+    {
+        try {
+            $studentId = $request->get('student_id');
+            $limit = (int) ($request->get('limit', 5));
+
+            if (!$studentId) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '缺少必要参数:student_id'
+                ], 400);
+            }
+
+            $snapshot = $this->mistakeBookService->getPanelSnapshot($studentId, $limit);
+
+            Log::info('获取错题本快照数据成功', [
+                'student_id' => $studentId,
+                'limit' => $limit,
+                'recent_count' => count($snapshot['recent'] ?? [])
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => $snapshot
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取错题本快照数据失败', [
+                'student_id' => $request->get('student_id'),
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取错题本快照数据失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 修改错题复习状态
+     */
+    public function updateReviewStatus(Request $request, string $mistakeId): JsonResponse
+    {
+        try {
+            $validated = $request->validate([
+                'action' => 'required|in:increment,reset',
+                'force_review' => 'boolean',
+            ]);
+
+            $action = $validated['action'];
+            $forceReview = $validated['force_review'] ?? false;
+
+            $result = $this->mistakeBookService->updateReviewStatus($mistakeId, $action, $forceReview);
+
+            if ($result['success'] ?? false) {
+                return response()->json([
+                    'success' => true,
+                    'data' => $result,
+                    'message' => $result['message'] ?? '复习状态更新成功',
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '更新复习状态失败',
+            ], 400);
+        } catch (\Throwable $e) {
+            Log::error('Update review status error', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '更新复习状态失败',
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取错题复习状态
+     */
+    public function getReviewStatus(string $mistakeId): JsonResponse
+    {
+        try {
+            $result = $this->mistakeBookService->getReviewStatus($mistakeId);
+
+            if ($result['success'] ?? false) {
+                return response()->json([
+                    'success' => true,
+                    'data' => $result,
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '获取复习状态失败',
+            ], 400);
+        } catch (\Throwable $e) {
+            Log::error('Get review status error', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取复习状态失败',
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 增加复习次数
+     */
+    public function incrementReview(Request $request, string $mistakeId): JsonResponse
+    {
+        try {
+            $validated = $request->validate([
+                'force_review' => 'boolean',
+            ]);
+
+            $forceReview = $validated['force_review'] ?? false;
+
+            $result = $this->mistakeBookService->incrementReviewCount($mistakeId, $forceReview);
+
+            if ($result['success'] ?? false) {
+                return response()->json([
+                    'success' => true,
+                    'data' => $result,
+                    'message' => $result['message'] ?? '复习次数增加成功',
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '增加复习次数失败',
+            ], 400);
+        } catch (\Throwable $e) {
+            Log::error('Increment review count error', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '增加复习次数失败',
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 重置为强制复习状态
+     */
+    public function resetReview(string $mistakeId): JsonResponse
+    {
+        try {
+            $result = $this->mistakeBookService->resetReviewStatus($mistakeId);
+
+            if ($result['success'] ?? false) {
+                return response()->json([
+                    'success' => true,
+                    'data' => $result,
+                    'message' => $result['message'] ?? '复习状态重置成功',
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => $result['error'] ?? '重置复习状态失败',
+            ], 400);
+        } catch (\Throwable $e) {
+            Log::error('Reset review status error', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '重置复习状态失败',
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+}

+ 2 - 2
app/Http/Controllers/Api/TextbookApiController.php

@@ -296,8 +296,8 @@ class TextbookApiController extends Controller
 
         return [
             'id' => $textbook['id'],
-            'name' => $textbook['official_title'] ?? $textbook['display_title'] ?? '',
-            'display_name' => $textbook['display_title'] ?? $textbook['official_title'] ?? '',
+            'name' => $textbook['official_title'] ?? '',
+            'display_name' => $textbook['official_title'] ?? '',
             'cover' => $this->formatCoverUrl($textbook['cover_path'] ?? ''),
             'series_id' => $textbook['series_id'] ?? null,
             'series_name' => $textbook['series']['name'] ?? '',

+ 77 - 0
app/Http/Controllers/TextbookController.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\TextbookApiService;
+use Filament\Notifications\Notification;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redirect;
+
+class TextbookController extends Controller
+{
+    protected TextbookApiService $apiService;
+
+    public function __construct(TextbookApiService $apiService)
+    {
+        $this->apiService = $apiService;
+    }
+
+    /**
+     * 删除教材 - 通过URL参数获取ID,完全绕过Filament的$record传递问题
+     * 使用GET方法避免CSRF问题
+     */
+    public function delete(Request $request, int $id)
+    {
+        Log::info('TextbookController::delete called', [
+            'textbook_id' => $id,
+            'request_all' => $request->all()
+        ]);
+
+        if (!$id) {
+            Notification::make()
+                ->title('错误')
+                ->body('无效的教材ID。')
+                ->danger()
+                ->send();
+
+            return Redirect::route('filament.admin.resources.textbooks.index');
+        }
+
+        try {
+            $deleted = $this->apiService->deleteTextbook($id);
+
+            Log::info('Delete API result', [
+                'deleted' => $deleted,
+                'textbook_id' => $id
+            ]);
+
+            if ($deleted) {
+                Notification::make()
+                    ->title('成功')
+                    ->body('教材删除成功。')
+                    ->success()
+                    ->send();
+            } else {
+                Notification::make()
+                    ->title('错误')
+                    ->body('删除失败,请重试。')
+                    ->danger()
+                    ->send();
+            }
+        } catch (\Exception $e) {
+            Log::error('Delete error', [
+                'error' => $e->getMessage(),
+                'textbook_id' => $id
+            ]);
+            Notification::make()
+                ->title('错误')
+                ->body('删除失败:' . $e->getMessage())
+                ->danger()
+                ->send();
+        }
+
+        // 无论成功失败,都重定向回列表页面
+        return Redirect::route('filament.admin.resources.textbooks.index');
+    }
+}

+ 234 - 0
app/Livewire/TextbookTable.php

@@ -0,0 +1,234 @@
+<?php
+
+namespace App\Livewire;
+
+use App\Services\TextbookApiService;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Filament\Livewire\Concerns\InteractsWithTables;
+use Livewire\Component;
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookTable extends Component implements Tables\Contracts\HasTable
+{
+    use InteractsWithTables;
+
+    public function render()
+    {
+        return view('livewire.textbook-table');
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->query(function () {
+                // 返回空查询,实际数据通过 getTableRecords 提供
+                return \App\Models\Textbook::query()->whereRaw('1=0');
+            })
+            ->columns([
+                Tables\Columns\TextColumn::make('id')
+                    ->label('ID')
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('series.name')
+                    ->label('系列')
+                    ->searchable(),
+
+                Tables\Columns\TextColumn::make('official_title')
+                    ->label('官方书名')
+                    ->searchable()
+                    ->wrap(),
+
+                Tables\Columns\TextColumn::make('stage')
+                    ->label('学段')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'primary' => '小学',
+                            'junior' => '初中',
+                            'senior' => '高中',
+                            default => $state,
+                        };
+                    })
+                    ->badge()
+                    ->color('info'),
+
+                Tables\Columns\TextColumn::make('grade')
+                    ->label('年级')
+                    ->formatStateUsing(function ($state): string {
+                        return $state ? "{$state}年级" : '-';
+                    }),
+
+                Tables\Columns\TextColumn::make('semester')
+                    ->label('学期')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            1 => '上学期',
+                            2 => '下学期',
+                            default => '-',
+                        };
+                    })
+                    ->badge()
+                    ->color('success'),
+
+                Tables\Columns\TextColumn::make('naming_scheme')
+                    ->label('体系')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'new' => '新体系',
+                            'old' => '旧体系',
+                            default => $state,
+                        };
+                    })
+                    ->badge(),
+
+                Tables\Columns\TextColumn::make('status')
+                    ->label('状态')
+                    ->formatStateUsing(function ($state): string {
+                        return match ($state) {
+                            'draft' => '草稿',
+                            'published' => '已发布',
+                            'archived' => '已归档',
+                            default => $state,
+                        };
+                    })
+                    ->badge()
+                    ->color(function ($state): string {
+                        return match ($state) {
+                            'draft' => 'gray',
+                            'published' => 'success',
+                            'archived' => 'danger',
+                            default => 'gray',
+                        };
+                    }),
+
+                Tables\Columns\TextColumn::make('created_at')
+                    ->label('创建时间')
+                    ->dateTime()
+                    ->sortable()
+                    ->toggleable(isToggledHiddenByDefault: true),
+
+                Tables\Columns\TextColumn::make('updated_at')
+                    ->label('更新时间')
+                    ->dateTime()
+                    ->sortable()
+                    ->toggleable(isToggledHiddenByDefault: true),
+            ])
+            ->filters([
+                Tables\Filters\SelectFilter::make('stage')
+                    ->label('学段')
+                    ->options([
+                        'primary' => '小学',
+                        'junior' => '初中',
+                        'senior' => '高中',
+                    ]),
+
+                Tables\Filters\SelectFilter::make('status')
+                    ->label('状态')
+                    ->options([
+                        'draft' => '草稿',
+                        'published' => '已发布',
+                        'archived' => '已归档',
+                    ]),
+            ])
+            ->actions([
+                Tables\Actions\EditAction::make()
+                    ->label('编辑'),
+
+                Tables\Actions\Action::make('delete')
+                    ->label('删除')
+                    ->color('danger')
+                    ->icon('heroicon-o-trash')
+                    ->requiresConfirmation()
+                    ->modalHeading('删除教材')
+                    ->modalDescription('确定要删除这个教材吗?此操作无法撤销。')
+                    ->action(function (Model $record) {
+                        // 在Livewire组件中,模型正确传递
+                        \Log::info('Livewire delete triggered', [
+                            'record_id' => $record->id,
+                            'record_class' => get_class($record)
+                        ]);
+
+                        try {
+                            $apiService = app(TextbookApiService::class);
+                            $deleted = $apiService->deleteTextbook($record->id);
+
+                            \Log::info('Delete API result', [
+                                'deleted' => $deleted,
+                                'record_id' => $record->id
+                            ]);
+
+                            if ($deleted) {
+                                \Filament\Notifications\Notification::make()
+                                    ->title('成功')
+                                    ->body('教材删除成功。')
+                                    ->success()
+                                    ->send();
+                            } else {
+                                \Filament\Notifications\Notification::make()
+                                    ->title('错误')
+                                    ->body('删除失败,请重试。')
+                                    ->danger()
+                                    ->send();
+                            }
+                        } catch (\Exception $e) {
+                            \Log::error('Delete error', [
+                                'error' => $e->getMessage(),
+                                'record_id' => $record->id
+                            ]);
+                            \Filament\Notifications\Notification::make()
+                                ->title('错误')
+                                ->body('删除失败:' . $e->getMessage())
+                                ->danger()
+                                ->send();
+                        }
+                    }),
+
+                Tables\Actions\Action::make('view_catalog')
+                    ->label('查看目录')
+                    ->icon('heroicon-o-list-bullet')
+                    ->url(fn(Model $record): string =>
+                        route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
+                    ),
+            ])
+            ->bulkActions([
+                Tables\Actions\BulkActionGroup::make([
+                    Tables\Actions\DeleteBulkAction::make()
+                        ->label('批量删除'),
+                ]),
+            ])
+            ->defaultSort('sort_order')
+            ->paginated([10, 25, 50, 100]);
+    }
+
+    public function getTableRecords(): array
+    {
+        // 从 API 获取数据
+        $apiService = app(TextbookApiService::class);
+        $result = $apiService->getTextbooks();
+
+        $records = [];
+        foreach ($result['data'] ?? [] as $item) {
+            $model = new \App\Models\Textbook();
+            // 使用 setAttribute 确保属性正确设置
+            foreach ($item as $key => $value) {
+                $model->setAttribute($key, $value);
+            }
+            $model->exists = true;
+            $model->id = $item['id'];
+
+            // 设置关联
+            if (isset($item['series'])) {
+                $seriesModel = new \App\Models\TextbookSeries();
+                foreach ($item['series'] as $key => $value) {
+                    $seriesModel->setAttribute($key, $value);
+                }
+                $seriesModel->exists = true;
+                $model->setRelation('series', $seriesModel);
+            }
+
+            $records[] = $model;
+        }
+
+        return $records;
+    }
+}

+ 1 - 1
app/Models/ApiTextbook.php

@@ -29,7 +29,7 @@ class ApiTextbook extends Model
         'id', 'series_id', 'series', 'stage', 'grade', 'semester',
         'naming_scheme', 'track', 'module_type', 'volume_no',
         'legacy_code', 'curriculum_standard_year', 'curriculum_revision_year',
-        'approval_year', 'edition_label', 'official_title', 'display_title',
+        'approval_year', 'edition_label', 'official_title',
         'aliases', 'isbn', 'cover_path', 'status', 'created_at'
     ];
 

+ 46 - 0
app/Models/PaperPart.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class PaperPart extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'source_paper_id',
+        'order',
+        'title',
+        'type',
+        'raw_markdown',
+        'question_count',
+        'detected_features',
+    ];
+
+    protected $casts = [
+        'order' => 'integer',
+        'question_count' => 'integer',
+        'detected_features' => 'array',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function paper(): BelongsTo
+    {
+        return $this->belongsTo(SourcePaper::class, 'source_paper_id');
+    }
+
+    public function candidates(): HasMany
+    {
+        return $this->hasMany(PreQuestionCandidate::class, 'part_id');
+    }
+
+    public function questionRefs(): HasMany
+    {
+        return $this->hasMany(PaperQuestionRef::class, 'part_id');
+    }
+}

+ 46 - 0
app/Models/PaperQuestionRef.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class PaperQuestionRef extends Model
+{
+    use HasFactory;
+
+    protected $table = 'paper_question_ref';
+
+    protected $fillable = [
+        'source_paper_id',
+        'part_id',
+        'candidate_id',
+        'question_number',
+        'order',
+        'raw_markdown',
+        'metadata',
+    ];
+
+    protected $casts = [
+        'metadata' => 'array',
+        'order' => 'integer',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function paper(): BelongsTo
+    {
+        return $this->belongsTo(SourcePaper::class, 'source_paper_id');
+    }
+
+    public function part(): BelongsTo
+    {
+        return $this->belongsTo(PaperPart::class, 'part_id');
+    }
+
+    public function candidate(): BelongsTo
+    {
+        return $this->belongsTo(PreQuestionCandidate::class, 'candidate_id');
+    }
+}

+ 46 - 0
app/Models/SourceFile.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasManyThrough;
+
+class SourceFile extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'uuid',
+        'original_filename',
+        'normalized_filename',
+        'extension',
+        'storage_path',
+        'raw_markdown',
+        'file_metadata',
+        'extracted_metadata',
+    ];
+
+    protected $casts = [
+        'file_metadata' => 'array',
+        'extracted_metadata' => 'array',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function papers(): HasMany
+    {
+        return $this->hasMany(SourcePaper::class);
+    }
+
+    public function parts(): HasManyThrough
+    {
+        return $this->hasManyThrough(PaperPart::class, SourcePaper::class);
+    }
+
+    public function candidates(): HasMany
+    {
+        return $this->hasMany(PreQuestionCandidate::class);
+    }
+}

+ 57 - 0
app/Models/SourcePaper.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class SourcePaper extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'uuid',
+        'source_file_id',
+        'order',
+        'title',
+        'full_title',
+        'chapter',
+        'grade',
+        'term',
+        'edition',
+        'textbook_series',
+        'source_type',
+        'source_year',
+        'raw_markdown',
+        'detected_metadata',
+    ];
+
+    protected $casts = [
+        'detected_metadata' => 'array',
+        'order' => 'integer',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function file(): BelongsTo
+    {
+        return $this->belongsTo(SourceFile::class, 'source_file_id');
+    }
+
+    public function parts(): HasMany
+    {
+        return $this->hasMany(PaperPart::class, 'source_paper_id');
+    }
+
+    public function candidates(): HasMany
+    {
+        return $this->hasMany(PreQuestionCandidate::class, 'source_paper_id');
+    }
+
+    public function questionRefs(): HasMany
+    {
+        return $this->hasMany(PaperQuestionRef::class, 'source_paper_id');
+    }
+}

+ 0 - 1
app/Models/Textbook.php

@@ -26,7 +26,6 @@ class Textbook extends Model
         'approval_year',
         'edition_label',
         'official_title',
-        'display_title',
         'aliases',
         'isbn',
         'cover_path',

+ 29 - 15
app/Services/Import/TextbookExcelImporter.php

@@ -421,7 +421,6 @@ class TextbookExcelImporter
                         'isbn' => $row[13] ?: null,
                         'cover_path' => $row[14] ?: null,
                         'official_title' => $row[15] ?: null,
-                        'display_title' => $row[16] ?: null,
                         'aliases' => json_encode($aliases),
                         'status' => $status,
                         'meta' => json_encode($meta),
@@ -575,17 +574,19 @@ class TextbookExcelImporter
         ];
 
         try {
-            // 首先验证Excel文件
+            // 加载Excel文件并解析
             $spreadsheet = IOFactory::load($filePath);
             $sheet = $spreadsheet->getActiveSheet();
             $data = $sheet->toArray();
 
-            // 跳过标题行
-            $rows = array_slice($data, 1);
+            // 准备数据发送到API
+            $catalogData = [];
             $successCount = 0;
             $errorCount = 0;
             $errors = [];
 
+            // 跳过标题行,处理数据行
+            $rows = array_slice($data, 1);
             foreach ($rows as $index => $row) {
                 try {
                     // 跳过空行
@@ -604,7 +605,7 @@ class TextbookExcelImporter
                     }
 
                     // 转换节点类型
-                    $nodeTypeInput = trim($row[3] ?? '章');
+                    $nodeTypeInput = trim($row[2] ?? '章');
                     $nodeType = $nodeTypeMap[$nodeTypeInput] ?? 'chapter';
 
                     // 解析是否必修/选修
@@ -622,11 +623,11 @@ class TextbookExcelImporter
                         $meta = [];
                     }
 
-                    // 构建目录数据(这里我们只是验证,实际导入通过API完成)
-                    $catalogData = [
+                    // 构建目录数据
+                    $nodeData = [
                         'textbook_id' => (int)$row[0],
                         'title' => $row[1],
-                        'display_no' => $row[2] ?: null,
+                        'display_no' => $row[3] ?: null,
                         'node_type' => $nodeType,
                         'depth' => $row[4] ? (int)$row[4] : 1,
                         'sort_order' => $row[5] ? (int)$row[5] : 0,
@@ -636,11 +637,11 @@ class TextbookExcelImporter
                         'page_end' => $row[9] ? (int)$row[9] : null,
                         'is_required' => $isRequired,
                         'is_elective' => $isElective,
-                        'tags' => json_encode($tags),
-                        'meta' => json_encode($meta),
+                        'tags' => $tags,
+                        'meta' => $meta,
                     ];
 
-                    // 验证数据(实际创建通过API完成)
+                    $catalogData[] = $nodeData;
                     $successCount++;
 
                 } catch (\Exception $e) {
@@ -649,11 +650,24 @@ class TextbookExcelImporter
                 }
             }
 
+            // 如果没有有效数据,返回错误
+            if (empty($catalogData)) {
+                return [
+                    'success' => false,
+                    'message' => 'Excel文件中没有有效的目录数据',
+                ];
+            }
+
+            // 调用API导入教材目录,直接传递JSON数据
+            $apiService = app(TextbookApiService::class);
+            $result = $apiService->importTextbookCatalog($textbookId, $catalogData);
+
             return [
-                'success' => true,
-                'success_count' => $successCount,
-                'error_count' => $errorCount,
-                'errors' => $errors,
+                'success' => $result['success'] ?? false,
+                'success_count' => $result['success_count'] ?? $successCount,
+                'error_count' => $result['error_count'] ?? $errorCount,
+                'errors' => array_merge($errors, $result['errors'] ?? []),
+                'message' => $result['message'] ?? null,
             ];
 
         } catch (\Exception $e) {

+ 385 - 0
app/Services/KnowledgeMasteryService.php

@@ -0,0 +1,385 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cache;
+
+/**
+ * 知识点掌握情况服务
+ *
+ * 提供:
+ * 1. 获取学生知识点掌握情况统计
+ * 2. 获取知识点图谱数据(考试快照)
+ * 3. 获取知识点图谱快照列表
+ */
+class KnowledgeMasteryService
+{
+    protected string $learningAnalyticsBase;
+    protected string $knowledgeServiceBase;
+    protected int $timeout;
+
+    public function __construct(?string $learningAnalyticsBase = null, ?string $knowledgeServiceBase = null, ?int $timeout = null)
+    {
+        $this->learningAnalyticsBase = rtrim(
+            $learningAnalyticsBase
+                ?: config('services.learning_analytics.url', env('LEARNING_ANALYTICS_API_BASE', 'http://localhost:5016')),
+            '/'
+        );
+
+        $this->knowledgeServiceBase = rtrim(
+            $knowledgeServiceBase
+                ?: config('services.knowledge_service.url', env('KNOWLEDGE_SERVICE_API_BASE', 'http://localhost:5011')),
+            '/'
+        );
+
+        $this->timeout = $timeout ?? (int) config('services.learning_analytics.timeout', 20);
+    }
+
+    /**
+     * 获取学生知识点掌握情况统计
+     *
+     * @param string $studentId 学生ID
+     * @return array
+     */
+    public function getStats(string $studentId): array
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/knowledge-mastery/stats/' . $studentId);
+
+            if ($response->successful()) {
+                $body = $response->json();
+
+                // 丰富知识点名称
+                $body = $this->enrichWithKnowledgePointNames($body);
+
+                // 添加知识图谱总数统计
+                $graphStats = $this->getKnowledgeGraphStats();
+                $body['graph_total_knowledge_points'] = $graphStats['total'] ?? 0;
+
+                Log::info('KnowledgeMasteryService::getStats', ['student_id' => $studentId]);
+                return [
+                    'success' => true,
+                    'data' => $body,
+                ];
+            }
+
+            Log::warning('Knowledge mastery stats request failed', [
+                'student_id' => $studentId,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点掌握情况失败: ' . $response->status(),
+            ];
+        } catch (\Throwable $e) {
+            Log::error('Knowledge mastery stats exception', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点掌握情况异常: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 丰富知识点名称
+     */
+    private function enrichWithKnowledgePointNames(array $data): array
+    {
+        if (empty($data['details'])) {
+            return $data;
+        }
+
+        // 收集所有kp_code
+        $kpCodes = array_column($data['details'], 'kp_code');
+        if (empty($kpCodes)) {
+            return $data;
+        }
+
+        // 批量获取知识点名称
+        $kpNames = $this->getKnowledgePointNames($kpCodes);
+
+        // 丰富details数据
+        foreach ($data['details'] as &$detail) {
+            $kpCode = $detail['kp_code'] ?? null;
+            if ($kpCode && isset($kpNames[$kpCode])) {
+                $detail['kp_name'] = $kpNames[$kpCode];
+            } else {
+                $detail['kp_name'] = $kpCode; // fallback to code
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * 批量获取知识点名称
+     */
+    private function getKnowledgePointNames(array $kpCodes): array
+    {
+        $result = [];
+
+        foreach ($kpCodes as $kpCode) {
+            $name = $this->getKnowledgePointName($kpCode);
+            if ($name) {
+                $result[$kpCode] = $name;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * 获取单个知识点名称(带缓存)
+     */
+    private function getKnowledgePointName(string $kpCode): ?string
+    {
+        $cacheKey = "kp_name_{$kpCode}";
+
+        return Cache::remember($cacheKey, 3600, function () use ($kpCode) {
+            try {
+                $response = Http::timeout(5)
+                    ->get($this->knowledgeServiceBase . '/knowledge-points/' . $kpCode);
+
+                if ($response->successful()) {
+                    $data = $response->json();
+                    return $data['cn_name'] ?? $data['en_name'] ?? null;
+                }
+            } catch (\Throwable $e) {
+                Log::debug('Failed to get knowledge point name', [
+                    'kp_code' => $kpCode,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+
+            return null;
+        });
+    }
+
+    /**
+     * 获取知识图谱统计信息(带缓存)
+     */
+    public function getKnowledgeGraphStats(): array
+    {
+        return Cache::remember('knowledge_graph_stats', 3600, function () {
+            try {
+                $response = Http::timeout(10)
+                    ->get($this->knowledgeServiceBase . '/knowledge-points/');
+
+                if ($response->successful()) {
+                    $data = $response->json();
+                    $items = $data['data'] ?? $data ?? [];
+
+                    return [
+                        'total' => count($items),
+                        'updated_at' => now()->toISOString(),
+                    ];
+                }
+            } catch (\Throwable $e) {
+                Log::error('Failed to get knowledge graph stats', [
+                    'error' => $e->getMessage(),
+                ]);
+            }
+
+            return ['total' => 0];
+        });
+    }
+
+    /**
+     * 获取学生知识点图谱数据
+     *
+     * @param string $studentId 学生ID
+     * @param string|null $examId 考试ID(可选,不指定则返回最新快照)
+     * @return array
+     */
+    public function getGraph(string $studentId, ?string $examId = null): array
+    {
+        try {
+            $query = array_filter(['exam_id' => $examId], fn($v) => filled($v));
+
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/knowledge-mastery/graph/' . $studentId, $query);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                Log::info('KnowledgeMasteryService::getGraph', ['student_id' => $studentId, 'exam_id' => $examId]);
+                return [
+                    'success' => true,
+                    'data' => $body,
+                ];
+            }
+
+            Log::warning('Knowledge graph request failed', [
+                'student_id' => $studentId,
+                'exam_id' => $examId,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点图谱失败: ' . $response->status(),
+            ];
+        } catch (\Throwable $e) {
+            Log::error('Knowledge graph exception', [
+                'student_id' => $studentId,
+                'exam_id' => $examId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点图谱异常: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 获取学生知识点图谱快照列表
+     *
+     * @param string $studentId 学生ID
+     * @param int $limit 返回数量限制
+     * @return array
+     */
+    public function getGraphSnapshots(string $studentId, int $limit = 10): array
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/knowledge-mastery/graph/snapshots/' . $studentId, [
+                    'limit' => $limit,
+                ]);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                Log::info('KnowledgeMasteryService::getGraphSnapshots', ['student_id' => $studentId, 'limit' => $limit]);
+                return [
+                    'success' => true,
+                    'data' => $body,
+                ];
+            }
+
+            Log::warning('Knowledge graph snapshots request failed', [
+                'student_id' => $studentId,
+                'limit' => $limit,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点图谱快照列表失败: ' . $response->status(),
+            ];
+        } catch (\Throwable $e) {
+            Log::error('Knowledge graph snapshots exception', [
+                'student_id' => $studentId,
+                'limit' => $limit,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '获取知识点图谱快照列表异常: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 获取学生知识点掌握摘要(简化版)
+     *
+     * @param string $studentId 学生ID
+     * @return array
+     */
+    public function getSummary(string $studentId): array
+    {
+        $stats = $this->getStats($studentId);
+
+        if (!$stats['success']) {
+            return $stats;
+        }
+
+        $data = $stats['data'];
+
+        return [
+            'success' => true,
+            'data' => [
+                'student_id' => $data['student_id'] ?? $studentId,
+                'total' => $data['total_knowledge_points'] ?? 0,
+                'mastered' => $data['mastered_knowledge_points'] ?? 0,
+                'unmastered' => $data['unmastered_knowledge_points'] ?? 0,
+                'mastery_rate' => $data['mastery_rate'] ?? 0.0,
+                'mastery_percentage' => round(($data['mastery_rate'] ?? 0) * 100, 1) . '%',
+                'graph_total' => $data['graph_total_knowledge_points'] ?? 0,
+            ],
+        ];
+    }
+
+    /**
+     * 创建知识点掌握度快照
+     *
+     * @param string $studentId 学生ID
+     * @param string $snapshotType 快照类型 (exam/report/manual/scheduled)
+     * @param string|null $sourceId 来源ID
+     * @param string|null $sourceName 来源名称
+     * @param string|null $notes 备注
+     * @return array
+     */
+    public function createSnapshot(
+        string $studentId,
+        string $snapshotType = 'report',
+        ?string $sourceId = null,
+        ?string $sourceName = null,
+        ?string $notes = null
+    ): array {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->post($this->learningAnalyticsBase . '/api/knowledge-mastery/snapshot/' . $studentId, [
+                    'snapshot_type' => $snapshotType,
+                    'source_id' => $sourceId,
+                    'source_name' => $sourceName,
+                    'notes' => $notes,
+                ]);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                Log::info('KnowledgeMasteryService::createSnapshot', [
+                    'student_id' => $studentId,
+                    'snapshot_type' => $snapshotType,
+                    'snapshot_id' => $body['data']['snapshot_id'] ?? null,
+                ]);
+                return [
+                    'success' => true,
+                    'data' => $body['data'] ?? $body,
+                ];
+            }
+
+            Log::warning('Create knowledge mastery snapshot failed', [
+                'student_id' => $studentId,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '创建知识点掌握度快照失败: ' . $response->status(),
+            ];
+        } catch (\Throwable $e) {
+            Log::error('Create knowledge mastery snapshot exception', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => '创建知识点掌握度快照异常: ' . $e->getMessage(),
+            ];
+        }
+    }
+}

+ 93 - 30
app/Services/LearningAnalyticsService.php

@@ -1213,6 +1213,9 @@ class LearningAnalyticsService
      */
     public function generateIntelligentExam(array $params): array
     {
+        $logFile = __DIR__ . '/../../../../learning_analytics.log';
+        $startTime = microtime(true);
+
         try {
             $studentId = $params['student_id'] ?? null;
             $grade = $params['grade'] ?? null; // 用户选择的年级
@@ -1232,28 +1235,53 @@ class LearningAnalyticsService
             $difficultyLevels = $params['difficulty_levels'] ?? [];
             // 如果用户没有选择任何难度,difficultyLevels 为空数组,表示随机难度
 
+            $logMsg = "=== " . date('Y-m-d H:i:s') . " ===\n";
+            $logMsg .= "generateIntelligentExam 开始\n";
+            $logMsg .= "student_id: $studentId\n";
+            $logMsg .= "total_questions: $totalQuestions\n";
+            $logMsg .= "kp_codes: " . json_encode($kpCodes) . "\n";
+            $logMsg .= "skills: " . json_encode($skills) . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
+
             // 1. 如果指定了学生,获取学生的薄弱点
             $weaknessFilter = [];
             if ($studentId) {
+                $logMsg = "获取学生薄弱点: $studentId\n";
+                file_put_contents($logFile, $logMsg, FILE_APPEND);
+
                 $weaknesses = $this->getStudentWeaknesses($studentId, 20);
+                $logMsg = "薄弱点数量: " . count($weaknesses) . "\n";
+                $logMsg .= "薄弱点: " . json_encode($weaknesses) . "\n\n";
+                file_put_contents($logFile, $logMsg, FILE_APPEND);
+
                 $weaknessFilter = array_column($weaknesses, 'kp_code');
 
                 // 如果用户没有指定知识点,使用学生的薄弱点
                 if (empty($kpCodes)) {
                     $kpCodes = $weaknessFilter;
+                    $logMsg = "用户未选择知识点,使用薄弱点作为kp_codes\n";
+                    $logMsg .= "最终kp_codes: " . json_encode($kpCodes) . "\n\n";
+                    file_put_contents($logFile, $logMsg, FILE_APPEND);
                 }
             }
 
-            // 如果没有指定知识点,直接从题库随机选择题目(不限制知识点)
-            // 这样可以确保总能找到题目,而不是依赖知识图谱中的知识点
+            $logMsg = "准备调用 getQuestionsFromBank\n";
+            $logMsg .= "kp_codes: " . json_encode($kpCodes) . "\n";
+            $logMsg .= "skills: " . json_encode($skills) . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
+
+            // 2. 调用题库API获取符合条件的所有题目
+            $allQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $difficultyRatio, 200);
 
-        // 2. 调用题库API获取符合条件的所有题目
-        $allQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $difficultyRatio, 200);
+            $logMsg = "getQuestionsFromBank 返回\n";
+            $logMsg .= "questions_count: " . count($allQuestions) . "\n";
+            $logMsg .= "耗时: " . round((microtime(true) - $startTime) * 1000, 2) . "ms\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
 
             if (empty($allQuestions)) {
                 // 如果指定了知识点但题库为空,给出明确提示
                 if (!empty($kpCodes)) {
-                    $message = '题库中暂无可用题目。您可以选择其他知识点,或点击"生成练习题"按钮先补充题库。';
+                    $message = '所选知识点 [' . implode(', ', $kpCodes) . '] 在题库中暂无可用题目。您可以:1) 选择其他知识点,2) 点击"生成练习题"按钮先补充题库,或 3) 取消知识点选择让系统随机选题。';
                 } else {
                     // 没有选择知识点时,从所有题目中选择
                     // 如果仍然没有题目,说明题库为空,提示补充题库
@@ -1263,7 +1291,9 @@ class LearningAnalyticsService
                 Log::warning('智能出卷失败 - 未找到题目', [
                     'student_id' => $studentId,
                     'selected_kp_codes' => $kpCodes,
-                    'message' => $message
+                    'kp_codes_count' => count($kpCodes),
+                    'message' => $message,
+                    'hint' => '如果选择了知识点但题库为空,请检查知识点代码是否正确,或尝试取消知识点选择'
                 ]);
 
                 return [
@@ -1274,15 +1304,21 @@ class LearningAnalyticsService
             }
 
             // 3. 根据掌握度对题目进行筛选和排序
-        $selectedQuestions = $this->selectQuestionsByMastery(
-            $allQuestions,
-            $studentId,
-            $totalQuestions,
-            $questionTypeRatio,
-            $difficultyRatio,
-            $difficultyLevels,
-            $weaknessFilter
-        );
+            $selectedQuestions = $this->selectQuestionsByMastery(
+                $allQuestions,
+                $studentId,
+                $totalQuestions,
+                $questionTypeRatio,
+                $difficultyRatio,
+                $difficultyLevels,
+                $weaknessFilter
+            );
+
+            Log::info('题目筛选结果', [
+                'input_count' => count($allQuestions),
+                'selected_count' => count($selectedQuestions),
+                'target_count' => $totalQuestions
+            ]);
 
             if (empty($selectedQuestions)) {
                 return [
@@ -1363,7 +1399,12 @@ class LearningAnalyticsService
             if (!empty($questions)) {
                 // 过滤掉没有解题思路的题目
                 $questionsWithSolution = array_filter($questions, function($q) {
-                    return !empty(trim($q['solution'] ?? ''));
+                    $solution = $q['solution'] ?? '';
+                    // 处理 solution 可能是数组的情况
+                    if (is_array($solution)) {
+                        $solution = json_encode($solution, JSON_UNESCAPED_UNICODE);
+                    }
+                    return !empty(trim($solution));
                 });
 
                 Log::info('从题库智能获取题目', [
@@ -1502,6 +1543,12 @@ class LearningAnalyticsService
      */
     private function adjustQuestionsByRatio(array $questions, array $typeRatio, array $difficultyRatio, int $targetCount): array
     {
+        Log::info('开始题型配比调整', [
+            'input_questions' => count($questions),
+            'target_count' => $targetCount,
+            'type_ratio' => $typeRatio
+        ]);
+
         // 按题型分桶
         $buckets = [
             'choice' => [],
@@ -1587,6 +1634,7 @@ class LearningAnalyticsService
                 'fill' => count(array_filter($selected, fn($q) => $this->determineQuestionType($q) === 'fill')),
                 'answer' => count(array_filter($selected, fn($q) => $this->determineQuestionType($q) === 'answer')),
             ],
+            'final_selected_count' => count($selected)
         ]);
 
         return $selected;
@@ -1596,7 +1644,15 @@ class LearningAnalyticsService
     {
         // 优先根据题目内容判断(而不是数据库字段)
         $stem = $q['stem'] ?? $q['content'] ?? '';
+        // 处理 stem 可能是数组的情况
+        if (is_array($stem)) {
+            $stem = json_encode($stem, JSON_UNESCAPED_UNICODE);
+        }
         $tags = $q['tags'] ?? '';
+        // 处理 tags 可能是数组的情况
+        if (is_array($tags)) {
+            $tags = json_encode($tags, JSON_UNESCAPED_UNICODE);
+        }
         $skills = $q['skills'] ?? [];
 
         // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
@@ -1621,23 +1677,30 @@ class LearningAnalyticsService
         }
 
         // 2. 根据技能点判断
-        if (is_array($skills)) {
-            $skillsStr = implode(',', $skills);
-            if (strpos($skillsStr, '选择题') !== false) return 'choice';
-            if (strpos($skillsStr, '填空题') !== false) return 'fill';
-            if (strpos($skillsStr, '解答题') !== false) return 'answer';
+        if (is_array($skills) && !empty($skills)) {
+            // 过滤非字符串元素,避免 implode 报错
+            $skillsFiltered = array_filter($skills, 'is_string');
+            if (!empty($skillsFiltered)) {
+                $skillsStr = implode(',', $skillsFiltered);
+                if (strpos($skillsStr, '选择题') !== false) return 'choice';
+                if (strpos($skillsStr, '填空题') !== false) return 'fill';
+                if (strpos($skillsStr, '解答题') !== false) return 'answer';
+            }
         }
 
         // 3. 根据题目已有类型字段判断(作为后备)
-        $t = strtolower($q['question_type'] ?? $q['type'] ?? '');
-        if (in_array($t, ['choice', 'single_choice', 'multiple_choice', '选择题'])) {
-            return 'choice';
-        }
-        if (in_array($t, ['fill', 'blank', 'fill_blank', '填空题'])) {
-            return 'fill';
-        }
-        if (in_array($t, ['answer', 'calculation', '解答题'])) {
-            return 'answer';
+        $typeField = $q['question_type'] ?? $q['type'] ?? '';
+        if (is_string($typeField)) {
+            $t = strtolower($typeField);
+            if (in_array($t, ['choice', 'single_choice', 'multiple_choice', '选择题'])) {
+                return 'choice';
+            }
+            if (in_array($t, ['fill', 'blank', 'fill_blank', '填空题'])) {
+                return 'fill';
+            }
+            if (in_array($t, ['answer', 'calculation', '解答题'])) {
+                return 'answer';
+            }
         }
 
         // 4. 根据标签判断

+ 107 - 0
app/Services/PaperPartExtractorService.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PaperPart;
+use App\Models\SourcePaper;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+class PaperPartExtractorService
+{
+    /**
+     * 基于卷子 Markdown 拆分题型区块。
+     */
+    public function extract(SourcePaper $paper): Collection
+    {
+        $parts = $this->splitIntoParts($paper->raw_markdown);
+
+        return DB::transaction(function () use ($paper, $parts) {
+            $paper->parts()->delete();
+
+            $result = collect();
+            foreach ($parts as $idx => $part) {
+                $result->push(PaperPart::create([
+                    'source_paper_id' => $paper->id,
+                    'order' => $idx + 1,
+                    'title' => $part['title'] ?? null,
+                    'type' => $part['type'] ?? null,
+                    'raw_markdown' => $part['raw'],
+                    'question_count' => $part['question_count'] ?? null,
+                    'detected_features' => $part['detected_features'] ?? [],
+                ]));
+            }
+
+            return $result;
+        });
+    }
+
+    public function splitIntoParts(string $markdown): array
+    {
+        $lines = preg_split('/\r\n|\r|\n/', $markdown);
+        $segments = [];
+        $current = ['title' => null, 'buffer' => []];
+
+        $partPattern = '/^(#{2,3})\s*(第? ?[一二三四五六七八九十0-9IVX]+[部分卷]|选择题|填空题|解答题|综合题|计算题|应用题)/u';
+
+        foreach ($lines as $line) {
+            if (preg_match($partPattern, $line, $m)) {
+                if (!empty($current['buffer'])) {
+                    $segments[] = $this->finalizeSegment($current);
+                }
+                $current = [
+                    'title' => trim($m[0], "# \t"),
+                    'buffer' => [$line],
+                ];
+            } else {
+                $current['buffer'][] = $line;
+            }
+        }
+
+        if (!empty($current['buffer'])) {
+            $segments[] = $this->finalizeSegment($current);
+        }
+
+        if (empty($segments)) {
+            return [[
+                'title' => null,
+                'type' => 'mixed',
+                'raw' => trim($markdown),
+                'detected_features' => [],
+            ]];
+        }
+
+        return $segments;
+    }
+
+    protected function finalizeSegment(array $segment): array
+    {
+        $raw = trim(implode("\n", $segment['buffer']));
+        $title = $segment['title'];
+
+        return [
+            'title' => $title,
+            'type' => $this->detectType($title),
+            'raw' => $raw,
+            'detected_features' => [
+                'title' => $title,
+            ],
+        ];
+    }
+
+    protected function detectType(?string $title): ?string
+    {
+        if (!$title) {
+            return null;
+        }
+
+        return match (true) {
+            Str::contains($title, '选择') => 'choice',
+            Str::contains($title, '填空') => 'fill',
+            Str::contains($title, ['解答', '简答', '分析']) => 'short',
+            Str::contains($title, ['计算', '推导']) => 'calc',
+            default => 'mixed',
+        };
+    }
+}

+ 60 - 20
app/Services/QuestionBankService.php

@@ -415,26 +415,53 @@ class QuestionBankService
      */
     public function selectQuestionsForExam(int $totalQuestions, array $filters): array
     {
+        $logFile = __DIR__ . '/../../../../select_questions.log';
+        $startTime = microtime(true);
+
         try {
+            $requestData = [
+                'total_questions' => $totalQuestions,
+                'filters' => $filters
+            ];
+
+            $logMsg = "=== " . date('Y-m-d H:i:s') . " ===\n";
+            $logMsg .= "开始调用 selectQuestionsForExam\n";
+            $logMsg .= "baseUrl: " . $this->baseUrl . "\n";
+            $logMsg .= "totalQuestions: $totalQuestions\n";
+            $logMsg .= "filters: " . json_encode($filters) . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
+
             $response = Http::timeout(30)
-                ->post($this->baseUrl . '/exam/select-questions', [
-                    'total_questions' => $totalQuestions,
-                    'filters' => $filters
-                ]);
+                ->post($this->baseUrl . '/exam/select-questions', $requestData);
+
+            $logMsg = "API响应:\n";
+            $logMsg .= "  status: " . $response->status() . "\n";
+            $logMsg .= "  successful: " . ($response->successful() ? 'true' : 'false') . "\n";
+            $logMsg .= "  body: " . $response->body() . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
 
             if ($response->successful()) {
-                return $response->json('data', []);
+                $data = $response->json('data', []);
+                $logMsg = "成功解析JSON:\n";
+                $logMsg .= "  data字段题目数量: " . count($data) . "\n";
+                $logMsg .= "  耗时: " . round((microtime(true) - $startTime) * 1000, 2) . "ms\n\n";
+                file_put_contents($logFile, $logMsg, FILE_APPEND);
+                return $data;
             }
 
-            Log::warning('智能选题API调用失败', [
-                'status' => $response->status()
-            ]);
+            $logMsg = "API调用失败! 状态码: " . $response->status() . "\n";
+            $logMsg .= "响应内容: " . $response->body() . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
         } catch (\Exception $e) {
-            Log::error('智能选题异常', [
-                'error' => $e->getMessage()
-            ]);
+            $logMsg = "异常: " . $e->getMessage() . "\n";
+            $logMsg .= "堆栈: " . $e->getTraceAsString() . "\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
         }
 
+        $logMsg = "返回空数组\n";
+        $logMsg .= "=== 结束 ===\n\n";
+        file_put_contents($logFile, $logMsg, FILE_APPEND);
+
         return [];
     }
 
@@ -443,20 +470,33 @@ class QuestionBankService
      */
     public function saveExamToDatabase(array $examData): ?string
     {
+        $logFile = __DIR__ . '/../../../../save_exam.log';
+        $logMsg = "=== " . date('Y-m-d H:i:s') . " ===\n";
+        $logMsg .= "saveExamToDatabase 被调用\n";
+        $logMsg .= "questions_count: " . count($examData['questions'] ?? []) . "\n";
+        $logMsg .= "paper_name: " . ($examData['paper_name'] ?? 'N/A') . "\n";
+        $logMsg .= "student_id: " . ($examData['student_id'] ?? 'N/A') . "\n";
+        if (!empty($examData['questions'])) {
+            $logMsg .= "first_question_id: " . ($examData['questions'][0]['id'] ?? 'N/A') . "\n";
+        }
+        file_put_contents($logFile, $logMsg, FILE_APPEND);
+
         // 数据完整性检查
         if (empty($examData['questions'])) {
-            Log::warning('尝试保存没有题目的试卷', [
-                'paper_name' => $examData['paper_name'] ?? '未命名试卷',
-                'student_id' => $examData['student_id'] ?? 'unknown'
-            ]);
+            $logMsg = "❌ 题目为空,返回null!\n";
+            $logMsg .= "这是导致生成demo ID的原因!\n\n";
+            file_put_contents($logFile, $logMsg, FILE_APPEND);
             return null;
         }
 
         try {
             // 使用数据库事务确保数据一致性
             return \Illuminate\Support\Facades\DB::transaction(function () use ($examData) {
-                // 生成试卷ID
-                $paperId = 'paper_' . time() . '_' . bin2hex(random_bytes(4));
+                // 生成12位唯一数字ID(时间戳后8位 + 4位随机数)
+                $timestamp = substr((string)time(), -8); // 取时间戳后8位
+                $random = str_pad((string)random_int(0, 9999), 4, '0', STR_PAD_LEFT); // 4位随机数
+                $uniqueId = $timestamp . $random; // 12位唯一数字
+                $paperId = 'paper_' . $uniqueId;
 
                 Log::info('开始保存试卷到数据库', [
                     'paper_id' => $paperId,
@@ -605,9 +645,9 @@ class QuestionBankService
                         'question_bank_id' => $question['id'] ?? $question['question_id'] ?? 0,
                         'knowledge_point' => $knowledgePoint,
                         'question_type' => $questionType,
-                        'question_text' => $question['stem'] ?? $question['content'] ?? $question['question_text'] ?? '',
-                        'correct_answer' => $correctAnswer,  // 保存正确答案
-                        'solution' => $question['solution'] ?? '',  // 保存解题思路
+                        'question_text' => is_array($question['stem'] ?? null) ? json_encode($question['stem'], JSON_UNESCAPED_UNICODE) : ($question['stem'] ?? $question['content'] ?? $question['question_text'] ?? ''),
+                        'correct_answer' => is_array($correctAnswer) ? json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) : $correctAnswer,  // 保存正确答案
+                        'solution' => is_array($question['solution'] ?? null) ? json_encode($question['solution'], JSON_UNESCAPED_UNICODE) : ($question['solution'] ?? ''),  // 保存解题思路
                         'difficulty' => $difficultyValue,
                         'score' => $question['score'] ?? 5, // 默认5分
                         'estimated_time' => $question['estimated_time'] ?? 300,

+ 51 - 0
app/Services/QuestionBankSyncService.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PreQuestionCandidate;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class QuestionBankSyncService
+{
+    public function pushCandidate(PreQuestionCandidate $candidate): bool
+    {
+        if (!$candidate->structured_json) {
+            return false;
+        }
+
+        $payload = [
+            'candidate_id' => $candidate->id,
+            'source_file_id' => $candidate->source_file_id,
+            'source_paper_id' => $candidate->source_paper_id,
+            'part_id' => $candidate->part_id,
+            'question_number' => $candidate->question_number ?? $candidate->index,
+            'structured' => json_decode($candidate->structured_json, true),
+        ];
+
+        $base = rtrim(config('services.question_bank.base_url'), '/');
+        $endpoint = $base . '/ingest/from-filament';
+
+        try {
+            $response = Http::timeout(config('services.question_bank.timeout', 60))
+                ->post($endpoint, $payload);
+
+            if ($response->failed()) {
+                Log::warning('Question bank sync failed', [
+                    'candidate_id' => $candidate->id,
+                    'status' => $response->status(),
+                    'body' => $response->body(),
+                ]);
+                return false;
+            }
+
+            return true;
+        } catch (\Throwable $e) {
+            Log::error('Question bank sync error', [
+                'candidate_id' => $candidate->id,
+                'error' => $e->getMessage(),
+            ]);
+            return false;
+        }
+    }
+}

+ 173 - 0
app/Services/QuestionExtractorService.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\MarkdownImport;
+use App\Models\PaperPart;
+use App\Models\PaperQuestionRef;
+use App\Models\PreQuestionCandidate;
+use App\Models\SourcePaper;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+class QuestionExtractorService
+{
+    /**
+     * 使用严格题号正则拆分题目,写入候选表与绑定表。
+     */
+    public function extractAndPersist(PaperPart $part, ?MarkdownImport $import = null, int &$sequenceStart = 1): Collection
+    {
+        $paper = $part->paper;
+        $file = $paper->file;
+        $import = $import ?? $this->ensureSyntheticImport($paper);
+
+        $blocks = app(\App\Services\AsyncMarkdownSplitter::class)->split($part->raw_markdown);
+
+        return DB::transaction(function () use ($blocks, $part, $paper, $file, $import, &$sequenceStart) {
+            $created = collect();
+
+            foreach ($blocks as $i => $block) {
+                $questionNumber = (string) ($block['question_number'] ?? ($i + 1));
+                $order = $i + 1;
+                $raw = $block['raw_markdown'];
+                $clean = $this->cleanMarkdown($raw);
+                $sequence = $sequenceStart++;
+                $reuse = $this->findReusableCandidate($raw, $clean);
+                $reuseConfidence = $reuse?->confidence ?? $reuse?->ai_confidence;
+
+                $candidate = PreQuestionCandidate::updateOrCreate(
+                    [
+                        'import_id' => $import->id,
+                        'sequence' => $sequence,
+                    ],
+                    [
+                        'source_file_id' => $file?->id,
+                        'source_paper_id' => $paper?->id,
+                        'part_id' => $part?->id,
+                        'index' => (int) $questionNumber,
+                        'question_number' => $questionNumber,
+                        'order' => $order,
+                        'sequence' => $sequence,
+                        'raw_markdown' => $raw,
+                        'clean_markdown' => $clean,
+                        'structured_json' => $reuse?->structured_json,
+                        'stem' => $reuse?->stem,
+                        'options' => $reuse?->options,
+                        'images' => !empty($reuse?->images) ? $reuse->images : ($block['images'] ?? []),
+                        'tables' => !empty($reuse?->tables) ? $reuse->tables : ($block['tables'] ?? []),
+                        'is_question_candidate' => $reuse?->is_question_candidate ?? true,
+                        'ai_confidence' => $reuse?->ai_confidence,
+                        'confidence' => $reuseConfidence,
+                        'formula_detected' => $this->detectFormula($raw),
+                        'is_valid_question' => $reuse?->is_valid_question ?? true,
+                        'status' => PreQuestionCandidate::STATUS_PENDING,
+                    ]
+                );
+
+                PaperQuestionRef::updateOrCreate(
+                    [
+                        'source_paper_id' => $paper->id,
+                        'part_id' => $part->id,
+                        'candidate_id' => $candidate->id,
+                    ],
+                    [
+                        'question_number' => $questionNumber,
+                        'order' => $order,
+                        'raw_markdown' => $raw,
+                        'metadata' => [
+                            'source_file_uuid' => $file?->uuid,
+                            'paper_uuid' => $paper?->uuid,
+                            'part_order' => $part?->order,
+                        ],
+                    ]
+                );
+
+                $created->push($candidate);
+            }
+
+            $part->update(['question_count' => $created->count()]);
+
+            return $created;
+        });
+    }
+
+    /**
+     * 严格按题号拆分,题号正则:^\s*(\d+)(\.|、|\)|)|\]|】)?\s+
+     */
+    public function splitQuestions(string $markdown): array
+    {
+        $pattern = '/^\s*(\d+)(?:[\\.、\\))\\]】])?\s+/m';
+        preg_match_all($pattern, $markdown, $matches, PREG_OFFSET_CAPTURE);
+
+        if (empty($matches[0])) {
+            return [[
+                'question_number' => 1,
+                'raw_markdown' => trim($markdown),
+            ]];
+        }
+
+        $blocks = [];
+        $positions = array_map(fn($m) => $m[1], $matches[0]);
+        $numbers = array_map(fn($m) => $m[0], $matches[1]);
+
+        foreach ($positions as $idx => $start) {
+            $end = $positions[$idx + 1] ?? strlen($markdown);
+            $slice = substr($markdown, $start, $end - $start);
+            $blocks[] = [
+                'question_number' => $numbers[$idx] ?? ($idx + 1),
+                'raw_markdown' => trim($slice),
+            ];
+        }
+
+        return $blocks;
+    }
+
+    protected function detectFormula(string $markdown): bool
+    {
+        return (bool) preg_match('/\\$\\$.*?\\$\\$|\\$[^$]+\\$|\\\\frac|\\\\sum|\\\\int|\\\\sqrt/u', $markdown);
+    }
+
+    protected function cleanMarkdown(string $markdown): string
+    {
+        return trim(Str::of($markdown)->replace("\r", '')->toString());
+    }
+
+    /**
+     * 查找可复用的高置信度候选题,避免重复 AI 解析。
+     */
+    protected function findReusableCandidate(string $raw, string $clean): ?PreQuestionCandidate
+    {
+        return PreQuestionCandidate::query()
+            ->where(function ($query) use ($raw, $clean) {
+                $query->where('raw_markdown', $raw)
+                    ->orWhere('clean_markdown', $clean);
+            })
+            ->where(function ($query) {
+                $query->where('confidence', '>=', 0.85)
+                    ->orWhere('ai_confidence', '>=', 0.85);
+            })
+            ->orderByDesc('confidence')
+            ->orderByDesc('ai_confidence')
+            ->first();
+    }
+
+    protected function ensureSyntheticImport(SourcePaper $paper): MarkdownImport
+    {
+        return MarkdownImport::firstOrCreate(
+            [
+                'file_name' => $paper->file?->original_filename ?? ('paper-' . $paper->id),
+                'source_name' => $paper->title,
+            ],
+            [
+                'original_markdown' => $paper->raw_markdown,
+                'status' => MarkdownImport::STATUS_PARSED,
+                'progress_stage' => MarkdownImport::STAGE_PARSED,
+                'progress_message' => 'Auto generated from source_papers',
+                'progress_current' => 0,
+                'progress_total' => 0,
+                'progress_updated_at' => now(),
+            ]
+        );
+    }
+}

+ 158 - 0
app/Services/SourceFileParserService.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\MarkdownImport;
+use App\Models\SourceFile;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+class SourceFileParserService
+{
+    public function storeFromMarkdown(
+        string $filename,
+        string $rawMarkdown,
+        ?MarkdownImport $import = null,
+        array $fileMetadata = [],
+        ?string $storagePath = null
+    ): SourceFile {
+        $normalized = $this->normalizeFilename($filename);
+        $extension = pathinfo($filename, PATHINFO_EXTENSION) ?: null;
+        $extracted = $this->extractMetadataFromFilename($filename);
+        $rawHash = sha1($rawMarkdown);
+
+        // 去重:同名且内容一致时直接复用,避免重复 source_file
+        $existing = SourceFile::query()
+            ->where('normalized_filename', $normalized)
+            ->orWhere('original_filename', $filename)
+            ->get()
+            ->first(function (SourceFile $file) use ($rawHash) {
+                $oldHash = sha1((string) $file->raw_markdown);
+                return $oldHash === $rawHash;
+            });
+
+        if ($existing) {
+            // 也把 hash 写入已存在记录的元数据,方便后续识别
+            $meta = $existing->extracted_metadata ?? [];
+            if (empty($meta['raw_hash'])) {
+                $meta['raw_hash'] = $rawHash;
+                $existing->update(['extracted_metadata' => $meta]);
+            }
+
+            if ($import) {
+                $import->update([
+                    'source_name' => $existing->normalized_filename,
+                    'file_name' => $existing->original_filename,
+                ]);
+            }
+
+            return $existing;
+        }
+
+        return DB::transaction(function () use ($filename, $normalized, $extension, $storagePath, $rawMarkdown, $fileMetadata, $extracted, $import) {
+            $sourceFile = SourceFile::create([
+                'uuid' => (string) Str::uuid(),
+                'original_filename' => $filename,
+                'normalized_filename' => $normalized,
+                'extension' => $extension,
+                'storage_path' => $storagePath,
+                'raw_markdown' => $rawMarkdown,
+                'file_metadata' => $fileMetadata,
+                'extracted_metadata' => array_merge($extracted, [
+                    'raw_hash' => sha1($rawMarkdown),
+                ]),
+            ]);
+
+            if ($import) {
+                $import->update([
+                    'source_name' => $normalized,
+                    'file_name' => $filename,
+                ]);
+            }
+
+            return $sourceFile;
+        });
+    }
+
+    /**
+     * 从文件名提取教材信息(版别、年级、学期、章节)。
+     */
+    public function extractMetadataFromFilename(string $filename): array
+    {
+        $info = [];
+        $basename = str_replace([' ', ' '], '', pathinfo($filename, PATHINFO_FILENAME));
+
+        // 统一下划线为分隔符,便于模式匹配
+        $normalized = str_replace(['-', '(', ')', '(', ')'], '_', $basename);
+
+        $editionPatterns = [
+            '人教' => 'PEP',
+            '苏教' => 'SJ',
+            '浙教' => 'ZJ',
+            '北师' => 'BS',
+            '沪教' => 'HJ',
+            '北师大' => 'BS',
+            '北师大版' => 'BS',
+        ];
+
+        foreach ($editionPatterns as $key => $code) {
+            if (Str::contains($normalized, $key)) {
+                $info['edition'] = $code;
+                break;
+            }
+        }
+
+        // 年级:支持中文数字与阿拉伯数字
+        if (preg_match('/高[一二三]|高[123]/u', $normalized, $m)) {
+            $info['grade'] = $m[0];
+        } elseif (preg_match('/初[一二三]|初[123]|初中/u', $normalized, $m)) {
+            $info['grade'] = $m[0];
+        } elseif (preg_match('/([1-9])[_年]?\s*年级?/u', $normalized, $m)) {
+            $num = (int) ($m[1] ?? 0);
+            if ($num > 0) {
+                $info['grade'] = $num . '年级';
+            }
+        } elseif (preg_match('/[一二三四五六七八九]年级/u', $normalized, $m)) {
+            $info['grade'] = $m[0];
+        }
+
+        // 学期/册次:上=1, 下=2,或文件名带 _1/_2;全册=0(便于前端/枚举)
+        if (preg_match('/上册|下册|第[12]学期|第[12]册/u', $normalized, $m)) {
+            $info['term'] = $m[0];
+        } elseif (preg_match('/[_-](1|2)(?:\\.md)?$/', $normalized, $m)) {
+            $info['term'] = $m[1] === '1' ? '上册' : '下册';
+        } elseif (Str::contains($normalized, ['全册', '全集'])) {
+            $info['term'] = '0'; // 全册
+        }
+
+        if (preg_match('/第[一二三四五六七八九十0-9]+章/u', $normalized, $m)) {
+            $info['chapter'] = $m[0];
+        }
+
+        if (preg_match('/20[0-9]{2}/', $basename, $m)) {
+            $info['year'] = $m[0];
+        }
+
+        if (preg_match('/培优|专项|期中|期末|模拟|基础卷|提升卷|练习卷/u', $normalized, $m)) {
+            $info['source_type'] = $m[0];
+        }
+
+        if (Str::contains($normalized, '数学')) {
+            $info['subject'] = '数学';
+        }
+
+        return $info;
+    }
+
+    protected function normalizeFilename(string $filename): string
+    {
+        $basename = pathinfo($filename, PATHINFO_FILENAME);
+        $normalized = Str::of($basename)
+            ->replace(['(', ')', '(', ')', ' '], ['_', '_', '_', '_', '_'])
+            ->slug('_')
+            ->toString();
+
+        return (string) $normalized;
+    }
+}

+ 123 - 0
app/Services/SourcePaperExtractorService.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\SourceFile;
+use App\Models\SourcePaper;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+class SourcePaperExtractorService
+{
+    /**
+     * 从单个 Markdown 文件中切出多套卷子,并持久化。
+     */
+    public function extract(SourceFile $sourceFile): Collection
+    {
+        $segments = $this->splitIntoPapers($sourceFile->raw_markdown);
+
+        return DB::transaction(function () use ($sourceFile, $segments) {
+            $sourceFile->papers()->delete();
+
+            $papers = collect();
+            foreach ($segments as $idx => $segment) {
+                $papers->push(
+                    SourcePaper::create([
+                        'uuid' => (string) Str::uuid(),
+                        'source_file_id' => $sourceFile->id,
+                        'order' => $idx + 1,
+                        'title' => $segment['title'] ?? null,
+                        'full_title' => $segment['full_title'] ?? null,
+                        'chapter' => $segment['chapter'] ?? $sourceFile->extracted_metadata['chapter'] ?? null,
+                        'grade' => $segment['grade'] ?? $sourceFile->extracted_metadata['grade'] ?? null,
+                        'term' => $segment['term'] ?? $sourceFile->extracted_metadata['term'] ?? null,
+                        'edition' => $segment['edition'] ?? $sourceFile->extracted_metadata['edition'] ?? null,
+                        'textbook_series' => $segment['textbook_series'] ?? $sourceFile->extracted_metadata['textbook_series'] ?? null,
+                        'source_type' => $segment['source_type'] ?? null,
+                        'source_year' => $segment['source_year'] ?? $sourceFile->extracted_metadata['year'] ?? null,
+                        'raw_markdown' => $segment['raw'],
+                        'detected_metadata' => $segment['meta'] ?? [],
+                    ])
+                );
+            }
+
+            return $papers;
+        });
+    }
+
+    /**
+     * 基于 Markdown 标题拆分卷子。
+     */
+    public function splitIntoPapers(string $markdown): array
+    {
+        $lines = preg_split('/\r\n|\r|\n/', $markdown);
+        $segments = [];
+        $current = ['title' => null, 'buffer' => []];
+
+        $paperPattern = '/^(#{1,2})\s*(.+卷|期中|期末|专项|模拟|基础卷|提升卷|练习卷)/u';
+
+        foreach ($lines as $line) {
+            if (preg_match($paperPattern, $line, $m)) {
+                if (!empty($current['buffer'])) {
+                    $segments[] = [
+                        'title' => $current['title'],
+                        'full_title' => $current['title'],
+                        'raw' => trim(implode("\n", $current['buffer'])),
+                        'meta' => $this->detectMetaFromTitle($current['title']),
+                    ];
+                }
+                $current = [
+                    'title' => trim($m[2]),
+                    'buffer' => [$line],
+                ];
+            } else {
+                $current['buffer'][] = $line;
+            }
+        }
+
+        if (!empty($current['buffer'])) {
+            $segments[] = [
+                'title' => $current['title'],
+                'full_title' => $current['title'],
+                'raw' => trim(implode("\n", $current['buffer'])),
+                'meta' => $this->detectMetaFromTitle($current['title']),
+            ];
+        }
+
+        if (empty($segments)) {
+            return [[
+                'title' => null,
+                'full_title' => null,
+                'raw' => trim($markdown),
+                'meta' => [],
+            ]];
+        }
+
+        return $segments;
+    }
+
+    protected function detectMetaFromTitle(?string $title): array
+    {
+        if (!$title) {
+            return [];
+        }
+
+        $meta = [];
+        if (preg_match('/第[一二三四五六七八九十0-9]+章/u', $title, $m)) {
+            $meta['chapter'] = $m[0];
+        }
+        if (preg_match('/20[0-9]{2}/', $title, $m)) {
+            $meta['source_year'] = $m[0];
+        }
+        if (Str::contains($title, '期中')) {
+            $meta['source_type'] = 'midterm';
+        } elseif (Str::contains($title, '期末')) {
+            $meta['source_type'] = 'final';
+        } elseif (Str::contains($title, '模拟')) {
+            $meta['source_type'] = 'mock';
+        }
+
+        return $meta;
+    }
+}

+ 179 - 0
app/Services/Storage/ChunsunUploader.php

@@ -0,0 +1,179 @@
+<?php
+
+namespace App\Services\Storage;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+
+class ChunsunUploader
+{
+    /**
+     * 从 URL 上传图片到春笋云
+     */
+    public function uploadFromUrl(string $url): string
+    {
+        try {
+            // 1. 下载图片
+            $response = Http::timeout(30)->get($url);
+
+            if (!$response->successful()) {
+                throw new \Exception("Failed to download image from URL: {$url}");
+            }
+
+            $imageContent = $response->body();
+            $mimeType = $response->header('Content-Type', 'image/jpeg');
+
+            // 2. 创建临时文件
+            $extension = $this->getExtensionFromMimeType($mimeType);
+            $tempFileName = 'temp_' . uniqid() . '.' . $extension;
+            $tempPath = sys_get_temp_dir() . '/' . $tempFileName;
+
+            file_put_contents($tempPath, $imageContent);
+
+            // 3. 上传到春笋云
+            $uploadedUrl = $this->uploadToChunsun($tempPath, $tempFileName);
+
+            // 4. 清理临时文件
+            @unlink($tempPath);
+
+            return $uploadedUrl;
+
+        } catch (\Exception $e) {
+            Log::error('Chunsun upload failed', [
+                'url' => $url,
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 从本地文件上传图片到春笋云
+     */
+    public function uploadFromFile(string $localPath): string
+    {
+        try {
+            if (!file_exists($localPath)) {
+                throw new \Exception("Local file does not exist: {$localPath}");
+            }
+
+            $fileName = basename($localPath);
+            $uploadedUrl = $this->uploadToChunsun($localPath, $fileName);
+
+            return $uploadedUrl;
+
+        } catch (\Exception $e) {
+            Log::error('Chunsun upload from file failed', [
+                'path' => $localPath,
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 上传到春笋云
+     */
+    private function uploadToChunsun(string $filePath, string $fileName): string
+    {
+        $uploadUrl = config('services.chunsun.upload_url', env('CHUNSUN_UPLOAD_URL', 'https://crmapi.dcjxb.yunzhixue.cn'));
+
+        try {
+            $response = Http::timeout(60)
+                ->attach('file', file_get_contents($filePath), $fileName)
+                ->post($uploadUrl);
+
+            if (!$response->successful()) {
+                Log::error('Chunsun upload HTTP error', [
+                    'status' => $response->status(),
+                    'body' => $response->body(),
+                    'url' => $uploadUrl,
+                ]);
+
+                throw new \Exception("Chunsun upload failed with status: {$response->status()}");
+            }
+
+            $data = $response->json();
+
+            if (!isset($data['code']) || $data['code'] !== 200) {
+                Log::error('Chunsun upload response error', [
+                    'response' => $data,
+                ]);
+
+                throw new \Exception("Chunsun upload failed: " . ($data['message'] ?? 'Unknown error'));
+            }
+
+            if (!isset($data['data']['url'])) {
+                throw new \Exception('No URL in Chunsun upload response');
+            }
+
+            Log::info('Chunsun upload successful', [
+                'file' => $fileName,
+                'url' => $data['data']['url'],
+            ]);
+
+            return $data['data']['url'];
+
+        } catch (\Exception $e) {
+            Log::error('Chunsun upload exception', [
+                'file' => $fileName,
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 批量上传图片数组
+     */
+    public function uploadImagesFromArray(array $imageUrls): array
+    {
+        $uploadedUrls = [];
+
+        foreach ($imageUrls as $index => $url) {
+            $url = (string) $url;
+
+            if ($url === '' || !preg_match('#^https?://#i', $url)) {
+                // 非 http(s) URL 不处理(例如 data:、相对路径、本地路径),直接保留
+                $uploadedUrls[$index] = $url;
+                continue;
+            }
+
+            try {
+                $uploadedUrls[$index] = $this->uploadFromUrl($url);
+            } catch (\Exception $e) {
+                Log::warning('Failed to upload image', [
+                    'index' => $index,
+                    'url' => $url,
+                    'error' => $e->getMessage(),
+                ]);
+
+                // 保留原 URL 作为降级方案
+                $uploadedUrls[$index] = $url;
+            }
+        }
+
+        return $uploadedUrls;
+    }
+
+    /**
+     * 根据 MIME 类型获取文件扩展名
+     */
+    private function getExtensionFromMimeType(string $mimeType): string
+    {
+        $extensions = [
+            'image/jpeg' => 'jpg',
+            'image/jpg' => 'jpg',
+            'image/png' => 'png',
+            'image/gif' => 'gif',
+            'image/webp' => 'webp',
+            'image/svg+xml' => 'svg',
+        ];
+
+        return $extensions[$mimeType] ?? 'jpg';
+    }
+}

+ 114 - 279
app/Services/TextbookApiService.php

@@ -19,25 +19,71 @@ class TextbookApiService
     }
 
     /**
-     * 获取教材系列列表
+     * 通用HTTP请求方法 - 减少重复代码,无缓存
      */
-    public function getTextbookSeries(array $params = []): array
+    protected function request(string $method, string $endpoint, array $data = []): array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . '/textbooks/series', $params);
+            $httpMethod = strtolower($method);
+            $response = match($httpMethod) {
+                'get' => Http::timeout(30)->get($this->baseUrl . $endpoint, $data),
+                'post' => Http::timeout(300)->post($this->baseUrl . $endpoint, $data),
+                'put' => Http::timeout(30)->put($this->baseUrl . $endpoint, $data),
+                'delete' => Http::timeout(30)->delete($this->baseUrl . $endpoint, $data),
+                default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}")
+            };
+
+            // 处理文件上传
+            if (isset($data['file']) && $data['file'] instanceof \Illuminate\Http\UploadedFile) {
+                $response = Http::timeout(300)
+                    ->attach('file', file_get_contents($data['file']->getPathname()), $data['file']->getClientOriginalName())
+                    ->{$httpMethod}($this->baseUrl . $endpoint, $data);
+            }
 
             if ($response->successful()) {
                 return $response->json();
             }
 
-            Log::error('Failed to fetch textbook series', [
-                'status' => $response->status(),
-                'body' => $response->body()
+            // 记录错误并抛出异常
+            $error = $this->handleErrorResponse($response, $endpoint);
+            throw new \Exception($error);
+
+        } catch (\Exception $e) {
+            Log::error("API request failed: {$method} {$endpoint}", [
+                'error' => $e->getMessage(),
+                'data' => $data
             ]);
+            throw $e;
+        }
+    }
 
-            return ['data' => [], 'meta' => []];
+    /**
+     * 处理错误响应
+     */
+    private function handleErrorResponse($response, string $endpoint): string
+    {
+        $status = $response->status();
+        $body = $response->body();
+
+        // 常见错误类型的友好提示
+        return match($status) {
+            404 => "未找到资源: {$endpoint}",
+            422 => "数据验证失败: {$body}",
+            500 => "服务器内部错误: {$body}",
+            default => "HTTP {$status}: {$body}"
+        };
+    }
+
+    /**
+     * 获取教材系列列表
+     */
+    public function getTextbookSeries(array $params = []): array
+    {
+        try {
+            return $this->request('GET', '/textbooks/series', $params);
         } catch (\Exception $e) {
-            Log::error('Error fetching textbook series', ['error' => $e->getMessage()]);
+            // 失败时返回空数据而不是抛出异常,保持向后兼容
+            Log::warning('Failed to fetch textbook series, returning empty result', ['error' => $e->getMessage()]);
             return ['data' => [], 'meta' => []];
         }
     }
@@ -48,20 +94,13 @@ class TextbookApiService
     public function getTextbookSeriesById(int $seriesId): ?array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . "/textbooks/series/{$seriesId}");
-
-            if ($response->successful()) {
-                return $response->json('data');
-            }
-
-            Log::warning('Series not found', [
+            $result = $this->request('GET', "/textbooks/series/{$seriesId}");
+            return $result['data'] ?? null;
+        } catch (\Exception $e) {
+            Log::warning('Series not found or error occurred', [
                 'series_id' => $seriesId,
-                'status' => $response->status(),
+                'error' => $e->getMessage()
             ]);
-
-            return null;
-        } catch (\Exception $e) {
-            Log::error('Error fetching textbook series', ['error' => $e->getMessage(), 'series_id' => $seriesId]);
             return null;
         }
     }
@@ -72,23 +111,13 @@ class TextbookApiService
     public function createTextbookSeries(array $data): array
     {
         try {
-            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks/series', $data);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            $responseBody = $response->body();
-            $status = $response->status();
-
-            Log::error('Failed to create textbook series', [
-                'status' => $status,
-                'body' => $responseBody
-            ]);
-
-            throw new \Exception('Failed to create textbook series: ' . $responseBody);
+            return $this->request('POST', '/textbooks/series', $data);
         } catch (\Exception $e) {
-            Log::error('Error creating textbook series', ['error' => $e->getMessage()]);
+            // 检查是否是series不存在的错误(虽然这里不太可能,但保持向后兼容)
+            if (strpos($e->getMessage(), 'Series not found') !== false) {
+                $seriesId = $data['id'] ?? 'unknown';
+                throw new \Exception("系列ID {$seriesId} 不存在");
+            }
             throw $e;
         }
     }
@@ -99,18 +128,7 @@ class TextbookApiService
     public function updateTextbookSeries(int $seriesId, array $data): array
     {
         try {
-            $response = Http::timeout(30)->put($this->baseUrl . "/textbooks/series/{$seriesId}", $data);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to update textbook series', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            throw new \Exception('Failed to update textbook series');
+            return $this->request('PUT', "/textbooks/series/{$seriesId}", $data);
         } catch (\Exception $e) {
             Log::error('Error updating textbook series', ['error' => $e->getMessage()]);
             throw $e;
@@ -123,18 +141,8 @@ class TextbookApiService
     public function deleteTextbookSeries(int $seriesId): bool
     {
         try {
-            $response = Http::timeout(30)->delete($this->baseUrl . "/textbooks/series/{$seriesId}");
-
-            if ($response->successful()) {
-                return true;
-            }
-
-            Log::error('Failed to delete textbook series', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return false;
+            $this->request('DELETE', "/textbooks/series/{$seriesId}");
+            return true;
         } catch (\Exception $e) {
             Log::error('Error deleting textbook series', ['error' => $e->getMessage()]);
             return false;
@@ -147,19 +155,8 @@ class TextbookApiService
     public function deleteTextbook(int $textbookId): bool
     {
         try {
-            $response = Http::timeout(30)->delete($this->baseUrl . "/textbooks/{$textbookId}");
-
-            if ($response->successful()) {
-                return true;
-            }
-
-            Log::error('Failed to delete textbook', [
-                'textbook_id' => $textbookId,
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return false;
+            $this->request('DELETE', "/textbooks/by-id/{$textbookId}");
+            return true;
         } catch (\Exception $e) {
             Log::error('Error deleting textbook', ['error' => $e->getMessage(), 'textbook_id' => $textbookId]);
             return false;
@@ -172,20 +169,9 @@ class TextbookApiService
     public function getTextbooks(array $params = []): array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . '/textbooks', $params);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to fetch textbooks', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return ['data' => [], 'meta' => []];
+            return $this->request('GET', '/textbooks', $params);
         } catch (\Exception $e) {
-            Log::error('Error fetching textbooks', ['error' => $e->getMessage()]);
+            Log::warning('Failed to fetch textbooks, returning empty result', ['error' => $e->getMessage()]);
             return ['data' => [], 'meta' => []];
         }
     }
@@ -196,20 +182,13 @@ class TextbookApiService
     public function getTextbook(int $textbookId): ?array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . "/textbooks/{$textbookId}");
-
-            if ($response->successful()) {
-                return $response->json('data');
-            }
-
-            Log::error('Failed to fetch textbook', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return null;
+            $result = $this->request('GET', "/textbooks/by-id/{$textbookId}");
+            return $result['data'] ?? null;
         } catch (\Exception $e) {
-            Log::error('Error fetching textbook', ['error' => $e->getMessage()]);
+            Log::warning('Textbook not found or error occurred', [
+                'textbook_id' => $textbookId,
+                'error' => $e->getMessage()
+            ]);
             return null;
         }
     }
@@ -220,29 +199,13 @@ class TextbookApiService
     public function createTextbook(array $data): array
     {
         try {
-            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks', $data);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            $responseBody = $response->body();
-            $status = $response->status();
-
-            Log::error('Failed to create textbook', [
-                'status' => $status,
-                'body' => $responseBody
-            ]);
-
+            return $this->request('POST', '/textbooks', $data);
+        } catch (\Exception $e) {
             // 检查是否是series不存在的错误
-            if ($status === 404 && strpos($responseBody, 'Series not found') !== false) {
+            if (strpos($e->getMessage(), 'Series not found') !== false) {
                 $seriesId = $data['series_id'] ?? 'unknown';
                 throw new \Exception("系列ID {$seriesId} 不存在,请先创建教材系列或检查ID是否正确");
             }
-
-            throw new \Exception('Failed to create textbook: ' . $responseBody);
-        } catch (\Exception $e) {
-            Log::error('Error creating textbook', ['error' => $e->getMessage()]);
             throw $e;
         }
     }
@@ -253,18 +216,7 @@ class TextbookApiService
     public function updateTextbook(int $textbookId, array $data): array
     {
         try {
-            $response = Http::timeout(30)->put($this->baseUrl . "/textbooks/{$textbookId}", $data);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to update textbook', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            throw new \Exception('Failed to update textbook');
+            return $this->request('PUT', "/textbooks/by-id/{$textbookId}", $data);
         } catch (\Exception $e) {
             Log::error('Error updating textbook', ['error' => $e->getMessage()]);
             throw $e;
@@ -278,29 +230,13 @@ class TextbookApiService
     public function createOrUpdateTextbook(array $data): array
     {
         try {
-            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks/upsert', $data);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            $responseBody = $response->body();
-            $status = $response->status();
-
-            Log::error('Failed to create or update textbook', [
-                'status' => $status,
-                'body' => $responseBody
-            ]);
-
+            return $this->request('POST', '/textbooks/upsert', $data);
+        } catch (\Exception $e) {
             // 检查是否是series不存在的错误
-            if ($status === 404 && strpos($responseBody, 'Series not found') !== false) {
+            if (strpos($e->getMessage(), 'Series not found') !== false) {
                 $seriesId = $data['series_id'] ?? 'unknown';
                 throw new \Exception("系列ID {$seriesId} 不存在,请先创建教材系列或检查ID是否正确");
             }
-
-            throw new \Exception('Failed to create or update textbook: ' . $responseBody);
-        } catch (\Exception $e) {
-            Log::error('Error creating or updating textbook', ['error' => $e->getMessage()]);
             throw $e;
         }
     }
@@ -311,19 +247,8 @@ class TextbookApiService
     public function deleteTextbookCatalog(int $catalogId): bool
     {
         try {
-            $response = Http::timeout(30)->delete($this->baseUrl . "/textbooks/catalog/{$catalogId}");
-
-            if ($response->successful()) {
-                return true;
-            }
-
-            Log::error('Failed to delete textbook catalog', [
-                'catalog_id' => $catalogId,
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return false;
+            $this->request('DELETE', "/textbooks/catalog/{$catalogId}");
+            return true;
         } catch (\Exception $e) {
             Log::error('Error deleting textbook catalog', ['error' => $e->getMessage(), 'catalog_id' => $catalogId]);
             return false;
@@ -336,22 +261,10 @@ class TextbookApiService
     public function getTextbookCatalog(int $textbookId, string $format = 'tree'): array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . "/textbooks/{$textbookId}/catalog", [
-                'format' => $format
-            ]);
-
-            if ($response->successful()) {
-                return $response->json('data');
-            }
-
-            Log::error('Failed to fetch textbook catalog', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return [];
+            $result = $this->request('GET', "/textbooks/by-id/{$textbookId}/catalog", ['format' => $format]);
+            return $result['data'] ?? [];
         } catch (\Exception $e) {
-            Log::error('Error fetching textbook catalog', ['error' => $e->getMessage()]);
+            Log::warning('Failed to fetch textbook catalog, returning empty result', ['error' => $e->getMessage()]);
             return [];
         }
     }
@@ -362,23 +275,13 @@ class TextbookApiService
     public function previewTextbookNaming(array $textbookData, array $seriesData): array
     {
         try {
-            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks/naming-preview', [
+            $result = $this->request('POST', '/textbooks/naming-preview', [
                 'textbook' => $textbookData,
                 'series' => $seriesData
             ]);
-
-            if ($response->successful()) {
-                return $response->json('data');
-            }
-
-            Log::error('Failed to preview textbook naming', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return [];
+            return $result['data'] ?? [];
         } catch (\Exception $e) {
-            Log::error('Error previewing textbook naming', ['error' => $e->getMessage()]);
+            Log::warning('Failed to preview textbook naming, returning empty result', ['error' => $e->getMessage()]);
             return [];
         }
     }
@@ -389,22 +292,10 @@ class TextbookApiService
     public function importTextbookMetadata($file, string $commitMode = 'overwrite'): array
     {
         try {
-            $response = Http::timeout(300)
-                ->attach('file', file_get_contents($file->getPathname()), $file->getClientOriginalName())
-                ->post($this->baseUrl . '/textbooks/import/meta', [
-                    'commit_mode' => $commitMode
-                ]);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to import textbook metadata', [
-                'status' => $response->status(),
-                'body' => $response->body()
+            return $this->request('POST', '/textbooks/import/meta', [
+                'file' => $file,
+                'commit_mode' => $commitMode
             ]);
-
-            throw new \Exception('Failed to import textbook metadata');
         } catch (\Exception $e) {
             Log::error('Error importing textbook metadata', ['error' => $e->getMessage()]);
             throw $e;
@@ -414,26 +305,18 @@ class TextbookApiService
     /**
      * 导入教材目录
      */
-    public function importTextbookCatalog(int $textbookId, $file, string $commitMode = 'overwrite'): array
+    public function importTextbookCatalog(int $textbookId, array $catalogData, string $commitMode = 'overwrite'): array
     {
         try {
-            $response = Http::timeout(300)
-                ->attach('file', file_get_contents($file->getPathname()), $file->getClientOriginalName())
-                ->post($this->baseUrl . '/textbooks/import/catalog', [
-                    'textbook_id' => $textbookId,
-                    'commit_mode' => $commitMode
-                ]);
+            // 将数组转换为JSON字符串
+            $jsonData = json_encode($catalogData, JSON_UNESCAPED_UNICODE);
 
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to import textbook catalog', [
-                'status' => $response->status(),
-                'body' => $response->body()
+            // 直接发送 JSON 数据
+            return $this->request('POST', '/textbooks/import/catalog', [
+                'textbook_id' => $textbookId,
+                'data' => $jsonData,
+                'commit_mode' => $commitMode
             ]);
-
-            throw new \Exception('Failed to import textbook catalog');
         } catch (\Exception $e) {
             Log::error('Error importing textbook catalog', ['error' => $e->getMessage()]);
             throw $e;
@@ -446,18 +329,7 @@ class TextbookApiService
     public function commitImportJob(int $jobId): array
     {
         try {
-            $response = Http::timeout(300)->post($this->baseUrl . "/api/textbooks/import/{$jobId}/commit");
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to commit import job', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            throw new \Exception('Failed to commit import job');
+            return $this->request('POST', "/api/textbooks/import/{$jobId}/commit");
         } catch (\Exception $e) {
             Log::error('Error committing import job', ['error' => $e->getMessage()]);
             throw $e;
@@ -470,20 +342,9 @@ class TextbookApiService
     public function getImportJobs(array $params = []): array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . '/textbooks/import/jobs', $params);
-
-            if ($response->successful()) {
-                return $response->json();
-            }
-
-            Log::error('Failed to fetch import jobs', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return ['data' => [], 'meta' => []];
+            return $this->request('GET', '/textbooks/import/jobs', $params);
         } catch (\Exception $e) {
-            Log::error('Error fetching import jobs', ['error' => $e->getMessage()]);
+            Log::warning('Failed to fetch import jobs, returning empty result', ['error' => $e->getMessage()]);
             return ['data' => [], 'meta' => []];
         }
     }
@@ -494,20 +355,10 @@ class TextbookApiService
     public function getImportJob(int $jobId): ?array
     {
         try {
-            $response = Http::timeout(30)->get($this->baseUrl . "/api/textbooks/import/jobs/{$jobId}");
-
-            if ($response->successful()) {
-                return $response->json('data');
-            }
-
-            Log::error('Failed to fetch import job', [
-                'status' => $response->status(),
-                'body' => $response->body()
-            ]);
-
-            return null;
+            $result = $this->request('GET', "/api/textbooks/import/jobs/{$jobId}");
+            return $result['data'] ?? null;
         } catch (\Exception $e) {
-            Log::error('Error fetching import job', ['error' => $e->getMessage()]);
+            Log::warning('Import job not found or error occurred', ['error' => $e->getMessage()]);
             return null;
         }
     }
@@ -548,30 +399,14 @@ class TextbookApiService
             }
 
             // 调用API进行完全同步
-            $response = Http::timeout(300)->post($this->baseUrl . '/textbooks/series/sync-all', [
+            $result = $this->request('POST', '/textbooks/series/sync-all', [
                 'series' => $seriesData
             ]);
 
-            if ($response->successful()) {
-                $result = $response->json();
-                return [
-                    'success' => true,
-                    'synced_count' => count($seriesData),
-                    'data' => $result
-                ];
-            }
-
-            $responseBody = $response->body();
-            $status = $response->status();
-
-            Log::error('Failed to sync textbook series', [
-                'status' => $status,
-                'body' => $responseBody
-            ]);
-
             return [
-                'success' => false,
-                'message' => "同步失败: HTTP {$status} - {$responseBody}"
+                'success' => true,
+                'synced_count' => count($seriesData),
+                'data' => $result
             ];
         } catch (\Exception $e) {
             Log::error('Error syncing textbook series', ['error' => $e->getMessage()]);

+ 1 - 7
resources/views/filament/pages/knowledge-graph-management.blade.php

@@ -6,13 +6,7 @@
             <p class="text-sm text-base-content/60 mt-1">管理和维护数学知识图谱数据</p>
         </div>
         <div class="flex items-center gap-2">
-            <div class="tooltip" data-tip="刷新数据">
-                <button class="btn btn-ghost btn-sm">
-                    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
-                    </svg>
-                </button>
-            </div>
+            <x-filament::actions :actions="$this->getCachedHeaderActions()" />
         </div>
     </div>
 

+ 87 - 0
resources/views/filament/resources/textbook-resource/edit.blade.php

@@ -0,0 +1,87 @@
+<div class="p-6">
+    <form wire:submit="save">
+        <div class="space-y-6">
+            <!-- 基本信息 -->
+            <div class="bg-white shadow rounded-lg p-6">
+                <h2 class="text-lg font-medium text-gray-900 mb-4">基本信息</h2>
+
+                <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
+                    <!-- 教材系列 -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">教材系列</label>
+                        <select wire:model="data.series_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
+                            <option value="">请选择教材系列</option>
+                            @php
+                                $apiService = app(App\Services\TextbookApiService::class);
+                                $series = $apiService->getTextbookSeries();
+                                foreach ($series['data'] as $s) {
+                                    echo '<option value="' . $s['id'] . '" ' . ($this->data['series_id'] == $s['id'] ? 'selected' : '') . '>' . htmlspecialchars($s['name']) . '</option>';
+                                }
+                            @endphp
+                        </select>
+                    </div>
+
+                    <!-- 学段 -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">学段</label>
+                        <select wire:model="data.stage" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
+                            <option value="">请选择学段</option>
+                            <option value="primary" {{ ($this->data['stage'] ?? '') == 'primary' ? 'selected' : '' }}>小学</option>
+                            <option value="junior" {{ ($this->data['stage'] ?? '') == 'junior' ? 'selected' : '' }}>初中</option>
+                            <option value="senior" {{ ($this->data['stage'] ?? '') == 'senior' ? 'selected' : '' }}>高中</option>
+                        </select>
+                    </div>
+
+                    <!-- 年级 -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">年级</label>
+                        <input type="number" wire:model="data.grade" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="例如:7">
+                    </div>
+
+                    <!-- 学期 -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">学期</label>
+                        <select wire:model="data.semester" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
+                            <option value="">请选择学期</option>
+                            <option value="1" {{ ($this->data['semester'] ?? '') == 1 ? 'selected' : '' }}>上学期</option>
+                            <option value="2" {{ ($this->data['semester'] ?? '') == 2 ? 'selected' : '' }}>下学期</option>
+                        </select>
+                    </div>
+
+                    <!-- 书名 -->
+                    <div class="sm:col-span-2">
+                        <label class="block text-sm font-medium text-gray-700">书名</label>
+                        <input type="text" wire:model="data.official_title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="请输入书名">
+                    </div>
+
+                    <!-- ISBN -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">ISBN</label>
+                        <input type="text" wire:model="data.isbn" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="请输入ISBN">
+                    </div>
+
+                    <!-- 状态 -->
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700">状态</label>
+                        <select wire:model="data.status" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
+                            <option value="">请选择状态</option>
+                            <option value="draft" {{ ($this->data['status'] ?? '') == 'draft' ? 'selected' : '' }}>草稿</option>
+                            <option value="published" {{ ($this->data['status'] ?? '') == 'published' ? 'selected' : '' }}>已发布</option>
+                            <option value="archived" {{ ($this->data['status'] ?? '') == 'archived' ? 'selected' : '' }}>已归档</option>
+                        </select>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 提交按钮 -->
+            <div class="flex items-center justify-end gap-3">
+                <a href="{{ route('filament.admin.resources.textbooks.index') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+                    返回列表
+                </a>
+                <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+                    保存更改
+                </button>
+            </div>
+        </div>
+    </form>
+</div>

+ 3 - 0
resources/views/livewire/textbook-table.blade.php

@@ -0,0 +1,3 @@
+<div>
+    {{ $this->table }}
+</div>

+ 37 - 6
resources/views/pdf/exam-grading.blade.php

@@ -1,6 +1,17 @@
 @php
     // 复用题目数据并开启判卷模式(显示方框+答案+思路)
     $grading = true;
+    // 生成13位识别码:判卷以2开头 + 12位paper_id数字部分
+    $rawPaperId = $paper->paper_id ?? 'unknown';
+    // 从 paper_id 提取12位数字部分(格式: paper_xxxxxxxxxxxx)
+    if (preg_match('/paper_(\d{12})/', $rawPaperId, $matches)) {
+        $paperIdNum = $matches[1];
+    } else {
+        // 兼容旧格式,取数字部分或生成哈希
+        $paperIdNum = preg_replace('/[^0-9]/', '', $rawPaperId);
+        $paperIdNum = str_pad(substr($paperIdNum, 0, 12), 12, '0', STR_PAD_LEFT);
+    }
+    $gradingCode = '2' . $paperIdNum; // 判卷识别码:2 + 12位数字
 @endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
@@ -9,7 +20,30 @@
     <title>{{ $paper->paper_name ?? '判卷预览' }}</title>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
     <style>
-        @page { size: A4; margin: 2cm; }
+        @page {
+            size: A4;
+            margin: 2cm 2cm 2.5cm 2cm;
+            @top-left {
+                content: "知了数学";
+                font-size: 10px;
+                color: #666;
+            }
+            @top-right {
+                content: "{{ $gradingCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-left {
+                content: "{{ $gradingCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-right {
+                content: counter(page) "/" counter(pages);
+                font-size: 10px;
+                color: #666;
+            }
+        }
         :root {
             --question-gap: 6px;
         }
@@ -95,12 +129,8 @@
         .solution-section {
             margin-top: 8px;
             padding: 6px 8px;
-            background: #f5f5f5;
-            border-left: 3px solid #4163ff;
-            border-radius: 3px;
         }
         .solution-section strong {
-            color: #4163ff;
             font-size: 13px;
         }
         .solution-parsed {
@@ -145,7 +175,7 @@
     <div class="page">
     <div class="header">
         <div style="font-size:22px;font-weight:bold;">判卷专用</div>
-        <div style="font-size:18px;">{{ $paper->paper_name ?? '未命名试卷' }}</div>
+        <div style="font-size:18px;">{{ $gradingCode }}</div>
         <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
             <span>老师:{{ $teacher['name'] ?? '________' }}</span>
             <span>年级:{{ $student['grade'] ?? '________' }}</span>
@@ -157,6 +187,7 @@
     @include('components.exam.paper-body', ['questions' => $questions, 'grading' => true])
     </div>
 
+
     <!-- KaTeX -->
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>

+ 36 - 6
resources/views/pdf/exam-paper.blade.php

@@ -4,10 +4,43 @@
     <meta charset="UTF-8">
     <title>{{ $paper->paper_name ?? '试卷预览' }}</title>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    @php
+        // 生成13位识别码:试卷以1开头 + 12位paper_id数字部分
+        $rawPaperId = $paper->paper_id ?? 'unknown';
+        // 从 paper_id 提取12位数字部分(格式: paper_xxxxxxxxxxxx)
+        if (preg_match('/paper_(\d{12})/', $rawPaperId, $matches)) {
+            $paperIdNum = $matches[1];
+        } else {
+            // 兼容旧格式,取数字部分或生成哈希
+            $paperIdNum = preg_replace('/[^0-9]/', '', $rawPaperId);
+            $paperIdNum = str_pad(substr($paperIdNum, 0, 12), 12, '0', STR_PAD_LEFT);
+        }
+        $examCode = '1' . $paperIdNum; // 试卷识别码:1 + 12位数字
+    @endphp
     <style>
         @page {
             size: A4;
-            margin: 2cm;
+            margin: 2cm 2cm 2.5cm 2cm;
+            @top-left {
+                content: "知了数学";
+                font-size: 10px;
+                color: #666;
+            }
+            @top-right {
+                content: "{{ $examCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-left {
+                content: "{{ $examCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-right {
+                content: counter(page) "/" counter(pages);
+                font-size: 10px;
+                color: #666;
+            }
         }
         :root {
             --question-gap: 6px;
@@ -134,12 +167,8 @@
         .solution-section {
             margin-top: 8px;
             padding: 6px 8px;
-            background: #f5f5f5;
-            border-left: 3px solid #4163ff;
-            border-radius: 3px;
         }
         .solution-section strong {
-            color: #4163ff;
             font-size: 13px;
         }
         .solution-parsed {
@@ -252,7 +281,7 @@
     <div class="page">
     <div class="header">
         <div class="school-name">数学智能测试卷</div>
-        <div class="paper-title">{{ $paper->paper_name ?? '未命名试卷' }}</div>
+        <div class="paper-title">{{ $examCode }}</div>
         <div class="info-row">
             <span>老师:{{ $teacher['name'] ?? '________' }}</span>
             <span>年级:{{ $student['grade'] ?? '________' }}</span>
@@ -362,6 +391,7 @@
     @endif
 
 
+
     <!-- KaTeX JavaScript 库 -->
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>

+ 10 - 0
routes/web.php

@@ -21,3 +21,13 @@ Route::get('/admin/question-management/check-notifications', [NotificationContro
 // 菜单可见性切换路由
 Route::post('/admin/toggle-menu-visibility', [MenuVisibilityController::class, 'toggle'])
     ->name('filament.admin.auth.toggle-menu-visibility');
+
+// Livewire测试路由
+Route::get('/test-livewire', function() {
+    return view('test-livewire');
+})->name('test.livewire');
+
+// 教材删除路由 - 通过URL参数传递ID,完全绕过Filament的$record传递问题
+// 使用GET方法避免CSRF问题
+Route::get('/admin/textbooks/{id}/delete', [\App\Http\Controllers\TextbookController::class, 'delete'])
+    ->name('filament.admin.resources.textbooks.delete');