PHP项目公众号与菜单管理

wen PHP项目 4

本文目录导读:

PHP项目公众号与菜单管理

  1. 数据表设计
  2. 核心功能实现
  3. 前端页面示例(Vue.js)
  4. 微信服务基类
  5. 菜单限制说明

我来为您详细介绍PHP项目中公众号菜单管理的实现方案。

数据表设计

-- 公众号账号表
CREATE TABLE `wechat_accounts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL COMMENT '公众号名称',
  `app_id` varchar(50) NOT NULL COMMENT 'AppID',
  `app_secret` varchar(100) NOT NULL COMMENT 'AppSecret',
  `token` varchar(100) NOT NULL COMMENT 'Token',
  `encoding_aes_key` varchar(100) DEFAULT NULL COMMENT '编码密钥',
  `status` tinyint(1) DEFAULT 1 COMMENT '状态 1启用 0禁用',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 菜单表
CREATE TABLE `wechat_menus` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `account_id` int(11) NOT NULL COMMENT '关联公众号ID',
  `parent_id` int(11) DEFAULT 0 COMMENT '父级菜单ID',
  `name` varchar(40) NOT NULL COMMENT '菜单名称',
  `type` enum('click','view','miniprogram','scancode_waitmsg','scancode_push',
            'pic_sysphoto','pic_photo_or_album','pic_weixin','location_select') 
            DEFAULT 'click' COMMENT '菜单类型',
  `key_value` varchar(128) DEFAULT NULL COMMENT 'key值(click类型)',
  `url` varchar(255) DEFAULT NULL COMMENT '链接(view类型)',
  `appid` varchar(50) DEFAULT NULL COMMENT '小程序appid',
  `pagepath` varchar(100) DEFAULT NULL COMMENT '小程序页面路径',
  `media_id` varchar(100) DEFAULT NULL COMMENT '素材ID',
  `sort_order` int(11) DEFAULT 0 COMMENT '排序',
  `status` tinyint(1) DEFAULT 1 COMMENT '状态',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `account_id` (`account_id`),
  KEY `parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心功能实现

菜单管理类

