菜单 slug 带斜杠时 `add_menu_page` 不报错但子菜单全失踪:一次路由层级与 capability 映射的"静默塌方"实录
上周给插件加"多级设置中心",想着菜单结构清晰点,把主菜单 slug 写成 myplugin/settings,子菜单分别挂 myplugin/settings/general、myplugin/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.php 的 add_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 导致匹配失败?

