Ver código fonte

后台游戏分类

doge 16 horas atrás
pai
commit
383ae71b0e

+ 67 - 25
app/Http/Controllers/admin/Egame.php

@@ -39,7 +39,10 @@ class Egame extends Controller
                 'plat_type' => ['nullable', 'string', 'max:32'],
                 'game_type' => ['nullable', 'integer', 'min:0', 'max:255'],
                 'game_code' => ['nullable', 'string', 'max:128'],
+                'name' => ['nullable', 'string', 'max:128'],
                 'keyword' => ['nullable', 'string', 'max:128'],
+                'ingress' => ['nullable', Rule::in(EgameItem::ingressValues())],
+                'network' => ['nullable', Rule::in(EgameItem::networkValues())],
                 'status' => ['nullable', 'integer', Rule::in([
                     EgameItem::STATUS_DISABLED,
                     EgameItem::STATUS_ENABLED,
@@ -49,13 +52,18 @@ class Egame extends Controller
             ]);
 
             $query = EgameItem::query();
-            foreach (['item_type', 'plat_type', 'game_type', 'game_code', 'status'] as $field) {
+            foreach (['item_type', 'plat_type', 'game_type', 'game_code', 'status', 'ingress', 'network'] as $field) {
                 $value = request()->input($field);
                 if ($value !== null && $value !== '') {
                     $query->where($field, $value);
                 }
             }
 
+            $name = trim((string) request()->input('name', ''));
+            if ($name !== '') {
+                $query->where('name', 'like', "%{$name}%");
+            }
+
             $platformScope = (string) request()->input('platform_scope', '');
             if ($platformScope !== '') {
                 $itemType = (string) request()->input('item_type', '');
@@ -83,11 +91,12 @@ class Egame extends Controller
             $page = (int) request()->input('page', 1);
             $total = (clone $query)->count();
             $states = $this->catalogStates();
+            $gameCountsByPlat = $this->gameCountsByPlat();
             $list = $query->orderByDesc('sort')
                 ->orderByDesc('id')
                 ->forPage($page, $limit)
                 ->get()
-                ->map(fn (EgameItem $item) => $this->decorateItem($item, $states))
+                ->map(fn (EgameItem $item) => $this->decorateItem($item, $states, $gameCountsByPlat))
                 ->values();
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
@@ -121,11 +130,7 @@ class Egame extends Controller
                 'plat_type',
                 'game_type'
             );
-            $platformGameCounts = $this->groupedCounts(
-                EgameItem::query()->where('item_type', EgameItem::TYPE_GAME),
-                'plat_type',
-                'id'
-            );
+            $platformGameCounts = $this->gameCountsByPlat();
 
             $categories = EgameItem::query()
                 ->where('item_type', EgameItem::TYPE_CATEGORY)
@@ -149,7 +154,7 @@ class Egame extends Controller
                 ->orderBy('plat_type')
                 ->get()
                 ->map(function (EgameItem $item) use ($states, $platformCategoryCounts, $platformGameCounts) {
-                    $this->decorateItem($item, $states);
+                    $this->decorateItem($item, $states, $platformGameCounts);
                     $key = strtolower((string) $item->plat_type);
                     $item->setAttribute('category_count', $platformCategoryCounts[$key] ?? 0);
                     $item->setAttribute('game_count', $platformGameCounts[$key] ?? 0);
@@ -163,15 +168,9 @@ class Egame extends Controller
         return $this->success([
             'categories' => $categories,
             'platforms' => $platforms,
-            'statuses' => [
-                ['value' => EgameItem::STATUS_ENABLED, 'label' => '开启'],
-                ['value' => EgameItem::STATUS_DISABLED, 'label' => '关闭'],
-            ],
-            'ingress' => [
-                ['value' => '1', 'label' => '仅电脑'],
-                ['value' => '2', 'label' => '仅手机'],
-                ['value' => '3', 'label' => '通用'],
-            ],
+            'statuses' => EgameItem::optionList(EgameItem::STATUS_LABELS),
+            'ingress' => EgameItem::optionList(EgameItem::INGRESS_LABELS),
+            'networks' => EgameItem::optionList(EgameItem::NETWORK_LABELS),
         ]);
     }
 
@@ -193,9 +192,13 @@ class Egame extends Controller
                 'game_type' => ['nullable', 'integer', 'min:0', 'max:255'],
                 'game_code' => ['nullable', 'string', 'max:128'],
                 'name' => ['nullable', 'string', 'max:128'],
-                'ingress' => ['nullable', Rule::in(['1', '2', '3'])],
+                'description' => ['nullable', 'string', 'max:255'],
+                'ingress' => ['nullable', Rule::in(EgameItem::ingressValues())],
+                'network' => ['nullable', Rule::in(array_merge([''], EgameItem::networkValues()))],
                 'lobby_enabled' => ['nullable', 'boolean'],
                 'logo' => ['nullable', 'string', 'max:500'],
+                'logo_pc' => ['nullable', 'string', 'max:500'],
+                'logo_h5' => ['nullable', 'string', 'max:500'],
                 'logo_langs' => ['nullable', 'array'],
                 'status' => ['nullable', 'integer', Rule::in([
                     EgameItem::STATUS_DISABLED,
@@ -235,7 +238,7 @@ class Egame extends Controller
             return $this->error((int) $e->getCode(), $e->getMessage());
         }
 
-        return $this->success($this->decorateItem($item, $this->catalogStates()));
+        return $this->success($this->decorateItem($item, $this->catalogStates(), $this->gameCountsByPlat()));
     }
 
     public function setStatus(): JsonResponse
@@ -262,7 +265,7 @@ class Egame extends Controller
             return $this->error((int) $e->getCode(), $e->getMessage());
         }
 
-        return $this->success($this->decorateItem($item, $this->catalogStates()));
+        return $this->success($this->decorateItem($item, $this->catalogStates(), $this->gameCountsByPlat()));
     }
 
     public function delete(): JsonResponse
@@ -397,19 +400,25 @@ class Egame extends Controller
         $input = request()->all();
         $data = $isNew ? [
             'name' => '',
-            'ingress' => '3',
+            'description' => '',
+            'ingress' => EgameItem::INGRESS_BOTH,
+            'network' => '',
             'lobby_enabled' => 0,
             'logo' => '',
+            'logo_h5' => '',
             'logo_langs' => null,
             'status' => EgameItem::STATUS_ENABLED,
             'sort' => 0,
         ] : [];
 
-        foreach (['name', 'ingress', 'logo'] as $field) {
+        foreach (['name', 'description', 'ingress', 'network', 'logo', 'logo_h5'] as $field) {
             if (array_key_exists($field, $input)) {
                 $data[$field] = trim((string) $input[$field]);
             }
         }
+        if (array_key_exists('logo_pc', $input) && !array_key_exists('logo', $data)) {
+            $data['logo'] = trim((string) $input['logo_pc']);
+        }
         if (array_key_exists('logo_langs', $input)) {
             $data['logo_langs'] = $item->item_type === EgameItem::TYPE_GAME
                 ? $input['logo_langs']
@@ -427,8 +436,11 @@ class Egame extends Controller
                 : 0;
         }
 
-        if ($item->item_type !== EgameItem::TYPE_GAME && array_key_exists('ingress', $data)) {
-            $data['ingress'] = '3';
+        if ($item->isCategory() && array_key_exists('ingress', $data)) {
+            $data['ingress'] = EgameItem::INGRESS_BOTH;
+        }
+        if ($item->isCategory() && array_key_exists('network', $data)) {
+            $data['network'] = '';
         }
         if ($item->item_type !== EgameItem::TYPE_GAME && array_key_exists('logo_langs', $data)) {
             $data['logo_langs'] = null;
@@ -471,7 +483,7 @@ class Egame extends Controller
         return $states;
     }
 
-    private function decorateItem(EgameItem $item, array $states): EgameItem
+    private function decorateItem(EgameItem $item, array $states, array $gameCountsByPlat = []): EgameItem
     {
         $platType = strtolower((string) $item->plat_type);
         $gameType = (string) $item->game_type;
@@ -526,10 +538,40 @@ class Egame extends Controller
         }
         $item->setAttribute('effective_status', $disabledBy === null ? 1 : 0);
         $item->setAttribute('disabled_by', $disabledBy);
+        $item->setAttribute('description', (string) ($item->description ?? ''));
+        $item->setAttribute('logo_pc', (string) ($item->logo ?? ''));
+        $item->setAttribute('logo_h5', (string) ($item->logo_h5 ?? ''));
+        $item->setAttribute('network', (string) ($item->network ?? ''));
+        $item->setAttribute('network_name', EgameItem::networkName($item->network ?? ''));
+        $item->setAttribute('ingress_name', EgameItem::ingressName($item->ingress ?? ''));
+        $item->setAttribute('status_name', EgameItem::STATUS_LABELS[(int) $item->status] ?? '');
+
+        $gameCount = $gameCountsByPlat[$platType] ?? 0;
+        $item->setAttribute(
+            'has_game_manage',
+            $item->item_type === EgameItem::TYPE_PLATFORM && $gameCount > 0
+        );
+        if ($item->item_type === EgameItem::TYPE_PLATFORM && !$item->hasAttribute('game_count')) {
+            $item->setAttribute('game_count', $gameCount);
+        }
 
         return $item;
     }
 
+    private function gameCountsByPlat(): array
+    {
+        $counts = [];
+        foreach ($this->groupedCounts(
+            EgameItem::query()->where('item_type', EgameItem::TYPE_GAME),
+            'plat_type',
+            'id'
+        ) as $platType => $count) {
+            $counts[strtolower((string) $platType)] = (int) $count;
+        }
+
+        return $counts;
+    }
+
     private function enabled(?EgameItem $item): bool
     {
         return $item !== null && $item->status === EgameItem::STATUS_ENABLED;

+ 60 - 0
app/Models/EgameItem.php

@@ -14,6 +14,33 @@ class EgameItem extends BaseModel
     public const STATUS_DISABLED = 0;
     public const STATUS_ENABLED = 1;
 
+    public const INGRESS_PC = '1';
+    public const INGRESS_MOBILE = '2';
+    public const INGRESS_BOTH = '3';
+
+    public const NETWORK_MAINLAND = 'mainland';
+    public const NETWORK_HK = 'hk';
+    public const NETWORK_KR = 'kr';
+    public const NETWORK_JP = 'jp';
+
+    public const INGRESS_LABELS = [
+        self::INGRESS_PC => '电脑网页',
+        self::INGRESS_MOBILE => '手机网页',
+        self::INGRESS_BOTH => '电脑/手机网页',
+    ];
+
+    public const NETWORK_LABELS = [
+        self::NETWORK_MAINLAND => '大陆',
+        self::NETWORK_HK => '香港',
+        self::NETWORK_KR => '韩国',
+        self::NETWORK_JP => '日本',
+    ];
+
+    public const STATUS_LABELS = [
+        self::STATUS_ENABLED => '启用',
+        self::STATUS_DISABLED => '停用',
+    ];
+
     protected $table = 'egame_items';
 
     protected $fillable = [
@@ -22,9 +49,12 @@ class EgameItem extends BaseModel
         'game_type',
         'game_code',
         'name',
+        'description',
         'ingress',
+        'network',
         'lobby_enabled',
         'logo',
+        'logo_h5',
         'logo_langs',
         'status',
         'sort',
@@ -52,4 +82,34 @@ class EgameItem extends BaseModel
     {
         return $this->item_type === self::TYPE_PLATFORM && $this->game_type > 0;
     }
+
+    public static function ingressValues(): array
+    {
+        return array_keys(self::INGRESS_LABELS);
+    }
+
+    public static function networkValues(): array
+    {
+        return array_keys(self::NETWORK_LABELS);
+    }
+
+    public static function ingressName(?string $ingress): string
+    {
+        return self::INGRESS_LABELS[(string) $ingress] ?? '';
+    }
+
+    public static function networkName(?string $network): string
+    {
+        return self::NETWORK_LABELS[(string) $network] ?? '';
+    }
+
+    public static function optionList(array $labels): array
+    {
+        $options = [];
+        foreach ($labels as $value => $label) {
+            $options[] = ['value' => $value, 'label' => $label];
+        }
+
+        return $options;
+    }
 }

+ 260 - 0
database/migrations/2026_08_18_120000_add_egame_admin_display_fields.php

@@ -0,0 +1,260 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('egame_items')) {
+            return;
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            if (!Schema::hasColumn('egame_items', 'description')) {
+                $table->string('description', 255)->default('')->after('name')->comment('游戏说明');
+            }
+            if (!Schema::hasColumn('egame_items', 'logo_h5')) {
+                $table->string('logo_h5', 500)->default('')->after('logo')->comment('H5/APP 图');
+            }
+            if (!Schema::hasColumn('egame_items', 'network')) {
+                $table->string('network', 32)->default('')->after('ingress')->comment('mainland/hk/kr/jp');
+            }
+        });
+
+        $now = date('Y-m-d H:i:s');
+        $this->seedCategories($now);
+        $this->seedDemoPlatforms($now);
+        $this->seedDemoGames($now);
+        $this->seedMenus($now);
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('egame_items')) {
+            return;
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            foreach (['description', 'logo_h5', 'network'] as $column) {
+                if (Schema::hasColumn('egame_items', $column)) {
+                    $table->dropColumn($column);
+                }
+            }
+        });
+    }
+
+    private function seedCategories(string $now): void
+    {
+        $categories = [
+            1 => ['name' => '视讯', 'description' => '真人视讯游戏', 'sort' => 70],
+            2 => ['name' => '电子', 'description' => '电子游戏机游戏', 'sort' => 60],
+            6 => ['name' => '捕鱼', 'description' => '捕鱼游戏', 'sort' => 50],
+            4 => ['name' => '体育', 'description' => '体育赛事游戏', 'sort' => 40],
+            7 => ['name' => '棋牌', 'description' => '棋牌游戏', 'sort' => 30],
+            3 => ['name' => '彩票', 'description' => '彩票游戏', 'sort' => 20],
+            5 => ['name' => '电竞', 'description' => '电竞游戏', 'sort' => 10],
+        ];
+
+        foreach ($categories as $gameType => $data) {
+            $query = $this->catalogQuery('category', '', $gameType);
+            if (!$query->exists()) {
+                continue;
+            }
+            $query->update([
+                'description' => $data['description'],
+                'sort' => $data['sort'],
+                'updated_at' => $now,
+            ]);
+        }
+    }
+
+    private function seedDemoPlatforms(string $now): void
+    {
+        $rows = [
+            ['plat_type' => 'jdb', 'game_type' => 2, 'name' => 'JDB电子', 'ingress' => '3', 'network' => 'mainland', 'status' => 1, 'sort' => 905],
+            ['plat_type' => 'mg', 'game_type' => 2, 'name' => 'MG电子', 'ingress' => '3', 'network' => 'hk', 'status' => 0, 'sort' => 904],
+            ['plat_type' => 'pt', 'game_type' => 6, 'name' => 'PT捕鱼', 'ingress' => '2', 'network' => 'kr', 'status' => 1, 'sort' => 903],
+            ['plat_type' => 'fg', 'game_type' => 6, 'name' => 'FG捕鱼', 'ingress' => '3', 'network' => 'jp', 'status' => 1, 'sort' => 902],
+            ['plat_type' => 'ky', 'game_type' => 7, 'name' => '开元棋牌', 'ingress' => '3', 'network' => 'mainland', 'status' => 1, 'sort' => 901],
+        ];
+
+        $globalNames = [
+            'jdb' => '夺宝',
+            'mg' => 'MG',
+            'pt' => 'PT',
+            'fg' => 'FG',
+            'ky' => '开元棋牌',
+        ];
+
+        foreach ($globalNames as $platType => $name) {
+            $this->ensureRow([
+                'item_type' => 'platform',
+                'plat_type' => $platType,
+                'game_type' => 0,
+                'game_code' => '',
+                'name' => $name,
+                'description' => '',
+                'ingress' => '3',
+                'network' => '',
+                'logo' => '',
+                'logo_h5' => '',
+                'logo_langs' => null,
+                'lobby_enabled' => 0,
+                'status' => 1,
+                'sort' => 0,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        }
+
+        foreach ($rows as $row) {
+            $payload = [
+                'item_type' => 'platform',
+                'plat_type' => $row['plat_type'],
+                'game_type' => $row['game_type'],
+                'game_code' => '',
+                'name' => $row['name'],
+                'description' => '',
+                'ingress' => $row['ingress'],
+                'network' => $row['network'],
+                'logo' => '',
+                'logo_h5' => '',
+                'logo_langs' => null,
+                'lobby_enabled' => 0,
+                'status' => $row['status'],
+                'sort' => $row['sort'],
+                'created_at' => $now,
+                'updated_at' => $now,
+            ];
+            $existing = $this->catalogQuery('platform', $row['plat_type'], $row['game_type'])->first(['id']);
+            if ($existing) {
+                $this->catalogQuery('platform', $row['plat_type'], $row['game_type'])->update([
+                    'name' => $row['name'],
+                    'ingress' => $row['ingress'],
+                    'network' => $row['network'],
+                    'sort' => $row['sort'],
+                    'updated_at' => $now,
+                ]);
+                continue;
+            }
+            DB::table('egame_items')->insert($payload);
+        }
+    }
+
+    private function seedDemoGames(string $now): void
+    {
+        $games = [
+            ['plat_type' => 'jdb', 'game_type' => 2, 'game_code' => 'JDB001', 'name' => 'JDB夺宝', 'ingress' => '3', 'network' => 'mainland', 'status' => 1, 'sort' => 30],
+            ['plat_type' => 'jdb', 'game_type' => 2, 'game_code' => 'JDB002', 'name' => 'JDB招财进宝', 'ingress' => '2', 'network' => 'hk', 'status' => 0, 'sort' => 20],
+            ['plat_type' => 'jdb', 'game_type' => 2, 'game_code' => 'JDB003', 'name' => 'JDB三国', 'ingress' => '1', 'network' => 'mainland', 'status' => 0, 'sort' => 10],
+            ['plat_type' => 'fg', 'game_type' => 6, 'game_code' => 'FG001', 'name' => 'FG捕鱼王', 'ingress' => '3', 'network' => 'jp', 'status' => 1, 'sort' => 10],
+            ['plat_type' => 'ky', 'game_type' => 7, 'game_code' => 'KY001', 'name' => '开元炸金花', 'ingress' => '3', 'network' => 'mainland', 'status' => 1, 'sort' => 10],
+        ];
+
+        foreach ($games as $game) {
+            $query = DB::table('egame_items')
+                ->where('item_type', 'game')
+                ->where('plat_type', $game['plat_type'])
+                ->where('game_type', $game['game_type'])
+                ->where('game_code', $game['game_code']);
+            if ($query->exists()) {
+                $query->update([
+                    'name' => $game['name'],
+                    'ingress' => $game['ingress'],
+                    'network' => $game['network'],
+                    'sort' => $game['sort'],
+                    'updated_at' => $now,
+                ]);
+                continue;
+            }
+            DB::table('egame_items')->insert([
+                'item_type' => 'game',
+                'plat_type' => $game['plat_type'],
+                'game_type' => $game['game_type'],
+                'game_code' => $game['game_code'],
+                'name' => $game['name'],
+                'description' => '',
+                'ingress' => $game['ingress'],
+                'network' => $game['network'],
+                'logo' => '',
+                'logo_h5' => '',
+                'logo_langs' => null,
+                'lobby_enabled' => 0,
+                'status' => $game['status'],
+                'sort' => $game['sort'],
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        }
+    }
+
+    private function seedMenus(string $now): void
+    {
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+
+        $parent = DB::table('menus')
+            ->where('uri', 'egame')
+            ->where('type', 1)
+            ->first(['id']);
+        if (!$parent) {
+            return;
+        }
+
+        $children = [
+            ['uri' => 'egame/category', 'title' => '游戏大类', 'permission_name' => 'egame.catalog.category', 'sort' => 0],
+            ['uri' => 'egame/list', 'title' => '游戏列表', 'permission_name' => 'egame.catalog.list', 'sort' => 1],
+        ];
+
+        foreach ($children as $child) {
+            $existing = DB::table('menus')->where('uri', $child['uri'])->first(['id']);
+            if ($existing) {
+                DB::table('menus')->where('id', $existing->id)->update([
+                    'parent_id' => $parent->id,
+                    'title' => $child['title'],
+                    'status' => 1,
+                    'type' => 1,
+                    'updated_at' => $now,
+                ]);
+                continue;
+            }
+            DB::table('menus')->insert([
+                'parent_id' => $parent->id,
+                'title' => $child['title'],
+                'icon' => null,
+                'uri' => $child['uri'],
+                'permission_name' => $child['permission_name'],
+                'sort' => $child['sort'],
+                'status' => 1,
+                'type' => 1,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        }
+    }
+
+    private function ensureRow(array $payload): void
+    {
+        $exists = $this->catalogQuery(
+            $payload['item_type'],
+            $payload['plat_type'],
+            $payload['game_type']
+        )->exists();
+        if (!$exists) {
+            DB::table('egame_items')->insert($payload);
+        }
+    }
+
+    private function catalogQuery(string $itemType, string $platType, int $gameType)
+    {
+        return DB::table('egame_items')
+            ->where('item_type', $itemType)
+            ->where('plat_type', $platType)
+            ->where('game_type', $gameType)
+            ->where('game_code', '');
+    }
+};

+ 46 - 0
database/migrations/2026_08_18_120000_add_egame_admin_display_fields.sql

@@ -0,0 +1,46 @@
+-- 第三方游戏后台:补齐前端稿需要的展示字段和示例数据。
+-- 表前缀默认 bot_。若已执行过对应 Laravel 迁移,不要重复跑本文件。
+
+ALTER TABLE `bot_egame_items`
+  ADD COLUMN `description` varchar(255) NOT NULL DEFAULT '' COMMENT '游戏说明' AFTER `name`,
+  ADD COLUMN `network` varchar(32) NOT NULL DEFAULT '' COMMENT 'mainland/hk/kr/jp' AFTER `ingress`,
+  ADD COLUMN `logo_h5` varchar(500) NOT NULL DEFAULT '' COMMENT 'H5/APP 图' AFTER `logo`;
+
+UPDATE `bot_egame_items` SET `description` = '真人视讯游戏', `sort` = 70 WHERE `item_type` = 'category' AND `game_type` = 1 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '电子游戏机游戏', `sort` = 60 WHERE `item_type` = 'category' AND `game_type` = 2 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '捕鱼游戏', `sort` = 50 WHERE `item_type` = 'category' AND `game_type` = 6 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '体育赛事游戏', `sort` = 40 WHERE `item_type` = 'category' AND `game_type` = 4 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '棋牌游戏', `sort` = 30 WHERE `item_type` = 'category' AND `game_type` = 7 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '彩票游戏', `sort` = 20 WHERE `item_type` = 'category' AND `game_type` = 3 AND `game_code` = '';
+UPDATE `bot_egame_items` SET `description` = '电竞游戏', `sort` = 10 WHERE `item_type` = 'category' AND `game_type` = 5 AND `game_code` = '';
+
+UPDATE `bot_egame_items`
+SET `name` = 'JDB电子', `ingress` = '3', `network` = 'mainland', `sort` = 905
+WHERE `item_type` = 'platform' AND `plat_type` = 'jdb' AND `game_type` = 2 AND `game_code` = '';
+
+UPDATE `bot_egame_items`
+SET `name` = 'MG电子', `ingress` = '3', `network` = 'hk', `sort` = 904
+WHERE `item_type` = 'platform' AND `plat_type` = 'mg' AND `game_type` = 2 AND `game_code` = '';
+
+UPDATE `bot_egame_items`
+SET `name` = 'PT捕鱼', `ingress` = '2', `network` = 'kr', `sort` = 903
+WHERE `item_type` = 'platform' AND `plat_type` = 'pt' AND `game_type` = 6 AND `game_code` = '';
+
+UPDATE `bot_egame_items`
+SET `name` = 'FG捕鱼', `ingress` = '3', `network` = 'jp', `sort` = 902
+WHERE `item_type` = 'platform' AND `plat_type` = 'fg' AND `game_type` = 6 AND `game_code` = '';
+
+UPDATE `bot_egame_items`
+SET `name` = '开元棋牌', `ingress` = '3', `network` = 'mainland', `sort` = 901
+WHERE `item_type` = 'platform' AND `plat_type` = 'ky' AND `game_type` = 7 AND `game_code` = '';
+
+INSERT IGNORE INTO `bot_egame_items`
+(`item_type`, `plat_type`, `game_type`, `game_code`, `name`, `description`, `ingress`, `network`, `logo`, `logo_h5`, `logo_langs`, `lobby_enabled`, `status`, `sort`, `created_at`, `updated_at`)
+VALUES
+('platform', 'mg', 2, '', 'MG电子', '', '3', 'hk', '', '', NULL, 0, 0, 904, NOW(), NOW()),
+('platform', 'pt', 6, '', 'PT捕鱼', '', '2', 'kr', '', '', NULL, 0, 1, 903, NOW(), NOW()),
+('game', 'jdb', 2, 'JDB001', 'JDB夺宝', '', '3', 'mainland', '', '', NULL, 0, 1, 30, NOW(), NOW()),
+('game', 'jdb', 2, 'JDB002', 'JDB招财进宝', '', '2', 'hk', '', '', NULL, 0, 0, 20, NOW(), NOW()),
+('game', 'jdb', 2, 'JDB003', 'JDB三国', '', '1', 'mainland', '', '', NULL, 0, 0, 10, NOW(), NOW()),
+('game', 'fg', 6, 'FG001', 'FG捕鱼王', '', '3', 'jp', '', '', NULL, 0, 1, 10, NOW(), NOW()),
+('game', 'ky', 7, 'KY001', '开元炸金花', '', '3', 'mainland', '', '', NULL, 0, 1, 10, NOW(), NOW());

+ 383 - 0
docs/admin-egame-api.md

@@ -0,0 +1,383 @@
+# 第三方游戏后台 API
+
+仓库里原先没有单独的第三方游戏接口文档(`docs/` 下只有财务和在线/注册两份)。这份就是正式文档,顶部先写给前端的对照说明。
+
+所有接口需要后台登录令牌:
+
+```http
+Authorization: Bearer <token>
+```
+
+POST 使用 `Content-Type: application/json`。统一响应:`code = 0` 成功,业务数据在 `data`;失败 `code != 0`,原因在 `msg`。
+
+图片先调已有上传接口 `POST /admin/upload/uploadFile`,把返回的 `path` 写进 `logo` / `logo_pc` / `logo_h5`。
+
+---
+
+## 给前端:对照设计稿改了什么
+
+接口路径没变,还是这 5 个:
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| GET | `/admin/egame/options` | 筛选项、大类、平台下拉 |
+| GET | `/admin/egame/items` | 大类 / 平台 / 游戏列表 |
+| POST | `/admin/egame/update` | 新增或修改(含排序、图片、名称) |
+| POST | `/admin/egame/setStatus` | 启用 / 停用 |
+| POST | `/admin/egame/delete` | 不支持删除,返回错误 |
+
+### 页面怎么对接口
+
+| 设计稿页面 | 请求 |
+| --- | --- |
+| 游戏大类 | `GET /admin/egame/items?item_type=category` |
+| 游戏列表(平台,如 JDB / AG) | `GET /admin/egame/items?item_type=platform&platform_scope=category` |
+| 平台修改弹窗 | 用列表行数据回填;保存 `POST /admin/egame/update` |
+| 游戏管理内页(如 JDB 电子) | `GET /admin/egame/items?item_type=game&plat_type=jdb` |
+| 修改游戏(含启用停用) | `POST /admin/egame/update`,开关也可走 `POST /admin/egame/setStatus` |
+| 筛选下拉 | `GET /admin/egame/options` |
+
+「序号」前端按分页自己算:`(page - 1) * limit + index + 1`。
+
+### 字段对照
+
+| 设计稿文案 | 接口字段 | 说明 |
+| --- | --- | --- |
+| 游戏大类 ID | `game_type` | 固定枚举,不是 1~5 连续号,见下表 |
+| 游戏大类名称 | `name` | |
+| 游戏说明 | `description` | 本次新增 |
+| PC Logo / PC 游戏图片 | `logo` 或 `logo_pc` | 两个等价,读的时候两个都会返回 |
+| H5/APP Logo / 图片 | `logo_h5` | 本次新增 |
+| 平台 ID | `id` | 行主键。不要用稿子里的 `FL01` |
+| 平台类型 | `plat_type` | `jdb` / `mg` / `pg` / `ag`,小写 |
+| 显示名称 / 游戏名称 | `name` | 列表筛选也用 `name` |
+| 游戏 ID | `game_code` | 如 `JDB001` |
+| 支持终端类型 | `ingress` | `1` 电脑网页,`2` 手机网页,`3` 电脑/手机网页 |
+| 支持终端(文案) | `ingress_name` | 后端已拼好,列表直接展示 |
+| 支持网络 | `network` | `mainland` / `hk` / `kr` / `jp`,不要传中文 |
+| 支持网络(文案) | `network_name` | 大陆 / 香港 / 韩国 / 日本 |
+| 启用状态 | `status` | `1` 启用,`0` 停用 |
+| 启用状态(文案) | `status_name` | |
+| 排序 | `sort` | **数字越大越靠前** |
+| 游戏管理按钮 | `has_game_manage` | `true` 才显示「游戏管理」 |
+
+`options` 里新增 `networks`,并改了文案:
+
+- `statuses`:`开启/关闭` 改为 `启用/停用`
+- `ingress`:`仅电脑/仅手机/通用` 改为 `电脑网页/手机网页/电脑/手机网页`
+
+### 和设计稿不一致、按真实业务改掉的地方
+
+1. **平台 ID 不用 FL01~FL05**  
+   那是稿子里的假编号。同一平台会挂在多个大类下(JDB 电子、JDB 捕鱼),真正唯一的是行 `id`。平台类型用 `plat_type`。
+
+2. **游戏大类 ID 不是 1~5**  
+   三方约定是:
+
+   | game_type | 名称 |
+   | ---: | --- |
+   | 1 | 视讯 |
+   | 2 | 电子 |
+   | 3 | 彩票 |
+   | 4 | 体育 |
+   | 5 | 电竞 |
+   | 6 | 捕鱼 |
+   | 7 | 棋牌 |
+
+   稿子里捕鱼写成 3、体育写成 4,对不上。捕鱼是 `6`,棋牌是 `7`。请用 `game_type`,不要自己重排。
+
+3. **保留「彩票」「电竞」两个大类**  
+   稿子只画了 5 个。线上目录本身就有 7 个,列表要能展示,筛选也要有。
+
+4. **游戏分类、平台类型不能改**  
+   稿子「修改游戏」里这两个是下拉。它们是三方目录主键,改了会对不上。请做成禁用展示,可以回传当前值,改了后端会报 `目录标识不允许修改`。
+
+5. **排序越大越靠前**  
+   和后台其它模块一致。稿子里 1、2、3 只是示意,不要理解成「1 排最前」。改排序只传 `{ id, sort }`。
+
+6. **有子游戏才出「游戏管理」**  
+   稿子里 MG、PT 是「修改 --」。请判断 `has_game_manage`。当前示例:JDB / FG / 开元有游戏,MG / PT 没有。
+
+7. **不能删除**  
+   `POST /admin/egame/delete` 固定失败。停用走开关。
+
+8. **体育大类保持启用**  
+   稿子里体育是红开关,那只是在演示开关。正式数据体育是开的。
+
+### 已补的示例数据
+
+跑完迁移后,游戏列表靠前能看到:
+
+| 显示名称 | plat_type | 分类 | 终端 | 网络 | 状态 | 游戏管理 |
+| --- | --- | --- | --- | --- | --- | --- |
+| JDB电子 | jdb | 电子 | 电脑/手机网页 | 大陆 | 启用 | 有 |
+| MG电子 | mg | 电子 | 电脑/手机网页 | 香港 | 停用 | 无 |
+| PT捕鱼 | pt | 捕鱼 | 手机网页 | 韩国 | 启用 | 无 |
+| FG捕鱼 | fg | 捕鱼 | 电脑/手机网页 | 日本 | 启用 | 有 |
+| 开元棋牌 | ky | 棋牌 | 电脑/手机网页 | 大陆 | 启用 | 有 |
+
+JDB 游戏管理内页:
+
+| 游戏 ID | 名称 | 终端 | 网络 | 状态 |
+| --- | --- | --- | --- | --- |
+| JDB001 | JDB夺宝 | 电脑/手机网页 | 大陆 | 启用 |
+| JDB002 | JDB招财进宝 | 手机网页 | 香港 | 停用 |
+| JDB003 | JDB三国 | 电脑网页 | 大陆 | 停用 |
+
+图片目前是空字符串,列表显示 `--`。菜单加了 `egame/category`(游戏大类)、`egame/list`(游戏列表),挂在「第三方游戏」下面。
+
+---
+
+## 1. 筛选项
+
+### GET `/admin/egame/options`
+
+无参数。
+
+```json
+{
+  "code": 0,
+  "data": {
+    "categories": [
+      {
+        "id": 1,
+        "item_type": "category",
+        "game_type": 1,
+        "name": "视讯",
+        "description": "真人视讯游戏",
+        "logo": "/static/img/game-type/sx.png",
+        "logo_pc": "/static/img/game-type/sx.png",
+        "logo_h5": "",
+        "status": 1,
+        "sort": 70,
+        "platform_count": 5,
+        "game_count": 0
+      }
+    ],
+    "platforms": [
+      {
+        "id": 20,
+        "item_type": "platform",
+        "plat_type": "jdb",
+        "name": "夺宝",
+        "platform_scope": "global",
+        "category_count": 3,
+        "game_count": 3,
+        "has_game_manage": true
+      }
+    ],
+    "statuses": [
+      {"value": 1, "label": "启用"},
+      {"value": 0, "label": "停用"}
+    ],
+    "ingress": [
+      {"value": "1", "label": "电脑网页"},
+      {"value": "2", "label": "手机网页"},
+      {"value": "3", "label": "电脑/手机网页"}
+    ],
+    "networks": [
+      {"value": "mainland", "label": "大陆"},
+      {"value": "hk", "label": "香港"},
+      {"value": "kr", "label": "韩国"},
+      {"value": "jp", "label": "日本"}
+    ]
+  }
+}
+```
+
+下拉请直接用 `value` / `label`。`categories` 给「游戏分类」,`platforms` 给「平台类型」。
+
+---
+
+## 2. 列表
+
+### GET `/admin/egame/items`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `item_type` | 否 | `category` / `platform` / `game` |
+| `platform_scope` | 否 | 只对平台有效:`global` 平台总开关,`category` 平台+大类(游戏列表页用这个) |
+| `plat_type` | 否 | 平台代码,游戏管理内页必带 |
+| `game_type` | 否 | 大类 |
+| `game_code` | 否 | 游戏 ID,精确匹配 |
+| `name` | 否 | 显示名称 / 游戏名称,模糊 |
+| `keyword` | 否 | 同时模糊 `name` / `plat_type` / `game_code` |
+| `status` | 否 | `1` / `0` |
+| `ingress` | 否 | `1` / `2` / `3` |
+| `network` | 否 | `mainland` / `hk` / `kr` / `jp` |
+| `page` | 否 | 默认 `1` |
+| `limit` | 否 | 默认 `15`,最大 `200` |
+
+返回 `data.total`、`data.data`。
+
+### 2.1 游戏大类
+
+`GET /admin/egame/items?item_type=category`
+
+主要字段:`game_type`、`name`、`description`、`logo` / `logo_pc`、`logo_h5`、`sort`、`status`。
+
+### 2.2 游戏列表(平台)
+
+`GET /admin/egame/items?item_type=platform&platform_scope=category&game_type=&plat_type=&name=&status=&ingress=&network=&page=1&limit=15`
+
+一行是「某个平台在某个大类下」的配置,例如 JDB 电子。
+
+```json
+{
+  "id": 88,
+  "item_type": "platform",
+  "platform_scope": "category",
+  "plat_type": "jdb",
+  "platform_name": "夺宝",
+  "game_type": 2,
+  "game_type_name": "电子",
+  "name": "JDB电子",
+  "ingress": "3",
+  "ingress_name": "电脑/手机网页",
+  "network": "mainland",
+  "network_name": "大陆",
+  "logo": "",
+  "logo_pc": "",
+  "logo_h5": "",
+  "status": 1,
+  "status_name": "启用",
+  "sort": 905,
+  "has_game_manage": true,
+  "game_count": 3,
+  "effective_status": 1,
+  "disabled_by": null
+}
+```
+
+`has_game_manage === true` 时显示「游戏管理」,点进去带上这一行的 `plat_type`(建议同时带 `game_type` 做筛选)。
+
+`effective_status` 是算上父级之后的实际是否可见:大类关了、或平台总开关关了,这里会是 `0`,`disabled_by` 为 `category` / `platform` / `platform_category` / `game`。行上的开关仍绑定 `status`。
+
+### 2.3 游戏管理内页
+
+`GET /admin/egame/items?item_type=game&plat_type=jdb&game_code=&name=&status=&ingress=&network=`
+
+```json
+{
+  "id": 201,
+  "item_type": "game",
+  "game_code": "JDB001",
+  "name": "JDB夺宝",
+  "plat_type": "jdb",
+  "platform_name": "夺宝",
+  "game_type": 2,
+  "game_type_name": "电子",
+  "ingress": "3",
+  "ingress_name": "电脑/手机网页",
+  "network": "mainland",
+  "network_name": "大陆",
+  "logo": "",
+  "logo_pc": "",
+  "logo_h5": "",
+  "sort": 30,
+  "status": 1,
+  "status_name": "启用"
+}
+```
+
+---
+
+## 3. 保存
+
+### POST `/admin/egame/update`
+
+有 `id` 就是改,没有或 `id=0` 就是按目录标识新增/补齐。普通修改只传要改的字段即可。
+
+| 参数 | 说明 |
+| --- | --- |
+| `id` | 行 ID,修改必传 |
+| `name` | 显示名称 / 游戏名称 / 大类名称 |
+| `description` | 游戏说明,大类用 |
+| `logo` / `logo_pc` | PC 图,二选一,都传时用 `logo` |
+| `logo_h5` | H5/APP 图 |
+| `ingress` | 终端,平台和游戏可改;大类固定为 `3` |
+| `network` | 网络,平台和游戏可改;大类忽略。传 `""` 可清空 |
+| `status` | 启用停用 |
+| `sort` | 排序 |
+| `item_type` / `plat_type` / `game_type` / `game_code` / `platform_scope` | 目录标识,修改时不要改 |
+
+修改大类示例:
+
+```json
+{
+  "id": 1,
+  "name": "视讯",
+  "description": "真人视讯游戏",
+  "logo_pc": "https://cdn.example.com/sx-pc.png",
+  "logo_h5": "https://cdn.example.com/sx-h5.png",
+  "sort": 70,
+  "status": 1
+}
+```
+
+修改平台示例(对应「平台游戏修改」弹窗):
+
+```json
+{
+  "id": 88,
+  "name": "JDB电子",
+  "logo_pc": "https://cdn.example.com/jdb-pc.png",
+  "logo_h5": "https://cdn.example.com/jdb-h5.png"
+}
+```
+
+修改游戏示例(对应「修改游戏」,含启用停用):
+
+```json
+{
+  "id": 201,
+  "name": "JDB夺宝",
+  "logo_pc": "https://cdn.example.com/jdb001-pc.png",
+  "logo_h5": "https://cdn.example.com/jdb001-h5.png",
+  "status": 1
+}
+```
+
+改排序只传:
+
+```json
+{ "id": 88, "sort": 910 }
+```
+
+成功返回该行最新数据,字段和列表一致。
+
+---
+
+## 4. 启用 / 停用
+
+### POST `/admin/egame/setStatus`
+
+列表开关、修改弹窗里的启用停用都走这里(也可以在 `update` 里带 `status`)。
+
+```json
+{ "id": 201, "status": 0 }
+```
+
+---
+
+## 5. 删除
+
+### POST `/admin/egame/delete`
+
+目录由同步任务维护,不能删。请把状态设为停用。
+
+```json
+{ "code": -3, "msg": "第三方游戏目录由同步任务维护,不能删除,请将状态设为关闭" }
+```
+
+---
+
+## 6. 菜单 uri
+
+| 菜单 | 前端 uri |
+| --- | --- |
+| 第三方游戏 | `egame` |
+| 游戏大类 | `egame/category` |
+| 游戏列表 | `egame/list` |
+
+按钮权限:`admin/egame/options`、`admin/egame/items`、`admin/egame/update`、`admin/egame/setStatus`、`admin/egame/delete`。

+ 35 - 0
tests/Unit/EgameItemTest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Models\EgameItem;
+use PHPUnit\Framework\TestCase;
+
+class EgameItemTest extends TestCase
+{
+    public function test_ingress_labels_match_admin_ui(): void
+    {
+        $this->assertSame('电脑网页', EgameItem::ingressName('1'));
+        $this->assertSame('手机网页', EgameItem::ingressName('2'));
+        $this->assertSame('电脑/手机网页', EgameItem::ingressName('3'));
+        $this->assertSame('', EgameItem::ingressName('9'));
+    }
+
+    public function test_network_labels_match_admin_ui(): void
+    {
+        $this->assertSame('大陆', EgameItem::networkName('mainland'));
+        $this->assertSame('香港', EgameItem::networkName('hk'));
+        $this->assertSame('韩国', EgameItem::networkName('kr'));
+        $this->assertSame('日本', EgameItem::networkName('jp'));
+        $this->assertSame('', EgameItem::networkName(''));
+    }
+
+    public function test_option_list_preserves_codes(): void
+    {
+        $options = EgameItem::optionList(EgameItem::NETWORK_LABELS);
+
+        $this->assertSame('mainland', $options[0]['value']);
+        $this->assertSame('大陆', $options[0]['label']);
+        $this->assertCount(4, $options);
+    }
+}