Files
moonwell-web/resources/js/Pages/Admin/Accounts.vue
T
2026-07-25 18:36:45 +04:00

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>