<?php
namespace App\Service\Wechat;
use think\facade\Db;
use think\facade\Cache;
class MenuService
{
    protected $wechatService;
    public function __construct()
    {
        $this->wechatService = new WechatService();
    }
    /**
     * 获取菜单列表(树形结构)
     */
    public function getMenuTree($accountId)
    {
        $menus = Db::name('wechat_menus')
            ->where('account_id', $accountId)
            ->where('status', 1)
            ->order('parent_id', 'asc')
            ->order('sort_order', 'asc')
            ->select()
            ->toArray();
        return $this->buildTree($menus, 0);
    }
    /**
     * 构建树形结构
     */
    private function buildTree($menus, $parentId = 0)
    {
        $tree = [];
        foreach ($menus as $menu) {
            if ($menu['parent_id'] == $parentId) {
                $children = $this->buildTree($menus, $menu['id']);
                if ($children) {
                    $menu['sub_button'] = $children;
                }
                $tree[] = $menu;
            }
        }
        return $tree;
    }
    /**
     * 保存菜单
     */
    public function saveMenu($accountId, $menuData)
    {
        // 清空原有菜单
        Db::name('wechat_menus')
            ->where('account_id', $accountId)
            ->delete();
        // 批量插入菜单
        return $this->insertMenus($accountId, $menuData, 0);
    }
    /**
     * 递归插入菜单
     */
    private function insertMenus($accountId, $menus, $parentId = 0, $sort = 0)
    {
        foreach ($menus as $menu) {
            $insertData = [
                'account_id' => $accountId,
                'parent_id' => $parentId,
                'name' => $menu['name'],
                'type' => $menu['type'] ?? 'click',
                'key_value' => $menu['key'] ?? '',
                'url' => $menu['url'] ?? '',
                'appid' => $menu['appid'] ?? '',
                'pagepath' => $menu['pagepath'] ?? '',
                'media_id' => $menu['media_id'] ?? '',
                'sort_order' => $sort++,
                'create_time' => date('Y-m-d H:i:s')
            ];
            $id = Db::name('wechat_menus')->insertGetId($insertData);
            // 处理子菜单
            if (!empty($menu['sub_button'])) {
                $this->insertMenus($accountId, $menu['sub_button'], $id);
            }
        }
    }
    /**
     * 发布菜单到微信服务器
     */
    public function publishMenu($accountId)
    {
        try {
            // 获取菜单数据
            $menus = $this->getMenuTree($accountId);
            if (empty($menus)) {
                return ['code' => 0, 'msg' => '菜单数据为空'];
            }
            // 转换菜单格式
            $menuData = $this->formatMenuData($menus);
            // 获取微信操作对象
            $wechatApp = $this->wechatService->getApp($accountId);
            // 发布菜单
            $result = $wechatApp->menu->create($menuData);
            // 记录发布日志
            $this->logMenuPublish($accountId, $result);
            return [
                'code' => 1,
                'msg' => '发布成功',
                'data' => $result
            ];
        } catch (\Exception $e) {
            return [
                'code' => 0,
                'msg' => '发布失败:' . $e->getMessage()
            ];
        }
    }
    /**
     * 格式化菜单数据(适配微信接口)
     */
    private function formatMenuData($menus)
    {
        $button = [];
        foreach ($menus as $menu) {
            $item = [
                'name' => $menu['name']
            ];
            // 有子菜单
            if (!empty($menu['sub_button'])) {
                $item['sub_button'] = $this->formatMenuData($menu['sub_button']);
            } else {
                // 不同类型的菜单配置
                switch ($menu['type']) {
                    case 'click':
                        $item['type'] = 'click';
                        $item['key'] = $menu['key_value'] ?: $menu['name'];
                        break;
                    case 'view':
                        $item['type'] = 'view';
                        $item['url'] = $menu['url'];
                        break;
                    case 'miniprogram':
                        $item['type'] = 'miniprogram';
                        $item['url'] = $menu['url'];
                        $item['appid'] = $menu['appid'];
                        $item['pagepath'] = $menu['pagepath'];
                        break;
                    case 'scancode_waitmsg':
                    case 'scancode_push':
                    case 'pic_sysphoto':
                    case 'pic_photo_or_album':
                    case 'pic_weixin':
                    case 'location_select':
                        $item['type'] = $menu['type'];
                        $item['key'] = $menu['key_value'] ?: $menu['name'];
                        break;
                }
            }
            $button[] = $item;
        }
        return ['button' => $button];
    }
    /**
     * 删除菜单
     */
    public function deleteMenu($accountId)
    {
        try {
            $wechatApp = $this->wechatService->getApp($accountId);
            $result = $wechatApp->menu->delete();
            // 删除本地菜单
            Db::name('wechat_menus')
                ->where('account_id', $accountId)
                ->delete();
            return [
                'code' => 1,
                'msg' => '删除成功'
            ];
        } catch (\Exception $e) {
            return [
                'code' => 0,
                'msg' => '删除失败:' . $e->getMessage()
            ];
        }
    }
    /**
     * 获取当前菜单(从微信服务器)
     */
    public function getCurrentMenu($accountId)
    {
        try {
            $wechatApp = $this->wechatService->getApp($accountId);
            $currentMenu = $wechatApp->menu->current();
            return [
                'code' => 1,
                'data' => $currentMenu
            ];
        } catch (\Exception $e) {
            return [
                'code' => 0,
                'msg' => '获取失败:' . $e->getMessage()
            ];
        }
    }
    /**
     * 记录菜单发布日志
     */
    private function logMenuPublish($accountId, $result)
    {
        $logData = [
            'account_id' => $accountId,
            'result' => json_encode($result),
            'create_time' => date('Y-m-d H:i:s')
        ];
        Db::name('wechat_menu_log')->insert($logData);
    }
}

控制器实现

