This commit is contained in:
Denis 2026-03-30 23:08:02 +03:00
commit 000134d629
28 changed files with 9243 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
API_BASE_URL=http://localhost:8080/api

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
node_modules/
dist/
.quasar/
.env
.env.*
!.env.example
coverage/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.idea/
.vscode/
.DS_Store
Thumbs.db
.claude/

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title>GameAdmin</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

6031
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "game-admin-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"lint": "eslint --ext .js,.vue src"
},
"dependencies": {
"@quasar/extras": "^1.16.9",
"axios": "^1.6.2",
"pinia": "^2.1.7",
"quasar": "^2.14.1",
"vue": "^3.4.3",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@quasar/app-vite": "^1.7.3",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"vite": "^5.0.10"
}
}

5
postcss.config.cjs Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer'),
],
}

23
quasar.config.js Normal file
View File

@ -0,0 +1,23 @@
const { configure } = require('quasar/wrappers')
module.exports = configure(function () {
return {
boot: ['axios'],
css: ['app.scss'],
extras: ['roboto-font', 'material-icons'],
build: {
target: { browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'] },
vueRouterMode: 'history',
env: {
API_BASE_URL: process.env.API_BASE_URL || 'http://localhost:8080/api',
},
},
devServer: {
port: 9000,
open: false,
},
framework: {
plugins: ['Notify', 'Dialog', 'Loading'],
},
}
})

3
src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

33
src/boot/axios.js Normal file
View File

@ -0,0 +1,33 @@
import { boot } from 'quasar/wrappers'
import axios from 'axios'
const api = axios.create({
baseURL: process.env.API_BASE_URL,
timeout: 10000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default boot(({ app }) => {
app.config.globalProperties.$api = api
})
export { api }

80
src/css/app.scss Normal file
View File

@ -0,0 +1,80 @@
// Palette
$primary: #1e293b;
$secondary: #f59e0b;
$accent: #e63946;
$dark: #1e293b;
$positive: #22c55e;
$negative: #ef4444;
$info: #3b82f6;
$warning: #f59e0b;
$dark-page: #f3f4f6;
body {
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: #f3f4f6;
}
// Custom scrollbar
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; }
// Cards
.admin-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
transition: border-color 0.2s;
&:hover { border-color: #d1d5db; }
}
// Stat badge
.stat-value {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.5;
}
// Role chips
.role-admin { background: rgba($accent, 0.12) !important; color: $accent !important; }
.role-moderator { background: rgba($secondary, 0.15) !important; color: #b45309 !important; }
.role-user { background: rgba(#6b7280, 0.12) !important; color: #6b7280 !important; }
// Table tweaks
.q-table {
.q-table__top, .q-table__bottom { background: #ffffff; }
thead tr th { background: #f9fafb; font-weight: 700; text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.06em; color: #6b7280; }
tbody tr { transition: background 0.15s; }
tbody tr:hover { background: #f3f4f6 !important; }
}
// Login / Register
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
}
.auth-card {
width: 100%;
max-width: 420px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 40px;
}
.auth-title {
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.gold-accent { color: $secondary; }

114
src/layouts/MainLayout.vue Normal file
View File

@ -0,0 +1,114 @@
<template>
<q-layout view="lHh LpR lff" class="bg-grey-1">
<!-- Sidebar -->
<q-drawer v-model="drawer" :width="240" bordered class="bg-white">
<div class="q-pa-lg q-mb-md">
<div class="text-h6 text-weight-bold text-grey-9">
<span class="gold-accent">Game</span>Admin
</div>
<div class="text-caption text-grey-6">{{ auth.user?.username }}</div>
</div>
<q-list padding>
<template v-for="item in menuGroups" :key="item.label ?? item.to">
<!-- group header -->
<div v-if="item.group" class="text-caption text-grey-5 text-uppercase q-px-md q-pt-md q-pb-xs"
style="letter-spacing:0.08em;font-size:0.68rem">
{{ item.group }}
</div>
<!-- nav item -->
<q-item
v-else
:to="item.to"
clickable
active-class="text-grey-9 bg-grey-2"
class="rounded-borders q-mx-sm q-my-xs"
>
<q-item-section avatar>
<q-icon :name="item.icon" size="20px" color="grey-7" />
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium text-grey-8">{{ item.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-list>
<q-space />
<div class="q-pa-md">
<q-btn
flat
dense
no-caps
icon="logout"
label="Выйти"
color="grey-6"
class="full-width"
@click="handleLogout"
/>
</div>
</q-drawer>
<!-- Header -->
<q-header bordered class="bg-white text-dark">
<q-toolbar>
<q-btn flat dense round icon="menu" color="grey-7" @click="drawer = !drawer" />
<q-toolbar-title class="text-subtitle1 text-weight-bold text-grey-9">
{{ $route.meta.title || pageTitle }}
</q-toolbar-title>
<q-chip
:class="'role-' + auth.user?.role"
dense
square
:label="auth.user?.role"
class="text-caption text-weight-bold q-px-sm"
/>
</q-toolbar>
</q-header>
<!-- Main -->
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const drawer = ref(true)
const menuGroups = [
{ label: 'Дашборд', icon: 'dashboard', to: '/dashboard' },
{ group: 'Управление' },
{ label: 'Пользователи', icon: 'people', to: '/users' },
{ label: 'Приглашения', icon: 'link', to: '/invites' },
{ group: 'Контент' },
{ label: 'Игры', icon: 'sports_esports', to: '/games' },
{ group: 'Система' },
{ label: 'Настройки', icon: 'settings', to: '/settings' },
]
const pageTitles = {
'/dashboard': 'Дашборд',
'/users': 'Пользователи',
'/games': 'Игры',
'/invites': 'Приглашения',
'/settings': 'Настройки',
}
const pageTitle = computed(() => pageTitles[route.path] || '')
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>

102
src/pages/DashboardPage.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<q-page class="q-pa-lg">
<div class="text-h5 text-grey-9 text-weight-bold q-mb-lg">Дашборд</div>
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-sm-6 col-md-3" v-for="stat in stats" :key="stat.label">
<div class="admin-card q-pa-lg">
<div class="row items-center q-gutter-sm">
<q-icon :name="stat.icon" :color="stat.color" size="28px" />
<div>
<div class="stat-value text-grey-9">{{ stat.value }}</div>
<div class="stat-label text-grey-6">{{ stat.label }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="admin-card q-pa-lg">
<div class="text-subtitle1 text-grey-9 text-weight-bold q-mb-md">Последние пользователи</div>
<q-list separator>
<q-item v-for="u in recentUsers" :key="u.id" clickable :to="'/users/' + u.id">
<q-item-section avatar>
<q-avatar color="grey-2" text-color="grey-8" size="36px">
{{ u.username?.[0]?.toUpperCase() }}
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label class="text-grey-9">{{ u.username }}</q-item-label>
<q-item-label caption class="text-grey-6">{{ u.email }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-chip dense square :class="'role-' + u.role" :label="u.role" size="sm" />
</q-item-section>
</q-item>
<q-item v-if="!recentUsers.length">
<q-item-section class="text-grey-6">Нет пользователей</q-item-section>
</q-item>
</q-list>
</div>
</div>
<div class="col-12 col-md-6">
<div class="admin-card q-pa-lg">
<div class="text-subtitle1 text-grey-9 text-weight-bold q-mb-md">Список игр</div>
<q-list separator>
<q-item v-for="g in games" :key="g.id">
<q-item-section avatar>
<q-icon name="sports_esports" color="amber-7" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey-9">{{ g.name }}</q-item-label>
<q-item-label caption class="text-grey-6">{{ g.slug }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="g.is_active ? 'positive' : 'grey'" :label="g.is_active ? 'Active' : 'Off'" />
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from 'src/boot/axios'
const stats = ref([
{ label: 'Пользователи', value: '—', icon: 'people', color: 'amber-7' },
{ label: 'Игры', value: '—', icon: 'sports_esports', color: 'blue-5' },
{ label: 'Приглашения', value: '—', icon: 'link', color: 'purple-4' },
{ label: 'Активных', value: '—', icon: 'check_circle', color: 'positive' },
])
const recentUsers = ref([])
const games = ref([])
onMounted(async () => {
try {
const [usersRes, gamesRes, invitesRes] = await Promise.all([
api.get('/users'),
api.get('/games'),
api.get('/invites'),
])
const users = usersRes.data || []
games.value = gamesRes.data || []
const invites = invitesRes.data || []
stats.value[0].value = users.length
stats.value[1].value = games.value.length
stats.value[2].value = invites.length
stats.value[3].value = users.filter(u => u.is_active).length
recentUsers.value = users.slice(0, 5)
} catch (e) {
console.error(e)
}
})
</script>

View File

@ -0,0 +1,9 @@
<template>
<div class="auth-page">
<div class="text-center">
<div class="text-h1 text-yellow-8 text-weight-bold">404</div>
<div class="text-h6 text-grey-5 q-mb-lg">Страница не найдена</div>
<q-btn to="/dashboard" color="yellow-8" text-color="black" label="На главную" no-caps unelevated style="border-radius: 8px" />
</div>
</div>
</template>

View File

@ -0,0 +1,532 @@
<template>
<q-page class="q-pa-lg">
<!-- Header -->
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" color="grey-6" class="q-mr-sm"
@click="$router.push(`/games/${gameId}/leaderboards`)" />
<div>
<div class="text-h5 text-grey-9 text-weight-bold">{{ leaderboard?.name || '...' }}</div>
<div class="text-caption text-grey-6">{{ gameName }} · {{ leaderboard?.key }}</div>
</div>
<q-space />
<q-chip v-if="leaderboard" dense color="blue-1" text-color="blue-8" class="text-caption">
{{ leaderboard.sort_order === 'desc' ? '↓ desc' : '↑ asc' }}
</q-chip>
<q-chip v-if="leaderboard" dense color="purple-1" text-color="purple-8" class="text-caption q-ml-xs">
{{ periodLabel(leaderboard.period_type) }}
</q-chip>
<q-badge v-if="leaderboard" :color="leaderboard.is_active ? 'positive' : 'grey'"
:label="leaderboard.is_active ? 'Активна' : 'Неактивна'" class="q-ml-xs" />
</div>
<!-- Tabs -->
<q-tabs v-model="tab" align="left" dense no-caps class="q-mb-lg"
active-color="grey-9" indicator-color="amber-8" content-class="text-grey-6">
<q-tab name="groups" icon="group" label="Группы" />
<q-tab name="rankings" icon="leaderboard" label="Рейтинг" />
</q-tabs>
<!-- -->
<!-- Tab: Groups -->
<!-- -->
<div v-if="tab === 'groups'">
<div class="row items-center q-mb-md">
<div class="text-subtitle1 text-grey-9 text-weight-bold">Группы</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить группу" no-caps unelevated
@click="openCreateGroup" style="border-radius:8px" />
</div>
<div v-if="groupsLoading" class="text-center q-pa-lg">
<q-spinner color="grey-5" size="32px" />
</div>
<div v-else-if="!groups.length" class="admin-card q-pa-lg text-center text-grey-5">
Нет групп
</div>
<q-list v-else class="admin-card" separator>
<q-expansion-item
v-for="group in groups" :key="group.id"
:label="group.name"
:caption="`key: ${group.key}${group.is_default ? ' · по умолчанию' : ''}`"
expand-separator
class="q-pa-none"
>
<template #header>
<q-item-section avatar>
<q-icon name="group" color="blue-5" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey-9 text-weight-medium">
{{ group.name }}
<q-badge v-if="group.is_default" color="amber-8" label="default" class="q-ml-xs" />
</q-item-label>
<q-item-label caption class="text-grey-5">{{ group.key }}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row q-gutter-xs">
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click.stop="openEditGroup(group)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click.stop="deleteGroup(group)" />
</div>
</q-item-section>
</template>
<!-- Members -->
<div class="q-pa-md q-pl-lg" style="background:#f9fafb">
<div class="row items-center q-mb-sm">
<div class="text-caption text-grey-6 text-weight-bold text-uppercase" style="letter-spacing:.06em">
Участники ({{ group.members?.length || 0 }})
</div>
<q-space />
<q-btn flat dense no-caps icon="person_add" label="Добавить" color="blue-6" size="sm"
@click="openAddMember(group)" />
</div>
<div v-if="!group.members?.length" class="text-grey-5 text-caption">
Нет участников
</div>
<div v-else class="row q-gutter-sm flex-wrap">
<q-chip
v-for="m in group.members" :key="m.id"
dense removable color="grey-2" text-color="grey-8"
@remove="removeMember(group, m)"
>
{{ usernameMap[m.user_id] || `user #${m.user_id}` }}
</q-chip>
</div>
</div>
</q-expansion-item>
</q-list>
</div>
<!-- -->
<!-- Tab: Rankings -->
<!-- -->
<div v-if="tab === 'rankings'">
<!-- filters -->
<div class="row q-col-gutter-md items-end q-mb-md">
<div class="col-12 col-sm-4">
<q-select v-model="rankFilter.group_id" :options="groupOptions" label="Группа (фильтр)"
outlined dense emit-value map-options clearable />
</div>
<div class="col-12 col-sm-2">
<q-input v-model.number="rankFilter.limit" label="Лимит" type="number" outlined dense />
</div>
<div class="col-auto">
<q-btn unelevated color="grey-2" text-color="grey-8" icon="refresh" label="Загрузить"
no-caps @click="fetchRankings" style="border-radius:8px" />
</div>
<q-space />
<div class="col-auto">
<q-btn color="amber-8" text-color="white" icon="add" label="Записать счёт"
no-caps unelevated @click="openNewScore" style="border-radius:8px" />
</div>
</div>
<div class="admin-card">
<q-table :rows="rankings" :columns="rankColumns" row-key="rank" flat
:loading="rankingsLoading" :pagination="{ rowsPerPage: 50 }" no-data-label="Нет данных">
<template #body-cell-rank="props">
<q-td :props="props">
<span :class="rankClass(props.row.rank)" class="text-weight-bold">
{{ props.row.rank }}
</span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="edit" color="grey-6" size="sm"
@click="openEditScore(props.row)">
<q-tooltip>Изменить счёт</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</div>
</div>
<!-- Group create/edit dialog -->
<q-dialog v-model="groupDialog" persistent>
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">{{ isEditGroup ? 'Редактировать группу' : 'Новая группа' }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="groupForm.key" label="Ключ (напр. vip)" outlined dense
:disable="isEditGroup" :rules="[v => !!v || 'Обязательно']" />
<q-input v-model="groupForm.name" label="Название" outlined dense
:rules="[v => !!v || 'Обязательно']" />
<q-toggle v-model="groupForm.is_default" label="По умолчанию" color="amber-8" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat :label="isEditGroup ? 'Сохранить' : 'Создать'" color="amber-8" @click="submitGroup" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Add member dialog -->
<q-dialog v-model="memberDialog" persistent>
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Добавить участника</div>
<div class="text-caption text-grey-6">{{ memberTargetGroup?.name }}</div>
</q-card-section>
<q-card-section>
<q-select
v-model="memberUserId"
:options="availableUserOptions"
label="Пользователь"
outlined dense
emit-value map-options
use-input
input-debounce="0"
@filter="filterUsers"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Добавить" color="amber-8" @click="addMember" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Set / edit score dialog -->
<q-dialog v-model="scoreDialog" persistent>
<q-card style="min-width:400px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Записать счёт</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<!-- user search (only when creating new) -->
<template v-if="!scoreIsEdit">
<q-select
v-model="scoreSelectedUser"
:options="scoreUserOptions"
label="Пользователь"
outlined dense
emit-value map-options
use-input
:loading="scoreUserLoading"
input-debounce="300"
no-error-icon
@filter="searchScoreUsers"
@update:model-value="onScoreUserSelected"
>
<template #no-option>
<q-item><q-item-section class="text-grey-6">Ничего не найдено</q-item-section></q-item>
</template>
<template #prepend><q-icon name="search" color="grey-5" size="18px" /></template>
</q-select>
</template>
<!-- pre-selected user (when editing from ranking row) -->
<template v-else>
<q-input :model-value="scoreEditUsername" label="Пользователь" outlined dense readonly>
<template #prepend><q-icon name="person" color="grey-5" size="18px" /></template>
</q-input>
</template>
<!-- resolved user_game_id info -->
<div v-if="scoreForm.user_game_id" class="row items-center q-gutter-xs">
<q-icon name="check_circle" color="positive" size="16px" />
<span class="text-caption text-grey-6">user_game_id: <strong>{{ scoreForm.user_game_id }}</strong></span>
</div>
<div v-else-if="scoreSelectedUser && !scoreForm.user_game_id" class="row items-center q-gutter-xs">
<q-spinner size="14px" color="grey-5" />
<span class="text-caption text-grey-5">Определяю user_game_id...</span>
</div>
<q-input v-model.number="scoreForm.score" label="Счёт" type="number"
outlined dense :rules="[v => v !== null && v !== '' || 'Обязательно']" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Сохранить" color="amber-8"
:disable="!scoreForm.user_game_id"
@click="submitScore" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const route = useRoute()
const gameId = route.params.id
const leaderboardId = route.params.leaderboard_id
const tab = ref('groups')
const leaderboard = ref(null)
const gameName = ref('')
// Groups
const groups = ref([])
const groupsLoading = ref(false)
const groupDialog = ref(false)
const isEditGroup = ref(false)
const editGroupId = ref(null)
const groupForm = ref({ key: '', name: '', is_default: false })
// Members
const memberDialog = ref(false)
const memberTargetGroup = ref(null)
const memberUserId = ref(null)
const allUsers = ref([])
const filteredUserOptions = ref([])
const usernameMap = ref({})
// Rankings
const rankings = ref([])
const rankingsLoading = ref(false)
const rankFilter = ref({ group_id: null, limit: 50 })
const scoreDialog = ref(false)
const scoreForm = ref({ user_game_id: null, score: null })
const scoreIsEdit = ref(false)
const scoreEditUsername = ref('')
const scoreSelectedUser = ref(null)
const scoreUserOptions = ref([])
const scoreUserLoading = ref(false)
const rankColumns = [
{ name: 'rank', label: '#', field: 'rank', align: 'left', style: 'width:60px' },
{ name: 'username', label: 'Пользователь', field: 'username', align: 'left' },
{ name: 'score', label: 'Счёт', field: 'score', sortable: true, align: 'left' },
{ name: 'user_game_id', label: 'user_game_id', field: 'user_game_id', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
const groupOptions = computed(() => [
{ label: 'Все группы', value: null },
...groups.value.map(g => ({ label: g.name, value: g.id })),
])
const availableUserOptions = ref([])
function filterUsers(val, update) {
const needle = val.toLowerCase()
update(() => {
const inGroup = new Set((memberTargetGroup.value?.members || []).map(m => m.user_id))
availableUserOptions.value = allUsers.value
.filter(u => !inGroup.has(u.id) && (u.username.toLowerCase().includes(needle) || String(u.id).includes(needle)))
.map(u => ({ label: `${u.username} (#${u.id})`, value: u.id }))
})
}
function periodLabel(v) {
return { all_time: 'Всё время', daily: 'День', weekly: 'Неделя', monthly: 'Месяц' }[v] || v
}
function rankClass(rank) {
if (rank === 1) return 'text-amber-8'
if (rank === 2) return 'text-grey-6'
if (rank === 3) return 'text-orange-7'
return 'text-grey-7'
}
// Group CRUD
function openCreateGroup() {
isEditGroup.value = false
editGroupId.value = null
groupForm.value = { key: '', name: '', is_default: false }
groupDialog.value = true
}
function openEditGroup(group) {
isEditGroup.value = true
editGroupId.value = group.id
groupForm.value = { key: group.key, name: group.name, is_default: group.is_default }
groupDialog.value = true
}
async function submitGroup() {
try {
if (isEditGroup.value) {
await api.put(`/leaderboard-groups/${editGroupId.value}`, groupForm.value)
$q.notify({ type: 'positive', message: 'Группа обновлена' })
} else {
await api.post(`/leaderboards/${leaderboardId}/groups`, groupForm.value)
$q.notify({ type: 'positive', message: 'Группа создана' })
}
groupDialog.value = false
fetchGroups()
} catch (e) {
const msg = e.response?.status === 409 ? 'Ключ уже занят' : e.response?.data?.error || 'Ошибка'
$q.notify({ type: 'negative', message: msg })
}
}
async function deleteGroup(group) {
$q.dialog({ title: 'Удалить группу?', message: group.name, cancel: true }).onOk(async () => {
try {
await api.delete(`/leaderboard-groups/${group.id}`)
$q.notify({ type: 'positive', message: 'Группа удалена' })
fetchGroups()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
// Members
function openAddMember(group) {
memberTargetGroup.value = group
memberUserId.value = null
availableUserOptions.value = allUsers.value
.filter(u => !(group.members || []).some(m => m.user_id === u.id))
.map(u => ({ label: `${u.username} (#${u.id})`, value: u.id }))
memberDialog.value = true
}
async function addMember() {
if (!memberUserId.value) return
try {
await api.post(`/leaderboard-groups/${memberTargetGroup.value.id}/members`, { user_id: memberUserId.value })
$q.notify({ type: 'positive', message: 'Участник добавлен' })
memberDialog.value = false
fetchGroups()
} catch (e) {
const msg = e.response?.status === 409 ? 'Пользователь уже в группе'
: e.response?.data?.error || 'Ошибка'
$q.notify({ type: 'negative', message: msg })
}
}
async function removeMember(group, member) {
try {
await api.delete(`/leaderboard-groups/${group.id}/members/${member.user_id}`)
group.members = group.members.filter(m => m.id !== member.id)
$q.notify({ type: 'positive', message: 'Участник удалён', timeout: 1500 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
}
// Scores
function openNewScore() {
scoreIsEdit.value = false
scoreEditUsername.value = ''
scoreSelectedUser.value = null
scoreUserOptions.value = []
scoreForm.value = { user_game_id: null, score: null }
scoreDialog.value = true
}
function openEditScore(row) {
scoreIsEdit.value = true
scoreEditUsername.value = row.username
scoreSelectedUser.value = null
scoreForm.value = { user_game_id: row.user_game_id, score: row.score }
scoreDialog.value = true
}
async function searchScoreUsers(val, update, abort) {
if (!val || val.length < 1) { update(() => { scoreUserOptions.value = [] }); return }
scoreUserLoading.value = true
try {
const { data } = await api.get(`/games/${gameId}/users`, { params: { search: val } })
update(() => {
scoreUserOptions.value = (data || []).map(u => ({
label: `${u.username} · ${u.email}`,
value: u.id,
username: u.username,
}))
})
} catch (e) {
update(() => { scoreUserOptions.value = [] })
} finally {
scoreUserLoading.value = false
}
}
async function onScoreUserSelected(userId) {
if (!userId) return
scoreForm.value.user_game_id = null
try {
const { data } = await api.get(`/users/${userId}/games`)
const entry = (data || []).find(ug => ug.game_id == gameId)
if (entry) {
scoreForm.value.user_game_id = entry.id
} else {
$q.notify({ type: 'warning', message: 'Пользователь не подключён к этой игре' })
}
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка получения данных пользователя' })
}
}
async function submitScore() {
if (!scoreForm.value.user_game_id || scoreForm.value.score === null) return
try {
await api.post(`/leaderboards/${leaderboardId}/scores`, scoreForm.value)
$q.notify({ type: 'positive', message: 'Счёт записан' })
scoreDialog.value = false
fetchRankings()
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
}
}
// Fetch
async function fetchLeaderboard() {
try {
const { data } = await api.get(`/games/${gameId}/leaderboards/${leaderboardId}`)
leaderboard.value = data
} catch (e) { console.error(e) }
}
async function fetchGroups() {
groupsLoading.value = true
try {
const { data } = await api.get(`/leaderboards/${leaderboardId}/groups`)
groups.value = data || []
} catch (e) { console.error(e) }
finally { groupsLoading.value = false }
}
async function fetchRankings() {
rankingsLoading.value = true
try {
const params = { limit: rankFilter.value.limit || 50 }
if (rankFilter.value.group_id) params.group_id = rankFilter.value.group_id
const { data } = await api.get(`/leaderboards/${leaderboardId}/rankings`, { params })
rankings.value = data?.items || []
} catch (e) { console.error(e) }
finally { rankingsLoading.value = false }
}
async function fetchUsers() {
try {
const { data } = await api.get('/users')
allUsers.value = data || []
usernameMap.value = Object.fromEntries((data || []).map(u => [u.id, u.username]))
} catch (e) { console.error(e) }
}
async function fetchGame() {
try {
const { data } = await api.get('/games')
const g = (data || []).find(g => g.id == gameId)
if (g) gameName.value = g.name
} catch (e) { console.error(e) }
}
watch(tab, (val) => {
if (val === 'rankings' && !rankings.value.length) fetchRankings()
})
onMounted(() => {
fetchLeaderboard()
fetchGroups()
fetchUsers()
fetchGame()
})
</script>

View File

@ -0,0 +1,198 @@
<template>
<q-page class="q-pa-lg">
<div class="row items-center q-mb-lg">
<q-btn flat dense icon="arrow_back" color="grey-6" class="q-mr-sm" @click="$router.push({ path: '/games', query: gameSlug ? { search: gameSlug } : {} })" />
<div>
<div class="text-h5 text-grey-9 text-weight-bold">Таблицы лидеров</div>
<div class="text-caption text-grey-6">{{ gameName }}</div>
</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить" no-caps unelevated
@click="openCreate" style="border-radius:8px" />
</div>
<div class="admin-card">
<q-table :rows="leaderboards" :columns="columns" row-key="id" flat
:loading="loading" :pagination="{ rowsPerPage: 20 }" no-data-label="Нет таблиц лидеров">
<template #body-cell-sort_order="props">
<q-td :props="props">
<q-chip dense size="sm" :color="props.row.sort_order === 'desc' ? 'blue-1' : 'green-1'"
:text-color="props.row.sort_order === 'desc' ? 'blue-8' : 'green-8'" class="text-caption">
{{ props.row.sort_order === 'desc' ? '↓ desc' : '↑ asc' }}
</q-chip>
</q-td>
</template>
<template #body-cell-period_type="props">
<q-td :props="props">
<q-chip dense size="sm" color="purple-1" text-color="purple-8" class="text-caption">
{{ periodLabel(props.row.period_type) }}
</q-chip>
</q-td>
</template>
<template #body-cell-is_active="props">
<q-td :props="props">
<q-badge :color="props.row.is_active ? 'positive' : 'grey'"
:label="props.row.is_active ? 'Активна' : 'Неактивна'" />
</q-td>
</template>
<template #body-cell-groups="props">
<q-td :props="props">
<span class="text-grey-6 text-caption">{{ props.row.groups?.length || 0 }} групп</span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="open_in_new" color="blue-6" size="sm"
@click="$router.push(`/games/${gameId}/leaderboards/${props.row.id}`)">
<q-tooltip>Открыть</q-tooltip>
</q-btn>
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click="openEdit(props.row)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteLeaderboard(props.row)" />
</q-td>
</template>
</q-table>
</div>
<!-- Create / Edit dialog -->
<q-dialog v-model="dialog" persistent>
<q-card style="min-width:440px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">{{ isEdit ? 'Редактировать' : 'Новая таблица лидеров' }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="form.key" label="Ключ (напр. top_balance)" outlined dense
:disable="isEdit" :rules="[v => !!v || 'Обязательно']" />
<q-input v-model="form.name" label="Название" outlined dense
:rules="[v => !!v || 'Обязательно']" />
<q-select v-model="form.sort_order" :options="sortOptions" label="Сортировка"
outlined dense emit-value map-options />
<q-select v-model="form.period_type" :options="periodOptions" label="Период"
outlined dense emit-value map-options />
<q-toggle v-model="form.is_active" label="Активна" color="positive" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat :label="isEdit ? 'Сохранить' : 'Создать'" color="amber-8" @click="submitForm" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const route = useRoute()
const gameId = route.params.id
const leaderboards = ref([])
const loading = ref(false)
const gameName = ref('')
const gameSlug = ref('')
const dialog = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({ key: '', name: '', sort_order: 'desc', period_type: 'all_time', is_active: true })
const sortOptions = [
{ label: '↓ По убыванию (desc)', value: 'desc' },
{ label: '↑ По возрастанию (asc)', value: 'asc' },
]
const periodOptions = [
{ label: 'За всё время', value: 'all_time' },
{ label: 'Ежедневно', value: 'daily' },
{ label: 'Еженедельно', value: 'weekly' },
{ label: 'Ежемесячно', value: 'monthly' },
]
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left', style: 'width:60px' },
{ name: 'key', label: 'Ключ', field: 'key', sortable: true, align: 'left' },
{ name: 'name', label: 'Название', field: 'name', sortable: true, align: 'left' },
{ name: 'sort_order', label: 'Сортировка', field: 'sort_order', align: 'left' },
{ name: 'period_type', label: 'Период', field: 'period_type', align: 'left' },
{ name: 'is_active', label: 'Статус', field: 'is_active', align: 'left' },
{ name: 'groups', label: 'Группы', field: 'groups', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
function periodLabel(v) {
return { all_time: 'Всё время', daily: 'День', weekly: 'Неделя', monthly: 'Месяц' }[v] || v
}
function openCreate() {
isEdit.value = false
editId.value = null
form.value = { key: '', name: '', sort_order: 'desc', period_type: 'all_time', is_active: true }
dialog.value = true
}
function openEdit(lb) {
isEdit.value = true
editId.value = lb.id
form.value = { key: lb.key, name: lb.name, sort_order: lb.sort_order, period_type: lb.period_type, is_active: lb.is_active }
dialog.value = true
}
async function submitForm() {
try {
if (isEdit.value) {
await api.put(`/games/${gameId}/leaderboards/${editId.value}`, form.value)
$q.notify({ type: 'positive', message: 'Обновлено' })
} else {
await api.post(`/games/${gameId}/leaderboards`, form.value)
$q.notify({ type: 'positive', message: 'Создано' })
}
dialog.value = false
fetchLeaderboards()
} catch (e) {
const msg = e.response?.status === 409 ? 'Ключ уже занят'
: e.response?.data?.error || 'Ошибка'
$q.notify({ type: 'negative', message: msg })
}
}
async function deleteLeaderboard(lb) {
$q.dialog({ title: 'Удалить таблицу?', message: lb.name, cancel: true }).onOk(async () => {
try {
await api.delete(`/games/${gameId}/leaderboards/${lb.id}`)
$q.notify({ type: 'positive', message: 'Удалено' })
fetchLeaderboards()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
async function fetchLeaderboards() {
loading.value = true
try {
const { data } = await api.get(`/games/${gameId}/leaderboards`)
leaderboards.value = data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function fetchGame() {
try {
const { data } = await api.get(`/games/${gameId}`)
gameName.value = data.name
gameSlug.value = data.slug
} catch (e) { console.error(e) }
}
onMounted(() => { fetchLeaderboards(); fetchGame() })
</script>

View File

@ -0,0 +1,475 @@
<template>
<q-page class="q-pa-lg">
<!-- Header -->
<div class="row items-center q-mb-lg">
<q-btn flat dense icon="arrow_back" color="grey-6" class="q-mr-sm" @click="$router.push({ path: '/games', query: gameSlug ? { search: gameSlug } : {} })" />
<div>
<div class="text-h5 text-grey-9 text-weight-bold">Уведомления</div>
<div class="text-caption text-grey-6">{{ gameName }}</div>
</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить" no-caps unelevated
@click="openCreate" style="border-radius:8px" />
</div>
<!-- Table -->
<div class="admin-card">
<q-table :rows="notifications" :columns="columns" row-key="id" flat
:loading="loading" :pagination="{ rowsPerPage: 20 }" no-data-label="Нет уведомлений">
<template #body-cell-descriptions="props">
<q-td :props="props">
<q-chip v-for="d in props.row.descriptions" :key="d.id"
dense size="sm" color="blue-1" text-color="blue-8" class="text-caption">
{{ d.language?.code }}
</q-chip>
</q-td>
</template>
<template #body-cell-entries="props">
<q-td :props="props">
<span class="text-grey-6 text-caption">{{ props.row.entries?.length || 0 }} записей</span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click="openEdit(props.row)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteNotif(props.row)" />
</q-td>
</template>
</q-table>
</div>
<!-- Form dialog -->
<q-dialog v-model="dialog" persistent maximized>
<q-card class="bg-grey-1" style="display:flex;flex-direction:column;overflow:hidden">
<!-- header -->
<q-bar class="bg-white q-px-lg" style="height:56px;border-bottom:1px solid #e5e7eb;flex-shrink:0">
<div class="text-subtitle1 text-weight-bold text-grey-9">
{{ isEdit ? 'Редактировать уведомление' : 'Новое уведомление' }}
</div>
<q-space />
<q-btn flat dense icon="close" color="grey-6" @click="dialog = false" />
</q-bar>
<!-- scrollable body -->
<div style="flex:1;overflow-y:auto" class="q-pa-lg">
<!-- Name -->
<div class="admin-card q-pa-md q-mb-md">
<div class="text-caption text-grey-5 text-uppercase q-mb-sm" style="letter-spacing:.06em">Название</div>
<q-input v-model="form.name" placeholder="Например: Welcome bonus" outlined dense />
</div>
<!-- Top row: custom vars + macros + descriptions -->
<div class="row q-col-gutter-md q-mb-md">
<!-- Custom variables (keys only) -->
<div class="col-12 col-md-4">
<div class="admin-card q-pa-md" style="height:100%">
<div class="text-caption text-grey-5 text-uppercase q-mb-md" style="letter-spacing:.06em">
Кастомные переменные
</div>
<div v-for="(v, idx) in form.variables" :key="idx" class="row items-center q-gutter-sm q-mb-sm">
<q-input
v-model="v.key"
placeholder="ключ (напр. promo_code)"
outlined dense class="col"
:rules="[vv => !!vv || '']"
@update:model-value="onVarKeyChange(idx, $event)"
/>
<q-btn flat dense round icon="remove_circle" color="negative" size="sm"
@click="removeCustomVar(idx)" />
</div>
<q-btn flat no-caps icon="add" label="Добавить переменную" color="blue-6" size="sm"
@click="addCustomVar" />
<q-separator class="q-my-md" />
<div class="text-caption text-grey-5 text-uppercase q-mb-sm" style="letter-spacing:.06em">Макросы</div>
<div class="row q-gutter-xs flex-wrap">
<q-chip v-for="m in macros" :key="m" dense color="grey-2" text-color="grey-8"
class="text-caption cursor-pointer" clickable @click="copyMacro(m)">
{{ m }}
<q-tooltip>Скопировать</q-tooltip>
</q-chip>
</div>
</div>
</div>
<!-- Descriptions per language -->
<div class="col-12 col-md-8">
<div class="admin-card q-pa-md" style="height:100%">
<div class="text-caption text-grey-5 text-uppercase q-mb-md" style="letter-spacing:.06em">
Описания по языкам
</div>
<div v-if="!languages.length" class="text-grey-5 text-caption">
Нет языков. Добавьте языки в Настройках.
</div>
<template v-else>
<q-tabs v-model="langTab" align="left" dense no-caps
active-color="grey-9" indicator-color="amber-8" content-class="text-grey-6">
<q-tab v-for="lang in languages" :key="lang.id" :name="lang.id" :label="lang.name" />
</q-tabs>
<q-separator class="q-mb-md" />
<q-tab-panels v-model="langTab" animated keep-alive>
<q-tab-panel v-for="lang in languages" :key="lang.id" :name="lang.id" class="q-pa-none">
<div class="text-caption text-grey-5 q-mb-xs">
Макросы: нажмите, чтобы скопировать
</div>
<q-input
v-model="getDesc(lang.id).description"
type="textarea" outlined autogrow
:label="`Описание — ${lang.name}`"
:rows="5"
/>
</q-tab-panel>
</q-tab-panels>
</template>
</div>
</div>
</div>
<!-- Entries -->
<div class="admin-card q-pa-md">
<div class="row items-center q-mb-md">
<div class="text-caption text-grey-5 text-uppercase" style="letter-spacing:.06em">
Записи (entries)
</div>
<q-space />
<q-btn flat no-caps icon="add" label="Добавить запись" color="blue-6" size="sm"
@click="addEntry" />
</div>
<div v-if="!form.entries.length" class="text-grey-5 text-caption text-center q-pa-md">
Нет записей. Нажмите «Добавить запись».
</div>
<div v-for="(entry, eIdx) in form.entries" :key="eIdx"
class="q-mb-md rounded-borders" style="border:1px solid #e5e7eb">
<!-- entry header -->
<div class="row items-center q-pa-sm q-px-md" style="background:#f9fafb;border-radius:11px 11px 0 0">
<div class="text-caption text-grey-7 text-weight-bold">Запись #{{ eIdx + 1 }}</div>
<q-space />
<q-btn flat dense round icon="delete" color="negative" size="sm"
@click="removeEntry(eIdx)" />
</div>
<!-- entry fields -->
<div class="q-pa-md">
<div class="row q-col-gutter-md q-mb-sm">
<div class="col-12 col-sm-4">
<q-input v-model.number="entry.time_second" label="time_second" type="number"
outlined dense :rules="[v => v >= 0 || 'Должно быть ≥ 0']">
<template #prepend><q-icon name="timer" color="grey-5" size="18px" /></template>
</q-input>
</div>
<div class="col-12 col-sm-4">
<q-input v-model="entry.login" label="login" outlined dense>
<template #prepend><q-icon name="person" color="grey-5" size="18px" /></template>
</q-input>
</div>
<div class="col-12 col-sm-4">
<q-input v-model="entry.image" label="image" outlined dense>
<template #prepend><q-icon name="image" color="grey-5" size="18px" /></template>
<template #append>
<q-btn flat dense round icon="upload" color="blue-6" size="sm"
:loading="imageUploading && imageUploadEntry === eIdx"
@click="triggerImageUpload(eIdx)">
<q-tooltip>Загрузить</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</div>
<q-img v-if="entry.image" :src="entry.image"
style="height:60px;width:60px;border-radius:6px;object-fit:cover" class="q-mb-sm" />
<!-- custom var values for this entry -->
<div v-if="form.variables.some(v => v.key.trim())" class="row q-col-gutter-md">
<div class="col-12 col-sm-6 col-md-4"
v-for="v in form.variables.filter(v => v.key.trim())" :key="v.key">
<q-input
v-model="entry._vals[v.key]"
:label="v.key"
outlined dense
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- footer -->
<q-bar class="bg-white q-px-lg" style="height:56px;border-top:1px solid #e5e7eb;flex-shrink:0">
<q-space />
<q-btn flat label="Отмена" color="grey" no-caps @click="dialog = false" class="q-mr-sm" />
<q-btn unelevated :label="isEdit ? 'Сохранить' : 'Создать'" color="amber-8"
text-color="white" no-caps style="border-radius:8px" @click="submitForm" />
</q-bar>
</q-card>
</q-dialog>
<!-- hidden image upload input -->
<input ref="imageInputRef" type="file" accept="image/*" style="display:none"
@change="handleImageUpload" />
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar, copyToClipboard } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const route = useRoute()
const gameId = route.params.id
const notifications = ref([])
const languages = ref([])
const loading = ref(false)
const gameName = ref('')
const gameSlug = ref('')
const dialog = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const langTab = ref(null)
const imageInputRef = ref(null)
const imageUploading = ref(false)
const imageUploadEntry = ref(null)
// Form
// form.variables = [{ key: 'promo_code' }, ...] (keys only)
// form.entries[i]._vals = { promo_code: 'WELCOME30', ... } (internal dict)
const form = ref({ name: '', variables: [], descriptions: [], entries: [] })
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left', style: 'width:60px' },
{ name: 'name', label: 'Название', field: 'name', sortable: true, align: 'left' },
{ name: 'descriptions', label: 'Языки', field: 'descriptions', align: 'left' },
{ name: 'entries', label: 'Записи', field: 'entries', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
const macros = computed(() => [
'{{time_second}}', '{{image}}', '{{login}}',
...form.value.variables.filter(v => v.key.trim()).map(v => `{{${v.key}}}`),
])
// Descriptions helpers
function getDesc(languageId) {
let d = form.value.descriptions.find(d => d.language_id === languageId)
if (!d) {
d = { language_id: languageId, description: '' }
form.value.descriptions.push(d)
}
return d
}
// Variables helpers
function addCustomVar() {
form.value.variables.push({ key: '' })
form.value.entries.forEach(e => {
// new key gets empty slot; key is empty string until user types
})
}
function removeCustomVar(idx) {
form.value.variables.splice(idx, 1)
}
function onVarKeyChange(idx, newKey) {
// nothing special _vals dict is accessed by current key in template
// old key entry in _vals stays but won't be included in payload (keys come from form.variables)
}
// Entries helpers
function makeEntry() {
const vals = {}
form.value.variables.forEach(v => { if (v.key.trim()) vals[v.key] = '' })
return { time_second: 0, image: '', login: '', _vals: vals }
}
function addEntry() {
form.value.entries.push(makeEntry())
}
function removeEntry(idx) {
form.value.entries.splice(idx, 1)
}
// Image upload
function triggerImageUpload(entryIdx) {
imageUploadEntry.value = entryIdx
imageInputRef.value.click()
}
async function handleImageUpload(event) {
const file = event.target.files[0]
if (!file) return
imageUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const { data } = await api.post('/images', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
form.value.entries[imageUploadEntry.value].image = data.url
$q.notify({ type: 'positive', message: 'Изображение загружено' })
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка загрузки' })
} finally {
imageUploading.value = false
event.target.value = ''
}
}
// Copy macro
function copyMacro(m) {
copyToClipboard(m)
$q.notify({ type: 'positive', message: `Скопировано: ${m}`, timeout: 1000 })
}
// Open dialogs
function openCreate() {
isEdit.value = false
editId.value = null
form.value = {
name: '',
variables: [],
descriptions: languages.value.map(l => ({ language_id: l.id, description: '' })),
entries: [],
}
langTab.value = languages.value[0]?.id || null
dialog.value = true
}
function openEdit(notif) {
isEdit.value = true
editId.value = notif.id
const variables = (notif.variables || []).map(v => ({ key: v.key }))
const descriptions = languages.value.map(l => {
const existing = notif.descriptions?.find(d => d.language_id === l.id)
return { language_id: l.id, description: existing?.description || '' }
})
const entries = (notif.entries || []).map(e => {
const vals = {}
;(e.variables || []).forEach(v => { vals[v.key] = v.value })
return { time_second: e.time_second, image: e.image || '', login: e.login || '', _vals: vals }
})
form.value = { name: notif.name, variables, descriptions, entries }
langTab.value = languages.value[0]?.id || null
dialog.value = true
}
// Build payload
function buildPayload() {
const customKeys = form.value.variables.filter(v => v.key.trim()).map(v => v.key)
return {
name: form.value.name,
variables: customKeys.map(k => ({ key: k })),
descriptions: form.value.descriptions
.filter(d => d.description.trim())
.map(d => ({ language_id: d.language_id, description: d.description })),
entries: form.value.entries.map(e => ({
time_second: Number(e.time_second) || 0,
image: e.image,
login: e.login,
variables: customKeys.map(k => ({ key: k, value: e._vals[k] ?? '' })),
})),
}
}
// Submit
async function submitForm() {
if (!form.value.name.trim()) {
$q.notify({ type: 'negative', message: 'Укажите название' }); return
}
const payload = buildPayload()
if (!payload.descriptions.length) {
$q.notify({ type: 'negative', message: 'Добавьте хотя бы одно описание' }); return
}
if (!payload.entries.length) {
$q.notify({ type: 'negative', message: 'Добавьте хотя бы одну запись' }); return
}
try {
if (isEdit.value) {
await api.put(`/games/${gameId}/notifications/${editId.value}`, payload)
$q.notify({ type: 'positive', message: 'Уведомление обновлено' })
} else {
await api.post(`/games/${gameId}/notifications`, payload)
$q.notify({ type: 'positive', message: 'Уведомление создано' })
}
dialog.value = false
fetchNotifications()
} catch (e) {
const status = e.response?.status
const msg = status === 409 ? 'Уведомление с таким названием уже существует'
: status === 403 ? 'Модуль notification не подключён'
: e.response?.data?.error || 'Ошибка'
$q.notify({ type: 'negative', message: msg })
}
}
// Delete
async function deleteNotif(notif) {
$q.dialog({ title: 'Удалить уведомление?', message: notif.name, cancel: true }).onOk(async () => {
try {
await api.delete(`/games/${gameId}/notifications/${notif.id}`)
$q.notify({ type: 'positive', message: 'Удалено' })
fetchNotifications()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
// Fetch
async function fetchNotifications() {
loading.value = true
try {
const { data } = await api.get(`/games/${gameId}/notifications`)
notifications.value = data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function fetchLanguages() {
try {
const { data } = await api.get('/languages')
languages.value = data || []
} catch (e) {
console.error(e)
}
}
async function fetchGame() {
try {
const { data } = await api.get(`/games/${gameId}`)
gameName.value = data.name
gameSlug.value = data.slug
} catch (e) {
console.error(e)
}
}
onMounted(() => {
fetchNotifications()
fetchLanguages()
fetchGame()
})
</script>

View File

@ -0,0 +1,335 @@
<template>
<q-page class="q-pa-lg">
<div class="row items-center q-mb-xs">
<q-btn flat dense icon="arrow_back" color="grey-6" class="q-mr-sm" @click="$router.push({ path: '/games', query: gameSlug ? { search: gameSlug } : {} })" />
<div>
<div class="text-h5 text-grey-9 text-weight-bold">Переменные</div>
<div class="text-caption text-grey-6">{{ gameName }}</div>
</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить переменную" no-caps unelevated
@click="openCreate" style="border-radius: 8px" />
</div>
<div class="admin-card q-mt-lg">
<q-table :rows="variables" :columns="columns" row-key="id" flat :loading="loading"
:pagination="{ rowsPerPage: 20 }" no-data-label="Нет переменных">
<template #body-cell-type="props">
<q-td :props="props">
<q-chip dense size="sm" :color="typeColor(props.row.type)" text-color="white" class="text-caption">
{{ props.row.type }}
</q-chip>
</q-td>
</template>
<template #body-cell-value="props">
<q-td :props="props">
<template v-if="props.row.type === 'number'">
<span class="text-grey-9">{{ props.row.number_value }}</span>
</template>
<template v-else-if="props.row.type === 'string'">
<span class="text-grey-9">"{{ props.row.string_value }}"</span>
</template>
<template v-else-if="props.row.type === 'table'">
<div class="row q-gutter-xs flex-wrap">
<q-chip
v-for="item in props.row.items" :key="item.key"
dense size="sm" color="grey-2" text-color="grey-8" class="text-caption"
>
{{ item.key }}: {{ item.number_value ?? item.string_value }}
</q-chip>
</div>
</template>
<template v-else-if="props.row.type === 'vector'">
<div class="row q-gutter-xs flex-wrap">
<q-chip
v-for="item in props.row.items" :key="item.index"
dense size="sm" color="grey-2" text-color="grey-8" class="text-caption"
>
[{{ item.index }}]: {{ item.number_value ?? item.string_value }}
</q-chip>
</div>
</template>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click="openEdit(props.row)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteVariable(props.row)" />
</q-td>
</template>
</q-table>
</div>
<!-- Create / Edit dialog -->
<q-dialog v-model="dialog" persistent>
<q-card style="min-width:480px;max-width:600px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">{{ isEdit ? 'Редактировать переменную' : 'Новая переменная' }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<!-- Key -->
<q-input
v-model="form.key"
label="Ключ"
outlined dense
:rules="[v => !!v || 'Обязательно', v => /^[a-z0-9_]+$/.test(v) || 'Только a-z, 0-9, _']"
:disable="isEdit"
/>
<!-- Type -->
<q-select
v-model="form.type"
:options="typeOptions"
label="Тип"
outlined dense emit-value map-options
:disable="isEdit"
@update:model-value="onTypeChange"
/>
<!-- number -->
<q-input
v-if="form.type === 'number'"
v-model.number="form.number_value"
label="Значение"
type="number"
outlined dense
:rules="[v => v !== null && v !== '' || 'Обязательно']"
/>
<!-- string -->
<q-input
v-if="form.type === 'string'"
v-model="form.string_value"
label="Значение"
outlined dense
:rules="[v => v !== null && v !== '' || 'Обязательно']"
/>
<!-- table / vector shared -->
<template v-if="form.type === 'table' || form.type === 'vector'">
<q-select
v-model="form.table_value_type"
:options="[{ label: 'Число (number)', value: 'number' }, { label: 'Строка (string)', value: 'string' }]"
label="Тип значений"
outlined dense emit-value map-options
:disable="isEdit"
/>
<div class="text-caption text-grey-7 q-mb-xs q-mt-sm">
{{ form.type === 'vector' ? 'Элементы вектора' : 'Элементы таблицы' }}
</div>
<div v-for="(item, idx) in form.items" :key="idx" class="row q-gutter-sm q-mb-sm items-start">
<!-- table: key field -->
<q-input
v-if="form.type === 'table'"
v-model="item.key"
label="Ключ"
outlined dense
class="col"
:rules="[v => !!v || 'Обязательно']"
/>
<!-- vector: index field (auto, editable) -->
<q-input
v-else
v-model.number="item.index"
label="Индекс"
type="number"
outlined dense
class="col-3"
:rules="[v => v >= 0 || '≥ 0']"
/>
<!-- value field -->
<q-input
v-if="form.table_value_type === 'number'"
v-model.number="item.number_value"
label="Значение"
type="number"
outlined dense
class="col"
:rules="[v => v !== null && v !== '' || 'Обязательно']"
/>
<q-input
v-else
v-model="item.string_value"
label="Значение"
outlined dense
class="col"
:rules="[v => v !== null && v !== '' || 'Обязательно']"
/>
<q-btn flat dense round icon="remove_circle" color="negative" size="sm"
class="q-mt-xs" @click="removeItem(idx)" />
</div>
<q-btn flat no-caps icon="add" label="Добавить элемент" color="blue-6" size="sm"
@click="addItem" />
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" @click="closeDialog" />
<q-btn flat :label="isEdit ? 'Сохранить' : 'Создать'" color="amber-8" @click="submitForm" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const route = useRoute()
const gameId = route.params.id
const variables = ref([])
const loading = ref(false)
const gameName = ref('')
const gameSlug = ref('')
const dialog = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const defaultForm = () => ({
key: '',
type: 'number',
number_value: null,
string_value: '',
table_value_type: 'number',
items: [],
})
const form = ref(defaultForm())
const typeOptions = [
{ label: 'Число (number)', value: 'number' },
{ label: 'Строка (string)', value: 'string' },
{ label: 'Таблица (table)', value: 'table' },
{ label: 'Вектор (vector)', value: 'vector' },
]
const columns = [
{ name: 'key', label: 'Ключ', field: 'key', sortable: true, align: 'left' },
{ name: 'type', label: 'Тип', field: 'type', align: 'left', style: 'width:100px' },
{ name: 'value', label: 'Значение', field: 'value', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right', style: 'width:80px' },
]
function typeColor(type) {
return { number: 'blue-6', string: 'purple-5', table: 'teal-6', vector: 'orange-6' }[type] || 'grey'
}
function onTypeChange() {
form.value.number_value = null
form.value.string_value = ''
form.value.table_value_type = 'number'
form.value.items = []
}
function addItem() {
const isVector = form.value.type === 'vector'
const nextIndex = form.value.items.length
const item = form.value.table_value_type === 'number'
? (isVector ? { index: nextIndex, number_value: null } : { key: '', number_value: null })
: (isVector ? { index: nextIndex, string_value: '' } : { key: '', string_value: '' })
form.value.items.push(item)
}
function removeItem(idx) {
form.value.items.splice(idx, 1)
}
function openCreate() {
isEdit.value = false
editId.value = null
form.value = defaultForm()
dialog.value = true
}
function openEdit(variable) {
isEdit.value = true
editId.value = variable.id
form.value = {
key: variable.key,
type: variable.type,
number_value: variable.number_value ?? null,
string_value: variable.string_value ?? '',
table_value_type: variable.table_value_type || 'number',
items: variable.items ? variable.items.map(i => ({ ...i })) : [],
}
dialog.value = true
}
function closeDialog() {
dialog.value = false
}
function buildPayload() {
const { key, type, number_value, string_value, table_value_type, items } = form.value
if (type === 'number') return { key, type, number_value }
if (type === 'string') return { key, type, string_value }
return { key, type, table_value_type, items }
}
async function submitForm() {
const payload = buildPayload()
try {
if (isEdit.value) {
await api.put(`/games/${gameId}/variables/${editId.value}`, payload)
$q.notify({ type: 'positive', message: 'Переменная обновлена' })
} else {
await api.post(`/games/${gameId}/variables`, payload)
$q.notify({ type: 'positive', message: 'Переменная создана' })
}
dialog.value = false
fetchVariables()
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
}
}
async function deleteVariable(variable) {
$q.dialog({ title: 'Удалить переменную?', message: variable.key, cancel: true }).onOk(async () => {
try {
await api.delete(`/games/${gameId}/variables/${variable.id}`)
$q.notify({ type: 'positive', message: 'Удалено' })
fetchVariables()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
async function fetchVariables() {
loading.value = true
try {
const { data } = await api.get(`/games/${gameId}/variables`)
variables.value = data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function fetchGame() {
try {
const { data } = await api.get(`/games/${gameId}`)
gameName.value = data.name
gameSlug.value = data.slug
} catch (e) {
console.error(e)
}
}
onMounted(() => {
fetchVariables()
fetchGame()
})
</script>

298
src/pages/GamesPage.vue Normal file
View File

@ -0,0 +1,298 @@
<template>
<q-page class="q-pa-lg">
<div class="row items-center q-mb-lg">
<div class="text-h5 text-grey-9 text-weight-bold">Игры</div>
<q-space />
<q-input v-model="search" placeholder="Поиск по названию или slug..." dense outlined clearable
style="width:280px" class="q-mr-md" @update:model-value="fetchGames">
<template #prepend><q-icon name="search" color="grey-6" /></template>
</q-input>
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить игру" no-caps unelevated
@click="openCreate" style="border-radius: 8px" />
</div>
<div class="admin-card">
<q-table :rows="games" :columns="columns" row-key="id" flat :loading="loading" :pagination="{ rowsPerPage: 20 }">
<template #body-cell-modules="props">
<q-td :props="props">
<div class="row q-gutter-xs">
<q-chip
v-for="mod in props.row.modules"
:key="mod.key"
dense
size="sm"
color="blue-1"
text-color="blue-8"
class="text-caption"
>
{{ mod.name }}
</q-chip>
<span v-if="!props.row.modules?.length" class="text-grey-5 text-caption"></span>
</div>
</q-td>
</template>
<template #body-cell-is_active="props">
<q-td :props="props">
<q-badge :color="props.row.is_active ? 'positive' : 'grey'" :label="props.row.is_active ? 'Активна' : 'Неактивна'" />
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn
v-if="hasModule(props.row, 'variables')"
flat dense icon="tune" color="purple-6" size="sm"
@click="$router.push(`/games/${props.row.id}/variables`)"
>
<q-tooltip>Переменные</q-tooltip>
</q-btn>
<q-btn
v-if="hasModule(props.row, 'notification')"
flat dense icon="notifications" color="orange-6" size="sm"
@click="$router.push(`/games/${props.row.id}/notifications`)"
>
<q-tooltip>Уведомления</q-tooltip>
</q-btn>
<q-btn
v-if="hasModule(props.row, 'leaderboard')"
flat dense icon="leaderboard" color="teal-6" size="sm"
@click="$router.push(`/games/${props.row.id}/leaderboards`)"
>
<q-tooltip>Таблицы лидеров</q-tooltip>
</q-btn>
<q-btn flat dense icon="extension" color="blue-6" size="sm" @click="openModules(props.row)">
<q-tooltip>Модули</q-tooltip>
</q-btn>
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click="openEdit(props.row)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteGame(props.row)" />
</q-td>
</template>
</q-table>
</div>
<!-- Create / Edit dialog -->
<q-dialog v-model="dialog">
<q-card style="min-width:420px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">{{ isEdit ? 'Редактировать' : 'Новая игра' }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="form.name" label="Название" outlined dense :rules="[v => !!v || 'Обязательно']" />
<q-input v-model="form.slug" label="Slug" outlined dense :rules="[v => !!v || 'Обязательно']" />
<q-input v-model="form.description" label="Описание" outlined dense type="textarea" autogrow />
<q-input v-model="form.image_url" label="URL изображения" outlined dense />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat :label="isEdit ? 'Сохранить' : 'Создать'" color="amber-8" @click="submitForm" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Modules dialog -->
<q-dialog v-model="modulesDialog">
<q-card style="min-width:420px" class="bg-white">
<q-card-section class="row items-center">
<div>
<div class="text-h6 text-grey-9">Модули</div>
<div class="text-caption text-grey-6">{{ selectedGame?.name }}</div>
</div>
</q-card-section>
<q-card-section>
<div v-if="modulesListLoading" class="text-center q-pa-md">
<q-spinner color="blue-6" size="24px" />
</div>
<q-list v-else separator>
<q-item v-for="mod in allModules" :key="mod.key">
<q-item-section avatar>
<q-icon :name="moduleIcon(mod.key)" :color="isConnected(mod.key) ? 'blue-6' : 'grey-4'" size="22px" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey-9 text-weight-medium">{{ mod.name }}</q-item-label>
<q-item-label caption class="text-grey-5">{{ mod.key }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-if="isConnected(mod.key)"
flat dense no-caps label="Отключить" color="negative" size="sm"
:loading="moduleLoading === mod.key"
@click="disconnectModule(mod.key)"
/>
<q-btn
v-else
flat dense no-caps label="Подключить" color="blue-6" size="sm"
:loading="moduleLoading === mod.key"
@click="connectModule(mod.key)"
/>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Закрыть" color="grey" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const router = useRouter()
const route = useRoute()
const games = ref([])
const loading = ref(false)
const search = ref('')
const allModules = ref([])
// game form
const dialog = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({ name: '', slug: '', description: '', image_url: '' })
// modules dialog
const modulesDialog = ref(false)
const selectedGame = ref(null)
const connectedModuleKeys = ref([])
const moduleLoading = ref(null)
const modulesListLoading = ref(false)
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left', style: 'width:60px' },
{ name: 'name', label: 'Название', field: 'name', sortable: true, align: 'left' },
{ name: 'slug', label: 'Slug', field: 'slug', align: 'left' },
{ name: 'modules', label: 'Модули', field: 'modules', align: 'left' },
{ name: 'is_active', label: 'Статус', field: 'is_active', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
function hasModule(game, key) {
return game.modules?.some(m => m.key === key)
}
function isConnected(key) {
return connectedModuleKeys.value.includes(key)
}
function moduleIcon(key) {
const icons = { variables: 'tune', notification: 'notifications', leaderboard: 'leaderboard' }
return icons[key] || 'extension'
}
async function fetchGames() {
loading.value = true
try {
const params = {}
if (search.value) params.search = search.value
const { data } = await api.get('/games', { params })
games.value = data || []
} finally {
loading.value = false
}
}
function openCreate() {
isEdit.value = false
form.value = { name: '', slug: '', description: '', image_url: '' }
dialog.value = true
}
function openEdit(game) {
isEdit.value = true
editId.value = game.id
form.value = { name: game.name, slug: game.slug, description: game.description || '', image_url: game.image_url || '' }
dialog.value = true
}
async function openModules(game) {
selectedGame.value = game
connectedModuleKeys.value = (game.modules || []).map(m => m.key)
modulesDialog.value = true
modulesListLoading.value = true
try {
const { data } = await api.get('/modules')
allModules.value = data || []
} catch (e) {
$q.notify({ type: 'negative', message: 'Не удалось загрузить список модулей' })
} finally {
modulesListLoading.value = false
}
}
async function connectModule(key) {
moduleLoading.value = key
try {
await api.post(`/games/${selectedGame.value.id}/modules`, { module_key: key })
connectedModuleKeys.value.push(key)
// update local game data
const mod = allModules.value.find(m => m.key === key)
const game = games.value.find(g => g.id === selectedGame.value.id)
if (game && mod) game.modules = [...(game.modules || []), mod]
$q.notify({ type: 'positive', message: `Модуль "${key}" подключён` })
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
} finally {
moduleLoading.value = null
}
}
async function disconnectModule(key) {
moduleLoading.value = key
try {
await api.delete(`/games/${selectedGame.value.id}/modules/${key}`)
connectedModuleKeys.value = connectedModuleKeys.value.filter(k => k !== key)
const game = games.value.find(g => g.id === selectedGame.value.id)
if (game) game.modules = (game.modules || []).filter(m => m.key !== key)
$q.notify({ type: 'positive', message: `Модуль "${key}" отключён` })
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
} finally {
moduleLoading.value = null
}
}
async function submitForm() {
try {
if (isEdit.value) {
await api.put(`/games/${editId.value}`, form.value)
} else {
await api.post('/games', form.value)
}
$q.notify({ type: 'positive', message: isEdit.value ? 'Игра обновлена' : 'Игра создана' })
dialog.value = false
fetchGames()
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
}
}
async function deleteGame(game) {
$q.dialog({ title: 'Удалить игру?', message: game.name, cancel: true }).onOk(async () => {
try {
await api.delete(`/games/${game.id}`)
$q.notify({ type: 'positive', message: 'Удалено' })
fetchGames()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
onMounted(() => {
if (route.query.search) search.value = route.query.search
fetchGames()
})
</script>

149
src/pages/InvitesPage.vue Normal file
View File

@ -0,0 +1,149 @@
<template>
<q-page class="q-pa-lg">
<div class="row items-center q-mb-lg">
<div class="text-h5 text-grey-9 text-weight-bold">Приглашения</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add_link" label="Создать инвайт" no-caps unelevated
@click="createDialog = true" style="border-radius: 8px" />
</div>
<div class="admin-card">
<q-table :rows="invites" :columns="columns" row-key="id" flat :loading="loading" :pagination="{ rowsPerPage: 20 }">
<template #body-cell-code="props">
<q-td :props="props">
<code class="text-primary">{{ props.row.code }}</code>
<q-btn flat dense round icon="content_copy" size="xs" color="grey-5" class="q-ml-xs"
@click="copyLink(props.row.code)">
<q-tooltip>Копировать ссылку</q-tooltip>
</q-btn>
</q-td>
</template>
<template #body-cell-role="props">
<q-td :props="props">
<q-chip dense square :class="'role-' + props.row.role" :label="props.row.role" size="sm" />
</q-td>
</template>
<template #body-cell-usage="props">
<q-td :props="props">
{{ props.row.used_count }} / {{ props.row.max_uses }}
</q-td>
</template>
<template #body-cell-expires_at="props">
<q-td :props="props">
{{ props.row.expires_at ? new Date(props.row.expires_at).toLocaleDateString('ru') : '—' }}
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteInvite(props.row)" />
</q-td>
</template>
</q-table>
</div>
<!-- Create dialog -->
<q-dialog v-model="createDialog">
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Новое приглашение</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select v-model="form.role" :options="['admin','moderator','user']" label="Роль" outlined dense />
<q-input v-model.number="form.max_uses" type="number" label="Макс. использований" outlined dense
:rules="[v => v > 0 || 'Больше 0']" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Создать" color="amber-8" @click="createInvite" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Link copied dialog -->
<q-dialog v-model="linkDialog">
<q-card style="min-width:400px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Ссылка скопирована!</div>
</q-card-section>
<q-card-section>
<q-input :model-value="generatedLink" outlined dense readonly>
<template #append>
<q-btn flat dense icon="content_copy" @click="copyToClipboard(generatedLink)" />
</template>
</q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Закрыть" color="grey" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar, copyToClipboard } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const invites = ref([])
const loading = ref(false)
const createDialog = ref(false)
const linkDialog = ref(false)
const generatedLink = ref('')
const form = ref({ role: 'user', max_uses: 1 })
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left' },
{ name: 'code', label: 'Код', field: 'code', align: 'left' },
{ name: 'role', label: 'Роль', field: 'role', align: 'left' },
{ name: 'usage', label: 'Использований', field: 'used_count', align: 'left' },
{ name: 'expires_at', label: 'Истекает', field: 'expires_at', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
async function fetchInvites() {
loading.value = true
try {
const { data } = await api.get('/invites')
invites.value = data || []
} finally {
loading.value = false
}
}
async function createInvite() {
try {
const { data } = await api.post('/invites', form.value)
generatedLink.value = data.link || `${window.location.origin}/register?invite=${data.invite.code}`
await copyToClipboard(generatedLink.value)
$q.notify({ type: 'positive', message: 'Приглашение создано и ссылка скопирована' })
createDialog.value = false
linkDialog.value = true
fetchInvites()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
}
function copyLink(code) {
const link = `${window.location.origin}/register?invite=${code}`
copyToClipboard(link).then(() => {
$q.notify({ type: 'positive', message: 'Ссылка скопирована', timeout: 1500 })
})
}
async function deleteInvite(inv) {
$q.dialog({ title: 'Удалить приглашение?', message: inv.code, cancel: true }).onOk(async () => {
try {
await api.delete(`/invites/${inv.code}`)
$q.notify({ type: 'positive', message: 'Удалено' })
fetchInvites()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
onMounted(fetchInvites)
</script>

78
src/pages/LoginPage.vue Normal file
View File

@ -0,0 +1,78 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-title text-grey-9 q-mb-xs">Вход</div>
<div class="text-grey-6 q-mb-lg">Войдите в панель администратора</div>
<q-form @submit="handleLogin" class="q-gutter-md">
<q-input
v-model="form.email"
label="Email"
type="email"
outlined
dense
lazy-rules
:rules="[v => !!v || 'Обязательное поле', v => /.+@.+/.test(v) || 'Некорректный email']"
/>
<q-input
v-model="form.password"
label="Пароль"
:type="showPwd ? 'text' : 'password'"
outlined
dense
lazy-rules
:rules="[v => !!v || 'Обязательное поле']"
>
<template #append>
<q-icon :name="showPwd ? 'visibility_off' : 'visibility'" class="cursor-pointer" @click="showPwd = !showPwd" />
</template>
</q-input>
<q-btn
type="submit"
label="Войти"
color="amber-8"
text-color="white"
no-caps
unelevated
class="full-width text-weight-bold"
:loading="loading"
style="border-radius: 8px; height: 44px"
/>
</q-form>
<div class="text-center q-mt-md">
<router-link to="/register" class="text-grey-5 text-caption" style="text-decoration: none">
Есть инвайт? <span class="gold-accent">Регистрация</span>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useAuthStore } from 'src/stores/auth'
const $q = useQuasar()
const router = useRouter()
const auth = useAuthStore()
const form = ref({ email: '', password: '' })
const showPwd = ref(false)
const loading = ref(false)
async function handleLogin() {
loading.value = true
try {
await auth.login(form.value.email, form.value.password)
router.push('/dashboard')
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка входа' })
} finally {
loading.value = false
}
}
</script>

View File

@ -0,0 +1,66 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-title text-grey-9 q-mb-xs">Регистрация</div>
<div class="text-grey-6 q-mb-lg">Создайте аккаунт по приглашению</div>
<q-form @submit="handleRegister" class="q-gutter-md">
<q-input v-model="form.invite_code" label="Код приглашения" outlined dense
:rules="[v => !!v || 'Введите код приглашения']" />
<q-input v-model="form.username" label="Имя пользователя" outlined dense
:rules="[v => !!v || 'Обязательное поле', v => v.length >= 3 || 'Минимум 3 символа']" />
<q-input v-model="form.email" label="Email" type="email" outlined dense
:rules="[v => !!v || 'Обязательное поле']" />
<q-input v-model="form.password" label="Пароль" :type="showPwd ? 'text' : 'password'" outlined dense
:rules="[v => !!v || 'Обязательное поле', v => v.length >= 6 || 'Минимум 6 символов']">
<template #append>
<q-icon :name="showPwd ? 'visibility_off' : 'visibility'" class="cursor-pointer" @click="showPwd = !showPwd" />
</template>
</q-input>
<q-btn type="submit" label="Зарегистрироваться" color="amber-8" text-color="white" no-caps unelevated
class="full-width text-weight-bold" :loading="loading" style="border-radius: 8px; height: 44px" />
</q-form>
<div class="text-center q-mt-md">
<router-link to="/login" class="text-grey-5 text-caption" style="text-decoration: none">
Уже есть аккаунт? <span class="gold-accent">Войти</span>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { useAuthStore } from 'src/stores/auth'
const $q = useQuasar()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const form = ref({ email: '', username: '', password: '', invite_code: '' })
const showPwd = ref(false)
const loading = ref(false)
onMounted(() => {
if (route.query.invite) {
form.value.invite_code = route.query.invite
}
})
async function handleRegister() {
loading.value = true
try {
await auth.register(form.value)
router.push('/dashboard')
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка регистрации' })
} finally {
loading.value = false
}
}
</script>

152
src/pages/SettingsPage.vue Normal file
View File

@ -0,0 +1,152 @@
<template>
<q-page class="q-pa-lg">
<div class="text-h5 text-grey-9 text-weight-bold q-mb-lg">Настройки</div>
<!-- Tabs -->
<q-tabs v-model="tab" align="left" dense no-caps class="q-mb-lg"
active-color="grey-9" indicator-color="amber-8" content-class="text-grey-6">
<q-tab name="languages" icon="language" label="Языки" />
</q-tabs>
<!-- Languages -->
<div v-if="tab === 'languages'">
<div class="row items-center q-mb-md">
<div class="text-subtitle1 text-grey-9 text-weight-bold">Языки</div>
<q-space />
<q-btn color="amber-8" text-color="white" icon="add" label="Добавить язык" no-caps unelevated
@click="openCreate" style="border-radius: 8px" />
</div>
<div class="admin-card">
<q-table :rows="languages" :columns="columns" row-key="id" flat
:loading="loading" :pagination="{ rowsPerPage: 20 }" no-data-label="Нет языков">
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="edit" color="grey-6" size="sm" @click="openEdit(props.row)" />
<q-btn flat dense icon="delete" color="negative" size="sm" @click="deleteLang(props.row)" />
</q-td>
</template>
</q-table>
</div>
</div>
<!-- Create / Edit dialog -->
<q-dialog v-model="dialog" persistent>
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">{{ isEdit ? 'Редактировать язык' : 'Новый язык' }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input
v-model="form.code"
label="Код (например: en, ru, de)"
outlined dense
:rules="[v => !!v || 'Обязательно']"
hint="Автоматически приводится к нижнему регистру"
/>
<q-input
v-model="form.name"
label="Название (например: English)"
outlined dense
:rules="[v => !!v || 'Обязательно']"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat :label="isEdit ? 'Сохранить' : 'Создать'" color="amber-8" @click="submitForm" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const tab = ref('languages')
const languages = ref([])
const loading = ref(false)
const dialog = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({ code: '', name: '' })
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left', style: 'width:60px' },
{ name: 'code', label: 'Код', field: 'code', sortable: true, align: 'left', style: 'width:100px' },
{ name: 'name', label: 'Название', field: 'name', sortable: true, align: 'left' },
{ name: 'created_at', label: 'Создан', field: 'created_at', align: 'left',
format: v => new Date(v).toLocaleDateString('ru') },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
async function fetchLanguages() {
loading.value = true
try {
const { data } = await api.get('/languages')
languages.value = data || []
} catch (e) {
$q.notify({ type: 'negative', message: 'Не удалось загрузить языки' })
} finally {
loading.value = false
}
}
function openCreate() {
isEdit.value = false
editId.value = null
form.value = { code: '', name: '' }
dialog.value = true
}
function openEdit(lang) {
isEdit.value = true
editId.value = lang.id
form.value = { code: lang.code, name: lang.name }
dialog.value = true
}
async function submitForm() {
try {
if (isEdit.value) {
await api.put(`/languages/${editId.value}`, form.value)
$q.notify({ type: 'positive', message: 'Язык обновлён' })
} else {
await api.post('/languages', form.value)
$q.notify({ type: 'positive', message: 'Язык добавлен' })
}
dialog.value = false
fetchLanguages()
} catch (e) {
const status = e.response?.status
const msg = status === 409 ? 'Такой код уже существует'
: status === 400 ? 'Заполните все поля'
: e.response?.data?.error || 'Ошибка'
$q.notify({ type: 'negative', message: msg })
}
}
async function deleteLang(lang) {
$q.dialog({
title: 'Удалить язык?',
message: `${lang.name} (${lang.code})`,
cancel: true,
}).onOk(async () => {
try {
await api.delete(`/languages/${lang.id}`)
$q.notify({ type: 'positive', message: 'Язык удалён' })
fetchLanguages()
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
}
})
}
onMounted(fetchLanguages)
</script>

View File

@ -0,0 +1,236 @@
<template>
<q-page class="q-pa-lg">
<q-btn flat dense icon="arrow_back" label="Назад" color="grey-6" class="q-mb-md" @click="$router.back()" />
<div v-if="user" class="row q-col-gutter-lg">
<!-- Profile card -->
<div class="col-12 col-md-4">
<div class="admin-card q-pa-lg">
<div class="text-center q-mb-md">
<q-avatar size="72px" color="grey-2" text-color="grey-8" class="text-h4 text-weight-bold">
{{ user.username?.[0]?.toUpperCase() }}
</q-avatar>
</div>
<div class="text-h6 text-grey-9 text-center text-weight-bold">{{ user.username }}</div>
<div class="text-grey-6 text-center q-mb-md">{{ user.email }}</div>
<div class="text-center q-mb-md">
<q-chip dense square :class="'role-' + user.role" :label="user.role" />
<q-badge :color="user.is_active ? 'positive' : 'grey'" :label="user.is_active ? 'Активен' : 'Заблокирован'" class="q-ml-sm" />
</div>
<div class="text-caption text-grey-6 text-center">
Создан: {{ new Date(user.created_at).toLocaleDateString('ru') }}
</div>
</div>
</div>
<!-- User games -->
<div class="col-12 col-md-8">
<div class="admin-card q-pa-lg">
<div class="row items-center q-mb-md">
<div class="text-subtitle1 text-grey-9 text-weight-bold">Подключённые игры</div>
<q-space />
<q-btn dense flat icon="add" color="amber-8" label="Подключить" no-caps @click="connectDialog = true" />
</div>
<q-list separator v-if="userGames.length">
<q-item v-for="ug in userGames" :key="ug.id">
<q-item-section avatar>
<q-icon name="sports_esports" color="amber-7" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey-9 text-weight-medium">{{ ug.game?.name }}</q-item-label>
<q-item-label caption class="text-grey-6">{{ ug.game?.slug }}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="text-right">
<div class="text-grey-9 text-weight-bold text-h6" style="line-height:1">
{{ Number(ug.balance).toFixed(2) }}
</div>
<div class="text-caption text-grey-6">баланс</div>
</div>
</q-item-section>
<q-item-section side>
<div class="row q-gutter-xs">
<q-btn flat dense round icon="add_circle" color="positive" @click="openTopup(ug)" size="sm">
<q-tooltip>Пополнить</q-tooltip>
</q-btn>
<q-btn flat dense round icon="receipt_long" color="info" @click="openTxns(ug)" size="sm">
<q-tooltip>Транзакции</q-tooltip>
</q-btn>
<q-btn flat dense round icon="link_off" color="negative" @click="disconnectGame(ug)" size="sm">
<q-tooltip>Отключить</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-grey-6 q-pa-md text-center">Нет подключённых игр</div>
</div>
</div>
</div>
<!-- Connect game dialog -->
<q-dialog v-model="connectDialog">
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Подключить игру</div>
</q-card-section>
<q-card-section>
<q-select v-model="selectedGameId" :options="availableGames" option-value="id" option-label="name"
emit-value map-options outlined dense label="Выберите игру" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Подключить" color="amber-8" @click="connectGame" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Top-up dialog -->
<q-dialog v-model="topupDialog">
<q-card style="min-width:360px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Пополнить баланс</div>
<div class="text-grey-6">{{ topupUg?.game?.name }} текущий: {{ Number(topupUg?.balance || 0).toFixed(2) }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model.number="topupAmount" type="number" label="Сумма" outlined dense :rules="[v => v > 0 || 'Больше 0']" />
<q-input v-model="topupComment" label="Комментарий" outlined dense />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Пополнить" color="positive" @click="submitTopup" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Transactions dialog -->
<q-dialog v-model="txnDialog" full-width>
<q-card class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Транзакции {{ txnGame?.game?.name }}</div>
</q-card-section>
<q-card-section>
<q-table :rows="transactions" :columns="txnCols" row-key="id" flat dense :pagination="{ rowsPerPage: 10 }" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Закрыть" color="grey" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const route = useRoute()
const user = ref(null)
const userGames = ref([])
const allGames = ref([])
const connectDialog = ref(false)
const selectedGameId = ref(null)
const topupDialog = ref(false)
const topupUg = ref(null)
const topupAmount = ref(0)
const topupComment = ref('')
const txnDialog = ref(false)
const txnGame = ref(null)
const transactions = ref([])
const txnCols = [
{ name: 'id', label: 'ID', field: 'id', align: 'left' },
{ name: 'amount', label: 'Сумма', field: 'amount', align: 'left', format: v => Number(v).toFixed(2) },
{ name: 'type', label: 'Тип', field: 'type', align: 'left' },
{ name: 'comment', label: 'Комментарий', field: 'comment', align: 'left' },
{ name: 'created_at', label: 'Дата', field: 'created_at', align: 'left', format: v => new Date(v).toLocaleString('ru') },
]
const availableGames = computed(() => {
const connectedIds = userGames.value.map(ug => ug.game_id)
return allGames.value.filter(g => !connectedIds.includes(g.id))
})
async function fetchData() {
const id = route.params.id
try {
const [uRes, ugRes, gRes] = await Promise.all([
api.get(`/users/${id}`),
api.get(`/users/${id}/games`),
api.get('/games'),
])
user.value = uRes.data
userGames.value = ugRes.data || []
allGames.value = gRes.data || []
} catch (e) {
console.error(e)
}
}
async function connectGame() {
if (!selectedGameId.value) return
try {
await api.post(`/users/${route.params.id}/games`, { game_id: selectedGameId.value })
$q.notify({ type: 'positive', message: 'Игра подключена' })
connectDialog.value = false
fetchData()
} catch (e) {
$q.notify({ type: 'negative', message: e.response?.data?.error || 'Ошибка' })
}
}
async function disconnectGame(ug) {
$q.dialog({ title: 'Отключить игру?', message: `${ug.game?.name} будет отключена`, cancel: true }).onOk(async () => {
try {
await api.delete(`/users/${route.params.id}/games/${ug.game_id}`)
$q.notify({ type: 'positive', message: 'Игра отключена' })
fetchData()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
})
}
function openTopup(ug) {
topupUg.value = ug
topupAmount.value = 0
topupComment.value = ''
topupDialog.value = true
}
async function submitTopup() {
if (topupAmount.value <= 0) return
try {
await api.post(`/user-games/${topupUg.value.id}/topup`, {
amount: topupAmount.value,
comment: topupComment.value,
})
$q.notify({ type: 'positive', message: 'Баланс пополнен' })
topupDialog.value = false
fetchData()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
}
async function openTxns(ug) {
txnGame.value = ug
try {
const { data } = await api.get(`/user-games/${ug.id}/transactions`)
transactions.value = data || []
txnDialog.value = true
} catch (e) {
console.error(e)
}
}
onMounted(fetchData)
</script>

145
src/pages/UsersPage.vue Normal file
View File

@ -0,0 +1,145 @@
<template>
<q-page class="q-pa-lg">
<div class="row items-center q-mb-lg">
<div class="text-h5 text-grey-9 text-weight-bold">Пользователи</div>
<q-space />
<q-input v-model="search" placeholder="Поиск..." dense outlined clearable style="width: 260px"
@update:model-value="fetchUsers">
<template #prepend><q-icon name="search" color="grey-6" /></template>
</q-input>
</div>
<div class="admin-card">
<q-table :rows="users" :columns="columns" row-key="id" flat binary-state-sort
:loading="loading" :pagination="{ rowsPerPage: 20 }">
<template #body-cell-username="props">
<q-td :props="props">
<router-link :to="'/users/' + props.row.id" class="text-primary" style="text-decoration:none">
{{ props.row.username }}
</router-link>
</q-td>
</template>
<template #body-cell-role="props">
<q-td :props="props">
<q-chip dense square :class="'role-' + props.row.role" :label="props.row.role" size="sm" />
</q-td>
</template>
<template #body-cell-is_active="props">
<q-td :props="props">
<q-badge :color="props.row.is_active ? 'positive' : 'grey'" :label="props.row.is_active ? 'Активен' : 'Заблокирован'" />
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn flat dense icon="more_vert" color="grey-5">
<q-menu>
<q-list style="min-width: 160px">
<q-item clickable v-close-popup :to="'/users/' + props.row.id">
<q-item-section avatar><q-icon name="visibility" size="xs" /></q-item-section>
<q-item-section>Подробнее</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="changeRole(props.row)">
<q-item-section avatar><q-icon name="admin_panel_settings" size="xs" /></q-item-section>
<q-item-section>Изменить роль</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="toggleActive(props.row)">
<q-item-section avatar><q-icon name="block" size="xs" /></q-item-section>
<q-item-section>{{ props.row.is_active ? 'Заблокировать' : 'Разблокировать' }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- Role dialog -->
<q-dialog v-model="roleDialog" persistent>
<q-card style="min-width: 320px" class="bg-white">
<q-card-section>
<div class="text-h6 text-grey-9">Изменить роль</div>
<div class="text-grey-6">{{ editingUser?.username }}</div>
</q-card-section>
<q-card-section>
<q-select v-model="newRole" :options="['admin','moderator','user']" outlined dense label="Роль" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="grey" v-close-popup />
<q-btn flat label="Сохранить" color="amber-8" @click="saveRole" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { api } from 'src/boot/axios'
const $q = useQuasar()
const users = ref([])
const loading = ref(false)
const search = ref('')
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left' },
{ name: 'username', label: 'Имя', field: 'username', sortable: true, align: 'left' },
{ name: 'email', label: 'Email', field: 'email', sortable: true, align: 'left' },
{ name: 'role', label: 'Роль', field: 'role', align: 'left' },
{ name: 'is_active', label: 'Статус', field: 'is_active', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
const roleDialog = ref(false)
const editingUser = ref(null)
const newRole = ref('user')
async function fetchUsers() {
loading.value = true
try {
const params = {}
if (search.value) params.search = search.value
const { data } = await api.get('/users', { params })
users.value = data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function changeRole(user) {
editingUser.value = user
newRole.value = user.role
roleDialog.value = true
}
async function saveRole() {
try {
await api.patch(`/users/${editingUser.value.id}/role`, { role: newRole.value })
$q.notify({ type: 'positive', message: 'Роль обновлена' })
roleDialog.value = false
fetchUsers()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
}
async function toggleActive(user) {
try {
await api.patch(`/users/${user.id}/toggle-active`)
$q.notify({ type: 'positive', message: 'Статус изменён' })
fetchUsers()
} catch (e) {
$q.notify({ type: 'negative', message: 'Ошибка' })
}
}
onMounted(fetchUsers)
</script>

58
src/router/index.js Normal file
View File

@ -0,0 +1,58 @@
import { route } from 'quasar/wrappers'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
component: () => import('pages/LoginPage.vue'),
},
{
path: '/register',
component: () => import('pages/RegisterPage.vue'),
},
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: () => import('pages/DashboardPage.vue') },
{ path: 'users', component: () => import('pages/UsersPage.vue') },
{ path: 'users/:id', component: () => import('pages/UserDetailPage.vue') },
{ path: 'games', component: () => import('pages/GamesPage.vue') },
{ path: 'games/:id/variables', component: () => import('pages/GameVariablesPage.vue') },
{ path: 'games/:id/notifications', component: () => import('pages/GameNotificationsPage.vue') },
{ path: 'games/:id/leaderboards', component: () => import('pages/GameLeaderboardsPage.vue') },
{ path: 'games/:id/leaderboards/:leaderboard_id', component: () => import('pages/GameLeaderboardDetailPage.vue') },
{ path: 'invites', component: () => import('pages/InvitesPage.vue') },
{ path: 'settings', component: () => import('pages/SettingsPage.vue') },
],
},
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
]
export default route(function () {
const createHistory = process.env.SERVER
? createMemoryHistory
: createWebHistory
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
history: createHistory(process.env.VUE_ROUTER_BASE),
})
Router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.matched.some((r) => r.meta.requiresAuth) && !token) {
next('/login')
} else {
next()
}
})
return Router
})

46
src/stores/auth.js Normal file
View File

@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { api } from 'src/boot/axios'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || null,
user: JSON.parse(localStorage.getItem('user') || 'null'),
}),
getters: {
isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin',
isModerator: (state) => ['admin', 'moderator'].includes(state.user?.role),
},
actions: {
async login(email, password) {
const { data } = await api.post('/auth/login', { email, password })
this.token = data.token
this.user = data.user
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
},
async register(payload) {
const { data } = await api.post('/auth/register', payload)
this.token = data.token
this.user = data.user
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
},
async fetchMe() {
const { data } = await api.get('/auth/me')
this.user = data
localStorage.setItem('user', JSON.stringify(data))
},
logout() {
this.token = null
this.user = null
localStorage.removeItem('token')
localStorage.removeItem('user')
},
},
})

7
src/stores/index.js Normal file
View File

@ -0,0 +1,7 @@
import { store } from 'quasar/wrappers'
import { createPinia } from 'pinia'
export default store((/* { ssrContext } */) => {
const pinia = createPinia()
return pinia
})

10
src/stores/store-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
store: true;
}
}