diff --git a/.gitignore b/.gitignore index 7be55e2..0e606e7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .phpunit.result.cache /.codex /.cursor/ +/.agents/ /.idea /.nova /.phpunit.cache diff --git a/app/Http/Controllers/Api/Admin/ReportController.php b/app/Http/Controllers/Api/Admin/ReportController.php index 14c088d..48a0b12 100644 --- a/app/Http/Controllers/Api/Admin/ReportController.php +++ b/app/Http/Controllers/Api/Admin/ReportController.php @@ -6,15 +6,12 @@ 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\Services\ReportQueryService; 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('后台')] @@ -22,138 +19,125 @@ use Illuminate\Support\Facades\Storage; #[Apidoc\RouteMiddleware(['jwt.auth'])] final class ReportController extends Controller { + #[Apidoc\Title('报表筛选选项')] + #[Apidoc\Url('/admin/reports/options')] + #[Apidoc\Method('GET')] + #[Apidoc\RouteMiddleware(['permission:reports'])] + public function options(Request $request, ReportQueryService $service): JsonResponse + { + $filters = $service->filters($request->query()); + + return ApiResponse::success($service->options($request->user(), $filters)); + } + #[Apidoc\Title('报表概览')] #[Apidoc\Url('/admin/reports/overview')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function overview(): JsonResponse + public function overview(Request $request, ReportQueryService $service): JsonResponse { - $attempts = QuizAttempt::query(); - $total = (clone $attempts)->count(); - $correct = (clone $attempts)->sum('correct_count'); - $questions = (clone $attempts)->sum('total_questions'); + $filters = $service->filters($request->query()); - 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, - ]); + return ApiResponse::success($service->overview($request->user(), $filters)); } #[Apidoc\Title('练习趋势')] #[Apidoc\Url('/admin/reports/trends')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function trends(): JsonResponse + public function trends(Request $request, ReportQueryService $service): 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(); + $filters = $service->filters($request->query()); - return ApiResponse::success($rows); + return ApiResponse::success($service->trends($request->user(), $filters)); } #[Apidoc\Title('题目错误率')] #[Apidoc\Url('/admin/reports/question-errors')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function questionErrors(Request $request): JsonResponse + public function questionErrors(Request $request, ReportQueryService $service): 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)); + $filters = $service->filters($request->query()); - return ApiResponse::page($rows); + return ApiResponse::page($service->questionErrors($request->user(), $filters, (int) $request->query('per_page', 20))); } #[Apidoc\Title('班级排行')] #[Apidoc\Url('/admin/reports/class-ranking')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function classRanking(Request $request): JsonResponse + public function classRanking(Request $request, ReportQueryService $service): 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; + $filters = $service->filters($request->query()); - 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); + return ApiResponse::success($service->classRanking($request->user(), $filters)); } #[Apidoc\Title('题库和分类掌握度')] #[Apidoc\Url('/admin/reports/mastery')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function mastery(Request $request): JsonResponse + public function mastery(Request $request, ReportQueryService $service): 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)); + $filters = $service->filters($request->query()); - $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)); + return ApiResponse::success($service->mastery($request->user(), $filters)); + } - $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]); + #[Apidoc\Title('学生统计')] + #[Apidoc\Url('/admin/reports/students')] + #[Apidoc\Method('GET')] + #[Apidoc\RouteMiddleware(['permission:reports'])] + public function students(Request $request, ReportQueryService $service): JsonResponse + { + $filters = $service->filters($request->query()); - return ApiResponse::success([ - 'banks' => $banks, - 'categories' => $categories, - ]); + return ApiResponse::page($service->students($request->user(), $filters, (int) $request->query('per_page', 20))); + } + + #[Apidoc\Title('学生统计详情')] + #[Apidoc\Url('/admin/reports/students/{user}')] + #[Apidoc\Method('GET')] + #[Apidoc\RouteMiddleware(['permission:reports'])] + public function studentDetail(Request $request, mixed $user, ReportQueryService $service): JsonResponse + { + $student = User::query()->findOrFail((int) $user); + $filters = $service->filters($request->query()); + + return ApiResponse::success($service->studentDetail($request->user(), $student, $filters)); + } + + #[Apidoc\Title('题库统计详情')] + #[Apidoc\Url('/admin/reports/banks/{bank}')] + #[Apidoc\Method('GET')] + #[Apidoc\RouteMiddleware(['permission:reports'])] + public function bankDetail(Request $request, mixed $bank, ReportQueryService $service): JsonResponse + { + $filters = $service->filters($request->query()); + + return ApiResponse::success($service->bankDetail($request->user(), (int) $bank, $filters)); + } + + #[Apidoc\Title('重点关注建议')] + #[Apidoc\Url('/admin/reports/insights')] + #[Apidoc\Method('GET')] + #[Apidoc\RouteMiddleware(['permission:reports'])] + public function insights(Request $request, ReportQueryService $service): JsonResponse + { + $filters = $service->filters($request->query()); + + return ApiResponse::success($service->insights($request->user(), $filters)); } #[Apidoc\Title('报表导出')] #[Apidoc\Url('/admin/reports/export')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:reports'])] - public function export(Request $request): JsonResponse + public function export(Request $request, ReportQueryService $service): 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'], - ]; + $filters = $service->filters($request->all()); + $payload = $service->exportPayload($request->user(), $filters); $path = 'exports/report-'.now()->format('YmdHis').'.json'; Storage::put($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); @@ -166,18 +150,4 @@ final class ReportController extends Controller 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, - ]; - } } diff --git a/app/Services/ReportQueryService.php b/app/Services/ReportQueryService.php new file mode 100644 index 0000000..3079e5e --- /dev/null +++ b/app/Services/ReportQueryService.php @@ -0,0 +1,685 @@ + $input + * @return array + */ + public function filters(array $input): array + { + $from = filled($input['date_from'] ?? null) + ? CarbonImmutable::parse((string) $input['date_from'])->startOfDay() + : now()->subDays(6)->startOfDay()->toImmutable(); + $to = filled($input['date_to'] ?? null) + ? CarbonImmutable::parse((string) $input['date_to'])->endOfDay() + : now()->endOfDay()->toImmutable(); + + return [ + 'date_from' => $from, + 'date_to' => $to, + 'class_id' => $this->nullableInt($input['class_id'] ?? null), + 'user_id' => $this->nullableInt($input['user_id'] ?? null), + 'question_bank_id' => $this->nullableInt($input['question_bank_id'] ?? null), + 'category_id' => $this->nullableInt($input['category_id'] ?? null), + 'tag_id' => $this->nullableInt($input['tag_id'] ?? null), + 'type' => filled($input['type'] ?? null) ? (string) $input['type'] : null, + 'mode' => filled($input['mode'] ?? null) ? (string) $input['mode'] : null, + ]; + } + + /** + * @param array $filters + * @return array + */ + public function overview(User $user, array $filters): array + { + $answered = $this->answeredItems($user, $filters) + ->selectRaw('count(*) as answered_count') + ->selectRaw('count(distinct qa.id) as attempts') + ->selectRaw('count(distinct qa.user_id) as active_students') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') + ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') + ->first(); + $totalItems = $this->allItems($user, $filters)->count(); + $studentIds = $this->scopedStudentIds($user, $filters); + $wrongBase = $this->wrongQuestions($user, $filters); + $answeredCount = (int) ($answered->answered_count ?? 0); + $correctCount = (int) ($answered->correct_count ?? 0); + + return [ + 'students' => $studentIds->count(), + 'active_students' => (int) ($answered->active_students ?? 0), + 'attempts' => (int) ($answered->attempts ?? 0), + 'answered_count' => $answeredCount, + 'accuracy' => $this->percent($correctCount, $answeredCount), + 'completion_rate' => $this->percent($answeredCount, $totalItems), + 'avg_duration_seconds' => round((float) ($answered->avg_duration_seconds ?? 0), 1), + 'wrong_questions' => (clone $wrongBase)->whereNull('wrong_questions.mastered_at')->count(), + 'mastered_wrong_questions' => (clone $wrongBase)->whereNotNull('wrong_questions.mastered_at')->count(), + ]; + } + + /** + * @param array $filters + * @return array> + */ + public function trends(User $user, array $filters): array + { + $rows = $this->answeredItems($user, $filters) + ->selectRaw('date(qaq.answered_at) as day') + ->selectRaw('count(distinct qa.id) as attempts') + ->selectRaw('count(distinct qa.user_id) as active_students') + ->selectRaw('count(*) as answered_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') + ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') + ->groupBy('day') + ->orderBy('day') + ->get() + ->keyBy('day'); + $wrongRows = $this->wrongQuestions($user, $filters) + ->whereBetween('wrong_questions.last_wrong_at', [$filters['date_from'], $filters['date_to']]) + ->selectRaw('date(wrong_questions.last_wrong_at) as day, count(*) as wrong_added') + ->groupBy('day') + ->pluck('wrong_added', 'day'); + + $days = []; + $cursor = CarbonImmutable::parse($filters['date_from'])->startOfDay(); + $end = CarbonImmutable::parse($filters['date_to'])->startOfDay(); + while ($cursor <= $end) { + $day = $cursor->toDateString(); + $row = $rows->get($day); + $answeredCount = (int) ($row->answered_count ?? 0); + $correctCount = (int) ($row->correct_count ?? 0); + $days[] = [ + 'day' => $day, + 'attempts' => (int) ($row->attempts ?? 0), + 'active_students' => (int) ($row->active_students ?? 0), + 'answered_count' => $answeredCount, + 'accuracy' => $this->percent($correctCount, $answeredCount), + 'avg_duration_seconds' => round((float) ($row->avg_duration_seconds ?? 0), 1), + 'wrong_added' => (int) ($wrongRows[$day] ?? 0), + ]; + $cursor = $cursor->addDay(); + } + + return $days; + } + + /** + * @param array $filters + */ + public function questionErrors(User $user, array $filters, int $perPage = 20): LengthAwarePaginator + { + return $this->questionErrorQuery($user, $filters) + ->paginate($perPage) + ->through(fn (object $row): array => $this->questionErrorRow($row)); + } + + /** + * @param array $filters + * @return array> + */ + public function classRanking(User $user, array $filters): array + { + return $this->visibleClasses($user, $filters) + ->limit(20) + ->get() + ->map(function (object $class) use ($user, $filters): array { + $classFilters = $filters + ['class_id' => (int) $class->id]; + $classFilters['class_id'] = (int) $class->id; + $overview = $this->overview($user, $classFilters); + + return [ + 'id' => (int) $class->id, + 'name' => $class->name, + 'members_count' => (int) $class->members_count, + 'attempts' => $overview['attempts'], + 'answered_count' => $overview['answered_count'], + 'completion_rate' => $overview['completion_rate'], + 'avg_duration_seconds' => $overview['avg_duration_seconds'], + 'accuracy' => $overview['accuracy'], + ]; + }) + ->sortByDesc('answered_count') + ->values() + ->all(); + } + + /** + * @param array $filters + * @return array + */ + public function mastery(User $user, array $filters): array + { + return [ + 'banks' => $this->dimensionRows($user, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 20), + 'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, [ + 'bank_name' => 'qb.name', + ]), + 'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10), + 'tags' => $this->tagRows($user, $filters, 30), + ]; + } + + /** + * @param array $filters + */ + public function students(User $user, array $filters, int $perPage = 20): LengthAwarePaginator + { + return $this->answeredItems($user, $filters) + ->join('users as u', 'u.id', '=', 'qa.user_id') + ->leftJoin('wrong_questions as wq', function ($join): void { + $join->on('wq.user_id', '=', 'u.id') + ->on('wq.question_id', '=', 'q.id') + ->whereNull('wq.mastered_at'); + }) + ->selectRaw('u.id, u.name, u.email') + ->selectRaw('count(distinct qa.id) as attempts') + ->selectRaw('count(*) as answered_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') + ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') + ->selectRaw('count(distinct wq.id) as wrong_questions') + ->selectRaw('max(qaq.answered_at) as last_answered_at') + ->groupBy('u.id', 'u.name', 'u.email') + ->orderByDesc('answered_count') + ->paginate($perPage) + ->through(fn (object $row): array => $this->studentRow($row)); + } + + /** + * @param array $filters + * @return array + */ + public function studentDetail(User $viewer, User $student, array $filters): array + { + $filters['user_id'] = $student->id; + abort_if(! $this->canViewStudent($viewer, $student->id), 403); + + return [ + 'student' => ['id' => $student->id, 'name' => $student->name, 'email' => $student->email], + 'overview' => $this->overview($viewer, $filters), + 'banks' => $this->dimensionRows($viewer, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 10), + 'categories' => $this->dimensionRows($viewer, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 10, ['bank_name' => 'qb.name']), + 'types' => $this->dimensionRows($viewer, $filters, 'q.type', 'q.type', ['q.type'], 10), + 'question_errors' => $this->questionErrorQuery($viewer, $filters)->limit(10)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(), + ]; + } + + /** + * @param array $filters + * @return array + */ + public function bankDetail(User $user, int $bankId, array $filters): array + { + $filters['question_bank_id'] = $bankId; + abort_if(! $this->canViewBank($user, $bankId), 403); + + return [ + 'overview' => $this->overview($user, $filters), + 'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name'], 20), + 'tags' => $this->tagRows($user, $filters, 20), + 'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10), + 'question_errors' => $this->questionErrorQuery($user, $filters)->limit(20)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(), + ]; + } + + /** + * @param array $filters + * @return array + */ + public function insights(User $user, array $filters): array + { + $students = $this->students($user, $filters, 100)->getCollection() + ->filter(fn (array $row): bool => $row['accuracy'] < 60 && $row['answered_count'] >= 10) + ->take(5) + ->values() + ->all(); + $questions = $this->questionErrorQuery($user, $filters) + ->havingRaw('wrong_count >= 3') + ->limit(5) + ->get() + ->map(fn (object $row): array => $this->questionErrorRow($row)) + ->filter(fn (array $row): bool => $row['wrong_rate'] > 50 && $row['attempts'] >= 5) + ->values() + ->all(); + $categories = collect($this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, ['bank_name' => 'qb.name'])) + ->filter(fn (array $row): bool => $row['accuracy'] < 65 && $row['answered_count'] >= 10) + ->take(5) + ->values() + ->all(); + + return [ + 'students' => $students, + 'questions' => $questions, + 'categories' => $categories, + ]; + } + + /** + * @param array $filters + * @return array + */ + public function options(User $user, array $filters): array + { + $bankQuery = DB::table('question_banks') + ->select('id', 'name') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id)) + ->orderBy('name'); + $classQuery = DB::table('classes') + ->select('id', 'name') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id)) + ->orderBy('name'); + $studentIds = $this->scopedStudentIds($user, $filters); + + return [ + 'banks' => $bankQuery->get(), + 'classes' => $classQuery->get(), + 'students' => User::query()->select('id', 'name', 'email')->whereIn('id', $studentIds)->orderBy('name')->limit(200)->get(), + 'categories' => DB::table('question_categories') + ->join('question_banks', 'question_banks.id', '=', 'question_categories.question_bank_id') + ->select('question_categories.id', 'question_categories.name', 'question_categories.question_bank_id') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id)) + ->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_categories.question_bank_id', $bankId)) + ->orderBy('question_categories.name') + ->get(), + 'tags' => DB::table('question_tags') + ->join('question_banks', 'question_banks.id', '=', 'question_tags.question_bank_id') + ->select('question_tags.id', 'question_tags.name', 'question_tags.question_bank_id') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id)) + ->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_tags.question_bank_id', $bankId)) + ->orderBy('question_tags.name') + ->get(), + ]; + } + + /** + * @param array $filters + * @return array + */ + public function exportPayload(User $user, array $filters): array + { + return [ + 'filters' => [ + 'date_from' => CarbonImmutable::parse($filters['date_from'])->toDateString(), + 'date_to' => CarbonImmutable::parse($filters['date_to'])->toDateString(), + ] + collect($filters)->except(['date_from', 'date_to'])->filter()->all(), + 'overview' => $this->overview($user, $filters), + 'trends' => $this->trends($user, $filters), + 'class_ranking' => $this->classRanking($user, $filters), + 'students' => $this->students($user, $filters, 1000)->items(), + 'mastery' => $this->mastery($user, $filters), + 'question_errors' => $this->questionErrors($user, $filters, 1000)->items(), + 'insights' => $this->insights($user, $filters), + ]; + } + + /** + * @param array $filters + */ + private function answeredItems(User $user, array $filters): Builder + { + return $this->itemBase($user, $filters) + ->whereNotNull('qaq.answered_at') + ->whereBetween('qaq.answered_at', [$filters['date_from'], $filters['date_to']]); + } + + /** + * @param array $filters + */ + private function allItems(User $user, array $filters): Builder + { + return $this->itemBase($user, $filters) + ->whereBetween('qa.started_at', [$filters['date_from'], $filters['date_to']]); + } + + /** + * @param array $filters + */ + private function itemBase(User $user, array $filters): Builder + { + return DB::table('quiz_attempt_questions as qaq') + ->join('quiz_attempts as qa', 'qa.id', '=', 'qaq.quiz_attempt_id') + ->join('questions as q', 'q.id', '=', 'qaq.question_id') + ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') + ->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id') + ->whereNull('q.deleted_at') + ->whereNull('qb.deleted_at') + ->tap(fn (Builder $query) => $this->applyScope($query, $user)) + ->tap(fn (Builder $query) => $this->applyFilters($query, $filters)); + } + + private function applyScope(Builder $query, User $user): void + { + if ($user->role === 'admin') { + return; + } + + $query->where(function (Builder $scope) use ($user): void { + $scope->where('qb.owner_id', $user->id) + ->orWhereExists(function (Builder $exists) use ($user): void { + $exists->selectRaw('1') + ->from('class_members as scope_cm') + ->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id') + ->whereColumn('scope_cm.user_id', 'qa.user_id') + ->where('scope_c.owner_id', $user->id) + ->whereNull('scope_c.deleted_at'); + }); + }); + } + + /** + * @param array $filters + */ + private function applyFilters(Builder $query, array $filters): void + { + $query + ->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void { + $exists->selectRaw('1') + ->from('class_members as filter_cm') + ->whereColumn('filter_cm.user_id', 'qa.user_id') + ->where('filter_cm.class_id', $classId); + })) + ->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('qa.user_id', $userId)) + ->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId)) + ->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId)) + ->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void { + $exists->selectRaw('1') + ->from('question_tag as filter_qt') + ->whereColumn('filter_qt.question_id', 'q.id') + ->where('filter_qt.question_tag_id', $tagId); + })) + ->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type)) + ->when($filters['mode'] ?? null, fn (Builder $builder, string $mode) => $builder->where('qa.mode', $mode)); + } + + /** + * @param array $filters + */ + private function wrongQuestions(User $user, array $filters): Builder + { + return DB::table('wrong_questions') + ->join('questions as q', 'q.id', '=', 'wrong_questions.question_id') + ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') + ->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id') + ->whereNull('q.deleted_at') + ->whereNull('qb.deleted_at') + ->tap(fn (Builder $query) => $this->applyWrongScope($query, $user)) + ->tap(fn (Builder $query) => $this->applyWrongFilters($query, $filters)); + } + + private function applyWrongScope(Builder $query, User $user): void + { + if ($user->role === 'admin') { + return; + } + + $query->where(function (Builder $scope) use ($user): void { + $scope->where('qb.owner_id', $user->id) + ->orWhereExists(function (Builder $exists) use ($user): void { + $exists->selectRaw('1') + ->from('class_members as scope_cm') + ->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id') + ->whereColumn('scope_cm.user_id', 'wrong_questions.user_id') + ->where('scope_c.owner_id', $user->id) + ->whereNull('scope_c.deleted_at'); + }); + }); + } + + /** + * @param array $filters + */ + private function applyWrongFilters(Builder $query, array $filters): void + { + $query + ->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void { + $exists->selectRaw('1') + ->from('class_members as filter_cm') + ->whereColumn('filter_cm.user_id', 'wrong_questions.user_id') + ->where('filter_cm.class_id', $classId); + })) + ->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('wrong_questions.user_id', $userId)) + ->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId)) + ->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId)) + ->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void { + $exists->selectRaw('1') + ->from('question_tag as filter_qt') + ->whereColumn('filter_qt.question_id', 'q.id') + ->where('filter_qt.question_tag_id', $tagId); + })) + ->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type)); + } + + /** + * @param array $filters + */ + private function questionErrorQuery(User $user, array $filters): Builder + { + return $this->answeredItems($user, $filters) + ->selectRaw('q.id, q.content, q.type, qb.name as bank_name, coalesce(qc.name, "未分类") as category_name') + ->selectRaw('count(*) as attempts') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') + ->selectRaw('max(case when qaq.is_correct = 0 then qaq.answered_at else null end) as last_wrong_at') + ->groupBy('q.id', 'q.content', 'q.type', 'qb.name', 'qc.name') + ->orderByDesc('wrong_count') + ->orderByDesc('attempts'); + } + + /** + * @param array $filters + * @param array $groups + * @param array $extraSelects + * @return array> + */ + private function dimensionRows(User $user, array $filters, string $idExpression, string $nameExpression, array $groups, int $limit, array $extraSelects = []): array + { + $query = $this->answeredItems($user, $filters) + ->selectRaw($idExpression.' as id') + ->selectRaw($nameExpression.' as name'); + foreach ($extraSelects as $alias => $expression) { + $query->selectRaw($expression.' as '.$alias); + } + + return $query + ->selectRaw('count(*) as answered_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') + ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') + ->groupBy(...$groups) + ->orderByDesc('wrong_count') + ->orderByDesc('answered_count') + ->limit($limit) + ->get() + ->map(fn (object $row): array => $this->dimensionRow($row)) + ->all(); + } + + /** + * @param array $filters + * @return array> + */ + private function tagRows(User $user, array $filters, int $limit): array + { + return $this->answeredItems($user, $filters) + ->join('question_tag as qt', 'qt.question_id', '=', 'q.id') + ->join('question_tags as qtag', 'qtag.id', '=', 'qt.question_tag_id') + ->selectRaw('qtag.id as id, qtag.name as name, qb.name as bank_name') + ->selectRaw('count(*) as answered_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') + ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') + ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') + ->groupBy('qtag.id', 'qtag.name', 'qb.name') + ->orderByDesc('wrong_count') + ->orderByDesc('answered_count') + ->limit($limit) + ->get() + ->map(fn (object $row): array => $this->dimensionRow($row)) + ->all(); + } + + /** + * @param array $filters + */ + private function visibleClasses(User $user, array $filters): Builder + { + return DB::table('classes') + ->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id') + ->whereNull('classes.deleted_at') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id)) + ->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('classes.id', $classId)) + ->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count') + ->groupBy('classes.id', 'classes.name') + ->orderBy('classes.name'); + } + + /** + * @param array $filters + * @return Collection + */ + private function scopedStudentIds(User $user, array $filters): Collection + { + $ids = DB::table('quiz_attempts as qa') + ->join('quiz_attempt_questions as qaq', 'qaq.quiz_attempt_id', '=', 'qa.id') + ->join('questions as q', 'q.id', '=', 'qaq.question_id') + ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') + ->whereNotNull('qaq.answered_at') + ->tap(fn (Builder $query) => $this->applyScope($query, $user)) + ->tap(fn (Builder $query) => $this->applyFilters($query, $filters)) + ->distinct() + ->pluck('qa.user_id'); + + $classMemberQuery = DB::table('class_members') + ->join('classes', 'classes.id', '=', 'class_members.class_id') + ->whereNull('classes.deleted_at') + ->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id)) + ->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('class_members.class_id', $classId)); + + if (($filters['class_id'] ?? null) || $user->role !== 'admin') { + $classIds = $classMemberQuery + ->pluck('user_id'); + $ids = $ids->merge($classIds); + } + + return $ids->map(fn ($id): int => (int) $id)->unique()->values(); + } + + private function canViewStudent(User $viewer, int $studentId): bool + { + if ($viewer->role === 'admin') { + return true; + } + + return DB::table('class_members') + ->join('classes', 'classes.id', '=', 'class_members.class_id') + ->where('class_members.user_id', $studentId) + ->where('classes.owner_id', $viewer->id) + ->whereNull('classes.deleted_at') + ->exists(); + } + + private function canViewBank(User $viewer, int $bankId): bool + { + if ($viewer->role === 'admin') { + return true; + } + + return DB::table('question_banks') + ->where('id', $bankId) + ->where('owner_id', $viewer->id) + ->whereNull('deleted_at') + ->exists(); + } + + /** + * @return array + */ + private function dimensionRow(object $row): array + { + $answered = (int) $row->answered_count; + $correct = (int) $row->correct_count; + $data = [ + 'id' => $row->id === null ? null : (is_numeric($row->id) ? (int) $row->id : (string) $row->id), + 'name' => (string) $row->name, + 'answered_count' => $answered, + 'correct_count' => $correct, + 'wrong_count' => (int) $row->wrong_count, + 'accuracy' => $this->percent($correct, $answered), + 'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1), + ]; + + if (property_exists($row, 'bank_name')) { + $data['bank_name'] = $row->bank_name; + } + + return $data; + } + + /** + * @return array + */ + private function questionErrorRow(object $row): array + { + $attempts = (int) $row->attempts; + $wrong = (int) $row->wrong_count; + + return [ + 'id' => (int) $row->id, + 'content' => $row->content, + 'type' => $row->type, + 'bank_name' => $row->bank_name, + 'category_name' => $row->category_name, + 'attempts' => $attempts, + 'wrong_count' => $wrong, + 'wrong_rate' => $this->percent($wrong, $attempts), + 'last_wrong_at' => $row->last_wrong_at, + ]; + } + + /** + * @return array + */ + private function studentRow(object $row): array + { + $answered = (int) $row->answered_count; + $correct = (int) $row->correct_count; + + return [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'email' => $row->email, + 'attempts' => (int) $row->attempts, + 'answered_count' => $answered, + 'correct_count' => $correct, + 'accuracy' => $this->percent($correct, $answered), + 'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1), + 'wrong_questions' => (int) $row->wrong_questions, + 'last_answered_at' => $row->last_answered_at, + ]; + } + + private function percent(int|float $value, int|float $total): float + { + return $total > 0 ? round($value / $total * 100, 2) : 0.0; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return (int) $value; + } +} diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 4f321fc..d71120b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 7649a71..ff174d9 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,5 +1,26 @@ import { apiDelete, apiGet, apiPost, apiPut } from './http' -import type { ImportJob, PageData, Paper, Permission, Question, QuestionBank, SchoolClass, TaxonomyItem, User } from '@/types/api' +import type { + ImportJob, + PageData, + Paper, + Permission, + Question, + QuestionBank, + ReportBankDetail, + ReportDimension, + ReportFilters, + ReportInsights, + ReportMastery, + ReportOptions, + ReportOverview, + ReportQuestionError, + ReportStudent, + ReportStudentDetail, + ReportTrend, + SchoolClass, + TaxonomyItem, + User, +} from '@/types/api' export function fetchBanks(params?: Record) { return apiGet>('/api/admin/banks', params) @@ -189,31 +210,48 @@ export function saveSettings(settings: Record) { return apiPut('/api/admin/settings', { settings }) } -export function fetchReportOverview() { - return apiGet>('/api/admin/reports/overview') +export function fetchReportOptions(params?: ReportFilters) { + return apiGet('/api/admin/reports/options', params) } -export function fetchReportTrends() { - return apiGet>>('/api/admin/reports/trends') +export function fetchReportOverview(params?: ReportFilters) { + return apiGet('/api/admin/reports/overview', params) } -export function fetchQuestionErrors(params?: Record) { - return apiGet>>('/api/admin/reports/question-errors', params) +export function fetchReportTrends(params?: ReportFilters) { + return apiGet('/api/admin/reports/trends', params) } -export function fetchClassRanking() { - return apiGet>>('/api/admin/reports/class-ranking') +export function fetchQuestionErrors(params?: ReportFilters) { + return apiGet>('/api/admin/reports/question-errors', params) } -export function fetchMastery() { - return apiGet<{ - banks: Array> - categories: Array> - }>('/api/admin/reports/mastery') +export function fetchClassRanking(params?: ReportFilters) { + return apiGet>('/api/admin/reports/class-ranking', params) } -export function exportReport() { - return apiPost('/api/admin/reports/export') +export function fetchMastery(params?: ReportFilters) { + return apiGet('/api/admin/reports/mastery', params) +} + +export function fetchReportStudents(params?: ReportFilters) { + return apiGet>('/api/admin/reports/students', params) +} + +export function fetchReportStudentDetail(userId: number, params?: ReportFilters) { + return apiGet(`/api/admin/reports/students/${userId}`, params) +} + +export function fetchReportBankDetail(bankId: number, params?: ReportFilters) { + return apiGet(`/api/admin/reports/banks/${bankId}`, params) +} + +export function fetchReportInsights(params?: ReportFilters) { + return apiGet('/api/admin/reports/insights', params) +} + +export function exportReport(params?: ReportFilters) { + return apiPost('/api/admin/reports/export', params) } export function fetchLogs(params?: Record) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 830b20e..d95bb3c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -132,3 +132,114 @@ export interface WrongQuestion { last_wrong_at?: string question: Question } + +export interface ReportFilters { + [key: string]: unknown + date_from?: string + date_to?: string + class_id?: number + user_id?: number + question_bank_id?: number + category_id?: number + tag_id?: number + type?: Question['type'] + mode?: string + page?: number + per_page?: number +} + +export interface ReportOverview { + students: number + active_students: number + attempts: number + answered_count: number + accuracy: number + completion_rate: number + avg_duration_seconds: number + wrong_questions: number + mastered_wrong_questions: number +} + +export interface ReportTrend { + day: string + attempts: number + active_students: number + answered_count: number + accuracy: number + avg_duration_seconds: number + wrong_added: number +} + +export interface ReportDimension { + id: number | string | null + name: string + bank_name?: string + answered_count: number + correct_count: number + wrong_count: number + accuracy: number + avg_duration_seconds: number +} + +export interface ReportStudent { + id: number + name: string + email: string + attempts: number + answered_count: number + correct_count: number + accuracy: number + avg_duration_seconds: number + wrong_questions: number + last_answered_at?: string +} + +export interface ReportQuestionError { + id: number + content: string + type: Question['type'] + bank_name: string + category_name: string + attempts: number + wrong_count: number + wrong_rate: number + last_wrong_at?: string +} + +export interface ReportMastery { + banks: ReportDimension[] + categories: ReportDimension[] + types: ReportDimension[] + tags: ReportDimension[] +} + +export interface ReportInsights { + students: ReportStudent[] + questions: ReportQuestionError[] + categories: ReportDimension[] +} + +export interface ReportOptions { + banks: Array> + classes: Array> + students: Array> + categories: TaxonomyItem[] + tags: TaxonomyItem[] +} + +export interface ReportStudentDetail { + student: Pick + overview: ReportOverview + banks: ReportDimension[] + categories: ReportDimension[] + types: ReportDimension[] + question_errors: ReportQuestionError[] +} + +export interface ReportBankDetail { + overview: ReportOverview + categories: ReportDimension[] + tags: ReportDimension[] + types: ReportDimension[] + question_errors: ReportQuestionError[] +} diff --git a/frontend/src/views/admin/ReportsView.vue b/frontend/src/views/admin/ReportsView.vue index 93eac49..352b4e0 100644 --- a/frontend/src/views/admin/ReportsView.vue +++ b/frontend/src/views/admin/ReportsView.vue @@ -1,145 +1,535 @@