yemeishu преди 1 седмица
родител
ревизия
1880775b42
променени са 100 файла, в които са добавени 7220 реда и са изтрити 381 реда
  1. 211 0
      DATABASE_SYNC_SUMMARY.md
  2. 170 0
      FIX_GRADING_PANEL.md
  3. 5 0
      app/Filament/AdminPanelProvider.php
  4. 308 124
      app/Filament/Pages/IntelligentExamGeneration.php
  5. 253 0
      app/Filament/Pages/QuestionReview.php
  6. 181 0
      app/Filament/Pages/TextbookImport/TextbookExcelImportPage.php
  7. 1 0
      app/Filament/Resources/MenuPermissionResource/Pages/CreateMenuPermission.php
  8. 1 0
      app/Filament/Resources/MenuPermissionResource/Pages/EditMenuPermission.php
  9. 1 0
      app/Filament/Resources/MenuPermissionResource/Pages/ListMenuPermissions.php
  10. 1 0
      app/Filament/Resources/OCRRecordResource/Pages/CreateOCRRecord.php
  11. 1 0
      app/Filament/Resources/OCRRecordResource/Pages/ListOCRRecords.php
  12. 1 0
      app/Filament/Resources/OCRRecordResource/Pages/ViewOCRRecord.php
  13. 1 0
      app/Filament/Resources/StudentResource/Pages/CreateStudent.php
  14. 1 0
      app/Filament/Resources/StudentResource/Pages/EditStudent.php
  15. 1 0
      app/Filament/Resources/StudentResource/Pages/ListStudents.php
  16. 1 0
      app/Filament/Resources/StudentResource/Pages/ViewStudent.php
  17. 1 0
      app/Filament/Resources/TeacherResource/Pages/CreateTeacher.php
  18. 1 0
      app/Filament/Resources/TeacherResource/Pages/EditTeacher.php
  19. 1 0
      app/Filament/Resources/TeacherResource/Pages/ListTeachers.php
  20. 1 0
      app/Filament/Resources/TeacherResource/Pages/ViewTeacher.php
  21. 241 0
      app/Filament/Resources/TextbookCatalogResource.php
  22. 33 0
      app/Filament/Resources/TextbookCatalogResource/Pages/ManageTextbookCatalogs.php
  23. 524 0
      app/Filament/Resources/TextbookResource.php
  24. 11 0
      app/Filament/Resources/TextbookResource/Pages/CreateTextbook.php
  25. 13 0
      app/Filament/Resources/TextbookResource/Pages/EditTextbook.php
  26. 28 0
      app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php
  27. 327 0
      app/Filament/Resources/TextbookSeriesResource.php
  28. 59 0
      app/Filament/Resources/TextbookSeriesResource/Pages/CreateTextbookSeries.php
  29. 57 0
      app/Filament/Resources/TextbookSeriesResource/Pages/EditTextbookSeries.php
  30. 84 0
      app/Filament/Resources/TextbookSeriesResource/Pages/ManageTextbookSeries.php
  31. 214 17
      app/Http/Controllers/Api/IntelligentExamController.php
  32. 448 0
      app/Http/Controllers/Api/TextbookApiController.php
  33. 3 1
      app/Http/Controllers/ExamAnalysisPdfController.php
  34. 47 36
      app/Http/Controllers/ExamPdfController.php
  35. 2 0
      app/Models/OCRRecord.php
  36. 4 0
      app/Models/Paper.php
  37. 6 0
      app/Models/PaperQuestion.php
  38. 2 0
      app/Models/Student.php
  39. 48 0
      app/Models/TextbookCatalog.php
  40. 32 0
      app/Models/TextbookSeries.php
  41. 8 1
      app/Providers/Filament/AdminPanelProvider.php
  42. 103 17
      app/Services/ExamPdfExportService.php
  43. 659 0
      app/Services/Import/TextbookExcelImporter.php
  44. 320 38
      app/Services/LearningAnalyticsService.php
  45. 14 0
      app/Services/QuestionBankService.php
  46. 443 0
      app/Services/TextbookApiService.php
  47. 90 0
      app/Services/TextbookCoverStorageService.php
  48. 3 0
      composer.json
  49. 955 9
      composer.lock
  50. 91 0
      debug_grading_panel.md
  51. 2 6
      phpunit.xml
  52. 570 0
      resources/css/app.css
  53. 6 6
      resources/views/examples/exam-analysis-components-example.blade.php
  54. 2 2
      resources/views/examples/math-render-example.blade.php
  55. 2 2
      resources/views/filament/pages/exam-analysis-compact.blade.php
  56. 2 2
      resources/views/filament/pages/exam-analysis-standard.blade.php
  57. 2 2
      resources/views/filament/pages/exam-detail.blade.php
  58. 2 2
      resources/views/filament/pages/exam-history-simple.blade.php
  59. 1 1
      resources/views/filament/pages/exam-history.blade.php
  60. 2 2
      resources/views/filament/pages/integrations/knowledge-graph-explorer.blade.php
  61. 2 2
      resources/views/filament/pages/integrations/knowledge-graph-integration.blade.php
  62. 47 20
      resources/views/filament/pages/intelligent-exam-generation-simple.blade.php
  63. 33 30
      resources/views/filament/pages/intelligent-exam-generation.blade.php
  64. 2 2
      resources/views/filament/pages/knowledge-graph-management.blade.php
  65. 2 2
      resources/views/filament/pages/knowledge-graph-visualization-backup.blade.php
  66. 2 2
      resources/views/filament/pages/knowledge-graph-visualization-simple.blade.php
  67. 2 2
      resources/views/filament/pages/knowledge-graph-visualization.blade.php
  68. 2 2
      resources/views/filament/pages/knowledge-point-detail.blade.php
  69. 2 2
      resources/views/filament/pages/knowledge-point-stats.blade.php
  70. 2 2
      resources/views/filament/pages/knowledge-points.blade.php
  71. 2 2
      resources/views/filament/pages/knowledge-relation-management.blade.php
  72. 2 2
      resources/views/filament/pages/ocr-analysis-view.blade.php
  73. 2 2
      resources/views/filament/pages/ocr-paper-analysis.blade.php
  74. 2 2
      resources/views/filament/pages/ocr-paper-grading.blade.php
  75. 2 2
      resources/views/filament/pages/ocr-record-list.blade.php
  76. 2 2
      resources/views/filament/pages/ocr-record-view-new.blade.php
  77. 2 2
      resources/views/filament/pages/ocr-record-view.blade.php
  78. 2 2
      resources/views/filament/pages/prompt-management.blade.php
  79. 2 2
      resources/views/filament/pages/question-generation.blade.php
  80. 2 2
      resources/views/filament/pages/question-management-simple.blade.php
  81. 2 2
      resources/views/filament/pages/question-management.blade.php
  82. 128 0
      resources/views/filament/pages/question-review.blade.php
  83. 2 2
      resources/views/filament/pages/recommendation-list.blade.php
  84. 2 2
      resources/views/filament/pages/simulated-grading.blade.php
  85. 2 2
      resources/views/filament/pages/student-analysis-simple.blade.php
  86. 1 1
      resources/views/filament/pages/student-analysis.blade.php
  87. 2 2
      resources/views/filament/pages/student-knowledge-graph-page.blade.php
  88. 2 2
      resources/views/filament/pages/student-management.blade.php
  89. 2 2
      resources/views/filament/pages/upload-exam-paper.blade.php
  90. 2 2
      resources/views/filament/resources/student-resource/pages/create-student.blade.php
  91. 2 2
      resources/views/filament/resources/student-resource/pages/edit-student.blade.php
  92. 2 2
      resources/views/filament/resources/student-resource/pages/view-student.blade.php
  93. 2 2
      resources/views/filament/resources/teacher/pages/edit-teacher.blade.php
  94. 2 2
      resources/views/filament/resources/teacher/pages/view-teacher.blade.php
  95. 101 0
      resources/views/filament/resources/textbook-catalog-resource/index-record.blade.php
  96. 116 0
      resources/views/filament/resources/textbook-resource/index-record.blade.php
  97. 103 0
      resources/views/filament/resources/textbook-series-resource/index-record.blade.php
  98. 1 1
      resources/views/vendor/filament/auth/login.blade.php
  99. 1 1
      resources/views/vendor/filament/auth/pages/login.blade.php
  100. 27 0
      routes/api.php

+ 211 - 0
DATABASE_SYNC_SUMMARY.md

@@ -0,0 +1,211 @@
+# 数据库同步总结
+
+## 📋 同步状态
+
+✅ **已成功完成题库服务数据库同步**
+
+### 🎯 同步内容
+
+我们在 FilamentAdmin 中添加了 `start_year` 字段,现在这个字段已经同步到题库服务的 PostgreSQL 数据库中。
+
+## 📊 数据库变更详情
+
+### FilamentAdmin (MySQL)
+- **模型**: `TextbookSeries.php`
+- **字段**: `start_year` (INT, 可选)
+- **位置**: fillable 数组中
+
+### 题库服务 (PostgreSQL)
+- **表**: `textbook_series`
+- **字段**: `start_year` (INTEGER, 可为空)
+- **索引**: `idx_textbook_series_start_year`
+- **注释**: '起始年份:教材系列首次发布的年份'
+
+## 🔧 执行的操作
+
+### 1. 创建迁移文件
+📄 `/Volumes/T9/code/math/apis/QuestionBankService/database/migrations/20241216_000001_add_start_year_to_textbook_series.sql`
+
+```sql
+-- 为 textbook_series 表添加 start_year 字段
+ALTER TABLE textbook_series ADD COLUMN IF NOT EXISTS start_year INTEGER;
+
+-- 添加索引以提高查询性能
+CREATE INDEX IF NOT EXISTS idx_textbook_series_start_year ON textbook_series(start_year);
+
+-- 添加注释
+COMMENT ON COLUMN textbook_series.start_year IS '起始年份:教材系列首次发布的年份';
+```
+
+### 2. 执行迁移
+在 PostgreSQL 容器中执行迁移:
+```bash
+docker exec -i question_bank_pg psql -U user -d question_bank -f migration.sql
+```
+
+### 3. 验证结果
+执行迁移后验证字段是否正确添加:
+
+```sql
+SELECT column_name, data_type, is_nullable
+FROM information_schema.columns
+WHERE table_name = 'textbook_series'
+AND column_name = 'start_year';
+```
+
+**结果**:
+```
+column_name | data_type | is_nullable
+-------------+-----------+-------------
+start_year  | integer   | YES
+```
+
+## 📈 数据同步流程
+
+### 创建教材系列时
+1. 用户在 FilamentAdmin 中填写表单
+2. FilamentAdmin 调用 API 服务
+3. API 服务将数据存储到题库服务的 PostgreSQL 数据库
+4. `start_year` 字段同步存储
+
+### 现有数据
+目前题库中已有 3 条教材系列记录:
+- ID 1: 人教版 (start_year: NULL)
+- ID 2: 北师大版 (start_year: NULL)
+- ID 3: 华东师大版 (start_year: NULL)
+
+这些记录的 `start_year` 字段为 NULL,可以后续手动更新。
+
+## 🔍 验证步骤
+
+### 1. 检查题库服务数据库
+```bash
+# 连接到题库数据库
+docker exec -it question_bank_pg psql -U user -d question_bank
+
+# 查看表结构
+\d textbook_series
+
+# 查看现有数据
+SELECT id, name, start_year FROM textbook_series;
+```
+
+### 2. 检查 FilamentAdmin
+访问:http://fa.test/admin/textbook-series/create
+
+验证:
+- [x] 起始年份字段显示在表单中
+- [x] 字段为可选填
+- [x] 数据可以正常保存
+
+### 3. 测试数据同步
+1. 在 FilamentAdmin 中创建新的教材系列
+2. 填写起始年份(如:2024)
+3. 保存后检查题库数据库是否同步
+
+```sql
+-- 在题库数据库中验证
+SELECT * FROM textbook_series WHERE name = '新创建的系列';
+```
+
+## 📚 相关文件
+
+### FilamentAdmin
+- ✅ `app/Models/TextbookSeries.php` - 模型更新
+- ✅ `app/Filament/Resources/TextbookSeriesResource.php` - 表单和表格更新
+- ✅ `App\Filament\Resources\TextbookSeriesResource\ApiTextbookSeries` - API模型更新
+
+### 题库服务
+- ✅ `database/migrations/20241216_000001_add_start_year_to_textbook_series.sql` - 迁移文件
+- ✅ 数据库表已更新
+
+## 🎉 同步完成确认
+
+### ✅ 数据库结构同步
+- [x] FilamentAdmin 模型包含 start_year
+- [x] 题库服务数据库表包含 start_year
+- [x] 字段类型匹配 (INT/INTEGER)
+- [x] 字段可为空
+
+### ✅ 索引同步
+- [x] 题库服务已创建索引 `idx_textbook_series_start_year`
+- [x] 索引用于提高查询性能
+
+### ✅ 注释同步
+- [x] 题库服务已添加字段注释
+- [x] 注释内容清晰描述字段用途
+
+### ✅ 数据同步
+- [x] 创建记录时数据同步
+- [x] 编辑记录时数据同步
+- [x] 删除记录时数据同步
+
+## 💡 最佳实践
+
+### 1. 字段命名一致性
+FilamentAdmin 和题库服务使用相同的字段名 `start_year`,确保数据一致性。
+
+### 2. 数据类型匹配
+- Filament: INT
+- 题库服务: INTEGER
+两者在语义上相同,都是整数类型。
+
+### 3. 可空性一致
+两个系统都允许 `start_year` 为 NULL,提高数据灵活性。
+
+### 4. 索引优化
+为题库服务的 `start_year` 字段添加索引,提高按年份查询的性能。
+
+## 🔄 后续维护
+
+### 添加新字段时
+1. 更新 FilamentAdmin 模型
+2. 更新题库服务数据库结构
+3. 创建数据库迁移文件
+4. 执行迁移
+5. 验证同步
+
+### 数据迁移脚本
+创建了通用的迁移脚本:`scripts/apply_migration_start_year.py`
+未来可以复用此脚本模式。
+
+## 📊 性能影响
+
+### 存储
+- 每个 `textbook_series` 记录增加 4 字节 (INTEGER)
+- 索引增加额外存储空间
+
+### 查询
+- 添加索引后,按 `start_year` 查询性能提升
+- 列表页面加载可能略有增加(增加一列显示)
+
+### 推荐查询优化
+```sql
+-- 按起始年份范围查询
+SELECT * FROM textbook_series
+WHERE start_year BETWEEN 2020 AND 2025;
+
+-- 按起始年份分组统计
+SELECT start_year, COUNT(*)
+FROM textbook_series
+GROUP BY start_year
+ORDER BY start_year DESC;
+```
+
+## ✅ 总结
+
+**数据库同步已成功完成!**
+
+- ✅ FilamentAdmin 和题库服务数据库结构已同步
+- ✅ `start_year` 字段已添加到两个系统
+- ✅ 数据类型、约束、索引已正确配置
+- ✅ 可以正常使用新字段创建和管理教材系列
+
+现在用户可以在 FilamentAdmin 中填写起始年份,数据将自动同步到题库服务的数据库中。
+
+---
+
+**同步完成时间**: 2025-12-16 10:45:00
+**同步状态**: ✅ 成功
+**影响范围**: 教材系列管理功能
+**测试状态**: ✅ 验证通过

+ 170 - 0
FIX_GRADING_PANEL.md

@@ -0,0 +1,170 @@
+# 修复报告:选择已有试卷评分显示"暂无题目数据"问题
+
+## 问题描述
+
+在 FilamentAdmin 管理后台的 `http://fa.test/admin/upload-exam-paper` 页面:
+- ✅ 在【最近试卷记录】中能正常查看吴同学的试卷详情
+- ❌ 在【选择已有试卷评分】选择试卷后,评分面板显示"暂无题目数据"
+
+## 根本原因
+
+### 1. Livewire 组件参数传递问题
+
+`grading-panel` 组件虽然在父视图中有以下调用:
+```blade
+<livewire:upload-exam.grading-panel
+    :teacherId="$teacherId"
+    :studentId="$studentId"
+/>
+```
+
+但是**没有传递 `selectedPaperId` 参数**,导致组件无法知道要加载哪份试卷的题目。
+
+### 2. 组件初始化逻辑缺失
+
+`GradingPanel.php` 组件虽然声明了相关属性:
+```php
+public ?string $teacherId = null;
+public ?string $studentId = null;
+public ?string $selectedPaperId = null;
+```
+
+但是**没有 `mount()` 方法**来接收从父组件传递的参数,因此这些属性始终为 null。
+
+### 3. 缺少响应式更新机制
+
+当 `selectedPaperId` 发生变化时(例如用户切换试卷),组件**没有自动重新加载**题目数据。
+
+## 修复方案
+
+### 修改 1:GradingPanel.php
+
+#### 1.1 添加 `mount()` 方法接收参数
+```php
+public function mount(?string $teacherId = null, ?string $studentId = null, ?string $selectedPaperId = null): void
+{
+    $this->teacherId = $teacherId;
+    $this->studentId = $studentId;
+
+    // 如果传入了 selectedPaperId,则直接加载题目
+    if (!empty($selectedPaperId)) {
+        $this->selectedPaperId = $selectedPaperId;
+        $this->loadPaperQuestions();
+    }
+}
+```
+
+#### 1.2 添加 `updatedSelectedPaperId()` 响应式更新
+```php
+public function updatedSelectedPaperId($value): void
+{
+    // 当 selectedPaperId 更新时,自动重新加载题目
+    if (!empty($value)) {
+        $this->loadPaperQuestions();
+    } else {
+        // 清空数据
+        $this->questions = [];
+        $this->gradingData = [];
+    }
+}
+```
+
+#### 1.3 优化 `loadPaper()` 方法签名
+```php
+#[On('loadPaper')]
+public function loadPaper(string $paperId, string $teacherId, string $studentId): void
+{
+    $this->selectedPaperId = $paperId;
+    $this->teacherId = $teacherId;
+    $this->studentId = $studentId;
+
+    $this->loadPaperQuestions();
+}
+```
+
+### 修改 2:upload-exam-paper.blade.php
+
+在渲染 `grading-panel` 组件时传递 `selectedPaperId` 参数:
+```blade
+{{-- 评分面板组件 --}}
+@if(!empty($selectedPaperId))
+    <livewire:upload-exam.grading-panel
+        :teacherId="$teacherId"
+        :studentId="$studentId"
+        :selectedPaperId="$selectedPaperId"
+    />
+@endif
+```
+
+## 数据流程(修复后)
+
+```
+用户选择试卷
+    ↓
+父组件 selectedPaperId 更新
+    ↓
+父组件重新渲染 grading-panel 组件
+    ↓
+grading-panel 组件 mount() 方法被调用
+    ↓
+接收到 selectedPaperId 参数
+    ↓
+调用 loadPaperQuestions() 加载题目
+    ↓
+查询 Paper 模型和关联的 PaperQuestion 数据
+    ↓
+从题库 API 获取正确答案(如果需要)
+    ↓
+设置 $questions 数组
+    ↓
+视图渲染题目列表
+```
+
+## 验证方法
+
+### 1. 清理缓存
+```bash
+cd FilamentAdmin
+php artisan view:clear
+```
+
+### 2. 访问测试页面
+打开浏览器,访问:`http://fa.test/admin/upload-exam-paper`
+
+### 3. 执行测试步骤
+1. 点击【选择已有试卷评分】
+2. 选择老师(例如:默认老师)
+3. 选择学生(例如:吴同学)
+4. 选择试卷(最新的一份试卷)
+5. **预期结果**:评分面板应该显示试卷的题目列表,而不是"暂无题目数据"
+
+### 4. 验证数据完整性
+- 题目数量应该与试卷列表中显示的数量一致
+- 每道题目应该显示:
+  - 题目内容
+  - 题目类型
+  - 分值
+  - 参考答案(如果从题库获取到)
+
+## 数据库查询验证
+
+通过 tinker 可以验证数据确实存在:
+```php
+$paper = \App\Models\Paper::where('paper_name', 'like', '%吴%')->latest()->first();
+$paper->questions()->count(); // 应该返回题目数量,例如 6
+```
+
+## 涉及的文件
+
+1. `/app/Livewire/UploadExam/GradingPanel.php` - 评分面板组件
+2. `/resources/views/filament/pages/upload-exam-paper.blade.php` - 上传试卷页面视图
+
+## 备注
+
+此修复确保了:
+- ✅ 组件正确接收参数
+- ✅ 数据自动加载
+- ✅ 响应式更新
+- ✅ 错误处理(空数据时显示友好提示)
+
+修复后,用户可以正常查看和评分已存在的试卷。

+ 5 - 0
app/Filament/AdminPanelProvider.php

@@ -24,6 +24,11 @@ class AdminPanelProvider extends PanelProvider
                 'danger' => Color::Rose,
             ])
             ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
+            ->resources([
+                \App\Filament\Resources\TextbookSeriesResource::class,
+                \App\Filament\Resources\TextbookResource::class,
+                \App\Filament\Resources\TextbookCatalogResource::class,
+            ])
             ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
             ->pages([
                 \App\Filament\Pages\UploadExamPaper::class,

+ 308 - 124
app/Filament/Pages/IntelligentExamGeneration.php

@@ -43,9 +43,9 @@ class IntelligentExamGeneration extends Page
 
     // 题型配比
     public array $questionTypeRatio = [
-        '选择题' => 40, // 百分比
-        '填空题' => 30,
-        '解答题' => 30,
+        '选择题' => 40, // 百分比,支持 4:2:4 或 5:2:3
+        '填空题' => 20,
+        '解答题' => 40,
     ];
 
     // 难度配比
@@ -54,6 +54,8 @@ class IntelligentExamGeneration extends Page
         '中等' => 35,
         '拔高' => 15,
     ];
+    // 难度多选(空数组表示随机难度)
+    public array $selectedDifficultyLevels = [];
 
     // 教师和学生相关
     public ?string $selectedTeacherId = null;
@@ -71,8 +73,8 @@ class IntelligentExamGeneration extends Page
         return !$this->isGenerating
             && $this->totalQuestions >= 6
             && !empty($this->selectedTeacherId)
-            && !empty($this->selectedStudentId)
-            && count($this->selectedKpCodes) > 0;
+            && !empty($this->selectedStudentId);
+        // 注意:不强制要求选择知识点,没有选择时将按年级或随机生成
     }
 
     public function mount(): void
@@ -100,6 +102,9 @@ class IntelligentExamGeneration extends Page
                 $this->selectedTeacherId = (string) $student->teacher_id;
             }
         }
+
+        // 注意:不初始化 selectedDifficultyLevels,保持为空数组表示随机
+        // 如果用户不选择任何难度,系统将随机生成题目
     }
 
     #[Computed(cache: false)]
@@ -550,33 +555,12 @@ class IntelligentExamGeneration extends Page
             return;
         }
 
-        if (empty($this->selectedKpCodes)) {
-            Notification::make()
-                ->title('请选择知识点')
-                ->body('请至少选择 1 个知识点后再生成试卷。可勾选学生薄弱点或手动选择知识点。')
-                ->danger()
-                ->send();
-            return;
-        }
+        // 若未选知识点,允许随机(后台会按年级或薄弱点补全)
 
-        // 自动生成试卷名称
+        // 自动生成试卷名称 - 使用 paper_id 作为唯一标识
         if (empty($this->paperName)) {
-            $studentName = '学生' . ($this->selectedStudentId ?? '未选择');
-            // 如果有选择学生,尝试从数据库获取真实姓名
-            if ($this->selectedStudentId) {
-                try {
-                    $student = \App\Models\Student::where('student_id', $this->selectedStudentId)->first();
-                    if ($student && $student->name) {
-                        $studentName = $student->name;
-                    }
-                } catch (\Exception $e) {
-                    \Illuminate\Support\Facades\Log::warning('获取学生姓名失败', [
-                        'student_id' => $this->selectedStudentId,
-                        'error' => $e->getMessage()
-                    ]);
-                }
-            }
-            $this->paperName = $studentName . '_' . now()->format('Ymd_His') . '_智能试卷';
+            // 先生成一个临时名称,保存后会更新为真实的 paper_id
+            $this->paperName = 'paper_' . time() . '_' . bin2hex(random_bytes(4));
         }
 
         $this->isGenerating = true;
@@ -586,15 +570,16 @@ class IntelligentExamGeneration extends Page
             $learningAnalyticsService = app(LearningAnalyticsService::class);
 
             // 准备出卷参数
-            $examParams = [
-                'student_id' => $this->selectedStudentId,
-                'grade' => $this->selectedGrade,
-                'total_questions' => $this->totalQuestions,
-                'kp_codes' => $this->selectedKpCodes,
-                'skills' => $this->selectedSkills,
-                'question_type_ratio' => $this->questionTypeRatio,
-                'difficulty_ratio' => $this->difficultyRatio,
-            ];
+        $examParams = [
+            'student_id' => $this->selectedStudentId,
+            'grade' => $this->selectedGrade,
+            'total_questions' => $this->totalQuestions,
+            'kp_codes' => $this->selectedKpCodes,
+            'skills' => $this->selectedSkills,
+            'question_type_ratio' => $this->questionTypeRatio,
+            'difficulty_ratio' => $this->difficultyRatio,
+            'difficulty_levels' => $this->selectedDifficultyLevels,
+        ];
 
             // 调用智能出卷API
             $result = $learningAnalyticsService->generateIntelligentExam($examParams);
@@ -637,11 +622,12 @@ class IntelligentExamGeneration extends Page
 
                 $newResponse = $questionBankService->filterQuestions($params);
 
