init
This commit is contained in:
commit
000134d629
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
API_BASE_URL=http://localhost:8080/api
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
12
index.html
Normal 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
6031
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
5
postcss.config.cjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('autoprefixer'),
|
||||||
|
],
|
||||||
|
}
|
||||||
23
quasar.config.js
Normal file
23
quasar.config.js
Normal 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
3
src/App.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
33
src/boot/axios.js
Normal file
33
src/boot/axios.js
Normal 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
80
src/css/app.scss
Normal 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
114
src/layouts/MainLayout.vue
Normal 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
102
src/pages/DashboardPage.vue
Normal 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>
|
||||||
9
src/pages/ErrorNotFound.vue
Normal file
9
src/pages/ErrorNotFound.vue
Normal 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>
|
||||||
532
src/pages/GameLeaderboardDetailPage.vue
Normal file
532
src/pages/GameLeaderboardDetailPage.vue
Normal 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>
|
||||||
198
src/pages/GameLeaderboardsPage.vue
Normal file
198
src/pages/GameLeaderboardsPage.vue
Normal 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>
|
||||||
475
src/pages/GameNotificationsPage.vue
Normal file
475
src/pages/GameNotificationsPage.vue
Normal 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>
|
||||||
335
src/pages/GameVariablesPage.vue
Normal file
335
src/pages/GameVariablesPage.vue
Normal 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
298
src/pages/GamesPage.vue
Normal 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
149
src/pages/InvitesPage.vue
Normal 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
78
src/pages/LoginPage.vue
Normal 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>
|
||||||
66
src/pages/RegisterPage.vue
Normal file
66
src/pages/RegisterPage.vue
Normal 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
152
src/pages/SettingsPage.vue
Normal 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>
|
||||||
236
src/pages/UserDetailPage.vue
Normal file
236
src/pages/UserDetailPage.vue
Normal 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
145
src/pages/UsersPage.vue
Normal 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
58
src/router/index.js
Normal 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
46
src/stores/auth.js
Normal 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
7
src/stores/index.js
Normal 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
10
src/stores/store-flag.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user