菜单 slug 带斜杠时 `add_menu_page` 不报错但子菜单全失踪:一次路由层级与 capability 映射的"静默塌方"实录

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 34 浏览 0 回复

上周给插件加"多级设置中心",想着菜单结构清晰点,把主菜单 slug 写成 myplugin/settings,子菜单分别挂 myplugin/settings/generalmyplugin/settings/advanced。保存刷新,主菜单正常显示,子菜单集体蒸发——没报错、没警告、白屏日志干干净净。

翻了两小时源码才锁定:add_menu_page$menu_slug 在 WordPress 内部会被直接用作 $_GET['page'] 的匹配键,而子菜单注册时 add_submenu_page 会把父 slug 和子 slug 拼接成权限检查路径。斜杠在这里不是路径分隔符,是被原样吞进字符串比较的"合法字符",导致父子匹配逻辑断裂。

最小复现

// 主菜单
add_menu_page(
    '设置中心',
    '我的插件',
    'manage_options',
    'myplugin/settings',          // ← 带斜杠的 slug
    [$this, 'render_main'],
    'dashicons-admin-generic',
    30
);

// 子菜单——注册成功但永远不会显示
add_submenu_page(
    'myplugin/settings',          // 父 slug
    '常规设置',
    '常规',
    'manage_options',
    'myplugin/settings/general',  // 子 slug 也带斜杠
    [$this, 'render_general']
);

问题藏在 wp-admin/includes/plugin.phpadd_submenu_page() 里。它用 $parent_slug === $menu_slug 做父子关联,但后续 user_can_access_admin_page() 检查权限节点时,会把 page=xxx 整个字符串去匹配 $_wp_submenu_nopriv 的键。斜杠让字符串比较"表面上对、实际上错",子菜单被标记为"无权访问"直接过滤掉。

两种修复思路

方案 A:扁平 slug + 路由分发(推荐)

// 菜单层保持扁平
add_menu_page('设置中心', '我的插件', 'manage_options', 'myplugin-dashboard', ...);
add_submenu_page('myplugin-dashboard', '常规', '常规', 'manage_options', 'myplugin-general', ...);
add_submenu_page('myplugin-dashboard', '高级', '高级', 'manage_options', 'myplugin-advanced', ...);

// 页面内部用 tab 或路由参数做层级
public function render_main() {
    $tab = $_GET['tab'] ?? 'general';
    // 分发到不同模板
}

方案 B:保留 URL 语义,但 slug 转义

// 用下划线或连字符替代斜杠,显示文本不受影响
add_submenu_page(
    'myplugin_settings',          // 父:myplugin_settings
    '常规设置',
    '常规',
    'manage_options',
    'myplugin_settings_general',  // 子:下划线分隔
    [$this, 'render_general']
);

// 前端 URL 想好看?自己包一层 rewrite,菜单层不动

权限节点的"隐性继承"坑

更隐蔽的是 capability 映射。WordPress 的 $_registered_pages$hookname 为键,而 $hookname = get_plugin_page_hookname($menu_slug, $parent_slug)。当父 slug 含斜杠时,hookname 变成 toplevel_page_myplugin/settings,子菜单的 hookname 是 mypluginsettings_page_myplugin/settings/general——斜杠被 sanitize 时处理方式不一致,导致 user_has_access 检查时 capability 节点对不上。

我现在的做法是菜单注册统一走一个工厂方法,slug 强制正则校验 /^[a-z0-9_-]+$/,带斜杠的直接抛异常。宁可注册阶段崩掉,也不让运行时静默丢菜单。

附带一个权限节点声明的顺手习惯

之前图省事,子菜单 capability 全写 manage_options。后来要开放部分功能给编辑角色,得逐个改。现在注册时会把 capability 抽成类常量,和自定义权限节点一起声明:

// 插件初始化时
add_action('admin_init', function () {
    $role = get_role('administrator');
    $role->add_cap('myplugin_manage_settings');
    $role->add_cap('myplugin_view_logs');
    
    $editor = get_role('editor');
    $editor->add_cap('myplugin_view_logs'); // 编辑只看日志
});

// 菜单注册时
add_submenu_page(
    'myplugin-dashboard',
    '系统日志',
    '日志',
    'myplugin_view_logs',  // 精确到功能点
    'myplugin-logs',
    [$this, 'render_logs']
);

这样菜单可见性和页面访问控制是同一套 capability,不会出现"菜单看得见、点进去 403"或者反过来"直接输 URL 能进、菜单却藏起来"的分裂现场。

你们有遇到过更诡异的 slug 字符问题吗?比如中文 slug 在某些环境下被二次 urlencode 导致匹配失败?

评论0
回复 · 0
还没有回复
微信客服 微信客服