diff --git a/app/Http/Controllers/Api/Admin/ClassController.php b/app/Http/Controllers/Api/Admin/ClassController.php index 6a55159..07f28c7 100644 --- a/app/Http/Controllers/Api/Admin/ClassController.php +++ b/app/Http/Controllers/Api/Admin/ClassController.php @@ -51,6 +51,26 @@ final class ClassController extends Controller return ApiResponse::success($class, '班级已创建'); } + #[Apidoc\Title('更新班级')] + #[Apidoc\Url('/admin/classes/{class}')] + #[Apidoc\Method('PUT')] + #[Apidoc\RouteMiddleware(['permission:classes'])] + public function update(Request $request, mixed $class): JsonResponse + { + $class = $this->resolveClass($class); + abort_if($request->user()->role !== 'admin' && $class->owner_id !== $request->user()->id, 403, '权限不足'); + + $data = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $class->update($data); + + return ApiResponse::success($class->fresh()->loadCount('members'), '班级已更新'); + } + #[Apidoc\Title('分配成员')] #[Apidoc\Url('/admin/classes/{class}/members')] #[Apidoc\Method('POST')] @@ -72,6 +92,31 @@ final class ClassController extends Controller return ApiResponse::success($class->load('members'), '成员已加入'); } + #[Apidoc\Title('批量分配成员')] + #[Apidoc\Url('/admin/classes/{class}/members/batch')] + #[Apidoc\Method('POST')] + #[Apidoc\RouteMiddleware(['permission:classes'])] + public function addMembers(Request $request, mixed $class): JsonResponse + { + $class = $this->resolveClass($class); + abort_if($request->user()->role !== 'admin' && $class->owner_id !== $request->user()->id, 403, '权限不足'); + + $data = $request->validate([ + 'user_ids' => ['required', 'array', 'min:1'], + 'user_ids.*' => ['integer', 'exists:users,id'], + 'role' => ['nullable', 'in:student,assistant'], + ]); + + $role = $data['role'] ?? 'student'; + $members = collect($data['user_ids']) + ->unique() + ->mapWithKeys(fn (int $userId): array => [$userId => ['role' => $role]]) + ->all(); + $class->members()->syncWithoutDetaching($members); + + return ApiResponse::success($class->fresh()->loadCount('members'), '成员已加入'); + } + private function resolveClass(mixed $class): SchoolClass { if ($class instanceof SchoolClass && $class->exists) { diff --git a/app/Http/Controllers/Api/Admin/DashboardController.php b/app/Http/Controllers/Api/Admin/DashboardController.php new file mode 100644 index 0000000..e01ec4a --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DashboardController.php @@ -0,0 +1,91 @@ +user(); + $bankScope = QuestionBank::query()->whereNull('deleted_at'); + if ($user->role !== 'admin') { + $bankScope->where('owner_id', $user->id); + } + $bankIds = (clone $bankScope)->pluck('id'); + $questionScope = Question::query()->whereIn('question_bank_id', $bankIds); + $todayAnswered = 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') + ->whereIn('questions.question_bank_id', $bankIds) + ->whereDate('quiz_attempt_questions.answered_at', today()) + ->whereNotNull('quiz_attempt_questions.answered_at'); + $answered = (clone $todayAnswered)->count(); + $correct = (clone $todayAnswered)->where('quiz_attempt_questions.is_correct', true)->count(); + $trend = DB::table('quiz_attempt_questions') + ->join('questions', 'questions.id', '=', 'quiz_attempt_questions.question_id') + ->whereIn('questions.question_bank_id', $bankIds) + ->where('quiz_attempt_questions.answered_at', '>=', now()->subDays(6)->startOfDay()) + ->whereNotNull('quiz_attempt_questions.answered_at') + ->selectRaw('date(quiz_attempt_questions.answered_at) as day, count(*) as answered_count') + ->groupBy('day') + ->orderBy('day') + ->get(); + $classRanking = DB::table('classes') + ->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id') + ->leftJoin('quiz_attempts', 'quiz_attempts.user_id', '=', 'class_members.user_id') + ->whereNull('classes.deleted_at') + ->when($user->role !== 'admin', fn ($query) => $query->where('classes.owner_id', $user->id)) + ->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count, count(distinct quiz_attempts.id) as attempts') + ->groupBy('classes.id', 'classes.name') + ->orderByDesc('attempts') + ->limit(5) + ->get(); + $recentAttempts = QuizAttempt::query() + ->join('users', 'users.id', '=', 'quiz_attempts.user_id') + ->leftJoin('question_banks', 'question_banks.id', '=', 'quiz_attempts.question_bank_id') + ->when($user->role !== 'admin', fn ($query) => $query->whereIn('quiz_attempts.question_bank_id', $bankIds)) + ->selectRaw('quiz_attempts.id, users.name as user_name, coalesce(question_banks.name, "固定试卷") as bank_name, quiz_attempts.mode, quiz_attempts.total_questions, quiz_attempts.correct_count, quiz_attempts.started_at') + ->latest('quiz_attempts.started_at') + ->limit(8) + ->get(); + + return ApiResponse::success([ + 'stats' => [ + 'banks' => (clone $bankScope)->count(), + 'questions' => (clone $questionScope)->count(), + 'today_answered' => $answered, + 'accuracy' => $answered > 0 ? round($correct / $answered * 100, 2) : 0, + 'users' => $user->role === 'admin' ? User::query()->count() : null, + 'wrong_questions' => WrongQuestion::query() + ->join('questions', 'questions.id', '=', 'wrong_questions.question_id') + ->whereIn('questions.question_bank_id', $bankIds) + ->whereNull('wrong_questions.mastered_at') + ->count(), + ], + 'trend' => $trend, + 'class_ranking' => $classRanking, + 'recent_attempts' => $recentAttempts, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index aa0fa8c..fe77d62 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -126,4 +126,16 @@ final class UserController extends Controller return ApiResponse::success($invite, '邀请码已创建'); } + + #[Apidoc\Title('删除邀请码')] + #[Apidoc\Url('/admin/invite-codes/{invite}')] + #[Apidoc\Method('DELETE')] + #[Apidoc\RouteMiddleware(['permission:users.update'])] + public function deleteInvite(mixed $invite): JsonResponse + { + $invite = InviteCode::query()->findOrFail((int) $invite); + $invite->delete(); + + return ApiResponse::success(null, '邀请码已删除'); + } } diff --git a/frontend/dist.zip b/frontend/dist.zip new file mode 100644 index 0000000..f59439c Binary files /dev/null and b/frontend/dist.zip differ diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index ff174d9..fa6c463 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,6 +1,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from './http' import type { ImportJob, + DashboardData, PageData, Paper, Permission, @@ -22,6 +23,10 @@ import type { User, } from '@/types/api' +export function fetchDashboard() { + return apiGet('/api/admin/dashboard') +} + export function fetchBanks(params?: Record) { return apiGet>('/api/admin/banks', params) } @@ -130,10 +135,18 @@ export function createClass(payload: { name: string; description?: string }) { return apiPost('/api/admin/classes', payload) } +export function updateClass(classId: number, payload: { name?: string; description?: string; is_active?: boolean }) { + return apiPut(`/api/admin/classes/${classId}`, payload) +} + export function addClassMember(classId: number, payload: { user_id: number; role?: string }) { return apiPost(`/api/admin/classes/${classId}/members`, payload) } +export function addClassMembers(classId: number, payload: { user_ids: number[]; role?: string }) { + return apiPost(`/api/admin/classes/${classId}/members/batch`, payload) +} + export function fetchPapers(params?: Record) { return apiGet>('/api/admin/papers', params) } @@ -191,6 +204,10 @@ export function createInvite(payload: { role: string; assigned_name?: string; ma return apiPost('/api/admin/invite-codes', payload) } +export function deleteInvite(inviteId: number) { + return apiDelete(`/api/admin/invite-codes/${inviteId}`) +} + export function fetchPermissions() { return apiGet<{ permissions: Permission[] diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d95bb3c..13785d6 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -243,3 +243,25 @@ export interface ReportBankDetail { types: ReportDimension[] question_errors: ReportQuestionError[] } + +export interface DashboardData { + stats: { + banks: number + questions: number + today_answered: number + accuracy: number + users?: number | null + wrong_questions: number + } + trend: Array<{ day: string; answered_count: number }> + class_ranking: Array<{ id: number; name: string; members_count: number; attempts: number }> + recent_attempts: Array<{ + id: number + user_name: string + bank_name: string + mode: string + total_questions: number + correct_count: number + started_at: string + }> +} diff --git a/frontend/src/views/admin/ClassesView.vue b/frontend/src/views/admin/ClassesView.vue index 700b5ac..9b9dd79 100644 --- a/frontend/src/views/admin/ClassesView.vue +++ b/frontend/src/views/admin/ClassesView.vue @@ -1,25 +1,28 @@ @@ -23,7 +90,7 @@ const stats = [