207 lines
11 KiB
Vue
207 lines
11 KiB
Vue
<script setup>
|
|
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
|
import { computed, reactive, ref } from 'vue';
|
|
import AdminLayout from '../../Layouts/AdminLayout.vue';
|
|
import StatCards from './Components/StatCards.vue';
|
|
|
|
const props = defineProps({
|
|
site: { type: Object, required: true },
|
|
auth: { type: Object, required: true },
|
|
urls: { type: Object, required: true },
|
|
flash: { type: Object, default: () => ({}) },
|
|
accounts: { type: Array, default: () => [] },
|
|
accountStats: { type: Object, required: true },
|
|
accountTabs: { type: Object, required: true },
|
|
activeTab: { type: String, required: true },
|
|
inviteCodes: { type: Array, default: () => [] },
|
|
inviteStats: { type: Object, required: true },
|
|
inviteStoreUrl: { type: String, required: true },
|
|
registrationRequiresInvite: { type: Boolean, required: true },
|
|
search: { type: String, default: '' },
|
|
accessLevels: { type: Array, required: true },
|
|
old: { type: Object, default: () => ({}) },
|
|
});
|
|
|
|
const page = usePage();
|
|
const errors = computed(() => page.props.errors || {});
|
|
const searchValue = ref(props.search);
|
|
const savingAccount = ref(null);
|
|
const expandedAccounts = reactive({});
|
|
const accessValues = reactive(Object.fromEntries(props.accounts.map((account) => [account.id, account.gm_level])));
|
|
const inviteForm = useForm({ quantity: props.old.quantity ?? 1 });
|
|
|
|
const inviteStats = computed(() => [
|
|
{ label: 'Всего кодов', value: props.inviteStats.total },
|
|
{ label: 'Доступно', value: props.inviteStats.available },
|
|
{ label: 'Использовано', value: props.inviteStats.redeemed },
|
|
]);
|
|
|
|
function tabUrl(tab) {
|
|
const params = new URLSearchParams({ tab });
|
|
if (props.search) params.set('search', props.search);
|
|
return `${props.urls.admin_accounts}?${params.toString()}`;
|
|
}
|
|
|
|
function searchAccounts() {
|
|
router.get(props.urls.admin_accounts, {
|
|
tab: props.activeTab,
|
|
search: searchValue.value || undefined,
|
|
}, { preserveState: true, replace: true });
|
|
}
|
|
|
|
function updateAccess(account) {
|
|
savingAccount.value = account.id;
|
|
router.patch(account.update_access_url, {
|
|
gmlevel: accessValues[account.id],
|
|
}, {
|
|
preserveScroll: true,
|
|
onFinish: () => {
|
|
savingAccount.value = null;
|
|
},
|
|
});
|
|
}
|
|
|
|
function createInvites() {
|
|
inviteForm.post(props.inviteStoreUrl, {
|
|
preserveScroll: true,
|
|
onSuccess: () => inviteForm.reset('quantity'),
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Head><title>Аккаунты и инвайты — {{ site.name }}</title></Head>
|
|
|
|
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="accounts">
|
|
<section class="admin-hero wrap">
|
|
<div>
|
|
<span class="eyebrow">Управление доступом</span>
|
|
<h1 class="h-display">Аккаунты и инвайты</h1>
|
|
<p class="lead">Игроки, боты, персонажи и уровни доступа — в одном рабочем пространстве.</p>
|
|
</div>
|
|
<div class="admin-hero__badge">
|
|
<small>Всего аккаунтов</small>
|
|
<strong>{{ (accountStats.players || 0) + (accountStats.bots || 0) }}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="admin-page wrap">
|
|
<section class="admin-section">
|
|
<div class="admin-section__head">
|
|
<div><span class="eyebrow">Инвайты</span><h2 class="h-serif">Доступ к регистрации</h2></div>
|
|
<p>{{ registrationRequiresInvite ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
|
|
</div>
|
|
|
|
<StatCards :items="inviteStats" />
|
|
|
|
<div class="admin-split">
|
|
<article class="portal-card admin-panel corners">
|
|
<span class="admin-kicker">Новая партия</span>
|
|
<h3 class="h-serif">Создать коды</h3>
|
|
<form class="admin-inline-form" @submit.prevent="createInvites">
|
|
<label class="admin-field">
|
|
<span>Количество</span>
|
|
<input v-model="inviteForm.quantity" type="number" min="1" max="50" required />
|
|
<small v-if="inviteForm.errors.quantity">{{ inviteForm.errors.quantity }}</small>
|
|
</label>
|
|
<button class="btn btn-primary" type="submit" :disabled="inviteForm.processing">
|
|
{{ inviteForm.processing ? 'Создаём…' : 'Создать коды' }}
|
|
</button>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="portal-card admin-panel">
|
|
<span class="admin-kicker">Последние коды</span>
|
|
<div v-if="inviteCodes.length" class="admin-compact-list">
|
|
<div v-for="invite in inviteCodes" :key="invite.id">
|
|
<span>
|
|
<strong class="admin-code">{{ invite.code }}</strong>
|
|
<small>{{ invite.created_by }}<template v-if="invite.created_at"> · {{ invite.created_at }}</template></small>
|
|
</span>
|
|
<span>
|
|
<strong :class="{ 'admin-positive': !invite.redeemed }">{{ invite.redeemed ? 'Использован' : 'Свободен' }}</strong>
|
|
<small>{{ invite.redeemed_by }}</small>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="admin-empty-inline">Инвайт-коды ещё не создавались.</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="admin-section">
|
|
<div class="admin-section__head">
|
|
<div><span class="eyebrow">Аккаунты</span><h2 class="h-serif">Игроки и персонажи</h2></div>
|
|
<p>Поиск по логину или email, управление GM-уровнем и просмотр состава аккаунта.</p>
|
|
</div>
|
|
|
|
<div class="admin-toolbar">
|
|
<div class="admin-tabs">
|
|
<Link
|
|
v-for="(tab, key) in accountTabs"
|
|
:key="key"
|
|
:class="{ active: activeTab === key }"
|
|
:href="tabUrl(key)"
|
|
>
|
|
{{ tab.label }} <span>{{ tab.count }}</span>
|
|
</Link>
|
|
</div>
|
|
<form class="admin-search" @submit.prevent="searchAccounts">
|
|
<input v-model="searchValue" type="search" placeholder="Логин или email" />
|
|
<button class="btn btn-primary" type="submit">Найти</button>
|
|
<Link v-if="search" class="btn btn-ghost" :href="tabUrl(activeTab).split('&search=')[0]">Сбросить</Link>
|
|
</form>
|
|
</div>
|
|
|
|
<div v-if="accounts.length" class="admin-account-list">
|
|
<article v-for="account in accounts" :key="account.id" class="portal-card admin-account">
|
|
<div class="admin-account__main">
|
|
<div class="admin-account__identity">
|
|
<span :class="['admin-account__status', { danger: account.locked }]"></span>
|
|
<span>
|
|
<strong>{{ account.username }}</strong>
|
|
<small>ID #{{ account.id }} · {{ account.email }}</small>
|
|
</span>
|
|
</div>
|
|
<dl class="admin-account__stats">
|
|
<div><dt>Доступ</dt><dd>{{ account.access_label }}</dd></div>
|
|
<div><dt>Регистрация</dt><dd>{{ account.joined_at }}</dd></div>
|
|
<div><dt>Последний вход</dt><dd>{{ account.last_login_at }}</dd></div>
|
|
<div><dt>Персонажи</dt><dd>{{ account.characters.length }}</dd></div>
|
|
</dl>
|
|
<form class="admin-access-form" @submit.prevent="updateAccess(account)">
|
|
<label class="admin-field">
|
|
<span>Уровень доступа</span>
|
|
<select v-model="accessValues[account.id]">
|
|
<option v-for="level in accessLevels" :key="level.value" :value="level.value">{{ level.label }}</option>
|
|
</select>
|
|
<small v-if="savingAccount === account.id && errors.gmlevel">{{ errors.gmlevel }}</small>
|
|
</label>
|
|
<button class="btn btn-primary" type="submit" :disabled="savingAccount === account.id">
|
|
{{ savingAccount === account.id ? 'Сохраняем…' : 'Сохранить' }}
|
|
</button>
|
|
</form>
|
|
<button class="admin-expand" type="button" @click="expandedAccounts[account.id] = !expandedAccounts[account.id]">
|
|
{{ expandedAccounts[account.id] ? 'Скрыть персонажей' : `Персонажи · ${account.characters.length}` }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="expandedAccounts[account.id]" class="admin-account__characters">
|
|
<div v-for="character in account.characters" :key="character.name">
|
|
<span><strong>{{ character.name }}</strong><small>{{ character.race }} · {{ character.class }}</small></span>
|
|
<span><strong>{{ character.level }} ур.</strong><small>{{ character.online ? 'Онлайн' : 'Оффлайн' }}</small></span>
|
|
</div>
|
|
<p v-if="!account.characters.length">На аккаунте пока нет персонажей.</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<article v-else class="portal-card admin-empty">
|
|
<span>☾</span>
|
|
<h3 class="h-serif">{{ activeTab === 'bots' ? 'Боты не найдены' : 'Игроки не найдены' }}</h3>
|
|
<p>Измени поисковый запрос или переключись на другую вкладку.</p>
|
|
</article>
|
|
</section>
|
|
</div>
|
|
</AdminLayout>
|
|
</template>
|