<?php
namespace App\Controller\Admin;
use App\Service\Wechat\MenuService;
use think\facade\View;
class WechatMenuController extends BaseController
{
    protected $menuService;
    public function __construct()
    {
        parent::__construct();
        $this->menuService = new MenuService();
    }
    /**
     * 菜单管理页面
     */
    public function index()
    {
        $accountId = input('account_id', 0);
        $menus = $this->menuService->getMenuTree($accountId);
        View::assign([
            'account_id' => $accountId,
            'menus' => json_encode($menus)
        ]);
        return View::fetch('wechat/menu/index');
    }
    /**
     * 保存菜单
     */
    public function save()
    {
        $accountId = input('account_id', 0);
        $menuData = input('menu_data/a', []);
        if (empty($accountId) || empty($menuData)) {
            return json(['code' => 0, 'msg' => '参数错误']);
        }
        $result = $this->menuService->saveMenu($accountId, $menuData);
        return json(['code' => 1, 'msg' => '保存成功']);
    }
    /**
     * 发布菜单
     */
    public function publish()
    {
        $accountId = input('account_id', 0);
        if (empty($accountId)) {
            return json(['code' => 0, 'msg' => '参数错误']);
        }
        $result = $this->menuService->publishMenu($accountId);
        return json($result);
    }
    /**
     * 删除菜单
     */
    public function delete()
    {
        $accountId = input('account_id', 0);
        if (empty($accountId)) {
            return json(['code' => 0, 'msg' => '参数错误']);
        }
        $result = $this->menuService->deleteMenu($accountId);
        return json($result);
    }
    /**
     * 获取当前菜单
     */
    public function getCurrent()
    {
        $accountId = input('account_id', 0);
        if (empty($accountId)) {
            return json(['code' => 0, 'msg' => '参数错误']);
        }
        $result = $this->menuService->getCurrentMenu($accountId);
        return json($result);
    }
}

前端页面示例(Vue.js)

<template>
  <div class="menu-manager">
    <div class="menu-preview">
      <div class="phone-frame">
        <div class="phone-header">公众号</div>
        <div class="phone-content">
          <!-- 预览内容 -->
        </div>
        <div class="menu-bar">
          <div 
            v-for="(menu, index) in menus" 
            :key="index"
            class="menu-item"
            :class="{ active: activeMenu === index }"
            @click="selectMenu(index)"
          >
            {{ menu.name }}
            <div v-if="menu.sub_button && activeMenu === index" class="sub-menu">
              <div 
                v-for="(sub, subIndex) in menu.sub_button" 
                :key="subIndex"
                class="sub-menu-item"
              >
                {{ sub.name }}
              </div>
            </div>
          </div>
          <div class="add-menu" @click="addMenu">+</div>
        </div>
      </div>
    </div>
    <div class="menu-editor">
      <el-form :model="currentEditMenu" label-width="80px">
        <el-form-item label="菜单名称">
          <el-input v-model="currentEditMenu.name" maxlength="4"></el-input>
        </el-form-item>
        <el-form-item label="菜单类型">
          <el-select v-model="currentEditMenu.type" @change="onTypeChange">
            <el-option label="点击事件" value="click"></el-option>
            <el-option label="跳转网页" value="view"></el-option>
            <el-option label="小程序" value="miniprogram"></el-option>
            <el-option label="扫码" value="scancode_push"></el-option>
            <el-option label="拍照" value="pic_sysphoto"></el-option>
            <el-option label="地理位置" value="location_select"></el-option>
          </el-select>
        </el-form-item>
        <!-- 根据类型显示不同配置 -->
        <el-form-item v-if="currentEditMenu.type === 'view'" label="链接URL">
          <el-input v-model="currentEditMenu.url"></el-input>
        </el-form-item>
        <el-form-item v-if="currentEditMenu.type === 'click'" label="Key值">
          <el-input v-model="currentEditMenu.key"></el-input>
        </el-form-item>
        <el-form-item v-if="currentEditMenu.type === 'miniprogram'">
          <el-input v-model="currentEditMenu.appid" placeholder="小程序appid"></el-input>
          <el-input v-model="currentEditMenu.pagepath" placeholder="小程序页面路径"></el-input>
        </el-form-item>
      </el-form>
      <div class="editor-actions">
        <el-button type="primary" @click="saveMenu">保存菜单</el-button>
        <el-button type="success" @click="publishMenu">发布到微信</el-button>
        <el-button type="danger" @click="deleteMenu">删除菜单</el-button>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      menus: [],
      activeMenu: -1,
      currentEditMenu: {
        name: '',
        type: 'click',
        key: '',
        url: '',
        appid: '',
        pagepath: ''
      }
    }
  },
  methods: {
    addMenu() {
      // 限制最多3个一级菜单
      if (this.menus.length >= 3) {
        this.$message.warning('最多只能有3个一级菜单');
        return;
      }
      this.menus.push({
        name: '新建菜单',
        sub_button: []
      });
    },
    selectMenu(index) {
      this.activeMenu = index;
      this.currentEditMenu = { ...this.menus[index] };
    },
    saveMenu() {
      // 保存菜单到本地
      this.menus[this.activeMenu] = { ...this.currentEditMenu };
    },
    async publishMenu() {
      const res = await this.$http.post('/admin/wechat_menu/publish', {
        account_id: this.accountId
      });
      if (res.code === 1) {
        this.$message.success('发布成功');
      } else {
        this.$message.error(res.msg);
      }
    },
    async deleteMenu() {
      const res = await this.$http.post('/admin/wechat_menu/delete', {
        account_id: this.accountId
      });
      if (res.code === 1) {
        this.$message.success('删除成功');
        this.menus = [];
      }
    }
  }
}
</script>