-                // 合并题目并去重
+                // 合并题目并去重,只保留有解题思路的题目
                 if (!empty($newResponse['data'])) {
                     $existingIds = array_column($questions, 'id');
                     foreach ($newResponse['data'] as $newQ) {
-                        if (!in_array($newQ['id'], $existingIds)) {
+                        // 只添加有解题思路的题目
+                        if (!in_array($newQ['id'], $existingIds) && !empty(trim($newQ['solution'] ?? ''))) {
                             $questions[] = $newQ;
                         }
                     }
@@ -660,14 +646,17 @@ class IntelligentExamGeneration extends Page
             // 3. 限制试卷题目数量为用户要求的数量
             if (count($questions) > $this->totalQuestions) {
                 // 根据题型配比和难度配比对题目进行筛选和排序
+                // 注意:如果用户未选择难度(selectedDifficultyLevels为空),则传入null表示随机难度
+                $effectiveDifficultyCategory = !empty($this->selectedDifficultyLevels) ? $this->difficultyCategory : null;
                 $questions = $this->selectBestQuestions(
                     $questions,
                     $this->totalQuestions,
-                    $this->difficultyCategory,
+                    $effectiveDifficultyCategory,
                     $this->totalScore,
                     $this->questionTypeRatio
                 );
-                \Illuminate\Support\Facades\Log::info("从 " . count($questions) . " 道题中筛选出 " . count($questions) . " 道题,难度分类: {$this->difficultyCategory}, 总分: {$this->totalScore}");
+                $difficultyLog = $effectiveDifficultyCategory ?? '随机';
+                \Illuminate\Support\Facades\Log::info("从 " . count($questions) . " 道题中筛选出 " . count($questions) . " 道题,难度: {$difficultyLog}, 总分: {$this->totalScore}");
             }
 
             // 3. 检查题型完整性(至少保证每种题型都有题目)
@@ -698,20 +687,36 @@ class IntelligentExamGeneration extends Page
 
                 $newResponse = $questionBankService->filterQuestions($params);
                 if (!empty($newResponse['data'])) {
-                    $questions = array_merge($questions, $newResponse['data']);
+                    // 只保留有解题思路的题目
+                    $questionsWithSolution = array_filter($newResponse['data'], function($q) {
+                        return !empty(trim($q['solution'] ?? ''));
+                    });
+                    $questions = array_merge($questions, array_values($questionsWithSolution));
                 }
 
                 // 再次筛选
+                $effectiveDifficultyCategory = !empty($this->selectedDifficultyLevels) ? $this->difficultyCategory : null;
                 $questions = $this->selectBestQuestions(
                     $questions,
                     $this->totalQuestions,
-                    $this->difficultyCategory,
+                    $effectiveDifficultyCategory,
                     $this->totalScore,
                     $this->questionTypeRatio
                 );
             }
 
-            // 2. 为题目添加类型信息(如果缺失)
+            // 2. 最终过滤:确保所有题目都有解题思路
+            $questions = array_filter($questions, function($q) {
+                return !empty(trim($q['solution'] ?? ''));
+            });
+            $questions = array_values($questions); // 重新索引数组
+
+            \Illuminate\Support\Facades\Log::info('最终过滤后题目数量', [
+                'total_questions' => count($questions),
+                'filtered_message' => '已过滤掉没有解题思路的题目'
+            ]);
+
+            // 3. 为题目添加类型信息(如果缺失)
             foreach ($questions as &$question) {
                 if (!isset($question['question_type'])) {
                     $question['question_type'] = $this->determineQuestionType($question);
@@ -741,12 +746,14 @@ class IntelligentExamGeneration extends Page
             // 4. 保存到数据库
             $questionBankService = app(QuestionBankService::class);
             $paperId = $questionBankService->saveExamToDatabase($examData);
-// 如果保存返回 null,使用默认占位 ID,防止 UI 不显示
-if (empty($paperId)) {
-    $paperId = 'demo_' . $this->selectedStudentId . '_' . now()->format('YmdHis');
-}
-\Illuminate\Support\Facades\Log::info('Generated paper ID: ' . $paperId);
-$this->generatedPaperId = $paperId;
+            // 如果保存返回 null,使用默认占位 ID,防止 UI 不显示
+            if (empty($paperId)) {
+                $paperId = 'demo_' . $this->selectedStudentId . '_' . now()->format('YmdHis');
+            }
+            // 使用 paper_id 作为试卷名称
+            $this->paperName = $paperId;
+            \Illuminate\Support\Facades\Log::info('Generated paper ID: ' . $paperId);
+            $this->generatedPaperId = $paperId;
 // 将生成的试卷数据缓存,以便 PDF 预览时使用(缓存 1 小时)
 \Illuminate\Support\Facades\Log::info('缓存试卷数据', [
     'paper_id' => $paperId,
@@ -906,15 +913,16 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
     protected function selectBestQuestions(
         array $questions,
         int $targetCount,
-        string $difficultyCategory,
+        ?string $difficultyCategory, // null 表示随机难度
         float $totalScore,
         array $questionTypeRatio
     ): array {
-        // 去重:确保输入题目列表没有重复ID
+        // 去重并过滤:确保输入题目列表没有重复ID,且都有解题思路
         $uniqueQuestions = [];
         foreach ($questions as $q) {
             $id = $q['id'] ?? $q['question_id'] ?? null;
-            if ($id && !isset($uniqueQuestions[$id])) {
+            // 只保留有解题思路且不重复的题目
+            if ($id && !isset($uniqueQuestions[$id]) && !empty(trim($q['solution'] ?? ''))) {
                 $uniqueQuestions[$id] = $q;
             }
         }
@@ -939,20 +947,68 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
             'answer' => [], // 解答题
         ];
 
+        // 统计所有题型的原始类型分布
+        $rawTypeStats = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+
         foreach ($questions as $question) {
             $type = $this->determineQuestionType($question);
             if (!isset($categorizedQuestions[$type])) {
                 $type = 'answer';
             }
             $categorizedQuestions[$type][] = $question;
+
+            // 统计原始类型
+            $rawType = $question['question_type'] ?? $question['type'] ?? 'unknown';
+            if (strtolower($rawType) === 'choice') $rawTypeStats['choice']++;
+            if (strtolower($rawType) === 'fill') $rawTypeStats['fill']++;
+            if (strtolower($rawType) === 'answer') $rawTypeStats['answer']++;
+
+            // 记录选择题的详细信息(用于调试)
+            if ($type === 'choice') {
+                \Illuminate\Support\Facades\Log::debug('题目被分类为选择题', [
+                    'question_id' => $question['id'] ?? '',
+                    'question_type' => $question['question_type'] ?? '',
+                    'type' => $question['type'] ?? '',
+                    'stem_preview' => mb_substr($question['stem'] ?? '', 0, 50)
+                ]);
+            }
         }
 
+        \Illuminate\Support\Facades\Log::info("题目类型分类统计", [
+            'total_questions' => count($questions),
+            'raw_type_stats' => $rawTypeStats,  // 题库中题目的原始类型分布
+            'final_type_stats' => [
+                'choice' => count($categorizedQuestions['choice']),
+                'fill' => count($categorizedQuestions['fill']),
+                'answer' => count($categorizedQuestions['answer'])
+            ]
+        ]);
+
         // 2. 根据难度分类筛选题目
-        $difficultyFilteredQuestions = $this->filterByDifficulty($categorizedQuestions, $difficultyCategory);
+        // 如果难度分类为null(用户未选择),则不过滤,保留所有题目(随机难度)
+        if ($difficultyCategory === null) {
+            \Illuminate\Support\Facades\Log::info("用户未选择难度,将随机生成所有难度的题目");
+            $difficultyFilteredQuestions = $categorizedQuestions;
+        } else {
+            $difficultyFilteredQuestions = $this->filterByDifficulty($categorizedQuestions, $difficultyCategory);
+        }
 
         // 3. 根据题型配比计算每种题型应选择的题目数量
         $selectedQuestions = [];
         $selectedIds = []; // 用于追踪已选题目ID
+        $typeTargets = $this->computeTypeTargets($targetCount, $questionTypeRatio);
+
+        // 检查每种题型的可用数量
+        $availableCounts = [
+            'choice' => count($difficultyFilteredQuestions['choice'] ?? []),
+            'fill' => count($difficultyFilteredQuestions['fill'] ?? []),
+            'answer' => count($difficultyFilteredQuestions['answer'] ?? []),
+        ];
+
+        \Illuminate\Support\Facades\Log::info("各题型可用数量", [
+            'available' => $availableCounts,
+            'targets' => $typeTargets
+        ]);
 
         // 优先保证每种题型至少一题(适用于总题目数>=3的情况)
         if ($targetCount >= 3) {
@@ -978,41 +1034,85 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         }
 
         // 根据题型配比计算每种题型应选择的题目数量
-        foreach ($questionTypeRatio as $type => $ratio) {
-            $typeKey = $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer');
-
-            // 计算该类型目标数量
-            $targetTypeCount = floor($targetCount * $ratio / 100);
+        foreach ($typeTargets as $typeKey => $targetTypeCount) {
+            if ($targetTypeCount <= 0) continue;
 
-            // 调整目标数量:如果总数>=3,需要考虑已经选了的题目
-            // 简单起见,我们计算总共需要的数量,然后减去已经选了的数量
-            // 但这里为了保证比例,我们还是尽量多选
+            $availableQuestions = $difficultyFilteredQuestions[$typeKey] ?? [];
 
-            if ($targetTypeCount <= 0) continue;
+            // 过滤掉已选的
+            $availableQuestions = array_filter($availableQuestions, function($q) use ($selectedIds) {
+                $id = $q['id'] ?? $q['question_id'];
+                return !in_array($id, $selectedIds);
+            });
 
-            if (!empty($difficultyFilteredQuestions[$typeKey])) {
-                $availableQuestions = $difficultyFilteredQuestions[$typeKey];
-                // 过滤掉已选的
-                $availableQuestions = array_filter($availableQuestions, function($q) use ($selectedIds) {
-                    $id = $q['id'] ?? $q['question_id'];
-                    return !in_array($id, $selectedIds);
-                });
+            // 还需要选多少:目标数量 - 已选该类型的数量
+            $currentTypeCount = 0;
+            foreach ($selectedQuestions as $sq) {
+                if ($this->determineQuestionType($sq) === $typeKey) {
+                    $currentTypeCount++;
+                }
+            }
 
-                // 如果没有可用题目了,跳过
-                if (empty($availableQuestions)) continue;
+            $needToSelect = $targetTypeCount - $currentTypeCount;
 
+            if ($needToSelect > 0) {
                 $availableCount = count($availableQuestions);
-                // 还需要选多少:目标数量 - 已选该类型的数量
-                $currentTypeCount = 0;
-                foreach ($selectedQuestions as $sq) {
-                    if ($this->determineQuestionType($sq) === $typeKey) {
-                        $currentTypeCount++;
+
+                // 如果该题型可用数量不足目标数量,从其他题型补充
+                if ($availableCount < $needToSelect) {
+                    \Illuminate\Support\Facades\Log::warning("题型 {$typeKey} 数量不足", [
+                        'available' => $availableCount,
+                        'target' => $needToSelect,
+                        'will_supplement' => true
+                    ]);
+
+                    // 先选完该题型的所有可用题目
+                    if ($availableCount > 0) {
+                        foreach ($availableQuestions as $q) {
+                            $selectedQuestions[] = $q;
+                            $selectedIds[] = $q['id'] ?? $q['question_id'];
+                        }
                     }
-                }
 
-                $needToSelect = $targetTypeCount - $currentTypeCount;
+                    // 剩余题目从其他题型补充
+                    $remainingNeed = $needToSelect - $availableCount;
+                    $otherTypes = ['choice', 'fill', 'answer'];
+                    $otherTypes = array_filter($otherTypes, fn($t) => $t !== $typeKey);
+
+                    foreach ($otherTypes as $otherType) {
+                        if ($remainingNeed <= 0) break;
+
+                        $otherQuestions = $difficultyFilteredQuestions[$otherType] ?? [];
+                        // 过滤掉已选的
+                        $otherQuestions = array_filter($otherQuestions, function($q) use ($selectedIds) {
+                            $id = $q['id'] ?? $q['question_id'];
+                            return !in_array($id, $selectedIds);
+                        });
+
+                        if (!empty($otherQuestions)) {
+                            $takeCount = min($remainingNeed, count($otherQuestions));
+                            $randomKeys = array_rand($otherQuestions, $takeCount);
+                            if (!is_array($randomKeys)) {
+                                $randomKeys = [$randomKeys];
+                            }
+
+                            foreach ($randomKeys as $key) {
+                                $q = $otherQuestions[$key];
+                                $selectedQuestions[] = $q;
+                                $selectedIds[] = $q['id'] ?? $q['question_id'];
+                            }
+
+                            $remainingNeed -= $takeCount;
+                        }
+                    }
 
-                if ($needToSelect > 0) {
+                    \Illuminate\Support\Facades\Log::info("题型 {$typeKey} 补充完成", [
+                        'original_need' => $needToSelect,
+                        'available' => $availableCount,
+                        'supplemented' => $needToSelect - $remainingNeed
+                    ]);
+                } else {
+                    // 该题型数量足够,正常选择
                     $takeCount = min($needToSelect, $availableCount, $targetCount - count($selectedQuestions));
 
                     if ($takeCount > 0) {
@@ -1068,6 +1168,67 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         return $finalQuestions;
     }
 
+    /**
+     * 根据比例与总题数计算各题型目标数量,四舍五入并校正总数
+     */
+    protected function computeTypeTargets(int $targetCount, array $questionTypeRatio): array
+    {
+        $map = [
+            '选择题' => 'choice',
+            '填空题' => 'fill',
+            '解答题' => 'answer',
+        ];
+        $targets = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+
+        // 初步分配
+        foreach ($questionTypeRatio as $label => $ratio) {
+            $key = $map[$label] ?? null;
+            if (!$key) {
+                continue;
+            }
+            $count = (int) round($targetCount * ($ratio / 100));
+            if ($ratio > 0 && $count < 1) {
+                $count = 1; // 有比例就至少 1 道
+            }
+            $targets[$key] = $count;
+        }
+
+        $currentSum = array_sum($targets);
+        if ($currentSum === 0) {
+            $targets['answer'] = $targetCount;
+            return $targets;
+        }
+
+        // 总数超出则递减最多的类型(>1 时才减)
+        while ($currentSum > $targetCount) {
+            arsort($targets);
+            foreach ($targets as $k => $v) {
+                if ($v > 1) {
+                    $targets[$k]--;
+                    $currentSum--;
+                    break;
+                }
+            }
+        }
+
+        // 总数不足则按比例最高的类型补
+        if ($currentSum < $targetCount) {
+            $ratioByKey = [
+                'choice' => $questionTypeRatio['选择题'] ?? 0,
+                'fill' => $questionTypeRatio['填空题'] ?? 0,
+                'answer' => $questionTypeRatio['解答题'] ?? 0,
+            ];
+            while ($currentSum < $targetCount) {
+                arsort($ratioByKey);
+                $key = array_key_first($ratioByKey);
+                $targets[$key]++;
+                $currentSum++;
+            }
+        }
+
+        return $targets;
+    }
+
     /**
      * 检查题型完整性,确保每种题型至少有一题
      */
@@ -1181,8 +1342,8 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
     {
         $filtered = [];
         $difficultyRanges = [
-            '基础' => [0, 0.4],
-            '中等' => [0.3, 0.7],
+            '基础' => [0, 0.6],      // 扩大基础难度上限,从0.4提升到0.6
+            '中等' => [0.4, 0.8],    // 扩大中等难度范围
             '拔高' => [0.6, 1.0]
         ];
 
@@ -1194,12 +1355,8 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
                 $difficulty = floatval($question['difficulty'] ?? 0.5);
                 if ($difficulty >= $targetRange[0] && $difficulty <= $targetRange[1]) {
                     $filtered[$type][] = $question;
-                } else {
-                    // 保留部分越界题目(如果该难度题目不足)
-                    if (count($filtered[$type]) < 2) {
-                        $filtered[$type][] = $question;
-                    }
                 }
+                // 不再保留越界题目,严格按难度范围筛选
             }
         }
 
@@ -1219,26 +1376,33 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
      */
     protected function determineQuestionType(array $question): string
     {
-        // 0. 如果题目已有明确类型,直接返回
-        if (!empty($question['question_type'])) {
-            $type = $question['question_type'];
-            if ($type === 'choice' || $type === '选择题') return 'choice';
-            if ($type === 'fill' || $type === '填空题') return 'fill';
-            if ($type === 'answer' || $type === '解答题') return 'answer';
-        }
-
-        if (!empty($question['type'])) {
-            $type = $question['type'];
-            if ($type === 'choice' || $type === '选择题') return 'choice';
-            if ($type === 'fill' || $type === '填空题') return 'fill';
-            if ($type === 'answer' || $type === '解答题') return 'answer';
-        }
-
-        $tags = $question['tags'] ?? '';
+        // 优先根据题目内容判断(而不是数据库字段)
         $stem = $question['stem'] ?? $question['content'] ?? '';
+        $tags = $question['tags'] ?? '';
         $skills = $question['skills'] ?? [];
 
-        // 1. 根据技能点判断
+        // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
+        if (is_string($stem)) {
+            // 选择题特征:必须包含 A. B. C. D. 四个选项(至少2个)
+            $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
+            $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
+            $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
+            $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
+            $hasOptionE = preg_match('/\bE\s*[\.\、\:]/', $stem) || preg_match('/\(E\)/', $stem) || preg_match('/^E[\.\s]/', $stem);
+
+            // 至少有2个选项就认为是选择题(降低阈值)
+            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0) + ($hasOptionE ? 1 : 0);
+            if ($optionCount >= 2) {
+                return 'choice';
+            }
+
+            // 检查是否有"( )"或"( )"括号,这通常是选择题的标志
+            if (preg_match('/(\s*)|\(\s*\)/', $stem) && (strpos($stem, 'A.') !== false || strpos($stem, 'B.') !== false || strpos($stem, 'C.') !== false || strpos($stem, 'D.') !== false)) {
+                return 'choice';
+            }
+        }
+
+        // 2. 根据技能点判断
         if (is_array($skills)) {
             $skillsStr = implode(',', $skills);
             if (strpos($skillsStr, '选择题') !== false) return 'choice';
@@ -1246,7 +1410,22 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
             if (strpos($skillsStr, '解答题') !== false) return 'answer';
         }
 
-        // 2. 根据标签判断
+        // 3. 根据题目已有类型字段判断(作为后备)
+        if (!empty($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'])) {
+            $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';
+            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
+        }
+
+        // 4. 根据标签判断
         if (is_string($tags)) {
             if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
                 return 'choice';
@@ -1259,22 +1438,7 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
             }
         }
 
-        // 3. 根据题干内容判断 - 必须有明确的选项格式才是选择题
-        if (is_string($stem)) {
-            // 选择题特征:必须包含 A. B. C. D. 四个选项(至少3个)
-            $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem);
-            $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem);
-            $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem);
-            $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem);
-
-            // 至少有3个选项才认为是选择题
-            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
-            if ($optionCount >= 3) {
-                return 'choice';
-            }
-        }
-
-        // 4. 填空题特征:连续下划线或明显的填空括号
+        // 5. 填空题特征:连续下划线或明显的填空括号
         if (is_string($stem)) {
             // 检查填空题特征:连续下划线
             if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
@@ -1286,7 +1450,7 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
             }
         }
 
-        // 5. 根据题干长度和内容判断
+        // 6. 根据题干内容关键词判断
         if (is_string($stem)) {
             // 有证明、解答、计算、求证等关键词的是解答题
             if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
@@ -1408,6 +1572,26 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
         return $questions;
     }
 
+    /**
+     * 快捷设置题型配比方案
+     */
+    public function applyRatioPreset(string $preset): void
+    {
+        if ($preset === '4-2-4') {
+            $this->questionTypeRatio = [
+                '选择题' => 40,
+                '填空题' => 20,
+                '解答题' => 40,
+            ];
+        } elseif ($preset === '5-2-3') {
+            $this->questionTypeRatio = [
+                '选择题' => 50,
+                '填空题' => 20,
+                '解答题' => 30,
+            ];
+        }
+    }
+
     /**
      * 保留旧方法以兼容(但不再使用)
      */

+ 253 - 0
app/Filament/Pages/QuestionReview.php

@@ -0,0 +1,253 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use Filament\Forms;
+use Filament\Pages\Page;
+use Filament\Actions\Action;
+use Filament\Schemas\Schema;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Filament\Notifications\Notification;
+
+class QuestionReview extends Page implements Forms\Contracts\HasForms
+{
+    use Forms\Concerns\InteractsWithForms;
+
+    protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
+    protected static string|\UnitEnum|null $navigationGroup = '题库校验';
+    protected static ?string $title = '题目校验';
+    protected string $view = 'filament.pages.question-review';
+
+    public string $book = '';
+    public int $page = 1;
+    public array $mineru = [];
+    public array $builder = [];
+    public string $message = '';
+    public bool $saving = false;
+    public string $builderJson = '';
+    public array $bookOptions = [];
+    public array $pageOptions = [];
+    public ?string $pagePngBase64 = null;
+    public bool $showOverlay = true;
+    public array $paths = [];
+
+    public function mount(): void
+    {
+        // 默认值可在 .env 中配置
+        $this->book = config('question_bank.default_book', '');
+        $this->page = 1;
+        // 预填可选书名(扫描 /data/mineru_raw)
+        $root = base_path('../data/mineru_raw');
+        $dirs = is_dir($root) ? scandir($root) : [];
+        $opts = [];
+        foreach ($dirs as $d) {
+            if ($d === '.' || $d === '..') {
+                continue;
+            }
+            if (is_dir($root . '/' . $d)) {
+                $opts[] = $d;
+            }
+        }
+        $this->bookOptions = $opts;
+        if ($this->book) {
+            $this->refreshPageOptions($this->book);
+        }
+    }
+
+    public function form(Schema $form): Schema
+    {
+        return $form
+            ->components([
+                Forms\Components\Select::make('book')
+                    ->label('书名/目录名')
+                    ->options(array_combine($this->bookOptions, $this->bookOptions))
+                    ->native(false)
+                    ->searchable()
+                    ->placeholder('选择书名目录')
+                    ->reactive()
+                    ->afterStateUpdated(fn($state) => $this->onBookChange($state))
+                    ->required(),
+                Forms\Components\Select::make('page')
+                    ->label('页码')
+                    ->options(fn() => array_combine(
+                        array_map('strval', $this->pageOptions),
+                        array_map(fn($p) => "第{$p}页", $this->pageOptions)
+                    ))
+                    ->native(false)
+                    ->searchable()
+                    ->placeholder('选择页码')
+                    ->reactive()
+                    ->afterStateUpdated(fn($state) => $this->onPageChange($state))
+                    ->required(),
+            ]);
+    }
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Action::make('load')
+                ->label('加载')
+                ->color('primary')
+                ->action('loadPage'),
+            Action::make('save')
+                ->label('保存到草稿')
+                ->color('success')
+                ->disabled(fn() => empty($this->builder))
+                ->action('saveDraft'),
+            Action::make('toggleOverlay')
+                ->label(fn() => $this->showOverlay ? '隐藏标框' : '显示标框')
+                ->color('gray')
+                ->action('toggleOverlay'),
+        ];
+    }
+
+    protected function refreshPageOptions(string $book): void
+    {
+        $dir = base_path("../data/mineru_raw/{$book}/pages");
+        $pages = [];
+        if (is_dir($dir)) {
+            foreach (glob($dir . '/page_*.json') as $file) {
+                if (preg_match('/page_(\\d+)\\.json$/', $file, $m)) {
+                    $pages[] = (int)$m[1];
+                }
+            }
+        }
+        sort($pages);
+        $this->pageOptions = $pages;
+        if (!empty($pages)) {
+            $this->page = $pages[0];
+        }
+    }
+
+    public function onBookChange($book): void
+    {
+        $this->book = (string)$book;
+        $this->refreshPageOptions($this->book);
+        if ($this->book && $this->page) {
+            $this->loadPage();
+        }
+    }
+
+    public function onPageChange($page): void
+    {
+        if ($page === null || $page === '') {
+            return;
+        }
+        $this->page = (int)$page;
+        if ($this->book) {
+            $this->loadPage();
+        }
+    }
+
+    public function toggleOverlay(): void
+    {
+        $this->showOverlay = !$this->showOverlay;
+    }
+
+    public function loadPage(): void
+    {
+        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
+        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
+
+        try {
+            // 重置,避免读取失败时残留旧数据
+            $this->mineru = [];
+            $this->builder = [];
+            $this->builderJson = '';
+            $this->pagePngBase64 = null;
+            $this->paths = [];
+
+            $resp = Http::timeout(10)->get($url);
+            if (!$resp->ok()) {
+                $this->message = "加载失败: {$resp->status()} {$resp->body()} (检查 QuestionBankService /review/{book}/{page} 是否可用,或该页是否已生成)";
+                return;
+            }
+            $data = $resp->json();
+            $this->mineru = $data['mineru'] ?? [];
+            $this->builder = $data['builder'] ?? [];
+            $this->builderJson = json_encode($this->builder, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
+            $this->pagePngBase64 = $data['page_png_base64'] ?? null;
+            $this->paths = $data['paths'] ?? [];
+            $this->message = '加载成功';
+        } catch (\Throwable $e) {
+            Log::warning('[QuestionReview] load failed: ' . $e->getMessage());
+            $this->message = '加载异常: ' . $e->getMessage();
+        }
+    }
+
+    public function saveDraft(): void
+    {
+        $payloadBuilder = $this->builder;
+        if ($this->builderJson) {
+            try {
+                $decoded = json_decode($this->builderJson, true, 512, JSON_THROW_ON_ERROR);
+                if (is_array($decoded)) {
+                    $payloadBuilder = $decoded;
+                }
+            } catch (\Throwable $e) {
+                $this->message = '保存异常: 题目 JSON 解析失败 - ' . $e->getMessage();
+                Notification::make()->title('保存异常')->body($this->message)->danger()->send();
+                return;
+            }
+        }
+        if (empty($payloadBuilder)) {
+            $this->message = '无可保存的数据,请先加载';
+            return;
+        }
+        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
+        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
+        $payload = [
+            'status' => 'reviewed',
+            'questions' => $payloadBuilder['questions'] ?? $payloadBuilder,
+            'source_type' => 'workbook',
+        ];
+        try {
+            $this->saving = true;
+            $resp = Http::timeout(10)->post($url, $payload);
+            $this->saving = false;
+            if (!$resp->ok()) {
+                $this->message = "保存失败: {$resp->status()} {$resp->body()}";
+                Notification::make()->title('保存失败')->body($this->message)->danger()->send();
+                return;
+            }
+            $this->message = '保存成功';
+            Notification::make()->title('保存成功')->success()->send();
+        } catch (\Throwable $e) {
+            $this->saving = false;
+            Log::warning('[QuestionReview] save failed: ' . $e->getMessage());
+            $this->message = '保存异常: ' . $e->getMessage();
+            Notification::make()->title('保存异常')->body($this->message)->danger()->send();
+        }
+    }
+
+    public function saveQuestion(int $index): void
+    {
+        $list = $this->builder['questions'] ?? [];
+        if (!isset($list[$index])) {
+            $this->message = '未找到该题目';
+            Notification::make()->title('保存失败')->body($this->message)->danger()->send();
+            return;
+        }
+        $apiBase = rtrim(config('services.question_bank.base_url'), '/');
+        $url = $apiBase . '/review/' . $this->book . '/' . $this->page;
+        $payload = [
+            'status' => 'reviewed',
+            'questions' => [$list[$index]],
+            'source_type' => 'workbook',
+        ];
+        try {
+            $resp = Http::timeout(10)->post($url, $payload);
+            if (!$resp->ok()) {
+                $this->message = "保存失败: {$resp->status()} {$resp->body()}";
+                Notification::make()->title('保存失败')->body($this->message)->danger()->send();
+                return;
+            }
+            $this->message = '单题保存成功';
+            Notification::make()->title('保存成功')->success()->send();
+        } catch (\Throwable $e) {
+            $this->message = '保存异常: ' . $e->getMessage();
+            Notification::make()->title('保存异常')->body($this->message)->danger()->send();
+        }
+    }
+}

+ 181 - 0
app/Filament/Pages/TextbookImport/TextbookExcelImportPage.php

@@ -0,0 +1,181 @@
+<?php
+
+namespace App\Filament\Pages\TextbookImport;
+
+use App\Services\Import\TextbookExcelImporter;
+use App\Services\TextbookApiService;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Filament\Pages\Page;
+use UnitEnum;
+use BackedEnum;
+use Filament\Actions\Action;
+use Filament\Forms;
+use Filament\Forms\Components\FileUpload;
+use Filament\Notifications\Notification;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Livewire\WithFileUploads;
+use Livewire\Component;
+use Livewire\Attributes\Validate;
+
+class TextbookExcelImportPage extends Page
+{
+    use WithFileUploads;
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-up';
+
+    protected static ?string $navigationLabel = 'Excel导入';
+
+    protected static UnitEnum|string|null $navigationGroup = '教材管理';
+
+    protected static ?int $navigationSort = 4;
+
+    public string $view = 'filament.pages.textbook-import';
+
+    #[Validate('required|string')]
+    public $selectedType = 'textbook_series';
+
+    #[Validate('required|file|mimes:xlsx,xls|max:10240')]
+    public $file;
+
+    public $importResult = null;
+
+    protected $importer;
+    protected $apiService;
+
+    public function boot()
+    {
+        $this->importer = app(TextbookExcelImporter::class);
+        $this->apiService = app(TextbookApiService::class);
+    }
+
+    public function mount()
+    {
+        $this->importer = app(TextbookExcelImporter::class);
+        $this->apiService = app(TextbookApiService::class);
+
+        // 检查URL参数,设置默认导入类型
+        $type = request()->get('type');
+        if (in_array($type, ['textbook_series', 'textbook', 'textbook_catalog'])) {
+            $this->selectedType = $type;
+        }
+    }
+
+    public function getHeaderActions(): array
+    {
+        return [
+            Action::make('downloadTemplate')
+                ->label('下载模板')
+                ->icon('heroicon-o-arrow-down-tray')
+                ->color('primary')
+                ->action('downloadTemplate'),
+
+            Action::make('import')
+                ->label('导入数据')
+                ->icon('heroicon-o-cloud-arrow-up')
+                ->color('success')
+                ->action('importData')
+                ->requiresConfirmation()
+                ->modalHeading('确认导入')
+                ->modalDescription('确定要导入Excel文件中的数据吗?此操作将同步到题库服务。')
+                ->modalSubmitActionLabel('确认导入'),
+        ];
+    }
+
+    public function downloadTemplate()
+    {
+        try {
+            $importer = app(TextbookExcelImporter::class);
+
+            if ($this->selectedType === 'textbook_series') {
+                $filePath = $importer->generateTextbookSeriesTemplate();
+                $fileName = '教材系列导入模板.xlsx';
+            } elseif ($this->selectedType === 'textbook') {
+                $filePath = $importer->generateTextbookTemplate();
+                $fileName = '教材导入模板.xlsx';
+            } else {
+                $filePath = $importer->generateTextbookCatalogTemplate();
+                $fileName = '教材目录导入模板.xlsx';
+            }
+
+            return response()->download($filePath, $fileName)->deleteFileAfterSend();
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('模板生成失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+
+    public function importData()
+    {
+        $this->validate();
+
+        try {
+            $importer = app(TextbookExcelImporter::class);
+
+            // 保存上传的文件
+            $path = $this->file->store('imports', 'local');
+
+            // 获取完整文件路径
+            $fullPath = storage_path('app/' . $path);
+
+            // 执行导入
+            if ($this->selectedType === 'textbook_series') {
+                $result = $importer->importTextbookSeries($fullPath);
+            } elseif ($this->selectedType === 'textbook') {
+                $result = $importer->importTextbook($fullPath);
+            } else {
+                // 教材目录导入 - 需要从Excel中获取textbook_id
+                $spreadsheet = IOFactory::load($fullPath);
+                $sheet = $spreadsheet->getActiveSheet();
+                $data = $sheet->toArray();
+
+                // 获取第一行数据中的教材ID
+                $firstRow = $data[1] ?? [];
+                $textbookId = (int)($firstRow[0] ?? 0);
+
+                if ($textbookId <= 0) {
+                    throw new \Exception('Excel文件中未找到有效的教材ID,请确保第一列包含教材ID');
+                }
+
+                $result = $importer->importTextbookCatalog($fullPath, $textbookId);
+            }
+
+            // 删除临时文件
+            Storage::disk('local')->delete($path);
+
+            $this->importResult = $result;
+
+            if ($result['success']) {
+                $message = sprintf(
+                    '导入完成!成功: %d 条,失败: %d 条',
+                    $result['success_count'],
+                    $result['error_count']
+                );
+
+                Notification::make()
+                    ->title('导入成功')
+                    ->body($message)
+                    ->success()
+                    ->send();
+            } else {
+                Notification::make()
+                    ->title('导入失败')
+                    ->body($result['message'])
+                    ->danger()
+                    ->send();
+            }
+
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('导入失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+
+            Log::error('Excel导入失败', ['error' => $e->getMessage()]);
+        }
+    }
+}

+ 1 - 0
app/Filament/Resources/MenuPermissionResource/Pages/CreateMenuPermission.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\MenuPermissionResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\MenuPermissionResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/MenuPermissionResource/Pages/EditMenuPermission.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\MenuPermissionResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\MenuPermissionResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/MenuPermissionResource/Pages/ListMenuPermissions.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\MenuPermissionResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\MenuPermissionResource;
 use App\Models\Teacher;

+ 1 - 0
app/Filament/Resources/OCRRecordResource/Pages/CreateOCRRecord.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\OCRRecordResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\OCRRecordResource;
 use Filament\Resources\Pages\CreateRecord;

+ 1 - 0
app/Filament/Resources/OCRRecordResource/Pages/ListOCRRecords.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\OCRRecordResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\OCRRecordResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/OCRRecordResource/Pages/ViewOCRRecord.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\OCRRecordResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\OCRRecordResource;
 use Filament\Resources\Pages\ViewRecord;

+ 1 - 0
app/Filament/Resources/StudentResource/Pages/CreateStudent.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\StudentResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\StudentResource;
 use Filament\Resources\Pages\CreateRecord;

+ 1 - 0
app/Filament/Resources/StudentResource/Pages/EditStudent.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\StudentResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\StudentResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/StudentResource/Pages/ListStudents.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\StudentResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\StudentResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/StudentResource/Pages/ViewStudent.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\StudentResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\StudentResource;
 use Filament\Resources\Pages\ViewRecord;

+ 1 - 0
app/Filament/Resources/TeacherResource/Pages/CreateTeacher.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\TeacherResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\TeacherResource;
 use App\Models\Teacher;

+ 1 - 0
app/Filament/Resources/TeacherResource/Pages/EditTeacher.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\TeacherResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\TeacherResource;
 use Filament\Resources\Pages\EditRecord;

+ 1 - 0
app/Filament/Resources/TeacherResource/Pages/ListTeachers.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\TeacherResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\TeacherResource;
 use Filament\Actions;

+ 1 - 0
app/Filament/Resources/TeacherResource/Pages/ViewTeacher.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Filament\Resources\TeacherResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
 
 use App\Filament\Resources\TeacherResource;
 use App\Models\Teacher;

+ 241 - 0
app/Filament/Resources/TextbookCatalogResource.php

@@ -0,0 +1,241 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\TextbookCatalogResource\Pages;
+use App\Models\TextbookCatalog;
+use App\Services\TextbookApiService;
+use BackedEnum;
+use UnitEnum;
+use Filament\Resources\Resource;
+use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Columns\BadgeColumn;
+use Filament\Actions\EditAction;
+use Filament\Actions\DeleteAction;
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookCatalogResource extends Resource
+{
+    protected static ?string $model = ApiTextbookCatalog::class;
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
+
+    protected static ?string $navigationLabel = '教材目录';
+
+    protected static UnitEnum|string|null $navigationGroup = '教材管理';
+
+    protected static ?int $navigationSort = 3;
+
+    protected static ?TextbookApiService $apiService = null;
+
+    public static function boot()
+    {
+        parent::boot();
+        static::$apiService = app(TextbookApiService::class);
+    }
+
+    protected static function getApiService(): TextbookApiService
+    {
+        if (!static::$apiService) {
+            static::$apiService = app(TextbookApiService::class);
+        }
+        return static::$apiService;
+    }
+
+    public static function table(Tables\Table $table): Tables\Table
+    {
+        return $table
+            ->columns([
+                TextColumn::make('textbook.official_title')
+                    ->label('教材')
+                    ->searchable()
+                    ->wrap(),
+
+                TextColumn::make('title')
+                    ->label('目录标题')
+                    ->searchable()
+                    ->wrap(),
+
+                TextColumn::make('display_no')
+                    ->label('编号')
+                    ->searchable(),
+
+                BadgeColumn::make('node_type')
+                    ->label('类型')
+                    ->formatStateUsing(fn (string $state): string => match ($state) {
+                        'chapter' => '章',
+                        'section' => '节',
+                        'subsection' => '小节',
+                        'item' => '条目',
+                        'project' => '项目学习',
+                        'reading' => '阅读材料',
+                        'practice' => '综合实践',
+                        'summary' => '复习',
+                        'appendix' => '附录',
+                        default => '其他',
+                    })
+                    ->color('info'),
+
+                TextColumn::make('depth')
+                    ->label('层级')
+                    ->sortable(),
+
+                TextColumn::make('page_start')
+                    ->label('起始页码')
+                    ->sortable(),
+
+                TextColumn::make('page_end')
+                    ->label('结束页码')
+                    ->sortable(),
+
+                TextColumn::make('created_at')
+                    ->label('创建时间')
+                    ->dateTime('Y-m-d H:i')
+                    ->sortable()
+                    ->toggleable(),
+            ])
+            ->filters([
+                Tables\Filters\SelectFilter::make('textbook_id')
+                    ->label('教材')
+                    ->options(function () {
+                        $textbooks = static::getApiService()->getTextbooks();
+                        $options = [];
+                        foreach ($textbooks['data'] ?? [] as $t) {
+                            $options[$t['id']] = $t['official_title'];
+                        }
+                        return $options;
+                    })
+                    ->searchable()
+                    ->preload(),
+
+                Tables\Filters\SelectFilter::make('node_type')
+                    ->label('节点类型')
+                    ->options([
+                        'chapter' => '章',
+                        'section' => '节',
+                        'subsection' => '小节',
+                        'item' => '条目',
+                        'project' => '项目学习',
+                        'reading' => '阅读材料',
+                        'practice' => '综合实践',
+                        'summary' => '复习',
+                        'appendix' => '附录',
+                        'custom' => '其他',
+                    ]),
+            ])
+            ->actions([
+                EditAction::make()
+                    ->label('编辑'),
+
+                DeleteAction::make()
+                    ->label('删除'),
+            ])
+            ->paginated([10, 25, 50, 100])
+            ->poll(null);  // 禁用自动刷新
+    }
+
+    public static function getEloquentQuery(): \Illuminate\Database\Eloquent\builder
+    {
+        // 完全不使用数据库查询,所有数据通过 API 获取
+        // 强制使用 migrations 表,这个表肯定存在
+        return parent::getEloquentQuery()->from('migrations')->whereRaw('1=0');
+    }
+
+    public static function getRecord(?string $key): ?Model
+    {
+        // 教材目录是嵌套数据,需要根据 textbook_id 获取
+        $textbookId = request()->get('textbook_id');
+        if (!$textbookId) {
+            return null;
+        }
+
+        $catalog = static::getApiService()->getTextbookCatalog((int) $textbookId, 'flat');
+        foreach ($catalog as $node) {
+            if ($node['id'] == $key) {
+                return new ApiTextbookCatalog($node);
+            }
+        }
+        return null;
+    }
+
+    public static function getRecords(): array
+    {
+        $textbookId = request()->get('tableFilters.textbook_id.value');
+        if (!$textbookId) {
+            return [];
+        }
+
+        $catalog = static::getApiService()->getTextbookCatalog((int) $textbookId, 'flat');
+        $records = [];
+        foreach ($catalog as $node) {
+            $records[] = new ApiTextbookCatalog($node);
+        }
+        return $records;
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ManageTextbookCatalogs::route('/'),
+        ];
+    }
+
+    public static function canViewAny(): bool
+    {
+        // 临时允许所有用户查看,等待权限系统完善
+        return true;
+    }
+
+    public static function canCreate(): bool
+    {
+        // 临时允许所有用户创建,等待权限系统完善
+        return true;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        // 临时允许所有用户编辑,等待权限系统完善
+        return true;
+    }
+
+    public static function canDelete(Model $record): bool
+    {
+        // 临时允许所有用户删除,等待权限系统完善
+        return true;
+    }
+
+    public static function canDeleteAny(): bool
+    {
+        // 临时允许所有用户批量删除,等待权限系统完善
+        return true;
+    }
+
+    protected static function deleteRecord(Model $record): bool
+    {
+        // 删除记录时,同时通过 API 删除题库服务中的数据
+        return static::getApiService()->deleteTextbookCatalog($record->id);
+    }
+}
+
+/**
+ * API 教材目录模型 - 完全通过 API 获取数据
+ * 这个类继承自 Model 但不执行任何数据库查询
+ */
+class ApiTextbookCatalog extends \Illuminate\Database\Eloquent\Model
+{
+    protected $table = 'migrations';  // 使用肯定存在的表
+
+    // 禁用时间戳
+    public $timestamps = false;
+
+    // 禁用所有fillable检查
+    protected $guarded = [];
+
+    public function __construct(array $attributes = [])
+    {
+        parent::__construct($attributes);
+    }
+}

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

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Filament\Resources\TextbookCatalogResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
+
+use App\Filament\Resources\TextbookCatalogResource;
+use Filament\Resources\Pages\ManageRecords;
+use Filament\Actions\Action;
+
+class ManageTextbookCatalogs extends ManageRecords
+{
+    protected static string $resource = TextbookCatalogResource::class;
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Action::make('import_excel')
+                ->label('Excel导入')
+                ->icon('heroicon-o-document-arrow-up')
+                ->color('success')
+                ->url(fn(): string =>
+                    route('filament.admin.pages.textbook-excel-import-page') . '?type=textbook_catalog'
+                ),
+        ];
+    }
+
+    protected function mutateTableQueryUsing(Builder $query): Builder
+    {
+        // 由于数据在 PostgreSQL 中,这里返回空查询
+        // 实际数据通过 API 获取
+        return $query->whereRaw('1=0');
+    }
+}

+ 524 - 0
app/Filament/Resources/TextbookResource.php

@@ -0,0 +1,524 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\TextbookResource\Pages;
+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\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
+{
+    protected static ?string $model = Textbook::class;
+
+    protected static ?string $recordTitleAttribute = 'official_title';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-book-open';
+
+    protected static ?string $navigationLabel = '教材管理';
+
+    protected static UnitEnum|string|null $navigationGroup = '教材管理';
+
+    protected static ?int $navigationSort = 2;
+
+    protected static ?TextbookApiService $apiService = null;
+
+    public static function boot()
+    {
+        parent::boot();
+        static::$apiService = app(TextbookApiService::class);
+    }
+
+    protected static function getApiService(): TextbookApiService
+    {
+        if (!static::$apiService) {
+            static::$apiService = app(TextbookApiService::class);
+        }
+        return static::$apiService;
+    }
+
+    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(),
+            ]);
+    }
+
+    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')
+                    ->label('系列')
+                    ->searchable()
+                    ->sortable(),
+
+                BadgeColumn::make('stage')
+                    ->label('学段')
+                    ->formatStateUsing(function ($state): string {
+                        // 确保返回字符串,即使输入是数组
+                        $state = is_array($state) ? ($state[0] ?? '') : $state;
+                        return match ((string) $state) {
+                            'primary' => '小学',
+                            'junior' => '初中',
+                            'senior' => '高中',
+                            default => (string) $state,
+                        };
+                    })
+                    ->color('success'),
+
+                TextColumn::make('grade')
+                    ->label('年级')
+                    ->sortable(),
+
+                TextColumn::make('semester')
+                    ->label('学期')
+                    ->formatStateUsing(function ($state): string {
+                        // 确保返回字符串,即使输入是数组
+                        $state = is_array($state) ? ($state[0] ?? null) : $state;
+                        return match ((int) $state) {
+                            1 => '上册',
+                            2 => '下册',
+                            default => '',
+                        };
+                    }),
+
+                TextColumn::make('official_title')
+                    ->label('官方书名')
+                    ->searchable()
+                    ->wrap(),
+
+                BadgeColumn::make('status')
+                    ->label('状态')
+                    ->formatStateUsing(function ($state): string {
+                        // 确保返回字符串,即使输入是数组
+                        $state = is_array($state) ? ($state[0] ?? '') : $state;
+                        return match ((string) $state) {
+                            'draft' => '草稿',
+                            'published' => '已发布',
+                            'archived' => '已归档',
+                            default => (string) $state,
+                        };
+                    })
+                    ->color(function ($state): string {
+                        // 确保返回字符串,即使输入是数组
+                        $state = is_array($state) ? ($state[0] ?? '') : $state;
+                        return match ((string) $state) {
+                            'draft' => 'warning',
+                            'published' => 'success',
+                            'archived' => 'gray',
+                            default => 'gray',
+                        };
+                    }),
+
+                TextColumn::make('approval_year')
+                    ->label('审定年份')
+                    ->sortable(),
+
+                TextColumn::make('created_at')
+                    ->label('创建时间')
+                    ->dateTime('Y-m-d H:i')
+                    ->sortable()
+                    ->toggleable(),
+            ])
+            ->filters([
+                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')
+                    ->label('状态')
+                    ->options([
+                        'draft' => '草稿',
+                        'published' => '已发布',
+                        'archived' => '已归档',
+                    ])
+                    ->query(function ($query, $data) {
+                        if ($data['value']) {
+                            // API 过滤
+                            return $query;
+                        }
+                    }),
+            ])
+            ->actions([
+                EditAction::make()
+                    ->label('编辑'),
+
+                DeleteAction::make()
+                    ->label('删除'),
+
+                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('id', 'desc')
+            ->paginated([10, 25, 50, 100])
+            ->poll('30s');
+    }
+
+    public static function getEloquentQuery(): \Illuminate\Database\Eloquent\builder
+    {
+        // 返回空查询,实际数据通过 API 获取
+        return parent::getEloquentQuery()->whereRaw('1=0');
+    }
+
+    public static function getRecord(?string $key): ?Model
+    {
+        $record = static::getApiService()->getTextbook((int) $key);
+        return $record ? new ApiTextbook($record) : null;
+    }
+
+    public static function getRecords(): array
+    {
+        $page = request()->get('page', 1);
+        $perPage = request()->get('perPage', 10);
+
+        $params = [
+            'page' => $page,
+            'per_page' => $perPage,
+        ];
+
+        $result = static::getApiService()->getTextbooks($params);
+
+        $records = [];
+        foreach ($result['data'] ?? [] as $item) {
+            $records[] = new ApiTextbook($item);
+        }
+
+        return $records;
+    }
+
+    protected static function newModel(array $data): Model
+    {
+        // 处理封面上传
+        if (isset($data['cover_path']) && $data['cover_path'] instanceof \Illuminate\Http\UploadedFile) {
+            $coverService = app(TextbookCoverStorageService::class);
+            $coverUrl = $coverService->uploadCover($data['cover_path']);
+            if ($coverUrl) {
+                $data['cover_path'] = $coverUrl;
+            } else {
+                // 上传失败则不保存封面路径
+                unset($data['cover_path']);
+            }
+        }
+
+        // ⚠️ 重要:通过 API 创建教材,数据同步到题库服务的 PostgreSQL 数据库
+        $record = static::getApiService()->createTextbook($data);
+        return new ApiTextbook($record['data']);
+    }
+
+    protected static function updateRecord(Model $record, array $data): Model
+    {
+        // 处理封面上传
+        if (isset($data['cover_path']) && $data['cover_path'] instanceof \Illuminate\Http\UploadedFile) {
+            $coverService = app(TextbookCoverStorageService::class);
+            $coverUrl = $coverService->uploadCover($data['cover_path'], (string) $record->id);
+            if ($coverUrl) {
+                $data['cover_path'] = $coverUrl;
+            } else {
+                // 上传失败则不更新封面路径
+                unset($data['cover_path']);
+            }
+        }
+
+        $result = static::getApiService()->updateTextbook($record->id, $data);
+        return new ApiTextbook($result['data']);
+    }
+
+    protected static function deleteRecord(Model $record): bool
+    {
+        // 删除记录时,同时通过 API 删除题库服务中的数据
+        return static::getApiService()->deleteTextbook($record->id);
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ManageTextbooks::route('/'),
+            'create' => Pages\CreateTextbook::route('/create'),
+            'edit' => Pages\EditTextbook::route('/{record}/edit'),
+        ];
+    }
+
+    public static function canViewAny(): bool
+    {
+        // 临时允许所有用户查看,等待权限系统完善
+        return true;
+    }
+
+    public static function getHeaderActions(): array
+    {
+        return [
+            \Filament\Actions\Action::make('import_excel')
+                ->label('Excel导入')
+                ->icon('heroicon-o-document-arrow-up')
+                ->color('success')
+                ->url(fn(): string =>
+                    route('filament.admin.pages.textbook-excel-import-page')
+                ),
+        ];
+    }
+
+    public static function canCreate(): bool
+    {
+        // 临时允许所有用户创建,等待权限系统完善
+        return true;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        // 临时允许所有用户编辑,等待权限系统完善
+        return true;
+    }
+
+    public static function canDelete(Model $record): bool
+    {
+        // 临时允许所有用户删除,等待权限系统完善
+        return true;
+    }
+
+    public static function canDeleteAny(): bool
+    {
+        // 临时允许所有用户批量删除,等待权限系统完善
+        return true;
+    }
+}
+
+/**
+ * API 教材模型
+ */
+class ApiTextbook extends Model
+{
+    protected $table = 'api_textbooks';
+
+    protected $fillable = [
+        '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',
+        'aliases', 'isbn', 'cover_path', 'status', 'created_at'
+    ];
+
+    protected $casts = [
+        // 移除所有 array cast,直接使用 JSON 字符串
+    ];
+
+    public function __construct(array $attributes = [])
+    {
+        foreach ($attributes as $key => $value) {
+            $this->setAttribute($key, $value);
+        }
+        parent::__construct($attributes);
+    }
+}

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

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

+ 13 - 0
app/Filament/Resources/TextbookResource/Pages/EditTextbook.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Pages;
+
+use App\Filament\Resources\TextbookResource;
+use Filament\Resources\Pages\EditRecord;
+
+class EditTextbook extends EditRecord
+{
+    protected static string $resource = TextbookResource::class;
+
+    protected string $view = 'filament.resources.textbook-resource.edit-record';
+}

+ 28 - 0
app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
+
+use App\Filament\Resources\TextbookResource;
+use Filament\Actions;
+use Filament\Resources\Pages\ManageRecords;
+
+class ManageTextbooks extends ManageRecords
+{
+    protected static string $resource = TextbookResource::class;
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Actions\CreateAction::make()
+                ->label('新建教材'),
+        ];
+    }
+
+    protected function mutateTableQueryUsing(Builder $query): Builder
+    {
+        // 由于数据在 PostgreSQL 中,这里返回空查询
+        // 实际数据通过 API 获取
+        return $query->whereRaw('1=0');
+    }
+}

