count(); $correct = (clone $attempts)->sum('correct_count'); $questions = (clone $attempts)->sum('total_questions'); return ApiResponse::success([ 'users' => User::query()->count(), 'questions' => Question::query()->count(), 'attempts' => $total, 'wrong_questions' => WrongQuestion::query()->whereNull('mastered_at')->count(), 'accuracy' => $questions > 0 ? round($correct / $questions * 100, 2) : 0, ]); } #[Apidoc\Title('练习趋势')] #[Apidoc\Url('/admin/reports/trends')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function trends(): JsonResponse { $rows = QuizAttempt::query() ->selectRaw('date(started_at) as day, count(*) as attempts, sum(correct_count) as correct_count, sum(total_questions) as total_questions') ->where('started_at', '>=', now()->subDays(14)) ->groupBy('day') ->orderBy('day') ->get(); return ApiResponse::success($rows); } #[Apidoc\Title('题目错误率')] #[Apidoc\Url('/admin/reports/question-errors')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function questionErrors(Request $request): JsonResponse { $rows = DB::table('quiz_attempt_questions') ->join('questions', 'questions.id', '=', 'quiz_attempt_questions.question_id') ->selectRaw('questions.id, questions.content, count(*) as attempts, sum(case when is_correct = 0 then 1 else 0 end) as wrong_count') ->groupBy('questions.id', 'questions.content') ->orderByDesc('wrong_count') ->paginate((int) $request->query('per_page', 20)); return ApiResponse::page($rows); } #[Apidoc\Title('班级排行')] #[Apidoc\Url('/admin/reports/class-ranking')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function classRanking(Request $request): JsonResponse { $rows = DB::table('classes') ->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id') ->leftJoin('quiz_attempts', 'quiz_attempts.user_id', '=', 'class_members.user_id') ->when($request->user()->role !== 'admin', fn ($query) => $query->where('classes.owner_id', $request->user()->id)) ->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count, count(distinct quiz_attempts.id) as attempts, coalesce(sum(quiz_attempts.correct_count), 0) as correct_count, coalesce(sum(quiz_attempts.total_questions), 0) as total_questions') ->groupBy('classes.id', 'classes.name') ->orderByDesc('attempts') ->limit(20) ->get() ->map(function (object $row): array { $totalQuestions = (int) $row->total_questions; return [ 'id' => (int) $row->id, 'name' => $row->name, 'members_count' => (int) $row->members_count, 'attempts' => (int) $row->attempts, 'correct_count' => (int) $row->correct_count, 'total_questions' => $totalQuestions, 'accuracy' => $totalQuestions > 0 ? round((int) $row->correct_count / $totalQuestions * 100, 2) : 0, ]; }); return ApiResponse::success($rows); } #[Apidoc\Title('题库和分类掌握度')] #[Apidoc\Url('/admin/reports/mastery')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function mastery(Request $request): JsonResponse { $query = DB::table('quiz_attempt_questions') ->join('quiz_attempts', 'quiz_attempts.id', '=', 'quiz_attempt_questions.quiz_attempt_id') ->join('questions', 'questions.id', '=', 'quiz_attempt_questions.question_id') ->join('question_banks', 'question_banks.id', '=', 'questions.question_bank_id') ->leftJoin('question_categories', 'question_categories.id', '=', 'questions.category_id') ->whereNotNull('quiz_attempt_questions.is_correct') ->when($request->user()->role !== 'admin', fn ($builder) => $builder->where('question_banks.owner_id', $request->user()->id)); $banks = (clone $query) ->selectRaw('question_banks.id, question_banks.name, count(*) as attempts, sum(case when quiz_attempt_questions.is_correct = 1 then 1 else 0 end) as correct_count') ->groupBy('question_banks.id', 'question_banks.name') ->orderByDesc('attempts') ->limit(20) ->get() ->map(fn (object $row): array => $this->masteryRow($row)); $categories = (clone $query) ->selectRaw('question_banks.name as bank_name, question_categories.id, coalesce(question_categories.name, "未分类") as name, count(*) as attempts, sum(case when quiz_attempt_questions.is_correct = 1 then 1 else 0 end) as correct_count') ->groupBy('question_banks.name', 'question_categories.id', 'question_categories.name') ->orderByDesc('attempts') ->limit(30) ->get() ->map(fn (object $row): array => $this->masteryRow($row) + ['bank_name' => $row->bank_name]); return ApiResponse::success([ 'banks' => $banks, 'categories' => $categories, ]); } #[Apidoc\Title('报表导出')] #[Apidoc\Url('/admin/reports/export')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function export(Request $request): JsonResponse { $payload = [ 'overview' => $this->overview()->getData(true)['data'], 'trends' => $this->trends()->getData(true)['data'], 'class_ranking' => $this->classRanking($request)->getData(true)['data'], 'mastery' => $this->mastery($request)->getData(true)['data'], ]; $path = 'exports/report-'.now()->format('YmdHis').'.json'; Storage::put($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $job = ExportJob::create([ 'user_id' => $request->user()->id, 'type' => 'report', 'file_path' => $path, 'payload' => $payload, ]); return ApiResponse::success($job, '报表已导出'); } private function masteryRow(object $row): array { $attempts = (int) $row->attempts; $correct = (int) $row->correct_count; return [ 'id' => $row->id === null ? null : (int) $row->id, 'name' => $row->name, 'attempts' => $attempts, 'correct_count' => $correct, 'accuracy' => $attempts > 0 ? round($correct / $attempts * 100, 2) : 0, ]; } }