новый дизайн
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<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>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { Head, Link, router, useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import AdminLayout from '../../Layouts/AdminLayout.vue';
|
||||
|
||||
const props = defineProps({
|
||||
site: { type: Object, required: true },
|
||||
auth: { type: Object, required: true },
|
||||
urls: { type: Object, required: true },
|
||||
flash: { type: Object, default: () => ({}) },
|
||||
transactions: { type: Array, default: () => [] },
|
||||
search: { type: String, default: '' },
|
||||
adjustUrl: { type: String, required: true },
|
||||
old: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const searchValue = ref(props.search);
|
||||
const form = useForm({
|
||||
account: props.old.account || '',
|
||||
direction: props.old.direction || 'credit',
|
||||
amount: props.old.amount || '',
|
||||
reason: props.old.reason || '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.post(props.adjustUrl, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
function searchTransactions() {
|
||||
router.get(props.urls.admin_balances, {
|
||||
search: searchValue.value || undefined,
|
||||
}, { preserveState: true, replace: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head><title>Слёзы Элуны — {{ site.name }}</title></Head>
|
||||
|
||||
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="balances">
|
||||
<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>{{ transactions.length }}</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-page wrap">
|
||||
<section class="admin-section">
|
||||
<div class="admin-split admin-split--form">
|
||||
<article class="portal-card admin-panel corners">
|
||||
<span class="admin-kicker">Новая операция</span>
|
||||
<h2 class="h-serif">Изменить баланс</h2>
|
||||
<form class="admin-form" @submit.prevent="submit">
|
||||
<label class="admin-field">
|
||||
<span>ID или логин аккаунта</span>
|
||||
<input v-model="form.account" required />
|
||||
<small v-if="form.errors.account">{{ form.errors.account }}</small>
|
||||
</label>
|
||||
<div class="admin-form-grid">
|
||||
<label class="admin-field">
|
||||
<span>Операция</span>
|
||||
<select v-model="form.direction" required>
|
||||
<option value="credit">Начислить</option>
|
||||
<option value="debit">Снять</option>
|
||||
</select>
|
||||
<small v-if="form.errors.direction">{{ form.errors.direction }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Количество</span>
|
||||
<input v-model="form.amount" type="number" min="0.01" max="1000000" step="0.01" required />
|
||||
<small v-if="form.errors.amount">{{ form.errors.amount }}</small>
|
||||
</label>
|
||||
</div>
|
||||
<label class="admin-field">
|
||||
<span>Причина</span>
|
||||
<input v-model="form.reason" maxlength="255" placeholder="Компенсация, возврат, нарушение…" required />
|
||||
<small v-if="form.errors.reason">{{ form.errors.reason }}</small>
|
||||
</label>
|
||||
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? 'Проводим…' : 'Провести операцию' }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="portal-card admin-panel admin-rules">
|
||||
<span class="admin-kicker">Контроль</span>
|
||||
<h2 class="h-serif">Правила операций</h2>
|
||||
<div><span>01</span><p>Баланс аккаунта не может стать отрицательным.</p></div>
|
||||
<div><span>02</span><p>В журнале сохраняются администратор, причина и итоговое значение.</p></div>
|
||||
<div><span>03</span><p>Ошибочная операция исправляется отдельной обратной корректировкой.</p></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section__head">
|
||||
<div><span class="eyebrow">Аудит</span><h2 class="h-serif">Последние транзакции</h2></div>
|
||||
<form class="admin-search" @submit.prevent="searchTransactions">
|
||||
<input v-model="searchValue" type="search" placeholder="Логин или ID аккаунта" />
|
||||
<button class="btn btn-primary" type="submit">Найти</button>
|
||||
<Link v-if="search" class="btn btn-ghost" :href="urls.admin_balances">Сбросить</Link>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length" class="admin-ledger">
|
||||
<article v-for="transaction in transactions" :key="transaction.id" class="portal-card admin-ledger__row">
|
||||
<div>
|
||||
<strong>{{ transaction.username }}</strong>
|
||||
<small>ID #{{ transaction.account_id }} · {{ transaction.created_at }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ transaction.type_label }}</strong>
|
||||
<small>{{ transaction.description }}</small>
|
||||
<small v-if="transaction.admin">Администратор: {{ transaction.admin.username }} (#{{ transaction.admin.account_id }})</small>
|
||||
</div>
|
||||
<div class="admin-ledger__amount">
|
||||
<strong :class="{ positive: transaction.is_positive }">
|
||||
{{ transaction.is_positive ? '+' : '' }}{{ transaction.formatted_amount }}
|
||||
</strong>
|
||||
<small>Баланс: {{ transaction.formatted_balance_after }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article v-else class="portal-card admin-empty"><p>Транзакции не найдены.</p></article>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
category: { type: Object, default: null },
|
||||
action: { type: String, required: true },
|
||||
submitLabel: { type: String, required: true },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.category?.name || '',
|
||||
icon: props.category?.icon || '',
|
||||
requiredRank: props.category?.required_rank ?? 0,
|
||||
flags: props.category?.flags ?? 0,
|
||||
enabled: props.category?.enabled ?? true,
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
if (!props.category) form.reset();
|
||||
},
|
||||
};
|
||||
|
||||
if (props.category) {
|
||||
form.patch(props.action, options);
|
||||
} else {
|
||||
form.post(props.action, options);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-form" @submit.prevent="submit">
|
||||
<div class="admin-form-grid">
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Название</span>
|
||||
<input v-model="form.name" maxlength="120" required />
|
||||
<small v-if="form.errors.name">{{ form.errors.name }}</small>
|
||||
</label>
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Иконка клиента</span>
|
||||
<input v-model="form.icon" maxlength="255" placeholder="inv_helmet_96" />
|
||||
<small v-if="form.errors.icon">{{ form.errors.icon }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Требуемый ранг</span>
|
||||
<input v-model="form.requiredRank" type="number" min="0" max="255" required />
|
||||
<small v-if="form.errors.requiredRank">{{ form.errors.requiredRank }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Флаги</span>
|
||||
<input v-model="form.flags" type="number" min="0" required />
|
||||
<small v-if="form.errors.flags">{{ form.errors.flags }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="admin-check">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span class="admin-check__box"></span>
|
||||
<span>Категория включена</span>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? 'Сохраняем…' : submitLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { router, useForm } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, default: null },
|
||||
action: { type: String, required: true },
|
||||
destroyUrl: { type: String, default: null },
|
||||
allowImage: { type: Boolean, default: false },
|
||||
labels: { type: Object, required: true },
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
title: props.item?.title ?? props.initial.title ?? '',
|
||||
sort_order: props.item?.sort_order ?? props.initial.sort_order ?? 0,
|
||||
is_published: props.item?.is_published ?? props.initial.is_published ?? true,
|
||||
body: props.item?.body ?? props.initial.body ?? '',
|
||||
...(props.allowImage ? { image: null } : {}),
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
forceFormData: props.allowImage,
|
||||
onSuccess: () => {
|
||||
if (!props.item) form.reset();
|
||||
},
|
||||
};
|
||||
|
||||
if (props.item && props.allowImage) {
|
||||
form.transform((data) => ({ ...data, _method: 'patch' })).post(props.action, options);
|
||||
} else if (props.item) {
|
||||
form.patch(props.action, options);
|
||||
} else {
|
||||
form.post(props.action, options);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyItem() {
|
||||
if (props.destroyUrl && window.confirm(props.labels.deleteConfirm)) {
|
||||
router.delete(props.destroyUrl, { preserveScroll: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-form" @submit.prevent="submit">
|
||||
<div class="admin-form-grid">
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>{{ labels.title }}</span>
|
||||
<input v-model="form.title" maxlength="120" required />
|
||||
<small v-if="form.errors.title">{{ form.errors.title }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Порядок вывода</span>
|
||||
<input v-model="form.sort_order" type="number" min="0" max="9999" required />
|
||||
<small v-if="form.errors.sort_order">{{ form.errors.sort_order }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="admin-check">
|
||||
<input v-model="form.is_published" type="checkbox" />
|
||||
<span class="admin-check__box"></span>
|
||||
<span>{{ labels.published }}</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-field">
|
||||
<span>{{ labels.body }}</span>
|
||||
<textarea v-model="form.body" rows="10" required></textarea>
|
||||
<em v-if="labels.bodyHint">{{ labels.bodyHint }}</em>
|
||||
<small v-if="form.errors.body">{{ form.errors.body }}</small>
|
||||
</label>
|
||||
|
||||
<label v-if="allowImage" class="admin-field admin-upload">
|
||||
<span>{{ item ? 'Заменить картинку' : 'Картинка' }}</span>
|
||||
<input type="file" accept="image/*" @input="form.image = $event.target.files[0]" />
|
||||
<em>{{ item ? 'Оставь пустым, чтобы сохранить текущую.' : 'Максимум 2 МБ. Необязательно.' }}</em>
|
||||
<small v-if="form.errors.image">{{ form.errors.image }}</small>
|
||||
</label>
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? 'Сохраняем…' : labels.submit }}
|
||||
</button>
|
||||
<button v-if="destroyUrl" class="btn btn-ghost admin-danger-button" type="button" @click="destroyItem">
|
||||
{{ labels.delete }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, default: null },
|
||||
categories: { type: Array, required: true },
|
||||
productTypes: { type: Array, required: true },
|
||||
action: { type: String, required: true },
|
||||
submitLabel: { type: String, required: true },
|
||||
});
|
||||
|
||||
const initialRewards = Object.fromEntries(
|
||||
Array.from({ length: 8 }, (_, index) => {
|
||||
const slot = index + 1;
|
||||
const reward = props.product?.rewards?.find((item) => item.slot === slot);
|
||||
return [
|
||||
[`reward_${slot}`, reward?.id ?? 0],
|
||||
[`rewardcount_${slot}`, reward?.count ?? 0],
|
||||
];
|
||||
}).flat(),
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
category_ids: props.product?.category_ids || [],
|
||||
type: props.product?.type ?? 1,
|
||||
name: props.product?.name || '',
|
||||
tooltipName: props.product?.tooltip_name || '',
|
||||
tooltipType: props.product?.tooltip_type || '',
|
||||
tooltipText: props.product?.tooltip_text || '',
|
||||
icon: props.product?.icon || '',
|
||||
price: props.product?.price ?? 1,
|
||||
hyperlinkId: props.product?.hyperlink_id ?? 0,
|
||||
creatureEntry: props.product?.creature_entry ?? 0,
|
||||
discountAmount: props.product?.discount_amount ?? 0,
|
||||
flags: props.product?.flags ?? 0,
|
||||
...initialRewards,
|
||||
new: props.product?.is_new ?? false,
|
||||
enabled: props.product?.enabled ?? true,
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const options = { preserveScroll: true };
|
||||
if (props.product) {
|
||||
form.patch(props.action, options);
|
||||
} else {
|
||||
form.post(props.action, {
|
||||
...options,
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-form" @submit.prevent="submit">
|
||||
<div class="admin-form-grid">
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Категории</span>
|
||||
<select v-model="form.category_ids" multiple :size="Math.min(Math.max(categories.length, 2), 8)" required>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }}</option>
|
||||
</select>
|
||||
<em>Можно выбрать несколько с Ctrl/Cmd.</em>
|
||||
<small v-if="form.errors.category_ids">{{ form.errors.category_ids }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Тип</span>
|
||||
<select v-model="form.type" required>
|
||||
<option v-for="type in productTypes" :key="type.value" :value="type.value">{{ type.label }} ({{ type.value }})</option>
|
||||
</select>
|
||||
<small v-if="form.errors.type">{{ form.errors.type }}</small>
|
||||
</label>
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Название</span>
|
||||
<textarea v-model="form.name" rows="3" required></textarea>
|
||||
<small v-if="form.errors.name">{{ form.errors.name }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Название подсказки</span>
|
||||
<input v-model="form.tooltipName" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Тип подсказки</span>
|
||||
<input v-model="form.tooltipType" placeholder="item или spell" />
|
||||
</label>
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Текст подсказки</span>
|
||||
<textarea v-model="form.tooltipText" rows="5"></textarea>
|
||||
</label>
|
||||
<label class="admin-field admin-field--wide">
|
||||
<span>Иконка клиента</span>
|
||||
<input v-model="form.icon" placeholder="inv_sword_22" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Цена, Слёзы Элуны</span>
|
||||
<input v-model="form.price" type="number" min="1" required />
|
||||
<small v-if="form.errors.price">{{ form.errors.price }}</small>
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Скидка, %</span>
|
||||
<input v-model="form.discountAmount" type="number" min="0" max="100" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Hyperlink ID</span>
|
||||
<input v-model="form.hyperlinkId" type="number" min="0" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Creature Entry</span>
|
||||
<input v-model="form.creatureEntry" type="number" min="0" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Флаги</span>
|
||||
<input v-model="form.flags" type="number" min="0" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="admin-subform">
|
||||
<summary>Награды · 8 слотов</summary>
|
||||
<div class="admin-rewards">
|
||||
<div v-for="slot in 8" :key="slot">
|
||||
<label class="admin-field">
|
||||
<span>Награда {{ slot }}</span>
|
||||
<input v-model="form[`reward_${slot}`]" type="number" min="0" />
|
||||
</label>
|
||||
<label class="admin-field">
|
||||
<span>Количество {{ slot }}</span>
|
||||
<input v-model="form[`rewardcount_${slot}`]" type="number" min="0" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="admin-check-row">
|
||||
<label class="admin-check">
|
||||
<input v-model="form.new" type="checkbox" />
|
||||
<span class="admin-check__box"></span>
|
||||
<span>Пометка «Новинка»</span>
|
||||
</label>
|
||||
<label class="admin-check">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span class="admin-check__box"></span>
|
||||
<span>Позиция включена</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? 'Сохраняем…' : submitLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
items: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-stat-grid">
|
||||
<article v-for="item in items" :key="item.label" class="portal-card admin-stat">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small v-if="item.note">{{ item.note }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import AdminLayout from '../../Layouts/AdminLayout.vue';
|
||||
import NewsForm from './Components/NewsForm.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: () => ({}) },
|
||||
newsItems: { type: Array, default: () => [] },
|
||||
newsStats: { type: Object, required: true },
|
||||
feedPreview: { type: String, required: true },
|
||||
feedUrl: { type: String, required: true },
|
||||
storeUrl: { type: String, required: true },
|
||||
old: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ label: 'Всего', value: props.newsStats.total },
|
||||
{ label: 'Опубликовано', value: props.newsStats.published },
|
||||
{ label: 'Черновики', value: props.newsStats.draft },
|
||||
];
|
||||
|
||||
const bodyHint = 'Поддерживаются WoW-коды цветов |cFF...|r, иконки |T...|t, переносы строк и обычный текст.';
|
||||
const createLabels = {
|
||||
title: 'Название в админке',
|
||||
published: 'Сразу публиковать эту запись',
|
||||
body: 'Текст для клиента',
|
||||
bodyHint,
|
||||
submit: 'Добавить запись',
|
||||
delete: 'Удалить запись',
|
||||
deleteConfirm: 'Удалить эту запись из фида?',
|
||||
};
|
||||
const editLabels = {
|
||||
...createLabels,
|
||||
title: 'Название',
|
||||
published: 'Показывать в публичном фиде',
|
||||
body: 'Текст блока',
|
||||
submit: 'Сохранить запись',
|
||||
};
|
||||
|
||||
const example = `|TInterface\\ICONS\\achievement_boss_lichking:25:25:0:-5|t |cFF00D1FFWrath of the Lich King|r
|
||||
|TInterface\\ICONS\\achievement_zone_icecrown_01:18:18:0:-8|t |cFFA6D8FF- Артас пробуждается как Король-лич|r
|
||||
|TInterface\\ICONS\\spell_deathknight_iceboundfortitude:18:18:0:-8|t |cFFA6D8FF- Рыцари смерти вторгаются в Азерот|r`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head><title>Новости WoW — {{ site.name }}</title></Head>
|
||||
|
||||
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="login-news">
|
||||
<section class="admin-hero wrap">
|
||||
<div>
|
||||
<span class="eyebrow">Клиент 3.3.5a</span>
|
||||
<h1 class="h-display">Новости логин-экрана WoW</h1>
|
||||
<p class="lead">Raw-текст с игровой разметкой и готовый публичный SERVERALERT-фид.</p>
|
||||
</div>
|
||||
<div class="admin-hero__badge"><small>Опубликовано</small><strong>{{ newsStats.published }}</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-page wrap">
|
||||
<section class="admin-section">
|
||||
<StatCards :items="stats" />
|
||||
<div class="admin-split">
|
||||
<article class="portal-card admin-panel corners">
|
||||
<span class="admin-kicker">Публичная ссылка</span>
|
||||
<h2 class="h-serif">Фид для клиента</h2>
|
||||
<p>Ссылка доступна без авторизации и всегда возвращает plain text.</p>
|
||||
<a class="btn btn-primary" :href="feedUrl" target="_blank" rel="noreferrer">Открыть фид</a>
|
||||
<code class="admin-code-block">{{ feedUrl }}</code>
|
||||
</article>
|
||||
<article class="portal-card admin-panel">
|
||||
<span class="admin-kicker">Итоговый ответ</span>
|
||||
<h2 class="h-serif">Превью сервера</h2>
|
||||
<pre class="admin-feed-preview">{{ feedPreview }}</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-split">
|
||||
<article class="portal-card admin-panel corners">
|
||||
<span class="admin-kicker">Новая запись</span>
|
||||
<h2 class="h-serif">Добавить новость</h2>
|
||||
<NewsForm :action="storeUrl" :labels="createLabels" :initial="old" />
|
||||
</article>
|
||||
<article class="portal-card admin-panel">
|
||||
<span class="admin-kicker">Шаблон</span>
|
||||
<h2 class="h-serif">Пример WoW-блока</h2>
|
||||
<p>Префикс SERVERALERT добавляется автоматически.</p>
|
||||
<pre class="admin-feed-preview">{{ example }}</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section__head">
|
||||
<div><span class="eyebrow">Текущие записи</span><h2 class="h-serif">Редактирование и публикация</h2></div>
|
||||
<p>Опубликованные записи попадают в фид по порядку sort_order.</p>
|
||||
</div>
|
||||
<div v-if="newsItems.length" class="admin-editor-list">
|
||||
<details v-for="item in newsItems" :key="item.id" class="portal-card admin-editor">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>Порядок: {{ item.sort_order }}<template v-if="item.updated_by"> · {{ item.updated_by }}</template><template v-if="item.updated_at"> · {{ item.updated_at }}</template></small>
|
||||
</span>
|
||||
<span :class="['admin-pill', { muted: !item.is_published }]">{{ item.is_published ? 'Опубликовано' : 'Черновик' }}</span>
|
||||
</summary>
|
||||
<div class="admin-editor__body">
|
||||
<NewsForm :item="item" :action="item.update_url" :destroy-url="item.destroy_url" :labels="editLabels" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<article v-else class="portal-card admin-empty"><p>Записей пока нет.</p></article>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import AdminLayout from '../../Layouts/AdminLayout.vue';
|
||||
import NewsForm from './Components/NewsForm.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: () => ({}) },
|
||||
newsItems: { type: Array, default: () => [] },
|
||||
newsStats: { type: Object, required: true },
|
||||
storeUrl: { type: String, required: true },
|
||||
old: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ label: 'Всего', value: props.newsStats.total },
|
||||
{ label: 'Опубликовано', value: props.newsStats.published },
|
||||
{ label: 'Черновики', value: props.newsStats.draft },
|
||||
];
|
||||
|
||||
const createLabels = {
|
||||
title: 'Заголовок',
|
||||
published: 'Сразу опубликовать',
|
||||
body: 'Текст новости',
|
||||
submit: 'Добавить новость',
|
||||
delete: 'Удалить',
|
||||
deleteConfirm: 'Удалить эту новость?',
|
||||
};
|
||||
|
||||
const editLabels = {
|
||||
...createLabels,
|
||||
published: 'Опубликовано',
|
||||
submit: 'Сохранить новость',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head><title>Новости — {{ site.name }}</title></Head>
|
||||
|
||||
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="news">
|
||||
<section class="admin-hero wrap">
|
||||
<div>
|
||||
<span class="eyebrow">Контент</span>
|
||||
<h1 class="h-display">Новости</h1>
|
||||
<p class="lead">Публикации для лендинга и лаунчера с изображениями из S3-хранилища.</p>
|
||||
</div>
|
||||
<div class="admin-hero__badge"><small>Опубликовано</small><strong>{{ newsStats.published }}</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-page wrap">
|
||||
<section class="admin-section">
|
||||
<StatCards :items="stats" />
|
||||
<article class="portal-card admin-panel corners admin-create-panel">
|
||||
<span class="admin-kicker">Новая запись</span>
|
||||
<h2 class="h-serif">Добавить новость</h2>
|
||||
<NewsForm :action="storeUrl" :allow-image="true" :labels="createLabels" :initial="old" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section__head">
|
||||
<div><span class="eyebrow">Текущие записи</span><h2 class="h-serif">Редактирование</h2></div>
|
||||
<p>Записи отсортированы по полю порядка вывода.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="newsItems.length" class="admin-editor-list">
|
||||
<details v-for="item in newsItems" :key="item.id" class="portal-card admin-editor">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>Порядок: {{ item.sort_order }} · {{ item.created_by }}<template v-if="item.updated_at"> · {{ item.updated_at }}</template></small>
|
||||
</span>
|
||||
<span :class="['admin-pill', { muted: !item.is_published }]">{{ item.is_published ? 'Опубликовано' : 'Черновик' }}</span>
|
||||
</summary>
|
||||
<div class="admin-editor__body">
|
||||
<img v-if="item.image_url" class="admin-news-image" :src="item.image_url" :alt="item.title" />
|
||||
<NewsForm
|
||||
:item="item"
|
||||
:action="item.update_url"
|
||||
:destroy-url="item.destroy_url"
|
||||
:allow-image="true"
|
||||
:labels="editLabels"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<article v-else class="portal-card admin-empty">
|
||||
<span>☾</span><h3 class="h-serif">Новостей пока нет</h3><p>Добавь первую публикацию.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import AdminLayout from '../../Layouts/AdminLayout.vue';
|
||||
import CategoryForm from './Components/CategoryForm.vue';
|
||||
import ProductForm from './Components/ProductForm.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: () => ({}) },
|
||||
categories: { type: Array, default: () => [] },
|
||||
products: { type: Array, default: () => [] },
|
||||
categoryStoreUrl: { type: String, required: true },
|
||||
productStoreUrl: { type: String, required: true },
|
||||
productTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ label: 'Категории', value: props.categories.length },
|
||||
{ label: 'Позиции', value: props.products.length },
|
||||
{ label: 'Активные', value: props.products.filter((item) => item.enabled).length },
|
||||
];
|
||||
|
||||
function destroy(url, message) {
|
||||
if (window.confirm(message)) {
|
||||
router.delete(url, { preserveScroll: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head><title>Магазин — {{ site.name }}</title></Head>
|
||||
|
||||
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="shop">
|
||||
<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>{{ products.length }}</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-page wrap">
|
||||
<section class="admin-section">
|
||||
<StatCards :items="stats" />
|
||||
<div class="admin-notice">
|
||||
Изменения записываются прямо в базу <code>store</code>. После сохранения перезагрузи данные магазина командой модуля или перезапусти worldserver.
|
||||
</div>
|
||||
|
||||
<div class="admin-split">
|
||||
<article class="portal-card admin-panel corners">
|
||||
<span class="admin-kicker">Навигация магазина</span>
|
||||
<h2 class="h-serif">Новая категория</h2>
|
||||
<CategoryForm :action="categoryStoreUrl" submit-label="Создать категорию" />
|
||||
</article>
|
||||
<article class="portal-card admin-panel corners">
|
||||
<span class="admin-kicker">Контент магазина</span>
|
||||
<h2 class="h-serif">Новая позиция</h2>
|
||||
<ProductForm
|
||||
v-if="categories.length"
|
||||
:categories="categories"
|
||||
:product-types="productTypes"
|
||||
:action="productStoreUrl"
|
||||
submit-label="Создать позицию"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="categories.length" class="admin-editor-list">
|
||||
<details v-for="category in categories" :key="category.id" class="portal-card admin-editor">
|
||||
<summary>
|
||||
<span><strong>#{{ category.id }} · {{ category.name }}</strong><small>{{ category.enabled ? 'Включена' : 'Отключена' }}</small></span>
|
||||
<span class="admin-pill">{{ category.products_count }} позиций</span>
|
||||
</summary>
|
||||
<div class="admin-editor__body">
|
||||
<CategoryForm :category="category" :action="category.update_url" submit-label="Сохранить категорию" />
|
||||
<button class="btn btn-ghost admin-danger-button" type="button" @click="destroy(category.destroy_url, 'Удалить категорию и её связи с товарами?')">Удалить категорию</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<article v-else class="portal-card admin-empty"><p>Категорий пока нет.</p></article>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section__head">
|
||||
<div><span class="eyebrow">Контент магазина</span><h2 class="h-serif">Товары и услуги</h2></div>
|
||||
</div>
|
||||
<div v-if="products.length" class="admin-editor-list">
|
||||
<details v-for="product in products" :key="product.id" class="portal-card admin-editor">
|
||||
<summary>
|
||||
<span>
|
||||
<strong class="admin-preline">#{{ product.id }} · {{ product.name }}</strong>
|
||||
<small>{{ product.category_names.join(', ') || 'Без категории' }} · тип {{ product.type }}</small>
|
||||
</span>
|
||||
<span class="admin-pill">{{ product.price }} Слёз Элуны</span>
|
||||
</summary>
|
||||
<div class="admin-editor__body">
|
||||
<ProductForm
|
||||
:product="product"
|
||||
:categories="categories"
|
||||
:product-types="productTypes"
|
||||
:action="product.update_url"
|
||||
submit-label="Сохранить позицию"
|
||||
/>
|
||||
<button class="btn btn-ghost admin-danger-button" type="button" @click="destroy(product.destroy_url, 'Удалить позицию магазина?')">Удалить позицию</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<article v-else class="portal-card admin-empty"><p>Позиций пока нет.</p></article>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user