+ 327 - 0
app/Filament/Resources/TextbookSeriesResource.php

@@ -0,0 +1,327 @@
+<?php
+
+namespace App\Filament\Resources;
+
+use App\Filament\Resources\TextbookSeriesResource\Pages;
+use App\Models\TextbookSeries;
+use App\Services\TextbookApiService;
+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\Resources\Resource;
+use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Columns\BadgeColumn;
+use Filament\Tables\Columns\ToggleColumn;
+use Filament\Actions\EditAction;
+use Filament\Actions\DeleteAction;
+use Filament\Actions\Action;
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookSeriesResource extends Resource
+{
+    protected static ?string $model = TextbookSeries::class;
+
+    protected static ?string $recordTitleAttribute = 'name';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
+
+    protected static ?string $navigationLabel = '教材系列';
+
+    protected static UnitEnum|string|null $navigationGroup = '教材管理';
+
+    protected static ?int $navigationSort = 1;
+
+    protected static ?TextbookApiService $apiService = null;
+
+    public static function boot()
+    {
+        parent::boot();
+        static::$apiService = app(TextbookApiService::class);
+    }
+
+    protected static function getApiService(): TextbookApiService
+    {
+        if (!static::$apiService) {
+            static::$apiService = app(TextbookApiService::class);
+        }
+        return static::$apiService;
+    }
+
+    public static function form(Schema $schema): Schema
+    {
+        return $schema
+            ->schema([
+                TextInput::make('name')
+                    ->label('系列名称')
+                    ->required()
+                    ->maxLength(128)
+                    ->placeholder('如:人教版、北师大版、华东师大版'),
+
+                TextInput::make('slug')
+                    ->label('别名')
+                    ->maxLength(64)
+                    ->placeholder('如:pep、bsd、ecnu'),
+
+                TextInput::make('publisher')
+                    ->label('出版社')
+                    ->maxLength(128)
+                    ->placeholder('如:人民教育出版社'),
+
+                TextInput::make('region')
+                    ->label('适用地区')
+                    ->maxLength(128)
+                    ->placeholder('如:全国、江苏省、浙江省'),
+
+                TextInput::make('stages')
+                    ->label('适用学段')
+                    ->helperText('JSON 格式,如:["primary", "junior"]')
+                    ->placeholder('["primary", "junior", "senior"]')
+                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
+                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state),
+
+                TextInput::make('start_year')
+                    ->label('起始年份')
+                    ->numeric()
+                    ->placeholder('如:2024'),
+
+                Select::make('is_active')
+                    ->label('是否启用')
+                    ->options(function (): array {
+                        return [
+                            1 => '已启用',
+                            0 => '已停用',
+                        ];
+                    })
+                    ->default(1)
+                    ->required()
+                    ->native(false)
+                    ->selectablePlaceholder(false)
+                    ->helperText('选择是否启用此教材系列'),
+
+                TextInput::make('sort_order')
+                    ->label('排序')
+                    ->numeric()
+                    ->default(0)
+                    ->helperText('数字越小排序越靠前'),
+
+                Textarea::make('meta')
+                    ->label('扩展信息')
+                    ->placeholder('JSON 格式,如:{"website": "http://example.com", "description": "说明"}')
+                    ->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(),
+            ]);
+    }
+
+    public static function table(Tables\Table $table): Tables\Table
+    {
+        return $table
+            ->columns([
+                TextColumn::make('id')
+                    ->label('ID')
+                    ->sortable()
+                    ->copyable()
+                    ->copyMessage('ID 已复制'),
+
+                TextColumn::make('name')
+                    ->label('系列名称')
+                    ->searchable()
+                    ->sortable(),
+
+                TextColumn::make('slug')
+                    ->label('别名')
+                    ->searchable()
+                    ->copyable(),
+
+                TextColumn::make('publisher')
+                    ->label('出版社')
+                    ->searchable(),
+
+                TextColumn::make('region')
+                    ->label('适用地区')
+                    ->searchable(),
+
+                BadgeColumn::make('stages')
+                    ->label('学段')
+                    ->formatStateUsing(function ($state): string {
+                        // 确保返回字符串,即使输入是数组
+                        if (is_string($state)) {
+                            $stages = json_decode($state, true) ?? [];
+                        } elseif (is_array($state)) {
+                            $stages = $state;
+                        } else {
+                            return '';
+                        }
+
+                        $stageMap = [
+                            'primary' => '小学',
+                            'junior' => '初中',
+                            'senior' => '高中',
+                        ];
+                        return implode(', ', array_map(fn($s) => $stageMap[$s] ?? $s, $stages));
+                    })
+                    ->separator(',')
+                    ->color('success'),
+
+                TextColumn::make('start_year')
+                    ->label('起始年份')
+                    ->sortable(),
+
+                ToggleColumn::make('is_active')
+                    ->label('启用状态'),
+
+                TextColumn::make('sort_order')
+                    ->label('排序')
+                    ->sortable(),
+
+                TextColumn::make('created_at')
+                    ->label('创建时间')
+                    ->dateTime('Y-m-d H:i')
+                    ->sortable()
+                    ->toggleable(),
+            ])
+            ->filters([
+                Tables\Filters\SelectFilter::make('is_active')
+                    ->label('启用状态')
+                    ->options([
+                        1 => '已启用',
+                        0 => '已停用',
+                    ]),
+            ])
+            ->actions([
+                EditAction::make()
+                    ->label('编辑'),
+
+                DeleteAction::make()
+                    ->label('删除'),
+
+                Action::make('view_textbooks')
+                    ->label('查看教材')
+                    ->icon('heroicon-o-book-open')
+                    ->url(fn(Model $record): string =>
+                        route('filament.admin.resources.textbooks.index', ['tableFilters[series_id][value]' => $record->id])
+                    ),
+            ])
+            ->bulkActions([
+                \Filament\Actions\BulkActionGroup::make([
+                    \Filament\Actions\DeleteBulkAction::make()
+                        ->label('批量删除'),
+
+                    \Filament\Actions\BulkAction::make('activate')
+                        ->label('批量启用')
+                        ->icon('heroicon-o-check-circle')
+                        ->action(function ($records) {
+                            foreach ($records as $record) {
+                                static::getApiService()->updateTextbookSeries($record->id, ['is_active' => true]);
+                            }
+                            Filament::notify('success', '批量启用成功');
+                        }),
+
+                    \Filament\Actions\BulkAction::make('deactivate')
+                        ->label('批量停用')
+                        ->icon('heroicon-o-x-circle')
+                        ->action(function ($records) {
+                            foreach ($records as $record) {
+                                static::getApiService()->updateTextbookSeries($record->id, ['is_active' => false]);
+                            }
+                            Filament::notify('success', '批量停用成功');
+                        }),
+                ]),
+            ])
+            ->defaultSort('sort_order')
+            ->paginated([10, 25, 50, 100])
+            ->poll('30s');
+    }
+
+    public static function getEloquentQuery(): \Illuminate\Database\Eloquent\builder
+    {
+        // 直接使用数据库查询
+        return parent::getEloquentQuery();
+    }
+
+    public static function getRecord(?string $key): ?Model
+    {
+        // 直接从数据库获取记录
+        return app(static::$model)->find($key);
+    }
+
+    protected static function newModel(array $data): Model
+    {
+        // 直接创建记录到数据库
+        $model = app(static::$model);
+        $model->fill($data);
+        $model->save();
+        return $model;
+    }
+
+    protected static function updateRecord(Model $record, array $data): Model
+    {
+        // 直接更新数据库记录
+        $record->update($data);
+        return $record;
+    }
+
+    protected static function deleteRecord(Model $record): bool
+    {
+        // 直接删除数据库记录
+        return $record->delete();
+    }
+
+    public static function getPages(): array
+    {
+        return [
+            'index' => Pages\ManageTextbookSeries::route('/'),
+            'create' => Pages\CreateTextbookSeries::route('/create'),
+            'edit' => Pages\EditTextbookSeries::route('/{record}/edit'),
+        ];
+    }
+
+    public static function canViewAny(): bool
+    {
+        // 临时允许所有用户查看,等待权限系统完善
+        return true;
+    }
+
+    public static function getHeaderActions(): array
+    {
+        return [
+            \Filament\Actions\Action::make('import_excel')
+                ->label('Excel导入')
+                ->icon('heroicon-o-document-arrow-up')
+                ->color('success')
+                ->url(fn(): string =>
+                    route('filament.admin.pages.textbook-excel-import-page')
+                ),
+        ];
+    }
+
+    public static function canCreate(): bool
+    {
+        // 临时允许所有用户创建,等待权限系统完善
+        return true;
+    }
+
+    public static function canEdit(Model $record): bool
+    {
+        // 临时允许所有用户编辑,等待权限系统完善
+        return true;
+    }
+
+    public static function canDelete(Model $record): bool
+    {
+        // 临时允许所有用户删除,等待权限系统完善
+        return true;
+    }
+
+    public static function canDeleteAny(): bool
+    {
+        // 临时允许所有用户批量删除,等待权限系统完善
+        return true;
+    }
+}

+ 59 - 0
app/Filament/Resources/TextbookSeriesResource/Pages/CreateTextbookSeries.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Filament\Resources\TextbookSeriesResource\Pages;
+
+use App\Filament\Resources\TextbookSeriesResource;
+use Filament\Resources\Pages\CreateRecord;
+use Illuminate\Support\Str;
+
+class CreateTextbookSeries extends CreateRecord
+{
+    protected static string $resource = TextbookSeriesResource::class;
+
+    protected function mutateFormDataBeforeCreate(array $data): array
+    {
+        // 如果没有填写 slug,自动生成
+        if (empty($data['slug']) && !empty($data['name'])) {
+            // 将中文名称转换为拼音首字母
+            $data['slug'] = $this->generateSlug($data['name']);
+        }
+
+        return $data;
+    }
+
+    /**
+     * 生成 slug(简化版)
+     * 简单处理:将中文转换为拼音首字母,英文直接使用
+     */
+    private function generateSlug(string $name): string
+    {
+        // 常见的教材系列名称到 slug 的映射
+        $mapping = [
+            '人教版' => 'pep',
+            '北师大版' => 'bsd',
+            '华东师大版' => 'ecnu',
+            '苏教版' => 'sjb',
+            '鲁教版' => 'ljb',
+            '冀教版' => 'hbb',
+            '沪教版' => 'shjb',
+            '粤教版' => 'gdjb',
+            '教科版' => 'jckb',
+            '外研版' => 'wyjb',
+            '湘教版' => 'xnjb',
+            '长春版' => 'ccb',
+            '语文版' => 'ywb',
+            '数学版' => 'sxb',
+            '英语版' => 'yyb',
+        ];
+
+        // 先查找映射
+        foreach ($mapping as $chinese => $slug) {
+            if (strpos($name, $chinese) !== false) {
+                return $slug;
+            }
+        }
+
+        // 如果没有找到映射,使用 slugify
+        return Str::slug($name);
+    }
+}

+ 57 - 0
app/Filament/Resources/TextbookSeriesResource/Pages/EditTextbookSeries.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Filament\Resources\TextbookSeriesResource\Pages;
+
+use App\Filament\Resources\TextbookSeriesResource;
+use Filament\Resources\Pages\EditRecord;
+use Illuminate\Support\Str;
+
+class EditTextbookSeries extends EditRecord
+{
+    protected static string $resource = TextbookSeriesResource::class;
+
+    protected function mutateFormDataBeforeSave(array $data): array
+    {
+        // 如果 slug 为空且 name 不为空,尝试生成
+        if (empty($data['slug']) && !empty($data['name'])) {
+            $data['slug'] = $this->generateSlug($data['name']);
+        }
+
+        return $data;
+    }
+
+    /**
+     * 生成 slug(简化版)
+     */
+    private function generateSlug(string $name): string
+    {
+        // 常见的教材系列名称到 slug 的映射
+        $mapping = [
+            '人教版' => 'pep',
+            '北师大版' => 'bsd',
+            '华东师大版' => 'ecnu',
+            '苏教版' => 'sjb',
+            '鲁教版' => 'ljb',
+            '冀教版' => 'hbb',
+            '沪教版' => 'shjb',
+            '粤教版' => 'gdjb',
+            '教科版' => 'jckb',
+            '外研版' => 'wyjb',
+            '湘教版' => 'xnjb',
+            '长春版' => 'ccb',
+            '语文版' => 'ywb',
+            '数学版' => 'sxb',
+            '英语版' => 'yyb',
+        ];
+
+        // 先查找映射
+        foreach ($mapping as $chinese => $slug) {
+            if (strpos($name, $chinese) !== false) {
+                return $slug;
+            }
+        }
+
+        // 如果没有找到映射,使用 slugify
+        return Str::slug($name);
+    }
+}

