Kaynağa Gözat

菜单角标

doge 3 gün önce
ebeveyn
işleme
2568cc7e7f

+ 39 - 0
app/Http/Controllers/admin/MenuBadge.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\Controller;
+use App\Services\MenuBadgeAdminService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class MenuBadge extends Controller
+{
+    public function index(MenuBadgeAdminService $service)
+    {
+        return $this->run(fn () => $service->listing());
+    }
+
+    public function save(MenuBadgeAdminService $service)
+    {
+        return $this->run(fn () => $service->save(request()->all()));
+    }
+
+    public function delete(MenuBadgeAdminService $service)
+    {
+        return $this->run(fn () => $service->delete(request()->all()));
+    }
+
+    private function run(callable $callback)
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(-3, $e->validator->errors()->first());
+        } catch (\Throwable $e) {
+            Log::error('menu_badge_admin_failed', ['exception' => $e]);
+
+            return $this->error(-3, '菜单角标配置操作失败,请稍后重试');
+        }
+    }
+}

+ 125 - 0
app/Services/MenuBadgeAdminService.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+final class MenuBadgeAdminService
+{
+    private function ready(): void
+    {
+        if (!Schema::hasTable('menu_badge_configs')) {
+            throw ValidationException::withMessages(['config' => '菜单角标配置未初始化,请先完成系统更新']);
+        }
+    }
+
+    private function badgeTexts(array $texts): array
+    {
+        $rules = [];
+        $messages = [];
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            $rules[$lang] = ['required', 'array:text'];
+            $rules[$lang . '.text'] = ['required', 'string', 'max:100'];
+            $messages[$lang . '.required'] = $label . '角标不能为空';
+            $messages[$lang . '.text.required'] = $label . '角标不能为空';
+        }
+        $result = Validator::make($texts, $rules, $messages)->validate();
+        foreach ($result as $lang => &$text) {
+            if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $text['text']) === '') {
+                throw ValidationException::withMessages(['badge_translations.' . $lang . '.text' => '角标不能为空']);
+            }
+            $text['text'] = trim($text['text']);
+        }
+        unset($text);
+
+        return $result;
+    }
+
+    private function row(object $row): array
+    {
+        return [
+            'id' => (int) $row->id,
+            'menu_text' => (string) $row->menu_text,
+            'badge_translations' => json_decode((string) $row->badge_translations, true) ?: [
+                'zh' => ['text' => ''], 'en' => ['text' => ''], 'vi' => ['text' => ''],
+            ],
+            'sort' => (int) $row->sort,
+            'created_at' => (string) $row->created_at,
+            'updated_at' => (string) $row->updated_at,
+        ];
+    }
+
+    public function listing(): array
+    {
+        $this->ready();
+        $rows = DB::table('menu_badge_configs')->orderBy('sort')->orderBy('id')->get();
+
+        return ['total' => $rows->count(), 'data' => $rows->map(fn ($row) => $this->row($row))->all()];
+    }
+
+    public function save(array $input): array
+    {
+        $this->ready();
+        $data = Validator::make($input, [
+            'id' => ['nullable', 'integer', 'min:1'],
+            'menu_text' => ['required', 'string', 'max:200'],
+            'badge_translations' => ['required', 'array:zh,en,vi'],
+            'sort' => ['nullable', 'integer', 'min:0', 'max:9999'],
+        ])->validate();
+        $data['menu_text'] = trim($data['menu_text']);
+        if ($data['menu_text'] === '') {
+            throw ValidationException::withMessages(['menu_text' => '菜单文本不能为空']);
+        }
+        $data['badge_translations'] = $this->badgeTexts($data['badge_translations']);
+        $sort = array_key_exists('sort', $data) && $data['sort'] !== null ? (int) $data['sort'] : null;
+
+        return DB::transaction(function () use ($data, $sort) {
+            $now = now()->format('Y-m-d H:i:s');
+            $payload = [
+                'menu_text' => $data['menu_text'],
+                'badge_translations' => json_encode($data['badge_translations'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+                'updated_at' => $now,
+            ];
+            if (!empty($data['id'])) {
+                $before = DB::table('menu_badge_configs')->where('id', $data['id'])->lockForUpdate()->first();
+                if (!$before) {
+                    throw ValidationException::withMessages(['id' => '配置不存在']);
+                }
+                if ($sort !== null) {
+                    $payload['sort'] = $sort;
+                }
+                DB::table('menu_badge_configs')->where('id', $data['id'])->update($payload);
+                OperationAuditService::record('menu_badge_config', (int) $data['id'], 'update', $this->row($before), $data);
+                $id = (int) $data['id'];
+            } else {
+                $payload['sort'] = $sort ?? (int) DB::table('menu_badge_configs')->max('sort') + 1;
+                $payload['created_at'] = $now;
+                $id = (int) DB::table('menu_badge_configs')->insertGetId($payload);
+                OperationAuditService::record('menu_badge_config', $id, 'create', [], $data + ['id' => $id]);
+            }
+            $saved = DB::table('menu_badge_configs')->where('id', $id)->first();
+
+            return $this->row($saved);
+        });
+    }
+
+    public function delete(array $input): array
+    {
+        $this->ready();
+        $data = Validator::make($input, ['id' => ['required', 'integer', 'min:1']])->validate();
+
+        return DB::transaction(function () use ($data) {
+            $before = DB::table('menu_badge_configs')->where('id', $data['id'])->lockForUpdate()->first();
+            if (!$before) {
+                throw ValidationException::withMessages(['id' => '配置不存在']);
+            }
+            DB::table('menu_badge_configs')->where('id', $data['id'])->delete();
+            OperationAuditService::record('menu_badge_config', (int) $data['id'], 'delete', $this->row($before), []);
+
+            return ['id' => (int) $data['id']];
+        });
+    }
+}

+ 26 - 0
database/migrations/2026_11_05_120000_create_menu_badge_configs.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (Schema::hasTable('menu_badge_configs')) {
+            return;
+        }
+        Schema::create('menu_badge_configs', function (Blueprint $table) {
+            $table->id();
+            $table->string('menu_text', 200)->comment('菜单文本');
+            $table->json('badge_translations')->comment('角标 zh/en/vi');
+            $table->unsignedInteger('sort')->default(0)->comment('展示顺序,越小越靠前');
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('menu_badge_configs');
+    }
+};

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -20,6 +20,7 @@
 | 会员彩金:统一变动类型筛选、变动前/变动金额/变动后、余额宝利息直接进余额(2026-11-05) | [会员彩金-前端接入](会员彩金-前端接入.md) |
 | 活动列表:到期自动下架、状态筛选与列表展示(2026-11-05) | [活动列表-到期自动下架](活动列表-到期自动下架.md) |
 | 会员列表:默认按注册时间倒序,钱包/三方/冻结/余额宝/投注/打码量列服务端排序(2026-11-05) | [会员列表-默认排序与列排序](会员列表-默认排序与列排序.md) |
+| 菜单角标:列表新增/编辑/删除,三语角标(2026-11-05) | [菜单角标配置-后台接口](菜单角标配置-后台接口.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |

+ 70 - 0
docs/前端交付/bot-28/后台/菜单角标配置-后台接口.md

@@ -0,0 +1,70 @@
+# 菜单角标配置:后台接入
+
+接口项目:`bot-28` 总后台。成功 `code=0`,失败 `code=-3`,提示看 `msg`。
+
+## 页面
+
+列表展示全部配置,支持 **「+」新增**、编辑、删除。每条包含:
+
+| 表单项 | 说明 |
+| --- | --- |
+| 菜单文本 | 单行文字,必填,最多 200 字,用于与前台菜单项匹配 |
+| 角标 badge | 中文、英文、越南文三个 Tab,各一个文字框;**三种语言都必填** 才能保存,每种最多 100 字 |
+
+可选填 **排序** `sort`(非负整数,越小越靠前);新增不传时自动排在末尾。
+
+## 接口
+
+| 方法 | 地址 | 用途 |
+| --- | --- | --- |
+| GET | `/admin/menuBadge/list` | 列表 |
+| POST | `/admin/menuBadge/save` | 新增或编辑 |
+| POST | `/admin/menuBadge/delete` | 删除 |
+
+建议按钮权限 URI:`admin/menuBadge/list`、`admin/menuBadge/save`、`admin/menuBadge/delete`(菜单与路由由前端自行配置)。
+
+### 列表 `data.data[]`
+
+```json
+{
+  "id": 1,
+  "menu_text": "活动",
+  "badge_translations": {
+    "zh": {"text": "热"},
+    "en": {"text": "Hot"},
+    "vi": {"text": "Nóng"}
+  },
+  "sort": 0,
+  "created_at": "2026-11-05 12:00:00",
+  "updated_at": "2026-11-05 12:00:00"
+}
+```
+
+总数:`data.total`。
+
+### 保存
+
+新增(不传 `id`):
+
+```json
+{
+  "menu_text": "活动",
+  "badge_translations": {
+    "zh": {"text": "热"},
+    "en": {"text": "Hot"},
+    "vi": {"text": "Nóng"}
+  }
+}
+```
+
+编辑传 `id`,字段同上;可附带 `sort`。
+
+### 删除
+
+```json
+{"id": 1}
+```
+
+## 前台展示
+
+会员端按当前语言读取角标文案,见 [菜单角标-前台接入](../../melbet_sport-api/前台/菜单角标-前台接入.md)(`melbet_sport-api` 项目 `GET /api/menuBadge/list`)。

+ 1 - 0
docs/前端交付/melbet_sport-api/前台/README.md

@@ -13,6 +13,7 @@
 | [邀请升级-会员接口](独立活动/邀请升级-会员接口.md) | 正文 |
 | [首充奖励-会员接口](独立活动/首充奖励-会员接口.md) | 正文 |
 | [转盘活动-会员接口](独立活动/转盘活动-会员接口.md) | 正文 |
+| [菜单角标-前台接入](菜单角标-前台接入.md) | 正文 |
 | [转盘播报示例](独立活动/转盘播报示例.json) | 示例数据 |
 
 [项目目录](../README.md) · [交付总目录](../../README.md)

+ 50 - 0
docs/前端交付/melbet_sport-api/前台/菜单角标-前台接入.md

@@ -0,0 +1,50 @@
+# 菜单角标:前台接入
+
+接口项目:`melbet_sport-api`。按当前请求语言返回后台配置的菜单角标,用于底部导航、侧边栏等 **按菜单文本匹配** 后展示 badge。
+
+需 **会员登录**,请求头带现有 Token;语言沿用公共 `lang` 请求头(`zh` / `en` / `vi`)。
+
+## 请求
+
+```http
+GET /api/menuBadge/list
+```
+
+无 Query 参数。
+
+## 成功响应
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "list": [
+      {"menu_text": "活动", "badge_text": "热"},
+      {"menu_text": "充值", "badge_text": "送5%"}
+    ]
+  }
+}
+```
+
+| 字段 | 说明 |
+| --- | --- |
+| `data.list` | 配置项数组,顺序与后台一致 |
+| `menu_text` | 菜单文本,与本地菜单文案 **完全一致** 时挂载角标 |
+| `badge_text` | 当前 `lang` 下的角标文字;空字符串表示不展示角标 |
+
+未配置任何项时:
+
+```json
+{"code":1,"show":0,"msg":"","data":{"list":[]}}
+```
+
+## 页面用法
+
+1. 进入主框架或切换语言后请求一次;后台改配置后重新进入或下拉刷新时再请求(`Cache-Control: no-store`)。
+2. 遍历 `data.list`,用 `menu_text` 找到对应菜单项,将 `badge_text` 非空时显示为角标/红点旁文字。
+3. 找不到匹配菜单项的配置行可忽略。
+4. `code=0` 时展示 `msg`,沿用公共请求失败处理。
+
+后台维护方式见 bot-28 交付文档 [菜单角标配置-后台接口](../../bot-28/后台/菜单角标配置-后台接口.md)。

+ 6 - 0
routes/admin.php

@@ -421,6 +421,12 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/status', [\App\Http\Controllers\admin\RechargeBonus::class, 'status'])->defaults('permission_uri', 'admin/rechargeBonus/save');
         });
 
+        Route::prefix('/menuBadge')->group(function () {
+            Route::get('/list', [\App\Http\Controllers\admin\MenuBadge::class, 'index']);
+            Route::post('/save', [\App\Http\Controllers\admin\MenuBadge::class, 'save']);
+            Route::post('/delete', [\App\Http\Controllers\admin\MenuBadge::class, 'delete']);
+        });
+
         Route::prefix('/rechargeChannel')->group(function () {
             Route::get('/logoOptions', [RechargeChannel::class, 'logoOptions'])->defaults('permission_uri', 'admin/rechargeChannel/list');
             Route::post('/saveLogo', [RechargeChannel::class, 'saveLogo'])->defaults('permission_uri', 'admin/rechargeChannel/update');