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, '班级已创建');
|
||||
}
|
||||
|
||||
#[Apidoc\Title('更新班级')]
|
||||
#[Apidoc\Url('/admin/classes/{class}')]
|
||||
#[Apidoc\Method('PUT')]
|
||||
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
||||
public function update(Request $request, mixed $class): JsonResponse
|
||||
{
|
||||
$class = $this->resolveClass($class);
|
||||
abort_if($request->user()->role !== 'admin' && $class->owner_id !== $request->user()->id, 403, '权限不足');
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$class->update($data);
|
||||
|
||||
return ApiResponse::success($class->fresh()->loadCount('members'), '班级已更新');
|
||||
}
|
||||
|
||||
#[Apidoc\Title('分配成员')]
|
||||
#[Apidoc\Url('/admin/classes/{class}/members')]
|
||||
#[Apidoc\Method('POST')]
|
||||
@ -72,6 +92,31 @@ final class ClassController extends Controller
|
||||
return ApiResponse::success($class->load('members'), '成员已加入');
|
||||
}
|
||||
|
||||
#[Apidoc\Title('批量分配成员')]
|
||||
#[Apidoc\Url('/admin/classes/{class}/members/batch')]
|
||||
#[Apidoc\Method('POST')]
|
||||
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
||||
public function addMembers(Request $request, mixed $class): JsonResponse
|
||||
{
|
||||
$class = $this->resolveClass($class);
|
||||
abort_if($request->user()->role !== 'admin' && $class->owner_id !== $request->user()->id, 403, '权限不足');
|
||||
|
||||
$data = $request->validate([
|
||||
'user_ids' => ['required', 'array', 'min:1'],
|
||||
'user_ids.*' => ['integer', 'exists:users,id'],
|
||||
'role' => ['nullable', 'in:student,assistant'],
|
||||
]);
|
||||
|
||||
$role = $data['role'] ?? 'student';
|
||||
$members = collect($data['user_ids'])
|
||||
->unique()
|
||||
->mapWithKeys(fn (int $userId): array => [$userId => ['role' => $role]])
|
||||
->all();
|
||||
$class->members()->syncWithoutDetaching($members);
|
||||
|
||||
return ApiResponse::success($class->fresh()->loadCount('members'), '成员已加入');
|
||||
}
|
||||
|
||||
private function resolveClass(mixed $class): SchoolClass
|
||||
{
|
||||
if ($class instanceof SchoolClass && $class->exists) {
|
||||
|
||||
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, '邀请码已创建');
|
||||
}
|
||||
|
||||
#[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 type {
|
||||
ImportJob,
|
||||
DashboardData,
|
||||
PageData,
|
||||
Paper,
|
||||
Permission,
|
||||
@ -22,6 +23,10 @@ import type {
|
||||
User,
|
||||
} from '@/types/api'
|
||||
|
||||
export function fetchDashboard() {
|
||||
return apiGet<DashboardData>('/api/admin/dashboard')
|
||||
}
|
||||
|
||||
export function fetchBanks(params?: Record<string, unknown>) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
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>) {
|
||||
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)
|
||||
}
|
||||
|
||||
export function deleteInvite(inviteId: number) {
|
||||
return apiDelete<null>(`/api/admin/invite-codes/${inviteId}`)
|
||||
}
|
||||
|
||||
export function fetchPermissions() {
|
||||
return apiGet<{
|
||||
permissions: Permission[]
|
||||
|
||||
@ -243,3 +243,25 @@ export interface ReportBankDetail {
|
||||
types: ReportDimension[]
|
||||
question_errors: ReportQuestionError[]
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: {
|
||||
banks: number
|
||||
questions: number
|
||||
today_answered: number
|
||||
accuracy: number
|
||||
users?: number | null
|
||||
wrong_questions: number
|
||||
}
|
||||
trend: Array<{ day: string; answered_count: number }>
|
||||
class_ranking: Array<{ id: number; name: string; members_count: number; attempts: number }>
|
||||
recent_attempts: Array<{
|
||||
id: number
|
||||
user_name: string
|
||||
bank_name: string
|
||||
mode: string
|
||||
total_questions: number
|
||||
correct_count: number
|
||||
started_at: string
|
||||
}>
|
||||
}
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, UserFilled } from '@element-plus/icons-vue'
|
||||
import { addClassMember, createClass, fetchClasses, fetchUsers } from '@/api/admin'
|
||||
import { Edit, Plus, UserFilled } from '@element-plus/icons-vue'
|
||||
import { addClassMembers, createClass, fetchClasses, fetchUsers, updateClass } from '@/api/admin'
|
||||
import type { SchoolClass, User } from '@/types/api'
|
||||
|
||||
const loading = shallowRef(false)
|
||||
const dialogVisible = shallowRef(false)
|
||||
const memberVisible = shallowRef(false)
|
||||
const dialogMode = shallowRef<'create' | 'edit'>('create')
|
||||
const classes = shallowRef<SchoolClass[]>([])
|
||||
const users = shallowRef<User[]>([])
|
||||
const selectedClass = shallowRef<SchoolClass | null>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
})
|
||||
const memberForm = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
user_ids: [] as number[],
|
||||
role: 'student',
|
||||
})
|
||||
const activeUsers = computed(() => users.value.filter((item) => item.is_active))
|
||||
const dialogTitle = computed(() => (dialogMode.value === 'create' ? '新建班级' : '编辑班级'))
|
||||
|
||||
async function loadClasses() {
|
||||
loading.value = true
|
||||
@ -36,26 +39,52 @@ async function loadUsers() {
|
||||
users.value = response.data.items
|
||||
}
|
||||
|
||||
async function submitClass() {
|
||||
await createClass(form)
|
||||
ElMessage.success('班级已创建')
|
||||
dialogVisible.value = false
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
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()
|
||||
}
|
||||
|
||||
function openMemberDialog(row: SchoolClass) {
|
||||
selectedClass.value = row
|
||||
memberForm.user_id = undefined
|
||||
memberForm.user_ids = []
|
||||
memberForm.role = 'student'
|
||||
memberVisible.value = true
|
||||
}
|
||||
|
||||
async function submitMember() {
|
||||
if (!selectedClass.value || !memberForm.user_id) return
|
||||
await addClassMember(selectedClass.value.id, {
|
||||
user_id: memberForm.user_id,
|
||||
if (!selectedClass.value || memberForm.user_ids.length === 0) return
|
||||
await addClassMembers(selectedClass.value.id, {
|
||||
user_ids: memberForm.user_ids,
|
||||
role: memberForm.role,
|
||||
})
|
||||
ElMessage.success('成员已加入班级')
|
||||
@ -76,7 +105,7 @@ onMounted(() => {
|
||||
<h1 class="page-title">班级管理</h1>
|
||||
<p class="muted text-sm mt-1">创建班级,使用班级码或后台分配成员。</p>
|
||||
</div>
|
||||
<ElButton :icon="Plus" type="primary" @click="dialogVisible = true">新建班级</ElButton>
|
||||
<ElButton :icon="Plus" type="primary" @click="openCreateDialog">新建班级</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="classes" row-key="id">
|
||||
@ -88,15 +117,21 @@ onMounted(() => {
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="members_count" label="成员数" width="100" />
|
||||
<ElTableColumn prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<ElTableColumn label="操作" width="140">
|
||||
<ElTableColumn prop="is_active" label="状态" width="100">
|
||||
<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>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</section>
|
||||
|
||||
<ElDialog v-model="dialogVisible" title="新建班级" width="420px">
|
||||
<ElDialog v-model="dialogVisible" :title="dialogTitle" width="420px">
|
||||
<ElForm :model="form" label-position="top">
|
||||
<ElFormItem label="名称">
|
||||
<ElInput v-model="form.name" />
|
||||
@ -104,6 +139,9 @@ onMounted(() => {
|
||||
<ElFormItem label="说明">
|
||||
<ElInput v-model="form.description" type="textarea" />
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="dialogMode === 'edit'" label="状态">
|
||||
<ElSwitch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
@ -114,7 +152,7 @@ onMounted(() => {
|
||||
<ElDialog v-model="memberVisible" title="分配成员" width="420px">
|
||||
<ElForm :model="memberForm" label-position="top">
|
||||
<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
|
||||
v-for="item in activeUsers"
|
||||
:key="item.id"
|
||||
|
||||
@ -1,21 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
const stats = [
|
||||
{ label: '题库', value: '0', tone: 'moss' },
|
||||
{ label: '题目', value: '0', tone: 'indigo' },
|
||||
{ label: '今日作答', value: '0', tone: 'amber' },
|
||||
{ label: '平均正确率', value: '--', tone: 'moss' },
|
||||
]
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-grid">
|
||||
<div v-loading="loading" class="dashboard-grid">
|
||||
<section v-for="item in stats" :key="item.label" class="stat-card">
|
||||
<p>{{ item.label }}</p>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</section>
|
||||
|
||||
<section class="panel dashboard-wide">
|
||||
<h2 class="page-title">今日工作</h2>
|
||||
<ElEmpty description="完成数据库配置并导入题库后,这里会显示练习趋势和班级排行。" />
|
||||
<div class="section-head">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@ -23,7 +90,7 @@ const stats = [
|
||||
<style scoped>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@ -48,9 +115,30 @@ const stats = [
|
||||
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 {
|
||||
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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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'
|
||||
|
||||
const users = shallowRef<User[]>([])
|
||||
@ -124,6 +124,17 @@ async function copyInviteLink(row: Record<string, unknown>) {
|
||||
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() {
|
||||
if (inviteForm.assigned_name.trim()) {
|
||||
inviteForm.max_uses = 1
|
||||
@ -193,10 +204,11 @@ onMounted(load)
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="max_uses" label="可用次数" />
|
||||
<ElTableColumn prop="used_count" label="已使用" />
|
||||
<ElTableColumn label="操作" width="220" fixed="right">
|
||||
<ElTableColumn label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton size="small" @click="copyInviteCode(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>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user