+ 84 - 0
app/Filament/Resources/TextbookSeriesResource/Pages/ManageTextbookSeries.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Filament\Resources\TextbookSeriesResource\Pages;
+use Illuminate\Database\Eloquent\Builder;
+
+use App\Filament\Resources\TextbookSeriesResource;
+use App\Services\TextbookApiService;
+use Filament\Actions;
+use Filament\Resources\Pages\ManageRecords;
+use Filament\Notifications\Notification;
+use Illuminate\Database\Eloquent\Model;
+
+class ManageTextbookSeries extends ManageRecords
+{
+    protected static string $resource = TextbookSeriesResource::class;
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Actions\CreateAction::make()
+                ->label('新建系列')
+                ->mutateFormDataUsing(function (array $data): array {
+                    // 调用 API 创建教材系列
+                    try {
+                        $apiService = app(TextbookApiService::class);
+                        $result = $apiService->createTextbookSeries($data);
+
+                        if ($result) {
+                            Notification::make()
+                                ->title('教材系列创建成功')
+                                ->success()
+                                ->send();
+                        }
+
+                        return $data;
+                    } catch (\Exception $e) {
+                        Notification::make()
+                            ->title('教材系列创建失败')
+                            ->body($e->getMessage())
+                            ->danger()
+                            ->send();
+
+                        return $data;
+                    }
+                }),
+        ];
+    }
+
+    protected function mutateTableQueryUsing(Builder $query): Builder
+    {
+        // 由于数据在 PostgreSQL 中,这里返回空查询
+        // 实际数据通过 API 获取
+        return $query->whereRaw('1=0');
+    }
+
+    public function mount(): void
+    {
+        parent::mount();
+
+        // 初始化时从 API 获取数据
+        $this->refreshTableDataFromApi();
+    }
+
+    protected function refreshTableDataFromApi(): void
+    {
+        // 通过 API 获取教材系列数据
+        try {
+            $apiService = app(TextbookApiService::class);
+            $seriesData = $apiService->getTextbookSeries();
+
+            // 将 API 数据转换为 Eloquent 集合(用于显示)
+            // 这里需要根据实际 API 响应格式调整
+            // 由于 Filament 表格需要 Eloquent 模型,这里只是示例
+            // 实际项目中可能需要创建临时的资源类来处理 API 数据
+
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('获取数据失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+}

+ 214 - 17
app/Http/Controllers/Api/IntelligentExamController.php

@@ -112,8 +112,6 @@ class IntelligentExamController extends Controller
                 ], 500);
             }
 
-            $paperPayload = $this->buildPaperPayload($paperId);
-
             // 生成真实 PDF(试卷 + 判卷),若失败则回退到 HTML 预览
             $pdfUrl = $this->pdfExportService->generateExamPdf($paperId)
                 ?? $this->questionBankService->exportExamToPdf($paperId)
@@ -122,14 +120,35 @@ class IntelligentExamController extends Controller
             $gradingUrl = $this->pdfExportService->generateGradingPdf($paperId)
                 ?? route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]);
 
+            // 生成判卷PDF URL(带答案版本)
+            $gradingPdfUrl = $this->pdfExportService->generateGradingPdf($paperId)
+                ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
+
+            // 构建包含完整信息的试卷内容
+            $examContent = $this->buildCompleteExamContent($paperId);
+
             $payload = [
                 'success' => true,
                 'message' => '智能试卷生成成功',
                 'data' => [
-                    'paper' => $paperPayload,
-                    'pdf_url' => $pdfUrl,
-                    'grading_url' => $gradingUrl,
+                    // 第一部分:组成卷子的所有内容
+                    'exam_content' => $examContent,
+
+                    // 第二部分:卷面和判卷的URL
+                    'urls' => [
+                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
+                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
+                    ],
+
+                    // 第三部分:卷面和判卷的PDF
+                    'pdfs' => [
+                        'exam_paper_pdf' => $pdfUrl,  // 纯试卷PDF(无答案)
+                        'grading_pdf' => $gradingPdfUrl,  // 判卷PDF(带答案和解析)
+                    ],
+
+                    // 额外信息
                     'stats' => $result['stats'] ?? null,
+                    'generated_at' => now()->toISOString(),
                 ],
             ];
 
@@ -166,10 +185,11 @@ class IntelligentExamController extends Controller
 
     private function normalizeQuestionTypeRatio(array $input): array
     {
+        // 默认按 4:2:4
         $defaults = [
             '选择题' => 40,
-            '填空题' => 30,
-            '解答题' => 30,
+            '填空题' => 20,
+            '解答题' => 40,
         ];
 
         $normalized = [];
@@ -183,7 +203,17 @@ class IntelligentExamController extends Controller
             }
         }
 
-        return array_merge($defaults, $normalized);
+        $merged = array_merge($defaults, $normalized);
+
+        // 归一化到 100%
+        $sum = array_sum($merged);
+        if ($sum > 0) {
+            foreach ($merged as $k => $v) {
+                $merged[$k] = round(($v / $sum) * 100, 2);
+            }
+        }
+
+        return $merged;
     }
 
     private function normalizeQuestionTypeKey(string $key): ?string
@@ -295,30 +325,197 @@ class IntelligentExamController extends Controller
         return 10;
     }
 
-    private function buildPaperPayload(string $paperId): array
+    /**
+     * 构建完整的试卷信息(包含所有题目详情)
+     */
+    private function buildCompleteExamContent(string $paperId): array
     {
         $paper = Paper::with('questions')->find($paperId);
         $questions = $paper ? $paper->questions : collect();
 
         return [
-            'paper_id' => $paperId,
-            'paper_name' => $paper?->paper_name ?? '',
-            'student_id' => $paper?->student_id ?? '',
-            'teacher_id' => $paper?->teacher_id ?? '',
-            'total_questions' => $questions->count(),
-            'total_score' => $paper?->total_score ?? 0,
-            'difficulty_category' => $paper?->difficulty_category ?? '基础',
+            // 试卷基本信息
+            'paper_info' => [
+                'paper_id' => $paperId,
+                'paper_name' => $paper?->paper_name ?? '',
+                'student_id' => $paper?->student_id ?? '',
+                'teacher_id' => $paper?->teacher_id ?? '',
+                'total_questions' => $questions->count(),
+                'total_score' => $paper?->total_score ?? 0,
+                'difficulty_category' => $paper?->difficulty_category ?? '基础',
+                'created_at' => $paper?->created_at?->toISOString(),
+                'updated_at' => $paper?->updated_at?->toISOString(),
+            ],
+
+            // 完整题目信息
             'questions' => $questions->map(function (PaperQuestion $q) {
+                // 构建选择题选项(如果适用)
+                $options = [];
+                if ($q->question_type === 'choice') {
+                    // 从题目文本中提取选项
+                    $questionText = $q->question_text ?? '';
+                    preg_match_all('/([A-D])\s*[\.\、\:]\s*([^A-D]+?)(?=[A-D]\s*[\.\、\:]|$)/u', $questionText, $matches, PREG_SET_ORDER);
+                    foreach ($matches as $match) {
+                        $options[] = [
+                            'label' => $match[1],
+                            'content' => trim($match[2]),
+                        ];
+                    }
+                }
+
                 return [
-                    'question_bank_id' => $q->question_bank_id,
+                    // 基本信息
                     'question_number' => $q->question_number,
+                    'question_id' => $q->question_id,
+                    'question_bank_id' => $q->question_bank_id,
                     'question_type' => $q->question_type,
                     'knowledge_point' => $q->knowledge_point,
                     'difficulty' => $q->difficulty,
                     'score' => $q->score,
                     'estimated_time' => $q->estimated_time,
+
+                    // 题目内容
+                    'stem' => $q->question_text ?? '',
+                    'options' => $options,
+
+                    // 答案和解析
+                    'correct_answer' => $q->correct_answer ?? '',
+                    'solution' => $q->solution ?? '',
+
+                    // 元数据
+                    'student_answer' => $q->student_answer,
+                    'is_correct' => $q->is_correct,
+                    'score_obtained' => $q->score_obtained,
+                    'score_ratio' => $q->score_ratio,
+                    'teacher_comment' => $q->teacher_comment,
+                    'graded_at' => $q->graded_at?->toISOString(),
+                    'graded_by' => $q->graded_by,
+
+                    // 题目属性
+                    'metadata' => [
+                        'has_solution' => !empty($q->solution),
+                        'is_choice' => $q->question_type === 'choice',
+                        'is_fill' => $q->question_type === 'fill',
+                        'is_answer' => $q->question_type === 'answer',
+                        'difficulty_label' => $this->getDifficultyLabel($q->difficulty),
+                        'question_type_label' => $this->getQuestionTypeLabel($q->question_type),
+                    ],
                 ];
             })->toArray(),
+
+            // 统计信息
+            'statistics' => [
+                'type_distribution' => $this->getTypeDistribution($questions),
+                'difficulty_distribution' => $this->getDifficultyDistribution($questions),
+                'knowledge_point_distribution' => $this->getKnowledgePointDistribution($questions),
+                'total_score' => $questions->sum('score'),
+                'average_difficulty' => $questions->avg('difficulty'),
+                'total_estimated_time' => $questions->sum('estimated_time'),
+            ],
+
+            // 知识点和技能标签
+            'knowledge_points' => $questions->pluck('knowledge_point')->unique()->filter()->values()->toArray(),
+            'skills' => $this->extractSkillsFromQuestions($questions),
         ];
     }
+
+    /**
+     * 获取题型中文标签
+     */
+    private function getQuestionTypeLabel(string $type): string
+    {
+        return match($type) {
+            'choice' => '选择题',
+            'fill' => '填空题',
+            'answer' => '解答题',
+            default => '未知题型'
+        };
+    }
+
+    /**
+     * 获取难度中文标签
+     */
+    private function getDifficultyLabel(?float $difficulty): string
+    {
+        if ($difficulty === null) return '未知';
+        if ($difficulty <= 0.4) return '基础';
+        if ($difficulty <= 0.7) return '中等';
+        return '拔高';
+    }
+
+    /**
+     * 获取题型分布
+     */
+    private function getTypeDistribution($questions): array
+    {
+        $distribution = [];
+        foreach ($questions as $q) {
+            $type = $q->question_type;
+            $distribution[$type] = ($distribution[$type] ?? 0) + 1;
+        }
+        return $distribution;
+    }
+
+    /**
+     * 获取难度分布
+     */
+    private function getDifficultyDistribution($questions): array
+    {
+        $distribution = [];
+        foreach ($questions as $q) {
+            $label = $this->getDifficultyLabel($q->difficulty);
+            $distribution[$label] = ($distribution[$label] ?? 0) + 1;
+        }
+        return $distribution;
+    }
+
+    /**
+     * 获取知识点分布
+     */
+    private function getKnowledgePointDistribution($questions): array
+    {
+        $distribution = [];
+        foreach ($questions as $q) {
+            $kp = $q->knowledge_point;
+            if ($kp) {
+                $distribution[$kp] = ($distribution[$kp] ?? 0) + 1;
+            }
+        }
+        return $distribution;
+    }
+
+    /**
+     * 从题目中提取技能标签
+     */
+    private function extractSkillsFromQuestions($questions): array
+    {
+        $skills = [];
+        // 注意:由于题库在PostgreSQL中,MySQL的questions表可能不存在
+        // 我们从PaperQuestion的solution或metadata中提取技能信息
+        foreach ($questions as $q) {
+            // 从解题过程中提取技能关键词
+            $solution = $q->solution ?? '';
+            if ($solution) {
+                // 简单的技能提取(基于常见关键词)
+                $skillKeywords = ['代入法', '配方法', '因式分解', '换元法', '判别式', '求根公式', '韦达定理'];
+                foreach ($skillKeywords as $keyword) {
+                    if (strpos($solution, $keyword) !== false) {
+                        $skills[] = $keyword;
+                    }
+                }
+            }
+
+            // 从题目文本中提取技能标签(如果存在)
+            $stem = $q->question_text ?? '';
+            if ($stem) {
+                // 尝试从题干中提取技能信息(格式如:{技能1,技能2})
+                preg_match_all('/\{([^}]+)\}/', $stem, $matches);
+                foreach ($matches[1] as $match) {
+                    $skillList = array_map('trim', explode(',', $match));
+                    $skills = array_merge($skills, $skillList);
+                }
+            }
+        }
+        return array_unique(array_filter($skills));
+    }
 }

+ 448 - 0
app/Http/Controllers/Api/TextbookApiController.php

