184 lines
7.7 KiB
PHP
184 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ExportJob;
|
|
use App\Models\Question;
|
|
use App\Models\QuizAttempt;
|
|
use App\Models\User;
|
|
use App\Models\WrongQuestion;
|
|
use App\Support\ApiResponse;
|
|
use hg\apidoc\annotation as Apidoc;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
#[Apidoc\Group('后台')]
|
|
#[Apidoc\Title('统计报表')]
|
|
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
|
final class ReportController extends Controller
|
|
{
|
|
#[Apidoc\Title('报表概览')]
|
|
#[Apidoc\Url('/admin/reports/overview')]
|
|
#[Apidoc\Method('GET')]
|
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
|
public function overview(): JsonResponse
|
|
{
|
|
$attempts = QuizAttempt::query();
|
|
$total = (clone $attempts)->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,
|
|
];
|
|
}
|
|
}
|