doge há 1 dia atrás
pai
commit
6e0aa2b7ed

+ 11 - 5
app/Services/MaintenanceService.php

@@ -29,7 +29,7 @@ class MaintenanceService
         return ['scopes' => $options(Policy::scopes()), 'categories' => $options(Policy::CATEGORIES), 'subcategories' => $subcategories,
         return ['scopes' => $options(Policy::scopes()), 'categories' => $options(Policy::CATEGORIES), 'subcategories' => $subcategories,
             'platforms' => $options(['PC' => 'PC', 'H5' => 'H5', 'APP' => 'APP']), 'languages' => $options(Policy::LANGUAGES),
             'platforms' => $options(['PC' => 'PC', 'H5' => 'H5', 'APP' => 'APP']), 'languages' => $options(Policy::LANGUAGES),
             'states' => $options(Policy::STATES), 'timezones' => DateTimeZone::listIdentifiers(),
             'states' => $options(Policy::STATES), 'timezones' => DateTimeZone::listIdentifiers(),
-            'defaults' => ['enabled' => 0, 'platforms' => ['PC', 'H5', 'APP'], 'timezone' => 'Asia/Shanghai', 'ends_at' => null],
+            'defaults' => ['enabled' => 0, 'platforms' => ['PC', 'H5', 'APP'], 'timezone' => 'Asia/Shanghai', 'ends_at' => null, 'embeddable' => 1],
             'business_codes' => ['site' => Policy::SITE_CODE, 'game' => Policy::GAME_CODE]];
             'business_codes' => ['site' => Policy::SITE_CODE, 'game' => Policy::GAME_CODE]];
     }
     }
 
 
@@ -102,13 +102,18 @@ class MaintenanceService
             'enabled' => ['sometimes', 'required', 'integer', 'in:0,1'], 'start_now' => ['sometimes', 'boolean'],
             'enabled' => ['sometimes', 'required', 'integer', 'in:0,1'], 'start_now' => ['sometimes', 'boolean'],
             'starts_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s'], 'ends_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
             'starts_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s'], 'ends_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
             'timezone' => ['sometimes', 'required', 'string', Rule::in(DateTimeZone::listIdentifiers())],
             'timezone' => ['sometimes', 'required', 'string', Rule::in(DateTimeZone::listIdentifiers())],
-            'translations' => ['sometimes', 'array'],
+            'translations' => ['sometimes', 'array'], 'embeddable' => ['sometimes', 'required', 'integer', 'in:0,1'],
         ])->validate();
         ])->validate();
         return DB::transaction(function () use ($params) {
         return DB::transaction(function () use ($params) {
             $id = (int) $params['id'];
             $id = (int) $params['id'];
             $found = DB::table('maintenance_settings')->where('id', $id)->lockForUpdate()->first();
             $found = DB::table('maintenance_settings')->where('id', $id)->lockForUpdate()->first();
             if (!$found) $this->invalid('id', '维护设置不存在');
             if (!$found) $this->invalid('id', '维护设置不存在');
             $before = $this->snapshot($id); $row = (array) $found;
             $before = $this->snapshot($id); $row = (array) $found;
+            // Whether the platform / lottery / sport opens embedded (iframe) on the member site; independent of maintenance.
+            if (array_key_exists('embeddable', $params)) {
+                if (Policy::parentScope($row['scope_code']) === null) $this->invalid('embeddable', '只有子类可以设置是否支持内嵌');
+                $row['embeddable'] = (int) $params['embeddable'];
+            }
             foreach (['enabled', 'timezone', 'platforms'] as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
             foreach (['enabled', 'timezone', 'platforms'] as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
             $row['platforms'] = array_values(array_intersect(['PC', 'H5', 'APP'], Policy::platforms($row)));
             $row['platforms'] = array_values(array_intersect(['PC', 'H5', 'APP'], Policy::platforms($row)));
             if (!empty($params['start_now'])) $row['starts_at'] = $this->now()->format('Y-m-d H:i:s');
             if (!empty($params['start_now'])) $row['starts_at'] = $this->now()->format('Y-m-d H:i:s');
@@ -126,7 +131,7 @@ class MaintenanceService
                     if (trim((string) ($value['content'] ?? '')) !== '') $texts[$language] = ['content' => $value['content']];
                     if (trim((string) ($value['content'] ?? '')) !== '') $texts[$language] = ['content' => $value['content']];
                 }
                 }
             }
             }
-            $values = array_intersect_key($row, array_flip(['enabled', 'timezone', 'platforms', 'starts_at', 'ends_at']));
+            $values = array_intersect_key($row, array_flip(['enabled', 'timezone', 'platforms', 'starts_at', 'ends_at', 'embeddable']));
             $values['enabled'] = (int) $values['enabled']; $values['platforms'] = json_encode($values['platforms'], JSON_THROW_ON_ERROR);
             $values['enabled'] = (int) $values['enabled']; $values['platforms'] = json_encode($values['platforms'], JSON_THROW_ON_ERROR);
             $values['updated_at'] = $this->now()->format('Y-m-d H:i:s');
             $values['updated_at'] = $this->now()->format('Y-m-d H:i:s');
             DB::table('maintenance_settings')->where('id', $id)->update($values);
             DB::table('maintenance_settings')->where('id', $id)->update($values);
@@ -173,8 +178,8 @@ class MaintenanceService
         $parent = Policy::parentScope($row['scope_code']);
         $parent = Policy::parentScope($row['scope_code']);
         return ['id' => (int) $row['id'], 'scope_code' => $row['scope_code'], 'scope_name' => $this->names()[$row['scope_code']] ?? $row['scope_code'],
         return ['id' => (int) $row['id'], 'scope_code' => $row['scope_code'], 'scope_name' => $this->names()[$row['scope_code']] ?? $row['scope_code'],
             'scope_level' => $row['scope_code'] === 'site' ? 'site' : ($parent === null ? 'category' : 'subcategory'), 'parent_scope' => $parent,
             'scope_level' => $row['scope_code'] === 'site' ? 'site' : ($parent === null ? 'category' : 'subcategory'), 'parent_scope' => $parent,
-            'enabled' => (int) $row['enabled'], 'state' => Policy::state($row, $this->now()->getTimestamp()),
-            'translations' => (object) $texts] + $public;
+            'enabled' => (int) $row['enabled'], 'embeddable' => $parent === null ? null : (int) ($row['embeddable'] ?? 1),
+            'state' => Policy::state($row, $this->now()->getTimestamp()), 'translations' => (object) $texts] + $public;
     }
     }
 
 
     private function texts(int $id): array
     private function texts(int $id): array
