UploadExamPaper.php 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Jobs\ProcessOCRRecord;
  4. use App\Models\OCRRecord;
  5. use App\Models\Student;
  6. use App\Models\Teacher;
  7. use App\Filament\Traits\HasUserRole;
  8. use BackedEnum;
  9. use Filament\Notifications\Notification;
  10. use Filament\Pages\Page;
  11. use Filament\Forms;
  12. use Livewire\WithFileUploads;
  13. use Livewire\Attributes\Computed;
  14. use Livewire\Attributes\On;
  15. use Illuminate\Support\Facades\Storage;
  16. use UnitEnum;
  17. class UploadExamPaper extends Page
  18. {
  19. use HasUserRole, WithFileUploads;
  20. protected static ?string $title = '上传试卷';
  21. protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud-arrow-up';
  22. protected static ?string $navigationLabel = '上传试卷';
  23. protected static string|UnitEnum|null $navigationGroup = '操作';
  24. protected static ?int $navigationSort = 2;
  25. protected static ?string $slug = 'upload-exam-paper';
  26. protected string $view = 'filament.pages.upload-exam-paper';
  27. public ?string $teacherId = null;
  28. public ?string $studentId = null;
  29. public $uploadedImage = null;
  30. public bool $isUploading = false;
  31. public ?string $paperType = null; // 试卷类型:unit_test, midterm, final, homework, quiz, other
  32. public $form;
  33. public array $data = [];
  34. public bool $analyzing = false;
  35. public ?string $analysisError = null;
  36. // 新增:模式选择
  37. public string $mode = 'upload'; // 'upload' 或 'select_paper'
  38. public ?string $selectedPaperId = null;
  39. public bool $showGrading = false;
  40. public array $questions = [];
  41. public array $gradingData = [];
  42. public ?string $paperName = null;
  43. public ?string $paperClass = null;
  44. public ?string $paperStudent = null;
  45. public ?string $paperDate = null;
  46. public array $questionGrades = []; // 存储每道题的评分
  47. public function mount()
  48. {
  49. // 初始化用户角色
  50. $this->initializeUserRole();
  51. // 如果是老师,自动选择当前老师
  52. if ($this->isTeacher) {
  53. $teacherId = $this->getCurrentTeacherId();
  54. if ($teacherId) {
  55. $this->teacherId = $teacherId;
  56. }
  57. } else {
  58. $this->teacherId = null;
  59. }
  60. $this->studentId = null;
  61. $this->uploadedImage = null;
  62. $this->paperType = null;
  63. $this->mode = 'upload';
  64. $this->selectedPaperId = null;
  65. $this->questionGrades = [];
  66. }
  67. public function form(Forms\Form $form): Forms\Form
  68. {
  69. return $form
  70. ->statePath('data')
  71. ->schema([
  72. Forms\Components\FileUpload::make('image')
  73. ->label('上传试卷图片')
  74. ->image()
  75. ->multiple()
  76. ->directory('exam-papers')
  77. ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/jpg'])
  78. ->helperText('支持PNG、JPG、JPEG格式,可同时上传多张图片')
  79. ->maxFiles(10)
  80. ->required(),
  81. Forms\Components\TextInput::make('paper_name')
  82. ->label('试卷名称')
  83. ->required()
  84. ->placeholder('例如:数学期末考试'),
  85. Forms\Components\Select::make('class')
  86. ->label('班级')
  87. ->options([
  88. 'ClassA' => '三年级一班',
  89. 'ClassB' => '三年级二班',
  90. 'ClassC' => '四年级一班',
  91. 'ClassD' => '四年级二班',
  92. 'ClassE' => '五年级一班',
  93. 'ClassF' => '五年级二班',
  94. 'ClassG' => '六年级一班',
  95. 'ClassH' => '六年级二班',
  96. ])
  97. ->required(),
  98. Forms\Components\TextInput::make('student_name')
  99. ->label('学生姓名')
  100. ->required()
  101. ->placeholder('请输入学生姓名'),
  102. Forms\Components\Select::make('paper_type')
  103. ->label('试卷类型')
  104. ->options([
  105. 'quiz' => '课堂测验',
  106. 'midterm' => '期中考试',
  107. 'final' => '期末考试',
  108. 'homework' => '家庭作业',
  109. ])
  110. ->default('quiz')
  111. ->required(),
  112. Forms\Components\TextInput::make('paper_subject')
  113. ->label('科目')
  114. ->default('数学')
  115. ->required(),
  116. ]);
  117. }
  118. #[Computed]
  119. public function teachers(): array
  120. {
  121. try {
  122. $query = Teacher::query()
  123. ->leftJoin('users as u', 'teachers.teacher_id', '=', 'u.user_id')
  124. ->select(
  125. 'teachers.teacher_id',
  126. 'teachers.name',
  127. 'teachers.subject',
  128. 'u.username',
  129. 'u.email'
  130. );
  131. // 如果是老师,只返回自己
  132. if ($this->isTeacher) {
  133. $teacherId = $this->getCurrentTeacherId();
  134. if ($teacherId) {
  135. $query->where('teachers.teacher_id', $teacherId);
  136. }
  137. }
  138. $teachers = $query->orderBy('teachers.name')->get();
  139. // 检查是否有学生没有对应的老师记录
  140. $teacherIds = $teachers->pluck('teacher_id')->toArray();
  141. $missingTeacherIds = Student::query()
  142. ->distinct()
  143. ->whereNotIn('teacher_id', $teacherIds)
  144. ->pluck('teacher_id')
  145. ->toArray();
  146. $teachersArray = $teachers->all();
  147. if (!empty($missingTeacherIds)) {
  148. foreach ($missingTeacherIds as $missingId) {
  149. $teachersArray[] = (object) [
  150. 'teacher_id' => $missingId,
  151. 'name' => '未知老师 (' . $missingId . ')',
  152. 'subject' => '未知',
  153. 'username' => null,
  154. 'email' => null
  155. ];
  156. }
  157. usort($teachersArray, function($a, $b) {
  158. return strcmp($a->name, $b->name);
  159. });
  160. }
  161. return $teachersArray;
  162. } catch (\Exception $e) {
  163. \Illuminate\Support\Facades\Log::error('加载老师列表失败', [
  164. 'error' => $e->getMessage()
  165. ]);
  166. return [];
  167. }
  168. }
  169. #[Computed]
  170. public function students(): array
  171. {
  172. if (empty($this->teacherId)) {
  173. return [];
  174. }
  175. try {
  176. return Student::query()
  177. ->leftJoin('users as u', 'students.student_id', '=', 'u.user_id')
  178. ->where('students.teacher_id', $this->teacherId)
  179. ->select(
  180. 'students.student_id',
  181. 'students.name',
  182. 'students.grade',
  183. 'students.class_name',
  184. 'u.username',
  185. 'u.email'
  186. )
  187. ->orderBy('students.grade')
  188. ->orderBy('students.class_name')
  189. ->orderBy('students.name')
  190. ->get()
  191. ->all();
  192. } catch (\Exception $e) {
  193. \Illuminate\Support\Facades\Log::error('加载学生列表失败', [
  194. 'teacher_id' => $this->teacherId,
  195. 'error' => $e->getMessage()
  196. ]);
  197. return [];
  198. }
  199. }
  200. #[Computed]
  201. public function recentRecords(): array
  202. {
  203. // 1. 获取OCR记录(图片上传)
  204. $ocrQuery = OCRRecord::with('student');
  205. // 如果选择了学生,则筛选该学生的记录
  206. if (!empty($this->studentId)) {
  207. $ocrQuery->where('user_id', $this->studentId);
  208. }
  209. $ocrRecords = $ocrQuery->latest()->take(5)->get()
  210. ->map(function($record) {
  211. $studentName = $record->student?->name ?: ('学生ID: ' . $record->user_id);
  212. return [
  213. 'type' => 'ocr_upload',
  214. 'id' => $record->id,
  215. 'record_id' => $record->id,
  216. 'paper_id' => null,
  217. 'student_id' => $record->user_id,
  218. 'student_name' => $studentName,
  219. 'paper_type' => $record->paper_type_label,
  220. 'paper_name' => $record->image_filename ?: '未命名图片',
  221. 'status' => $record->status,
  222. 'total_questions' => $record->total_questions,
  223. 'processed_questions' => $record->processed_questions ?? 0,
  224. 'created_at' => $record->created_at->format('Y-m-d H:i'),
  225. 'is_completed' => $record->status === 'completed',
  226. ];
  227. })->toArray();
  228. // 2. 获取所有Paper记录(包括草稿和已评分)
  229. $paperQuery = \App\Models\Paper::with('student');
  230. // 如果选择了学生,则筛选该学生的记录
  231. if (!empty($this->studentId)) {
  232. $paperQuery->where('student_id', $this->studentId);
  233. }
  234. $allPapers = $paperQuery->latest()->take(5)->get()
  235. ->map(function($paper) {
  236. $type = $paper->status === 'completed' ? 'graded_paper' : 'generated';
  237. $paperType = $paper->status === 'completed' ? '已评分试卷' : '系统生成试卷';
  238. $iconColor = $paper->status === 'completed' ? 'text-green-500' : 'text-blue-500';
  239. $studentName = $paper->student?->name ?: ('学生ID: ' . $paper->student_id);
  240. return [
  241. 'type' => $type,
  242. 'id' => $paper->paper_id,
  243. 'record_id' => null,
  244. 'paper_id' => $paper->paper_id,
  245. 'student_id' => $paper->student_id,
  246. 'student_name' => $studentName,
  247. 'paper_type' => $paperType,
  248. 'paper_name' => $paper->paper_name ?? '未命名试卷',
  249. 'status' => $paper->difficulty_category,
  250. 'total_questions' => $paper->question_count ?? 0,
  251. 'created_at' => $paper->created_at->format('Y-m-d H:i'),
  252. 'is_completed' => $paper->status === 'completed',
  253. 'icon_color' => $iconColor,
  254. ];
  255. })->toArray();
  256. // 3. 合并并按时间排序
  257. $allRecords = array_merge($ocrRecords, $allPapers);
  258. usort($allRecords, function($a, $b) {
  259. return strcmp($b['created_at'], $a['created_at']);
  260. });
  261. return array_slice($allRecords, 0, 10);
  262. }
  263. /**
  264. * 获取学生的试卷列表
  265. */
  266. #[Computed]
  267. public function studentPapers(): array
  268. {
  269. if (empty($this->studentId)) {
  270. return [];
  271. }
  272. try {
  273. // 使用 Student 关联查询试卷
  274. $student = \App\Models\Student::find($this->studentId);
  275. if (!$student) {
  276. \Log::warning('未找到指定学生', ['student_id' => $this->studentId]);
  277. return [];
  278. }
  279. return $student->papers()
  280. ->withCount('questions') // 添加题目计数
  281. ->orderBy('created_at', 'desc')
  282. ->take(20)
  283. ->get()
  284. ->map(function($paper) {
  285. return [
  286. 'paper_id' => $paper->paper_id, // 使用 paper_id 而不是 id
  287. 'paper_name' => $paper->paper_name ?? '未命名试卷',
  288. 'total_questions' => $paper->questions_count ?? 0,
  289. 'total_score' => $paper->total_score ?? 0,
  290. 'created_at' => $paper->created_at->format('Y-m-d H:i'),
  291. ];
  292. })
  293. ->toArray();
  294. } catch (\Exception $e) {
  295. \Log::error('获取学生试卷列表失败', [
  296. 'student_id' => $this->studentId,
  297. 'error' => $e->getMessage()
  298. ]);
  299. return [];
  300. }
  301. }
  302. #[Computed]
  303. public function paperTypes(): array
  304. {
  305. return [
  306. '' => '请选择试卷形式',
  307. 'unit_test' => '单元测试',
  308. 'midterm' => '期中考试',
  309. 'final' => '期末考试',
  310. 'homework' => '家庭作业',
  311. 'quiz' => '随堂测验',
  312. 'other' => '其他',
  313. ];
  314. }
  315. /**
  316. * 获取选中试卷的题目列表
  317. */
  318. #[Computed]
  319. public function selectedPaperQuestions(): array
  320. {
  321. if (empty($this->selectedPaperId)) {
  322. return [];
  323. }
  324. try {
  325. // 首先检查试卷是否存在
  326. $paper = \App\Models\Paper::where('paper_id', $this->selectedPaperId)->first();
  327. if (!$paper) {
  328. \Log::warning('未找到指定试卷', ['paper_id' => $this->selectedPaperId]);
  329. return [];
  330. }
  331. // 使用关联关系查询题目
  332. $paperWithQuestions = \App\Models\Paper::with(['questions' => function($query) {
  333. $query->orderBy('question_number');
  334. }])->where('paper_id', $this->selectedPaperId)->first();
  335. $questions = $paperWithQuestions ? $paperWithQuestions->questions : collect([]);
  336. // 处理数据不一致的情况:如果题目为空但试卷显示有题目
  337. if ($questions->isEmpty() && ($paper->question_count ?? 0) > 0) {
  338. \Log::warning('试卷显示有题目但实际题目数据缺失', [
  339. 'paper_id' => $this->selectedPaperId,
  340. 'expected_questions' => $paper->question_count,
  341. 'actual_questions' => 0
  342. ]);
  343. // 返回占位题目,让用户知道有数据缺失
  344. return [
  345. [
  346. 'id' => 'missing_data',
  347. 'question_number' => 1,
  348. 'question_bank_id' => null,
  349. 'question_type' => 'info',
  350. 'content' => "⚠️ 数据异常:试卷显示应有 {$paper->question_count} 道题目,但未找到题目数据。这通常是试卷创建过程中断导致的。请联系管理员或重新创建试卷。",
  351. 'answer' => '',
  352. 'score' => 0,
  353. 'is_missing_data' => true
  354. ]
  355. ];
  356. }
  357. if ($questions->isEmpty()) {
  358. \Log::info('试卷确实没有题目', ['paper_id' => $this->selectedPaperId]);
  359. return [
  360. [
  361. 'id' => 'no_questions',
  362. 'question_number' => 1,
  363. 'question_bank_id' => null,
  364. 'question_type' => 'info',
  365. 'content' => '该试卷暂无题目数据',
  366. 'answer' => '',
  367. 'score' => 0,
  368. 'is_empty' => true
  369. ]
  370. ];
  371. }
  372. // 获取题目详情
  373. $questionBankService = app(\App\Services\QuestionBankService::class);
  374. $questionIds = $questions->pluck('question_bank_id')->filter()->unique()->toArray();
  375. if (empty($questionIds)) {
  376. \Log::info('题目没有关联题库ID', ['paper_id' => $this->selectedPaperId]);
  377. // 返回基本的题目信息,不包含题库详情
  378. return $questions->map(function($q) {
  379. return [
  380. 'id' => $q->id,
  381. 'question_number' => $q->question_number,
  382. 'question_bank_id' => $q->question_bank_id,
  383. 'question_type' => $q->question_type,
  384. 'content' => '题目内容未关联到题库',
  385. 'answer' => '',
  386. 'score' => $q->score ?? 5,
  387. ];
  388. })->toArray();
  389. }
  390. $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
  391. $questionDetails = collect($questionsResponse['data'] ?? [])->keyBy('id');
  392. return $questions->map(function($q) use ($questionDetails) {
  393. $detail = $questionDetails->get($q->question_bank_id);
  394. return [
  395. 'id' => $q->id,
  396. 'question_number' => $q->question_number,
  397. 'question_bank_id' => $q->question_bank_id,
  398. 'question_type' => $q->question_type,
  399. 'content' => $detail['stem'] ?? '题目内容缺失',
  400. 'answer' => $detail['answer'] ?? '',
  401. 'score' => $q->score ?? 5,
  402. 'kp_code' => $q->knowledge_point, // 从本地数据库获取知识点代码
  403. ];
  404. })->toArray();
  405. } catch (\Exception $e) {
  406. \Log::error('获取试卷题目失败', [
  407. 'paper_id' => $this->selectedPaperId,
  408. 'error' => $e->getMessage(),
  409. 'trace' => $e->getTraceAsString()
  410. ]);
  411. return [
  412. [
  413. 'id' => 'error',
  414. 'question_number' => 1,
  415. 'question_bank_id' => null,
  416. 'question_type' => 'error',
  417. 'content' => '获取题目数据时发生错误:' . $e->getMessage(),
  418. 'answer' => '',
  419. 'score' => 0,
  420. 'is_error' => true
  421. ]
  422. ];
  423. }
  424. }
  425. public function updatedTeacherId($value): void
  426. {
  427. // 当教师选择变化时,清空之前选择的学生
  428. $this->studentId = null;
  429. $this->selectedPaperId = null;
  430. $this->questionGrades = [];
  431. }
  432. public function updatedStudentId($value): void
  433. {
  434. // 当学生选择变化时,清空已选试卷
  435. $this->selectedPaperId = null;
  436. $this->questionGrades = [];
  437. }
  438. public function updatedMode($value): void
  439. {
  440. // 切换模式时重置相关字段
  441. $this->uploadedImage = null;
  442. $this->selectedPaperId = null;
  443. $this->questionGrades = [];
  444. }
  445. /**
  446. * 处理评分完成事件
  447. */
  448. #[On('gradingComplete')]
  449. public function handleGradingComplete(): void
  450. {
  451. // 提交完成后跳转到详情页
  452. if ($this->selectedPaperId && $this->studentId) {
  453. $url = $this->getViewRecordUrl('graded_paper', $this->selectedPaperId, '', $this->studentId);
  454. $this->redirect($url, navigate: true);
  455. }
  456. }
  457. /**
  458. * 处理来自子组件的评分提交事件
  459. */
  460. #[On('submitManualGrading')]
  461. public function handleSubmitFromParent(array $questionGrades, array $gradingData, array $questions): void
  462. {
  463. // 从子组件接收数据
  464. $this->questionGrades = $questionGrades;
  465. $this->gradingData = $gradingData;
  466. $this->questions = $questions;
  467. \Log::info('UploadExamPaper: 接收到子组件提交的评分数据', [
  468. 'questionGrades_count' => count($questionGrades),
  469. 'questions_count' => count($questions)
  470. ]);
  471. // 调用原有的提交方法
  472. $this->submitManualGrading();
  473. // 通知用户提交成功
  474. Notification::make()
  475. ->title('评分提交成功')
  476. ->success()
  477. ->send();
  478. // 触发事件处理跳转
  479. $this->dispatch('gradingComplete');
  480. }
  481. public function submitUpload(): void
  482. {
  483. if (!$this->teacherId) {
  484. Notification::make()
  485. ->title('请选择老师')
  486. ->danger()
  487. ->send();
  488. return;
  489. }
  490. if (!$this->studentId) {
  491. Notification::make()
  492. ->title('请选择学生')
  493. ->danger()
  494. ->send();
  495. return;
  496. }
  497. // 获取表单数据
  498. $formData = $this->data;
  499. if (empty($formData['image'])) {
  500. Notification::make()
  501. ->title('请上传图片')
  502. ->danger()
  503. ->send();
  504. return;
  505. }
  506. if (empty($formData['paper_name'])) {
  507. Notification::make()
  508. ->title('请填写试卷名称')
  509. ->danger()
  510. ->send();
  511. return;
  512. }
  513. if (empty($formData['class'])) {
  514. Notification::make()
  515. ->title('请选择班级')
  516. ->danger()
  517. ->send();
  518. return;
  519. }
  520. if (empty($formData['student_name'])) {
  521. Notification::make()
  522. ->title('请填写学生姓名')
  523. ->danger()
  524. ->send();
  525. return;
  526. }
  527. $this->isUploading = true;
  528. try {
  529. // 处理图片(可能是单张或多张)
  530. $images = $formData['image'];
  531. if (!is_array($images)) {
  532. $images = [$images];
  533. }
  534. $paths = [];
  535. foreach ($images as $image) {
  536. if ($image) {
  537. $paths[] = storage_path('app/public/' . $image);
  538. }
  539. }
  540. if (empty($paths)) {
  541. throw new \Exception('图片保存失败');
  542. }
  543. $paperId = 'paper_' . time() . '_' . substr(md5(uniqid()), 0, 8);
  544. // AI分析服务调用
  545. $response = \Http::timeout(300)
  546. ->post('http://localhost:5016/analyze-exam', [
  547. 'paper_id' => $paperId,
  548. 'paper_name' => $formData['paper_name'],
  549. 'student_name' => $formData['student_name'],
  550. 'class_name' => $formData['class'],
  551. 'paper_type' => $formData['paper_type'],
  552. 'subject' => $formData['paper_subject'],
  553. 'image_files' => $paths,
  554. ]);
  555. if ($response->successful()) {
  556. $result = $response->json();
  557. $this->saveAnalysisResult($result, $paperId);
  558. $this->analysisResult = $result;
  559. Notification::make()
  560. ->title('分析完成')
  561. ->success()
  562. ->send();
  563. } else {
  564. $this->analysisError = '分析服务响应失败: ' . $response->status();
  565. Notification::make()
  566. ->title('分析失败')
  567. ->body($this->analysisError)
  568. ->error()
  569. ->send();
  570. }
  571. // 重置表单
  572. $this->teacherId = null;
  573. $this->studentId = null;
  574. $this->uploadedImage = null;
  575. $this->paperType = null;
  576. } catch (\Exception $e) {
  577. Notification::make()
  578. ->title('上传失败')
  579. ->body($e->getMessage())
  580. ->danger()
  581. ->send();
  582. } finally {
  583. $this->isUploading = false;
  584. $this->analyzing = false;
  585. }
  586. }
  587. #[On('teacherChanged')]
  588. public function updateTeacherId($teacherId)
  589. {
  590. $this->teacherId = $teacherId;
  591. $this->studentId = null;
  592. }
  593. #[On('studentChanged')]
  594. public function updateStudentId($teacherId, $studentId)
  595. {
  596. $this->studentId = $studentId;
  597. }
  598. public function removeImage(): void
  599. {
  600. $this->uploadedImage = null;
  601. }
  602. /**
  603. * 提交手动评分
  604. */
  605. public function submitManualGrading(): void
  606. {
  607. if (!$this->selectedPaperId) {
  608. Notification::make()
  609. ->title('请选择试卷')
  610. ->danger()
  611. ->send();
  612. return;
  613. }
  614. // 将 gradingData 转换为 questionGrades 格式
  615. $this->convertGradingDataToQuestionGrades();
  616. if (empty($this->questionGrades)) {
  617. Notification::make()
  618. ->title('请至少为一道题目评分')
  619. ->danger()
  620. ->send();
  621. return;
  622. }
  623. try {
  624. // 准备数据发送到 LearningAnalytics
  625. $analyticsData = [];
  626. // 获取题目详情以便查找kp_code
  627. $questionsMap = collect($this->selectedPaperQuestions)->keyBy('id');
  628. // 收集需要从API补充信息的题目ID
  629. $missingKpCodeQuestionIds = [];
  630. foreach ($this->questionGrades as $questionId => $grade) {
  631. $question = $questionsMap->get($questionId);
  632. if (!$question) {
  633. continue;
  634. }
  635. // 优先使用本地存储的 kp_code
  636. if (empty($question['kp_code'])) {
  637. $missingKpCodeQuestionIds[] = $questionId;
  638. }
  639. }
  640. // 如果有缺失 kp_code 的题目,尝试从 API 获取
  641. $apiDetailsMap = collect([]);
  642. if (!empty($missingKpCodeQuestionIds)) {
  643. $questionBankIds = collect($missingKpCodeQuestionIds)
  644. ->map(fn($qId) => $questionsMap->get($qId)['question_bank_id'] ?? null)
  645. ->filter()
  646. ->toArray();
  647. if (!empty($questionBankIds)) {
  648. $questionBankService = app(\App\Services\QuestionBankService::class);
  649. $questionsDetails = $questionBankService->getQuestionsByIds($questionBankIds);
  650. $apiDetailsMap = collect($questionsDetails['data'] ?? [])->keyBy('id');
  651. }
  652. }
  653. foreach ($this->questionGrades as $questionId => $grade) {
  654. $question = $questionsMap->get($questionId);
  655. if (!$question) {
  656. continue;
  657. }
  658. $kpCode = $question['kp_code'];
  659. // 如果本地没有,尝试从API结果中获取
  660. if (empty($kpCode)) {
  661. $detail = $apiDetailsMap->get($question['question_bank_id']);
  662. $kpCode = $detail['kp_code'] ?? $detail['knowledge_point_code'] ?? null;
  663. }
  664. // 确保 is_correct 有值(如果为 null,设置为 false)
  665. $isCorrect = $grade['is_correct'];
  666. if ($isCorrect === null) {
  667. $isCorrect = false;
  668. }
  669. $analyticsData[] = [
  670. 'question_bank_id' => $question['question_bank_id'],
  671. 'student_answer' => $grade['student_answer'] ?? '',
  672. 'is_correct' => $isCorrect,
  673. 'score' => $grade['score'] ?? 0,
  674. 'max_score' => $question['score'] ?? 0,
  675. 'kp_code' => $kpCode,
  676. 'ip_address' => '127.0.0.1', // 提供默认IP地址,避免PostgreSQL inet类型错误
  677. 'device_type' => 'web', // 提供默认设备类型
  678. 'feedback_provided' => false, // 提供默认反馈状态
  679. ];
  680. }
  681. // 调用 LearningAnalytics 服务
  682. $learningAnalyticsService = app(\App\Services\LearningAnalyticsService::class);
  683. // 步骤0: 保存学生答案到本地数据库 (重要:确保数据持久化)
  684. foreach ($this->questionGrades as $questionId => $grade) {
  685. // 从数据库获取当前题目的记录(包含满分)
  686. $paperQuestion = \App\Models\PaperQuestion::where('id', $questionId)->first();
  687. if (!$paperQuestion) {
  688. \Log::warning('未找到题目记录', ['question_id' => $questionId]);
  689. continue;
  690. }
  691. // 确保 is_correct 是布尔值(转换字符串 'true'/'false' 为布尔值)
  692. $isCorrect = $grade['is_correct'];
  693. if ($isCorrect === 'true' || $isCorrect === true) {
  694. $isCorrect = true;
  695. } elseif ($isCorrect === 'false' || $isCorrect === false) {
  696. $isCorrect = false;
  697. }
  698. // 确保 score_obtained 是数字
  699. $score = $grade['score'];
  700. if ($score !== null) {
  701. $score = is_numeric($score) ? (float)$score : 0;
  702. }
  703. // **关键修复**:确保 is_correct 和 score 的一致性
  704. // score 优先级高于 is_correct,根据得分比例动态计算
  705. $maxScore = $paperQuestion->score ?? 0;
  706. if ($maxScore > 0) {
  707. $scoreRatio = $score / $maxScore;
  708. // 只有达到满分才算完全正确
  709. if ($scoreRatio >= 1.0) {
  710. $isCorrect = true;
  711. } elseif ($scoreRatio > 0) {
  712. $isCorrect = false; // 部分得分不算完全正确
  713. } else {
  714. $isCorrect = false;
  715. }
  716. }
  717. \Log::info('保存评分数据', [
  718. 'question_id' => $questionId,
  719. 'max_score' => $maxScore,
  720. 'score_obtained' => $score,
  721. 'is_correct' => $isCorrect,
  722. 'score_ratio' => $maxScore > 0 ? ($score / $maxScore) : 0
  723. ]);
  724. \App\Models\PaperQuestion::where('id', $questionId)->update([
  725. 'student_answer' => $grade['student_answer'] ?? '',
  726. 'is_correct' => $isCorrect,
  727. 'score_obtained' => $score ?? 0,
  728. ]);
  729. }
  730. \Log::info('学生答案已保存到数据库', [
  731. 'student_id' => $this->studentId,
  732. 'paper_id' => $this->selectedPaperId,
  733. 'updated_count' => count($this->questionGrades)
  734. ]);
  735. // 步骤1: 保存答题记录到 LearningAnalytics
  736. \Log::info('准备调用submitBatchAttempts API', [
  737. 'student_id' => $this->studentId,
  738. 'paper_id' => $this->selectedPaperId,
  739. 'analytics_data_sample' => array_slice($analyticsData, 0, 2) // 记录前2题的数据作为样本
  740. ]);
  741. $result = $learningAnalyticsService->submitBatchAttempts($this->studentId, [
  742. 'paper_id' => $this->selectedPaperId,
  743. 'answers' => $analyticsData,
  744. ]);
  745. // 检查API返回结果
  746. if (is_array($result) && isset($result['error']) && $result['error']) {
  747. throw new \Exception($result['message'] ?? 'API调用失败');
  748. }
  749. if ($result === null || (is_array($result) && empty($result))) {
  750. throw new \Exception('API返回空数据');
  751. }
  752. \Log::info('答题记录已保存到学习分析服务', [
  753. 'student_id' => $this->studentId,
  754. 'paper_id' => $this->selectedPaperId,
  755. 'count' => count($analyticsData)
  756. ]);
  757. // 步骤2: 触发 AI 分析(包含掌握度更新和学习报告生成)
  758. try {
  759. $paper = \App\Models\Paper::find($this->selectedPaperId);
  760. // 构造 AI 分析请求数据
  761. $analysisQuestions = [];
  762. foreach ($this->questionGrades as $questionId => $grade) {
  763. $question = $questionsMap->get($questionId);
  764. if (!$question) {
  765. continue;
  766. }
  767. $kpCode = $question['kp_code'];
  768. if (empty($kpCode)) {
  769. $detail = $apiDetailsMap->get($question['question_bank_id']);
  770. $kpCode = $detail['kp_code'] ?? $detail['knowledge_point_code'] ?? null;
  771. }
  772. $analysisQuestions[] = [
  773. 'question_id' => $question['question_bank_id'],
  774. 'question_number' => (string)$question['question_number'],
  775. 'question_text' => $question['content'] ?? '',
  776. 'student_answer' => $grade['student_answer'] ?? '',
  777. 'correct_answer' => $question['answer'] ?? '',
  778. 'kp_code' => $kpCode,
  779. 'score_value' => $grade['score'] ?? 0,
  780. 'max_score' => $question['score'],
  781. 'is_correct' => $grade['is_correct'] ?? false,
  782. 'teacher_validated' => true, // 手动评分即为教师验证
  783. 'ocr_confidence' => 1.0, // 手动评分置信度为1
  784. ];
  785. }
  786. $analysisData = [
  787. 'exam_id' => $this->selectedPaperId,
  788. 'student_id' => $this->studentId,
  789. 'ocr_record_id' => 0, // 系统生成卷子没有OCR记录ID
  790. 'paper_id' => $this->selectedPaperId,
  791. 'teacher_name' => auth()->user()->name ?? 'Teacher',
  792. 'analysis_type' => 'mastery',
  793. 'questions' => $analysisQuestions,
  794. ];
  795. // 调用统一的 AI 分析接口
  796. \Log::info('准备调用submitOCRAnalysis API', [
  797. 'paper_id' => $this->selectedPaperId,
  798. 'student_id' => $this->studentId,
  799. 'analysis_data_sample' => [
  800. 'question_count' => count($analysisQuestions),
  801. 'first_question' => $analysisQuestions[0] ?? null
  802. ]
  803. ]);
  804. $analysisResult = $learningAnalyticsService->submitOCRAnalysis($analysisData);
  805. \Log::info('AI分析已触发', [
  806. 'paper_id' => $this->selectedPaperId,
  807. 'student_id' => $this->studentId,
  808. 'analysis_result_keys' => is_array($analysisResult) ? array_keys($analysisResult) : 'not_array',
  809. 'analysis_result' => $analysisResult
  810. ]);
  811. // 保存 analysis_id 到 Paper 表
  812. if (isset($analysisResult['analysis_id'])) {
  813. \App\Models\Paper::where('paper_id', $this->selectedPaperId)->update([
  814. 'analysis_id' => $analysisResult['analysis_id'],
  815. ]);
  816. \Log::info('已保存 analysis_id', [
  817. 'paper_id' => $this->selectedPaperId,
  818. 'analysis_id' => $analysisResult['analysis_id']
  819. ]);
  820. }
  821. } catch (\Exception $analysisError) {
  822. // AI 分析失败不影响主流程
  823. \Log::warning('触发AI分析失败', [
  824. 'paper_id' => $this->selectedPaperId,
  825. 'error' => $analysisError->getMessage()
  826. ]);
  827. }
  828. // 更新Paper表状态为已完成评分
  829. \App\Models\Paper::where('paper_id', $this->selectedPaperId)->update([
  830. 'status' => 'completed',
  831. 'completed_at' => now(),
  832. ]);
  833. Notification::make()
  834. ->title('提交成功')
  835. ->body('评分已提交,AI分析正在进行中')
  836. ->success()
  837. ->send();
  838. // 刷新最近记录列表
  839. unset($this->recentRecords);
  840. // 重置表单
  841. $this->selectedPaperId = null;
  842. $this->questionGrades = [];
  843. } catch (\Exception $e) {
  844. \Log::error('提交手动评分失败', [
  845. 'error' => $e->getMessage(),
  846. 'student_id' => $this->studentId,
  847. 'paper_id' => $this->selectedPaperId,
  848. ]);
  849. Notification::make()
  850. ->title('提交失败')
  851. ->body($e->getMessage())
  852. ->danger()
  853. ->send();
  854. }
  855. }
  856. /**
  857. * 将 gradingData 转换为 questionGrades 格式
  858. * gradingData: 索引数组 [{is_correct: bool, score: float}]
  859. * questionGrades: 题目ID为键的数组 [questionId => {is_correct: bool, score: float, student_answer: string}]
  860. */
  861. private function convertGradingDataToQuestionGrades(): void
  862. {
  863. $this->questionGrades = [];
  864. // 遍历 questions 数组(包含题目信息)
  865. foreach ($this->questions as $index => $question) {
  866. // 获取对应索引的 gradingData
  867. $grading = $this->gradingData[$index] ?? null;
  868. // 只有当 grading 不为空且有评分数据时才添加
  869. if ($grading && (
  870. (isset($grading['is_correct']) && $grading['is_correct'] !== null) ||
  871. (isset($grading['score']) && $grading['score'] !== null)
  872. )) {
  873. $questionId = $question['id'];
  874. // 处理 is_correct 值(字符串 'true'/'false' 或布尔值)
  875. $isCorrect = $grading['is_correct'] ?? null;
  876. if ($isCorrect === 'true') {
  877. $isCorrect = true;
  878. } elseif ($isCorrect === 'false') {
  879. $isCorrect = false;
  880. }
  881. // 如果 is_correct 为 null,保持为 null(不要转换为布尔值)
  882. // 处理 score 值
  883. $score = $grading['score'] ?? null;
  884. if ($score !== null && $score !== '') {
  885. $score = is_numeric($score) ? (float)$score : null;
  886. }
  887. // **关键修复**:根据题型处理缺失的字段
  888. if ($question['question_type'] === 'choice') {
  889. // 选择题:只有 is_correct,需要自动计算分数
  890. if ($isCorrect === true) {
  891. $score = $question['score'] ?? 0; // 正确给满分
  892. } elseif ($isCorrect === false) {
  893. $score = 0; // 错误给0分
  894. }
  895. } else {
  896. // 填空/解答题:只有 score,需要自动计算 is_correct
  897. if ($score !== null) {
  898. // 动态计算:得分等于满分才算正确
  899. $maxScore = $question['score'] ?? 0;
  900. $isCorrect = ($score >= $maxScore && $maxScore > 0);
  901. }
  902. }
  903. // 获取学生答案(优先使用 gradingData 中的值,如果没有则使用题目中的值)
  904. $studentAnswer = $grading['student_answer'] ?? $question['student_answer'] ?? '';
  905. // 对于选择题,如果学生答案为空,基于评分推断
  906. if (empty($studentAnswer) && $question['question_type'] === 'choice') {
  907. if ($isCorrect === true) {
  908. // 如果选"正确",学生答案就是正确答案
  909. $studentAnswer = $question['correct_answer'] ?? '正确答案';
  910. } elseif ($isCorrect === false) {
  911. // 如果选"错误",学生答案可以为空或者设置为特殊标记
  912. $studentAnswer = '错误答案';
  913. }
  914. }
  915. // 转换格式
  916. $this->questionGrades[$questionId] = [
  917. 'is_correct' => $isCorrect,
  918. 'score' => $score,
  919. 'student_answer' => $studentAnswer,
  920. ];
  921. }
  922. }
  923. \Log::info('转换评分数据', [
  924. 'grading_data_count' => count(array_filter($this->gradingData ?? [])),
  925. 'question_grades_count' => count($this->questionGrades),
  926. 'questions_count' => count($this->questions ?? []),
  927. 'sample_question_grades' => array_slice($this->questionGrades, 0, 2, true),
  928. ]);
  929. }
  930. #[Computed]
  931. public function gradingProgress(): string
  932. {
  933. $gradedCount = count(array_filter($this->gradingData ?? []));
  934. $totalCount = count($this->questions ?? []);
  935. return "已评分:{$gradedCount}/{$totalCount}题";
  936. }
  937. public function startAnalysis(): void
  938. {
  939. $this->analyzing = true;
  940. $this->analysisError = null;
  941. try {
  942. $this->submitUpload();
  943. } catch (\Exception $e) {
  944. $this->analysisError = $e->getMessage();
  945. $this->analyzing = false;
  946. }
  947. }
  948. public function saveGrading(): void
  949. {
  950. $this->submitManualGrading();
  951. }
  952. public function updatedSelectedPaperId($value): void
  953. {
  954. if (empty($value)) {
  955. $this->questions = [];
  956. $this->gradingData = [];
  957. $this->showGrading = false;
  958. return;
  959. }
  960. // 加载试卷信息和题目
  961. $this->loadPaperForGrading($value);
  962. }
  963. public function loadPaperForGrading($paperId): void
  964. {
  965. try {
  966. $paper = \App\Models\Paper::where('paper_id', $paperId)->first();
  967. if (!$paper) {
  968. Notification::make()
  969. ->title('试卷不存在')
  970. ->danger()
  971. ->send();
  972. return;
  973. }
  974. // 设置试卷信息
  975. $this->paperName = $paper->paper_name;
  976. $this->paperClass = $paper->difficulty_category ?? '未设置';
  977. $this->paperStudent = $paper->student_id;
  978. $this->paperDate = $paper->created_at->format('Y-m-d H:i');
  979. // 加载题目
  980. $paperWithQuestions = \App\Models\Paper::with(['questions' => function($query) {
  981. $query->orderBy('question_number');
  982. }])->where('paper_id', $paperId)->first();
  983. $questions = $paperWithQuestions ? $paperWithQuestions->questions : collect([]);
  984. // 如果没有正确答案,先尝试从题库API获取
  985. $apiDetailsMap = new \Illuminate\Support\Collection();
  986. if (!$questions->isEmpty()) {
  987. $questionBankIds = $questions->where('question_bank_id', '!=', null)->pluck('question_bank_id')->unique()->toArray();
  988. if (!empty($questionBankIds)) {
  989. try {
  990. $questionBankService = app(\App\Services\QuestionBankService::class);
  991. $apiResponse = $questionBankService->getQuestionsByIds($questionBankIds);
  992. if (!empty($apiResponse['data'])) {
  993. foreach ($apiResponse['data'] as $detail) {
  994. $apiDetailsMap->put($detail['id'], $detail);
  995. }
  996. \Log::info('成功从题库API获取题目详情', [
  997. 'count' => count($apiResponse['data']),
  998. 'ids' => array_keys($apiResponse['data'])
  999. ]);
  1000. }
  1001. } catch (\Exception $e) {
  1002. \Log::warning('获取题库详情失败', ['error' => $e->getMessage()]);
  1003. }
  1004. }
  1005. }
  1006. if ($questions->isEmpty()) {
  1007. $this->questions = [
  1008. [
  1009. 'id' => 'no_questions',
  1010. 'question_number' => 1,
  1011. 'question_type' => 'info',
  1012. 'content' => '该试卷暂无题目数据',
  1013. 'answer' => '',
  1014. 'score' => 0,
  1015. 'is_empty' => true
  1016. ]
  1017. ];
  1018. } else {
  1019. $this->questions = $questions->map(function($question, $index) use ($apiDetailsMap) {
  1020. // 从 API 获取正确答案(优先使用 API 数据)
  1021. $correctAnswer = $question->correct_answer;
  1022. if (empty($correctAnswer) && $question->question_bank_id && $apiDetailsMap->has($question->question_bank_id)) {
  1023. $detail = $apiDetailsMap->get($question->question_bank_id);
  1024. $correctAnswer = $detail['answer'] ?? $detail['correct_answer'] ?? '';
  1025. }
  1026. return [
  1027. 'id' => $question->id,
  1028. 'question_number' => $question->question_number,
  1029. 'question_type' => $question->question_type,
  1030. 'question_text' => $question->question_text,
  1031. 'content' => $question->question_text,
  1032. 'options' => json_decode($question->options, true) ?: [],
  1033. 'answer' => $correctAnswer,
  1034. 'correct_answer' => $correctAnswer,
  1035. 'student_answer' => '', // 学生答案暂不显示,等后续完善
  1036. 'score' => $question->score,
  1037. 'max_score' => $question->score,
  1038. 'question_bank_id' => $question->question_bank_id,
  1039. 'is_empty' => false
  1040. ];
  1041. })->toArray();
  1042. }
  1043. // 初始化评分数据
  1044. $this->gradingData = array_fill(0, count($this->questions), ['score' => null, 'is_correct' => null, 'comment' => '']);
  1045. $this->showGrading = true;
  1046. } catch (\Exception $e) {
  1047. \Log::error('加载试卷题目失败', [
  1048. 'paper_id' => $paperId,
  1049. 'error' => $e->getMessage()
  1050. ]);
  1051. Notification::make()
  1052. ->title('加载失败')
  1053. ->body($e->getMessage())
  1054. ->danger()
  1055. ->send();
  1056. }
  1057. }
  1058. private function saveAnalysisResult(array $result, string $paperId): void
  1059. {
  1060. try {
  1061. \DB::beginTransaction();
  1062. // 保存试卷基本信息
  1063. $examPaper = \App\Models\Paper::create([
  1064. 'paper_id' => $paperId,
  1065. 'paper_name' => $result['paper_name'] ?? '未命名试卷',
  1066. 'student_id' => $this->studentId,
  1067. 'teacher_id' => $this->teacherId,
  1068. 'paper_type' => $result['paper_type'] ?? 'quiz',
  1069. 'question_count' => count($result['questions'] ?? []),
  1070. 'total_score' => $result['total_score'] ?? 0,
  1071. 'status' => 'completed',
  1072. ]);
  1073. // 保存题目信息
  1074. foreach ($result['questions'] ?? [] as $index => $questionData) {
  1075. \App\Models\PaperQuestion::create([
  1076. 'paper_id' => $paperId,
  1077. 'question_number' => $index + 1,
  1078. 'question_text' => $questionData['question_text'] ?? '',
  1079. 'question_type' => $questionData['question_type'] ?? 'choice',
  1080. 'options' => json_encode($questionData['options'] ?? []),
  1081. 'correct_answer' => $questionData['correct_answer'] ?? '',
  1082. 'score' => $questionData['score'] ?? 1,
  1083. ]);
  1084. }
  1085. \DB::commit();
  1086. } catch (\Exception $e) {
  1087. \DB::rollBack();
  1088. \Log::error('保存分析结果失败: ' . $e->getMessage());
  1089. }
  1090. }
  1091. /**
  1092. * 查看记录详情 - 使用页面跳转
  1093. */
  1094. public function getViewRecordUrl(string $type, string $paperId, string $recordId, string $studentId): string
  1095. {
  1096. // 返回ExamAnalysis详情页面URL
  1097. if (in_array($type, ['graded_paper', 'generated'])) {
  1098. // 系统生成或已评分试卷,使用paperId
  1099. return '/admin/exam-analysis?paperId=' . $paperId . '&studentId=' . $studentId;
  1100. } elseif ($type === 'ocr_upload') {
  1101. // OCR上传记录,也跳转到详情页
  1102. return '/admin/exam-analysis?recordId=' . $recordId . '&studentId=' . $studentId;
  1103. }
  1104. return '#';
  1105. }
  1106. }