Add admin dashboard and class management actions

This commit is contained in:
Boen_Shi 2026-06-29 14:42:40 +08:00
parent 829b99ad48
commit a8095be4d8
9 changed files with 354 additions and 29 deletions

View File

@ -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) {

View 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,
]);
}
}

View File

@ -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

Binary file not shown.

View File

@ -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[]

View File

@ -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
}>
}

View File

@ -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"

View File

@ -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;
} }
} }

View File

@ -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>