@@ -188,6 +193,7 @@ class MaintenanceService
     {
     {
         $row = (array) DB::table('maintenance_settings')->where('id', $id)->first();
         $row = (array) DB::table('maintenance_settings')->where('id', $id)->first();
         unset($row['created_at'], $row['updated_at']); $row['enabled'] = (int) $row['enabled']; $row['platforms'] = Policy::platforms($row);
         unset($row['created_at'], $row['updated_at']); $row['enabled'] = (int) $row['enabled']; $row['platforms'] = Policy::platforms($row);
+        if (Policy::parentScope($row['scope_code']) === null) unset($row['embeddable']); else $row['embeddable'] = (int) ($row['embeddable'] ?? 1);
         $row['translations'] = $this->texts($id);
         $row['translations'] = $this->texts($id);
         $names = $this->names();
         $names = $this->names();
         foreach ($row['translations'] as &$text) $text['title'] = $names[$row['scope_code']] ?? $row['scope_code'];
         foreach ($row['translations'] as &$text) $text['title'] = $names[$row['scope_code']] ?? $row['scope_code'];

+ 22 - 0
database/migrations/2026_11_10_140000_add_embeddable_to_maintenance_settings.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/** Subcategory flag: the member site may open this platform / lottery / sport embedded. Defaults to supported. */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('maintenance_settings') || Schema::hasColumn('maintenance_settings', 'embeddable')) return;
+        Schema::table('maintenance_settings', function (Blueprint $table) {
+            $table->boolean('embeddable')->default(true)->after('timezone');
+        });
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('maintenance_settings') || !Schema::hasColumn('maintenance_settings', 'embeddable')) return;
+        Schema::table('maintenance_settings', function (Blueprint $table) { $table->dropColumn('embeddable'); });
+    }
+};

+ 2 - 0
docs/前端交付/README.md

@@ -4,6 +4,8 @@
 
 
 ## 会员绑定上级(2026-11-10)
 ## 会员绑定上级(2026-11-10)
 
 
+- [整站与游戏维护子类-总后台前端修改单](整站与游戏维护子类-总后台前端修改单.md):维护可以细到子类(三方平台、F1 彩种、F1 体育项);列表为树形(已包含《维护列表按层级返回》的内容);子类新增「支持内嵌」开关 `embeddable`;新增菜单「F1体育列表」(`GET /admin/config/sportList`,用 `setField` 的 `sport_list` 保存)。
+- [整站与游戏维护子类-会员前台修改单](整站与游戏维护子类-会员前台修改单.md):三方平台 / 游戏、F1 彩票、F1 体育列表项新增 `is_maintaining`、`maintenance`、`embeddable`;维护中照常展示、点击提示,`embeddable=false` 时用新窗口打开;新增 `GET /api/index/sportConfig`;状态接口大类新增 `children`。
 - [会员绑定上级-前端修改单](会员绑定上级-前端修改单.md):注册时代理域名和会员邀请码同时存在,**以会员邀请码为主**,会员前台不用改。总后台会员列表新增 `can_bind_superior`,无上级的会员可以通过 `POST /admin/user/bindSuperior` 绑定合营代理(代理 ID)或上级会员(会员 ID),绑定后不能修改。
 - [会员绑定上级-前端修改单](会员绑定上级-前端修改单.md):注册时代理域名和会员邀请码同时存在,**以会员邀请码为主**,会员前台不用改。总后台会员列表新增 `can_bind_superior`,无上级的会员可以通过 `POST /admin/user/bindSuperior` 绑定合营代理(代理 ID)或上级会员(会员 ID),绑定后不能修改。
 
 
 ## 代理审核实时推送(2026-11-10)
 ## 代理审核实时推送(2026-11-10)

+ 4 - 2
docs/前端交付/bot-28/后台/整站与游戏维护-后台接口.md

@@ -43,8 +43,9 @@
 ### 列表和表单
 ### 列表和表单
 
 
 - `options.scopes`、`categories`、`platforms`、`languages`、`states` 均为 `[{value,label}]`。`subcategories` 为 `[{value,label,parent}]`,用 `parent` 关联大类,`label` 形如 `电子 / PG电子`、`F1体育 / 篮球`。时区选项来自 `timezones`,表单默认值来自 `defaults`。
 - `options.scopes`、`categories`、`platforms`、`languages`、`states` 均为 `[{value,label}]`。`subcategories` 为 `[{value,label,parent}]`,用 `parent` 关联大类,`label` 形如 `电子 / PG电子`、`F1体育 / 篮球`。时区选项来自 `timezones`,表单默认值来自 `defaults`。