@@ -0,0 +1,448 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Services\TextbookApiService;
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Log;
+
+class TextbookApiController extends Controller
+{
+    protected TextbookApiService $textbookService;
+
+    public function __construct(TextbookApiService $textbookService)
+    {
+        $this->textbookService = $textbookService;
+    }
+
+    /**
+     * 获取教材列表(按年级排序)
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function index(Request $request): JsonResponse
+    {
+        try {
+            $params = [
+                'page' => $request->get('page', 1),
+                'per_page' => $request->get('per_page', 50),
+            ];
+
+            // 可选过滤参数
+            if ($request->has('grade')) {
+                $params['grade'] = $request->get('grade');
+            }
+            if ($request->has('stage')) {
+                $params['stage'] = $this->convertStageToCode($request->get('stage'));
+            }
+            if ($request->has('semester')) {
+                $params['semester'] = $this->convertSemesterToCode($request->get('semester'));
+            }
+            if ($request->has('series_id')) {
+                $params['series_id'] = $request->get('series_id');
+            }
+            if ($request->has('status')) {
+                $params['status'] = $request->get('status');
+            }
+
+            $result = $this->textbookService->getTextbooks($params);
+
+            // 格式化返回数据
+            $textbooks = $this->formatTextbookList($result['data'] ?? []);
+
+            // 按年级排序
+            usort($textbooks, function ($a, $b) {
+                // 先按学段排序:小学 < 初中 < 高中
+                $stageOrder = ['primary' => 1, 'junior' => 2, 'senior' => 3];
+                $stageA = $stageOrder[$a['stage_code']] ?? 99;
+                $stageB = $stageOrder[$b['stage_code']] ?? 99;
+
+                if ($stageA !== $stageB) {
+                    return $stageA - $stageB;
+                }
+
+                // 再按年级排序
+                $gradeA = $a['grade'] ?? 0;
+                $gradeB = $b['grade'] ?? 0;
+
+                if ($gradeA !== $gradeB) {
+                    return $gradeA - $gradeB;
+                }
+
+                // 最后按学期排序
+                $semesterA = $a['semester_code'] ?? 0;
+                $semesterB = $b['semester_code'] ?? 0;
+
+                return $semesterA - $semesterB;
+            });
+
+            return response()->json([
+                'success' => true,
+                'data' => $textbooks,
+                'meta' => $result['meta'] ?? [
+                    'page' => $params['page'],
+                    'per_page' => $params['per_page'],
+                    'total' => count($textbooks),
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取教材列表失败', ['error' => $e->getMessage()]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取教材列表失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 根据年级获取教材
+     *
+     * @param int $grade 年级(1-12)
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getByGrade(int $grade, Request $request): JsonResponse
+    {
+        try {
+            // 根据年级自动判断学段
+            $stage = $this->getStageByGrade($grade);
+
+            $params = [
+                'grade' => $grade,
+                'stage' => $stage,
+                'per_page' => 100,
+            ];
+
+            if ($request->has('semester')) {
+                $params['semester'] = $this->convertSemesterToCode($request->get('semester'));
+            }
+            if ($request->has('series_id')) {
+                $params['series_id'] = $request->get('series_id');
+            }
+
+            $result = $this->textbookService->getTextbooks($params);
+            $textbooks = $this->formatTextbookList($result['data'] ?? []);
+
+            // 按学期排序
+            usort($textbooks, function ($a, $b) {
+                return ($a['semester_code'] ?? 0) - ($b['semester_code'] ?? 0);
+            });
+
+            return response()->json([
+                'success' => true,
+                'data' => $textbooks,
+                'meta' => [
+                    'grade' => $grade,
+                    'stage' => $this->getStageLabel($stage),
+                    'total' => count($textbooks),
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('根据年级获取教材失败', [
+                'grade' => $grade,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取教材失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取单个教材详情
+     *
+     * @param int $id 教材ID
+     * @return JsonResponse
+     */
+    public function show(int $id): JsonResponse
+    {
+        try {
+            $textbook = $this->textbookService->getTextbook($id);
+
+            if (!$textbook) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '教材不存在'
+                ], 404);
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => $this->formatTextbook($textbook)
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取教材详情失败', [
+                'id' => $id,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取教材详情失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取教材系列列表
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getSeries(Request $request): JsonResponse
+    {
+        try {
+            $params = [];
+
+            if ($request->has('stage')) {
+                $params['stage'] = $this->convertStageToCode($request->get('stage'));
+            }
+
+            $result = $this->textbookService->getTextbookSeries($params);
+
+            $series = array_map(function ($item) {
+                return [
+                    'id' => $item['id'],
+                    'name' => $item['name'],
+                    'slug' => $item['slug'] ?? '',
+                    'publisher' => $item['publisher'] ?? '',
+                    'region' => $item['region'] ?? '全国',
+                    'stages' => $this->formatStages($item['stages'] ?? '[]'),
+                    'is_active' => $item['is_active'] ?? true,
+                    'sort_order' => $item['sort_order'] ?? 0,
+                ];
+            }, $result['data'] ?? []);
+
+            // 按排序字段排序
+            usort($series, fn($a, $b) => ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0));
+
+            return response()->json([
+                'success' => true,
+                'data' => $series,
+                'meta' => [
+                    'total' => count($series),
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取教材系列失败', ['error' => $e->getMessage()]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取教材系列失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取教材目录
+     *
+     * @param int $textbookId 教材ID
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getCatalog(int $textbookId, Request $request): JsonResponse
+    {
+        try {
+            $format = $request->get('format', 'tree'); // tree 或 flat
+            $catalog = $this->textbookService->getTextbookCatalog($textbookId, $format);
+
+            return response()->json([
+                'success' => true,
+                'data' => $catalog,
+                'meta' => [
+                    'textbook_id' => $textbookId,
+                    'format' => $format,
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取教材目录失败', [
+                'textbook_id' => $textbookId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取教材目录失败: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 格式化教材列表
+     */
+    private function formatTextbookList(array $textbooks): array
+    {
+        return array_map(fn($item) => $this->formatTextbook($item), $textbooks);
+    }
+
+    /**
+     * 格式化单个教材
+     */
+    private function formatTextbook(array $textbook): array
+    {
+        $stage = $textbook['stage'] ?? '';
+        $semester = $textbook['semester'] ?? null;
+
+        return [
+            'id' => $textbook['id'],
+            'name' => $textbook['official_title'] ?? $textbook['display_title'] ?? '',
+            'display_name' => $textbook['display_title'] ?? $textbook['official_title'] ?? '',
+            'cover' => $this->formatCoverUrl($textbook['cover_path'] ?? ''),
+            'series_id' => $textbook['series_id'] ?? null,
+            'series_name' => $textbook['series']['name'] ?? '',
+            'publisher' => $textbook['series']['publisher'] ?? '',
+            'stage' => $this->getStageLabel($stage),
+            'stage_code' => $stage,
+            'grade' => $textbook['grade'] ?? null,
+            'grade_label' => $this->getGradeLabel($textbook['grade'] ?? null, $stage),
+            'semester' => $this->getSemesterLabel($semester),
+            'semester_code' => $semester,
+            'module_type' => $textbook['module_type'] ?? null,
+            'volume_no' => $textbook['volume_no'] ?? null,
+            'isbn' => $textbook['isbn'] ?? '',
+            'approval_year' => $textbook['approval_year'] ?? null,
+            'curriculum_standard_year' => $textbook['curriculum_standard_year'] ?? null,
+            'status' => $textbook['status'] ?? 'draft',
+            'sort_order' => $textbook['sort_order'] ?? 0,
+        ];
+    }
+
+    /**
+     * 格式化封面URL
+     */
+    private function formatCoverUrl(?string $coverPath): string
+    {
+        if (empty($coverPath)) {
+            return '';
+        }
+
+        // 如果已经是完整URL,直接返回
+        if (str_starts_with($coverPath, 'http://') || str_starts_with($coverPath, 'https://')) {
+            return $coverPath;
+        }
+
+        // 本地存储路径,添加域名
+        return url('/storage/' . ltrim($coverPath, '/'));
+    }
+
+    /**
+     * 学段代码转中文
+     */
+    private function getStageLabel(string $stage): string
+    {
+        return match ($stage) {
+            'primary' => '小学',
+            'junior' => '初中',
+            'senior' => '高中',
+            default => $stage,
+        };
+    }
+
+    /**
+     * 中文学段转代码
+     */
+    private function convertStageToCode(string $stage): string
+    {
+        return match ($stage) {
+            '小学' => 'primary',
+            '初中' => 'junior',
+            '高中' => 'senior',
+            default => $stage,
+        };
+    }
+
+    /**
+     * 学期代码转中文
+     */
+    private function getSemesterLabel(?int $semester): string
+    {
+        return match ($semester) {
+            1 => '上册',
+            2 => '下册',
+            default => '',
+        };
+    }
+
+    /**
+     * 中文学期转代码
+     */
+    private function convertSemesterToCode(string $semester): ?int
+    {
+        return match ($semester) {
+            '上册', '1' => 1,
+            '下册', '2' => 2,
+            default => null,
+        };
+    }
+
+    /**
+     * 年级标签
+     */
+    private function getGradeLabel(?int $grade, string $stage): string
+    {
+        if ($grade === null) {
+            return '';
+        }
+
+        return match ($stage) {
+            'primary' => $grade . '年级',
+            'junior' => match ($grade) {
+                7 => '七年级',
+                8 => '八年级',
+                9 => '九年级',
+                default => $grade . '年级',
+            },
+            'senior' => match ($grade) {
+                10 => '高一',
+                11 => '高二',
+                12 => '高三',
+                default => '高' . ($grade - 9),
+            },
+            default => $grade . '年级',
+        };
+    }
+
+    /**
+     * 根据年级判断学段
+     */
+    private function getStageByGrade(int $grade): string
+    {
+        if ($grade >= 1 && $grade <= 6) {
+            return 'primary';
+        } elseif ($grade >= 7 && $grade <= 9) {
+            return 'junior';
+        } else {
+            return 'senior';
+        }
+    }
+
+    /**
+     * 格式化学段数组
+     */
+    private function formatStages($stages): array
+    {
+        if (is_string($stages)) {
+            $stages = json_decode($stages, true) ?? [];
+        }
+
+        if (!is_array($stages)) {
+            return [];
+        }
+
+        return array_map(fn($s) => [
+            'code' => $s,
+            'label' => $this->getStageLabel($s),
+        ], $stages);
+    }
+}

+ 3 - 1
app/Http/Controllers/ExamAnalysisPdfController.php

@@ -12,16 +12,18 @@ class ExamAnalysisPdfController extends Controller
     {
         $paperId = $request->query('paperId');
         $studentId = $request->query('studentId');
+        $recordId = $request->query('recordId'); // 可选的OCR记录ID
 
         if (!$paperId || !$studentId) {
             return response('paperId 和 studentId 不能为空', 400);
         }
 
-        $pdfUrl = $pdfExportService->generateAnalysisReportPdf($paperId, $studentId);
+        $pdfUrl = $pdfExportService->generateAnalysisReportPdf($paperId, $studentId, $recordId);
         if (!$pdfUrl) {
             Log::error('ExamAnalysisPdfController: 学情报告生成失败', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
+                'record_id' => $recordId,
             ]);
             return response('生成学情报告失败,请稍后重试', 500);
         }

+ 47 - 36
app/Http/Controllers/ExamPdfController.php

@@ -11,30 +11,37 @@ use Illuminate\Support\Facades\Log;
 class ExamPdfController extends Controller
 {
     /**
-     * 根据题目类型字段或内容判断题型
+     * 根据题目内容或类型字段判断题型
      */
     private function determineQuestionType(array $question): string
     {
-        // 0. 如果题目已有明确类型,直接返回
-        if (!empty($question['question_type'])) {
-            $type = $question['question_type'];
-            if ($type === 'choice' || $type === '选择题') return 'choice';
-            if ($type === 'fill' || $type === '填空题') return 'fill';
-            if ($type === 'answer' || $type === '解答题') return 'answer';
-        }
-
-        if (!empty($question['type'])) {
-            $type = $question['type'];
-            if ($type === 'choice' || $type === '选择题') return 'choice';
-            if ($type === 'fill' || $type === '填空题') return 'fill';
-            if ($type === 'answer' || $type === '解答题') return 'answer';
-        }
-
-        $tags = $question['tags'] ?? '';
+        // 优先根据题目内容判断(而不是数据库字段)
         $stem = $question['stem'] ?? $question['content'] ?? '';
+        $tags = $question['tags'] ?? '';
         $skills = $question['skills'] ?? [];
 
-        // 1. 根据技能点判断
+        // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
+        if (is_string($stem)) {
+            // 选择题特征:必须包含 A. B. C. D. 四个选项(至少2个)
+            $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
+            $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
+            $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
+            $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
+            $hasOptionE = preg_match('/\bE\s*[\.\、\:]/', $stem) || preg_match('/\(E\)/', $stem) || preg_match('/^E[\.\s]/', $stem);
+
+            // 至少有2个选项就认为是选择题(降低阈值)
+            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0) + ($hasOptionE ? 1 : 0);
+            if ($optionCount >= 2) {
+                return 'choice';
+            }
+
+            // 检查是否有"( )"或"( )"括号,这通常是选择题的标志
+            if (preg_match('/(\s*)|\(\s*\)/', $stem) && (strpos($stem, 'A.') !== false || strpos($stem, 'B.') !== false || strpos($stem, 'C.') !== false || strpos($stem, 'D.') !== false)) {
+                return 'choice';
+            }
+        }
+
+        // 2. 根据技能点判断
         if (is_array($skills)) {
             $skillsStr = implode(',', $skills);
             if (strpos($skillsStr, '选择题') !== false) return 'choice';
@@ -42,7 +49,22 @@ class ExamPdfController extends Controller
             if (strpos($skillsStr, '解答题') !== false) return 'answer';
         }
 
-        // 2. 根据标签判断
+        // 3. 根据题目已有类型字段判断(作为后备)
+        if (!empty($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'])) {
+            $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';
+            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
+        }
+
+        // 4. 根据标签判断
         if (is_string($tags)) {
             if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
                 return 'choice';
@@ -55,22 +77,7 @@ class ExamPdfController extends Controller
             }
         }
 
-        // 3. 根据题干内容判断 - 必须有明确的选项格式才是选择题
-        if (is_string($stem)) {
-            // 选择题特征:必须包含 A. B. C. D. 四个选项(至少3个)
-            $hasOptionA = preg_match('/\bA\s*[\.、\:]/', $stem) || preg_match('/\(A\)/', $stem);
-            $hasOptionB = preg_match('/\bB\s*[\.、\:]/', $stem) || preg_match('/\(B\)/', $stem);
-            $hasOptionC = preg_match('/\bC\s*[\.、\:]/', $stem) || preg_match('/\(C\)/', $stem);
-            $hasOptionD = preg_match('/\bD\s*[\.、\:]/', $stem) || preg_match('/\(D\)/', $stem);
-
-            // 至少有3个选项才认为是选择题
-            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
-            if ($optionCount >= 3) {
-                return 'choice';
-            }
-        }
-
-        // 4. 填空题特征:连续下划线或明显的填空括号
+        // 5. 填空题特征:连续下划线或明显的填空括号
         if (is_string($stem)) {
             // 检查填空题特征:连续下划线
             if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
@@ -82,7 +89,7 @@ class ExamPdfController extends Controller
             }
         }
 
-        // 5. 根据题干长度和内容判断
+        // 6. 根据题干内容关键词判断
         if (is_string($stem)) {
             // 有证明、解答、计算、求证等关键词的是解答题
             if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
@@ -436,6 +443,8 @@ class ExamPdfController extends Controller
                     'kp_code' => $pq->knowledge_point,
                     'question_type' => $pq->question_type ?? 'answer', // 包含题目类型
                     'stem' => $pq->question_text ?? '题目内容缺失', // 如果有存储题目文本
+                    'solution' => $pq->solution ?? '', // 保存解题思路!
+                    'answer' => $pq->correct_answer ?? '', // 保存正确答案
                     'difficulty' => $pq->difficulty ?? 0.5,
                     'score' => $pq->score ?? 5, // 包含已计算的分值
                     'tags' => '',
@@ -637,6 +646,8 @@ class ExamPdfController extends Controller
                     'kp_code' => $pq->knowledge_point,
                     'question_type' => $pq->question_type ?? 'answer',
                     'stem' => $pq->question_text ?? '题目内容缺失',
+                    'solution' => $pq->solution ?? '', // 保存解题思路!
+                    'answer' => $pq->correct_answer ?? '', // 保存正确答案
                     'difficulty' => $pq->difficulty ?? 0.5,
                     'score' => $pq->score ?? 5,
                     'tags' => '',

+ 2 - 0
app/Models/OCRRecord.php

@@ -26,6 +26,7 @@ class OCRRecord extends Model
         'updated_at',
         'analysis_id',
         'image_path', // 添加兼容性字段访问器
+        'analysis_pdf_url', // 学情分析PDF URL
     ];
 
     protected $casts = [
@@ -33,6 +34,7 @@ class OCRRecord extends Model
         'updated_at' => 'datetime',
         'image_count' => 'integer',
         'total_questions' => 'integer',
+        'analysis_pdf_url' => 'string',
     ];
 
     public function questions(): HasMany

+ 4 - 0
app/Models/Paper.php

@@ -27,6 +27,8 @@ class Paper extends Model
         'difficulty_category',
         'completed_at',
         'analysis_id', // AI分析记录ID
+        'exam_pdf_url', // 试卷PDF URL
+        'grading_pdf_url', // 判卷PDF URL
     ];
     
     protected $casts = [
@@ -40,6 +42,8 @@ class Paper extends Model
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
         'completed_at' => 'datetime',
+        'exam_pdf_url' => 'string',
+        'grading_pdf_url' => 'string',
     ];
     
     /**

+ 6 - 0
app/Models/PaperQuestion.php

@@ -16,6 +16,8 @@ class PaperQuestion extends Model
         'knowledge_point',
         'question_type',  // choice-选择题, fill-填空题, answer-解答题
         'question_text',
+        'correct_answer',
+        'solution',  // 解题思路
         'difficulty',
         'score',
         'estimated_time',
@@ -23,6 +25,10 @@ class PaperQuestion extends Model
         'student_answer',
         'is_correct',
         'score_obtained',
+        'score_ratio',
+        'teacher_comment',
+        'graded_at',
+        'graded_by',
     ];
     
     protected $casts = [

+ 2 - 0
app/Models/Student.php

@@ -26,11 +26,13 @@ class Student extends Model
         'class_name',
         'teacher_id',
         'remark',
+        'student_report_pdf_url', // 学生学情报告PDF URL
     ];
 
     protected $casts = [
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
+        'student_report_pdf_url' => 'string',
     ];
 
     protected static function boot()

+ 48 - 0
app/Models/TextbookCatalog.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookCatalog extends Model
+{
+    protected $table = 'textbook_catalog_nodes';
+
+    protected $fillable = [
+        'textbook_id',
+        'parent_id',
+        'node_type',
+        'title',
+        'display_no',
+        'depth',
+        'sort_order',
+        'path_key',
+        'is_required',
+        'is_elective',
+        'tags',
+        'page_start',
+        'page_end',
+        'meta',
+    ];
+
+    protected $casts = [
+        'is_required' => 'boolean',
+        'is_elective' => 'boolean',
+        // 移除 array cast,直接使用 JSON 字符串
+    ];
+
+    public function textbook()
+    {
+        return $this->belongsTo(Textbook::class);
+    }
+
+    public function parent()
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    public function children()
+    {
+        return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
+    }
+}

+ 32 - 0
app/Models/TextbookSeries.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class TextbookSeries extends Model
+{
+    protected $table = 'textbook_series';
+
+    protected $fillable = [
+        'name',
+        'slug',
+        'publisher',
+        'region',
+        'stages',
+        'start_year',
+        'is_active',
+        'sort_order',
+        'meta',
+    ];
+
+    protected $casts = [
+        'is_active' => 'boolean',
+        // 移除 array cast,直接使用 JSON 字符串
+    ];
+
+    public function textbooks()
+    {
+        return $this->hasMany(Textbook::class);
+    }
+}

+ 8 - 1
app/Providers/Filament/AdminPanelProvider.php

@@ -13,6 +13,7 @@ use App\Filament\Pages\Statistics\KnowledgePointStats;
 use App\Filament\Pages\StudentDashboard;
 use App\Filament\Pages\StudentManagement;
 use App\Filament\Pages\StudentKnowledgeGraphPage;
+use App\Filament\Pages\TextbookImport\TextbookExcelImportPage;
 use App\Filament\Widgets\DashboardQuickLinks;
 use Filament\Http\Middleware\Authenticate;
 use Filament\Http\Middleware\AuthenticateSession;
@@ -42,9 +43,14 @@ class AdminPanelProvider extends PanelProvider
             ->path('admin')
             ->login(CustomLogin::class)
             ->colors([
-                'primary' => Color::hex('#4163ff'),
+                'primary' => Color::hex('#0ea5e9'), // 使用DaisyUI的primary色
+                'gray' => Color::hex('#6b7280'),
+                'warning' => Color::hex('#f59e0b'),
+                'success' => Color::hex('#10b981'),
+                'danger' => Color::hex('#ef4444'),
             ])
             ->defaultAvatarProvider(\App\Providers\Filament\AvatarProviders\DiceBearAvatarProvider::class)
+            ->darkMode(false) // 默认使用浅色模式,保留DaisyUI主题
             ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
             ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
             ->pages([
@@ -60,6 +66,7 @@ class AdminPanelProvider extends PanelProvider
                 KnowledgePointStats::class,
                 KnowledgeGraphIntegration::class,
                 KnowledgeGraphExplorer::class,
+                TextbookExcelImportPage::class,
             ])
             ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
             ->widgets([

+ 103 - 17
app/Services/ExamPdfExportService.php

@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\URL;
 use App\Services\LearningAnalyticsService;
 use App\Services\QuestionBankService;
 use App\Services\QuestionServiceApi;
+use App\Services\PdfStorageService;
 use Symfony\Component\Process\Exception\ProcessSignaledException;
 use Symfony\Component\Process\Exception\ProcessTimedOutException;
 use Symfony\Component\Process\Process;
@@ -23,16 +24,19 @@ class ExamPdfExportService
     private ExamPdfController $controller;
     private LearningAnalyticsService $learningAnalyticsService;
     private QuestionBankService $questionBankService;
+    private PdfStorageService $pdfStorageService;
 
     public function __construct(
         ExamPdfController $controller,
         LearningAnalyticsService $learningAnalyticsService,
-        QuestionBankService $questionBankService
+        QuestionBankService $questionBankService,
+        PdfStorageService $pdfStorageService
     )
     {
         $this->controller = $controller;
         $this->learningAnalyticsService = $learningAnalyticsService;
         $this->questionBankService = $questionBankService;
+        $this->pdfStorageService = $pdfStorageService;
     }
 
     /**
@@ -40,7 +44,28 @@ class ExamPdfExportService
      */
     public function generateExamPdf(string $paperId): ?string
     {
-        return $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam');
+        $url = $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam');
+
+        // 如果生成成功,将 URL 写入数据库
+        if ($url) {
+            try {
+                $paper = Paper::where('paper_id', $paperId)->first();
+                if ($paper) {
+                    $paper->update(['exam_pdf_url' => $url]);
+                    Log::info('ExamPdfExportService: 试卷PDF URL已写入数据库', [
+                        'paper_id' => $paperId,
+                        'url' => $url,
+                    ]);
+                }
+            } catch (\Throwable $e) {
+                Log::error('ExamPdfExportService: 写入试卷PDF URL失败', [
+                    'paper_id' => $paperId,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $url;
     }
 
     /**
@@ -48,13 +73,34 @@ class ExamPdfExportService
      */
     public function generateGradingPdf(string $paperId): ?string
     {
-        return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
+        $url = $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
+
+        // 如果生成成功,将 URL 写入数据库
+        if ($url) {
+            try {
+                $paper = Paper::where('paper_id', $paperId)->first();
+                if ($paper) {
+                    $paper->update(['grading_pdf_url' => $url]);
+                    Log::info('ExamPdfExportService: 判卷PDF URL已写入数据库', [
+                        'paper_id' => $paperId,
+                        'url' => $url,
+                    ]);
+                }
+            } catch (\Throwable $e) {
+                Log::error('ExamPdfExportService: 写入判卷PDF URL失败', [
+                    'paper_id' => $paperId,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $url;
     }
 
     /**
      * 生成学情分析 PDF
      */
-    public function generateAnalysisReportPdf(string $paperId, string $studentId): ?string
+    public function generateAnalysisReportPdf(string $paperId, string $studentId, ?string $recordId = null): ?string
     {
         if (function_exists('set_time_limit')) {
             @set_time_limit(240);
@@ -74,21 +120,55 @@ class ExamPdfExportService
 
             $version = time();
             $path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
-            Storage::disk('public')->makeDirectory('analysis_reports');
-            $written = Storage::disk('public')->put($path, $pdfBinary);
-            if (!$written) {
+            $url = $this->pdfStorageService->put($path, $pdfBinary);
+            if (!$url) {
                 Log::error('ExamPdfExportService: 保存学情 PDF 失败', [
-                    'disk' => 'public',
                     'path' => $path,
                 ]);
                 return null;
             }
 
-            return URL::to(Storage::url($path));
+            // 根据记录类型将 URL 写入不同表
+            try {
+                if ($recordId) {
+                    // OCR 记录:写入 ocr_records 表
+                    $ocrRecord = \App\Models\OCRRecord::find($recordId);
+                    if ($ocrRecord) {
+                        $ocrRecord->update(['analysis_pdf_url' => $url]);
+                        Log::info('ExamPdfExportService: OCR记录学情分析PDF URL已写入数据库', [
+                            'record_id' => $recordId,
+                            'paper_id' => $paperId,
+                            'student_id' => $studentId,
+                            'url' => $url,
+                        ]);
+                    }
+                } else {
+                    // 学生记录:写入 students 表
+                    $student = \App\Models\Student::where('student_id', $studentId)->first();
+                    if ($student) {
+                        $student->update(['student_report_pdf_url' => $url]);
+                        Log::info('ExamPdfExportService: 学生学情报告PDF URL已写入数据库', [
+                            'student_id' => $studentId,
+                            'paper_id' => $paperId,
+                            'url' => $url,
+                        ]);
+                    }
+                }
+            } catch (\Throwable $e) {
+                Log::error('ExamPdfExportService: 写入学情分析PDF URL失败', [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId,
+                    'record_id' => $recordId,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+
+            return $url;
         } catch (\Throwable $e) {
             Log::error('ExamPdfExportService: 生成学情分析 PDF 失败', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
+                'record_id' => $recordId,
                 'error' => $e->getMessage(),
                 'exception' => get_class($e),
                 'trace' => $e->getTraceAsString(),
@@ -125,17 +205,15 @@ class ExamPdfExportService
             }
 
             $path = "exams/{$paperId}_{$suffix}.pdf";
-            Storage::disk('public')->makeDirectory('exams');
-            $written = Storage::disk('public')->put($path, $pdfBinary);
-            if (!$written) {
+            $url = $this->pdfStorageService->put($path, $pdfBinary);
+            if (!$url) {
                 Log::error('ExamPdfExportService: 保存 PDF 失败', [
-                    'disk' => 'public',
                     'path' => $path,
                 ]);
                 return null;
             }
 
-            return URL::to(Storage::url($path));
+            return $url;
         } catch (\Throwable $e) {
             Log::error('ExamPdfExportService: 生成 PDF 失败', [
                 'paper_id' => $paperId,
@@ -183,8 +261,8 @@ class ExamPdfExportService
     private function renderWithChrome(string $htmlPath): ?string
     {
         $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
-        // 固定用户目录,减少 Chrome 首次初始化开销;允许多进程并发时可按需加锁
-        $userDataDir = sys_get_temp_dir() . '/chrome-pdf-profile';
+        // 每次使用唯一的临时用户目录,彻底避免钥匙串问题
+        $userDataDir = sys_get_temp_dir() . '/chrome-profile-' . uniqid();
 
         $chromeBinary = env('PDF_CHROME_BINARY');
         if (!$chromeBinary) {
@@ -245,6 +323,14 @@ class ExamPdfExportService
             '--disable-crash-reporter',
             '--disable-print-preview',
             '--disable-features=PrintHeaderFooter',
+            '--disable-features=TranslateUI',
+            '--disable-features=OptimizationHints',
+            '--disable-ipc-flooding-protection',
+            '--disable-background-networking',
+            '--disable-background-timer-throttling',
+            '--disable-backgrounding-occluded-windows',
+            '--disable-renderer-backgrounding',
+            '--disable-features=AudioServiceOutOfProcess',
             '--user-data-dir=' . $userDataDir,
             '--print-to-pdf=' . $tmpPdf,
             '--print-to-pdf-no-header',
@@ -274,7 +360,7 @@ class ExamPdfExportService
 
             // 轮询检测 PDF 是否生成,尽快返回,避免等待 Chrome 完整退出
             $pollStart = microtime(true);
-            $maxPollSeconds = 45;
+            $maxPollSeconds = 30;
             while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) {
                 if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) {
                     $pdfGenerated = true;

+ 659 - 0
app/Services/Import/TextbookExcelImporter.php

@@ -0,0 +1,659 @@
+<?php
+
+namespace App\Services\Import;
+
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use App\Services\TextbookApiService;
+
+class TextbookExcelImporter
+{
+    protected $apiService;
+
+    public function __construct(TextbookApiService $apiService)
+    {
+        $this->apiService = $apiService;
+    }
+
+    /**
+     * 生成教材系列Excel模板
+     */
+    public function generateTextbookSeriesTemplate(): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // 设置列标题
+        $headers = [
+            'A1' => '系列名称',
+            'B1' => '别名',
+            'C1' => '出版社',
+            'D1' => '适用地区',
+            'E1' => '适用学段',
+            'F1' => '是否启用',
+            'G1' => '排序',
+            'H1' => '扩展信息(JSON)',
+        ];
+
+        foreach ($headers as $cell => $header) {
+            $sheet->setCellValue($cell, $header);
+        }
+
+        // 设置表头样式
+        $headerStyle = [
+            'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
+            'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
+            'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
+        ];
+        $sheet->getStyle('A1:H1')->applyFromArray($headerStyle);
+
+        // 设置列宽
+        $sheet->getColumnDimension('A')->setWidth(20);
+        $sheet->getColumnDimension('B')->setWidth(15);
+        $sheet->getColumnDimension('C')->setWidth(25);
+        $sheet->getColumnDimension('D')->setWidth(20);
+        $sheet->getColumnDimension('E')->setWidth(20);
+        $sheet->getColumnDimension('F')->setWidth(12);
+        $sheet->getColumnDimension('G')->setWidth(10);
+        $sheet->getColumnDimension('H')->setWidth(30);
+
+        // 添加示例数据
+        $examples = [
+            ['人教版', 'pep', '人民教育出版社', '全国', '["primary","junior","senior"]', '是', '1', '{"website":"http://www.pep.com.cn"}'],
+            ['北师大版', 'bsd', '北京师范大学出版社', '全国', '["primary","junior","senior"]', '是', '2', '{}'],
+            ['苏教版', 'js', '江苏教育出版社', '江苏省', '["primary","junior"]', '是', '3', '{}'],
+        ];
+
+        for ($i = 0; $i < count($examples); $i++) {
+            $row = $i + 2;
+            for ($j = 0; $j < count($examples[$i]); $j++) {
+                $column = chr(65 + $j); // A, B, C...
+                $sheet->setCellValue($column . $row, $examples[$i][$j]);
+            }
+        }
+
+        // 添加说明信息
+        $sheet->setCellValue('A6', '填写说明:');
+        $sheet->setCellValue('A7', '1. 系列名称: 必填,如"人教版"、"北师大版"');
+        $sheet->setCellValue('A8', '2. 别名: 必填,唯一标识,如"pep"、"bsd"');
+        $sheet->setCellValue('A9', '3. 出版社: 必填,如"人民教育出版社"');
+        $sheet->setCellValue('A10', '4. 适用地区: 可选,如"全国"、"江苏省"');
+        $sheet->setCellValue('A11', '5. 适用学段: 必填,JSON格式["primary","junior","senior"]');
+        $sheet->setCellValue('A12', '6. 是否启用: 填"是"或"否"');
+        $sheet->setCellValue('A13', '7. 排序: 数字,越小越靠前');
+        $sheet->setCellValue('A14', '8. 扩展信息: JSON格式,可选');
+
+        // 保存文件
+        $fileName = 'textbook_series_template_' . date('Ymd_His') . '.xlsx';
+        $filePath = storage_path('app/templates/' . $fileName);
+
+        // 确保目录存在
+        if (!is_dir(dirname($filePath))) {
+            mkdir(dirname($filePath), 0755, true);
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($filePath);
+
+        return $filePath;
+    }
+
+    /**
+     * 生成教材Excel模板
+     */
+    public function generateTextbookTemplate(): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // 设置列标题
+        $headers = [
+            'A1' => '系列ID',
+            'B1' => '学段',
+            'C1' => '年级',
+            'D1' => '学期',
+            'E1' => '命名体系',
+            'F1' => '版本',
+            'G1' => '模块类型',
+            'H1' => '册次',
+            'I1' => '旧体系编码',
+            'J1' => '课标年代',
+            'K1' => '修订年份',
+            'L1' => '审定年份',
+            'M1' => '版次标识',
+            'N1' => 'ISBN',
+            'O1' => '封面路径',
+            'P1' => '官方书名',
+            'Q1' => '展示名称',
+            'R1' => '别名(JSON)',
+            'S1' => '状态',
+            'T1' => '扩展信息(JSON)',
+        ];
+
+        foreach ($headers as $cell => $header) {
+            $sheet->setCellValue($cell, $header);
+        }
+
+        // 设置表头样式
+        $headerStyle = [
+            'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
+            'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
+            'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
+        ];
+        $sheet->getStyle('A1:T1')->applyFromArray($headerStyle);
+
+        // 设置列宽
+        $columnWidths = [10, 12, 10, 10, 12, 10, 15, 10, 15, 12, 12, 12, 15, 15, 20, 30, 30, 20, 12, 30];
+        foreach ($columnWidths as $i => $width) {
+            $sheet->getColumnDimensionByColumn($i + 1)->setWidth($width);
+        }
+
+        // 添加示例数据
+        $examples = [
+            ['1', '小学', '1', '上册', '', '', '', '', '', '2011', '', '', '', '', '', '人教版小学数学一年级上册', '', '[]', '草稿', '{}'],
+            ['1', '小学', '1', '下册', '', '', '', '', '', '2011', '', '', '', '', '', '人教版小学数学一年级下册', '', '[]', '草稿', '{}'],
+            ['1', '初中', '7', '上册', '', '', '', '', '', '2022', '', '', '', '', '', '人教版初中数学七年级上册', '', '[]', '草稿', '{}'],
+            ['1', '高中', '10', '', '新体系', 'A版', '必修', '1', '', '2017', '2020', '', '', '', '', '人教A版高中数学必修第一册', '', '[]', '草稿', '{}'],
+        ];
+
+        for ($i = 0; $i < count($examples); $i++) {
+            $row = $i + 2;
+            for ($j = 0; $j < count($examples[$i]); $j++) {
+                $column = chr(65 + $j); // A, B, C...
+                $sheet->setCellValue($column . $row, $examples[$i][$j]);
+            }
+        }
+
+        // 添加说明信息
+        $sheet->setCellValue('A7', '填写说明:');
+        $sheet->setCellValue('A8', '1. 系列ID: 必填,对应教材系列的ID(可在教材系列列表查看)');
+        $sheet->setCellValue('A9', '2. 学段: 必填,小学/初中/高中');
+        $sheet->setCellValue('A10', '3. 年级: 数字,小学1-6,初中7-9,高中10-12');
+        $sheet->setCellValue('A11', '4. 学期: 上册/下册(高中可留空)');
+        $sheet->setCellValue('A12', '5. 命名体系: 新体系/旧体系,高中适用');
+        $sheet->setCellValue('A13', '6. 版本: A版/B版,高中新体系适用');
+        $sheet->setCellValue('A14', '7. 模块类型: 必修/选择性必修/选修,高中适用');
+        $sheet->setCellValue('A15', '8. 册次: 数字,高中新体系适用');
+        $sheet->setCellValue('A16', '9. 旧体系编码: 高中旧体系适用,如"必修1"');
+        $sheet->setCellValue('A17', '10. 课标年代: 义务教育2011/2022,高中2017');
+        $sheet->setCellValue('A18', '11. 修订年份: 高中为2020');
+        $sheet->setCellValue('A19', '12. 审定年份: 如2024');
+        $sheet->setCellValue('A20', '13. 版次标识: 如"2024秋版"');
+        $sheet->setCellValue('A21', '14. ISBN: 可选');
+        $sheet->setCellValue('A22', '15. 封面路径: 可选');
+        $sheet->setCellValue('A23', '16. 官方书名: 可选,系统自动生成');
+        $sheet->setCellValue('A24', '17. 展示名称: 可选,站内显示');
+        $sheet->setCellValue('A25', '18. 别名: JSON格式,可选');
+        $sheet->setCellValue('A26', '19. 状态: 草稿/已发布/已归档');
+        $sheet->setCellValue('A27', '20. 扩展信息: JSON格式,可选');
+
+        // 保存文件
+        $fileName = 'textbook_template_' . date('Ymd_His') . '.xlsx';
+        $filePath = storage_path('app/templates/' . $fileName);
+
+        if (!is_dir(dirname($filePath))) {
+            mkdir(dirname($filePath), 0755, true);
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($filePath);
+
+        return $filePath;
+    }
+
+    /**
+     * 导入教材系列Excel文件
+     */
+    public function importTextbookSeries(string $filePath): array
+    {
+        try {
+            $spreadsheet = IOFactory::load($filePath);
+            $sheet = $spreadsheet->getActiveSheet();
+            $data = $sheet->toArray();
+
+            // 跳过标题行
+            $rows = array_slice($data, 1);
+            $successCount = 0;
+            $errorCount = 0;
+            $errors = [];
+
+            foreach ($rows as $index => $row) {
+                try {
+                    // 验证必填字段
+                    if (empty($row[0]) || empty($row[1]) || empty($row[2]) || empty($row[4])) {
+                        throw new \Exception('必填字段不能为空');
+                    }
+
+                    // 解析适用学段
+                    $stages = json_decode($row[4], true);
+                    if (!is_array($stages)) {
+                        throw new \Exception('适用学段必须是JSON数组格式');
+                    }
+
+                    // 解析是否启用
+                    $isActive = $row[5] === '是' || $row[5] === 'true' || $row[5] === '1';
+
+                    // 解析扩展信息
+                    $meta = !empty($row[7]) ? json_decode($row[7], true) : [];
+                    if (!is_array($meta)) {
+                        $meta = [];
+                    }
+
+                    $textbookSeriesData = [
+                        'name' => $row[0],
+                        'slug' => $row[1],
+                        'publisher' => $row[2],
+                        'region' => $row[3] ?: null,
+                        'stages' => json_encode($stages),
+                        'is_active' => $isActive,
+                        'sort_order' => (int)($row[6] ?: 0),
+                        'meta' => json_encode($meta),
+                    ];
+
+                    // 通过API创建教材系列
+                    $result = $this->apiService->createTextbookSeries($textbookSeriesData);
+
+                    if ($result && isset($result['data'])) {
+                        $successCount++;
+                    } else {
+                        throw new \Exception('API创建失败');
+                    }
+
+                } catch (\Exception $e) {
+                    $errorCount++;
+                    $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
+                }
+            }
+
+            return [
+                'success' => true,
+                'success_count' => $successCount,
+                'error_count' => $errorCount,
+                'errors' => $errors,
+            ];
+
+        } catch (\Exception $e) {
+            Log::error('Excel导入失败', ['error' => $e->getMessage()]);
+            return [
+                'success' => false,
+                'message' => '文件解析失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 导入教材Excel文件
+     */
+    public function importTextbook(string $filePath): array
+    {
+        // 中文到英文的映射
+        $stageMap = [
+            '小学' => 'primary',
+            '初中' => 'junior',
+            '高中' => 'senior',
+            // 兼容英文输入
+            'primary' => 'primary',
+            'junior' => 'junior',
+            'senior' => 'senior',
+        ];
+
+        $semesterMap = [
+            '上册' => 1,
+            '下册' => 2,
+            '1' => 1,
+            '2' => 2,
+        ];
+
+        $namingSchemeMap = [
+            '新体系' => 'new',
+            '旧体系' => 'old',
+            'new' => 'new',
+            'old' => 'old',
+        ];
+
+        $trackMap = [
+            'A版' => 'A',
+            'B版' => 'B',
+            'A' => 'A',
+            'B' => 'B',
+        ];
+
+        $statusMap = [
+            '草稿' => 'draft',
+            '已发布' => 'published',
+            '已归档' => 'archived',
+            'draft' => 'draft',
+            'published' => 'published',
+            'archived' => 'archived',
+        ];
+
+        try {
+            $spreadsheet = IOFactory::load($filePath);
+            $sheet = $spreadsheet->getActiveSheet();
+            $data = $sheet->toArray();
+
+            // 跳过标题行
+            $rows = array_slice($data, 1);
+            $successCount = 0;
+            $errorCount = 0;
+            $errors = [];
+
+            foreach ($rows as $index => $row) {
+                try {
+                    // 跳过空行
+                    if (empty($row[0]) && empty($row[1])) {
+                        continue;
+                    }
+
+                    // 验证必填字段
+                    if (empty($row[0]) || empty($row[1])) {
+                        throw new \Exception('系列ID和学段不能为空');
+                    }
+
+                    // 转换学段
+                    $stageInput = trim($row[1]);
+                    $stage = $stageMap[$stageInput] ?? null;
+                    if (!$stage) {
+                        throw new \Exception("无效的学段: {$stageInput},请填写:小学/初中/高中");
+                    }
+
+                    // 转换学期
+                    $semester = null;
+                    if (!empty($row[3])) {
+                        $semesterInput = trim($row[3]);
+                        $semester = $semesterMap[$semesterInput] ?? null;
+                        if ($semester === null) {
+                            throw new \Exception("无效的学期: {$semesterInput},请填写:上册/下册");
+                        }
+                    }
+
+                    // 转换命名体系
+                    $namingScheme = null;
+                    if (!empty($row[4])) {
+                        $namingInput = trim($row[4]);
+                        $namingScheme = $namingSchemeMap[$namingInput] ?? null;
+                    }
+
+                    // 转换版本
+                    $track = null;
+                    if (!empty($row[5])) {
+                        $trackInput = trim($row[5]);
+                        $track = $trackMap[$trackInput] ?? $trackInput;
+                    }
+
+                    // 转换状态
+                    $statusInput = trim($row[18] ?? 'draft');
+                    $status = $statusMap[$statusInput] ?? 'draft';
+
+                    // 解析别名
+                    $aliases = !empty($row[17]) ? json_decode($row[17], true) : [];
+                    if (!is_array($aliases)) {
+                        $aliases = [];
+                    }
+
+                    // 解析扩展信息
+                    $meta = !empty($row[19]) ? json_decode($row[19], true) : [];
+                    if (!is_array($meta)) {
+                        $meta = [];
+                    }
+
+                    $textbookData = [
+                        'series_id' => (int)$row[0],
+                        'stage' => $stage,
+                        'grade' => $row[2] ?: null,
+                        'semester' => $semester,
+                        'naming_scheme' => $namingScheme,
+                        'track' => $track,
+                        'module_type' => $row[6] ?: null,
+                        'volume_no' => $row[7] ? (int)$row[7] : null,
+                        'legacy_code' => $row[8] ?: null,
+                        'curriculum_standard_year' => $row[9] ?: null,
+                        'curriculum_revision_year' => $row[10] ?: null,
+                        'approval_year' => $row[11] ?: null,
+                        'edition_label' => $row[12] ?: null,
+                        '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),
+                    ];
+
+                    // 通过API创建教材
+                    $result = $this->apiService->createTextbook($textbookData);
+
+                    if ($result && isset($result['data'])) {
+                        $successCount++;
+                    } else {
+                        throw new \Exception('API创建失败');
+                    }
+
+                } catch (\Exception $e) {
+                    $errorCount++;
+                    $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
+                }
+            }
+
+            return [
+                'success' => true,
+                'success_count' => $successCount,
+                'error_count' => $errorCount,
+                'errors' => $errors,
+            ];
+
+        } catch (\Exception $e) {
+            Log::error('Excel导入失败', ['error' => $e->getMessage()]);
+            return [
+                'success' => false,
+                'message' => '文件解析失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 生成教材目录Excel模板
+     */
+    public function generateTextbookCatalogTemplate(): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // 设置列标题
+        $headers = [
+            'A1' => '教材ID',
+            'B1' => '目录标题',
+            'C1' => '显示编号',
+            'D1' => '节点类型',
+            'E1' => '层级深度',
+            'F1' => '排序',
+            'G1' => '父级ID',
+            'H1' => '路径键',
+            'I1' => '起始页码',
+            'J1' => '结束页码',
+            'K1' => '是否必修',
+            'L1' => '是否选修',
+            'M1' => '标签(JSON)',
+            'N1' => '扩展信息(JSON)',
+        ];
+
+        foreach ($headers as $cell => $header) {
+            $sheet->setCellValue($cell, $header);
+        }
+
+        // 设置表头样式
+        $headerStyle = [
+            'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
+            'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
+            'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
+        ];
+        $sheet->getStyle('A1:N1')->applyFromArray($headerStyle);
+
+        // 设置列宽
+        $columnWidths = [12, 30, 15, 15, 12, 10, 12, 25, 12, 12, 12, 12, 20, 30];
+        foreach ($columnWidths as $i => $width) {
+            $sheet->getColumnDimensionByColumn($i + 1)->setWidth($width);
+        }
+
+        // 添加示例数据(使用中文节点类型)
+        $examples = [
+            ['1', '第一章 有理数', '1', '章', '1', '1', '', 'chapter-1', '1', '20', '是', '否', '[]', '{}'],
+            ['1', '1.1 正数和负数', '1.1', '节', '2', '1', '', 'chapter-1-section-1', '2', '5', '是', '否', '[]', '{}'],
+            ['1', '1.2 有理数', '1.2', '节', '2', '2', '', 'chapter-1-section-2', '6', '10', '是', '否', '[]', '{}'],
+            ['1', '1.2.1 有理数的概念', '1.2.1', '小节', '3', '1', '', 'chapter-1-section-2-sub-1', '6', '7', '是', '否', '[]', '{}'],
+            ['1', '第二章 整式的加减', '2', '章', '1', '2', '', 'chapter-2', '21', '40', '是', '否', '[]', '{}'],
+        ];
+
+        for ($i = 0; $i < count($examples); $i++) {
+            $row = $i + 2;
+            for ($j = 0; $j < count($examples[$i]); $j++) {
+                $column = chr(65 + $j); // A, B, C...
+                $sheet->setCellValue($column . $row, $examples[$i][$j]);
+            }
+        }
+
+        // 添加说明信息
+        $sheet->setCellValue('A8', '填写说明:');
+        $sheet->setCellValue('A9', '1. 教材ID: 必填,对应教材的ID(可在教材列表查看)');
+        $sheet->setCellValue('A10', '2. 目录标题: 必填,如"第一章 有理数"');
+        $sheet->setCellValue('A11', '3. 显示编号: 可选,如"1"、"1.1"、"1.2.1"');
+        $sheet->setCellValue('A12', '4. 节点类型: 章/节/小节/条目/项目学习');
+        $sheet->setCellValue('A13', '5. 层级深度: 数字,1=章,2=节,3=小节');
+        $sheet->setCellValue('A14', '6. 排序: 数字,同级内排序');
+        $sheet->setCellValue('A15', '7. 父级ID: 可选,上级节点的ID(首次导入可留空,系统自动处理)');
+        $sheet->setCellValue('A16', '8. 路径键: 可选,层级路径标识');
+        $sheet->setCellValue('A17', '9. 起始页码: 数字,可选');
+        $sheet->setCellValue('A18', '10. 结束页码: 数字,可选');
+        $sheet->setCellValue('A19', '11. 是否必修: 是/否');
+        $sheet->setCellValue('A20', '12. 是否选修: 是/否');
+        $sheet->setCellValue('A21', '13. 标签: JSON格式,可选');
+        $sheet->setCellValue('A22', '14. 扩展信息: JSON格式,可选');
+
+        // 保存文件
+        $fileName = 'textbook_catalog_template_' . date('Ymd_His') . '.xlsx';
+        $filePath = storage_path('app/templates/' . $fileName);
+
+        if (!is_dir(dirname($filePath))) {
+            mkdir(dirname($filePath), 0755, true);
+        }
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($filePath);
+
+        return $filePath;
+    }
+
+    /**
+     * 导入教材目录Excel文件
+     */
+    public function importTextbookCatalog(string $filePath, int $textbookId): array
+    {
+        // 中文到英文的映射
+        $nodeTypeMap = [
+            '章' => 'chapter',
+            '节' => 'section',
+            '小节' => 'subsection',
+            '条目' => 'item',
+            '项目学习' => 'project',
+            // 兼容英文输入
+            'chapter' => 'chapter',
+            'section' => 'section',
+            'subsection' => 'subsection',
+            'item' => 'item',
+            'project' => 'project',
+        ];
+
+        try {
+            // 首先验证Excel文件
+            $spreadsheet = IOFactory::load($filePath);
+            $sheet = $spreadsheet->getActiveSheet();
+            $data = $sheet->toArray();
+
+            // 跳过标题行
+            $rows = array_slice($data, 1);
+            $successCount = 0;
+            $errorCount = 0;
+            $errors = [];
+
+            foreach ($rows as $index => $row) {
+                try {
+                    // 跳过空行
+                    if (empty($row[0]) && empty($row[1])) {
+                        continue;
+                    }
+
+                    // 验证必填字段
+                    if (empty($row[0]) || empty($row[1])) {
+                        throw new \Exception('教材ID和目录标题不能为空');
+                    }
+
+                    // 验证教材ID是否匹配
+                    if ((int)$row[0] !== $textbookId) {
+                        throw new \Exception('教材ID必须与选择的教材ID一致');
+                    }
+
+                    // 转换节点类型
+                    $nodeTypeInput = trim($row[3] ?? '章');
+                    $nodeType = $nodeTypeMap[$nodeTypeInput] ?? 'chapter';
+
+                    // 解析是否必修/选修
+                    $isRequired = $row[10] === '是' || $row[10] === 'true' || $row[10] === '1';
+                    $isElective = $row[11] === '是' || $row[11] === 'true' || $row[11] === '1';
+
+                    // 解析标签和扩展信息
+                    $tags = !empty($row[12]) ? json_decode($row[12], true) : [];
+                    if (!is_array($tags)) {
+                        $tags = [];
+                    }
+
+                    $meta = !empty($row[13]) ? json_decode($row[13], true) : [];
+                    if (!is_array($meta)) {
+                        $meta = [];
+                    }
+
+                    // 构建目录数据(这里我们只是验证,实际导入通过API完成)
+                    $catalogData = [
+                        'textbook_id' => (int)$row[0],
+                        'title' => $row[1],
+                        'display_no' => $row[2] ?: null,
+                        'node_type' => $nodeType,
+                        'depth' => $row[4] ? (int)$row[4] : 1,
+                        'sort_order' => $row[5] ? (int)$row[5] : 0,
+                        'parent_id' => $row[6] ?: null,
+                        'path_key' => $row[7] ?: null,
+                        'page_start' => $row[8] ? (int)$row[8] : null,
+                        'page_end' => $row[9] ? (int)$row[9] : null,
+                        'is_required' => $isRequired,
+                        'is_elective' => $isElective,
+                        'tags' => json_encode($tags),
+                        'meta' => json_encode($meta),
+                    ];
+
+                    // 验证数据(实际创建通过API完成)
+                    $successCount++;
+
+                } catch (\Exception $e) {
+                    $errorCount++;
+                    $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
+                }
+            }
+
+            return [
+                'success' => true,
+                'success_count' => $successCount,
+                'error_count' => $errorCount,
+                'errors' => $errors,
+            ];
+
+        } catch (\Exception $e) {
+            Log::error('Excel导入失败', ['error' => $e->getMessage()]);
+            return [
+                'success' => false,
+                'message' => '文件解析失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+}

+ 320 - 38
app/Services/LearningAnalyticsService.php

@@ -1229,6 +1229,8 @@ class LearningAnalyticsService
                 '中等' => 35,
                 '拔高' => 15,
             ];
+            $difficultyLevels = $params['difficulty_levels'] ?? [];
+            // 如果用户没有选择任何难度,difficultyLevels 为空数组,表示随机难度
 
             // 1. 如果指定了学生,获取学生的薄弱点
             $weaknessFilter = [];
@@ -1305,15 +1307,18 @@ class LearningAnalyticsService
                 }
             }
 
-            // 2. 调用题库API获取符合条件的所有题目
-            $allQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId);
+        // 2. 调用题库API获取符合条件的所有题目
+        $allQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $difficultyRatio, 200);
 
             if (empty($allQuestions)) {
-                // 根据是否有选择的知识点给出不同的错误信息
-                if (empty($kpCodes)) {
-                    $message = '未选择知识点,无法生成试卷。请先选择知识点或选择学生以获取薄弱点推荐。';
-                } else {
+                // 只有在选择了知识点但题库为空时才报错
+                // 如果没有选择知识点,系统会从所有题目中选择,不应该报错
+                if (!empty($kpCodes)) {
                     $message = '题库中暂无可用题目。您可以选择其他知识点,或点击"生成练习题"按钮先补充题库。';
+                } else {
+                    // 没有选择知识点时,从所有题目中选择,这里不应该出现空的情况
+                    // 如果出现,说明API调用失败,返回通用错误
+                    $message = '题库服务异常,请稍后重试。';
                 }
 
                 Log::warning('智能出卷失败 - 未找到题目', [
@@ -1330,14 +1335,15 @@ class LearningAnalyticsService
             }
 
             // 3. 根据掌握度对题目进行筛选和排序
-            $selectedQuestions = $this->selectQuestionsByMastery(
-                $allQuestions,
-                $studentId,
-                $totalQuestions,
-                $questionTypeRatio,
-                $difficultyRatio,
-                $weaknessFilter
-            );
+        $selectedQuestions = $this->selectQuestionsByMastery(
+            $allQuestions,
+            $studentId,
+            $totalQuestions,
+            $questionTypeRatio,
+            $difficultyRatio,
+            $difficultyLevels,
+            $weaknessFilter
+        );
 
             if (empty($selectedQuestions)) {
                 return [
@@ -1354,7 +1360,9 @@ class LearningAnalyticsService
                 'stats' => [
                     'total_selected' => count($selectedQuestions),
                     'source_questions' => count($allQuestions),
-                    'weakness_targeted' => $studentId ? count(array_intersect(array_column($selectedQuestions, 'kp_code'), $weaknessFilter)) : 0
+                    'weakness_targeted' => $studentId && !empty($weaknessFilter) ? count(array_filter($selectedQuestions, function($q) use ($weaknessFilter) {
+                        return in_array($q['kp_code'] ?? '', $weaknessFilter);
+                    })) : 0
                 ]
             ];
         } catch (\Exception $e) {
@@ -1372,46 +1380,70 @@ class LearningAnalyticsService
     }
 
     /**
-     * 从题库获取题目
+     * 从题库获取题目 - 使用智能选题API,直接根据要求筛选
      */
-    private function getQuestionsFromBank(array $kpCodes, array $skills, ?string $studentId): array
+    private function getQuestionsFromBank(array $kpCodes, array $skills, ?string $studentId, array $questionTypeRatio = [], array $difficultyRatio = [], int $totalNeeded = 100): array
     {
         try {
-            // 构建查询参数
-            $params = [
-                'kp_codes' => implode(',', $kpCodes),
-                'limit' => 1000 // 获取足够多的题目用于筛选
-            ];
+            // 构建筛选条件
+            $filters = [];
+
+            // 知识点筛选
+            if (!empty($kpCodes)) {
+                $filters['kp_codes'] = $kpCodes;
+            }
 
+            // 技能筛选
             if (!empty($skills)) {
-                $params['skills'] = implode(',', $skills);
+                $filters['skills'] = $skills;
             }
 
+            // 题型配比
+            if (!empty($questionTypeRatio)) {
+                $filters['question_type_ratio'] = $questionTypeRatio;
+            }
+
+            // 难度配比
+            if (!empty($difficultyRatio)) {
+                $filters['difficulty_ratio'] = $difficultyRatio;
+            }
+
+            // 过滤学生做过的题目
             if ($studentId) {
-                $params['exclude_student_questions'] = $studentId; // 过滤学生做过的题目
+                $filters['exclude_student_questions'] = $studentId;
             }
 
-            // 调用QuestionBank API
-            // 使用 QuestionBankService 获取题目 (使用 filterQuestions 方法以支持 kp_codes)
             // 从容器动态获取实例
             if (!$this->questionBankService) {
                 $this->questionBankService = app(QuestionBankService::class);
             }
 
-            $response = $this->questionBankService->filterQuestions($params);
+            // 调用智能选题API - 直接获取符合要求的题目
+            $questions = $this->questionBankService->selectQuestionsForExam($totalNeeded, $filters);
+
+            if (!empty($questions)) {
+                // 过滤掉没有解题思路的题目
+                $questionsWithSolution = array_filter($questions, function($q) {
+                    return !empty(trim($q['solution'] ?? ''));
+                });
 
-            if (!empty($response['data'])) {
-                return $response['data'];
+                Log::info('从题库智能获取题目', [
+                    'total_from_bank' => count($questions),
+                    'has_solution' => count($questionsWithSolution),
+                    'filtered_out' => count($questions) - count($questionsWithSolution),
+                    'filters' => $filters
+                ]);
+
+                return array_values($questionsWithSolution);
             }
 
-            Log::warning('Get Questions From Bank Failed or Empty', [
-                'params' => $params,
-                'response' => $response
+            Log::warning('智能选题返回空结果', [
+                'filters' => $filters
             ]);
 
             return [];
         } catch (\Exception $e) {
-            Log::error('Get Questions From Bank Error', [
+            Log::error('智能选题异常', [
                 'error' => $e->getMessage()
             ]);
         }
@@ -1428,8 +1460,23 @@ class LearningAnalyticsService
         int $totalQuestions,
         array $questionTypeRatio,
         array $difficultyRatio,
+        array $difficultyLevels,
         array $weaknessFilter
     ): array {
+        // 如果未选择难度,则不过滤(随机生成所有难度)
+        if (empty($difficultyLevels)) {
+            Log::info('用户未选择难度,将随机生成所有难度的题目');
+            // 不过滤任何题目,保留所有难度
+        } else {
+            // 按难度筛掉不在选择范围内的题目
+            $questions = array_values(array_filter($questions, function ($q) use ($difficultyLevels) {
+                $d = $q['difficulty'] ?? null;
+                if ($d === null) return true; // 无难度信息则保留
+                $level = $this->mapDifficultyLevel((float)$d);
+                return in_array($level, $difficultyLevels);
+            }));
+        }
+
         // 1. 按知识点分组
         $questionsByKp = [];
         foreach ($questions as $question) {
@@ -1486,7 +1533,7 @@ class LearningAnalyticsService
         }
 
         // 5. 按题型和难度进行微调
-        return $this->adjustQuestionsByRatio($selectedQuestions, $questionTypeRatio, $difficultyRatio);
+        return $this->adjustQuestionsByRatio($selectedQuestions, $questionTypeRatio, $difficultyRatio, $totalQuestions);
     }
 
     /**
@@ -1514,12 +1561,247 @@ class LearningAnalyticsService
     /**
      * 根据题型和难度配比调整题目
      */
-    private function adjustQuestionsByRatio(array $questions, array $typeRatio, array $difficultyRatio): array
+    private function adjustQuestionsByRatio(array $questions, array $typeRatio, array $difficultyRatio, int $targetCount): array
     {
-        // 这里可以实现更精细的调整逻辑
-        // 目前先返回原始题目,后续可以基于question_type和difficulty字段进行调整
+        // 按题型分桶
+        $buckets = [
+            'choice' => [],
+            'fill' => [],
+            'answer' => [],
+        ];
+        foreach ($questions as $q) {
+            $type = $this->determineQuestionType($q);
+            if (!isset($buckets[$type])) {
+                $type = 'answer';
+            }
+            $buckets[$type][] = $q;
+        }
+
+        // 计算目标数(四舍五入,比例>0 则至少 1 道),并校正总数
+        $targetCount = min($targetCount, count($questions));
+        $targets = $this->computeTypeTargets($targetCount, $typeRatio);
+
+        Log::info('题型配比调整前桶统计', [
+            'target_count' => $targetCount,
+            'targets' => $targets,
+            'bucket_counts' => [
+                'choice' => count($buckets['choice']),
+                'fill' => count($buckets['fill']),
+                'answer' => count($buckets['answer']),
+            ],
+            'raw_ratio' => $typeRatio,
+        ]);
 
-        return $questions;
+        // 随机打乱桶
+        foreach ($buckets as $k => $v) {
+            if (!empty($v)) {
+                shuffle($v);
+                $buckets[$k] = $v;
+            }
+        }
+
+        $selected = [];
+        $selectedIds = [];
+
+        // 按目标数依次取题
+        foreach (['choice', 'fill', 'answer'] as $typeKey) {
+            $need = $targets[$typeKey] ?? 0;
+            if ($need <= 0 || empty($buckets[$typeKey])) {
+                continue;
+            }
+            $take = min($need, count($buckets[$typeKey]));
+            $slice = array_slice($buckets[$typeKey], 0, $take);
+            foreach ($slice as $q) {
+                $id = $q['id'] ?? $q['question_id'] ?? spl_object_id((object)$q);
+                if (isset($selectedIds[$id])) {
+                    continue;
+                }
+                $selected[] = $q;
+                $selectedIds[$id] = true;
+            }
+        }
+
+        // 不足则从剩余题中补齐
+        if (count($selected) < $targetCount) {
+            $remaining = [];
+            foreach ($buckets as $v) {
+                foreach ($v as $q) {
+                    $id = $q['id'] ?? $q['question_id'] ?? spl_object_id((object)$q);
+                    if (!isset($selectedIds[$id])) {
+                        $remaining[] = $q;
+                    }
+                }
+            }
+            shuffle($remaining);
+            $needMore = $targetCount - count($selected);
+            $selected = array_merge($selected, array_slice($remaining, 0, $needMore));
+        }
+
+        // 截断至目标数
+        $selected = array_slice($selected, 0, $targetCount);
+
+        Log::info('题型配比调整完成', [
+            'target_count' => $targetCount,
+            'targets' => $targets,
+            'selected_counts' => [
+                'choice' => count(array_filter($selected, fn($q) => $this->determineQuestionType($q) === 'choice')),
+                'fill' => count(array_filter($selected, fn($q) => $this->determineQuestionType($q) === 'fill')),
+                'answer' => count(array_filter($selected, fn($q) => $this->determineQuestionType($q) === 'answer')),
+            ],
+        ]);
+
+        return $selected;
+    }
+
+    private function determineQuestionType(array $q): string
+    {
+        // 优先根据题目内容判断(而不是数据库字段)
+        $stem = $q['stem'] ?? $q['content'] ?? '';
+        $tags = $q['tags'] ?? '';
+        $skills = $q['skills'] ?? [];
+
+        // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
+        if (is_string($stem)) {
+            // 选择题特征:必须包含 A. B. C. D. 四个选项(至少2个)
+            $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
+            $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
+            $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
+            $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
+            $hasOptionE = preg_match('/\bE\s*[\.\、\:]/', $stem) || preg_match('/\(E\)/', $stem) || preg_match('/^E[\.\s]/', $stem);
+
+            // 至少有2个选项就认为是选择题(降低阈值)
+            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0) + ($hasOptionE ? 1 : 0);
+            if ($optionCount >= 2) {
+                return 'choice';
+            }
+
+            // 检查是否有"( )"或"( )"括号,这通常是选择题的标志
+            if (preg_match('/(\s*)|\(\s*\)/', $stem) && (strpos($stem, 'A.') !== false || strpos($stem, 'B.') !== false || strpos($stem, 'C.') !== false || strpos($stem, 'D.') !== false)) {
+                return 'choice';
+            }
+        }
+
+        // 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';
+        }
+
+        // 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';
+        }
+
+        // 4. 根据标签判断
+        if (is_string($tags)) {
+            if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
+                return 'choice';
+            }
+            if (strpos($tags, '填空') !== false || strpos($tags, '填空题') !== false) {
+                return 'fill';
+            }
+            if (strpos($tags, '解答') !== false || strpos($tags, '简答') !== false || strpos($tags, '证明') !== false) {
+                return 'answer';
+            }
+        }
+
+        // 5. 根据options字段判断
+        if (!empty($q['options']) && is_array($q['options'])) {
+            return 'choice';
+        }
+
+        // 6. 填空题特征:连续下划线或明显的填空括号
+        if (is_string($stem)) {
+            // 检查填空题特征:连续下划线
+            if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
+                return 'fill';
+            }
+            // 空括号填空
+            if (preg_match('/(\s*)/', $stem) || preg_match('/\(\s*\)/', $stem)) {
+                return 'fill';
+            }
+        }
+
+        // 7. 根据题干内容关键词判断
+        if (is_string($stem)) {
+            // 有证明、解答、计算、求证等关键词的是解答题
+            if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
+                return 'answer';
+            }
+        }
+
+        // 默认是解答题(更安全的默认值)
+        return 'answer';
+    }
+
+    private function mapDifficultyLevel(float $d): string
+    {
+        if ($d <= 0.4) {
+            return '基础';
+        }
+        if ($d <= 0.7) {
+            return '中等';
+        }
+        return '拔高';
+    }
+
+    private function computeTypeTargets(int $targetCount, array $questionTypeRatio): array
+    {
+        $map = [
+            '选择题' => 'choice',
+            '填空题' => 'fill',
+            '解答题' => 'answer',
+        ];
+        $targets = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+        foreach ($questionTypeRatio as $label => $ratio) {
+            $key = $map[$label] ?? null;
+            if (!$key) {
+                continue;
+            }
+            $cnt = (int) round($targetCount * ($ratio / 100));
+            if ($ratio > 0 && $cnt < 1) {
+                $cnt = 1;
+            }
+            $targets[$key] = $cnt;
+        }
+        $sum = array_sum($targets);
+        if ($sum === 0) {
+            $targets['answer'] = $targetCount;
+            return $targets;
+        }
+        while ($sum > $targetCount) {
+            arsort($targets);
+            foreach ($targets as $k => $v) {
+                if ($v > 1) {
+                    $targets[$k]--;
+                    $sum--;
+                    break;
+                }
+            }
+        }
+        if ($sum < $targetCount) {
+            $ratioByKey = [
+                'choice' => $questionTypeRatio['选择题'] ?? 0,
+                'fill' => $questionTypeRatio['填空题'] ?? 0,
+                'answer' => $questionTypeRatio['解答题'] ?? 0,
+            ];
+            while ($sum < $targetCount) {
+                arsort($ratioByKey);
+                $k = array_key_first($ratioByKey);
+                $targets[$k]++;
+                $sum++;
+            }
+        }
+        return $targets;
     }
 
     /**

+ 14 - 0
app/Services/QuestionBankService.php

@@ -607,6 +607,7 @@ class QuestionBankService
                         'question_type' => $questionType,
                         'question_text' => $question['stem'] ?? $question['content'] ?? $question['question_text'] ?? '',
                         'correct_answer' => $correctAnswer,  // 保存正确答案
+                        'solution' => $question['solution'] ?? '',  // 保存解题思路
                         'difficulty' => $difficultyValue,
                         'score' => $question['score'] ?? 5, // 默认5分
                         'estimated_time' => $question['estimated_time'] ?? 300,
@@ -620,6 +621,19 @@ class QuestionBankService
                     throw new \Exception('没有有效的题目数据');
                 }
 
+                // 调试:检查第一个题目的solution字段
+                if (!empty($questionInsertData)) {
+                    $firstQuestion = $questionInsertData[0];
+                    Log::debug('试卷保存调试 - 第一个题目', [
+                        'paper_id' => $paperId,
+                        'question_id' => $firstQuestion['question_id'] ?? '',
+                        'question_bank_id' => $firstQuestion['question_bank_id'] ?? '',
+                        'has_solution' => !empty($firstQuestion['solution']),
+                        'solution_length' => strlen($firstQuestion['solution'] ?? ''),
+                        'solution_preview' => substr($firstQuestion['solution'] ?? '', 0, 80)
+                    ]);
+                }
+
                 // 使用Laravel模型批量插入题目数据
                 \App\Models\PaperQuestion::insert($questionInsertData);
 

+ 443 - 0
app/Services/TextbookApiService.php

@@ -0,0 +1,443 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class TextbookApiService
+{
+    protected string $baseUrl;
+
+    public function __construct()
+    {
+        // QuestionBankService API 地址
+        $baseUrl = config('services.question_bank.base_url', 'http://localhost:5015/api');
+        // 移除末尾的 /,保持 baseUrl 以 /api 结尾
+        $this->baseUrl = rtrim($baseUrl, '/');
+    }
+
+    /**
+     * 获取教材系列列表
+     */
+    public function getTextbookSeries(array $params = []): array
+    {
+        try {
+            $response = Http::timeout(30)->get($this->baseUrl . '/textbooks/series', $params);
+
+            if ($response->successful()) {
+                return $response->json();
+            }
+
+            Log::error('Failed to fetch textbook series', [
+                'status' => $response->status(),
+                'body' => $response->body()
+            ]);
+
+            return ['data' => [], 'meta' => []];
+        } catch (\Exception $e) {
+            Log::error('Error fetching textbook series', ['error' => $e->getMessage()]);
+            return ['data' => [], 'meta' => []];
+        }
+    }
+
+    /**
+     * 创建教材系列
+     */
+    public function createTextbookSeries(array $data): array
+    {
+        try {
+            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks/series', $data);
+
+            if ($response->successful()) {
+                return $response->json();
+            }
+
+            Log::error('Failed to create textbook series', [
+                'status' => $response->status(),
+                'body' => $response->body()
+            ]);
+
+            throw new \Exception('Failed to create textbook series');
+        } catch (\Exception $e) {
+            Log::error('Error creating textbook series', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 更新教材系列
+     */
+    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');
+        } catch (\Exception $e) {
+            Log::error('Error updating textbook series', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 删除教材系列
+     */
+    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;
+        } catch (\Exception $e) {
+            Log::error('Error deleting textbook series', ['error' => $e->getMessage()]);
+            return false;
+        }
+    }
+
+    /**
+     * 删除教材
+     */
+    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;
+        } catch (\Exception $e) {
+            Log::error('Error deleting textbook', ['error' => $e->getMessage(), 'textbook_id' => $textbookId]);
+            return false;
+        }
+    }
+
+    /**
+     * 获取教材列表
+     */
+    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' => []];
+        } catch (\Exception $e) {
+            Log::error('Error fetching textbooks', ['error' => $e->getMessage()]);
+            return ['data' => [], 'meta' => []];
+        }
+    }
+
+    /**
+     * 获取单个教材
+     */
+    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;
+        } catch (\Exception $e) {
+            Log::error('Error fetching textbook', ['error' => $e->getMessage()]);
+            return null;
+        }
+    }
+
+    /**
+     * 创建教材
+     */
+    public function createTextbook(array $data): array
+    {
+        try {
+            $response = Http::timeout(30)->post($this->baseUrl . '/textbooks', $data);
+
+            if ($response->successful()) {
+                return $response->json();
+            }
+
+            Log::error('Failed to create textbook', [
+                'status' => $response->status(),
+                'body' => $response->body()
+            ]);
+
+            throw new \Exception('Failed to create textbook');
+        } catch (\Exception $e) {
+            Log::error('Error creating textbook', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 更新教材
+     */
+    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');
+        } catch (\Exception $e) {
+            Log::error('Error updating textbook', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 删除教材目录节点
+     */
+    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;
+        } catch (\Exception $e) {
+            Log::error('Error deleting textbook catalog', ['error' => $e->getMessage(), 'catalog_id' => $catalogId]);
+            return false;
+        }
+    }
+
+    /**
+     * 获取教材目录树
+     */
+    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 [];
+        } catch (\Exception $e) {
+            Log::error('Error fetching textbook catalog', ['error' => $e->getMessage()]);
+            return [];
+        }
+    }
+
+    /**
+     * 预览教材命名
+     */
+    public function previewTextbookNaming(array $textbookData, array $seriesData): array
+    {
+        try {
+            $response = Http::timeout(30)->post($this->baseUrl . '/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 [];
+        } catch (\Exception $e) {
+            Log::error('Error previewing textbook naming', ['error' => $e->getMessage()]);
+            return [];
+        }
+    }
+
+    /**
+     * 导入教材元信息
+     */
+    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()
+            ]);
+
+            throw new \Exception('Failed to import textbook metadata');
+        } catch (\Exception $e) {
+            Log::error('Error importing textbook metadata', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 导入教材目录
+     */
+    public function importTextbookCatalog(int $textbookId, $file, 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
+                ]);
+
+            if ($response->successful()) {
+                return $response->json();
+            }
+
+            Log::error('Failed to import textbook catalog', [
+                'status' => $response->status(),
+                'body' => $response->body()
+            ]);
+
+            throw new \Exception('Failed to import textbook catalog');
+        } catch (\Exception $e) {
+            Log::error('Error importing textbook catalog', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 提交导入任务
+     */
+    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');
+        } catch (\Exception $e) {
+            Log::error('Error committing import job', ['error' => $e->getMessage()]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 获取导入任务列表
+     */
+    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' => []];
+        } catch (\Exception $e) {
+            Log::error('Error fetching import jobs', ['error' => $e->getMessage()]);
+            return ['data' => [], 'meta' => []];
+        }
+    }
+
+    /**
+     * 获取单个导入任务
+     */
+    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;
+        } catch (\Exception $e) {
+            Log::error('Error fetching import job', ['error' => $e->getMessage()]);
+            return null;
+        }
+    }
+}

+ 90 - 0
app/Services/TextbookCoverStorageService.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class TextbookCoverStorageService
+{
+    /**
+     * 保存教材封面上传到云存储,返回URL
+     */
+    public function uploadCover(UploadedFile $file, ?string $textbookId = null): ?string
+    {
+        // 验证文件类型
+        $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
+        if (!in_array($file->getMimeType(), $allowedMimeTypes)) {
+            Log::error('TextbookCoverStorageService: 不支持的文件类型', ['mime_type' => $file->getMimeType()]);
+            return null;
+        }
+
+        // 验证文件大小(最大 5MB)
+        $maxSize = 5 * 1024 * 1024;
+        if ($file->getSize() > $maxSize) {
+            Log::error('TextbookCoverStorageService: 文件过大', ['size' => $file->getSize()]);
+            return null;
+        }
+
+        // 生成唯一文件名
+        $extension = $file->getClientOriginalExtension();
+        $filename = Str::uuid() . '.' . $extension;
+
+        // 构建存储路径
+        $directory = $textbookId ? "textbook-covers/{$textbookId}" : 'textbook-covers/temp';
+        $path = $directory . '/' . $filename;
+
+        // 获取文件二进制内容
+        $binary = file_get_contents($file->getPathname());
+
+        // 使用云存储服务
+        $storageService = app(PdfStorageService::class);
+        $url = $storageService->put($path, $binary);
+
+        if (!$url) {
+            Log::error('TextbookCoverStorageService: 上传失败', ['path' => $path]);
+            return null;
+        }
+
+        Log::info('TextbookCoverStorageService: 上传成功', [
+            'path' => $path,
+            'url' => $url,
+            'textbook_id' => $textbookId,
+        ]);
+
+        return $url;
+    }
+
+    /**
+     * 删除封面文件
+     */
+    public function deleteCover(string $url): bool
+    {
+        // 注意:又拍云和春笋云目前没有删除API
+        // 这里只是记录日志,实际删除需要手动或定时任务处理
+        Log::warning('TextbookCoverStorageService: 删除操作未实现', ['url' => $url]);
+        return true;
+    }
+
+    /**
+     * 获取存储驱动信息
+     */
+    public function getStorageInfo(): array
+    {
+        $driver = config('services.pdf_storage.driver', env('PDF_STORAGE_DRIVER', 'local'));
+
+        return [
+            'driver' => $driver,
+            'name' => match ($driver) {
+                'upyun' => '又拍云',
+                'chunsun' => '春笋云',
+                'local' => '本地存储',
+                default => '未知',
+            },
+            'supports_delete' => false, // 又拍云和春笋都不支持删除API
+        ];
+    }
+}

+ 3 - 0
composer.json

@@ -9,10 +9,13 @@
         "php": "^8.2",
         "alibabacloud/ocr-api-20210707": "^3.1",
         "doctrine/dbal": "*",
+        "dompdf/dompdf": "*",
         "filament/filament": "*",
         "intervention/image": "^3.11",
         "laravel/framework": "^12.0",
         "laravel/tinker": "^2.10.1",
+        "mpdf/mpdf": "*",
+        "phpoffice/phpspreadsheet": "^5.3",
         "thiagoalessio/tesseract_ocr": "^2.13"
     },
     "require-dev": {

Файловите разлики са ограничени, защото са твърде много
+ 955 - 9
composer.lock


+ 91 - 0
debug_grading_panel.md

@@ -0,0 +1,91 @@
+# 调试指南:查看评分面板问题
+
+## 已添加的调试功能
+
+我已经为 `GradingPanel` 组件添加了详细的日志记录,包括:
+- ✅ 开始加载试卷时的参数
+- ✅ 查询到的题目数量
+- ✅ 题目数据处理过程
+- ✅ 异常信息(如果有)
+
+## 测试步骤
+
+### 1. 清空日志
+```bash
+cd FilamentAdmin
+echo "" > storage/logs/laravel.log
+```
+
+### 2. 访问页面并选择试卷
+1. 打开浏览器,访问:http://fa.test/admin/upload-exam-paper
+2. 点击【选择已有试卷评分】
+3. 选择老师和学生(吴同学)
+4. **选择试卷**(选择最新的一份)
+
+### 3. 查看日志
+在另一个终端窗口中,实时监控日志:
+```bash
+tail -f storage/logs/laravel.log | grep "GradingPanel"
+```
+
+或者查看完整日志:
+```bash
+cat storage/logs/laravel.log | grep "GradingPanel" -A 5 -B 2
+```
+
+## 预期日志输出
+
+### 正常情况(题目存在)
+```
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 开始加载试卷题目 {"selected_paper_id":"paper_xxx",...}
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 查询到的题目数量 {"paper_id":"paper_xxx","questions_count":6,"is_empty":false}
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 开始处理题目数据
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 题目数据处理完成 {"questions_count":6,"sample_question":{...}}
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 加载完成 {"final_questions_count":6,"paper_id":"paper_xxx"}
+```
+
+### 异常情况(有问题)
+```
+[YYYY-MM-DD HH:MM:SS] WARNING GradingPanel: 题目为空,设置空数据提示
+[YYYY-MM-DD HH:MM:SS] INFO GradingPanel: 加载完成 {"final_questions_count":1,"paper_id":"paper_xxx"}
+```
+
+或:
+```
+[YYYY-MM-DD HH:MM:SS] ERROR GradingPanel: 加载试卷题目失败 {"paper_id":"paper_xxx","error":"具体错误信息",...}
+```
+
+## 可能的问题和解决方案
+
+### 问题1:日志中没有 GradingPanel 相关记录
+**原因**:组件的 `mount()` 或 `updatedSelectedPaperId()` 方法没有被调用
+**解决**:
+- 检查父视图是否正确传递了 `:selectedPaperId` 参数
+- 确认缓存已清理:`php artisan view:clear`
+
+### 问题2:日志显示 `questions_count: 0`
+**原因**:数据库查询返回空结果
+**解决**:
+- 检查试卷ID是否正确
+- 验证数据库中是否存在该试卷的题目
+
+### 问题3:日志显示异常
+**原因**:代码执行出错
+**解决**:
+- 查看完整的错误信息和堆栈跟踪
+- 根据错误信息修复问题
+
+## 快速验证命令
+
+```bash
+# 1. 检查日志中是否有 GradingPanel 记录
+grep "GradingPanel" storage/logs/laravel.log | tail -20
+
+# 2. 查看最近的错误
+tail -50 storage/logs/laravel.log | grep -A 10 "ERROR"
+
+# 3. 查看所有 GradingPanel 相关的日志
+grep "GradingPanel" storage/logs/laravel.log
+```
+
+请执行以上测试步骤,然后将日志输出结果发送给我,我会根据日志分析具体问题所在。

+ 2 - 6
phpunit.xml

@@ -23,12 +23,8 @@
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="BROADCAST_CONNECTION" value="null"/>
         <env name="CACHE_STORE" value="array"/>
-        <env name="DB_CONNECTION" value="mysql"/>
-        <env name="DB_HOST" value="120.78.197.180"/>
-        <env name="DB_PORT" value="3306"/>
-        <env name="DB_DATABASE" value="math"/>
-        <env name="DB_USERNAME" value="root"/>
-        <env name="DB_PASSWORD" value="bamasoso902"/>
+        <env name="DB_CONNECTION" value="sqlite"/>
+        <env name="DB_DATABASE" value=":memory:"/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="QUEUE_CONNECTION" value="sync"/>
         <env name="SESSION_DRIVER" value="array"/>

+ 570 - 0
resources/css/app.css

@@ -38,6 +38,125 @@
 
 @layer components {
 
+    /* 教材管理页面美化 */
+    .textbook-page {
+        @apply bg-slate-50 min-h-screen;
+    }
+
+    .textbook-header {
+        @apply mb-6 p-6 bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl border border-blue-100 shadow-sm;
+    }
+
+    .textbook-header h1 {
+        @apply text-3xl font-bold text-blue-600 mb-2;
+    }
+
+    .textbook-header p {
+        @apply text-sm text-slate-600;
+    }
+
+    /* 卡片美化 */
+    .textbook-card {
+        @apply bg-white rounded-xl shadow-lg border border-slate-200 p-6 transition-all duration-300 hover:shadow-xl;
+    }
+
+    .textbook-card-header {
+        @apply mb-4 p-4 bg-slate-50 rounded-lg border border-slate-200;
+    }
+
+    .textbook-card-header h2 {
+        @apply text-lg font-semibold text-slate-900;
+    }
+
+    .textbook-card-header p {
+        @apply text-sm text-slate-600 mt-1;
+    }
+
+    /* 表格美化 */
+    .textbook-table {
+        @apply w-full border-collapse overflow-hidden rounded-lg shadow-sm border border-slate-200 bg-white;
+    }
+
+    .textbook-table thead th {
+        @apply px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider bg-slate-50;
+    }
+
+    .textbook-table tbody tr {
+        @apply transition-colors duration-200 hover:bg-slate-50;
+    }
+
+    .textbook-table tbody td {
+        @apply px-6 py-4 text-sm text-slate-900 border-t border-slate-200;
+    }
+
+    /* 按钮美化 */
+    .textbook-btn {
+        @apply px-4 py-2 rounded-lg font-medium transition-all duration-200;
+    }
+
+    .textbook-btn-primary {
+        @apply bg-blue-600 text-white hover:bg-blue-700 shadow-sm hover:shadow-md;
+    }
+
+    .textbook-btn-secondary {
+        @apply bg-white text-slate-700 border border-slate-300 hover:bg-slate-50;
+    }
+
+    .textbook-btn-danger {
+        @apply bg-red-600 text-white hover:bg-red-700 shadow-sm hover:shadow-md;
+    }
+
+    /* 表单美化 */
+    .textbook-form-field {
+        @apply mb-4;
+    }
+
+    .textbook-form-label {
+        @apply block text-sm font-medium text-slate-700 mb-2;
+    }
+
+    .textbook-form-input {
+        @apply w-full px-4 py-3 text-sm border border-slate-300 rounded-lg transition-all duration-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none hover:border-slate-400;
+    }
+
+    /* 徽章美化 */
+    .textbook-badge {
+        @apply inline-flex items-center px-3 py-1 rounded-full text-xs font-medium;
+    }
+
+    .textbook-badge-primary {
+        @apply bg-blue-100 text-blue-800;
+    }
+
+    .textbook-badge-success {
+        @apply bg-green-100 text-green-800;
+    }
+
+    .textbook-badge-warning {
+        @apply bg-yellow-100 text-yellow-800;
+    }
+
+    .textbook-badge-danger {
+        @apply bg-red-100 text-red-800;
+    }
+
+    /* 状态指示器 */
+    .textbook-status {
+        @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
+    }
+
+    .textbook-status-active {
+        @apply bg-green-100 text-green-800;
+    }
+
+    .textbook-status-inactive {
+        @apply bg-red-100 text-red-800;
+    }
+
+    .textbook-status-pending {
+        @apply bg-yellow-100 text-yellow-800;
+    }
+
     /* Filament 登录页面定制 */
     .fi-logo {
         display: none !important;
@@ -516,6 +635,457 @@
     :is(.dark) .fi-simple-layout .fi-btn {
         @apply btn-primary text-white !important;
     }
+
+    /* =========================================
+       教材管理表单美化 (Textbook Form Polish)
+       ========================================= */
+
+    /* 表单容器美化 */
+    .fi-fo-component-ctn {
+        @apply space-y-6 !important;
+    }
+
+    /* 表单字段组美化 */
+    .fi-fo-field-wrp {
+        @apply mb-5 !important;
+    }
+
+    /* 标签美化 */
+    .fi-fo-field-wrp-label {
+        @apply text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2 !important;
+    }
+
+    .fi-fo-field-wrp-label sup {
+        @apply text-red-500 font-bold !important;
+    }
+
+    /* 输入框容器 - 玻璃态效果 */
+    .fi-input-wrp {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl shadow-sm transition-all duration-300 overflow-hidden !important;
+    }
+
+    .fi-input-wrp:hover {
+        @apply border-slate-300 shadow-md !important;
+    }
+
+    .fi-input-wrp:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 !important;
+    }
+
+    /* 输入框本体 */
+    .fi-input-wrp input,
+    .fi-input-wrp textarea {
+        @apply bg-transparent px-4 py-3 text-slate-800 placeholder-slate-400 text-base !important;
+    }
+
+    .fi-input-wrp input::placeholder,
+    .fi-input-wrp textarea::placeholder {
+        @apply text-slate-400 !important;
+    }
+
+    /* 选择框美化 */
+    .fi-select-wrp {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl shadow-sm transition-all duration-300 !important;
+    }
+
+    .fi-select-wrp:hover {
+        @apply border-slate-300 shadow-md !important;
+    }
+
+    .fi-select-wrp:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 !important;
+    }
+
+    /* 原生选择框 */
+    .fi-select-input {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl shadow-sm px-4 py-3 text-slate-800 transition-all duration-300 !important;
+    }
+
+    .fi-select-input:hover {
+        @apply border-slate-300 shadow-md !important;
+    }
+
+    .fi-select-input:focus {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 outline-none !important;
+    }
+
+    /* 多选框美化 */
+    .fi-checkbox-input {
+        @apply w-5 h-5 rounded-lg border-2 border-slate-300 text-blue-600 transition-all duration-200 cursor-pointer !important;
+    }
+
+    .fi-checkbox-input:hover {
+        @apply border-blue-400 !important;
+    }
+
+    .fi-checkbox-input:checked {
+        @apply bg-blue-600 border-blue-600 !important;
+    }
+
+    .fi-checkbox-input:focus {
+        @apply ring-4 ring-blue-500/20 !important;
+    }
+
+    /* Radio 美化 */
+    .fi-radio-input {
+        @apply w-5 h-5 border-2 border-slate-300 text-blue-600 transition-all duration-200 cursor-pointer !important;
+    }
+
+    .fi-radio-input:hover {
+        @apply border-blue-400 !important;
+    }
+
+    .fi-radio-input:checked {
+        @apply border-blue-600 !important;
+    }
+
+    .fi-radio-input:focus {
+        @apply ring-4 ring-blue-500/20 !important;
+    }
+
+    /* Toggle 开关美化 */
+    .fi-toggle-input {
+        @apply w-11 h-6 bg-slate-200 rounded-full transition-all duration-300 cursor-pointer !important;
+    }
+
+    .fi-toggle-input:checked {
+        @apply bg-blue-600 !important;
+    }
+
+    /* 文本域美化 */
+    .fi-textarea-wrp {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl shadow-sm transition-all duration-300 !important;
+    }
+
+    .fi-textarea-wrp:hover {
+        @apply border-slate-300 shadow-md !important;
+    }
+
+    .fi-textarea-wrp:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 !important;
+    }
+
+    .fi-textarea-wrp textarea {
+        @apply bg-transparent px-4 py-3 text-slate-800 placeholder-slate-400 text-base min-h-[120px] !important;
+    }
+
+    /* 帮助文本美化 */
+    .fi-fo-field-wrp-hint {
+        @apply mt-2 text-xs text-slate-500 flex items-center gap-1 !important;
+    }
+
+    .fi-fo-field-wrp-hint::before {
+        content: '💡';
+        @apply text-sm;
+    }
+
+    /* 错误提示美化 */
+    .fi-fo-field-wrp-error-message {
+        @apply mt-2 text-sm text-red-600 flex items-center gap-1 bg-red-50 px-3 py-2 rounded-lg !important;
+    }
+
+    .fi-fo-field-wrp-error-message::before {
+        content: '⚠️';
+    }
+
+    /* 表单区块美化 */
+    .fi-fo-section {
+        @apply bg-white/60 backdrop-blur-sm rounded-2xl border border-slate-200/50 p-6 mb-6 shadow-sm !important;
+    }
+
+    .fi-fo-section-header {
+        @apply pb-4 mb-4 border-b border-slate-200 !important;
+    }
+
+    .fi-fo-section-header-heading {
+        @apply text-lg font-bold text-slate-800 !important;
+    }
+
+    .fi-fo-section-header-description {
+        @apply text-sm text-slate-500 mt-1 !important;
+    }
+
+    /* =========================================
+       表单按钮美化 (Enhanced Form Actions)
+       ========================================= */
+
+    /* 按钮容器 */
+    .fi-ac {
+        @apply flex items-center justify-between gap-4 pt-8 mt-8 border-t border-slate-200 bg-gradient-to-r from-slate-50 to-white px-6 py-6 rounded-xl shadow-sm !important;
+    }
+
+    /* 基础按钮样式重写 - 扩展到所有按钮 */
+    .fi-btn,
+    button[wire*="click"],
+    button[type="submit"],
+    button[type="button"],
+    .fi-icon-btn,
+    .fi-dropdown-list-item-button {
+        @apply transition-all duration-300 !important;
+    }
+
+    /* ===== 主要按钮 (保存/提交) ===== */
+    button.fi-btn[class*="bg-primary"],
+    a.fi-btn[class*="bg-primary"],
+    .fi-btn-fi-color-primary,
+    button[type="submit"] {
+        @apply bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold px-8 py-3 rounded-xl shadow-lg shadow-blue-500/30 transition-all duration-300 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 focus:ring-4 focus:ring-blue-500/20 active:translate-y-0 active:shadow-md border-0 relative overflow-hidden !important;
+    }
+
+    button.fi-btn[class*="bg-primary"]::before,
+    a.fi-btn[class*="bg-primary"]::before,
+    .fi-btn-fi-color-primary::before,
+    button[type="submit"]::before {
+        content: '';
+        @apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 transition-transform duration-700;
+        transform: translateX(-100%);
+    }
+
+    button.fi-btn[class*="bg-primary"]:hover::before,
+    a.fi-btn[class*="bg-primary"]:hover::before,
+    .fi-btn-fi-color-primary:hover::before,
+    button[type="submit"]:hover::before {
+        transform: translateX(100%);
+    }
+
+    button.fi-btn[class*="bg-primary"]:hover,
+    a.fi-btn[class*="bg-primary"]:hover,
+    .fi-btn-fi-color-primary:hover,
+    button[type="submit"]:hover {
+        @apply shadow-blue-500/40 !important;
+    }
+
+    /* ===== 次要按钮 (取消/返回) ===== */
+    button.fi-btn[class*="bg-gray"],
+    a.fi-btn[class*="bg-gray"],
+    .fi-btn-fi-color-gray {
+        @apply bg-white hover:bg-slate-50 text-slate-700 font-semibold px-8 py-3 rounded-xl border-2 border-slate-200 shadow-md hover:shadow-lg transition-all duration-300 hover:-translate-y-0.5 focus:ring-4 focus:ring-slate-300/50 active:translate-y-0 active:shadow-sm !important;
+    }
+
+    button.fi-btn[class*="bg-gray"]:hover,
+    a.fi-btn[class*="bg-gray"]:hover,
+    .fi-btn-fi-color-gray:hover {
+        @apply border-slate-300 text-slate-900 !important;
+    }
+
+    /* ===== 成功按钮 (保存并创建另一个) ===== */
+    button.fi-btn[class*="bg-success"],
+    a.fi-btn[class*="bg-success"],
+    .fi-btn-fi-color-success {
+        @apply bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-semibold px-8 py-3 rounded-xl shadow-lg shadow-emerald-500/30 transition-all duration-300 hover:shadow-xl hover:shadow-emerald-500/40 hover:-translate-y-0.5 focus:ring-4 focus:ring-emerald-500/20 active:translate-y-0 active:shadow-md border-0 relative overflow-hidden !important;
+    }
+
+    button.fi-btn[class*="bg-success"]::before,
+    a.fi-btn[class*="bg-success"]::before,
+    .fi-btn-fi-color-success::before {
+        content: '';
+        @apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 transition-transform duration-700;
+        transform: translateX(-100%);
+    }
+
+    button.fi-btn[class*="bg-success"]:hover::before,
+    a.fi-btn[class*="bg-success"]:hover::before,
+    .fi-btn-fi-color-success:hover::before {
+        transform: translateX(100%);
+    }
+
+    button.fi-btn[class*="bg-success"]:hover,
+    a.fi-btn[class*="bg-success"]:hover,
+    .fi-btn-fi-color-success:hover {
+        @apply shadow-emerald-500/40 !important;
+    }
+
+    /* ===== 危险按钮 (删除) ===== */
+    button.fi-btn[class*="bg-danger"],
+    a.fi-btn[class*="bg-danger"],
+    .fi-btn-fi-color-danger {
+        @apply bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-600 hover:to-rose-600 text-white font-semibold px-8 py-3 rounded-xl shadow-lg shadow-red-500/30 transition-all duration-300 hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5 focus:ring-4 focus:ring-red-500/20 active:translate-y-0 active:shadow-md border-0 relative overflow-hidden !important;
+    }
+
+    button.fi-btn[class*="bg-danger"]::before,
+    a.fi-btn[class*="bg-danger"]::before,
+    .fi-btn-fi-color-danger::before {
+        content: '';
+        @apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 transition-transform duration-700;
+        transform: translateX(-100%);
+    }
+
+    button.fi-btn[class*="bg-danger"]:hover::before,
+    a.fi-btn[class*="bg-danger"]:hover::before,
+    .fi-btn-fi-color-danger:hover::before {
+        transform: translateX(100%);
+    }
+
+    button.fi-btn[class*="bg-danger"]:hover,
+    a.fi-btn[class*="bg-danger"]:hover,
+    .fi-btn-fi-color-danger:hover {
+        @apply shadow-red-500/40 !important;
+    }
+
+    /* ===== 警告按钮 ===== */
+    button.fi-btn[class*="bg-warning"],
+    a.fi-btn[class*="bg-warning"],
+    .fi-btn-fi-color-warning {
+        @apply bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold px-8 py-3 rounded-xl shadow-lg shadow-amber-500/30 transition-all duration-300 hover:shadow-xl hover:shadow-amber-500/40 hover:-translate-y-0.5 focus:ring-4 focus:ring-amber-500/20 active:translate-y-0 active:shadow-md border-0 relative overflow-hidden !important;
+    }
+
+    button.fi-btn[class*="bg-warning"]::before,
+    a.fi-btn[class*="bg-warning"]::before,
+    .fi-btn-fi-color-warning::before {
+        content: '';
+        @apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 transition-transform duration-700;
+        transform: translateX(-100%);
+    }
+
+    button.fi-btn[class*="bg-warning"]:hover::before,
+    a.fi-btn[class*="bg-warning"]:hover::before,
+    .fi-btn-fi-color-warning:hover::before {
+        transform: translateX(100%);
+    }
+
+    button.fi-btn[class*="bg-warning"]:hover,
+    a.fi-btn[class*="bg-warning"]:hover,
+    .fi-btn-fi-color-warning:hover {
+        @apply shadow-amber-500/40 !important;
+    }
+
+    /* ===== 操作按钮 (编辑/查看) ===== */
+    .fi-btn-fi-color-secondary {
+        @apply bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-semibold px-6 py-2.5 rounded-lg shadow-md transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 !important;
+    }
+
+    /* ===== 通用轮廓按钮 ===== */
+    .fi-outlined.fi-btn,
+    button.fi-btn.outline,
+    a.fi-btn.outline {
+        @apply bg-white hover:bg-slate-50 text-slate-700 border-2 border-slate-200 shadow-sm hover:shadow-md transition-all duration-300 hover:-translate-y-0.5 !important;
+    }
+
+    .fi-outlined.fi-btn:hover,
+    button.fi-btn.outline:hover,
+    a.fi-btn.outline:hover {
+        @apply border-slate-300 text-slate-900 !important;
+    }
+
+    /* ===== 表格操作按钮 ===== */
+    .fi-table .fi-btn,
+    [data-fi-table] button,
+    .fi-bulk-action,
+    .fi-dropdown-list-item-button {
+        @apply transition-all duration-300 !important;
+    }
+
+    .fi-table .fi-btn:hover,
+    [data-fi-table] button:hover,
+    .fi-bulk-action:hover,
+    .fi-dropdown-list-item-button:hover {
+        @apply -translate-y-0.5 shadow-lg !important;
+    }
+
+    /* ===== 批量操作按钮 ===== */
+    .fi-bulk-action-group .fi-btn {
+        @apply mx-1 !important;
+    }
+
+    /* ===== 响应式按钮 ===== */
+    @media (max-width: 640px) {
+        .fi-ac {
+            @apply flex-col !important;
+        }
+
+        .fi-ac .fi-btn {
+            @apply w-full justify-center !important;
+        }
+
+        .fi-btn,
+        button[type="submit"],
+        button[type="button"],
+        a.fi-btn {
+            @apply px-6 py-3 text-sm !important;
+        }
+
+        .fi-table .fi-btn {
+            @apply px-3 py-2 text-xs !important;
+        }
+    }
+
+    /* KeyValue 组件美化 */
+    .fi-fo-key-value {
+        @apply bg-white/80 backdrop-blur-sm rounded-xl border-2 border-slate-200 overflow-hidden !important;
+    }
+
+    .fi-fo-key-value-header {
+        @apply bg-slate-50 px-4 py-3 border-b border-slate-200 !important;
+    }
+
+    .fi-fo-key-value-row {
+        @apply flex items-center border-b border-slate-100 last:border-b-0 !important;
+    }
+
+    .fi-fo-key-value-row input {
+        @apply px-4 py-3 text-slate-800 !important;
+    }
+
+    /* TagsInput 美化 */
+    .fi-fo-tags-input {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl p-2 transition-all duration-300 !important;
+    }
+
+    .fi-fo-tags-input:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 !important;
+    }
+
+    .fi-fo-tags-input-tag {
+        @apply bg-blue-100 text-blue-800 px-3 py-1 rounded-lg text-sm font-medium !important;
+    }
+
+    /* Repeater 美化 */
+    .fi-fo-repeater-item {
+        @apply bg-white/80 backdrop-blur-sm rounded-xl border-2 border-slate-200 p-4 mb-4 transition-all duration-300 !important;
+    }
+
+    .fi-fo-repeater-item:hover {
+        @apply border-slate-300 shadow-md !important;
+    }
+
+    /* 文件上传美化 */
+    .fi-fo-file-upload {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-dashed border-slate-300 rounded-xl p-8 text-center transition-all duration-300 !important;
+    }
+
+    .fi-fo-file-upload:hover {
+        @apply border-blue-400 bg-blue-50/50 !important;
+    }
+
+    .fi-fo-file-upload-icon {
+        @apply text-slate-400 mb-3 !important;
+    }
+
+    /* 日期选择器美化 */
+    .fi-fo-date-time-picker {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl shadow-sm transition-all duration-300 !important;
+    }
+
+    .fi-fo-date-time-picker:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 ring-4 ring-blue-500/10 !important;
+    }
+
+    /* 富文本编辑器美化 */
+    .fi-fo-rich-editor {
+        @apply bg-white/90 backdrop-blur-sm border-2 border-slate-200 rounded-xl overflow-hidden transition-all duration-300 !important;
+    }
+
+    .fi-fo-rich-editor:focus-within {
+        @apply border-blue-500 shadow-lg shadow-blue-500/10 !important;
+    }
+
+    .fi-fo-rich-editor-toolbar {
+        @apply bg-slate-50 border-b border-slate-200 px-3 py-2 !important;
+    }
+
+    .fi-fo-rich-editor-content {
+        @apply px-4 py-3 min-h-[200px] !important;
+    }
 }
 
 @layer utilities {

+ 6 - 6
resources/views/examples/exam-analysis-components-example.blade.php

@@ -4,7 +4,7 @@
 -->
 
 <!-- 示例1: OCR记录页面 (紧凑模式) -->
-<x-filament-panels::page>
+<div>
     <div class="space-y-4">
         @if($loading)
             <x-exam-analysis.loading message="正在分析试卷数据..." />
@@ -18,10 +18,10 @@
             @endif
         @endif
     </div>
-</x-filament-panels::page>
+</div>
 
 <!-- 示例2: 系统生成卷子页面 (标准模式) -->
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         @if($loading)
             <x-exam-analysis.loading message="正在加载试卷数据..." />
@@ -59,10 +59,10 @@
             @endif
         @endif
     </div>
-</x-filament-panels::page>
+</div>
 
 <!-- 示例3: 自定义页面布局 -->
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <!-- 只显示题目详情 -->
         <x-exam-analysis.question-details :questions="$questions" />
@@ -75,4 +75,4 @@
             <x-exam-analysis.recommendations :recommendations="$recommendations" />
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/examples/math-render-example.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <div class="bg-white p-6 rounded-lg border">
             <h2 class="text-2xl font-bold mb-4">数学公式渲染示例</h2>
@@ -69,7 +69,7 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>
 
 @push('scripts')
 <script>

+ 2 - 2
resources/views/filament/pages/exam-analysis-compact.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-4">
         @if($loading)
             <x-exam-analysis.loading message="正在分析试卷数据..." />
@@ -105,4 +105,4 @@
             @endif
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/exam-analysis-standard.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         @if($loading)
             <x-exam-analysis.loading message="正在分析试卷数据..." />
@@ -270,4 +270,4 @@
             @endif
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/exam-detail.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <!-- 页面顶部:返回按钮和标题 -->
         <div class="flex items-center justify-between">
@@ -436,4 +436,4 @@
             });
         });
     </script>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/exam-history-simple.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <!-- 页面标题 -->
         <div class="flex justify-between items-center">
@@ -223,4 +223,4 @@
         </div>
     </div>
     @endif
-</x-filament-panels::page>
+</div>

+ 1 - 1
resources/views/filament/pages/exam-history.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .exam-card {

+ 2 - 2
resources/views/filament/pages/integrations/knowledge-graph-explorer.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-4">
         {{-- 操作切换条(保留单一标题区) --}}
         <div class="flex items-center gap-2">
@@ -245,7 +245,7 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>
 
 @push('styles')
 <style>

+ 2 - 2
resources/views/filament/pages/integrations/knowledge-graph-integration.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         {{-- 页面标题 --}}
         <div class="flex items-center justify-between">
@@ -170,7 +170,7 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>
 
 @push('scripts')
 <script>

+ 47 - 20
resources/views/filament/pages/intelligent-exam-generation-simple.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .exam-card {
@@ -178,12 +178,35 @@
                     </div>
 
                     <div class="selection-card border rounded-lg p-4">
-                        <label class="block text-sm font-medium text-gray-700 mb-2">难度分类</label>
-                        <select wire:model="difficultyCategory" class="form-select w-full px-3 py-2 rounded-lg text-sm">
-                            <option value="基础">基础</option>
-                            <option value="进阶">进阶</option>
-                            <option value="竞赛">竞赛</option>
-                        </select>
+                        <label class="block text-sm font-medium text-gray-700 mb-2">难度选择 <span class="text-gray-400 font-normal">(多选,不选=随机)</span></label>
+                        <div class="space-y-2">
+                            @php
+                                $difficultyLevels = ['基础', '中等', '拔高'];
+                            @endphp
+                            @foreach($difficultyLevels as $level)
+                                <label class="flex items-center gap-2 cursor-pointer hover:bg-blue-50 p-2 rounded-lg transition-all">
+                                    <input
+                                        type="checkbox"
+                                        wire:model.live="selectedDifficultyLevels"
+                                        value="{{ $level }}"
+                                        class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+                                    />
+                                    <span class="text-sm text-gray-700">{{ $level }}</span>
+                                </label>
+                            @endforeach
+                        </div>
+                        @if(empty($selectedDifficultyLevels))
+                            <p class="text-xs text-amber-600 mt-2 flex items-center gap-1">
+                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                                </svg>
+                                未选择难度,将随机生成
+                            </p>
+                        @else
+                            <p class="text-xs text-blue-600 mt-2">
+                                已选择: {{ implode(', ', $selectedDifficultyLevels) }}
+                            </p>
+                        @endif
                     </div>
 
                     <div class="selection-card border rounded-lg p-4">
@@ -479,14 +502,18 @@
                     </svg>
                 </div>
                 <div>
-                    <h3 class="text-xl font-semibold text-gray-900">步骤 3:选择知识点</h3>
-                    <p class="text-sm text-gray-500" x-data x-effect="$el.textContent = `勾选要考查的知识点(已选择: ${$wire.selectedKpCodes.length} 个)`">
-                        勾选要考查的知识点(已选择: {{ count($selectedKpCodes) }} 个)
+                    <h3 class="text-xl font-semibold text-gray-900">步骤 3:选择知识点 <span class="text-sm font-normal text-gray-500">(可选,未选择将按年级出题)</span></h3>
+                    <p class="text-sm text-gray-500" x-data x-effect="$el.textContent = `勾选要考查的知识点(已选择: ${$wire.selectedKpCodes.length} 个,未选择将按年级随机生成)`">
+                        勾选要考查的知识点(已选择: {{ count($selectedKpCodes) }} 个,未选择将按年级随机生成
                     </p>
                     @if($selectedStudentId && $filterByStudentWeakness && count($this->studentWeaknesses) > 0)
                         <p class="text-xs text-blue-600 mt-1">
                             💡 提示:您也可以在上方选择学生的薄弱知识点,两个区域的知识点会合并生效
                         </p>
+                    @else
+                        <p class="text-xs text-blue-600 mt-1">
+                            💡 提示:如果不选择知识点,系统将根据所选年级自动生成题目
+                        </p>
                     @endif
                 </div>
             </div>
@@ -532,7 +559,7 @@
             $missingSteps = [];
             if (empty($selectedTeacherId)) { $missingSteps[] = '选择教师'; }
             if (empty($selectedStudentId)) { $missingSteps[] = '选择学生'; }
-            if (!$hasKnowledgePoints) { $missingSteps[] = '勾选至少 1 个知识点'; }
+            // 注意:不强制要求选择知识点,没有选择时将按年级或随机生成
             if (!$questionCountValid) { $missingSteps[] = '题目数量需 ≥ 6 题'; }
 
             // 强制访问 selectedKpCodes 来触发 Livewire 刷新
@@ -541,7 +568,7 @@
             // 调试信息
             $debugInfo = "教师: " . ($selectedTeacherId ? "✓" : "✗") .
                         " | 学生: " . ($selectedStudentId ? "✓" : "✗") .
-                        " | 知识点: " . ($hasKnowledgePoints ? "✓ (" . count($selectedKpCodesForDebug) . "个)" : "✗ (实际:" . count($selectedKpCodesForDebug) . "个)") .
+                        " | 知识点: " . ($hasKnowledgePoints ? "✓ (" . count($selectedKpCodesForDebug) . "个)" : "○ (未选,将按年级出题)") .
                         " | 题目数: " . ($questionCountValid ? "✓ ({$totalQuestions})" : "✗ ({$totalQuestions})");
 
             // 显示实际的知识点代码
@@ -590,23 +617,23 @@
                     </p>
                 </div>
 
-                <div class="selection-card border-2 rounded-xl p-5 {{ $hasKnowledgePoints ? 'border-green-400 bg-gradient-to-br from-green-50 to-emerald-50' : 'border-amber-300 bg-gradient-to-br from-amber-50 to-orange-50' }}">
+                <div class="selection-card border-2 rounded-xl p-5 {{ $hasKnowledgePoints ? 'border-green-400 bg-gradient-to-br from-green-50 to-emerald-50' : 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50' }}">
                     <div class="flex items-center gap-3 mb-2">
-                        <div class="w-10 h-10 rounded-lg {{ $hasKnowledgePoints ? 'bg-green-100' : 'bg-amber-100' }} flex items-center justify-center">
+                        <div class="w-10 h-10 rounded-lg {{ $hasKnowledgePoints ? 'bg-green-100' : 'bg-blue-100' }} flex items-center justify-center">
                             @if($hasKnowledgePoints)
                                 <svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                     <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
                                 </svg>
                             @else
-                                <svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                                <svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                                 </svg>
                             @endif
                         </div>
-                        <span class="text-sm font-bold {{ $hasKnowledgePoints ? 'text-green-800' : 'text-amber-800' }}">知识点选择</span>
+                        <span class="text-sm font-bold {{ $hasKnowledgePoints ? 'text-green-800' : 'text-blue-800' }}">知识点选择</span>
                     </div>
-                    <p class="text-sm {{ $hasKnowledgePoints ? 'text-green-700' : 'text-amber-700' }}" x-data x-effect="$el.textContent = $wire.selectedKpCodes.length > 0 ? `✓ 已选 ${$wire.selectedKpCodes.length} 个知识点` : '请选择至少1个知识点'">
-                        {{ $hasKnowledgePoints ? '✓ 已选 ' . count($selectedKpCodes) . ' 个知识点' : '请选择至少1个知识点' }}
+                    <p class="text-sm {{ $hasKnowledgePoints ? 'text-green-700' : 'text-blue-700' }}" x-data x-effect="$el.textContent = $wire.selectedKpCodes.length > 0 ? `✓ 已选 ${$wire.selectedKpCodes.length} 个知识点` : '○ 未选择,将按年级出题'">
+                        {{ $hasKnowledgePoints ? '✓ 已选 ' . count($selectedKpCodes) . ' 个知识点' : '○ 未选择,将按年级出题' }}
                     </p>
                 </div>
 
@@ -801,4 +828,4 @@
             @endif
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 33 - 30
resources/views/filament/pages/intelligent-exam-generation.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .exam-card {
@@ -82,14 +82,20 @@
                         />
 
                         <div class="grid grid-cols-3 gap-4">
-                            <select
-                                wire:model="difficultyCategory"
-                                label="难度分类"
-                            >
-                                <option value="基础">基础</option>
-                                <option value="进阶">进阶</option>
-                                <option value="竞赛">竞赛</option>
-                            /select>
+                            <div class="col-span-2">
+                                <div class="text-sm font-medium text-gray-700 mb-2">难度选择(可多选)</div>
+                                <div class="flex flex-wrap gap-3">
+                                    @foreach(['基础','中等','拔高'] as $level)
+                                        <label class="inline-flex items-center gap-2 text-sm text-gray-700">
+                                            <input type="checkbox"
+                                                   wire:model="selectedDifficultyLevels"
+                                                   value="{{ $level }}"
+                                                   class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
+                                            <span>{{ $level }}</span>
+                                        </label>
+                                    @endforeach
+                                </div>
+                            </div>
 
                             <input
                                 wire:model="totalQuestions"
@@ -210,6 +216,12 @@
                     </x-slot>
 
                     <div class="space-y-3">
+                        <div class="flex items-center gap-2 text-xs text-gray-600">
+                            快捷配比:
+                            <button wire:click="applyRatioPreset('4-2-4')" type="button" class="px-2 py-1 border rounded hover:bg-gray-100">4:2:4</button>
+                            <button wire:click="applyRatioPreset('5-2-3')" type="button" class="px-2 py-1 border rounded hover:bg-gray-100">5:2:3</button>
+                            <span class="ml-2 text-gray-400">(选择题/填空题/解答题)</span>
+                        </div>
                         @foreach($questionTypeRatio as $type => $percentage)
                             <div class="flex items-center gap-4">
                                 <div class="w-24 text-sm font-medium text-gray-700">{{ $type }}</div>
@@ -249,27 +261,18 @@
                     </x-slot>
 
                     <div class="space-y-3">
-                        @foreach($difficultyRatio as $level => $percentage)
-                            <div class="flex items-center gap-4">
-                                <div class="w-24 text-sm font-medium text-gray-700">{{ $level }}</div>
-                                <div class="flex-1">
-                                    <input
-                                        type="range"
-                                        min="0"
-                                        max="100"
-                                        wire:model="difficultyRatio.{{ $level }}"
-                                        class="w-full"
-                                    />
-                                </div>
-                                <div class="w-16 text-sm text-gray-600">{{ $percentage }}%</div>
-                            </div>
-                        @endforeach
-                        <div class="text-xs text-gray-500">
-                            总计: {{ array_sum($difficultyRatio) }}%
-                            @if(array_sum($difficultyRatio) !== 100)
-                                <span class="text-red-500 ml-2">(应为100%)</span>
-                            @endif
+                        <div class="flex flex-wrap gap-3 text-sm text-gray-700">
+                            @foreach(['基础','中等','拔高'] as $level)
+                                <label class="inline-flex items-center gap-2">
+                                    <input type="checkbox"
+                                           wire:model="selectedDifficultyLevels"
+                                           value="{{ $level }}"
+                                           class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
+                                    <span>{{ $level }}</span>
+                                </label>
+                            @endforeach
                         </div>
+                        <div class="text-xs text-gray-500 mt-2">可多选,未选时默认全选。</div>
                     </div>
                 </div>
             </div>
@@ -545,7 +548,7 @@
                             </div>
                             <div>
                                 <h3 class="text-xl font-bold text-gray-900">参考答案</h3>
-                                <p class="text-sm text-gray-500">{{ $paperName ?: '智能生成试卷' }}</p>
+                                <p class="text-sm text-gray-500">{{ $generatedPaperId ?: ($paperName ?: '智能生成试卷') }}</p>
                             </div>
                         </div>
                     </x-slot>

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

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <!-- 页面标题 -->
     <div class="flex items-center justify-between mb-6">
         <div>
@@ -214,4 +214,4 @@
         </div>
     </div>
     <x-filament-actions::modals />
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-graph-visualization-backup.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div 
         x-data="{
             initGraph() {
@@ -95,4 +95,4 @@
     >
         <div id="mountNode" class="w-full h-full"></div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-graph-visualization-simple.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .graph-container {
@@ -255,4 +255,4 @@
             });
         </script>
     @endpush
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-graph-visualization.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .graph-container {
@@ -317,4 +317,4 @@
             });
         </script>
     @endpush
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-point-detail.blade.php

@@ -3,7 +3,7 @@
     $phaseParam = request()->query('phase');
 @endphp
 
-<x-filament-panels::page>
+<div>
     @if($point)
         <div class="space-y-6">
                 <!-- 头部信息 - 紧凑版本 -->
@@ -823,4 +823,4 @@
                 });
         </script>
     @endpush
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-point-stats.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         {{-- 页面标题和筛选 --}}
         <div class="flex items-center justify-between">
@@ -317,4 +317,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-points.blade.php

@@ -13,7 +13,7 @@
     $currentPhase = $this->currentPhase;
 @endphp
 
-<x-filament-panels::page>
+<div>
     <div class="space-y-8">
         <div class="grid gap-4 md:grid-cols-4">
             @foreach($stats as $stat)
@@ -307,4 +307,4 @@
             window.location.href = url.toString();
         }
     </script>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/knowledge-relation-management.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
         <div class="stats shadow bg-base-100">
             <div class="stat">
@@ -93,4 +93,4 @@
             </tbody>
         </table>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-analysis-view.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .stat-card {
@@ -240,4 +240,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-paper-analysis.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <!-- 页面标题 -->
         <div class="flex items-center justify-between">
@@ -272,4 +272,4 @@
             </x-filament::section>
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-paper-grading.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .status-pending { color: #6b7280; }
@@ -305,4 +305,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-record-list.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="space-y-6">
     {{-- 统计卡片 --}}
@@ -241,4 +241,4 @@
     </div>
 </div>
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-record-view-new.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="space-y-6" @if($isGenerating) wire:poll.5s="checkGenerationStatus" @endif>
     @php
@@ -751,4 +751,4 @@
 
 <x-math-render />
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/ocr-record-view.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="space-y-6">
     @php
@@ -567,4 +567,4 @@
     @endif
 </div>
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/prompt-management.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="flex flex-col gap-y-6">
 
         {{-- 统计信息 --}}
@@ -252,4 +252,4 @@
             </div>
         </div>
     @endif
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/question-generation.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <!-- 数学公式渲染组件 -->
 <x-math-render />
@@ -308,4 +308,4 @@ document.addEventListener('livewire:init', () => {
 });
 </script>
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/question-management-simple.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <!-- 数学公式渲染组件 -->
 <x-math-render />
@@ -352,7 +352,7 @@
         </div>
     @endif
 
-    </x-filament-panels::page>
+    </div>
 
 <script>
 document.addEventListener('livewire:init', () => {

+ 2 - 2
resources/views/filament/pages/question-management.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="space-y-6">
     @php
@@ -407,4 +407,4 @@
     </script>
 </div>
 
-</x-filament-panels::page>
+</div>

+ 128 - 0
resources/views/filament/pages/question-review.blade.php

@@ -0,0 +1,128 @@
+<x-filament::page>
+    @php
+        $mineruBlocks = $mineru['blocks'] ?? [];
+        $builderQuestions = $builder['questions'] ?? [];
+    @endphp
+
+    <div class="space-y-4">
+        <x-filament::section>
+            {{ $this->form }}
+            @if($message)
+                <div class="text-sm text-gray-600 mt-2">{{ $message }}</div>
+            @endif
+        </x-filament::section>
+
+        <x-filament::section key="page-{{ $book }}-{{ $page }}">
+            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
+                <div class="space-y-2 sticky top-4">
+                    <div class="flex items-center justify-between">
+                        <div>
+                            <h3 class="text-lg font-semibold">整页预览 (page {{ $page }})</h3>
+                            @if(!empty($paths))
+                                <div class="text-[11px] text-gray-500">builder: {{ $paths['builder_page'] ?? '' }}</div>
+                            @endif
+                        </div>
+                        <div class="text-xs text-gray-500">点击页码自动加载</div>
+                    </div>
+                    @if(!empty($pagePngBase64))
+                        @php
+                            $width = $mineru['width'] ?? 0;
+                            $height = $mineru['height'] ?? 0;
+                            $targetHeight = 820;
+                            $scale = ($height > 0) ? ($targetHeight / $height) : 0.28;
+                            $showWidth = $width ? $width * $scale : 520;
+                            $showHeight = $targetHeight;
+                        @endphp
+                        <div class="relative border rounded overflow-hidden bg-gray-100" style="max-width: 100%; height: {{ $showHeight }}px; width: {{ $showWidth }}px;">
+                            <img loading="lazy" src="data:image/png;base64,{{ $pagePngBase64 }}" style="height: 100%; width: auto; max-width: 100%; object-fit: contain;" />
+                            @if($showOverlay)
+                                @foreach($mineruBlocks as $b)
+                                    @php
+                                        $bbox = $b['bbox'] ?? [0,0,0,0];
+                                        $x = $bbox[0] * $scale; $y = $bbox[1] * $scale;
+                                        $w = ($bbox[2]-$bbox[0]) * $scale; $h = ($bbox[3]-$bbox[1]) * $scale;
+                                        if ($w < 4 && $h < 4) continue;
+                                        $color = 'rgba(59,130,246,0.25)';
+                                        $type = strtolower($b['type'] ?? 'text');
+                                        if($type === 'figure') $color = 'rgba(16,185,129,0.25)';
+                                        if($type === 'table') $color = 'rgba(234,179,8,0.25)';
+                                        if($type === 'formula') $color = 'rgba(239,68,68,0.25)';
+                                    @endphp
+                                    <div class="absolute border border-blue-500"
+                                         style="pointer-events: none; left: {{ $x }}px; top: {{ $y }}px; width: {{ $w }}px; height: {{ $h }}px; background: {{ $color }};"
+                                         title="{{ $b['type'] ?? '' }}">
+                                    </div>
+                                @endforeach
+                            @endif
+                        </div>
+                    @else
+                        <div class="text-sm text-gray-500">无页面图片</div>
+                    @endif
+                </div>
+
+                <div class="space-y-2">
+                    <h3 class="text-lg font-semibold">生成题目({{ count($builderQuestions) }})</h3>
+                    <div class="h-[820px] overflow-auto text-sm bg-gray-50 rounded p-3 space-y-3">
+                        @foreach($builderQuestions as $idx => $q)
+                            <div class="border border-gray-200 rounded p-2 space-y-2">
+                                <div class="flex items-center justify-between">
+                                    <div class="font-semibold">Q{{ $q['index'] ?? ($idx+1) }} ({{ $q['type'] ?? '' }})</div>
+                                    <div class="flex items-center gap-2">
+                                        @if(!empty($q['qa_flags']))
+                                            <span class="text-xs text-amber-600">QA: {{ implode(',', $q['qa_flags']) }}</span>
+                                        @endif
+                                        <x-filament::button wire:click="saveQuestion({{ $idx }})" color="success" size="sm">加入草稿</x-filament::button>
+                                    </div>
+                                </div>
+                                <div class="text-gray-800 text-sm">{{ $q['stem'] ?? '' }}</div>
+                                @if(!empty($q['options']))
+                                    <div class="text-xs text-gray-700 space-y-1">
+                                        @foreach($q['options'] as $k=>$v)
+                                            <div>{{ $k }}. {{ $v }}</div>
+                                        @endforeach
+                                    </div>
+                                @endif
+                                @if(!empty($q['images']))
+                                    <div class="flex flex-wrap gap-2">
+                                        @foreach($q['images'] as $img)
+                                            <div class="border rounded bg-white p-1 text-xs text-blue-700">
+                                                {{ $img['path'] ?? 'img' }}
+                                            </div>
+                                        @endforeach
+                                    </div>
+                                @endif
+                                <div class="text-[11px] text-gray-500">
+                                    raw_blocks: {{ isset($q['raw_blocks']) ? json_encode($q['raw_blocks']) : '' }}
+                                </div>
+                            </div>
+                        @endforeach
+                    </div>
+                </div>
+            </div>
+        </x-filament::section>
+
+        @if($mineruBlocks)
+            <x-filament::section>
+                <details>
+                    <summary class="cursor-pointer font-semibold">MinerU Blocks({{ count($mineruBlocks) }})点击展开</summary>
+                    <div class="mt-2 max-h-[360px] overflow-auto text-xs bg-gray-50 rounded p-3 space-y-2">
+                        @foreach($mineruBlocks as $b)
+                            <div class="border border-gray-200 rounded p-2">
+                                <div class="font-semibold">{{ $b['type'] ?? 'text' }} / cat: {{ $b['category_id'] ?? '' }}</div>
+                                <div class="text-gray-600">bbox: {{ json_encode($b['bbox'] ?? []) }}</div>
+                                <div class="text-gray-600 truncate">text: {{ $b['text'] ?? ($b['content'] ?? '') }}</div>
+                            </div>
+                        @endforeach
+                    </div>
+                </details>
+            </x-filament::section>
+        @endif
+
+        @if($builder)
+            <x-filament::section>
+                <h3 class="text-lg font-semibold mb-2">整页题目 JSON(编辑后可“保存到草稿”)</h3>
+                <textarea wire:model.defer="builderJson" class="w-full h-[720px] min-h-[720px] text-xs font-mono border rounded p-2 resize-y">{{ $builderJson }}</textarea>
+            </x-filament::section>
+        @endif
+    </div>
+</x-filament::page>

+ 2 - 2
resources/views/filament/pages/recommendation-list.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         @if($loading)
             <div class="flex items-center justify-center py-12">
@@ -162,4 +162,4 @@
             @endif
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/simulated-grading.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="min-h-screen bg-gray-50 p-8">
     {{-- 页面标题区域 --}}
@@ -425,4 +425,4 @@
 
 <x-math-render />
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/student-analysis-simple.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .mastery-card {
@@ -180,4 +180,4 @@
             </div>
         @endif
     </div>
-</x-filament-panels::page>
+</div>

+ 1 - 1
resources/views/filament/pages/student-analysis.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @push('styles')
         <style>
             .mastery-card {

+ 2 - 2
resources/views/filament/pages/student-knowledge-graph-page.blade.php

@@ -1,6 +1,6 @@
-<x-filament-panels::page>
+<div>
     @livewire('student-knowledge-graph', [
         'showNodeDetails' => $showNodeDetails,
         'detailLayout' => $detailLayout,
     ])
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/student-management.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     <div class="space-y-6">
         <!-- 顶部操作条 -->
         <div class="flex flex-wrap items-center justify-between gap-3">
@@ -158,4 +158,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/pages/upload-exam-paper.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
 
 <div class="space-y-6">
     {{-- 模式选择 --}}
@@ -255,4 +255,4 @@
         @endif
     </div>
 
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/resources/student-resource/pages/create-student.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @php
         $currentUser = auth()->user();
         $isTeacher = $currentUser?->isTeacher() ?? false;
@@ -172,4 +172,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/resources/student-resource/pages/edit-student.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @php
         $student = $this->record;
         $currentUser = auth()->user();
@@ -204,4 +204,4 @@
             </x-filament::button>
         </x-slot>
     </x-filament::modal>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/resources/student-resource/pages/view-student.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @php
         $student = $this->record;
         $teacher = $student->teacher;
@@ -195,4 +195,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/resources/teacher/pages/edit-teacher.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @php
         $teacher = $this->record->loadMissing('students');
         $user = $teacher->user ?? \App\Models\User::find($teacher->user_id);
@@ -249,4 +249,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 2 - 2
resources/views/filament/resources/teacher/pages/view-teacher.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page>
+<div>
     @php
         $teacher = $this->record;
     @endphp
@@ -222,4 +222,4 @@
             </div>
         </div>
     </div>
-</x-filament-panels::page>
+</div>

+ 101 - 0
resources/views/filament/resources/textbook-catalog-resource/index-record.blade.php

@@ -0,0 +1,101 @@
+<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
+    @push('styles')
+        <style>
+            .glass-card {
+                @apply bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl;
+            }
+
+            .gradient-text {
+                @apply bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
+            }
+
+            .stat-card {
+                @apply glass-card p-6 hover-lift;
+            }
+
+            .stat-icon {
+                @apply w-12 h-12 rounded-full flex items-center justify-center text-white text-xl shadow-lg;
+            }
+
+            .hover-lift {
+                @apply transition-all duration-300 hover:transform hover:-translate-y-1 hover:shadow-2xl;
+            }
+
+            .pulse-animation {
+                animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+            }
+
+            @keyframes pulse {
+                0%, 100% {
+                    opacity: 1;
+                }
+                50% {
+                    opacity: .8;
+                }
+            }
+        </style>
+    @endpush
+
+    <div class="container mx-auto px-4 py-8 max-w-7xl">
+        <!-- 页面标题 -->
+        <div class="text-center mb-8">
+            <div class="glass-card inline-block px-8 py-4 mb-6">
+                <h1 class="text-4xl font-bold gradient-text mb-2">
+                    教材目录管理
+                </h1>
+                <p class="text-slate-600">
+                    管理教材的章节目录结构
+                </p>
+            </div>
+        </div>
+
+        <!-- 统计卡片 -->
+        <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
+            @php
+                $catalogs = $this->table->getRecords();
+                $total = $catalogs->count();
+            @endphp
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">目录总数</p>
+                        <p class="text-3xl font-bold gradient-text">{{ $total }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-blue-500 to-blue-600 pulse-animation" style="animation-delay: 0s;">
+                        📋
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">已发布</p>
+                        <p class="text-3xl font-bold text-green-600">{{ $total }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-green-500 to-green-600 pulse-animation" style="animation-delay: 0.3s;">
+                        ✓
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">活跃</p>
+                        <p class="text-3xl font-bold text-purple-600">{{ $total }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-purple-500 to-purple-600 pulse-animation" style="animation-delay: 0.6s;">
+                        🔄
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 表格容器 -->
+        <div class="glass-card p-6">
+            {{ $this->table }}
+        </div>
+    </div>
+</div>

+ 116 - 0
resources/views/filament/resources/textbook-resource/index-record.blade.php

@@ -0,0 +1,116 @@
+<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
+    @push('styles')
+        <style>
+            .glass-card {
+                @apply bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl;
+            }
+
+            .gradient-text {
+                @apply bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
+            }
+
+            .stat-card {
+                @apply glass-card p-6 hover-lift;
+            }
+
+            .stat-icon {
+                @apply w-12 h-12 rounded-full flex items-center justify-center text-white text-xl shadow-lg;
+            }
+
+            .hover-lift {
+                @apply transition-all duration-300 hover:transform hover:-translate-y-1 hover:shadow-2xl;
+            }
+
+            .pulse-animation {
+                animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+            }
+
+            @keyframes pulse {
+                0%, 100% {
+                    opacity: 1;
+                }
+                50% {
+                    opacity: .8;
+                }
+            }
+        </style>
+    @endpush
+
+    <div class="container mx-auto px-4 py-8 max-w-7xl">
+        <!-- 页面标题 -->
+        <div class="text-center mb-8">
+            <div class="glass-card inline-block px-8 py-4 mb-6">
+                <h1 class="text-4xl font-bold gradient-text mb-2">
+                    教材管理中心
+                </h1>
+                <p class="text-slate-600">
+                    管理所有教材信息和版本
+                </p>
+            </div>
+        </div>
+
+        <!-- 统计卡片 -->
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
+            @php
+                $textbooks = $this->table->getRecords();
+                $total = $textbooks->count();
+                $published = $textbooks->where('status', 'published')->count();
+                $draft = $textbooks->where('status', 'draft')->count();
+                $archived = $textbooks->where('status', 'archived')->count();
+            @endphp
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">教材总数</p>
+                        <p class="text-3xl font-bold gradient-text">{{ $total }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-blue-500 to-blue-600 pulse-animation" style="animation-delay: 0s;">
+                        📖
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">已发布</p>
+                        <p class="text-3xl font-bold text-green-600">{{ $published }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-green-500 to-green-600 pulse-animation" style="animation-delay: 0.3s;">
+                        ✓
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">草稿</p>
+                        <p class="text-3xl font-bold text-yellow-600">{{ $draft }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-yellow-500 to-yellow-600 pulse-animation" style="animation-delay: 0.6s;">
+                        📝
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">已归档</p>
+                        <p class="text-3xl font-bold text-gray-600">{{ $archived }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-gray-500 to-gray-600 pulse-animation" style="animation-delay: 0.9s;">
+                        📦
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 表格容器 -->
+        <div class="glass-card p-6">
+            {{ $this->table }}
+        </div>
+    </div>
+</div>

+ 103 - 0
resources/views/filament/resources/textbook-series-resource/index-record.blade.php

@@ -0,0 +1,103 @@
+<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
+    @push('styles')
+        <style>
+            .glass-card {
+                @apply bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl;
+            }
+
+            .gradient-text {
+                @apply bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
+            }
+
+            .stat-card {
+                @apply glass-card p-6 hover-lift;
+            }
+
+            .stat-icon {
+                @apply w-12 h-12 rounded-full flex items-center justify-center text-white text-xl shadow-lg;
+            }
+
+            .hover-lift {
+                @apply transition-all duration-300 hover:transform hover:-translate-y-1 hover:shadow-2xl;
+            }
+
+            .pulse-animation {
+                animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+            }
+
+            @keyframes pulse {
+                0%, 100% {
+                    opacity: 1;
+                }
+                50% {
+                    opacity: .8;
+                }
+            }
+        </style>
+    @endpush
+
+    <div class="container mx-auto px-4 py-8 max-w-7xl">
+        <!-- 页面标题 -->
+        <div class="text-center mb-8">
+            <div class="glass-card inline-block px-8 py-4 mb-6">
+                <h1 class="text-4xl font-bold gradient-text mb-2">
+                    教材系列管理中心
+                </h1>
+                <p class="text-slate-600">
+                    管理所有教材版本和系列信息
+                </p>
+            </div>
+        </div>
+
+        <!-- 统计卡片 -->
+        <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
+            @php
+                $series = $this->table->getRecords();
+                $total = count($series);
+                $active = $series->where('is_active', true)->count();
+                $inactive = $total - $active;
+            @endphp
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">系列总数</p>
+                        <p class="text-3xl font-bold gradient-text">{{ $total }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-blue-500 to-blue-600 pulse-animation" style="animation-delay: 0s;">
+                        📚
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">启用系列</p>
+                        <p class="text-3xl font-bold text-green-600">{{ $active }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-green-500 to-green-600 pulse-animation" style="animation-delay: 0.5s;">
+                        ✓
+                    </div>
+                </div>
+            </div>
+
+            <div class="stat-card">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm font-medium text-slate-600">停用系列</p>
+                        <p class="text-3xl font-bold text-red-600">{{ $inactive }}</p>
+                    </div>
+                    <div class="stat-icon bg-gradient-to-r from-red-500 to-red-600 pulse-animation" style="animation-delay: 1s;">
+                        ⏸
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 表格容器 -->
+        <div class="glass-card p-6">
+            {{ $this->table }}
+        </div>
+    </div>
+</div>

+ 1 - 1
resources/views/vendor/filament/auth/login.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page.simple>
+<div.simple>
     {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::SIMPLE_PAGE_START) }}
 
     {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::SIMPLE_LAYOUT_START) }}

+ 1 - 1
resources/views/vendor/filament/auth/pages/login.blade.php

@@ -1,4 +1,4 @@
-<x-filament-panels::page.simple>
+<div.simple>
     {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::SIMPLE_PAGE_START) }}
 
     {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::SIMPLE_LAYOUT_START) }}

+ 27 - 0
routes/api.php

@@ -1,6 +1,7 @@
 <?php
 
 use App\Http\Controllers\Api\IntelligentExamController;
+use App\Http\Controllers\Api\TextbookApiController;
 use App\Services\QuestionServiceApi;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Route;
@@ -425,6 +426,32 @@ Route::post('/exam-analysis/report', [ExamAnalysisApiController::class, 'store']
     ])
     ->name('api.exam-analysis.report');
 
+/*
+|--------------------------------------------------------------------------
+| 教材管理 API 路由
+|--------------------------------------------------------------------------
+*/
+
+// 获取教材列表(按年级排序)
+Route::get('/textbooks', [TextbookApiController::class, 'index'])
+    ->name('api.textbooks.index');
+
+// 根据年级获取教材
+Route::get('/textbooks/grade/{grade}', [TextbookApiController::class, 'getByGrade'])
+    ->name('api.textbooks.by-grade');
+
+// 获取教材系列列表(必须在 {id} 路由之前定义)
+Route::get('/textbooks/series', [TextbookApiController::class, 'getSeries'])
+    ->name('api.textbooks.series');
+
+// 获取单个教材详情
+Route::get('/textbooks/{id}', [TextbookApiController::class, 'show'])
+    ->name('api.textbooks.show');
+
+// 获取教材目录
+Route::get('/textbooks/{id}/catalog', [TextbookApiController::class, 'getCatalog'])
+    ->name('api.textbooks.catalog');
+
 /*
 |--------------------------------------------------------------------------
 | MathRecSys 集成 API 路由

Някои файлове не бяха показани, защото твърде много файлове са промени