Procházet zdrojové kódy

提示词相关管理和初步错题本功能

yemeishu před 1 měsícem
rodič
revize
d526293e81

+ 467 - 0
app/Filament/Pages/MistakeBook.php

@@ -0,0 +1,467 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use App\Models\Student;
+use App\Services\KnowledgeServiceApi;
+use App\Services\MistakeBookService;
+use App\Services\QuestionBankService;
+use BackedEnum;
+use Filament\Pages\Page;
+use Illuminate\Http\Request;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Log;
+use UnitEnum;
+use Livewire\Attributes\On;
+use Livewire\Attributes\Computed;
+use App\Models\Teacher;
+
+class MistakeBook extends Page
+{
+    protected static ?string $title = '错题本';
+
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bookmark';
+
+    protected static ?string $navigationLabel = '错题本';
+
+    protected static string|UnitEnum|null $navigationGroup = '资源';
+
+    protected static ?int $navigationSort = 6;
+
+    protected static ?string $slug = 'mistake-book';
+
+    protected string $view = 'filament.pages.mistake-book';
+
+    public string $teacherId = '';
+    public string $studentId = '';
+
+    public array $filters = [
+        'kp_ids' => [],
+        'skill_ids' => [],
+        'error_types' => [],
+        'time_range' => 'last_30',
+        'start_date' => null,
+        'end_date' => null,
+    ];
+
+    public array $filterOptions = [
+        'knowledge_points' => [],
+        'skills' => [],
+    ];
+
+    public array $mistakes = [];
+
+    public array $patterns = [];
+
+    public array $summary = [];
+
+    public array $recommendations = [];
+
+    public array $relatedQuestions = [];
+
+    public array $selectedMistakeIds = [];
+
+    public bool $isLoading = false;
+
+    public string $errorMessage = '';
+
+    public string $actionMessage = '';
+
+    public string $actionMessageType = 'success';
+
+    public function mount(Request $request): void
+    {
+        $this->teacherId = (string) ($request->input('teacher_id') ?? '');
+        $this->studentId = (string) ($request->input('student_id') ?? '');
+        $this->filters['time_range'] = (string) ($request->input('range') ?? 'last_30');
+
+        if ($this->studentId && empty($this->teacherId)) {
+            $student = Student::find($this->studentId);
+            if ($student && $student->teacher_id) {
+                $this->teacherId = (string) $student->teacher_id;
+            }
+        }
+
+        $this->loadFilterOptions();
+
+        if ($this->studentId) {
+            $this->loadMistakeData();
+        }
+    }
+
+    public function updatedStudentId(): void
+    {
+        if ($this->studentId) {
+            $this->loadMistakeData();
+        } else {
+            $this->resetPageState();
+        }
+    }
+
+    public function loadMistakeData(): void
+    {
+        if (empty($this->studentId)) {
+            $this->errorMessage = '请先选择学生';
+            return;
+        }
+
+        $this->isLoading = true;
+        $this->errorMessage = '';
+        $this->actionMessage = '';
+
+        try {
+            $service = app(MistakeBookService::class);
+
+            $list = $service->listMistakes([
+                ...$this->filters,
+                'student_id' => $this->studentId,
+            ]);
+            $this->mistakes = $list['data'] ?? [];
+
+            $this->summary = $service->summarize($this->studentId);
+            $this->patterns = $service->getMistakePatterns($this->studentId);
+
+            // 清理无效的选中项
+            $validIds = collect($this->mistakes)->pluck('id')->filter()->all();
+            $this->selectedMistakeIds = array_values(
+                array_intersect($this->selectedMistakeIds, $validIds)
+            );
+        } catch (\Throwable $e) {
+            $this->errorMessage = '加载错题本数据失败:' . $e->getMessage();
+            Log::error('Load mistake book failed', [
+                'student_id' => $this->studentId,
+                'error' => $e->getMessage(),
+            ]);
+        } finally {
+            $this->isLoading = false;
+        }
+    }
+
+    public function refreshPatterns(): void
+    {
+        if (!$this->studentId) {
+            return;
+        }
+
+        try {
+            $service = app(MistakeBookService::class);
+            $this->patterns = $service->getMistakePatterns($this->studentId);
+        } catch (\Throwable $e) {
+            Log::error('Refresh mistake patterns failed', [
+                'student_id' => $this->studentId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
+    public function toggleFavorite(string $mistakeId): void
+    {
+        $service = app(MistakeBookService::class);
+
+        $current = $this->findMistakeById($mistakeId);
+        $willFavorite = !($current['favorite'] ?? false);
+
+        if ($service->toggleFavorite($mistakeId, $willFavorite)) {
+            $this->updateMistakeField($mistakeId, 'favorite', $willFavorite);
+            $this->notify('已更新收藏状态');
+        } else {
+            $this->notify('收藏操作失败,请稍后再试', 'danger');
+        }
+    }
+
+    public function markReviewed(string $mistakeId): void
+    {
+        $service = app(MistakeBookService::class);
+
+        if ($service->markReviewed($mistakeId)) {
+            $this->updateMistakeField($mistakeId, 'reviewed', true);
+            $this->notify('已标记为已复习');
+        } else {
+            $this->notify('标记失败,请稍后再试', 'danger');
+        }
+    }
+
+    public function addToRetryList(string $mistakeId): void
+    {
+        $service = app(MistakeBookService::class);
+
+        if ($service->addToRetryList($mistakeId)) {
+            $this->notify('已加入重练清单');
+        } else {
+            $this->notify('加入清单失败,请稍后再试', 'danger');
+        }
+    }
+
+    public function loadRelatedQuestions(string $mistakeId): void
+    {
+        $mistake = $this->findMistakeById($mistakeId);
+        if (empty($mistake)) {
+            return;
+        }
+
+        $questionBank = app(QuestionBankService::class);
+        $kpIds = Arr::wrap($mistake['kp_ids'] ?? []);
+        $skills = Arr::wrap($mistake['skill_ids'] ?? $mistake['skills'] ?? []);
+
+        $response = $questionBank->filterQuestions(array_filter([
+            'kp_codes' => !empty($kpIds) ? implode(',', $kpIds) : null,
+            'skills' => !empty($skills) ? implode(',', $skills) : null,
+            'limit' => 5,
+        ]));
+
+        $this->relatedQuestions[$mistakeId] = $response['data'] ?? [];
+    }
+
+    public function toggleSelection(string $mistakeId): void
+    {
+        if (in_array($mistakeId, $this->selectedMistakeIds, true)) {
+            $this->selectedMistakeIds = array_values(array_diff($this->selectedMistakeIds, [$mistakeId]));
+        } else {
+            $this->selectedMistakeIds[] = $mistakeId;
+        }
+    }
+
+    public function generatePracticeFromSelection(): void
+    {
+        if (empty($this->selectedMistakeIds)) {
+            $this->notify('请先选择至少一道错题', 'warning');
+            return;
+        }
+
+        $selected = array_filter($this->mistakes, fn ($item) => in_array($item['id'] ?? '', $this->selectedMistakeIds, true));
+
+        $kpIds = collect($selected)
+            ->pluck('kp_ids')
+            ->flatten()
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+
+        $skillIds = collect($selected)
+            ->pluck('skill_ids')
+            ->flatten()
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+
+        $service = app(MistakeBookService::class);
+        $result = $service->recommendPractice($this->studentId, $kpIds, $skillIds);
+
+        $this->recommendations = $result['data'] ?? ($result['questions'] ?? []);
+
+        if (!empty($this->recommendations)) {
+            $this->notify('已生成重练题单');
+        } else {
+            $this->notify('未能生成题单,请稍后再试', 'warning');
+        }
+    }
+
+    public function applyFilters(): void
+    {
+        $this->loadMistakeData();
+    }
+
+    public function clearCustomRange(): void
+    {
+        $this->filters['start_date'] = null;
+        $this->filters['end_date'] = null;
+    }
+
+    #[Computed]
+    public function teachers(): array
+    {
+        try {
+            $teachers = Teacher::query()
+                ->leftJoin('users as u', 'teachers.teacher_id', '=', 'u.user_id')
+                ->select(
+                    'teachers.teacher_id',
+                    'teachers.name',
+                    'teachers.subject',
+                    'u.username',
+                    'u.email'
+                )
+                ->orderBy('teachers.name')
+                ->get();
+
+            $teacherIds = $teachers->pluck('teacher_id')->toArray();
+            $missingTeacherIds = Student::query()
+                ->distinct()
+                ->whereNotIn('teacher_id', $teacherIds)
+                ->pluck('teacher_id')
+                ->toArray();
+
+            $teachersArray = $teachers->all();
+
+            if (!empty($missingTeacherIds)) {
+                foreach ($missingTeacherIds as $missingId) {
+                    $teachersArray[] = (object) [
+                        'teacher_id' => $missingId,
+                        'name' => '未知老师 (' . $missingId . ')',
+                        'subject' => '未知',
+                        'username' => null,
+                        'email' => null
+                    ];
+                }
+
+                usort($teachersArray, function($a, $b) {
+                    return strcmp($a->name, $b->name);
+                });
+            }
+
+            return $teachersArray;
+        } catch (\Exception $e) {
+            Log::error('加载老师列表失败', [
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    #[Computed]
+    public function students(): array
+    {
+        if (empty($this->teacherId)) {
+            return [];
+        }
+
+        try {
+            return Student::query()
+                ->leftJoin('users as u', 'students.student_id', '=', 'u.user_id')
+                ->where('students.teacher_id', $this->teacherId)
+                ->select(
+                    'students.student_id',
+                    'students.name',
+                    'students.grade',
+                    'students.class_name',
+                    'u.username',
+                    'u.email'
+                )
+                ->orderBy('students.grade')
+                ->orderBy('students.class_name')
+                ->orderBy('students.name')
+                ->get()
+                ->all();
+        } catch (\Exception $e) {
+            Log::error('加载学生列表失败', [
+                'teacher_id' => $this->teacherId,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    public function getStudents(): array
+    {
+        return Student::query()
+            ->select(['student_id', 'name', 'grade', 'class_name'])
+            ->orderBy('grade')
+            ->orderBy('class_name')
+            ->orderBy('name')
+            ->get()
+            ->toArray();
+    }
+
+    #[On('teacherChanged')]
+    public function onTeacherChanged(string $teacherId): void
+    {
+        $this->teacherId = $teacherId;
+        $this->studentId = '';
+        $this->resetPageState();
+    }
+
+    #[On('studentChanged')]
+    public function onStudentChanged(?string $teacherId, ?string $studentId): void
+    {
+        $this->teacherId = (string) ($teacherId ?? '');
+        $this->studentId = (string) ($studentId ?? '');
+
+        if ($this->studentId) {
+            $this->loadMistakeData();
+        } else {
+            $this->resetPageState();
+        }
+    }
+
+    protected function loadFilterOptions(): void
+    {
+        try {
+            $knowledgeService = app(KnowledgeServiceApi::class);
+            $knowledge = $knowledgeService->listKnowledgePoints(150);
+
+            $this->filterOptions['knowledge_points'] = $knowledge
+                ->map(function ($item) {
+                    $code = $item['kp_code'] ?? $item['code'] ?? null;
+                    if (!$code) {
+                        return null;
+                    }
+                    return [
+                        'code' => $code,
+                        'name' => $item['cn_name'] ?? $item['name'] ?? $code,
+                    ];
+                })
+                ->filter()
+                ->take(200)
+                ->values()
+                ->toArray();
+
+            $skills = $knowledgeService->listSkills(null, 200);
+            $this->filterOptions['skills'] = $skills
+                ->map(function ($item) {
+                    return [
+                        'id' => $item['skill_id'] ?? $item['id'] ?? ($item['code'] ?? ''),
+                        'name' => $item['name'] ?? $item['skill_name'] ?? ($item['code'] ?? ''),
+                        'kp_code' => $item['kp_code'] ?? $item['knowledge_point_code'] ?? null,
+                    ];
+                })
+                ->filter(fn ($item) => filled($item['id']))
+                ->values()
+                ->toArray();
+        } catch (\Throwable $e) {
+            Log::error('Load filter options failed', [
+                'error' => $e->getMessage(),
+            ]);
+            $this->filterOptions = ['knowledge_points' => [], 'skills' => []];
+        }
+    }
+
+    protected function updateMistakeField(string $mistakeId, string $field, $value): void
+    {
+        foreach ($this->mistakes as &$mistake) {
+            if (($mistake['id'] ?? null) === $mistakeId) {
+                $mistake[$field] = $value;
+                break;
+            }
+        }
+    }
+
+    protected function findMistakeById(string $mistakeId): array
+    {
+        foreach ($this->mistakes as $mistake) {
+            if (($mistake['id'] ?? null) === $mistakeId) {
+                return $mistake;
+            }
+        }
+
+        return [];
+    }
+
+    protected function resetPageState(): void
+    {
+        $this->mistakes = [];
+        $this->patterns = [];
+        $this->summary = [];
+        $this->selectedMistakeIds = [];
+        $this->recommendations = [];
+        $this->relatedQuestions = [];
+        $this->actionMessage = '';
+        $this->errorMessage = '';
+    }
+
+    protected function notify(string $message, string $type = 'success'): void
+    {
+        $this->actionMessage = $message;
+        $this->actionMessageType = $type;
+    }
+}

+ 184 - 25
app/Filament/Pages/PromptManagement.php

@@ -5,11 +5,13 @@ namespace App\Filament\Pages;
 use App\Services\QuestionServiceApi;
 use BackedEnum;
 use Filament\Actions;
+use Filament\Actions\Action;
 use Filament\Notifications\Notification;
 use Filament\Pages\Page;
 use UnitEnum;
 use Livewire\Attributes\Computed;
 use Livewire\Attributes\On;
+use Illuminate\Support\Facades\Http;
 
 class PromptManagement extends Page
 {
@@ -35,6 +37,20 @@ class PromptManagement extends Page
 
     public array $selectedPrompts = [];
 
+    // 表单状态
+    public bool $showPromptModal = false;
+    public bool $isEditing = false;
+    public ?string $editingName = null;
+    public array $form = [
+        'template_name' => '',
+        'template_type' => 'question_generation',
+        'template_content' => '',
+        'variables' => '[]',
+        'description' => '',
+        'tags' => '',
+        'is_active' => 'yes',
+    ];
+
     /**
      * 获取提示词列表
      */
@@ -173,11 +189,10 @@ class PromptManagement extends Page
     #[On('create-prompt')]
     public function createPrompt(): void
     {
-        Notification::make()
-            ->title('创建提示词')
-            ->body('打开创建表单')
-            ->info()
-            ->send();
+        $this->resetPromptForm();
+        $this->isEditing = false;
+        $this->editingName = null;
+        $this->showPromptModal = true;
     }
 
     /**
@@ -186,11 +201,18 @@ class PromptManagement extends Page
     #[On('edit-prompt')]
     public function editPrompt(array $prompt): void
     {
-        Notification::make()
-            ->title('编辑提示词:' . $prompt['template_name'])
-            ->body('打开编辑表单')
-            ->info()
-            ->send();
+        $this->form = [
+            'template_name' => $prompt['template_name'] ?? '',
+            'template_type' => $prompt['template_type'] ?? 'question_generation',
+            'template_content' => $this->fetchPromptContent($prompt['template_name'] ?? '') ?? '',
+            'variables' => $prompt['variables'] ?? '[]',
+            'description' => $prompt['description'] ?? '',
+            'tags' => $prompt['tags'] ?? '',
+            'is_active' => ($prompt['is_active'] === 'yes' || $prompt['is_active'] === true) ? 'yes' : 'no',
+        ];
+        $this->isEditing = true;
+        $this->editingName = $prompt['template_name'] ?? null;
+        $this->showPromptModal = true;
     }
 
     /**
@@ -199,24 +221,47 @@ class PromptManagement extends Page
     #[On('delete-prompt')]
     public function deletePrompt(string $promptName): void
     {
-        Notification::make()
-            ->title('删除提示词:' . $promptName)
-            ->body('此操作不可恢复')
-            ->danger()
-            ->send();
+        try {
+            $this->request('DELETE', "/prompts/{$promptName}");
+            Notification::make()
+                ->title('删除成功')
+                ->body("提示词 {$promptName} 已删除")
+                ->success()
+                ->send();
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('删除失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
     }
 
     /**
      * 启用/禁用提示词
      */
     #[On('toggle-prompt')]
-    public function togglePrompt(string $promptName, bool $isActive): void
+    public function togglePrompt(?string $promptName = null, ?bool $isActive = null): void
     {
-        $status = $isActive ? '禁用' : '启用';
-        Notification::make()
-            ->title($status . '提示词:' . $promptName)
-            ->success()
-            ->send();
+        if (!$promptName || $isActive === null) {
+            return;
+        }
+
+        try {
+            $this->request('PUT', "/prompts/{$promptName}", [
+                'is_active' => $isActive ? 'no' : 'yes',
+            ]);
+            Notification::make()
+                ->title(($isActive ? '已禁用 ' : '已启用 ') . $promptName)
+                ->success()
+                ->send();
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('更新状态失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
     }
 
     /**
@@ -225,10 +270,28 @@ class PromptManagement extends Page
     #[On('duplicate-prompt')]
     public function duplicatePrompt(array $prompt): void
     {
-        Notification::make()
-            ->title('复制提示词:' . $prompt['template_name'])
-            ->success()
-            ->send();
+        $newName = ($prompt['template_name'] ?? 'template') . '_copy';
+        try {
+            $this->request('POST', '/prompts', [
+                'template_name' => $newName,
+                'template_type' => $prompt['template_type'] ?? 'question_generation',
+                'template_content' => $this->fetchPromptContent($prompt['template_name'] ?? '') ?? '',
+                'variables' => $prompt['variables'] ?? '[]',
+                'description' => $prompt['description'] ?? '',
+                'tags' => $prompt['tags'] ?? '',
+            ]);
+            Notification::make()
+                ->title('复制成功')
+                ->body("已创建副本:{$newName}")
+                ->success()
+                ->send();
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('复制失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
     }
 
     /**
@@ -262,4 +325,100 @@ class PromptManagement extends Page
                 ->action('refreshPrompts'),
         ];
     }
+
+    public function savePrompt(): void
+    {
+        $data = $this->validate([
+            'form.template_name' => $this->isEditing ? 'nullable|string' : 'required|string',
+            'form.template_type' => 'required|string',
+            'form.template_content' => 'required|string',
+            'form.variables' => 'nullable|string',
+            'form.description' => 'nullable|string',
+            'form.tags' => 'nullable|string',
+            'form.is_active' => 'nullable|string',
+        ])['form'];
+
+        try {
+            if ($this->isEditing && $this->editingName) {
+                $payload = [
+                    'template_content' => $data['template_content'],
+                    'template_type' => $data['template_type'],
+                    'variables' => $data['variables'] ?? '[]',
+                    'description' => $data['description'] ?? '',
+                    'tags' => $data['tags'] ?? '',
+                    'is_active' => $data['is_active'] ?? 'yes',
+                ];
+                $this->request('PUT', "/prompts/{$this->editingName}", $payload);
+            } else {
+                $this->request('POST', '/prompts', [
+                    'template_name' => $data['template_name'],
+                    'template_type' => $data['template_type'],
+                    'template_content' => $data['template_content'],
+                    'variables' => $data['variables'] ?? '[]',
+                    'description' => $data['description'] ?? '',
+                    'tags' => $data['tags'] ?? '',
+                ]);
+            }
+
+            $this->showPromptModal = false;
+            $this->refreshPrompts();
+
+            Notification::make()
+                ->title('保存成功')
+                ->success()
+                ->send();
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('保存失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+
+    protected function request(string $method, string $path, array $payload = []): mixed
+    {
+        $baseUrl = rtrim(config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')), '/');
+        $url = $baseUrl . $path;
+
+        $response = Http::timeout(10)->send($method, $url, [
+            'json' => $payload,
+        ]);
+
+        if (!$response->successful()) {
+            throw new \Exception("API 请求失败: {$response->status()} - " . ($response->json('detail') ?? $response->body()));
+        }
+
+        return $response->json();
+    }
+
+    protected function fetchPromptContent(string $templateName): ?string
+    {
+        if (!$templateName) {
+            return null;
+        }
+        try {
+            $baseUrl = rtrim(config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')), '/');
+            $resp = Http::timeout(8)->get($baseUrl . "/prompts/{$templateName}");
+            if ($resp->successful()) {
+                return $resp->json('template_content');
+            }
+        } catch (\Exception $e) {
+            \Log::warning('获取提示词内容失败: ' . $e->getMessage());
+        }
+        return null;
+    }
+
+    protected function resetPromptForm(): void
+    {
+        $this->form = [
+            'template_name' => '',
+            'template_type' => 'question_generation',
+            'template_content' => '',
+            'variables' => '[]',
+            'description' => '',
+            'tags' => '',
+            'is_active' => 'yes',
+        ];
+    }
 }

+ 24 - 0
app/Filament/Pages/QuestionGeneration.php

@@ -61,6 +61,30 @@ class QuestionGeneration extends Page
         ];
     }
 
+    #[Computed(cache: false)]
+    public function promptOptions(): array
+    {
+        $service = app(\App\Services\QuestionServiceApi::class);
+        $prompts = $service->listPrompts(type: 'question_generation', active: 'yes');
+
+        // 只展示激活的题目生成模板
+        $options = [];
+        foreach ($prompts as $prompt) {
+            $label = $prompt['template_name'];
+            if (!empty($prompt['description'])) {
+                $label .= ' - ' . (is_string($prompt['description']) ? $prompt['description'] : json_encode($prompt['description']));
+            }
+            $options[$prompt['template_name']] = $label;
+        }
+
+        // 自动选择第一个激活模板
+        if (!$this->promptTemplate && !empty($options)) {
+            $this->promptTemplate = array_key_first($options);
+        }
+
+        return $options;
+    }
+
     public function updatedGenerateKpCode(): void
     {
         // 选择新知识点时清空技能选择

+ 15 - 0
app/Filament/Pages/StudentDashboard.php

@@ -18,6 +18,7 @@ use Livewire\Attributes\Title;
 use Livewire\Attributes\On;
 use Livewire\Attributes\Computed;
 use App\Models\Student as StudentModel;
+use App\Services\MistakeBookService;
 
 class StudentDashboard extends Page
 {
@@ -47,6 +48,7 @@ class StudentDashboard extends Page
     public array $mindmapNodeDetails = [];
     public ?string $mindmapSelectedNode = null;
     // teachers 和 students 现在是 Computed 属性,不再需要声明
+    public array $mistakePanel = [];
 
     public function mount(Request $request): void
     {
@@ -171,6 +173,7 @@ class StudentDashboard extends Page
         } else {
             $this->mindmapMasteryData = [];
             $this->dispatch('mastery-updated', data: []);
+            $this->mistakePanel = [];
         }
     }
 
@@ -247,6 +250,17 @@ class StudentDashboard extends Page
                 'dashboard_data_keys' => array_keys($this->dashboardData)
             ]);
 
+            try {
+                $mistakeService = app(MistakeBookService::class);
+                $this->mistakePanel = $mistakeService->getPanelSnapshot($this->studentId, 5);
+            } catch (\Exception $e) {
+                Log::warning('加载错题本面板数据失败', [
+                    'student_id' => $this->studentId,
+                    'error' => $e->getMessage()
+                ]);
+                $this->mistakePanel = [];
+            }
+
         } catch (\Exception $e) {
             $this->errorMessage = '加载数据时发生错误:' . $e->getMessage();
             Log::error('学生仪表板数据加载失败', [
@@ -255,6 +269,7 @@ class StudentDashboard extends Page
             ]);
             $this->mindmapMasteryData = [];
             $this->dispatch('mastery-updated', data: []);
+            $this->mistakePanel = [];
         } finally {
             $this->isLoading = false;
         }

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

@@ -59,7 +59,7 @@ class AdminPanelProvider extends PanelProvider
                 view('filament.layout.vite-styles')->render() . view('filament.layout.vite-scripts')->render()
             )
             ->renderHook('global::head.start', fn (): string => view('filament.layout.vite-styles')->render())
-            // ->renderHook('panels::body.end', fn (): string => view('filament.layout.math-renderer')->render())
+            ->renderHook('panels::body.end', fn (): string => view('filament.layout.math-renderer')->render())
             ->middleware([
                 EncryptCookies::class,
                 AddQueuedCookiesToResponse::class,

+ 281 - 0
app/Services/MistakeBookService.php

@@ -0,0 +1,281 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class MistakeBookService
+{
+    protected string $learningAnalyticsBase;
+    protected string $questionBankBase;
+    protected int $timeout;
+
+    public function __construct(
+        ?string $learningAnalyticsBase = null,
+        ?string $questionBankBase = null,
+        ?int $timeout = null
+    ) {
+        $this->learningAnalyticsBase = rtrim(
+            $learningAnalyticsBase
+                ?: config('services.learning_analytics.url', env('LEARNING_ANALYTICS_API_BASE', 'http://localhost:5016')),
+            '/'
+        );
+
+        $this->questionBankBase = rtrim(
+            $questionBankBase
+                ?: config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')),
+            '/'
+        );
+
+        $this->timeout = $timeout ?? (int) config('services.learning_analytics.timeout', 20);
+    }
+
+    /**
+     * 获取错题列表(支持多维筛选)
+     */
+    public function listMistakes(array $params = []): array
+    {
+        $query = array_filter([
+            'student_id' => $params['student_id'] ?? null,
+            'kp_ids' => $this->implodeIfArray($params['kp_ids'] ?? null),
+            'skill_ids' => $this->implodeIfArray($params['skill_ids'] ?? null),
+            'error_types' => $this->implodeIfArray($params['error_types'] ?? null),
+            'time_range' => $params['time_range'] ?? null,
+            'start_date' => $params['start_date'] ?? null,
+            'end_date' => $params['end_date'] ?? null,
+            'page' => $params['page'] ?? 1,
+            'per_page' => $params['per_page'] ?? 20,
+        ], fn ($value) => filled($value));
+
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/mistake-book', $query);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : ['data' => $body];
+            }
+
+            Log::warning('MistakeBook list failed', [
+                'status' => $response->status(),
+                'body' => $response->body(),
+                'query' => $query,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('MistakeBook list exception', [
+                'error' => $e->getMessage(),
+                'query' => $query,
+            ]);
+        }
+
+        return [
+            'data' => [],
+            'meta' => ['total' => 0, 'page' => 1, 'per_page' => $query['per_page'] ?? 20],
+        ];
+    }
+
+    /**
+     * 获取错题统计概要
+     */
+    public function summarize(string $studentId): array
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/mistake-book/summary', [
+                    'student_id' => $studentId,
+                ]);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : [];
+            }
+
+            Log::warning('MistakeBook summary failed', [
+                'status' => $response->status(),
+                'body' => $response->body(),
+                'student_id' => $studentId,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('MistakeBook summary exception', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        // fallback 返回一个空结构,避免前端崩溃
+        return [
+            'total' => 0,
+            'this_week' => 0,
+            'pending_review' => 0,
+            'mastery_rate' => null,
+        ];
+    }
+
+    /**
+     * 获取错误模式与推荐路径
+     */
+    public function getMistakePatterns(string $studentId): array
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/analytics/mistake-pattern', [
+                    'student_id' => $studentId,
+                ]);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : [];
+            }
+
+            Log::warning('Mistake pattern request failed', [
+                'status' => $response->status(),
+                'body' => $response->body(),
+                'student_id' => $studentId,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Mistake pattern request exception', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return [];
+    }
+
+    /**
+     * 收藏/取消收藏错题
+     */
+    public function toggleFavorite(string $mistakeId, bool $favorite = true): bool
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/favorite', [
+                    'favorite' => $favorite,
+                ]);
+
+            return $response->successful();
+        } catch (\Throwable $e) {
+            Log::error('Toggle favorite failed', [
+                'mistake_id' => $mistakeId,
+                'favorite' => $favorite,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return false;
+    }
+
+    /**
+     * 标记已复习
+     */
+    public function markReviewed(string $mistakeId): bool
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review');
+
+            return $response->successful();
+        } catch (\Throwable $e) {
+            Log::error('Mark reviewed failed', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return false;
+    }
+
+    /**
+     * 添加到重练清单
+     */
+    public function addToRetryList(string $mistakeId): bool
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/retry-list');
+
+            return $response->successful();
+        } catch (\Throwable $e) {
+            Log::error('Add to retry list failed', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return false;
+    }
+
+    /**
+     * 基于错题推荐练习题
+     */
+    public function recommendPractice(string $studentId, array $kpIds = [], array $skillIds = []): array
+    {
+        $payload = array_filter([
+            'student_id' => $studentId,
+            'kp_ids' => array_values(array_unique($kpIds)),
+            'skill_ids' => array_values(array_unique($skillIds)),
+        ], fn ($value) => $value !== null);
+
+        try {
+            $response = Http::timeout($this->timeout)
+                ->post($this->questionBankBase . '/api/questions/recommend', $payload);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : ['data' => $body];
+            }
+
+            Log::warning('Recommend practice failed', [
+                'status' => $response->status(),
+                'body' => $response->body(),
+                'payload' => $payload,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Recommend practice exception', [
+                'payload' => $payload,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return ['data' => []];
+    }
+
+    /**
+     * 为学生仪表板提供快照数据
+     */
+    public function getPanelSnapshot(string $studentId, int $limit = 5): array
+    {
+        $list = $this->listMistakes([
+            'student_id' => $studentId,
+            'per_page' => $limit,
+        ]);
+
+        $patterns = $this->getMistakePatterns($studentId);
+        $summary = $this->summarize($studentId);
+
+        return [
+            'recent' => $list['data'] ?? [],
+            'weak_skills' => $patterns['top_skills'] ?? [],
+            'weak_kps' => $patterns['top_kps'] ?? [],
+            'error_types' => $patterns['error_types'] ?? [],
+            'recommend_path' => $patterns['recommend_path'] ?? [],
+            'stats' => [
+                'total' => $summary['total'] ?? Arr::get($list, 'meta.total', 0),
+                'this_week' => $summary['this_week'] ?? null,
+                'pending_review' => $summary['pending_review'] ?? null,
+                'mastery_rate' => $summary['mastery_rate'] ?? null,
+            ],
+        ];
+    }
+
+    private function implodeIfArray($value): ?string
+    {
+        if (is_array($value)) {
+            return implode(',', array_filter($value));
+        }
+
+        return $value;
+    }
+}

+ 11 - 26
app/Services/QuestionServiceApi.php

@@ -280,30 +280,18 @@ class QuestionServiceApi
      */
     public function listPrompts(?string $type = null, ?string $active = null): array
     {
-        $cacheKey = sprintf(
-            'prompts-list-%s-%s',
-            $type ?: 'all',
-            $active ?: 'all'
-        );
-
-        return Cache::remember(
-            $cacheKey,
-            now()->addSeconds($this->cacheTtl),
-            function () use ($type, $active): array {
-                $query = array_filter([
-                    'type' => $type,
-                    'active' => $active,
-                ], fn ($value) => filled($value));
+        $query = array_filter([
+            'type' => $type,
+            'active' => $active,
+        ], fn ($value) => filled($value));
 
-                try {
-                    $response = $this->request('GET', '/prompts', $query);
-                    return $response ?? [];
-                } catch (\Exception $e) {
-                    \Log::error('Failed to get prompts: ' . $e->getMessage());
-                    return [];
-                }
-            }
-        );
+        try {
+            $response = $this->request('GET', '/prompts', $query);
+            return $response ?? [];
+        } catch (\Exception $e) {
+            \Log::error('Failed to get prompts: ' . $e->getMessage());
+            return [];
+        }
     }
 
     /**
@@ -320,9 +308,6 @@ class QuestionServiceApi
                 $response = $this->request('POST', '/prompts', $data);
             }
 
-            // 清除提示词缓存
-            Cache::forget('prompts-list-all-all');
-
             return $response ?? [
                 'success' => true,
                 'message' => '提示词保存成功',

+ 3 - 0
docs/math-render-system.md

@@ -370,6 +370,9 @@ KATEX_AUTO_INIT=false
 window.MathRenderConfig.autoInit = false;
 ```
 
+## TODO(数据侧公式清洗)
+- 题库/ OCR 入库阶段加一层 LaTeX 清洗与分隔校验,避免连写或缺少幂符号导致渲染异常。示例:题目 `P06_SK001_59555` 的选项出现 `x2+4y2x^2+4y^2` 连写,应在入库前拆分、补 `^` 或用 `$...$` 包裹。当前渲染器正常,剩余问题属于内容质量,后续在题目生成与 OCR 导入时统一修复。
+
 ## 示例代码
 
 完整示例请参考:

+ 152 - 0
resources/views/components/mistake-book-panel.blade.php

@@ -0,0 +1,152 @@
+@props(['data' => [], 'studentId' => '', 'teacherId' => ''])
+
+@php
+    $stats = $data['stats'] ?? [];
+    $recent = $data['recent'] ?? [];
+    $weakSkills = $data['weak_skills'] ?? [];
+    $weakKps = $data['weak_kps'] ?? [];
+    $errorTypes = $data['error_types'] ?? [];
+@endphp
+
+<div class="bg-white shadow-sm rounded-xl border border-gray-200">
+    <div class="px-6 py-5 border-b border-gray-100 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
+        <div>
+            <p class="text-sm text-slate-500 mb-1">MistakeBook</p>
+            <h3 class="text-xl font-semibold text-slate-900">错题本速览</h3>
+            <p class="text-sm text-slate-500">最近错题 · 易错技能 · 薄弱知识点</p>
+        </div>
+        <div class="flex flex-wrap gap-2">
+            <a
+                href="{{ url('/admin/mistake-book') }}{{ $studentId ? ('?student_id=' . $studentId . ($teacherId ? '&teacher_id=' . $teacherId : '')) : '' }}{{ !$studentId && $teacherId ? ('?teacher_id=' . $teacherId) : '' }}"
+                class="btn btn-primary btn-sm"
+                target="_blank"
+            >
+                打开错题本
+            </a>
+        </div>
+    </div>
+    <div class="px-6 py-5 space-y-4">
+        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
+            <div class="stat bg-slate-50 rounded-lg border border-slate-200">
+                <div class="stat-title text-slate-500">总错题</div>
+                <div class="stat-value text-slate-900 text-2xl">{{ $stats['total'] ?? 0 }}</div>
+            </div>
+            <div class="stat bg-slate-50 rounded-lg border border-slate-200">
+                <div class="stat-title text-slate-500">本周错题</div>
+                <div class="stat-value text-amber-600 text-2xl">{{ $stats['this_week'] ?? 0 }}</div>
+            </div>
+            <div class="stat bg-slate-50 rounded-lg border border-slate-200">
+                <div class="stat-title text-slate-500">待复习</div>
+                <div class="stat-value text-indigo-600 text-2xl">{{ $stats['pending_review'] ?? 0 }}</div>
+            </div>
+            <div class="stat bg-slate-50 rounded-lg border border-slate-200">
+                <div class="stat-title text-slate-500">掌握率</div>
+                <div class="stat-value text-emerald-600 text-2xl">
+                    {{ isset($stats['mastery_rate']) ? number_format($stats['mastery_rate'] * 100, 1) . '%' : '--' }}
+                </div>
+            </div>
+        </div>
+
+        <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
+            <div class="lg:col-span-1 bg-slate-50 border border-slate-200 rounded-lg p-4">
+                <div class="flex items-center justify-between mb-3">
+                    <h4 class="font-semibold text-slate-800">最近错题</h4>
+                    <span class="badge badge-ghost">最新</span>
+                </div>
+                <div class="space-y-3">
+                    @forelse(array_slice($recent, 0, 4) as $item)
+                        <div class="p-3 bg-white rounded-lg border border-slate-200">
+                            <div class="flex items-center justify-between">
+                                <p class="text-xs text-slate-400">{{ $item['created_at'] ?? '' }}</p>
+                                @if(!empty($item['error_type']))
+                                    <span class="badge badge-warning badge-outline">{{ $item['error_type'] }}</span>
+                                @endif
+                            </div>
+                            <p class="text-sm text-slate-800 mt-1">
+                                {{ \Illuminate\Support\Str::limit(strip_tags($item['question']['stem'] ?? $item['question']['content'] ?? '错题'), 80) }}
+                            </p>
+                            @if(!empty($item['kp_ids']))
+                                <div class="flex flex-wrap gap-1 mt-2">
+                                    @foreach(array_slice($item['kp_ids'], 0, 2) as $kp)
+                                        <span class="badge badge-ghost">KP {{ $kp }}</span>
+                                    @endforeach
+                                </div>
+                            @endif
+                        </div>
+                    @empty
+                        <p class="text-sm text-slate-500">暂无错题记录</p>
+                    @endforelse
+                </div>
+            </div>
+
+            <div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
+                <div class="flex items-center justify-between mb-3">
+                    <h4 class="font-semibold text-slate-800">本周易错技能</h4>
+                    <span class="badge badge-warning badge-outline">Top</span>
+                </div>
+                <div class="space-y-2">
+                    @forelse(array_slice($weakSkills, 0, 4) as $skill)
+                        @php
+                            $score = $skill['score'] ?? $skill['count'] ?? 0;
+                            $score = is_numeric($score) ? (float) $score : 0;
+                        @endphp
+                        <div>
+                            <div class="flex items-center justify-between text-sm text-slate-700">
+                                <span>{{ $skill['name'] ?? ($skill['skill'] ?? '技能') }}</span>
+                                <span class="text-xs text-slate-500">{{ $score }}</span>
+                            </div>
+                            <progress class="progress progress-warning w-full" value="{{ min(100, $score * 10) }}" max="100"></progress>
+                        </div>
+                    @empty
+                        <p class="text-sm text-slate-500">暂无技能数据</p>
+                    @endforelse
+                </div>
+            </div>
+
+            <div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
+                <div class="flex items-center justify-between mb-3">
+                    <h4 class="font-semibold text-slate-800">薄弱知识点 Top 5</h4>
+                    <span class="badge badge-error badge-outline">KP</span>
+                </div>
+                <div class="space-y-2">
+                    @forelse(array_slice($weakKps, 0, 5) as $kp)
+                        @php
+                            $score = $kp['score'] ?? $kp['mistake_count'] ?? $kp['count'] ?? 0;
+                            $score = is_numeric($score) ? (float) $score : 0;
+                        @endphp
+                        <div class="flex items-center justify-between">
+                            <div>
+                                <p class="text-sm font-medium text-slate-800">{{ $kp['name'] ?? ($kp['kp'] ?? $kp['kp_code'] ?? '知识点') }}</p>
+                                <p class="text-xs text-slate-500">{{ $kp['kp_code'] ?? $kp['kp'] ?? '' }}</p>
+                            </div>
+                            <div class="text-right">
+                                <p class="text-sm text-slate-700">错误 {{ $kp['mistake_count'] ?? $kp['count'] ?? 0 }}</p>
+                                <div class="w-28 bg-slate-200 h-1.5 rounded-full mt-1">
+                                    <div class="h-1.5 rounded-full bg-red-500" style="width: {{ min(100, (1 - min($score, 1)) * 100) }}%"></div>
+                                </div>
+                            </div>
+                        </div>
+                    @empty
+                        <p class="text-sm text-slate-500">暂无知识点数据</p>
+                    @endforelse
+                </div>
+            </div>
+        </div>
+
+        <div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
+            <div class="flex items-center justify-between">
+                <h4 class="font-semibold text-slate-800">错误类型分布</h4>
+                <span class="badge badge-ghost">AI</span>
+            </div>
+            <div class="flex flex-wrap gap-2 mt-3">
+                @forelse($errorTypes as $error)
+                    <span class="badge badge-outline">
+                        {{ $error['name'] ?? $error['type'] ?? '错误' }} · {{ $error['count'] ?? $error['value'] ?? 0 }}
+                    </span>
+                @empty
+                    <p class="text-sm text-slate-500">暂无错误类型数据</p>
+                @endforelse
+            </div>
+        </div>
+    </div>
+</div>

+ 81 - 44
resources/views/filament/layout/math-renderer.blade.php

@@ -1,54 +1,91 @@
 @once
-    @push('styles')
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
-    @endpush
-
-    @push('scripts')
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    <style>
+        /* 避免 $$ 块级公式强制换行,用 inline 展示方便题干内联 */
+        .katex-display {
+            display: inline;
+            margin: 0;
+        }
+    </style>
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
     <script>
-    document.addEventListener('DOMContentLoaded', function() {
-        console.log('数学渲染器加载');
-
-        function renderMath() {
-            // 查找所有包含 $$ 的元素
-            const elements = document.querySelectorAll('*');
-
-            elements.forEach(element => {
-                if (element.children.length === 0) { // 只处理叶子节点
-                    const text = element.textContent;
-                    if (text && text.includes('$$')) {
-                        try {
-                            const newHtml = text.replace(/\$\$([^$]+)\$\$/g, (match, formula) => {
-                                return katex.renderToString(formula.trim(), {
-                                    throwOnError: false,
-                                    displayMode: true
-                                });
-                            });
-
-                            if (newHtml !== text) {
-                                element.innerHTML = newHtml;
-                            }
-                        } catch (e) {
-                            console.warn('数学公式渲染失败:', e);
+        (() => {
+            const MAX_ATTEMPTS = 60;
+            let attempts = 0;
+            let observer;
+
+            const renderOptions = {
+                delimiters: [
+                    // 统一用 inline 渲染,避免题干中 $$ 造成强制换行
+                    {left: "$$", right: "$$", display: false},
+                    {left: "$", right: "$", display: false},
+                    {left: "\\(", right: "\\)", display: false},
+                    {left: "\\[", right: "\\]", display: false},
+                ],
+                strict: 'ignore',
+                throwOnError: false,
+                ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'option']
+            };
+
+            const ready = () =>
+                typeof window.renderMathInElement !== 'undefined' &&
+                typeof window.katex !== 'undefined';
+
+            const renderAll = () => {
+                if (!ready()) return;
+                document.querySelectorAll('.math-render').forEach(el => {
+                    el.removeAttribute('data-rendered');
+                });
+                window.renderMathInElement(document.body, renderOptions);
+            };
+
+            const debouncedRender = () => {
+                clearTimeout(window.__mathRenderTimer);
+                window.__mathRenderTimer = setTimeout(renderAll, 25);
+            };
+
+            const waitAndRender = () => {
+                if (ready()) {
+                    renderAll();
+                    setupObserver();
+                    return;
+                }
+                if (attempts++ < MAX_ATTEMPTS) {
+                    setTimeout(waitAndRender, 50);
+                }
+            };
+
+            const setupObserver = () => {
+                if (observer) return;
+                observer = new MutationObserver((mutations) => {
+                    for (const mutation of mutations) {
+                        if (mutation.addedNodes.length > 0) {
+                            debouncedRender();
+                            break;
                         }
                     }
+                });
+                observer.observe(document.body, { childList: true, subtree: true });
+            };
+
+            // Livewire 局部刷新后重渲染
+            document.addEventListener('livewire:init', () => {
+                if (window.Livewire?.hook) {
+                    Livewire.hook('message.processed', () => debouncedRender());
                 }
             });
-        }
 
-        // 页面加载后渲染
-        setTimeout(renderMath, 500);
+            // 手动触发事件
+            document.addEventListener('math:render', () => debouncedRender());
 
-        // Livewire 更新后重新渲染
-        if (window.Livewire) {
-            window.Livewire.on('updated', () => {
-                setTimeout(renderMath, 100);
-            });
-        }
+            // 全局暴露便于调试
+            window.renderMath = () => {
+                waitAndRender();
+            };
 
-        // 添加手动渲染函数
-        window.renderMath = renderMath;
-    });
+            // 初始渲染
+            waitAndRender();
+        })();
     </script>
-    @endpush
-@endonce
+@endonce

+ 569 - 0
resources/views/filament/pages/mistake-book.blade.php

@@ -0,0 +1,569 @@
+<div class="min-h-screen bg-[#f5f7fb] p-6">
+    <div class="max-w-7xl mx-auto space-y-6">
+        <div class="rounded-2xl bg-gradient-to-r from-sky-50 via-white to-indigo-50 border border-slate-100 shadow-sm p-6">
+            <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
+                <div class="lg:col-span-1">
+                    <p class="text-xs uppercase tracking-[0.2em] text-slate-400">MistakeBook</p>
+                    <h1 class="text-3xl font-bold text-slate-900 mt-1">错题本 · 诊断与重练</h1>
+                    <p class="text-sm text-slate-500 mt-1">完全复用上传卷子/智能出卷的师生联动:先选老师,再选学生,再刷新数据。</p>
+                </div>
+                <div class="lg:col-span-2">
+                    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+                        <div class="form-control w-full">
+                            <label class="label">
+                                <span class="label-text font-medium">选择老师 <span class="text-error">*</span></span>
+                            </label>
+                            <select
+                                wire:model.live="teacherId"
+                                class="select select-bordered w-full select-lg"
+                            >
+                                <option value="">请选择老师...</option>
+                                @foreach($this->teachers as $teacher)
+                                    <option value="{{ $teacher->teacher_id }}">
+                                        {{ trim($teacher->name ?? $teacher->teacher_id) . ($teacher->subject ? " ({$teacher->subject})" : '') }}
+                                    </option>
+                                @endforeach
+                            </select>
+                        </div>
+                        <div class="form-control w-full">
+                            <label class="label">
+                                <span class="label-text font-medium">选择学生 <span class="text-error">*</span></span>
+                            </label>
+                            <select
+                                wire:model.live="studentId"
+                                class="select select-bordered w-full select-lg"
+                                @if(empty($teacherId)) disabled @endif
+                            >
+                                <option value="">
+                                    @if(empty($teacherId))
+                                        请先选择老师
+                                    @else
+                                        请选择学生...
+                                    @endif
+                                </option>
+                                @foreach($this->students as $student)
+                                    <option value="{{ $student->student_id }}">
+                                        {{ trim($student->name ?? $student->student_id) . " ({$student->grade} - {$student->class_name})" }}
+                                    </option>
+                                @endforeach
+                            </select>
+                        </div>
+                    </div>
+                    <div class="mt-3 flex items-center justify-between">
+                        <div class="text-xs text-slate-500">按照上传卷子/智能出卷同款逻辑联动师生</div>
+                        <button
+                            wire:click="loadMistakeData"
+                            class="btn btn-primary btn-md"
+                        >
+                            <span wire:loading.remove>刷新</span>
+                            <span wire:loading class="loading loading-spinner loading-xs"></span>
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        @if ($actionMessage)
+            <div class="alert {{ $actionMessageType === 'danger' ? 'alert-error' : ($actionMessageType === 'warning' ? 'alert-warning' : 'alert-success') }} shadow-sm">
+                <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                <span>{{ $actionMessage }}</span>
+            </div>
+        @endif
+
+        @if ($errorMessage)
+            <div class="alert alert-error shadow-sm">
+                <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                <div>
+                    <h3 class="font-bold">加载失败</h3>
+                    <div class="text-xs">{{ $errorMessage }}</div>
+                </div>
+            </div>
+        @endif
+
+        <div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
+            <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 flex items-center gap-3">
+                <div class="w-10 h-10 rounded-full bg-sky-100 text-sky-600 flex items-center justify-center font-semibold">总</div>
+                <div>
+                    <p class="text-xs text-slate-500">总错题</p>
+                    <p class="text-3xl font-bold text-slate-900">{{ $summary['total'] ?? 0 }}</p>
+                </div>
+            </div>
+            <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 flex items-center gap-3">
+                <div class="w-10 h-10 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center font-semibold">7d</div>
+                <div>
+                    <p class="text-xs text-slate-500">本周错题</p>
+                    <p class="text-3xl font-bold text-slate-900">{{ $summary['this_week'] ?? 0 }}</p>
+                </div>
+            </div>
+            <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 flex items-center gap-3">
+                <div class="w-10 h-10 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center font-semibold">待</div>
+                <div>
+                    <p class="text-xs text-slate-500">待复习</p>
+                    <p class="text-3xl font-bold text-amber-600">{{ $summary['pending_review'] ?? 0 }}</p>
+                </div>
+            </div>
+            <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 flex items-center gap-3">
+                <div class="w-10 h-10 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center font-semibold">AI</div>
+                <div class="flex-1">
+                    <p class="text-xs text-slate-500">掌握率</p>
+                    @php $masteryRate = $summary['mastery_rate'] ?? null; @endphp
+                    <p class="text-3xl font-bold text-emerald-700">
+                        {{ $masteryRate !== null ? number_format($masteryRate * 100, 1) . '%' : '--' }}
+                    </p>
+                    <div class="mt-1 h-2 bg-slate-200 rounded-full overflow-hidden">
+                        <div class="h-2 bg-emerald-500" style="width: {{ $masteryRate ? $masteryRate * 100 : 0 }}%"></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
+            <div class="lg:col-span-4 space-y-4">
+                <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 space-y-4">
+                    <div class="flex items-center justify-between">
+                        <h3 class="font-semibold text-slate-900">多维筛选</h3>
+                        <button class="btn btn-primary btn-sm" wire:click="applyFilters">应用</button>
+                    </div>
+                    <div class="space-y-3">
+                        <div class="form-control">
+                            <label class="label pb-1">
+                                <span class="label-text font-medium text-slate-800">知识点</span>
+                                <span class="text-xs text-slate-400">多选</span>
+                            </label>
+                            <select
+                                multiple
+                                size="6"
+                                wire:model="filters.kp_ids"
+                                class="select select-bordered w-full bg-white"
+                            >
+                                @foreach($filterOptions['knowledge_points'] as $kp)
+                                    <option value="{{ $kp['code'] }}">{{ $kp['name'] }} ({{ $kp['code'] }})</option>
+                                @endforeach
+                            </select>
+                        </div>
+                        <div class="form-control">
+                            <label class="label pb-1">
+                                <span class="label-text font-medium text-slate-800">技能</span>
+                                <span class="text-xs text-slate-400">联动</span>
+                            </label>
+                            @php
+                                $selectedKps = $filters['kp_ids'] ?? [];
+                                $skillOptions = collect($filterOptions['skills'] ?? [])
+                                    ->when(!empty($selectedKps), function($c) use ($selectedKps) {
+                                        return $c->filter(fn($item) => empty($item['kp_code']) || in_array($item['kp_code'], $selectedKps));
+                                    })
+                                    ->values()
+                                    ->all();
+                            @endphp
+                            <select
+                                multiple
+                                size="6"
+                                wire:model="filters.skill_ids"
+                                class="select select-bordered w-full bg-white"
+                            >
+                                @foreach($skillOptions as $skill)
+                                    <option value="{{ $skill['id'] }}">
+                                        {{ $skill['name'] ?? $skill['id'] }}
+                                        @if(!empty($skill['kp_code']))
+                                            · {{ $skill['kp_code'] }}
+                                        @endif
+                                    </option>
+                                @endforeach
+                            </select>
+                        </div>
+                        <div>
+                            <p class="label-text font-medium mb-2">错误类型</p>
+                            <div class="grid grid-cols-2 gap-2">
+                                @foreach(['计算错误', '概念错误', '方法错误', '审题错误', '表达错误'] as $type)
+                                    <label class="flex items-center gap-2 rounded-lg px-2 py-1 bg-slate-50 border border-slate-200">
+                                        <input
+                                            type="checkbox"
+                                            class="checkbox checkbox-sm checkbox-primary"
+                                            value="{{ $type }}"
+                                            wire:model="filters.error_types"
+                                        >
+                                        <span class="text-sm text-slate-700">{{ $type }}</span>
+                                    </label>
+                                @endforeach
+                            </div>
+                        </div>
+                        <div>
+                            <p class="label-text font-medium mb-2">时间范围</p>
+                            <div class="grid grid-cols-3 gap-2">
+                                <button class="btn btn-sm {{ $filters['time_range'] === 'last_7' ? 'btn-primary' : 'btn-outline' }}" wire:click="$set('filters.time_range', 'last_7')">7天</button>
+                                <button class="btn btn-sm {{ $filters['time_range'] === 'last_30' ? 'btn-primary' : 'btn-outline' }}" wire:click="$set('filters.time_range', 'last_30')">30天</button>
+                                <button class="btn btn-sm {{ $filters['time_range'] === 'custom' ? 'btn-primary' : 'btn-outline' }}" wire:click="$set('filters.time_range', 'custom')">自定义</button>
+                            </div>
+                            @if($filters['time_range'] === 'custom')
+                                <div class="mt-3 space-y-2">
+                                    <input type="date" class="input input-bordered w-full" wire:model="filters.start_date">
+                                    <input type="date" class="input input-bordered w-full" wire:model="filters.end_date">
+                                    <button class="btn btn-ghost btn-xs" wire:click="clearCustomRange">清空</button>
+                                </div>
+                            @endif
+                        </div>
+                        <button
+                            wire:click="applyFilters"
+                            class="btn btn-primary btn-md w-full"
+                        >
+                            <span wire:loading.remove>应用筛选</span>
+                            <span wire:loading class="loading loading-spinner"></span>
+                        </button>
+                    </div>
+                </div>
+
+                <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 space-y-3">
+                    <div class="flex items-center justify-between">
+                        <h3 class="font-semibold text-slate-900">推荐补救路径</h3>
+                        <button class="btn btn-ghost btn-sm text-indigo-600" wire:click="refreshPatterns">刷新</button>
+                    </div>
+                    @if(!empty($patterns['recommend_path']))
+                        <ul class="timeline timeline-vertical">
+                            @foreach($patterns['recommend_path'] as $step)
+                                <li>
+                                    <div class="timeline-middle">
+                                        <div class="w-2.5 h-2.5 rounded-full bg-indigo-500"></div>
+                                    </div>
+                                    <div class="timeline-end timeline-box bg-white border border-indigo-100 text-sm text-slate-700">
+                                        {{ $step['title'] ?? ($step['kp'] ?? '学习步骤') }}
+                                        @if(!empty($step['description']))
+                                            <p class="text-xs text-slate-500 mt-1">{{ $step['description'] }}</p>
+                                        @endif
+                                    </div>
+                                    <hr class="bg-indigo-200"/>
+                                </li>
+                            @endforeach
+                        </ul>
+                    @else
+                        <p class="text-sm text-slate-500">暂无推荐路径,稍后重试</p>
+                    @endif
+                </div>
+            </div>
+
+            <div class="lg:col-span-8 space-y-5">
+                <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5">
+                    <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+                        <div>
+                            <h3 class="text-lg font-semibold text-slate-900">错题列表</h3>
+                            <p class="text-sm text-slate-500">题干、作答、AI 解析与操作</p>
+                        </div>
+                        <div class="flex flex-wrap gap-2 items-center">
+                            <button class="btn btn-md btn-secondary" wire:click="generatePracticeFromSelection">
+                                📚 基于错题生成练习
+                            </button>
+                            <div class="badge badge-outline">
+                                已选 {{ count($selectedMistakeIds) }} / {{ count($mistakes) }}
+                            </div>
+                        </div>
+                    </div>
+
+                    @if ($isLoading)
+                        <div class="flex items-center justify-center py-10 text-slate-500">
+                            <span class="loading loading-spinner loading-lg mr-3"></span>
+                            正在加载错题...
+                        </div>
+                    @elseif(empty($mistakes))
+                        <div class="text-center py-12 text-slate-500">
+                            <svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2m6-2a9 9 0 11-18 0 9 9 0 0118 0z" />
+                            </svg>
+                            <p class="mt-3">暂无错题,先选择老师和学生</p>
+                        </div>
+                    @else
+                        <div class="space-y-4 mt-4">
+                            @foreach($mistakes as $mistake)
+                                <div class="rounded-xl border border-slate-200 bg-white shadow-sm p-4 space-y-4" wire:key="mistake-{{ $mistake['id'] ?? $loop->index }}">
+                                    <div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
+                                        <div class="flex items-center gap-3">
+                                            <input
+                                                type="checkbox"
+                                                class="checkbox checkbox-primary"
+                                                wire:click="toggleSelection('{{ $mistake['id'] ?? '' }}')"
+                                                @checked(in_array($mistake['id'] ?? '', $selectedMistakeIds, true))
+                                            >
+                                            <div>
+                                                <p class="text-xs text-slate-400">{{ $mistake['id'] ?? 'ID' }}</p>
+                                                <p class="text-sm text-slate-500">
+                                                    {{ $mistake['created_at'] ?? '' }}
+                                                </p>
+                                            </div>
+                                        </div>
+                                        <div class="flex flex-wrap gap-2">
+                                            @foreach(($mistake['kp_ids'] ?? []) as $kp)
+                                                <span class="badge badge-ghost">KP {{ $kp }}</span>
+                                            @endforeach
+                                            @foreach(($mistake['skill_ids'] ?? []) as $skill)
+                                                <span class="badge badge-outline badge-info">{{ $skill }}</span>
+                                            @endforeach
+                                            @if(!empty($mistake['error_type']))
+                                                <span class="badge badge-warning badge-outline">{{ $mistake['error_type'] }}</span>
+                                            @endif
+                                            @if(isset($mistake['correct']))
+                                                <span class="badge {{ $mistake['correct'] ? 'badge-success' : 'badge-error' }}">
+                                                    {{ $mistake['correct'] ? '已掌握' : '错误' }}
+                                                </span>
+                                            @endif
+                                            @if(!empty($mistake['reviewed']))
+                                                <span class="badge badge-success badge-outline">已复习</span>
+                                            @endif
+                                            @if(!empty($mistake['favorite']))
+                                                <span class="badge badge-primary badge-outline">已收藏</span>
+                                            @endif
+                                        </div>
+                                    </div>
+
+                                    <div class="rounded-lg bg-slate-50 p-4 border border-slate-100">
+                                        <p class="text-sm font-semibold text-slate-800 mb-2">题干</p>
+                                        <div class="prose max-w-none question-content text-slate-800">
+                                            <x-math-render :content="$mistake['question']['stem'] ?? ($mistake['question']['content'] ?? '暂无题干')" class="text-base" />
+                                        </div>
+                                    </div>
+
+                                    <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
+                                        <div class="bg-white border border-slate-200 rounded-lg p-3">
+                                            <p class="text-xs text-slate-500 mb-1">学生作答</p>
+                                            <p class="text-sm text-slate-800 break-words">{{ $mistake['student_answer'] ?? '无' }}</p>
+                                        </div>
+                                        <div class="bg-white border border-slate-200 rounded-lg p-3">
+                                            <p class="text-xs text-slate-500 mb-1">正确答案</p>
+                                            <p class="text-sm text-emerald-700 break-words">
+                                                {{ $mistake['question']['answer'] ?? ($mistake['correct_answer'] ?? '未知') }}
+                                            </p>
+                                        </div>
+                                        <div class="bg-white border border-slate-200 rounded-lg p-3">
+                                            <p class="text-xs text-slate-500 mb-1">错误类型</p>
+                                            <p class="text-sm text-amber-700">
+                                                {{ $mistake['error_type'] ?? '未分类' }}
+                                            </p>
+                                        </div>
+                                    </div>
+
+                                    <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+                                        <div class="bg-gradient-to-br from-amber-50 to-white border border-amber-100 rounded-lg p-4 space-y-2">
+                                            <p class="text-sm font-semibold text-amber-800">错误原因分析</p>
+                                            <p class="text-sm text-amber-700">{{ $mistake['ai_analysis']['reason'] ?? $mistake['ai_analysis'] ?? '暂无分析' }}</p>
+                                            <p class="text-sm font-semibold text-amber-800">对应技能</p>
+                                            <p class="text-sm text-amber-700">{{ $mistake['ai_analysis']['skill'] ?? ($mistake['skill_desc'] ?? '未识别') }}</p>
+                                        </div>
+                                        <div class="bg-gradient-to-br from-emerald-50 to-white border border-emerald-100 rounded-lg p-4 space-y-2">
+                                            <p class="text-sm font-semibold text-emerald-800">正确解法</p>
+                                            <p class="text-sm text-emerald-700">{{ $mistake['ai_analysis']['solution'] ?? '可向AI请求解析' }}</p>
+                                            <p class="text-sm font-semibold text-emerald-800">避免类似错误</p>
+                                            <p class="text-sm text-emerald-700">{{ $mistake['ai_analysis']['tip'] ?? ($mistake['ai_analysis']['suggestion'] ?? '加强审题与演算步骤复查') }}</p>
+                                        </div>
+                                    </div>
+
+                                    <div class="divider my-2"></div>
+                                    <div class="flex flex-wrap gap-2">
+                                        <button class="btn btn-sm btn-ghost" wire:click="toggleFavorite('{{ $mistake['id'] ?? '' }}')">
+                                            {{ !empty($mistake['favorite']) ? '取消收藏' : '收藏' }}
+                                        </button>
+                                        <button class="btn btn-sm btn-ghost" wire:click="markReviewed('{{ $mistake['id'] ?? '' }}')">
+                                            标记已复习
+                                        </button>
+                                        <button class="btn btn-sm btn-ghost" wire:click="addToRetryList('{{ $mistake['id'] ?? '' }}')">
+                                            加入重练清单
+                                        </button>
+                                        <button class="btn btn-sm btn-outline" wire:click="loadRelatedQuestions('{{ $mistake['id'] ?? '' }}')">
+                                            查看关联题
+                                        </button>
+                                    </div>
+
+                                    @if(!empty($relatedQuestions[$mistake['id'] ?? ''] ?? []))
+                                        <div class="bg-slate-50 border border-slate-200 rounded-lg p-3 space-y-2">
+                                            <p class="text-sm font-semibold text-slate-800">关联题目</p>
+                                            <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
+                                                @foreach($relatedQuestions[$mistake['id']] as $related)
+                                                    <div class="p-3 bg-white border border-slate-200 rounded-lg">
+                                                        <p class="text-xs text-slate-400 mb-1">ID: {{ $related['id'] ?? '' }}</p>
+                                                        <p class="text-sm text-slate-800 overflow-hidden max-h-14">{{ $related['stem'] ?? $related['content'] ?? '相关题目' }}</p>
+                                                        <p class="text-xs text-slate-500 mt-2">
+                                                            难度: {{ $related['difficulty'] ?? '中等' }}
+                                                            @if(!empty($related['kp_codes']))
+                                                                · KP: {{ is_array($related['kp_codes']) ? implode(',', $related['kp_codes']) : $related['kp_codes'] }}
+                                                            @endif
+                                                        </p>
+                                                    </div>
+                                                @endforeach
+                                            </div>
+                                        </div>
+                                    @endif
+                                </div>
+                            @endforeach
+                        </div>
+                    @endif
+                </div>
+
+                @if(!empty($recommendations))
+                    <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 space-y-4">
+                        <div class="flex items-center justify-between">
+                            <div>
+                                <h3 class="text-lg font-semibold text-slate-900">重练题单</h3>
+                                <p class="text-sm text-slate-500">基于错题推荐的新题,支持导出</p>
+                            </div>
+                            <a href="{{ url('/admin/question-management') }}" class="btn btn-outline btn-sm" target="_blank">打开题库</a>
+                        </div>
+                        <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+                            @foreach($recommendations as $rec)
+                                <div class="p-4 border border-slate-200 rounded-lg bg-slate-50">
+                                    <p class="text-xs text-slate-400 mb-1">题目 ID: {{ $rec['id'] ?? '' }}</p>
+                                    <p class="text-sm text-slate-800 overflow-hidden max-h-16">{{ $rec['stem'] ?? $rec['content'] ?? '推荐题目' }}</p>
+                                    <div class="flex flex-wrap gap-2 mt-2">
+                                        @if(!empty($rec['kp_codes']))
+                                            <span class="badge badge-ghost">KP {{ is_array($rec['kp_codes']) ? implode(',', $rec['kp_codes']) : $rec['kp_codes'] }}</span>
+                                        @endif
+                                        @if(!empty($rec['skills']))
+                                            <span class="badge badge-outline badge-info">{{ is_array($rec['skills']) ? implode(',', $rec['skills']) : $rec['skills'] }}</span>
+                                        @endif
+                                    </div>
+                                </div>
+                            @endforeach
+                        </div>
+                    </div>
+                @endif
+
+                <div class="rounded-2xl bg-white border border-slate-200 shadow-sm p-5 space-y-6">
+                    <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+                        <div>
+                            <h3 class="text-lg font-semibold text-slate-900">智能分析</h3>
+                            <p class="text-sm text-slate-500">错误类型雷达图 · 弱点技能排名 · 薄弱知识点</p>
+                        </div>
+                        <button class="btn btn-ghost btn-sm" wire:click="refreshPatterns">
+                            重新拉取
+                        </button>
+                    </div>
+
+                    <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
+                        @if(!empty($patterns['error_types']))
+                            <div class="bg-slate-50 rounded-xl border border-slate-200 p-4">
+                                <div class="flex items-center justify-between mb-3">
+                                    <h4 class="font-semibold text-slate-800">错误类型雷达图</h4>
+                                    <span class="badge badge-ghost">AI</span>
+                                </div>
+                                <canvas id="mistakeRadarChart" class="w-full h-64"></canvas>
+                            </div>
+                        @endif
+                        @if(!empty($patterns['top_skills']))
+                            <div class="bg-slate-50 rounded-xl border border-slate-200 p-4">
+                                <div class="flex items-center justify-between mb-3">
+                                    <h4 class="font-semibold text-slate-800">弱点技能排名</h4>
+                                    <span class="badge badge-warning badge-outline">Top</span>
+                                </div>
+                                <canvas id="skillBarChart" class="w-full h-64"></canvas>
+                            </div>
+                        @endif
+                    </div>
+
+                    @if(!empty($patterns['top_kps']))
+                        <div class="bg-slate-50 rounded-xl border border-slate-200 p-4">
+                            <h4 class="font-semibold text-slate-800 mb-3">薄弱知识点热力</h4>
+                            <div class="space-y-2">
+                                @foreach(($patterns['top_kps'] ?? []) as $kp)
+                                    <div>
+                                        <div class="flex items-center justify-between text-sm text-slate-700">
+                                            <span>{{ $kp['name'] ?? ($kp['kp'] ?? $kp['kp_code'] ?? '知识点') }}</span>
+                                            <span class="text-xs text-slate-500">错误 {{ $kp['count'] ?? $kp['mistake_count'] ?? 0 }}</span>
+                                        </div>
+                                        @php
+                                            $score = ($kp['score'] ?? $kp['accuracy'] ?? 0);
+                                            if ($score > 1) $score = $score / 100;
+                                            $width = max(10, min(100, (1 - (float) $score) * 100));
+                                        @endphp
+       				            <progress class="progress progress-error w-full" value="{{ $width }}" max="100"></progress>
+                                    </div>
+                                @endforeach
+                            </div>
+                        </div>
+                    @endif
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+@push('scripts')
+    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+    <script>
+        document.addEventListener('livewire:navigated', initMistakeCharts);
+        document.addEventListener('livewire:load', initMistakeCharts);
+
+        function initMistakeCharts() {
+            const radarCanvas = document.getElementById('mistakeRadarChart');
+            const barCanvas = document.getElementById('skillBarChart');
+
+            if ((!radarCanvas && !barCanvas)) {
+                return;
+            }
+
+            const errorTypes = @json($patterns['error_types'] ?? []);
+            const skills = @json($patterns['top_skills'] ?? []);
+
+            const radarLabels = errorTypes.map(e => e.name || e.type || '错误');
+            const radarData = errorTypes.map(e => e.count || e.value || 0);
+
+            const skillLabels = skills.map(s => s.name || s.skill || '技能');
+            const skillData = skills.map(s => s.score || s.count || 0);
+
+            if (window.mistakeRadarChart) {
+                window.mistakeRadarChart.destroy();
+            }
+            if (window.skillBarChart) {
+                window.skillBarChart.destroy();
+            }
+
+            if (radarCanvas && radarLabels.length) {
+                window.mistakeRadarChart = new Chart(radarCanvas.getContext('2d'), {
+                    type: 'radar',
+                    data: {
+                        labels: radarLabels,
+                        datasets: [{
+                            label: '错误频次',
+                            data: radarData,
+                            backgroundColor: 'rgba(248, 180, 0, 0.2)',
+                            borderColor: '#f59e0b',
+                            borderWidth: 2,
+                            pointBackgroundColor: '#f97316'
+                        }]
+                    },
+                    options: {
+                        plugins: { legend: { display: false } },
+                        scales: {
+                            r: {
+                                beginAtZero: true,
+                                ticks: { stepSize: 1 }
+                            }
+                        }
+                    }
+                });
+            }
+
+            if (barCanvas && skillLabels.length) {
+                window.skillBarChart = new Chart(barCanvas.getContext('2d'), {
+                    type: 'bar',
+                    data: {
+                        labels: skillLabels,
+                        datasets: [{
+                            label: '弱点指数',
+                            data: skillData,
+                            backgroundColor: 'rgba(59, 130, 246, 0.2)',
+                            borderColor: '#3b82f6',
+                            borderWidth: 1,
+                            borderRadius: 6
+                        }]
+                    },
+                    options: {
+                        plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
+                        scales: {
+                            x: { ticks: { color: '#475569' } },
+                            y: { beginAtZero: true }
+                        }
+                    }
+                });
+            }
+        }
+    </script>
+@endpush

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

@@ -183,4 +183,73 @@
             @endif
         </div>
     </div>
+
+    {{-- 创建/编辑模态框 --}}
+    @if($showPromptModal)
+        <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
+            <div class="bg-white rounded-xl shadow-xl w-full max-w-3xl p-6">
+                <div class="flex items-center justify-between mb-4">
+                    <h3 class="text-lg font-semibold">
+                        {{ $isEditing ? '编辑提示词' : '新建提示词' }}
+                    </h3>
+                    <button wire:click="$set('showPromptModal', false)" class="text-gray-500 hover:text-gray-700">&times;</button>
+                </div>
+
+                <div class="space-y-4">
+                    @unless($isEditing)
+                        <div>
+                            <label class="block text-sm font-medium text-gray-700 mb-1">模板名称</label>
+                            <input type="text" wire:model="form.template_name" class="w-full border rounded px-3 py-2 text-sm">
+                        </div>
+                    @endunless
+
+                    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+                        <div>
+                            <label class="block text-sm font-medium text-gray-700 mb-1">模板类型</label>
+                            <select wire:model="form.template_type" class="w-full border rounded px-3 py-2 text-sm">
+                                <option value="question_generation">题目生成</option>
+                                <option value="掌握度评估">掌握度评估</option>
+                                <option value="技能熟练度">技能熟练度</option>
+                                <option value="质量审核">质量审核</option>
+                            </select>
+                        </div>
+                        <div>
+                            <label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
+                            <select wire:model="form.is_active" class="w-full border rounded px-3 py-2 text-sm">
+                                <option value="yes">启用</option>
+                                <option value="no">禁用</option>
+                            </select>
+                        </div>
+                    </div>
+
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700 mb-1">模板内容</label>
+                        <textarea wire:model="form.template_content" rows="10" class="w-full border rounded px-3 py-2 text-sm font-mono"></textarea>
+                        <p class="text-xs text-gray-500 mt-1">可使用占位符:{kp_code}, {skills}, {count}, {basic_ratio}, {intermediate_ratio}, {advanced_ratio}, {type_mix}, {tags}, {skills_short}</p>
+                    </div>
+
+                    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+                        <div>
+                            <label class="block text-sm font-medium text-gray-700 mb-1">变量(JSON)</label>
+                            <input type="text" wire:model="form.variables" class="w-full border rounded px-3 py-2 text-sm" placeholder='["kp_code","skills"]'>
+                        </div>
+                        <div>
+                            <label class="block text-sm font-medium text-gray-700 mb-1">标签</label>
+                            <input type="text" wire:model="form.tags" class="w-full border rounded px-3 py-2 text-sm" placeholder="question_generation,math">
+                        </div>
+                    </div>
+
+                    <div>
+                        <label class="block text-sm font-medium text-gray-700 mb-1">描述</label>
+                        <input type="text" wire:model="form.description" class="w-full border rounded px-3 py-2 text-sm" placeholder="用于题目生成的模板">
+                    </div>
+                </div>
+
+                <div class="mt-6 flex justify-end gap-3">
+                    <button wire:click="$set('showPromptModal', false)" class="px-4 py-2 border rounded text-sm">取消</button>
+                    <button wire:click="savePrompt" class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">保存</button>
+                </div>
+            </div>
+        </div>
+    @endif
 </x-filament-panels::page>

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

@@ -99,6 +99,27 @@
                 <input type="number" wire:model="questionCount" min="1" max="500" class="w-full border rounded p-2">
             </div>
 
+            <!-- 提示词模板 -->
+            <div>
+                <div class="flex items-center justify-between mb-2">
+                    <label class="block text-sm font-medium text-gray-700">
+                        提示词模板
+                    </label>
+                    <a href="{{ route('filament.admin.pages.prompt-management') }}"
+                       class="text-xs text-blue-600 hover:underline"
+                       target="_blank">
+                        去提示词管理
+                    </a>
+                </div>
+                <select wire:model="promptTemplate" class="w-full border rounded p-2">
+                    <option value="">使用默认模板</option>
+                    @foreach($this->promptOptions as $name => $label)
+                        <option value="{{ $name }}">{{ $label }}</option>
+                    @endforeach
+                </select>
+                <p class="text-xs text-gray-500 mt-1">仅展示激活的“题目生成”模板,后台调整后可即时生效。</p>
+            </div>
+
             <!-- 生成按钮 -->
             <div class="flex justify-end pt-4">
                 <button

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

@@ -398,11 +398,4 @@
     </script>
 </div>
 
-@push('scripts')
-    <script src="/js/math-render.js"></script>
-@endpush
-
-@push('styles')
-    <link rel="stylesheet" href="/css/katex/katex.min.css">
-@endpush
 </x-filament-panels::page>

+ 3 - 0
resources/views/filament/pages/student-dashboard.blade.php

@@ -654,6 +654,9 @@
                     @endif
                 </div>
             </div>
+            <div class="xl:col-span-3">
+                <x-mistake-book-panel :data="$mistakePanel" :student-id="$studentId" :teacher-id="$teacherId" />
+            </div>
             </div>
     </div>
 </div>

+ 1 - 1
routes/api.php

@@ -53,7 +53,7 @@ Route::post('/questions/callback', function () {
         Log::error('Callback processing failed: ' . $e->getMessage());
         return response()->json(['error' => $e->getMessage()], 500);
     }
-})->name('api.questions.callback.post');
+})->name('api.questions.callback');
 
 // 接收OCR题目生成回调
 Route::post('/ocr-question-callback', function () {