-- 列表、详情、保存和启停返回的记录包含 `id`、`scope_code`、`scope_name`、`scope_level`、`parent_scope`、`enabled`、`state`、`platforms`、`timezone`、起止时间及其 `*_utc` 字段和 `translations`。`scope_level` 为 `site`、`category` 或 `subcategory`,仅子类的 `parent_scope` 有值。
-- 列表可传 `scope_code` 精确筛选某条规则,或传 `parent_scope` 查看某个大类的子类。例如 `GET /admin/maintenance?parent_scope=self:sport&page=1&limit=20`。还支持 `platform`、`enabled`、`state`、`language`。`language` 筛选有填写该语言文案的记录。`page` 默认 1,`limit` 默认 20、最大 100。
+- 列表、详情、保存和启停返回的记录包含 `id`、`scope_code`、`scope_name`、`scope_level`、`parent_scope`、`enabled`、`state`、`platforms`、`timezone`、起止时间及其 `*_utc` 字段和 `translations`。`scope_level` 为 `site`、`category` 或 `subcategory`,仅子类的 `parent_scope` 有值。子类另有 `embeddable`(`1` 支持内嵌、`0` 不支持,默认 `1`),整站和大类为 `null`。
+- 列表**按层级返回**:`data` 只有整站和大类,分页和 `total` 只按这一层计算;子类放在所属大类的 `children` 里(完整记录)。顶层行另有 `children_count`(子类总数,不受筛选影响)和 `matched`(这一行本身是否符合筛选,`false` 表示只是因为有子类命中而显示)。
+- 列表可传 `parent_scope` 只看某个大类及其子类,例如 `GET /admin/maintenance?parent_scope=self:sport&page=1&limit=20`。`scope_code` 传大类时只返回该大类;传子类时返回它的大类(`matched=false`),`children` 里只有这个子类。`platform`、`enabled`、`state`、`language` 对大类和子类分别判断,`children` 里只放符合的子类。`language` 筛选有填写该语言文案的记录。`page` 默认 1,`limit` 默认 20、最大 100。
 - 开关与状态分别显示。`enabled` 为 `0/1`,`state` 为 `disabled`(已关闭)、`scheduled`(待开始)、`active`(维护中)或 `expired`(已结束)。关闭保留原时间与文案,再次开启沿用原时间。
 - 开关与状态分别显示。`enabled` 为 `0/1`,`state` 为 `disabled`(已关闭)、`scheduled`(待开始)、`active`(维护中)或 `expired`(已结束)。关闭保留原时间与文案,再次开启沿用原时间。
 
 
 `POST /admin/maintenance/submit` 示例。`id` 取自实际列表或详情。
 `POST /admin/maintenance/submit` 示例。`id` 取自实际列表或详情。
@@ -65,6 +66,7 @@
 }
 }
 ```
 ```
 
 
+- `embeddable` 只有子类可以提交(`0/1`),表示会员端是否可以内嵌打开该平台、彩种或体育项。它和维护开关互不影响:维护关闭时也可以单独修改,整站或大类提交该字段会返回校验错误。表单可在子类行上放一个「支持内嵌」开关,`options.defaults.embeddable` 为 `1`。
 - `id` 必填,`scope_code` 仅用于识别和筛选。其余字段省略时保留原值。`platforms` 从 `PC/H5/APP` 中至少选一个。
 - `id` 必填,`scope_code` 仅用于识别和筛选。其余字段省略时保留原值。`platforms` 从 `PC/H5/APP` 中至少选一个。
 - `starts_at`、`ends_at` 按所选时区填写,格式为 `YYYY-MM-DD HH:mm:ss`。结束须晚于开始,`ends_at: null` 清除结束时间。立即开始可传 `start_now: true`,同时清除或更新已经过去的结束时间。仅改时区时,省略起止时间可保留原时间点。
 - `starts_at`、`ends_at` 按所选时区填写,格式为 `YYYY-MM-DD HH:mm:ss`。结束须晚于开始,`ends_at: null` 清除结束时间。立即开始可传 `start_now: true`,同时清除或更新已经过去的结束时间。仅改时区时,省略起止时间可保留原时间点。
 - 传入 `translations` 会整体替换三种语言的文案。单独改一种语言时,把要保留的其他语言一起提交。每种语言只传 `content`,最多 5000 字符。空文案使用该语言的系统提示,详情没有文案时返回 `{}`。
 - 传入 `translations` 会整体替换三种语言的文案。单独改一种语言时,把要保留的其他语言一起提交。每种语言只传 `content`,最多 5000 字符。空文案使用该语言的系统提示,详情没有文案时返回 `{}`。

+ 6 - 5
docs/前端交付/melbet_sport-api/前台/整站与游戏维护-前台接口.md

@@ -61,21 +61,22 @@ Lang: zh
 
 
 | 接口 | 内容 | 维护字段 |
 | 接口 | 内容 | 维护字段 |
 |---|---|---|
 |---|---|---|
