| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- <?php
- namespace App\Filament\Pages;
- use App\Services\QuestionServiceApi;
- use App\Services\MistakeBookService;
- use App\Services\KnowledgeGraphService;
- use App\Services\QuestionPromptService;
- use App\Services\AiClientService;
- use App\Models\Question;
- use App\Models\Student;
- use Filament\Notifications\Notification;
- use Filament\Pages\Page;
- use Illuminate\Support\Facades\Request;
- use Illuminate\Support\Facades\Log;
- class QuestionDetail extends Page
- {
- protected static ?string $title = '题目详情';
- protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
- protected static ?string $navigationLabel = '题目详情';
- protected static string|\UnitEnum|null $navigationGroup = '题库管理';
- protected static ?int $navigationSort = 4;
- protected static ?string $slug = 'question-detail/{questionId?}';
- protected string $view = 'filament.pages.question-detail';
- public ?string $questionId = null;
- public ?string $sourceType = 'question';
- public ?string $mistakeId = null;
- public ?string $studentId = null;
- public array $questionData = [];
- public array $mistakeData = [];
- public array $relatedQuestions = [];
- public ?string $studentName = null;
- public array $historySummary = [];
- public string $answerOverride = '';
- public function mount(?string $questionId = null): void
- {
- // 检查是否是错题本来源(同时有mistake_id和student_id)
- $this->mistakeId = Request::get('mistake_id');
- $this->studentId = Request::get('student_id');
- $this->questionId = $questionId ?: Request::get('question_id');
- if ($this->mistakeId && $this->studentId) {
- $this->sourceType = 'mistake';
- $this->loadFromMistake();
- } else {
- $this->sourceType = 'question';
- $this->loadFromQuestionBank();
- }
- if (empty($this->questionData)) {
- $this->notifyAndRedirect(
- '未能加载到题目数据,请稍后重试',
- $this->sourceType === 'mistake'
- ? route('filament.admin.pages.mistake-book')
- : route('filament.admin.pages.question-management')
- );
- }
- }
- protected function loadFromQuestionBank(): void
- {
- if (!$this->questionId) {
- $this->notifyAndRedirect('缺少题目ID参数', route('filament.admin.pages.question-management'));
- return;
- }
- $question = $this->fetchQuestion($this->questionId);
- if ($question) {
- $this->questionData = $this->prepareQuestionDisplay($question);
- $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
- $this->attachGlobalAccuracy();
- }
- }
- protected function loadFromMistake(): void
- {
- $mistakeService = app(MistakeBookService::class);
- $detail = $mistakeService->getMistakeDetail($this->mistakeId, $this->studentId);
- if (empty($detail)) {
- $this->notifyAndRedirect('未能获取错题详情', route('filament.admin.pages.mistake-book'));
- return;
- }
- $this->mistakeData = $detail;
- $this->questionId = $detail['question_id'] ?? $this->questionId;
- $this->studentName = $detail['student_name'] ?? null;
- $bankQuestion = $this->fetchQuestion($this->questionId);
- $fallbackQuestion = $detail['question'] ?? [];
- if (!$bankQuestion && empty($fallbackQuestion)) {
- $this->notifyAndRedirect('题目不存在或已被删除', route('filament.admin.pages.mistake-book'));
- return;
- }
- $question = array_merge($fallbackQuestion, $bankQuestion ?? []);
- $question = $this->prepareQuestionDisplay($question);
- $this->questionData = array_merge($question, [
- 'mistake_info' => [
- 'student_answer' => $detail['student_answer'] ?? '',
- 'correct' => (bool) ($detail['correct'] ?? false),
- 'score' => $detail['score'] ?? null,
- 'full_score' => $detail['full_score'] ?? null,
- 'partial_score_ratio' => $detail['partial_score_ratio'] ?? null,
- 'error_type' => $detail['error_type'] ?? '',
- 'mistake_category' => $detail['mistake_category'] ?? '',
- 'ai_analysis' => $detail['ai_analysis'] ?? [],
- 'created_at' => $detail['created_at'] ?? '',
- ],
- ]);
- $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
- // 尝试从本地学生表补充姓名,避免仅展示ID
- if (!$this->studentName && $this->studentId) {
- try {
- $student = Student::find($this->studentId);
- if ($student && $student->name) {
- $this->studentName = $student->name;
- }
- } catch (\Throwable $e) {
- Log::warning('Load student name failed', [
- 'student_id' => $this->studentId,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- $this->loadHistorySummary();
- $this->attachGlobalAccuracy();
- }
- protected function fetchQuestion(?string $questionId): ?array
- {
- if (!$questionId) {
- return null;
- }
- try {
- $service = app(QuestionServiceApi::class);
- $response = $service->getQuestionDetail($questionId);
- return $response['data'] ?? null;
- } catch (\Throwable $e) {
- Log::error('Failed to load question detail', [
- 'question_id' => $questionId,
- 'error' => $e->getMessage(),
- ]);
- return null;
- }
- }
- protected function notifyAndRedirect(string $message, string $route): void
- {
- Notification::make()
- ->title('提示')
- ->body($message)
- ->danger()
- ->send();
- $this->redirect($route);
- }
- public function getTitle(): string
- {
- if ($this->sourceType === 'mistake') {
- $label = $this->questionData['question_number'] ?? ('#' . ($this->mistakeId ?? ''));
- return '错题详情 - ' . $label;
- }
- if (!empty($this->questionData)) {
- return '题目详情 - ' . ($this->questionData['question_code'] ?? $this->questionId);
- }
- return '题目详情';
- }
- public function getBreadcrumbs(): array
- {
- $breadcrumbs = [
- [
- 'name' => '题库管理',
- 'url' => route('filament.admin.pages.question-management'),
- ],
- ];
- if ($this->sourceType === 'mistake') {
- $breadcrumbs[] = [
- 'name' => '错题本',
- 'url' => route('filament.admin.pages.mistake-book'),
- ];
- }
- $breadcrumbs[] = [
- 'name' => '题目详情',
- 'url' => '',
- ];
- return $breadcrumbs;
- }
- public function getDifficultyColor(): string
- {
- if (!isset($this->questionData['difficulty'])) {
- return 'bg-gray-100 text-gray-700';
- }
- $difficulty = (float) $this->questionData['difficulty'];
- if ($difficulty < 0.4) {
- return 'bg-green-100 text-green-700';
- } elseif ($difficulty < 0.7) {
- return 'bg-yellow-100 text-yellow-700';
- }
- return 'bg-red-100 text-red-700';
- }
- public function getDifficultyLabel(): string
- {
- if (!isset($this->questionData['difficulty'])) {
- return '未知';
- }
- $difficulty = (float) $this->questionData['difficulty'];
- if ($difficulty < 0.4) {
- return '简单';
- } elseif ($difficulty < 0.7) {
- return '中等';
- }
- return '困难';
- }
- public function saveAnswerOverride(): void
- {
- if (!$this->questionId) {
- return;
- }
- $question = Question::query()->find($this->questionId);
- if (!$question) {
- Notification::make()
- ->title('题目不存在')
- ->danger()
- ->send();
- return;
- }
- $question->update(['answer' => $this->answerOverride]);
- $this->questionData['answer'] = $this->answerOverride;
- Notification::make()
- ->title('答案已更新')
- ->success()
- ->send();
- }
- public function regenerateSolution(): void
- {
- if (!$this->questionId) {
- return;
- }
- $question = Question::query()->find($this->questionId);
- if (!$question) {
- Notification::make()
- ->title('题目不存在')
- ->danger()
- ->send();
- return;
- }
- $stem = (string) ($this->questionData['stem'] ?? '');
- $answer = (string) ($this->answerOverride ?: ($this->questionData['answer'] ?? ''));
- $images = $this->questionData['images']
- ?? ($this->questionData['meta']['images'] ?? []);
- if (is_string($images)) {
- $decoded = json_decode($images, true);
- $images = is_array($decoded) ? $decoded : [];
- }
- if ($stem === '' || $answer === '') {
- Notification::make()
- ->title('题干或答案为空,无法生成')
- ->warning()
- ->send();
- return;
- }
- try {
- $prompt = app(QuestionPromptService::class)->buildSolutionRegenPrompt($stem, $answer, $images);
- $result = app(AiClientService::class)->callJson($prompt);
- $solution = $result['solution'] ?? '';
- $steps = $result['steps'] ?? [];
- $meta = $question->meta ?? [];
- $meta['solution_steps'] = $steps;
- $question->update([
- 'solution' => $solution,
- 'answer' => $answer,
- 'meta' => $meta,
- ]);
- $this->questionData['solution'] = $solution;
- $this->questionData['answer'] = $answer;
- $this->questionData['meta']['solution_steps'] = $steps;
- Notification::make()
- ->title('解题思路已更新')
- ->success()
- ->send();
- } catch (\Throwable $e) {
- Notification::make()
- ->title('AI 生成失败')
- ->body($e->getMessage())
- ->danger()
- ->send();
- }
- }
- public function getKnowledgePointName(): string
- {
- if (!isset($this->questionData['kp_code'])) {
- return '';
- }
- $kpCode = $this->questionData['kp_code'];
- static $kpMap = null;
- if ($kpMap === null) {
- $kpMap = [];
- try {
- $service = app(KnowledgeGraphService::class);
- $resp = $service->listKnowledgePoints(1, 1000);
- foreach ($resp['data'] ?? [] as $kp) {
- $code = $kp['kp_code'] ?? $kp['id'] ?? null;
- if (!$code) {
- continue;
- }
- $kpMap[$code] = $kp['cn_name'] ?? $kp['name'] ?? $code;
- }
- } catch (\Throwable $e) {
- Log::warning('Load knowledge point names failed', [
- 'error' => $e->getMessage(),
- ]);
- }
- }
- return $kpMap[$kpCode] ?? $kpCode;
- }
- public function getSkillNames(): array
- {
- $skillCodes = $this->questionData['skills'] ?? [];
- if (empty($skillCodes) || !is_array($skillCodes)) {
- return [];
- }
- static $skillMap = null;
- if ($skillMap === null) {
- $skillMap = [];
- try {
- $service = app(KnowledgeGraphService::class);
- // 优先拉全量技能映射
- $resp = $service->listSkills(1, 1000);
- $skills = $resp['data'] ?? $resp ?? [];
- if (is_array($skills)) {
- foreach ($skills as $skill) {
- $code = $skill['code'] ?? null;
- if (!$code) {
- continue;
- }
- $skillMap[$code] = $skill['name'] ?? $code;
- }
- }
- // 如果仍为空,尝试按知识点单独获取
- if (empty($skillMap) && !empty($this->questionData['kp_code'])) {
- $kpSkills = $service->getSkillsByKnowledgePoint($this->questionData['kp_code']);
- foreach ($kpSkills as $skill) {
- $code = $skill['code'] ?? null;
- if (!$code) {
- continue;
- }
- $skillMap[$code] = $skill['name'] ?? $code;
- }
- }
- } catch (\Throwable $e) {
- Log::warning('Load skills failed', [
- 'error' => $e->getMessage(),
- ]);
- }
- }
- return array_map(function ($code) use ($skillMap) {
- return $skillMap[$code] ?? $code;
- }, $skillCodes);
- }
- protected function loadHistorySummary(): void
- {
- if (!$this->studentId || !$this->questionId) {
- return;
- }
- try {
- $service = app(MistakeBookService::class);
- $list = $service->listMistakes([
- 'student_id' => $this->studentId,
- 'per_page' => 100,
- ]);
- $data = $list['data'] ?? [];
- $filtered = array_values(array_filter($data, function ($item) {
- $qid = $item['question_id'] ?? ($item['question']['id'] ?? null);
- if ($qid === null) {
- return false;
- }
- return (string) $qid === (string) $this->questionId;
- }));
- if (empty($filtered)) {
- return;
- }
- $total = count($filtered);
- $correct = count(array_filter($filtered, fn ($item) => !empty($item['correct'])));
- $latest = collect($filtered)->sortByDesc('created_at')->first();
- $this->historySummary = [
- 'total' => $total,
- 'correct' => $correct,
- 'last_correct' => (bool) ($latest['correct'] ?? false),
- 'last_time' => $latest['created_at'] ?? null,
- ];
- } catch (\Throwable $e) {
- Log::warning('Load history summary failed', [
- 'student_id' => $this->studentId,
- 'question_id' => $this->questionId,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- /**
- * 对题干/选项做展示友好化处理:拆分嵌入式选项,保留 display_stem 与 display_options
- */
- protected function prepareQuestionDisplay(array $question): array
- {
- $question['display_stem'] = $question['stem'] ?? '';
- // 规范化选项
- $options = $question['options'] ?? [];
- if (is_string($options)) {
- $decoded = json_decode($options, true);
- if (is_array($decoded)) {
- $options = $decoded;
- }
- }
- // 如果没有单独选项,但题干里嵌入了 A./B./C./D.,尝试提取
- if (empty($options) && !empty($question['stem']) && is_string($question['stem'])) {
- $stem = $question['stem'];
- $parsedOptions = [];
- $pattern = '/(?<![A-Za-z0-9])([A-D])[\\..、,,\\s]+\\s*(.+?)(?=(?<![A-Za-z0-9])[A-D][\\..、,,\\s]+|$)/su';
- if (preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER)) {
- foreach ($matches as $match) {
- $text = trim($match[2]);
- if ($text !== '') {
- $parsedOptions[] = $text;
- }
- }
- // 只接受 2-4 个选项,避免误切分
- if (count($parsedOptions) >= 2 && count($parsedOptions) <= 4) {
- $options = $parsedOptions;
- $firstMatchPos = mb_strpos($stem, $matches[0][0]);
- if ($firstMatchPos !== false) {
- $question['display_stem'] = trim(mb_substr($stem, 0, $firstMatchPos));
- }
- }
- }
- }
- $question['display_options'] = $options;
- return $question;
- }
- protected function attachGlobalAccuracy(): void
- {
- if (!$this->questionId) {
- return;
- }
- try {
- $service = app(MistakeBookService::class);
- $accuracy = $service->getQuestionAccuracy($this->questionId);
- if ($accuracy !== null) {
- $this->questionData['global_accuracy'] = $accuracy;
- }
- } catch (\Throwable $e) {
- Log::warning('Attach global accuracy failed', [
- 'question_id' => $this->questionId,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- }
|