UploadExamPaper.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Models\Student;
  4. use App\Models\Teacher;
  5. use App\Services\ExamPaperService;
  6. use App\Filament\Traits\HasUserRole;
  7. use BackedEnum;
  8. use Filament\Notifications\Notification;
  9. use Filament\Pages\Page;
  10. use Livewire\Attributes\Computed;
  11. use Livewire\Attributes\On;
  12. use UnitEnum;
  13. class UploadExamPaper extends Page
  14. {
  15. use HasUserRole;
  16. protected static ?string $title = '上传试卷';
  17. protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud-arrow-up';
  18. protected static ?string $navigationLabel = '上传试卷';
  19. protected static string|UnitEnum|null $navigationGroup = '操作';
  20. protected static ?int $navigationSort = 3;
  21. protected static ?string $slug = 'upload-exam-paper';
  22. protected string $view = 'filament.pages.upload-exam-paper';
  23. public ?string $teacherId = null;
  24. public ?string $studentId = null;
  25. // 模式选择
  26. public string $mode = 'select_paper'; // 'upload' 或 'select_paper'
  27. public ?string $selectedPaperId = null;
  28. public array $questions = [];
  29. public array $gradingData = [];
  30. public array $questionGrades = []; // 存储每道题的评分
  31. public function mount()
  32. {
  33. // 初始化用户角色
  34. $this->initializeUserRole();
  35. // 如果是老师,自动选择当前老师
  36. if ($this->isTeacher) {
  37. $teacherId = $this->getCurrentTeacherId();
  38. if ($teacherId) {
  39. $this->teacherId = $teacherId;
  40. }
  41. } else {
  42. $this->teacherId = null;
  43. }
  44. $this->studentId = null;
  45. $this->mode = 'select_paper';
  46. $this->selectedPaperId = null;
  47. $this->questionGrades = [];
  48. }
  49. #[Computed]
  50. public function teachers(): array
  51. {
  52. return app(ExamPaperService::class)->getTeachers(
  53. $this->isTeacher ? $this->getCurrentTeacherId() : null
  54. );
  55. }
  56. #[Computed]
  57. public function students(): array
  58. {
  59. return app(ExamPaperService::class)->getStudents($this->teacherId);
  60. }
  61. #[Computed]
  62. public function recentRecords(): array
  63. {
  64. return app(ExamPaperService::class)->getRecentRecords($this->studentId);
  65. }
  66. #[Computed]
  67. public function studentPapers(): array
  68. {
  69. return app(ExamPaperService::class)->getStudentPapers($this->studentId);
  70. }
  71. #[Computed]
  72. public function selectedPaperQuestions(): array
  73. {
  74. return app(ExamPaperService::class)->getPaperQuestions($this->selectedPaperId);
  75. }
  76. public function updatedTeacherId($value): void
  77. {
  78. // 当教师选择变化时,清空之前选择的学生
  79. $this->studentId = null;
  80. $this->selectedPaperId = null;
  81. $this->questionGrades = [];
  82. }
  83. public function updatedStudentId($value): void
  84. {
  85. // 当学生选择变化时,清空已选试卷
  86. $this->selectedPaperId = null;
  87. $this->questionGrades = [];
  88. }
  89. public function updatedMode($value): void
  90. {
  91. // 切换模式时重置相关字段
  92. $this->selectedPaperId = null;
  93. $this->questionGrades = [];
  94. }
  95. /**
  96. * 处理评分完成事件
  97. */
  98. #[On('gradingComplete')]
  99. public function handleGradingComplete(): void
  100. {
  101. // 提交完成后跳转到详情页
  102. if ($this->selectedPaperId && $this->studentId) {
  103. $url = $this->getViewRecordUrl('graded_paper', $this->selectedPaperId, '', $this->studentId);
  104. $this->redirect($url, navigate: true);
  105. }
  106. }
  107. /**
  108. * 处理来自子组件的评分提交事件
  109. */
  110. #[On('submitManualGrading')]
  111. public function handleSubmitFromParent(array $questionGrades, array $gradingData, array $questions): void
  112. {
  113. // 从子组件接收数据
  114. $this->questionGrades = $questionGrades;
  115. $this->gradingData = $gradingData;
  116. $this->questions = $questions;
  117. \Log::info('UploadExamPaper: 接收到子组件提交的评分数据', [
  118. 'questionGrades_count' => count($questionGrades),
  119. 'questions_count' => count($questions)
  120. ]);
  121. // 调用原有的提交方法
  122. $this->submitManualGrading();
  123. // 通知用户提交成功
  124. Notification::make()
  125. ->title('评分提交成功')
  126. ->success()
  127. ->send();
  128. // 触发事件处理跳转
  129. $this->dispatch('gradingComplete');
  130. }
  131. #[On('teacherChanged')]
  132. public function updateTeacherId($teacherId)
  133. {
  134. $this->teacherId = $teacherId;
  135. $this->studentId = null;
  136. }
  137. #[On('studentChanged')]
  138. public function updateStudentId($teacherId, $studentId)
  139. {
  140. $this->studentId = $studentId;
  141. }
  142. /**
  143. * 提交手动评分
  144. */
  145. public function submitManualGrading(): void
  146. {
  147. if (!$this->selectedPaperId) {
  148. Notification::make()
  149. ->title('请选择试卷')
  150. ->danger()
  151. ->send();
  152. return;
  153. }
  154. // 将 gradingData 转换为 questionGrades 格式
  155. // 注意:这里假设子组件已经传递了处理好的 questionGrades,或者我们在这里再次处理
  156. // 如果子组件传递了 questionGrades,我们直接使用它。
  157. // 如果没有(比如直接在父组件调用),我们需要 convertGradingDataToQuestionGrades。
  158. // 但目前逻辑是子组件调用 handleSubmitFromParent 传递数据。
  159. if (empty($this->questionGrades)) {
  160. Notification::make()
  161. ->title('请至少为一道题目评分')
  162. ->danger()
  163. ->send();
  164. return;
  165. }
  166. try {
  167. // 准备数据发送到 LearningAnalytics
  168. $analyticsData = [];
  169. // 获取题目详情以便查找kp_code
  170. $questionsMap = collect($this->selectedPaperQuestions)->keyBy('id');
  171. // 收集需要从API补充信息的题目ID
  172. $missingKpCodeQuestionIds = [];
  173. foreach ($this->questionGrades as $questionId => $grade) {
  174. $question = $questionsMap->get($questionId);
  175. if (!$question) {
  176. continue;
  177. }
  178. // 优先使用本地存储的 kp_code
  179. if (empty($question['kp_code'])) {
  180. $missingKpCodeQuestionIds[] = $questionId;
  181. }
  182. }
  183. // 如果有缺失 kp_code 的题目,尝试从 API 获取
  184. $apiDetailsMap = collect([]);
  185. if (!empty($missingKpCodeQuestionIds)) {
  186. $questionBankIds = collect($missingKpCodeQuestionIds)
  187. ->map(fn($qId) => $questionsMap->get($qId)['question_bank_id'] ?? null)
  188. ->filter()
  189. ->toArray();
  190. if (!empty($questionBankIds)) {
  191. $questionBankService = app(\App\Services\QuestionBankService::class);
  192. $questionsDetails = $questionBankService->getQuestionsByIds($questionBankIds);
  193. $apiDetailsMap = collect($questionsDetails['data'] ?? [])->keyBy('id');
  194. }
  195. }
  196. foreach ($this->questionGrades as $questionId => $grade) {
  197. $question = $questionsMap->get($questionId);
  198. if (!$question) {
  199. continue;
  200. }
  201. $kpCode = $question['kp_code'];
  202. // 如果本地没有,尝试从API结果中获取
  203. if (empty($kpCode)) {
  204. $detail = $apiDetailsMap->get($question['question_bank_id']);
  205. $kpCode = $detail['kp_code'] ?? $detail['knowledge_point_code'] ?? null;
  206. }
  207. // 确保 is_correct 有值(如果为 null,设置为 false)
  208. $isCorrect = $grade['is_correct'];
  209. if ($isCorrect === null) {
  210. $isCorrect = false;
  211. }
  212. $analyticsData[] = [
  213. 'question_bank_id' => $question['question_bank_id'],
  214. 'student_answer' => $grade['student_answer'] ?? '',
  215. 'is_correct' => $isCorrect,
  216. 'score' => $grade['score'] ?? 0,
  217. 'max_score' => $question['score'] ?? 0,
  218. 'kp_code' => $kpCode,
  219. 'ip_address' => '127.0.0.1', // 提供默认IP地址,避免PostgreSQL inet类型错误
  220. 'device_type' => 'web', // 提供默认设备类型
  221. 'feedback_provided' => false, // 提供默认反馈状态
  222. ];
  223. }
  224. // 调用 LearningAnalytics 服务
  225. $learningAnalyticsService = app(\App\Services\LearningAnalyticsService::class);
  226. // 步骤0: 保存学生答案到本地数据库 (重要:确保数据持久化)
  227. foreach ($this->questionGrades as $questionId => $grade) {
  228. // 从数据库获取当前题目的记录(包含满分)
  229. $paperQuestion = \App\Models\PaperQuestion::where('id', $questionId)->first();
  230. if (!$paperQuestion) {
  231. \Log::warning('未找到题目记录', ['question_id' => $questionId]);
  232. continue;
  233. }
  234. // 确保 is_correct 是布尔值(转换字符串 'true'/'false' 为布尔值)
  235. $isCorrect = $grade['is_correct'];
  236. if ($isCorrect === 'true' || $isCorrect === true) {
  237. $isCorrect = true;
  238. } elseif ($isCorrect === 'false' || $isCorrect === false) {
  239. $isCorrect = false;
  240. }
  241. // 确保 score_obtained 是数字
  242. $score = $grade['score'];
  243. if ($score !== null) {
  244. $score = is_numeric($score) ? (float)$score : 0;
  245. }
  246. // **关键修复**:确保 is_correct 和 score 的一致性
  247. // score 优先级高于 is_correct,根据得分比例动态计算
  248. $maxScore = $paperQuestion->score ?? 0;
  249. if ($maxScore > 0) {
  250. $scoreRatio = $score / $maxScore;
  251. // 只有达到满分才算完全正确
  252. if ($scoreRatio >= 1.0) {
  253. $isCorrect = true;
  254. } elseif ($scoreRatio > 0) {
  255. $isCorrect = false; // 部分得分不算完全正确
  256. } else {
  257. $isCorrect = false;
  258. }
  259. }
  260. \Log::info('保存评分数据', [
  261. 'question_id' => $questionId,
  262. 'max_score' => $maxScore,
  263. 'score_obtained' => $score,
  264. 'is_correct' => $isCorrect,
  265. 'score_ratio' => $maxScore > 0 ? ($score / $maxScore) : 0
  266. ]);
  267. \App\Models\PaperQuestion::where('id', $questionId)->update([
  268. 'student_answer' => $grade['student_answer'] ?? '',
  269. 'is_correct' => $isCorrect,
  270. 'score_obtained' => $score ?? 0,
  271. ]);
  272. }
  273. \Log::info('学生答案已保存到数据库', [
  274. 'student_id' => $this->studentId,
  275. 'paper_id' => $this->selectedPaperId,
  276. 'updated_count' => count($this->questionGrades)
  277. ]);
  278. // 步骤1: 保存答题记录到 LearningAnalytics
  279. \Log::info('准备调用submitBatchAttempts API', [
  280. 'student_id' => $this->studentId,
  281. 'paper_id' => $this->selectedPaperId,
  282. 'analytics_data_sample' => array_slice($analyticsData, 0, 2) // 记录前2题的数据作为样本
  283. ]);
  284. $result = $learningAnalyticsService->submitBatchAttempts($this->studentId, [
  285. 'paper_id' => $this->selectedPaperId,
  286. 'answers' => $analyticsData,
  287. ]);
  288. // 检查API返回结果
  289. if (is_array($result) && isset($result['error']) && $result['error']) {
  290. throw new \Exception($result['message'] ?? 'API调用失败');
  291. }
  292. if ($result === null || (is_array($result) && empty($result))) {
  293. throw new \Exception('API返回空数据');
  294. }
  295. \Log::info('答题记录已保存到学习分析服务', [
  296. 'student_id' => $this->studentId,
  297. 'paper_id' => $this->selectedPaperId,
  298. 'count' => count($analyticsData)
  299. ]);
  300. // 步骤2: 触发 AI 分析(包含掌握度更新和学习报告生成)
  301. try {
  302. // 构造 AI 分析请求数据
  303. $analysisQuestions = [];
  304. foreach ($this->questionGrades as $questionId => $grade) {
  305. $question = $questionsMap->get($questionId);
  306. if (!$question) {
  307. continue;
  308. }
  309. $kpCode = $question['kp_code'];
  310. if (empty($kpCode)) {
  311. $detail = $apiDetailsMap->get($question['question_bank_id']);
  312. $kpCode = $detail['kp_code'] ?? $detail['knowledge_point_code'] ?? null;
  313. }
  314. // 计算满分
  315. $maxScore = floatval($question['score'] ?? 0);
  316. $scoreValue = floatval($grade['score'] ?? 0);
  317. $isCorrect = $maxScore > 0 ? ($scoreValue >= $maxScore) : ($grade['is_correct'] ?? false);
  318. $analysisQuestions[] = [
  319. 'question_id' => $question['question_bank_id'],
  320. 'question_number' => (string)$question['question_number'],
  321. 'question_text' => $question['content'] ?? '',
  322. 'student_answer' => $grade['student_answer'] ?? '',
  323. 'correct_answer' => $question['answer'] ?? '',
  324. 'kp_code' => $kpCode,
  325. 'score_value' => $scoreValue,
  326. 'max_score' => $maxScore,
  327. 'is_correct' => $isCorrect,
  328. 'teacher_validated' => true, // 手动评分即为教师验证
  329. 'ocr_confidence' => 1.0, // 手动评分置信度为1
  330. ];
  331. }
  332. $analysisData = [
  333. 'exam_id' => $this->selectedPaperId,
  334. 'student_id' => $this->studentId,
  335. 'ocr_record_id' => 0, // 系统生成卷子没有OCR记录ID
  336. 'paper_id' => $this->selectedPaperId,
  337. 'teacher_name' => auth()->user()->name ?? 'Teacher',
  338. 'analysis_type' => 'mastery',
  339. 'questions' => $analysisQuestions,
  340. ];
  341. // 调用统一的 AI 分析接口
  342. \Log::info('准备调用submitOCRAnalysis API', [
  343. 'paper_id' => $this->selectedPaperId,
  344. 'student_id' => $this->studentId,
  345. 'analysis_data_sample' => [
  346. 'question_count' => count($analysisQuestions),
  347. 'first_question' => $analysisQuestions[0] ?? null
  348. ]
  349. ]);
  350. $analysisResult = $learningAnalyticsService->submitOCRAnalysis($analysisData);
  351. \Log::info('AI分析已触发', [
  352. 'paper_id' => $this->selectedPaperId,
  353. 'student_id' => $this->studentId,
  354. 'analysis_result_keys' => is_array($analysisResult) ? array_keys($analysisResult) : 'not_array',
  355. 'analysis_result' => $analysisResult
  356. ]);
  357. // 保存 analysis_id 到 Paper 表
  358. if (isset($analysisResult['analysis_id'])) {
  359. \App\Models\Paper::where('paper_id', $this->selectedPaperId)->update([
  360. 'analysis_id' => $analysisResult['analysis_id'],
  361. ]);
  362. \Log::info('已保存 analysis_id', [
  363. 'paper_id' => $this->selectedPaperId,
  364. 'analysis_id' => $analysisResult['analysis_id']
  365. ]);
  366. }
  367. } catch (\Exception $analysisError) {
  368. // AI 分析失败不影响主流程
  369. \Log::warning('触发AI分析失败', [
  370. 'paper_id' => $this->selectedPaperId,
  371. 'error' => $analysisError->getMessage()
  372. ]);
  373. }
  374. // 更新Paper表状态为已完成评分
  375. \App\Models\Paper::where('paper_id', $this->selectedPaperId)->update([
  376. 'status' => 'completed',
  377. 'completed_at' => now(),
  378. ]);
  379. Notification::make()
  380. ->title('提交成功')
  381. ->body('评分已提交,AI分析正在进行中')
  382. ->success()
  383. ->send();
  384. // 刷新最近记录列表
  385. unset($this->recentRecords);
  386. // 重置表单
  387. $this->selectedPaperId = null;
  388. $this->questionGrades = [];
  389. } catch (\Exception $e) {
  390. \Log::error('提交手动评分失败', [
  391. 'error' => $e->getMessage(),
  392. 'student_id' => $this->studentId,
  393. 'paper_id' => $this->selectedPaperId,
  394. ]);
  395. Notification::make()
  396. ->title('提交失败')
  397. ->body($e->getMessage())
  398. ->danger()
  399. ->send();
  400. }
  401. }
  402. /**
  403. * 查看记录详情 - 使用页面跳转
  404. */
  405. public function getViewRecordUrl(string $type, string $paperId, string $recordId, string $studentId): string
  406. {
  407. // 返回ExamAnalysis详情页面URL
  408. if (in_array($type, ['graded_paper', 'generated'])) {
  409. // 系统生成或已评分试卷,使用paperId
  410. return '/admin/exam-analysis?paperId=' . $paperId . '&studentId=' . $studentId;
  411. } elseif ($type === 'ocr_upload') {
  412. // OCR上传记录,也跳转到详情页
  413. return '/admin/exam-analysis?recordId=' . $recordId . '&studentId=' . $studentId;
  414. }
  415. return '#';
  416. }
  417. }