-| `/api/egame/platforms`、`/api/egame/electronicGames`、`/api/egame/gameCodes` | 三方平台和游戏 | 平台或游戏项上的 `is_maintaining`、`maintenance` |
-| `/api/index/gameConfig`、`/api/index/getLastLottery` | F1 彩票列表 | 每项新增 `maintenance_scope`、`is_maintaining`、`maintenance` |
+| `/api/egame/platforms`、`/api/egame/electronicGames`、`/api/egame/gameCodes` | 三方平台和游戏 | 平台或游戏项上的 `is_maintaining`、`maintenance`、`embeddable` |
+| `/api/index/gameConfig`、`/api/index/getLastLottery` | F1 彩票列表 | 每项新增 `maintenance_scope`、`is_maintaining`、`maintenance`、`embeddable` |
 | `/api/index/sportConfig`(新增,免登录) | F1 体育列表 | 同上 |
 | `/api/index/sportConfig`(新增,免登录) | F1 体育列表 | 同上 |
 
 
+- `embeddable` 为布尔值,表示该平台、彩种或体育项是否支持内嵌打开,由后台子类设置,默认 `true`;为 `false` 时用新窗口或跳转打开。它和维护状态无关。三方平台下的游戏项沿用所属平台的值。
 - `maintenance_scope` 是该项的子类代码;`maintenance` 为 `null` 或上面的维护详情。`is_maintaining` 为 `true` 时点击展示 `maintenance.content`。
 - `maintenance_scope` 是该项的子类代码;`maintenance` 为 `null` 或上面的维护详情。`is_maintaining` 为 `true` 时点击展示 `maintenance.content`。
-- `sportConfig` 返回后台启用的体育项,排序与 `gameConfig` 相同。每项为 `{type, code, name, status, sort, maintenance_scope, is_maintaining, maintenance}`:
+- `sportConfig` 返回后台启用的体育项,排序与 `gameConfig` 相同。每项为 `{type, code, name, status, sort, maintenance_scope, is_maintaining, maintenance, embeddable}`:
 
 
 ```json
 ```json
 {
 {
   "code": 1,
   "code": 1,
   "data": [
   "data": [
     {"type": 1, "code": "football", "name": "足球", "status": 1, "sort": 0,
     {"type": 1, "code": "football", "name": "足球", "status": 1, "sort": 0,
-     "maintenance_scope": "self:sport:football", "is_maintaining": false, "maintenance": null},
+     "maintenance_scope": "self:sport:football", "is_maintaining": false, "maintenance": null, "embeddable": true},
     {"type": 2, "code": "basketball", "name": "篮球", "status": 1, "sort": 0,
     {"type": 2, "code": "basketball", "name": "篮球", "status": 1, "sort": 0,
-     "maintenance_scope": "self:sport:basketball", "is_maintaining": true,
+     "maintenance_scope": "self:sport:basketball", "is_maintaining": true, "embeddable": false,
      "maintenance": {"scope": "game", "category": "self:sport", "subcategory": "self:sport:basketball", "content": "篮球维护中", "...": "其余字段同上"}}
      "maintenance": {"scope": "game", "category": "self:sport", "subcategory": "self:sport:basketball", "content": "篮球维护中", "...": "其余字段同上"}}
   ]
   ]
 }
 }

+ 77 - 0
docs/前端交付/整站与游戏维护子类-会员前台修改单.md

