вкладка для ботов в админке
This commit is contained in:
@@ -96,6 +96,7 @@ AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
|
|||||||
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
||||||
MOONWELL_ADMIN_MIN_GMLEVEL=3
|
MOONWELL_ADMIN_MIN_GMLEVEL=3
|
||||||
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
||||||
|
MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT
|
||||||
|
|
||||||
MOONWELL_SERVER_NAME=Moonwell
|
MOONWELL_SERVER_NAME=Moonwell
|
||||||
MOONWELL_REALM_NAME=Moonwell
|
MOONWELL_REALM_NAME=Moonwell
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ chmod +x scripts/deploy-ubuntu.sh
|
|||||||
- `AZEROTHCORE_AUTH_DB_PASSWORD=password`
|
- `AZEROTHCORE_AUTH_DB_PASSWORD=password`
|
||||||
- `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters`
|
- `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters`
|
||||||
- `MOONWELL_ADMIN_MIN_GMLEVEL=3`
|
- `MOONWELL_ADMIN_MIN_GMLEVEL=3`
|
||||||
|
- `MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT`
|
||||||
- `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"`
|
- `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"`
|
||||||
- `AWS_ENDPOINT=https://storage.yandexcloud.net`
|
- `AWS_ENDPOINT=https://storage.yandexcloud.net`
|
||||||
- `AWS_BUCKET=warcraft-client`
|
- `AWS_BUCKET=warcraft-client`
|
||||||
|
|||||||
@@ -20,9 +20,29 @@ class AccountController extends Controller
|
|||||||
): View
|
): View
|
||||||
{
|
{
|
||||||
$search = trim((string) $request->query('search', ''));
|
$search = trim((string) $request->query('search', ''));
|
||||||
|
$searchTerm = $search !== '' ? $search : null;
|
||||||
|
$activeTab = (string) $request->query('tab', AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS);
|
||||||
|
|
||||||
|
if (! in_array($activeTab, [AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS, AzerothCoreAccountService::ACCOUNT_TAB_BOTS], true)) {
|
||||||
|
$activeTab = AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accountStats = $accounts->accountTypeStats($searchTerm);
|
||||||
|
|
||||||
return view('admin.accounts.index', [
|
return view('admin.accounts.index', [
|
||||||
'accounts' => $accounts->listAccounts($search !== '' ? $search : null),
|
'accounts' => $accounts->listAccounts($searchTerm, $activeTab),
|
||||||
|
'accountStats' => $accountStats,
|
||||||
|
'accountTabs' => [
|
||||||
|
AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS => [
|
||||||
|
'label' => 'Игроки',
|
||||||
|
'count' => $accountStats[AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS] ?? 0,
|
||||||
|
],
|
||||||
|
AzerothCoreAccountService::ACCOUNT_TAB_BOTS => [
|
||||||
|
'label' => 'Боты',
|
||||||
|
'count' => $accountStats[AzerothCoreAccountService::ACCOUNT_TAB_BOTS] ?? 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'activeTab' => $activeTab,
|
||||||
'inviteCodes' => $inviteCodes->latest(),
|
'inviteCodes' => $inviteCodes->latest(),
|
||||||
'inviteStats' => $inviteCodes->stats(),
|
'inviteStats' => $inviteCodes->stats(),
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\GameAccountUser;
|
use App\Models\GameAccountUser;
|
||||||
use App\Support\WowData;
|
use App\Support\WowData;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class AzerothCoreAccountService
|
class AzerothCoreAccountService
|
||||||
{
|
{
|
||||||
|
public const ACCOUNT_TAB_PLAYERS = 'players';
|
||||||
|
|
||||||
|
public const ACCOUNT_TAB_BOTS = 'bots';
|
||||||
|
|
||||||
public function __construct(private readonly AzerothCoreSrpService $srp)
|
public function __construct(private readonly AzerothCoreSrpService $srp)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -66,23 +71,11 @@ class AzerothCoreAccountService
|
|||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public function listAccounts(?string $search = null): Collection
|
public function listAccounts(?string $search = null, string $accountTab = self::ACCOUNT_TAB_PLAYERS): Collection
|
||||||
{
|
{
|
||||||
$query = $this->authConnection()
|
$query = $this->baseAccountsQuery($search);
|
||||||
->table('account')
|
|
||||||
->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online')
|
|
||||||
->orderByDesc('id');
|
|
||||||
|
|
||||||
if ($search !== null && trim($search) !== '') {
|
$this->applyAccountTabFilter($query, $accountTab);
|
||||||
$normalizedSearch = $this->srp->normalizeUsername($search);
|
|
||||||
|
|
||||||
$query->where(function ($builder) use ($normalizedSearch): void {
|
|
||||||
$builder
|
|
||||||
->where('username', 'like', "%{$normalizedSearch}%")
|
|
||||||
->orWhere('email', 'like', "%{$normalizedSearch}%")
|
|
||||||
->orWhere('reg_mail', 'like', "%{$normalizedSearch}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$accounts = $query->get();
|
$accounts = $query->get();
|
||||||
$accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
$accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||||
@@ -109,6 +102,23 @@ class AzerothCoreAccountService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{players: int, bots: int}
|
||||||
|
*/
|
||||||
|
public function accountTypeStats(?string $search = null): array
|
||||||
|
{
|
||||||
|
$playersQuery = $this->baseAccountsQuery($search);
|
||||||
|
$botsQuery = $this->baseAccountsQuery($search);
|
||||||
|
|
||||||
|
$this->applyAccountTabFilter($playersQuery, self::ACCOUNT_TAB_PLAYERS);
|
||||||
|
$this->applyAccountTabFilter($botsQuery, self::ACCOUNT_TAB_BOTS);
|
||||||
|
|
||||||
|
return [
|
||||||
|
self::ACCOUNT_TAB_PLAYERS => $playersQuery->count(),
|
||||||
|
self::ACCOUNT_TAB_BOTS => $botsQuery->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -217,6 +227,51 @@ class AzerothCoreAccountService
|
|||||||
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function baseAccountsQuery(?string $search = null): Builder
|
||||||
|
{
|
||||||
|
$query = $this->authConnection()
|
||||||
|
->table('account')
|
||||||
|
->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online')
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($search !== null && trim($search) !== '') {
|
||||||
|
$normalizedSearch = $this->srp->normalizeUsername($search);
|
||||||
|
|
||||||
|
$query->where(function ($builder) use ($normalizedSearch): void {
|
||||||
|
$builder
|
||||||
|
->where('username', 'like', "%{$normalizedSearch}%")
|
||||||
|
->orWhere('email', 'like', "%{$normalizedSearch}%")
|
||||||
|
->orWhere('reg_mail', 'like', "%{$normalizedSearch}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyAccountTabFilter(Builder $query, string $accountTab): void
|
||||||
|
{
|
||||||
|
$botPrefix = $this->botAccountPrefix();
|
||||||
|
|
||||||
|
if ($botPrefix === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = $botPrefix.'%';
|
||||||
|
|
||||||
|
if ($accountTab === self::ACCOUNT_TAB_BOTS) {
|
||||||
|
$query->where('username', 'like', $pattern);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where('username', 'not like', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function botAccountPrefix(): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim((string) config('moonwell.portal.bot_account_prefix', 'RNDBOT')));
|
||||||
|
}
|
||||||
|
|
||||||
private function targetRealmId(): int
|
private function targetRealmId(): int
|
||||||
{
|
{
|
||||||
return (int) config('moonwell.registration.realm_id', -1);
|
return (int) config('moonwell.registration.realm_id', -1);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ return [
|
|||||||
'portal' => [
|
'portal' => [
|
||||||
'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3),
|
'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3),
|
||||||
'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'),
|
'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'),
|
||||||
|
'bot_account_prefix' => env('MOONWELL_BOT_ACCOUNT_PREFIX', 'RNDBOT'),
|
||||||
],
|
],
|
||||||
|
|
||||||
'client' => [
|
'client' => [
|
||||||
|
|||||||
@@ -610,6 +610,13 @@ pre {
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-form .form-control {
|
.search-form .form-control {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
@@ -687,6 +694,7 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions,
|
.topbar-actions,
|
||||||
|
.tab-switcher,
|
||||||
.search-form,
|
.search-form,
|
||||||
.inline-form,
|
.inline-form,
|
||||||
.character-card__top,
|
.character-card__top,
|
||||||
|
|||||||
@@ -79,11 +79,23 @@
|
|||||||
<p>Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.</p>
|
<p>Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-switcher">
|
||||||
|
@foreach ($accountTabs as $tabKey => $tab)
|
||||||
|
<a
|
||||||
|
class="button {{ $activeTab === $tabKey ? 'button--gold' : 'button--ghost' }}"
|
||||||
|
href="{{ route('admin.accounts.index', array_filter(['tab' => $tabKey, 'search' => $search !== '' ? $search : null])) }}"
|
||||||
|
>
|
||||||
|
{{ $tab['label'] }} · {{ $tab['count'] }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="search-form" method="GET" action="{{ route('admin.accounts.index') }}">
|
<form class="search-form" method="GET" action="{{ route('admin.accounts.index') }}">
|
||||||
|
<input type="hidden" name="tab" value="{{ $activeTab }}">
|
||||||
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Поиск по логину или email">
|
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Поиск по логину или email">
|
||||||
<button class="button button--gold" type="submit">Найти</button>
|
<button class="button button--gold" type="submit">Найти</button>
|
||||||
@if ($search !== '')
|
@if ($search !== '')
|
||||||
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Сбросить</a>
|
<a class="button button--ghost" href="{{ route('admin.accounts.index', ['tab' => $activeTab]) }}">Сбросить</a>
|
||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -157,8 +169,8 @@
|
|||||||
</article>
|
</article>
|
||||||
@empty
|
@empty
|
||||||
<article class="content-card empty-state">
|
<article class="content-card empty-state">
|
||||||
<h3>Аккаунты не найдены</h3>
|
<h3>{{ $activeTab === 'bots' ? 'Боты не найдены' : 'Игроки не найдены' }}</h3>
|
||||||
<p>Измени поисковый запрос или попробуй позже.</p>
|
<p>Измени поисковый запрос или переключись на другую вкладку.</p>
|
||||||
</article>
|
</article>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ class AdminAccountsFeatureTest extends TestCase
|
|||||||
public function test_admin_can_open_accounts_page(): void
|
public function test_admin_can_open_accounts_page(): void
|
||||||
{
|
{
|
||||||
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
|
||||||
$mock->shouldReceive('listAccounts')
|
$mock->shouldReceive('accountTypeStats')
|
||||||
->once()
|
->once()
|
||||||
->with(null)
|
->with(null)
|
||||||
|
->andReturn(['players' => 1, 'bots' => 1]);
|
||||||
|
|
||||||
|
$mock->shouldReceive('listAccounts')
|
||||||
|
->once()
|
||||||
|
->with(null, 'players')
|
||||||
->andReturn(new Collection([
|
->andReturn(new Collection([
|
||||||
[
|
[
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
@@ -72,6 +77,53 @@ class AdminAccountsFeatureTest extends TestCase
|
|||||||
$response->assertSee('Stormmage');
|
$response->assertSee('Stormmage');
|
||||||
$response->assertSee('Аккаунты и персонажи');
|
$response->assertSee('Аккаунты и персонажи');
|
||||||
$response->assertSee('Управление инвайт-кодами');
|
$response->assertSee('Управление инвайт-кодами');
|
||||||
|
$response->assertSee('Игроки · 1');
|
||||||
|
$response->assertSee('Боты · 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_open_bots_tab(): void
|
||||||
|
{
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('accountTypeStats')
|
||||||
|
->once()
|
||||||
|
->with(null)
|
||||||
|
->andReturn(['players' => 4, 'bots' => 2]);
|
||||||
|
|
||||||
|
$mock->shouldReceive('listAccounts')
|
||||||
|
->once()
|
||||||
|
->with(null, 'bots')
|
||||||
|
->andReturn(new Collection([
|
||||||
|
[
|
||||||
|
'id' => 7,
|
||||||
|
'username' => 'RNDBOT0007',
|
||||||
|
'email' => '',
|
||||||
|
'reg_mail' => '',
|
||||||
|
'gm_level' => 0,
|
||||||
|
'access_label' => 'Игрок',
|
||||||
|
'locked' => false,
|
||||||
|
'online' => false,
|
||||||
|
'joined_at' => '14.03.2026 01:00',
|
||||||
|
'last_login_at' => '14.03.2026 01:30',
|
||||||
|
'characters' => new Collection(),
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('latest')
|
||||||
|
->once()
|
||||||
|
->andReturn(new Collection());
|
||||||
|
|
||||||
|
$mock->shouldReceive('stats')
|
||||||
|
->once()
|
||||||
|
->andReturn(['total' => 0, 'available' => 0, 'redeemed' => 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->makeUser(3))->get(route('admin.accounts.index', ['tab' => 'bots']));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('RNDBOT0007');
|
||||||
|
$response->assertSee('Боты · 2');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_admin_can_update_access_level(): void
|
public function test_admin_can_update_access_level(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user