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