@@ -0,0 +1,77 @@
+# 整站与游戏维护子类-会员前台修改单(PC / H5 / APP,2026-11-10)
+
+## 改动
+
+1. **维护可以细到子类**:除了整站和 9 个大类,后台现在可以单独维护某个三方平台(如电子里的 PG)、某个 F1 彩种(如极速赛车)或某个 F1 体育项(如篮球)。大类没维护时,其他平台 / 彩种 / 体育项照常可进。
+2. **游戏列表直接带维护状态和「是否支持内嵌」**:三方平台和游戏、F1 彩票、F1 体育的列表项都新增 `is_maintaining`、`maintenance`、`embeddable`,前端按列表项处理,不需要自己拼代码或对照编号。
+3. **新增 F1 体育列表接口** `GET /api/index/sportConfig`。
+
+接口都在 `melbet_sport-api`。原有请求头(`Platform`、`Lang`、`Authorization`)和 `11001` / `11002` 的处理方式不变。
+
+## 一、列表项新增字段
+
+| 接口 | 列表项 | 新增字段 |
+|---|---|---|
+| `/api/egame/platforms`、`/api/egame/electronicGames`、`/api/egame/gameCodes` | 三方平台、平台下的游戏 | `is_maintaining`、`maintenance`、`embeddable` |
+| `/api/index/gameConfig`、`/api/index/getLastLottery` | F1 彩种 | `maintenance_scope`、`is_maintaining`、`maintenance`、`embeddable` |
+| `/api/index/sportConfig`(新增) | F1 体育项 | 同上 |
+
+| 字段 | 说明 |
+|---|---|
+| `is_maintaining` | 该项当前是否不可进入(整站、所属大类或它自己在维护,都会是 `true`) |
+| `maintenance` | `null`,或维护详情:`content`(文案)、`ends_at_utc`(预计结束,可能为 `null`)、`category`(大类)、`subcategory`(子类,大类维护时为 `null`)等,结构同 `/api/maintenance/status` |
+| `embeddable` | 布尔值,该项是否支持内嵌打开,后台设置,默认 `true`;和维护无关。三方平台下的游戏沿用所属平台的值 |
+| `maintenance_scope` | 该项的子类代码,如 `self:lottery:pc28`、`self:sport:basketball`,用于和 `/api/maintenance/status` 对应,一般不用展示 |
+
+F1 彩种示例(`gameConfig` 一项):
+
+```json
+{"type": 7, "name": "极速赛车", "game": "jisu", "params": {"type": 1}, "status": 1, "sort": 0, "is_rec": 0,
+ "maintenance_scope": "self:lottery:jisu:1", "is_maintaining": true, "embeddable": true,
+ "maintenance": {"scope": "game", "category": "self:lottery", "subcategory": "self:lottery:jisu:1",
+   "content": "极速赛车维护中,请稍后再试。", "ends_at_utc": "2026-11-10T20:00:00Z", "...": "其余字段同状态接口"}}
+```
+
+## 二、点击列表项的处理
+
+1. `is_maintaining` 为 `true`:**列表照常显示,不要隐藏**,加「维护中」标记;点击时弹出 `maintenance.content`(纯文本,保留换行),不进入游戏。有 `ends_at_utc` 时可显示预计恢复时间,`null` 显示「恢复时间待定」。
+2. 否则按 `embeddable` 打开:
+   - `true`:沿用现在的方式,页面内嵌(iframe / 内嵌 WebView)打开;
+   - `false`:**不要内嵌**,改为新窗口打开(PC、H5 用 `window.open` 或整页跳转,APP 用外部浏览器或新开 WebView)。
+3. 最终以进入 / 下注接口的返回为准:列表加载后后台才开始的维护,进入时会返回 `11002`,按原有方式提示 `msg`。
+
+`11002` 的 `data` 新增 `subcategory`:单独维护某个平台 / 彩种 / 体育项时为子类代码(如 `third:2:pg`),大类维护时为 `null`;`data.category` 始终是大类代码。前端提示 `msg` 即可,不需要按它区分处理。
+
+## 三、新增 F1 体育列表 `GET /api/index/sportConfig`
+
+免登录,返回后台「F1体育列表」中显示的体育项,排序规则同 `gameConfig`:
+
+```json
+{"code": 1, "data": [
+  {"type": 1, "code": "football", "name": "足球", "status": 1, "sort": 0,
+   "maintenance_scope": "self:sport:football", "is_maintaining": false, "maintenance": null, "embeddable": true},
+  {"type": 2, "code": "basketball", "name": "篮球", "status": 1, "sort": 0,
+   "maintenance_scope": "self:sport:basketball", "is_maintaining": true, "embeddable": true,
+   "maintenance": {"scope": "game", "category": "self:sport", "subcategory": "self:sport:basketball", "content": "篮球维护中", "...": "其余字段同状态接口"}}
+]}
+```
+
+- 可用于 F1 体育入口的体育分类导航,点击处理同第二节。
+- 目前只有足球有赛事数据,F1 体育现有的赛事、下注、撤单、足球游戏接口都按「足球」判断维护。其他体育项暂时只用于展示和维护标记。
+
+## 四、维护状态接口 `GET /api/maintenance/status`
+
+`data.games` 的每个大类新增 `children`:当前单独在维护的子类(只列生效的,没有则为 `[]`)。
+
+```json
+{"category": "third:2", "is_maintaining": false, "maintenance": null,
+ "children": [{"category": "third:2:pg", "parent": "third:2", "is_maintaining": true, "maintenance": {"...": "维护详情"}}]}
+```
+
+- 大类入口仍看 `games[].is_maintaining`。只有 PG 维护时电子大类是 `false`,电子入口照常可进。
+- 平台 / 彩种 / 体育项的维护标记优先用第一节列表上的字段;没有列表数据的页面可以用 `children` 判断。
+
+## 不用改的
+
+- 请求头、整站维护页(`11001`)、维护页轮询和倒计时,沿用原有接入。
+- 下注接口参数不变:`/api/game/bet` 的 `pc28` 传 `0`(加拿大28)或 `1`(极速28);`/api/jisuGameOrder/submit` 的 `data[].id` 传单个正整数玩法 ID(传数组等非法值会返回「投注项编号无效」)。

+ 157 - 0
docs/前端交付/整站与游戏维护子类-总后台前端修改单.md

