Refactor quiz flow and update UI components
This commit is contained in:
parent
48ee2628d4
commit
829b99ad48
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@
|
|||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
/.codex
|
/.codex
|
||||||
/.cursor/
|
/.cursor/
|
||||||
|
/.agents/
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
|
|||||||
@ -6,15 +6,12 @@ namespace App\Http\Controllers\Api\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ExportJob;
|
use App\Models\ExportJob;
|
||||||
use App\Models\Question;
|
|
||||||
use App\Models\QuizAttempt;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WrongQuestion;
|
use App\Services\ReportQueryService;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use hg\apidoc\annotation as Apidoc;
|
use hg\apidoc\annotation as Apidoc;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
#[Apidoc\Group('后台')]
|
#[Apidoc\Group('后台')]
|
||||||
@ -22,138 +19,125 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
||||||
final class ReportController extends Controller
|
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\Title('报表概览')]
|
||||||
#[Apidoc\Url('/admin/reports/overview')]
|
#[Apidoc\Url('/admin/reports/overview')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function overview(): JsonResponse
|
public function overview(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$attempts = QuizAttempt::query();
|
$filters = $service->filters($request->query());
|
||||||
$total = (clone $attempts)->count();
|
|
||||||
$correct = (clone $attempts)->sum('correct_count');
|
|
||||||
$questions = (clone $attempts)->sum('total_questions');
|
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success($service->overview($request->user(), $filters));
|
||||||
'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\Title('练习趋势')]
|
||||||
#[Apidoc\Url('/admin/reports/trends')]
|
#[Apidoc\Url('/admin/reports/trends')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function trends(): JsonResponse
|
public function trends(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$rows = QuizAttempt::query()
|
$filters = $service->filters($request->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);
|
return ApiResponse::success($service->trends($request->user(), $filters));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('题目错误率')]
|
#[Apidoc\Title('题目错误率')]
|
||||||
#[Apidoc\Url('/admin/reports/question-errors')]
|
#[Apidoc\Url('/admin/reports/question-errors')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function questionErrors(Request $request): JsonResponse
|
public function questionErrors(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$rows = DB::table('quiz_attempt_questions')
|
$filters = $service->filters($request->query());
|
||||||
->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);
|
return ApiResponse::page($service->questionErrors($request->user(), $filters, (int) $request->query('per_page', 20)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('班级排行')]
|
#[Apidoc\Title('班级排行')]
|
||||||
#[Apidoc\Url('/admin/reports/class-ranking')]
|
#[Apidoc\Url('/admin/reports/class-ranking')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function classRanking(Request $request): JsonResponse
|
public function classRanking(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$rows = DB::table('classes')
|
$filters = $service->filters($request->query());
|
||||||
->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 [
|
return ApiResponse::success($service->classRanking($request->user(), $filters));
|
||||||
'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\Title('题库和分类掌握度')]
|
||||||
#[Apidoc\Url('/admin/reports/mastery')]
|
#[Apidoc\Url('/admin/reports/mastery')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function mastery(Request $request): JsonResponse
|
public function mastery(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$query = DB::table('quiz_attempt_questions')
|
$filters = $service->filters($request->query());
|
||||||
->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)
|
return ApiResponse::success($service->mastery($request->user(), $filters));
|
||||||
->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)
|
#[Apidoc\Title('学生统计')]
|
||||||
->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')
|
#[Apidoc\Url('/admin/reports/students')]
|
||||||
->groupBy('question_banks.name', 'question_categories.id', 'question_categories.name')
|
#[Apidoc\Method('GET')]
|
||||||
->orderByDesc('attempts')
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
->limit(30)
|
public function students(Request $request, ReportQueryService $service): JsonResponse
|
||||||
->get()
|
{
|
||||||
->map(fn (object $row): array => $this->masteryRow($row) + ['bank_name' => $row->bank_name]);
|
$filters = $service->filters($request->query());
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::page($service->students($request->user(), $filters, (int) $request->query('per_page', 20)));
|
||||||
'banks' => $banks,
|
}
|
||||||
'categories' => $categories,
|
|
||||||
]);
|
#[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\Title('报表导出')]
|
||||||
#[Apidoc\Url('/admin/reports/export')]
|
#[Apidoc\Url('/admin/reports/export')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function export(Request $request): JsonResponse
|
public function export(Request $request, ReportQueryService $service): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = [
|
$filters = $service->filters($request->all());
|
||||||
'overview' => $this->overview()->getData(true)['data'],
|
$payload = $service->exportPayload($request->user(), $filters);
|
||||||
'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';
|
$path = 'exports/report-'.now()->format('YmdHis').'.json';
|
||||||
Storage::put($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
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, '报表已导出');
|
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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
685
app/Services/ReportQueryService.php
Normal file
685
app/Services/ReportQueryService.php
Normal file
@ -0,0 +1,685 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ReportQueryService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $filters
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $filters
|
||||||
|
* @param array<int, string> $groups
|
||||||
|
* @param array<string, string> $extraSelects
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $filters
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $filters
|
||||||
|
* @return Collection<int, int>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@ -15,6 +15,7 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
|
|||||||
@ -1,5 +1,26 @@
|
|||||||
import { apiDelete, apiGet, apiPost, apiPut } from './http'
|
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<string, unknown>) {
|
export function fetchBanks(params?: Record<string, unknown>) {
|
||||||
return apiGet<PageData<QuestionBank>>('/api/admin/banks', params)
|
return apiGet<PageData<QuestionBank>>('/api/admin/banks', params)
|
||||||
@ -189,31 +210,48 @@ export function saveSettings(settings: Record<string, unknown>) {
|
|||||||
return apiPut('/api/admin/settings', { settings })
|
return apiPut('/api/admin/settings', { settings })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchReportOverview() {
|
export function fetchReportOptions(params?: ReportFilters) {
|
||||||
return apiGet<Record<string, number>>('/api/admin/reports/overview')
|
return apiGet<ReportOptions>('/api/admin/reports/options', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchReportTrends() {
|
export function fetchReportOverview(params?: ReportFilters) {
|
||||||
return apiGet<Array<Record<string, unknown>>>('/api/admin/reports/trends')
|
return apiGet<ReportOverview>('/api/admin/reports/overview', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchQuestionErrors(params?: Record<string, unknown>) {
|
export function fetchReportTrends(params?: ReportFilters) {
|
||||||
return apiGet<PageData<Record<string, unknown>>>('/api/admin/reports/question-errors', params)
|
return apiGet<ReportTrend[]>('/api/admin/reports/trends', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchClassRanking() {
|
export function fetchQuestionErrors(params?: ReportFilters) {
|
||||||
return apiGet<Array<Record<string, unknown>>>('/api/admin/reports/class-ranking')
|
return apiGet<PageData<ReportQuestionError>>('/api/admin/reports/question-errors', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchMastery() {
|
export function fetchClassRanking(params?: ReportFilters) {
|
||||||
return apiGet<{
|
return apiGet<Array<ReportDimension & { members_count: number; attempts: number; completion_rate: number }>>('/api/admin/reports/class-ranking', params)
|
||||||
banks: Array<Record<string, unknown>>
|
|
||||||
categories: Array<Record<string, unknown>>
|
|
||||||
}>('/api/admin/reports/mastery')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportReport() {
|
export function fetchMastery(params?: ReportFilters) {
|
||||||
return apiPost('/api/admin/reports/export')
|
return apiGet<ReportMastery>('/api/admin/reports/mastery', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReportStudents(params?: ReportFilters) {
|
||||||
|
return apiGet<PageData<ReportStudent>>('/api/admin/reports/students', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReportStudentDetail(userId: number, params?: ReportFilters) {
|
||||||
|
return apiGet<ReportStudentDetail>(`/api/admin/reports/students/${userId}`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReportBankDetail(bankId: number, params?: ReportFilters) {
|
||||||
|
return apiGet<ReportBankDetail>(`/api/admin/reports/banks/${bankId}`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReportInsights(params?: ReportFilters) {
|
||||||
|
return apiGet<ReportInsights>('/api/admin/reports/insights', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportReport(params?: ReportFilters) {
|
||||||
|
return apiPost('/api/admin/reports/export', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchLogs(params?: Record<string, unknown>) {
|
export function fetchLogs(params?: Record<string, unknown>) {
|
||||||
|
|||||||
@ -132,3 +132,114 @@ export interface WrongQuestion {
|
|||||||
last_wrong_at?: string
|
last_wrong_at?: string
|
||||||
question: Question
|
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<Pick<QuestionBank, 'id' | 'name'>>
|
||||||
|
classes: Array<Pick<SchoolClass, 'id' | 'name'>>
|
||||||
|
students: Array<Pick<User, 'id' | 'name' | 'email'>>
|
||||||
|
categories: TaxonomyItem[]
|
||||||
|
tags: TaxonomyItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStudentDetail {
|
||||||
|
student: Pick<User, 'id' | 'name' | 'email'>
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@ -1,145 +1,535 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, shallowRef } from 'vue'
|
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { BarChart, LineChart } from 'echarts/charts'
|
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { exportReport, fetchClassRanking, fetchMastery, fetchQuestionErrors, fetchReportOverview, fetchReportTrends } from '@/api/admin'
|
import {
|
||||||
|
exportReport,
|
||||||
|
fetchClassRanking,
|
||||||
|
fetchMastery,
|
||||||
|
fetchQuestionErrors,
|
||||||
|
fetchReportBankDetail,
|
||||||
|
fetchReportInsights,
|
||||||
|
fetchReportOptions,
|
||||||
|
fetchReportOverview,
|
||||||
|
fetchReportStudentDetail,
|
||||||
|
fetchReportStudents,
|
||||||
|
fetchReportTrends,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type {
|
||||||
|
PageMeta,
|
||||||
|
ReportBankDetail,
|
||||||
|
ReportDimension,
|
||||||
|
ReportFilters,
|
||||||
|
ReportInsights,
|
||||||
|
ReportMastery,
|
||||||
|
ReportOptions,
|
||||||
|
ReportOverview,
|
||||||
|
ReportQuestionError,
|
||||||
|
ReportStudent,
|
||||||
|
ReportStudentDetail,
|
||||||
|
ReportTrend,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent])
|
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])
|
||||||
|
|
||||||
const overview = shallowRef<Record<string, number>>({})
|
type ClassRanking = ReportDimension & { members_count: number; attempts: number; completion_rate: number }
|
||||||
const trends = shallowRef<Array<Record<string, unknown>>>([])
|
|
||||||
const errors = shallowRef<Array<Record<string, unknown>>>([])
|
const today = new Date()
|
||||||
const ranking = shallowRef<Array<Record<string, unknown>>>([])
|
const weekStart = new Date()
|
||||||
const mastery = shallowRef<{ banks: Array<Record<string, unknown>>; categories: Array<Record<string, unknown>> }>({
|
weekStart.setDate(today.getDate() - 6)
|
||||||
banks: [],
|
|
||||||
categories: [],
|
const filters = reactive({
|
||||||
|
dateRange: [formatDate(weekStart), formatDate(today)] as [string, string],
|
||||||
|
class_id: undefined as number | undefined,
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
question_bank_id: undefined as number | undefined,
|
||||||
|
category_id: undefined as number | undefined,
|
||||||
|
tag_id: undefined as number | undefined,
|
||||||
|
type: undefined as string | undefined,
|
||||||
|
mode: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loading = shallowRef(false)
|
||||||
|
const options = shallowRef<ReportOptions>({ banks: [], classes: [], students: [], categories: [], tags: [] })
|
||||||
|
const overview = shallowRef<ReportOverview>(emptyOverview())
|
||||||
|
const trends = shallowRef<ReportTrend[]>([])
|
||||||
|
const mastery = shallowRef<ReportMastery>({ banks: [], categories: [], types: [], tags: [] })
|
||||||
|
const ranking = shallowRef<ClassRanking[]>([])
|
||||||
|
const insights = shallowRef<ReportInsights>({ students: [], questions: [], categories: [] })
|
||||||
|
const students = shallowRef<ReportStudent[]>([])
|
||||||
|
const studentMeta = shallowRef<PageMeta>(emptyMeta())
|
||||||
|
const studentPage = shallowRef(1)
|
||||||
|
const studentPageSize = shallowRef(10)
|
||||||
|
const questionErrors = shallowRef<ReportQuestionError[]>([])
|
||||||
|
const questionMeta = shallowRef<PageMeta>(emptyMeta())
|
||||||
|
const questionPage = shallowRef(1)
|
||||||
|
const questionPageSize = shallowRef(10)
|
||||||
|
const detailVisible = shallowRef(false)
|
||||||
|
const detailTitle = shallowRef('')
|
||||||
|
const studentDetail = shallowRef<ReportStudentDetail | null>(null)
|
||||||
|
const bankDetail = shallowRef<ReportBankDetail | null>(null)
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: '单选', value: 'single' },
|
||||||
|
{ label: '多选', value: 'multiple' },
|
||||||
|
{ label: '判断', value: 'judge' },
|
||||||
|
{ label: '填空', value: 'blank' },
|
||||||
|
]
|
||||||
|
const modeOptions = [
|
||||||
|
{ label: '顺序背题', value: 'memorize' },
|
||||||
|
{ label: '顺序刷题', value: 'sequence' },
|
||||||
|
{ label: '随机刷题', value: 'random' },
|
||||||
|
{ label: '错题回顾', value: 'wrong_practice' },
|
||||||
|
{ label: '整卷测试', value: 'paper' },
|
||||||
|
]
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ label: '学生数', value: overview.value.students, suffix: '人' },
|
||||||
|
{ label: '活跃学生', value: overview.value.active_students, suffix: '人' },
|
||||||
|
{ label: '练习次数', value: overview.value.attempts, suffix: '次' },
|
||||||
|
{ label: '已作答', value: overview.value.answered_count, suffix: '题' },
|
||||||
|
{ label: '正确率', value: overview.value.accuracy, suffix: '%' },
|
||||||
|
{ label: '完成率', value: overview.value.completion_rate, suffix: '%' },
|
||||||
|
{ label: '平均耗时', value: overview.value.avg_duration_seconds, suffix: '秒' },
|
||||||
|
{ label: '当前错题', value: overview.value.wrong_questions, suffix: '题' },
|
||||||
|
])
|
||||||
const trendOption = computed(() => ({
|
const trendOption = computed(() => ({
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['练习次数', '总题数'] },
|
legend: { data: ['作答题数', '正确率', '活跃学生'] },
|
||||||
grid: { left: 36, right: 18, top: 36, bottom: 28 },
|
grid: { left: 42, right: 28, top: 42, bottom: 30 },
|
||||||
xAxis: { type: 'category', data: trends.value.map((item) => item.day) },
|
xAxis: { type: 'category', data: trends.value.map((item) => item.day) },
|
||||||
yAxis: { type: 'value' },
|
yAxis: [{ type: 'value' }, { type: 'value', max: 100 }],
|
||||||
series: [
|
series: [
|
||||||
{ name: '练习次数', type: 'line', smooth: true, data: trends.value.map((item) => Number(item.attempts ?? 0)) },
|
{ name: '作答题数', type: 'bar', data: trends.value.map((item) => item.answered_count) },
|
||||||
{ name: '总题数', type: 'bar', data: trends.value.map((item) => Number(item.total_questions ?? 0)) },
|
{ name: '正确率', type: 'line', yAxisIndex: 1, smooth: true, data: trends.value.map((item) => item.accuracy) },
|
||||||
|
{ name: '活跃学生', type: 'line', smooth: true, data: trends.value.map((item) => item.active_students) },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
const bankOption = computed(() => ({
|
||||||
const masteryOption = computed(() => ({
|
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
grid: { left: 36, right: 18, top: 24, bottom: 58 },
|
grid: { left: 42, right: 18, top: 32, bottom: 68 },
|
||||||
xAxis: {
|
xAxis: { type: 'category', axisLabel: { rotate: 28 }, data: mastery.value.banks.map((item) => item.name) },
|
||||||
type: 'category',
|
|
||||||
axisLabel: { rotate: 28 },
|
|
||||||
data: mastery.value.banks.map((item) => item.name),
|
|
||||||
},
|
|
||||||
yAxis: { type: 'value', max: 100 },
|
yAxis: { type: 'value', max: 100 },
|
||||||
series: [
|
series: [{ name: '正确率', type: 'bar', data: mastery.value.banks.map((item) => item.accuracy) }],
|
||||||
{
|
}))
|
||||||
name: '掌握度',
|
const typeOption = computed(() => ({
|
||||||
type: 'bar',
|
tooltip: { trigger: 'item' },
|
||||||
data: mastery.value.banks.map((item) => Number(item.accuracy ?? 0)),
|
legend: { bottom: 0 },
|
||||||
},
|
series: [{
|
||||||
],
|
name: '题型作答',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['42%', '66%'],
|
||||||
|
data: mastery.value.types.map((item) => ({ name: typeLabel(String(item.id)), value: item.answered_count })),
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
const weakCategoryOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 42, right: 18, top: 28, bottom: 80 },
|
||||||
|
xAxis: { type: 'category', axisLabel: { rotate: 32 }, data: mastery.value.categories.slice(0, 8).map((item) => item.name) },
|
||||||
|
yAxis: { type: 'value', max: 100 },
|
||||||
|
series: [{ name: '正确率', type: 'bar', data: mastery.value.categories.slice(0, 8).map((item) => item.accuracy) }],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function load() {
|
function formatDate(date: Date) {
|
||||||
overview.value = (await fetchReportOverview()).data
|
return date.toISOString().slice(0, 10)
|
||||||
trends.value = (await fetchReportTrends()).data
|
}
|
||||||
errors.value = (await fetchQuestionErrors()).data.items
|
|
||||||
ranking.value = (await fetchClassRanking()).data
|
function emptyOverview(): ReportOverview {
|
||||||
mastery.value = (await fetchMastery()).data
|
return {
|
||||||
|
students: 0,
|
||||||
|
active_students: 0,
|
||||||
|
attempts: 0,
|
||||||
|
answered_count: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
completion_rate: 0,
|
||||||
|
avg_duration_seconds: 0,
|
||||||
|
wrong_questions: 0,
|
||||||
|
mastered_wrong_questions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyMeta(): PageMeta {
|
||||||
|
return { current_page: 1, per_page: 10, total: 0, last_page: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function params(extra: Partial<ReportFilters> = {}): ReportFilters {
|
||||||
|
return {
|
||||||
|
date_from: filters.dateRange?.[0],
|
||||||
|
date_to: filters.dateRange?.[1],
|
||||||
|
class_id: filters.class_id,
|
||||||
|
user_id: filters.user_id,
|
||||||
|
question_bank_id: filters.question_bank_id,
|
||||||
|
category_id: filters.category_id,
|
||||||
|
tag_id: filters.tag_id,
|
||||||
|
type: filters.type as ReportFilters['type'],
|
||||||
|
mode: filters.mode,
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(value?: string) {
|
||||||
|
return typeOptions.find((item) => item.value === value)?.label ?? value ?? '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
options.value = (await fetchReportOptions(params())).data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await loadOptions()
|
||||||
|
const [overviewResponse, trendsResponse, masteryResponse, rankingResponse, insightsResponse] = await Promise.all([
|
||||||
|
fetchReportOverview(params()),
|
||||||
|
fetchReportTrends(params()),
|
||||||
|
fetchMastery(params()),
|
||||||
|
fetchClassRanking(params()),
|
||||||
|
fetchReportInsights(params()),
|
||||||
|
])
|
||||||
|
overview.value = overviewResponse.data
|
||||||
|
trends.value = trendsResponse.data
|
||||||
|
mastery.value = masteryResponse.data
|
||||||
|
ranking.value = rankingResponse.data
|
||||||
|
insights.value = insightsResponse.data
|
||||||
|
studentPage.value = 1
|
||||||
|
questionPage.value = 1
|
||||||
|
await Promise.all([loadStudents(), loadQuestionErrors()])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
const response = await fetchReportStudents(params({ page: studentPage.value, per_page: studentPageSize.value }))
|
||||||
|
students.value = response.data.items
|
||||||
|
studentMeta.value = response.data.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuestionErrors() {
|
||||||
|
const response = await fetchQuestionErrors(params({ page: questionPage.value, per_page: questionPageSize.value }))
|
||||||
|
questionErrors.value = response.data.items
|
||||||
|
questionMeta.value = response.data.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStudentPage(page: number) {
|
||||||
|
studentPage.value = page
|
||||||
|
await loadStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStudentPageSize(size: number) {
|
||||||
|
studentPageSize.value = size
|
||||||
|
studentPage.value = 1
|
||||||
|
await loadStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeQuestionPage(page: number) {
|
||||||
|
questionPage.value = page
|
||||||
|
await loadQuestionErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeQuestionPageSize(size: number) {
|
||||||
|
questionPageSize.value = size
|
||||||
|
questionPage.value = 1
|
||||||
|
await loadQuestionErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFilters() {
|
||||||
|
filters.dateRange = [formatDate(weekStart), formatDate(today)]
|
||||||
|
filters.class_id = undefined
|
||||||
|
filters.user_id = undefined
|
||||||
|
filters.question_bank_id = undefined
|
||||||
|
filters.category_id = undefined
|
||||||
|
filters.tag_id = undefined
|
||||||
|
filters.type = undefined
|
||||||
|
filters.mode = undefined
|
||||||
|
await loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportData() {
|
async function exportData() {
|
||||||
await exportReport()
|
await exportReport(params())
|
||||||
ElMessage.success('报表已导出到 storage/app/exports')
|
ElMessage.success('报表已导出到 storage/app/exports')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
async function openStudent(row: ReportStudent) {
|
||||||
|
studentDetail.value = (await fetchReportStudentDetail(row.id, params())).data
|
||||||
|
bankDetail.value = null
|
||||||
|
detailTitle.value = `${row.name} 的学习画像`
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBank(row: ReportDimension) {
|
||||||
|
if (typeof row.id !== 'number') return
|
||||||
|
bankDetail.value = (await fetchReportBankDetail(row.id, params())).data
|
||||||
|
studentDetail.value = null
|
||||||
|
detailTitle.value = `${row.name} 题库分析`
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="report-page">
|
<section v-loading="loading" class="report-page">
|
||||||
<div class="toolbar mb-4">
|
<div class="toolbar mb-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h1 class="page-title">统计报表</h1>
|
<h1 class="page-title">统计报表</h1>
|
||||||
<p class="muted text-sm mt-1">查看练习趋势、正确率、错题和题目错误率。</p>
|
<p class="muted text-sm mt-1">按已作答题目统计正确率,未答题只计入完成率。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ElButton @click="resetFilters">重置</ElButton>
|
||||||
<ElButton type="primary" @click="exportData">导出报表</ElButton>
|
<ElButton type="primary" @click="exportData">导出报表</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel report-filter">
|
||||||
|
<ElDatePicker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" />
|
||||||
|
<ElSelect v-model="filters.class_id" clearable filterable placeholder="班级">
|
||||||
|
<ElOption v-for="item in options.classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.user_id" clearable filterable placeholder="学生">
|
||||||
|
<ElOption v-for="item in options.students" :key="item.id" :label="`${item.name} ${item.email}`" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.question_bank_id" clearable filterable placeholder="题库" @change="loadOptions">
|
||||||
|
<ElOption v-for="item in options.banks" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.category_id" clearable filterable placeholder="分类">
|
||||||
|
<ElOption v-for="item in options.categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.tag_id" clearable filterable placeholder="标签">
|
||||||
|
<ElOption v-for="item in options.tags" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.type" clearable placeholder="题型">
|
||||||
|
<ElOption v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect v-model="filters.mode" clearable placeholder="模式">
|
||||||
|
<ElOption v-for="item in modeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</ElSelect>
|
||||||
|
<ElButton type="primary" @click="loadAll">查询</ElButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="report-stats">
|
<div class="report-stats">
|
||||||
<article v-for="(value, key) in overview" :key="key" class="stat-card">
|
<article v-for="item in statCards" :key="item.label" class="stat-card">
|
||||||
<span>{{ key }}</span>
|
<span>{{ item.label }}</span>
|
||||||
<strong>{{ value }}</strong>
|
<strong>{{ item.value }}<small>{{ item.suffix }}</small></strong>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel p-5 mt-4">
|
<section class="panel p-5 mt-4">
|
||||||
<h2 class="page-title mb-3">近 14 天趋势</h2>
|
<div class="section-head">
|
||||||
|
<h2 class="page-title">总览趋势</h2>
|
||||||
|
<span class="muted text-sm">默认展示近 7 天</span>
|
||||||
|
</div>
|
||||||
<VChart class="trend-chart" :option="trendOption" autoresize />
|
<VChart class="trend-chart" :option="trendOption" autoresize />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel p-5 mt-4">
|
<section class="report-layout mt-4">
|
||||||
<div class="report-split">
|
<div class="panel p-5">
|
||||||
<div>
|
<h2 class="page-title mb-3">题库分析</h2>
|
||||||
<h2 class="page-title mb-3">题库掌握度</h2>
|
<VChart class="mastery-chart" :option="bankOption" autoresize />
|
||||||
<VChart class="mastery-chart" :option="masteryOption" autoresize />
|
<ElTable :data="mastery.banks" row-key="id" class="mt-3" height="260">
|
||||||
</div>
|
<ElTableColumn prop="name" label="题库" min-width="150" show-overflow-tooltip />
|
||||||
<div>
|
<ElTableColumn prop="answered_count" label="作答" width="82" />
|
||||||
<h2 class="page-title mb-3">分类掌握度</h2>
|
<ElTableColumn prop="wrong_count" label="错误" width="82" />
|
||||||
<ElTable :data="mastery.categories" row-key="id" height="320">
|
<ElTableColumn label="正确率" width="92">
|
||||||
<ElTableColumn prop="bank_name" label="题库" min-width="120" show-overflow-tooltip />
|
<template #default="{ row }">{{ row.accuracy }}%</template>
|
||||||
<ElTableColumn prop="name" label="分类" min-width="120" show-overflow-tooltip />
|
</ElTableColumn>
|
||||||
<ElTableColumn prop="attempts" label="作答" width="80" />
|
<ElTableColumn label="操作" width="86" fixed="right">
|
||||||
<ElTableColumn prop="accuracy" label="掌握度" width="100">
|
<template #default="{ row }">
|
||||||
<template #default="{ row }">{{ row.accuracy }}%</template>
|
<ElButton link type="primary" @click="openBank(row)">详情</ElButton>
|
||||||
</ElTableColumn>
|
</template>
|
||||||
</ElTable>
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
<div class="panel p-5">
|
||||||
|
<h2 class="page-title mb-3">题型与薄弱分类</h2>
|
||||||
|
<div class="mini-charts">
|
||||||
|
<VChart class="mini-chart" :option="typeOption" autoresize />
|
||||||
|
<VChart class="mini-chart" :option="weakCategoryOption" autoresize />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel p-5 mt-4">
|
<section class="panel p-5 mt-4">
|
||||||
<h2 class="page-title mb-3">班级排行</h2>
|
<h2 class="page-title mb-3">学生分析</h2>
|
||||||
<ElTable :data="ranking" row-key="id">
|
<ElTable :data="students" row-key="id">
|
||||||
<ElTableColumn prop="name" label="班级" min-width="160" />
|
<ElTableColumn prop="name" label="学生" min-width="120" show-overflow-tooltip />
|
||||||
<ElTableColumn prop="members_count" label="成员" width="90" />
|
<ElTableColumn prop="email" label="账号" min-width="180" show-overflow-tooltip />
|
||||||
<ElTableColumn prop="attempts" label="练习次数" width="110" />
|
<ElTableColumn prop="attempts" label="练习" width="82" />
|
||||||
<ElTableColumn prop="total_questions" label="作答题数" width="110" />
|
<ElTableColumn prop="answered_count" label="作答题数" width="100" />
|
||||||
<ElTableColumn prop="accuracy" label="正确率" width="100">
|
<ElTableColumn label="正确率" width="92">
|
||||||
<template #default="{ row }">{{ row.accuracy }}%</template>
|
<template #default="{ row }">{{ row.accuracy }}%</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="wrong_questions" label="错题" width="82" />
|
||||||
|
<ElTableColumn prop="avg_duration_seconds" label="均耗时(秒)" width="112" />
|
||||||
|
<ElTableColumn prop="last_answered_at" label="最近作答" min-width="150" show-overflow-tooltip />
|
||||||
|
<ElTableColumn label="操作" width="86" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="primary" @click="openStudent(row)">画像</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
|
<ElPagination
|
||||||
|
class="report-pagination"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="studentMeta.total"
|
||||||
|
:page-size="studentPageSize"
|
||||||
|
:current-page="studentPage"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@update:current-page="changeStudentPage"
|
||||||
|
@update:page-size="changeStudentPageSize"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel p-5 mt-4">
|
<section class="report-layout mt-4">
|
||||||
<h2 class="page-title mb-3">题目错误率</h2>
|
<div class="panel p-5">
|
||||||
<ElTable :data="errors" row-key="id">
|
<h2 class="page-title mb-3">题目错误排行</h2>
|
||||||
<ElTableColumn prop="content" label="题干" min-width="280" show-overflow-tooltip />
|
<ElTable :data="questionErrors" row-key="id" height="420">
|
||||||
<ElTableColumn prop="attempts" label="作答次数" />
|
<ElTableColumn prop="content" label="题干" min-width="260" show-overflow-tooltip />
|
||||||
<ElTableColumn prop="wrong_count" label="错误次数" />
|
<ElTableColumn prop="bank_name" label="题库" min-width="120" show-overflow-tooltip />
|
||||||
</ElTable>
|
<ElTableColumn label="题型" width="80">
|
||||||
|
<template #default="{ row }">{{ typeLabel(row.type) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="attempts" label="作答" width="76" />
|
||||||
|
<ElTableColumn prop="wrong_count" label="错误" width="76" />
|
||||||
|
<ElTableColumn label="错误率" width="92">
|
||||||
|
<template #default="{ row }">{{ row.wrong_rate }}%</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<ElPagination
|
||||||
|
class="report-pagination"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="questionMeta.total"
|
||||||
|
:page-size="questionPageSize"
|
||||||
|
:current-page="questionPage"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@update:current-page="changeQuestionPage"
|
||||||
|
@update:page-size="changeQuestionPageSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="panel p-5">
|
||||||
|
<h2 class="page-title mb-3">重点关注</h2>
|
||||||
|
<div class="insight-block">
|
||||||
|
<h3>低正确率学生</h3>
|
||||||
|
<ElEmpty v-if="insights.students.length === 0" description="暂无需要重点关注的学生" />
|
||||||
|
<div v-for="item in insights.students" :key="item.id" class="insight-row">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<ElTag type="danger" effect="plain">{{ item.accuracy }}%</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-block">
|
||||||
|
<h3>高频错题</h3>
|
||||||
|
<ElEmpty v-if="insights.questions.length === 0" description="暂无高频错题" />
|
||||||
|
<div v-for="item in insights.questions" :key="item.id" class="insight-row">
|
||||||
|
<span>{{ item.content }}</span>
|
||||||
|
<ElTag type="warning" effect="plain">{{ item.wrong_rate }}%</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-block">
|
||||||
|
<h3>薄弱分类</h3>
|
||||||
|
<ElEmpty v-if="insights.categories.length === 0" description="暂无明显薄弱分类" />
|
||||||
|
<div v-for="item in insights.categories" :key="`${item.bank_name}-${item.id}`" class="insight-row">
|
||||||
|
<span>{{ item.bank_name ? `${item.bank_name} / ` : '' }}{{ item.name }}</span>
|
||||||
|
<ElTag type="danger" effect="plain">{{ item.accuracy }}%</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ElDrawer v-model="detailVisible" :title="detailTitle" size="min(720px, 100vw)">
|
||||||
|
<div v-if="studentDetail" class="detail-stack">
|
||||||
|
<div class="report-stats compact">
|
||||||
|
<article v-for="item in [
|
||||||
|
{ label: '作答题数', value: studentDetail.overview.answered_count },
|
||||||
|
{ label: '正确率', value: `${studentDetail.overview.accuracy}%` },
|
||||||
|
{ label: '完成率', value: `${studentDetail.overview.completion_rate}%` },
|
||||||
|
{ label: '当前错题', value: studentDetail.overview.wrong_questions },
|
||||||
|
]" :key="item.label" class="stat-card">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<ElTable :data="studentDetail.banks" row-key="id">
|
||||||
|
<ElTableColumn prop="name" label="薄弱题库" min-width="160" />
|
||||||
|
<ElTableColumn prop="answered_count" label="作答" width="80" />
|
||||||
|
<ElTableColumn label="正确率" width="90">
|
||||||
|
<template #default="{ row }">{{ row.accuracy }}%</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<ElTable :data="studentDetail.question_errors" row-key="id">
|
||||||
|
<ElTableColumn prop="content" label="错题" min-width="220" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="wrong_count" label="错误" width="80" />
|
||||||
|
<ElTableColumn label="错误率" width="90">
|
||||||
|
<template #default="{ row }">{{ row.wrong_rate }}%</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
<div v-if="bankDetail" class="detail-stack">
|
||||||
|
<div class="report-stats compact">
|
||||||
|
<article v-for="item in [
|
||||||
|
{ label: '作答题数', value: bankDetail.overview.answered_count },
|
||||||
|
{ label: '正确率', value: `${bankDetail.overview.accuracy}%` },
|
||||||
|
{ label: '平均耗时', value: `${bankDetail.overview.avg_duration_seconds}秒` },
|
||||||
|
{ label: '当前错题', value: bankDetail.overview.wrong_questions },
|
||||||
|
]" :key="item.label" class="stat-card">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<ElTable :data="bankDetail.categories" row-key="id">
|
||||||
|
<ElTableColumn prop="name" label="分类" min-width="160" />
|
||||||
|
<ElTableColumn prop="answered_count" label="作答" width="80" />
|
||||||
|
<ElTableColumn prop="wrong_count" label="错误" width="80" />
|
||||||
|
<ElTableColumn label="正确率" width="90">
|
||||||
|
<template #default="{ row }">{{ row.accuracy }}%</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<ElTable :data="bankDetail.question_errors" row-key="id">
|
||||||
|
<ElTableColumn prop="content" label="高错题目" min-width="220" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="wrong_count" label="错误" width="80" />
|
||||||
|
<ElTableColumn label="错误率" width="90">
|
||||||
|
<template #default="{ row }">{{ row.wrong_rate }}%</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</ElDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.report-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1.4fr) repeat(4, minmax(140px, 1fr)) auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filter :deep(.el-date-editor),
|
||||||
|
.report-filter :deep(.el-select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.report-stats {
|
.report-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stats.compact {
|
||||||
|
margin-top: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 18px;
|
padding: 16px;
|
||||||
border: 1px solid var(--qq-line);
|
border: 1px solid var(--qq-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: white;
|
background: white;
|
||||||
@ -148,30 +538,113 @@ onMounted(load)
|
|||||||
.stat-card span {
|
.stat-card span {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--qq-muted);
|
color: var(--qq-muted);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card strong {
|
.stat-card strong {
|
||||||
font-size: 28px;
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-chart {
|
.stat-card small {
|
||||||
width: 100%;
|
margin-left: 2px;
|
||||||
height: 320px;
|
font-size: 13px;
|
||||||
|
color: var(--qq-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart,
|
||||||
.mastery-chart {
|
.mastery-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-split {
|
.report-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr);
|
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.85fr);
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.mini-charts {
|
||||||
.report-split {
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pagination {
|
||||||
|
margin-top: 14px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-block + .insight-block {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-block h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--qq-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-row span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.report-filter {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-filter {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stats.compact {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user