diff --git a/.env.example b/.env.example index 95990ed..197efbd 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,7 @@ AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web" AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false MOONWELL_ADMIN_MIN_GMLEVEL=3 MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin" +MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT MOONWELL_SERVER_NAME=Moonwell MOONWELL_REALM_NAME=Moonwell diff --git a/README.md b/README.md index 09d8f04..8534ba8 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ chmod +x scripts/deploy-ubuntu.sh - `AZEROTHCORE_AUTH_DB_PASSWORD=password` - `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters` - `MOONWELL_ADMIN_MIN_GMLEVEL=3` +- `MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT` - `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"` - `AWS_ENDPOINT=https://storage.yandexcloud.net` - `AWS_BUCKET=warcraft-client` diff --git a/app/Http/Controllers/Admin/AccountController.php b/app/Http/Controllers/Admin/AccountController.php index a04ff32..aa3f46e 100644 --- a/app/Http/Controllers/Admin/AccountController.php +++ b/app/Http/Controllers/Admin/AccountController.php @@ -20,9 +20,29 @@ class AccountController extends Controller ): View { $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', [ - '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(), 'inviteStats' => $inviteCodes->stats(), 'search' => $search, diff --git a/app/Services/AzerothCoreAccountService.php b/app/Services/AzerothCoreAccountService.php index 13f1d9d..697a462 100644 --- a/app/Services/AzerothCoreAccountService.php +++ b/app/Services/AzerothCoreAccountService.php @@ -4,12 +4,17 @@ namespace App\Services; use App\Models\GameAccountUser; use App\Support\WowData; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class AzerothCoreAccountService { + public const ACCOUNT_TAB_PLAYERS = 'players'; + + public const ACCOUNT_TAB_BOTS = 'bots'; + public function __construct(private readonly AzerothCoreSrpService $srp) { } @@ -66,23 +71,11 @@ class AzerothCoreAccountService /** * @return \Illuminate\Support\Collection> */ - public function listAccounts(?string $search = null): Collection + public function listAccounts(?string $search = null, string $accountTab = self::ACCOUNT_TAB_PLAYERS): Collection { - $query = $this->authConnection() - ->table('account') - ->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online') - ->orderByDesc('id'); + $query = $this->baseAccountsQuery($search); - 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}%"); - }); - } + $this->applyAccountTabFilter($query, $accountTab); $accounts = $query->get(); $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> */ @@ -217,6 +227,51 @@ class AzerothCoreAccountService 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 { return (int) config('moonwell.registration.realm_id', -1); diff --git a/config/moonwell.php b/config/moonwell.php index f7a3b13..a2cba82 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -15,6 +15,7 @@ return [ 'portal' => [ 'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3), 'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'), + 'bot_account_prefix' => env('MOONWELL_BOT_ACCOUNT_PREFIX', 'RNDBOT'), ], 'client' => [ diff --git a/public/site.css b/public/site.css index 1034ef9..c7858a7 100644 --- a/public/site.css +++ b/public/site.css @@ -610,6 +610,13 @@ pre { margin-top: 18px; } +.tab-switcher { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 18px; +} + .search-form .form-control { flex: 1 1 auto; } @@ -687,6 +694,7 @@ pre { } .topbar-actions, + .tab-switcher, .search-form, .inline-form, .character-card__top, diff --git a/resources/views/admin/accounts/index.blade.php b/resources/views/admin/accounts/index.blade.php index 751df63..a561ef3 100644 --- a/resources/views/admin/accounts/index.blade.php +++ b/resources/views/admin/accounts/index.blade.php @@ -79,11 +79,23 @@

Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.

+
+ @foreach ($accountTabs as $tabKey => $tab) + + {{ $tab['label'] }} · {{ $tab['count'] }} + + @endforeach +
+
+ @if ($search !== '') - Сбросить + Сбросить @endif
@@ -157,8 +169,8 @@ @empty
-

Аккаунты не найдены

-

Измени поисковый запрос или попробуй позже.

+

{{ $activeTab === 'bots' ? 'Боты не найдены' : 'Игроки не найдены' }}

+

Измени поисковый запрос или переключись на другую вкладку.

@endforelse diff --git a/tests/Feature/AdminAccountsFeatureTest.php b/tests/Feature/AdminAccountsFeatureTest.php index 4516b6a..8884a7c 100644 --- a/tests/Feature/AdminAccountsFeatureTest.php +++ b/tests/Feature/AdminAccountsFeatureTest.php @@ -28,9 +28,14 @@ class AdminAccountsFeatureTest extends TestCase public function test_admin_can_open_accounts_page(): void { $this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void { - $mock->shouldReceive('listAccounts') + $mock->shouldReceive('accountTypeStats') ->once() ->with(null) + ->andReturn(['players' => 1, 'bots' => 1]); + + $mock->shouldReceive('listAccounts') + ->once() + ->with(null, 'players') ->andReturn(new Collection([ [ 'id' => 1, @@ -72,6 +77,53 @@ class AdminAccountsFeatureTest extends TestCase $response->assertSee('Stormmage'); $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