@@ -0,0 +1,157 @@
+# 整站与游戏维护子类-总后台前端修改单(2026-11-10)
+
+## 改动
+
+1. **维护可以细到子类**:除了整站和 9 个大类,现在可以单独维护大类下的某一项:
+   - 三方平台:某个大类下的某个平台,例如「电子 / PG电子」「真人 / BBIN」;
+   - F1 彩票:某个彩种,例如「F1彩票 / 加拿大28」「F1彩票 / 极速赛车」;
+   - F1 体育:某个体育项,例如「F1体育 / 足球」「F1体育 / 篮球」。
+2. **维护列表改为树形**:顶层只有整站和大类,子类放在所属大类的 `children` 里。
+3. **子类新增「支持内嵌」开关**:控制会员端能否内嵌打开该平台 / 彩种 / 体育项,默认支持,和维护开关互不影响。
+4. **新增菜单「F1体育列表」**:和现有的「彩票列表」一样,可以增删改体育项(足球、篮球、乒乓球、排球、板球、网球……)。
+
+接口地址、权限 URI 都没有变;维护的保存、启停、日志用法不变。
+
+规则:
+
+- 生效顺序:整站 → 子类 → 大类。大类维护时,其下所有子类都不可进入;只开某个子类时,只拦这一项。
+- 子类记录由后端自动生成(打开列表时补齐),默认关闭。前端不需要「新增」按钮,也不能删除。
+- 维护不影响游戏管理里的「展示 / 不展示」,会员端照常展示,只在进入或下注时提示维护。
+- Telegram 机器人的 28 下注也按这里的设置拦截(加拿大28 或极速28,看当前 `pc28_switch`),前端无需处理。
+
+## 一、整站与游戏维护
+
+### 1. 列表 `GET /admin/maintenance`
+
+本节内容与已发的[维护列表按层级返回-前端修改单](维护列表按层级返回-前端修改单.md)一致,已按那份改过的只需补上 `embeddable` 列。
+
+返回仍是 `{total, data: []}`,但 **`data` 只有整站和大类**,分页和 `total` 也只按这一层算。每个顶层行新增:
+
+| 字段 | 说明 |
+|---|---|
+| `children` | 该大类下的子类数组,每项是完整的设置记录(字段同详情);整站为 `[]` |
+| `children_count` | 该大类下子类总数,不受筛选影响 |
+| `matched` | 这一行本身是否符合筛选;`false` 表示只是因为有子类命中才显示 |
+
+所有记录(顶层和子类)新增:
+
+| 字段 | 说明 |
+|---|---|
+| `scope_level` | `site` / `category` / `subcategory` |
+| `parent_scope` | 子类的大类代码,如 `third:2`;整站和大类为 `null` |
+| `scope_name` | 子类为「大类 / 名称」,如 `电子 / PG电子`、`F1体育 / 篮球` |
+| `embeddable` | 子类为 `1`(支持内嵌)或 `0`;整站和大类为 `null` |
+
+```json
+{"total": 10, "data": [
+  {"id": 1, "scope_code": "site", "scope_name": "整站", "scope_level": "site", "parent_scope": null, "embeddable": null,
+   "enabled": 0, "matched": true, "children_count": 0, "children": [], "...": "其余字段同详情"},
+  {"id": 3, "scope_code": "third:2", "scope_name": "电子", "scope_level": "category", "parent_scope": null, "embeddable": null,
+   "enabled": 0, "matched": true, "children_count": 2, "children": [
+     {"id": 21, "scope_code": "third:2:pg", "scope_name": "电子 / PG电子", "scope_level": "subcategory", "parent_scope": "third:2",
+      "embeddable": 1, "enabled": 1, "state": "active", "...": "其余字段同详情"},
+     {"id": 22, "scope_code": "third:2:jdb", "scope_name": "电子 / JDB", "scope_level": "subcategory", "parent_scope": "third:2",
+      "embeddable": 0, "enabled": 0, "state": "disabled", "...": "其余字段同详情"}
+   ]}
+]}
+```
+
+页面建议:
+
+- 表格改为可展开的树:大类行显示「子类 N 个」(`children_count`),展开显示 `children`。
+- 子类行和大类行用同样的列(范围、平台、状态、开关、时间、操作),子类行多一列「支持内嵌」(显示 `embeddable`)。
+- `matched=false` 的大类行置灰,表示它本身不符合筛选,只是作为容器显示。
+
+筛选参数:
+
+| 参数 | 变化 | 说明 |
+|---|---|---|
+| `parent_scope` | 新增 | 大类代码,只返回该大类及其子类 |
+| `scope_code` | 可传子类 | 传大类只返回该大类;传子类(如 `third:2:pg`)返回它的大类(`matched=false`),`children` 里只有这一个子类 |
+| `platform` / `enabled` / `state` / `language` | 规则变化 | 大类和子类分别判断:大类本身符合或有子类符合就显示,`children` 里只放符合的子类 |
+
+范围筛选下拉可以用两级:先选大类(`options.categories`),再选子类(`options.subcategories` 里 `parent` 等于该大类的项)。
+
+### 2. 选项 `GET /admin/maintenance/options`
+
+新增:
+
+- `subcategories`:`[{value, label, parent}]`,如 `{"value": "third:2:pg", "label": "电子 / PG电子", "parent": "third:2"}`。
+- `defaults.embeddable`:`1`。
+
+### 3. 编辑弹窗 `POST /admin/maintenance/submit`
+
+子类和大类用同一个弹窗、同一套字段。子类额外显示一个开关「支持内嵌」:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `embeddable` | 否 | **仅子类**可传,`1` 支持内嵌、`0` 不支持;不传则不改 |
+
+```json
+{"id": 22, "embeddable": 0}
+```
+
+- 「支持内嵌」与维护开关互不影响:维护关闭时也能单独保存这一项,不需要填时间和文案。
+- 整站或大类传 `embeddable` 会返回校验错误「只有子类可以设置是否支持内嵌」,这两类行不要显示这个开关。
+- 开关下方提示:「关闭后,会员端点击该项时用新窗口打开,不在页面内嵌。」
+
+`POST /admin/maintenance/status` 启停、`GET /admin/maintenance/detail` 详情用法不变;详情同样带 `scope_level`、`parent_scope`、`embeddable`。
+
+### 4. 操作日志
+
+不用改。修改「支持内嵌」也会记录,`old_values` / `new_values` 里多一个 `embeddable`。
+
+## 二、新增菜单:F1体育列表
+
+放在「彩票列表」旁边,页面照「彩票列表」做。
+
+### 1. 读取 `GET /admin/config/sportList`
+
+返回体育项数组,已按 `sort` 从大到小排好:
+
+```json
+[
+  {"type": 1, "code": "football", "name": "足球", "status": 1, "sort": 0},
+  {"type": 2, "code": "basketball", "name": "篮球", "status": 1, "sort": 0},
+  {"type": 3, "code": "tabletennis", "name": "乒乓球", "status": 1, "sort": 0},
+  {"type": 4, "code": "volleyball", "name": "排球", "status": 1, "sort": 0},
+  {"type": 5, "code": "cricket", "name": "板球", "status": 1, "sort": 0},
+  {"type": 6, "code": "tennis", "name": "网球", "status": 1, "sort": 0}
+]
+```
+
+| 字段 | 说明 |
+|---|---|
+| `type` | 编号,可自定 |
+| `code` | 体育代码,1-32 位小写字母或数字,不能重复。**新增后不要再改**,改了会变成另一个体育项,原来的维护设置不再对它生效 |
+| `name` | 名称 |
+| `status` | `1` 显示、`0` 隐藏(隐藏后会员端不展示,仍可在维护里设置) |
+| `sort` | 排序,越大越靠前 |
+
+### 2. 保存 `POST /admin/config/setField`
+
+和彩票列表一样整份提交:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `field` | 是 | 固定 `sport_list` |
+| `val` | 是 | 整份列表的 JSON 字符串;新增、删除、排序改完后一次提交 |
+
+```json
+{"field": "sport_list", "val": "[{\"type\":1,\"code\":\"football\",\"name\":\"足球\",\"status\":1,\"sort\":10},{\"type\":2,\"code\":\"basketball\",\"name\":\"篮球\",\"status\":1,\"sort\":0}]"}
+```
+
+错误提示:
+
+| `msg` | 原因 |
+|---|---|
+| `体育列表格式错误` | `val` 不是数组 |
+| `体育代码只能是1-32位小写字母或数字` | `code` 为空或格式不对 |
+| `体育代码 xxx 重复` | 两项 `code` 相同 |
+| `体育名称不能为空` | |
+
+页面建议:编辑已有项时 `code` 只读;新增时校验格式。新增的体育项在下次打开维护列表时出现在「F1体育」下,默认关闭。
+
+## 三、彩票列表(提醒,不用改)
+
+彩票列表的页面和接口不变。维护里的「F1彩票」子类直接来自这份列表,按 `game` 和 `params.type` 对应(例如 `game=lhc`、`params.type=1` 对应「新澳门六合彩」)。修改某个彩种的 `game` 或 `params.type` 等于换成另一个彩种,原来的维护设置不再对它生效;改名称、排序、显示状态没有影响。