微信服务基类

<?php
namespace App\Service\Wechat;
use EasyWeChat\Factory;
use think\facade\Db;
use think\facade\Cache;
class WechatService
{
    /**
     * 获取微信应用实例
     */
    public function getApp($accountId)
    {
        $account = $this->getAccount($accountId);
        if (empty($account)) {
            throw new \Exception('公众号不存在');
        }
        $config = [
            'app_id' => $account['app_id'],
            'secret' => $account['app_secret'],
            'token' => $account['token'],
            'aes_key' => $account['encoding_aes_key'],
            'log' => [
                'default' => 'app',
                'channels' => [
                    'app' => [
                        'level' => 'debug',
                        'file' => runtime_path() . '/log/wechat.log',
                    ],
                ],
            ],
            'http' => [
                'max_retries' => 3,
                'retry_delay' => 500,
                'timeout' => 5.0,
            ],
        ];
        // 配置缓存
        $config['cache'] = function () {
            return Cache::store('redis');
        };
        return Factory::officialAccount($config);
    }
    /**
     * 获取公众号信息
     */
    protected function getAccount($accountId)
    {
        $account = Db::name('wechat_accounts')
            ->where('id', $accountId)
            ->where('status', 1)
            ->find();
        return $account;
    }
}

菜单限制说明

微信菜单规则

  • 一级菜单最多3个
  • 每个一级菜单下子菜单最多5个
  • 菜单名称长度限制
    • 一级菜单:4个汉字(或8个字符)
    • 子菜单:8个汉字(或16个字符)
  • 不同类型的菜单有不同的配置要求

发布注意事项

// 发布前检查
public function validateMenuBeforePublish($menus)
{
    $errors = [];
    // 检查一级菜单数量
    if (count($menus) > 3) {
        $errors[] = '一级菜单不能超过3个';
    }
    foreach ($menus as $menu) {
        // 检查菜单名称长度
        if (mb_strlen($menu['name']) > 4) {
            $errors[] = '一级菜单"' . $menu['name'] . '"名称过长';
        }
        // 检查子菜单
        if (!empty($menu['sub_button'])) {
            if (count($menu['sub_button']) > 5) {
                $errors[] = $menu['name'] . '的子菜单不能超过5个';
            }
            foreach ($menu['sub_button'] as $sub) {
                if (mb_strlen($sub['name']) > 8) {
                    $errors[] = '子菜单"' . $sub['name'] . '"名称过长';
                }
            }
        }
    }
    return $errors;
}

这个方案提供了完整的公众号菜单管理功能,包括:

  1. 菜单的CRUD操作
  2. 本地与微信服务器的同步
  3. 可视化的编辑界面
  4. 完整的错误处理

抱歉,评论功能暂时关闭!