Add admin dashboard and class management actions
This commit is contained in:
parent
829b99ad48
commit
a8095be4d8
@ -51,6 +51,26 @@ final class ClassController extends Controller
|
|||||||
return ApiResponse::success($class, '班级已创建');
|
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\Title('分配成员')]
|
||||||
#[Apidoc\Url('/admin/classes/{class}/members')]
|
#[Apidoc\Url('/admin/classes/{class}/members')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
@ -72,6 +92,31 @@ final class ClassController extends Controller
|
|||||||
return ApiResponse::success($class->load('members'), '成员已加入');
|
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
|
private function resolveClass(mixed $class): SchoolClass
|
||||||
{
|
{
|
||||||
if ($class instanceof SchoolClass && $class->exists) {
|
if ($class instanceof SchoolClass && $class->exists) {
|
||||||
|
|||||||
91
app/Http/Controllers/Api/Admin/DashboardController.php
Normal file
91
app/Http/Controllers/Api/Admin/DashboardController.php
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Question;
|
||||||
|
use App\Models\QuestionBank;
|
||||||
|
use App\Models\QuizAttempt;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WrongQuestion;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use hg\apidoc\annotation as Apidoc;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
#[Apidoc\Group('后台')]
|
||||||
|
#[Apidoc\Title('控制台')]
|
||||||
|
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
||||||
|
final class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
#[Apidoc\Title('控制台概览')]
|
||||||
|
#[Apidoc\Url('/admin/dashboard')]
|
||||||
|
#[Apidoc\Method('GET')]
|
||||||
|
#[Apidoc\RouteMiddleware(['permission:dashboard'])]
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -126,4 +126,16 @@ final class UserController extends Controller
|
|||||||
|
|
||||||
return ApiResponse::success($invite, '邀请码已创建');
|
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, '邀请码已删除');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/dist.zip
Normal file
BIN
frontend/dist.zip
Normal file
Binary file not shown.
@ -1,6 +1,7 @@
|
|||||||
import { apiDelete, apiGet, apiPost, apiPut } from './http'
|
import { apiDelete, apiGet, apiPost, apiPut } from './http'
|
||||||
import type {
|
import type {
|
||||||
ImportJob,
|
ImportJob,
|
||||||
|
DashboardData,
|
||||||
PageData,
|
PageData,
|
||||||
Paper,
|
Paper,
|
||||||
Permission,
|
Permission,
|
||||||
@ -22,6 +23,10 @@ import type {
|
|||||||
User,
|
User,
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export function fetchDashboard() {
|
||||||
|
return apiGet<DashboardData>('/api/admin/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@ -130,10 +135,18 @@ export function createClass(payload: { name: string; description?: string }) {
|
|||||||
return apiPost<SchoolClass>('/api/admin/classes', payload)
|
return apiPost<SchoolClass>('/api/admin/classes', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateClass(classId: number, payload: { name?: string; description?: string; is_active?: boolean }) {
|
||||||
|
return apiPut<SchoolClass>(`/api/admin/classes/${classId}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function addClassMember(classId: number, payload: { user_id: number; role?: string }) {
|
export function addClassMember(classId: number, payload: { user_id: number; role?: string }) {
|
||||||
return apiPost<SchoolClass>(`/api/admin/classes/${classId}/members`, payload)
|
return apiPost<SchoolClass>(`/api/admin/classes/${classId}/members`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addClassMembers(classId: number, payload: { user_ids: number[]; role?: string }) {
|
||||||
|
return apiPost<SchoolClass>(`/api/admin/classes/${classId}/members/batch`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchPapers(params?: Record<string, unknown>) {
|
export function fetchPapers(params?: Record<string, unknown>) {
|
||||||
return apiGet<PageData<Paper>>('/api/admin/papers', params)
|
return apiGet<PageData<Paper>>('/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)
|
return apiPost('/api/admin/invite-codes', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteInvite(inviteId: number) {
|
||||||
|
return apiDelete<null>(`/api/admin/invite-codes/${inviteId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchPermissions() {
|
export function fetchPermissions() {
|
||||||
return apiGet<{
|
return apiGet<{
|
||||||
permissions: Permission[]
|
permissions: Permission[]
|
||||||
|
|||||||
@ -243,3 +243,25 @@ export interface ReportBankDetail {
|
|||||||
types: ReportDimension[]
|
types: ReportDimension[]
|
||||||
question_errors: ReportQuestionError[]
|
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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|||||||
@ -1,25 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Plus, UserFilled } from '@element-plus/icons-vue'
|
import { Edit, Plus, UserFilled } from '@element-plus/icons-vue'
|
||||||
import { addClassMember, createClass, fetchClasses, fetchUsers } from '@/api/admin'
|
import { addClassMembers, createClass, fetchClasses, fetchUsers, updateClass } from '@/api/admin'
|
||||||
import type { SchoolClass, User } from '@/types/api'
|
import type { SchoolClass, User } from '@/types/api'
|
||||||
|
|
||||||
const loading = shallowRef(false)
|
const loading = shallowRef(false)
|
||||||
const dialogVisible = shallowRef(false)
|
const dialogVisible = shallowRef(false)
|
||||||
const memberVisible = shallowRef(false)
|
const memberVisible = shallowRef(false)
|
||||||
|
const dialogMode = shallowRef<'create' | 'edit'>('create')
|
||||||
const classes = shallowRef<SchoolClass[]>([])
|
const classes = shallowRef<SchoolClass[]>([])
|
||||||
const users = shallowRef<User[]>([])
|
const users = shallowRef<User[]>([])
|
||||||
const selectedClass = shallowRef<SchoolClass | null>(null)
|
const selectedClass = shallowRef<SchoolClass | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
is_active: true,
|
||||||
})
|
})
|
||||||
const memberForm = reactive({
|
const memberForm = reactive({
|
||||||
user_id: undefined as number | undefined,
|
user_ids: [] as number[],
|
||||||
role: 'student',
|
role: 'student',
|
||||||
})
|
})
|
||||||
const activeUsers = computed(() => users.value.filter((item) => item.is_active))
|
const activeUsers = computed(() => users.value.filter((item) => item.is_active))
|
||||||
|
const dialogTitle = computed(() => (dialogMode.value === 'create' ? '新建班级' : '编辑班级'))
|
||||||
|
|
||||||
async function loadClasses() {
|
async function loadClasses() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -36,26 +39,52 @@ async function loadUsers() {
|
|||||||
users.value = response.data.items
|
users.value = response.data.items
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitClass() {
|
function resetForm() {
|
||||||
await createClass(form)
|
|
||||||
ElMessage.success('班级已创建')
|
|
||||||
dialogVisible.value = false
|
|
||||||
form.name = ''
|
form.name = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
|
form.is_active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
selectedClass.value = null
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: SchoolClass) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
selectedClass.value = row
|
||||||
|
form.name = row.name
|
||||||
|
form.description = row.description ?? ''
|
||||||
|
form.is_active = row.is_active
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitClass() {
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await createClass({ name: form.name, description: form.description })
|
||||||
|
ElMessage.success('班级已创建')
|
||||||
|
} else if (selectedClass.value) {
|
||||||
|
await updateClass(selectedClass.value.id, form)
|
||||||
|
ElMessage.success('班级已更新')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
resetForm()
|
||||||
await loadClasses()
|
await loadClasses()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMemberDialog(row: SchoolClass) {
|
function openMemberDialog(row: SchoolClass) {
|
||||||
selectedClass.value = row
|
selectedClass.value = row
|
||||||
memberForm.user_id = undefined
|
memberForm.user_ids = []
|
||||||
memberForm.role = 'student'
|
memberForm.role = 'student'
|
||||||
memberVisible.value = true
|
memberVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMember() {
|
async function submitMember() {
|
||||||
if (!selectedClass.value || !memberForm.user_id) return
|
if (!selectedClass.value || memberForm.user_ids.length === 0) return
|
||||||
await addClassMember(selectedClass.value.id, {
|
await addClassMembers(selectedClass.value.id, {
|
||||||
user_id: memberForm.user_id,
|
user_ids: memberForm.user_ids,
|
||||||
role: memberForm.role,
|
role: memberForm.role,
|
||||||
})
|
})
|
||||||
ElMessage.success('成员已加入班级')
|
ElMessage.success('成员已加入班级')
|
||||||
@ -76,7 +105,7 @@ onMounted(() => {
|
|||||||
<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 :icon="Plus" type="primary" @click="dialogVisible = true">新建班级</ElButton>
|
<ElButton :icon="Plus" type="primary" @click="openCreateDialog">新建班级</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElTable v-loading="loading" :data="classes" row-key="id">
|
<ElTable v-loading="loading" :data="classes" row-key="id">
|
||||||
@ -88,15 +117,21 @@ onMounted(() => {
|
|||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn prop="members_count" label="成员数" width="100" />
|
<ElTableColumn prop="members_count" label="成员数" width="100" />
|
||||||
<ElTableColumn prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
<ElTableColumn prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||||
<ElTableColumn label="操作" width="140">
|
<ElTableColumn prop="is_active" label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '启用' : '停用' }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="230" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton :icon="Edit" size="small" @click="openEditDialog(row)">编辑</ElButton>
|
||||||
<ElButton :icon="UserFilled" size="small" @click="openMemberDialog(row)">分配成员</ElButton>
|
<ElButton :icon="UserFilled" size="small" @click="openMemberDialog(row)">分配成员</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ElDialog v-model="dialogVisible" title="新建班级" width="420px">
|
<ElDialog v-model="dialogVisible" :title="dialogTitle" width="420px">
|
||||||
<ElForm :model="form" label-position="top">
|
<ElForm :model="form" label-position="top">
|
||||||
<ElFormItem label="名称">
|
<ElFormItem label="名称">
|
||||||
<ElInput v-model="form.name" />
|
<ElInput v-model="form.name" />
|
||||||
@ -104,6 +139,9 @@ onMounted(() => {
|
|||||||
<ElFormItem label="说明">
|
<ElFormItem label="说明">
|
||||||
<ElInput v-model="form.description" type="textarea" />
|
<ElInput v-model="form.description" type="textarea" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="dialogMode === 'edit'" label="状态">
|
||||||
|
<ElSwitch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
@ -114,7 +152,7 @@ onMounted(() => {
|
|||||||
<ElDialog v-model="memberVisible" title="分配成员" width="420px">
|
<ElDialog v-model="memberVisible" title="分配成员" width="420px">
|
||||||
<ElForm :model="memberForm" label-position="top">
|
<ElForm :model="memberForm" label-position="top">
|
||||||
<ElFormItem label="用户">
|
<ElFormItem label="用户">
|
||||||
<ElSelect v-model="memberForm.user_id" class="w-full" filterable>
|
<ElSelect v-model="memberForm.user_ids" class="w-full" filterable multiple collapse-tags collapse-tags-tooltip placeholder="可一次选择多个用户">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="item in activeUsers"
|
v-for="item in activeUsers"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
|||||||
@ -1,21 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const stats = [
|
import { computed, onMounted, shallowRef } from 'vue'
|
||||||
{ label: '题库', value: '0', tone: 'moss' },
|
import VChart from 'vue-echarts'
|
||||||
{ label: '题目', value: '0', tone: 'indigo' },
|
import { use } from 'echarts/core'
|
||||||
{ label: '今日作答', value: '0', tone: 'amber' },
|
import { BarChart } from 'echarts/charts'
|
||||||
{ label: '平均正确率', value: '--', tone: 'moss' },
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||||
]
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { fetchDashboard } from '@/api/admin'
|
||||||
|
import type { DashboardData } from '@/types/api'
|
||||||
|
|
||||||
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||||||
|
|
||||||
|
const loading = shallowRef(false)
|
||||||
|
const dashboard = shallowRef<DashboardData | null>(null)
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: '题库', value: dashboard.value?.stats.banks ?? 0 },
|
||||||
|
{ label: '题目', value: dashboard.value?.stats.questions ?? 0 },
|
||||||
|
{ label: '今日作答', value: dashboard.value?.stats.today_answered ?? 0 },
|
||||||
|
{ label: '平均正确率', value: `${dashboard.value?.stats.accuracy ?? 0}%` },
|
||||||
|
{ label: '当前错题', value: dashboard.value?.stats.wrong_questions ?? 0 },
|
||||||
|
{ label: '用户', value: dashboard.value?.stats.users ?? '-' },
|
||||||
|
])
|
||||||
|
const trendOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 36, right: 16, top: 24, bottom: 28 },
|
||||||
|
xAxis: { type: 'category', data: (dashboard.value?.trend ?? []).map((item) => item.day) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{ name: '作答题数', type: 'bar', data: (dashboard.value?.trend ?? []).map((item) => item.answered_count) }],
|
||||||
|
}))
|
||||||
|
const modeLabels: Record<string, string> = {
|
||||||
|
memorize: '背题',
|
||||||
|
sequence: '顺序刷题',
|
||||||
|
random: '随机刷题',
|
||||||
|
paper: '整卷测试',
|
||||||
|
practice: '刷题',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
dashboard.value = (await fetchDashboard()).data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard-grid">
|
<div v-loading="loading" class="dashboard-grid">
|
||||||
<section v-for="item in stats" :key="item.label" class="stat-card">
|
<section v-for="item in stats" :key="item.label" class="stat-card">
|
||||||
<p>{{ item.label }}</p>
|
<p>{{ item.label }}</p>
|
||||||
<strong>{{ item.value }}</strong>
|
<strong>{{ item.value }}</strong>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel dashboard-wide">
|
<section class="panel dashboard-wide">
|
||||||
<h2 class="page-title">今日工作</h2>
|
<div class="section-head">
|
||||||
<ElEmpty description="完成数据库配置并导入题库后,这里会显示练习趋势和班级排行。" />
|
<h2 class="page-title">近 7 天作答趋势</h2>
|
||||||
|
<ElButton text type="primary" @click="load">刷新</ElButton>
|
||||||
|
</div>
|
||||||
|
<VChart class="trend-chart" :option="trendOption" autoresize />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel dashboard-panel">
|
||||||
|
<h2 class="page-title mb-3">班级排行</h2>
|
||||||
|
<ElTable :data="dashboard?.class_ranking ?? []" row-key="id" height="280">
|
||||||
|
<ElTableColumn prop="name" label="班级" min-width="140" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="members_count" label="成员" width="80" />
|
||||||
|
<ElTableColumn prop="attempts" label="练习" width="80" />
|
||||||
|
</ElTable>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel dashboard-panel">
|
||||||
|
<h2 class="page-title mb-3">最近练习</h2>
|
||||||
|
<ElTable :data="dashboard?.recent_attempts ?? []" row-key="id" height="280">
|
||||||
|
<ElTableColumn prop="user_name" label="用户" min-width="100" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="bank_name" label="题库/试卷" min-width="140" show-overflow-tooltip />
|
||||||
|
<ElTableColumn label="模式" width="90">
|
||||||
|
<template #default="{ row }">{{ modeLabels[row.mode] || row.mode }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="正确" width="90">
|
||||||
|
<template #default="{ row }">{{ row.correct_count }}/{{ row.total_questions }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -23,7 +90,7 @@ const stats = [
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,9 +115,30 @@ const stats = [
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.dashboard-panel {
|
||||||
|
grid-column: span 3;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, reactive, shallowRef, watch } from 'vue'
|
import { computed, onMounted, reactive, shallowRef, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Delete, Edit } from '@element-plus/icons-vue'
|
import { Delete, Edit } from '@element-plus/icons-vue'
|
||||||
import { createInvite, createUser, fetchInvites, fetchUsers, updateUser } from '@/api/admin'
|
import { createInvite, createUser, deleteInvite, fetchInvites, fetchUsers, updateUser } from '@/api/admin'
|
||||||
import type { User } from '@/types/api'
|
import type { User } from '@/types/api'
|
||||||
|
|
||||||
const users = shallowRef<User[]>([])
|
const users = shallowRef<User[]>([])
|
||||||
@ -124,6 +124,17 @@ async function copyInviteLink(row: Record<string, unknown>) {
|
|||||||
await copyText(registerLink(String(row.code ?? '')), '注册链接已复制')
|
await copyText(registerLink(String(row.code ?? '')), '注册链接已复制')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeInvite(row: Record<string, unknown>) {
|
||||||
|
await ElMessageBox.confirm(`确定删除邀请码 ${String(row.code ?? '')} 吗?`, '删除邀请码', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteInvite(Number(row.id))
|
||||||
|
ElMessage.success('邀请码已删除')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveInvite() {
|
async function saveInvite() {
|
||||||
if (inviteForm.assigned_name.trim()) {
|
if (inviteForm.assigned_name.trim()) {
|
||||||
inviteForm.max_uses = 1
|
inviteForm.max_uses = 1
|
||||||
@ -193,10 +204,11 @@ onMounted(load)
|
|||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn prop="max_uses" label="可用次数" />
|
<ElTableColumn prop="max_uses" label="可用次数" />
|
||||||
<ElTableColumn prop="used_count" label="已使用" />
|
<ElTableColumn prop="used_count" label="已使用" />
|
||||||
<ElTableColumn label="操作" width="220" fixed="right">
|
<ElTableColumn label="操作" width="320" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElButton size="small" @click="copyInviteCode(row)">复制分享码</ElButton>
|
<ElButton size="small" @click="copyInviteCode(row)">复制分享码</ElButton>
|
||||||
<ElButton size="small" type="primary" plain @click="copyInviteLink(row)">复制分享链接</ElButton>
|
<ElButton size="small" type="primary" plain @click="copyInviteLink(row)">复制分享链接</ElButton>
|
||||||
|
<ElButton :icon="Delete" size="small" type="danger" plain @click="removeInvite(row)">删除</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user