+ 5 - 3
docs/总后台/整站与游戏维护-后台接口.md

@@ -42,7 +42,7 @@
 
 
 ## 表单与保存
 ## 表单与保存
 
 
-options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{"value":"...","label":"..."}]`;`timezones` 是标准 IANA 时区字符串数组;`defaults` 含默认关闭、全部平台、Asia/Shanghai 时区和无结束时间;`business_codes` 为 `{"site":11001,"game":11002}`。
+options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{"value":"...","label":"..."}]`;`timezones` 是标准 IANA 时区字符串数组;`defaults` 含默认关闭、全部平台、Asia/Shanghai 时区、无结束时间和 `embeddable=1`;`business_codes` 为 `{"site":11001,"game":11002}`。
 
 
 ```json
 ```json
 {
 {
@@ -71,6 +71,7 @@ options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{
 | starts_at | 所选时区的本地时间,格式 `YYYY-MM-DD HH:mm:ss` |
 | starts_at | 所选时区的本地时间,格式 `YYYY-MM-DD HH:mm:ss` |
 | ends_at | 同一时区的本地时间,必须晚于开始;传 null 清空,之后手动关闭 |
 | ends_at | 同一时区的本地时间,必须晚于开始;传 null 清空,之后手动关闭 |
 | translations | 以 `zh/en/vi` 为键;每个语言仅 content,最多 5000 字符,按纯文本展示 |
 | translations | 以 `zh/en/vi` 为键;每个语言仅 content,最多 5000 字符,按纯文本展示 |
+| embeddable | 仅子类可传,整数 `0/1`,是否支持内嵌打开,默认 `1`;与维护开关无关,整站或大类提交返回校验错误。列表和详情中子类返回该值,整站和大类为 `null` |
 
 
 - 编辑省略字段时保留原值。`scope_code` 固定,不可提交修改。
 - 编辑省略字段时保留原值。`scope_code` 固定,不可提交修改。
 - `start_now=true` 每次明确提交都会重新设置开始时间,编辑普通文案时应省略此字段。若原结束时间已经过去,立即开启时同时传新的 ends_at 或 `ends_at:null`。
 - `start_now=true` 每次明确提交都会重新设置开始时间,编辑普通文案时应省略此字段。若原结束时间已经过去,立即开启时同时传新的 ends_at 或 `ends_at:null`。
@@ -190,7 +191,7 @@ options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{
 
 
 ## 数据与上线
 ## 数据与上线
 
 
-新增迁移:`database/migrations/2026_10_26_120000_create_maintenance_settings.php`;子类上线后另执行 `database/migrations/2026_11_09_120000_extend_maintenance_scope_code.php`(`scope_code` 扩至 64 字符)和 `database/migrations/2026_11_10_130000_add_sport_list_config.php`(写入初始体育列表 `sport_list`)。使用项目既有数据库前缀,默认实体表为 `bot_maintenance_settings`、`bot_maintenance_translations`。开始、结束及设置记录的时间按 UTC 保存;操作日志按现有北京时间约定记录。
+新增迁移:`database/migrations/2026_10_26_120000_create_maintenance_settings.php`;子类上线后另执行 `database/migrations/2026_11_09_120000_extend_maintenance_scope_code.php`(`scope_code` 扩至 64 字符)、`database/migrations/2026_11_10_130000_add_sport_list_config.php`(写入初始体育列表 `sport_list`)和 `database/migrations/2026_11_10_140000_add_embeddable_to_maintenance_settings.php`(子类「是否支持内嵌」,默认支持)。使用项目既有数据库前缀,默认实体表为 `bot_maintenance_settings`、`bot_maintenance_translations`。开始、结束及设置记录的时间按 UTC 保存;操作日志按现有北京时间约定记录。
 
 
 部署顺序:
 部署顺序:
 
 
@@ -205,7 +206,8 @@ options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{
 php artisan migrate \
 php artisan migrate \
   --path=database/migrations/2026_10_26_120000_create_maintenance_settings.php \
   --path=database/migrations/2026_10_26_120000_create_maintenance_settings.php \
   --path=database/migrations/2026_11_09_120000_extend_maintenance_scope_code.php \
   --path=database/migrations/2026_11_09_120000_extend_maintenance_scope_code.php \
-  --path=database/migrations/2026_11_10_130000_add_sport_list_config.php
+  --path=database/migrations/2026_11_10_130000_add_sport_list_config.php \
+  --path=database/migrations/2026_11_10_140000_add_embeddable_to_maintenance_settings.php
 ```
 ```
 
 
 若回滚到仅支持整站和大类的版本,先暂停新版后台的维护接口并等待正在处理的请求结束,避免列表和选项请求重新生成子类记录。备份维护设置和文案后,在含扩列迁移文件的版本中用 `php artisan migrate:status` 查到该迁移的批次。将下面的 `42` 替换成实际批次号,再回滚该文件。
 若回滚到仅支持整站和大类的版本,先暂停新版后台的维护接口并等待正在处理的请求结束,避免列表和选项请求重新生成子类记录。备份维护设置和文案后,在含扩列迁移文件的版本中用 `php artisan migrate:status` 查到该迁移的批次。将下面的 `42` 替换成实际批次号,再回滚该文件。

+ 12 - 1
tests/Integration/MaintenanceTest.php

@@ -71,7 +71,8 @@ class MaintenanceTest extends TestCase
         DB::table('config')->insert([['field' => 'game_list', 'val' => json_encode(self::GAME_LIST, JSON_UNESCAPED_UNICODE), 'group_id' => 5],
         DB::table('config')->insert([['field' => 'game_list', 'val' => json_encode(self::GAME_LIST, JSON_UNESCAPED_UNICODE), 'group_id' => 5],
             ['field' => 'pc28_switch', 'val' => '0', 'group_id' => 5]]);
             ['field' => 'pc28_switch', 'val' => '0', 'group_id' => 5]]);
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_130000_add_sport_list_config.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_130000_add_sport_list_config.php')->up();
-        $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up(); $this->service = new MaintenanceService(); $this->request();
+        $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_140000_add_embeddable_to_maintenance_settings.php')->up(); $this->service = new MaintenanceService(); $this->request();
     }
     }
 
 
     protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
     protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
@@ -239,6 +240,16 @@ class MaintenanceTest extends TestCase
         $this->assertSame('self:sport:basketball', $this->decide('PC', 'self:sport', 'self:sport:basketball')['scope_code']);
         $this->assertSame('self:sport:basketball', $this->decide('PC', 'self:sport', 'self:sport:basketball')['scope_code']);
         $this->assertNull($this->decide('PC', 'self:sport', 'self:sport:football'));
         $this->assertNull($this->decide('PC', 'self:sport', 'self:sport:football'));
         $this->assertNull($this->decide('PC', 'self:lottery', 'self:sport:basketball'));
         $this->assertNull($this->decide('PC', 'self:lottery', 'self:sport:basketball'));
+        // Embeddable: subcategory-only, defaults to supported, independent of maintenance.
+        $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2:jdb'])['data'][0]['children'][0]['embeddable']);
+        $this->assertNull($this->service->detail($this->id('third:2'))['embeddable']);
+        $jdb = $this->service->save(['id' => $this->id('third:2:jdb'), 'embeddable' => 0]);
+        $this->assertSame([0, 0], [$jdb['embeddable'], $jdb['enabled']]);
+        $this->assertSame(0, $this->service->listing(['scope_code' => 'third:2:jdb'])['data'][0]['children'][0]['embeddable']);
+        $this->assertSame(1, $this->service->options()['defaults']['embeddable']);
+        try { $this->service->save(['id' => $this->id('third:2'), 'embeddable' => 0]); $this->fail('category cannot set embeddable'); }
+        catch (ValidationException $e) { $this->assertArrayHasKey('embeddable', $e->errors()); }
+        $this->assertSame(1, (int) DB::table('maintenance_settings')->where('scope_code', 'third:2')->value('embeddable'));
         $long = str_repeat('a', 32);
         $long = str_repeat('a', 32);
         $this->assertSame('third:2:' . $long, Policy::thirdSubcategory(2, $long));
         $this->assertSame('third:2:' . $long, Policy::thirdSubcategory(2, $long));
         $this->assertNull(Policy::thirdSubcategory(2, str_repeat('a', 33)));
         $this->assertNull(Policy::thirdSubcategory(2, str_repeat('a', 33)));