fix(admin): 修复移动端后台侧栏错位与子菜单不收起

根因:此前为移动端手写的一段折叠逻辑直接给 body 加 pear-mini,
绕过了 admin.js 里唯一的同步入口 collapse(),导致三处状态错位——
布局层认为已折叠(侧栏宽 0、菜单文字被 CSS 隐藏),
菜单 DOM 仍是展开态(#side 没有 pear-nav-mini),于是"文字看不见、
但子菜单又在原地铺开"。

处理:
- templates/system/index.html 还原为框架原始版本,删除手写的
  foldSidebar() IIFE 与首屏强制加类逻辑
- 删除无引用的死文件 templates/system/common/{head,side}.html
- 新增插件 plugins/mobileUI,以运行时注入 mobile.js 的方式实现移动端增强:
  * 首屏折叠只通过框架的 li.collapse 点击完成,不手写 class
  * 轮询等待菜单异步渲染就绪后再折叠
  * 补齐 framework 内部状态 isCollapse(后端 /rights/configs 把键拼成了
    "collaspe",admin.js 读 collapse 恒为 undefined,窄屏 resize 会反向展开)
  * 触屏下点击菜单外区域收起 mini 浮层(触屏没有可靠的 mouseleave)
  * 展开态点击叶子菜单后自动收起侧栏
- applications/config.py 的 PLUGIN_ENABLE_FOLDERS 追加 cookieManager、mobileUI

framework 业务逻辑零改动,仅 config.py 一行配置。
This commit is contained in:
bwstudio
2026-09-09 19:20:03 +08:00
parent 3e8c490851
commit b0865ea18d
8 changed files with 283 additions and 135 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ class BaseConfig:
# ---- 插件 ----
# 站点业务模块(导航 / 友链 / 关于 / 访问统计)均通过 plugins 方式开发,启用即可挂载路由
PLUGIN_ENABLE_FOLDERS = ["navManager", "friendManager", "aboutManager", "siteStats"]
PLUGIN_ENABLE_FOLDERS = ["navManager", "friendManager", "aboutManager", "siteStats", "cookieManager", "mobileUI"]
class DevConfig(BaseConfig):
+54
View File
@@ -0,0 +1,54 @@
# mobileUI —— 移动端侧栏增强插件
修复手机/窄屏(≤768px)下后台左侧菜单的两个问题:
1. **菜单错位**:侧栏只剩一列图标、文字全部消失;
2. **子菜单不自动隐藏**:mini 模式下点开的子菜单浮层赖着不走。
## 为什么会出问题
Pear Admin 的"侧栏折叠"由**三处状态**共同决定:
| 状态 | 作用 |
|---|---|
| `body`/`.pear-admin` 上的 `pear-mini` | 整页布局:`.layui-side` 压成 0/60px |
| `#side` 上的 `pear-nav-mini` | 菜单自身宽度、子菜单内联展开还是悬浮弹出 |
| `PearAdmin.instances.menu.isCollapse` | 框架状态机,决定下一次 collapse 的方向 |
框架把三者的同步封装在 admin.js 的**私有函数** `collapse()` 里,只能通过
点击 `.collapse` / `.pear-cover` 触发。此前在 `templates/system/index.html`
里手写的"首屏 `addClass('pear-mini')`"绕过了它,导致 body 与 #side 状态反相:
布局按折叠渲染(文字被 `.pear-mini ... span {display:none}` 藏掉、侧栏 0px),
菜单却停留在展开态 —— 即"只剩图标""子菜单收不起来"。
## 本插件的做法(不改 framework 任何文件)
- `event_finish` 注册 `after_request`,仅向后台主框架页(含 `#side` 的 HTML
注入 `static/mobile.js`
- 折叠一律**触发框架原生入口**`li.collapse` 的点击),三态由框架同步;
- 折叠后补写 `menu.isCollapse = true`:后端 `/rights/configs` 里菜单配置的键
拼成了 `collaspe`(框架读 `collapse`),done 回调会把它覆写成 `undefined`
不补写的话窄屏下任何 resize 都会把侧栏反向弹开;
- 触屏补位:mini 浮层靠 `mouseenter/mouseleave` 开关,而触屏 tap 后
`mouseleave` 常常不来 —— 点菜单以外区域时主动收起浮层;
- 移动端习惯:展开态点叶子菜单后自动收起侧栏。
## 涉及文件
```
plugins/mobileUI/
├── __init__.json # 插件元信息
├── __init__.py # after_request 注入 + 静态蓝图
├── static/mobile.js # 全部前端逻辑
└── README.md
```
## 启用
`applications/config.py``PLUGIN_ENABLE_FOLDERS` 加入 `"mobileUI"`,重启即可。
## 备注
配置里 `"collaspe"` 的拼写错误属于框架原文件(`applications/view/system/rights.py`),
按"不改动原项目内容"的原则未修正;插件已在运行时补齐状态,不受影响。
若未来升级框架,可顺手把键名改为 `"collapse"`
+5
View File
@@ -0,0 +1,5 @@
{
"plugin_name": "移动端侧栏增强",
"plugin_version": "1.0.0",
"plugin_description": "修复移动端(≤768px)后台左侧菜单错位与触屏子菜单浮层不消失的问题。不修改 framework 任何文件,通过 after_request 向后台主页注入脚本,折叠动作一律走框架原生 .collapse 入口,保证 body/#side/isCollapse 三处状态同步。"
}
+75
View File
@@ -0,0 +1,75 @@
"""
移动端侧栏增强插件入口
背景
----
Pear Admin 的侧栏折叠涉及三处状态,必须同步变化:
1. body / .pear-admin 上的 `pear-mini` —— 控制整页布局(CSS)
2. #side 上的 `pear-nav-mini` —— 控制菜单自身宽度与子菜单呈现方式
3. PearAdmin.instances.menu.isCollapse —— 框架内部状态机
框架只暴露了一个能同时同步这三者的入口:admin.js 里的私有函数 `collapse()`
它由 `body.on("click", ".collapse,.pear-cover", ...)` 驱动。**外面拿不到这个函数**,
所以任何"手动 addClass('pear-mini')"的写法都必然造成状态错位(详见 README)。
本插件的做法
------------
- 不修改 framework 任何文件;
- 通过 after_request 向后台主页注入 `static/mobile.js`
- 脚本里一律用「触发原生 .collapse 点击」的方式折叠,绝不手写 class;
- 额外补两处移动端体验:触屏点空白处收起子菜单浮层、点菜单后自动收起侧栏。
"""
from flask import Blueprint, Flask, request, url_for
bp = Blueprint(
"mobileUI",
__name__,
url_prefix="/plugin/mobileUI",
static_folder="static",
)
def _script_tag() -> bytes:
"""脚本标签的 URL 用 url_for 生成,避免手拼蓝图静态路径出错。"""
tag = f'<script src="{url_for("mobileUI.static", filename="mobile.js")}"></script>'
return tag.encode("utf-8")
def event_init(app: Flask):
"""注册静态资源蓝图(提供 /plugin/mobileUI/static/mobile.js)。"""
app.register_blueprint(bp)
print(" * mobileUI: registered /plugin/mobileUI/static/* blueprint")
def event_finish(app: Flask):
"""在后台主页响应里注入移动端脚本。"""
@app.after_request
def _inject_mobile_script(response):
try:
# 只处理成功的 HTML 页面
if response.status_code != 200:
return response
if not response.content_type.startswith("text/html"):
return response
if response.direct_passthrough:
return response
# 只注入后台主框架页(含侧栏容器 #side),不碰 iframe 子页面与前台页
if request.path not in ("/", "/index", "/system/index"):
return response
body = response.get_data()
if b'id="side"' not in body or b"pear-admin" not in body:
return response
script_tag = _script_tag()
if script_tag in body: # 幂等,避免重复注入
return response
response.set_data(body.replace(b"</body>", script_tag + b"</body>"))
except Exception: # 注入失败绝不能影响正常业务响应
app.logger.exception("mobileUI: inject failed")
return response
print(" * mobileUI: registered after_request hook (inject mobile.js into admin index)")
+148
View File
@@ -0,0 +1,148 @@
/**
* 移动端侧栏增强(视口 ≤ 768px)
*
* 为什么要这个脚本
* ----------------
* Pear Admin 的"侧栏折叠"由三处状态共同决定,缺一个就会错位:
*
* 1. body/.pear-admin 上的 `pear-mini` —— 决定整页布局(CSS 把 .layui-side 压成 0/60px
* 2. #side 上的 `pear-nav-mini` —— 决定菜单自身宽度、子菜单是内联展开还是悬浮弹出
* 3. PearAdmin.instances.menu.isCollapse —— 框架内部状态机,决定后续 collapse 的方向
*
* 框架把这三个状态的同步封装在 admin.js 的**私有函数** collapse() 里,
* 而它只由 `body.on("click", ".collapse,.pear-cover")` 这一个委托触发。
* 外部拿不到该函数,因此"手动 addClass('pear-mini')"必然导致 1 和 2 反相:
* 布局说已折叠(侧栏 0px、菜单文字被隐藏),菜单却还停留在展开态,
* 于是出现「侧栏只剩一列图标」「子菜单点开后不收起」的现象。
*
* 本脚本的原则:**折叠一律走框架原生入口**(触发 .collapse 的点击),
* 绝不手写 class,从而保证三处状态永远同步。
*/
(function () {
"use strict";
var MOBILE_MAX = 768;
function isMobile() {
return window.innerWidth <= MOBILE_MAX;
}
function getAdminEl() {
return document.querySelector(".pear-admin");
}
function isCollapsed() {
var el = getAdminEl();
return !!(el && el.classList.contains("pear-mini"));
}
function menuRendered() {
return document.querySelectorAll("#side .layui-nav-item").length > 0;
}
/**
* 折叠/展开侧栏:只触发框架委托的 .collapse 点击。
* 这样 admin.js 的私有 collapse() 会同步 body class、#side 的 pear-nav-mini、
* isCollapse 以及顶栏图标,不会出现状态错位。
*/
function toggleViaFramework() {
var btn = document.querySelector("li.collapse") || document.querySelector(".collapse");
if (!btn) {
return false;
}
btn.click();
return true;
}
/**
* 补齐框架内部状态 isCollapse。
* 后端 /rights/configs 里菜单配置的键拼成了 "collaspe"(框架读 "collapse"),
* 因此 admin.js 的 done 回调会把 isCollapse 覆写为 undefinedfalsy)。
* 折叠后若 isCollapse 不为 true,窄屏下任何 resize 都会触发框架的反向
* collapse,把已经折叠的侧栏重新弹开 —— 这里把状态补齐。
*/
function patchCollapseState() {
try {
var m = window.PearAdmin && window.PearAdmin.instances && window.PearAdmin.instances.menu;
if (m) {
m.isCollapse = true;
}
} catch (e) { /* 忽略:状态补齐失败不影响布局正确性 */ }
}
// ---------------------------------------------------------------------
// 1) 首屏折叠:菜单是异步渲染的,轮询等它就绪后再确认折叠(幂等)
// ---------------------------------------------------------------------
(function foldOnStart() {
var tries = 0;
var timer = setInterval(function () {
if (++tries > 40) { // 最多等约 6 秒,避免无限轮询
clearInterval(timer);
return;
}
if (!isMobile() || !menuRendered()) {
return;
}
if (isCollapsed()) { // 框架自己(resize 兼容逻辑)已完成折叠
clearInterval(timer);
patchCollapseState();
return;
}
if (toggleViaFramework()) { // 否则由我们走原生入口折叠
clearInterval(timer);
patchCollapseState();
}
}, 150);
})();
// ---------------------------------------------------------------------
// 2) 触屏补位:mini 模式下子菜单是「悬浮浮层」,靠 mouseenter/mouseleave 开关,
// 而触屏 tap 只可靠触发 mouseenter、mouseleave 常常不来,浮层就赖着不走。
// 这里在点菜单以外的地方时主动收起浮层。
// ---------------------------------------------------------------------
function closeFlyout() {
var list = document.querySelectorAll("#side .layui-nav-child.layui-nav-hover");
for (var i = 0; i < list.length; i++) {
list[i].classList.remove("layui-nav-hover");
list[i].style.left = "0px";
list[i].style.top = "0px";
}
}
document.addEventListener("touchstart", function (e) {
if (!isMobile()) {
return;
}
var t = e.target;
if (t && t.closest && t.closest("#side")) {
return; // 点在菜单里,交给框架自己处理
}
closeFlyout();
}, { passive: true });
// ---------------------------------------------------------------------
// 3) 移动端习惯:展开态下点了叶子菜单(要跳转的项)之后自动收起侧栏,
// 否则 220px 的侧栏会一直压在内容上。目录项(有子菜单)不收起。
// 注:a.site-demo-active 只挂在可跳转的叶子菜单上,目录项没有这个 class。
// ---------------------------------------------------------------------
document.addEventListener("click", function (e) {
if (!isMobile()) {
return;
}
var t = e.target;
if (!t || !t.closest) {
return;
}
if (!t.closest('#side a.site-demo-active')) {
return;
}
if (isCollapsed()) {
return;
}
setTimeout(function () {
if (!isCollapsed()) {
toggleViaFramework();
}
}, 250);
});
})();
-26
View File
@@ -1,26 +0,0 @@
<!-- 后台顶部导航条(顶栏) -->
<!-- 与 templates/system/index.html 主页的 .layui-header 部分等价;供所有子模块引用 -->
<div class="layui-header">
<div class="layui-logo">
<img class="logo">
<span class="title"></span>
</div>
<ul class="layui-nav layui-layout-left">
<li class="collapse layui-nav-item"><a href="#" class="layui-icon layui-icon-shrink-right"></a></li>
<li class="refresh layui-nav-item"><a href="#" class="layui-icon layui-icon-refresh-1" loading=600></a></li>
</ul>
<div id="control" class="layui-layout-control"></div>
<ul class="layui-nav layui-layout-right">
<li class="layui-nav-item layui-hide-xs"><a href="#" class="menuSearch layui-icon layui-icon-search"></a></li>
<li class="layui-nav-item layui-hide-xs message"></li>
<li class="layui-nav-item layui-hide-xs"><a href="#" class="fullScreen layui-icon layui-icon-screen-full"></a></li>
<li class="layui-nav-item user">
<a class="layui-icon layui-icon-username" href="javascript:;"></a>
<dl class="layui-nav-child">
<dd><a href="javascript:void(0);" user-menu-url="/system/user/center" user-menu-id="5555" user-menu-title="基本资料">基本资料</a></dd>
<dd><a href="javascript:void(0);" class="logout">注销登录</a></dd>
</dl>
</li>
<li class="layui-nav-item setting"><a href="#" class="layui-icon layui-icon-more-vertical"></a></li>
</ul>
</div>
-74
View File
@@ -1,74 +0,0 @@
<!-- 后台左侧菜单(侧栏) -->
<!-- 与 templates/system/index.html 主页的 .layui-side 部分等价;供所有子模块引用 -->
<div class="layui-side layui-bg-black">
<div class="layui-logo">
<img class="logo">
<span class="title"></span>
</div>
<div class="layui-side-scroll">
<div id="side"></div>
</div>
</div>
<!-- 依赖脚本 + 框架初始化 -->
<script src="{{ url_for('static', filename='system/component/layui/layui.js') }}"></script>
<script src="{{ url_for('static', filename='system/component/pear/pear.js') }}"></script>
<script>
layui.use(['admin', 'jquery', 'popup'], function () {
var admin = layui.admin;
var popup = layui.popup;
var $ = layui.jquery;
admin.setConfigurationPath("{{ url_for('system.rights.configs') }}");
admin._changeTheme = admin.changeTheme;
admin.changeTheme = function () {
admin._changeTheme();
const variableKey = "--global-primary-color";
const variableVal = localStorage.getItem("theme-color-color");
const iframes = document.querySelectorAll('iframe');
iframes.forEach(function (iframe) {
try {
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
iframeDocument.documentElement.style.setProperty(variableKey, variableVal);
} catch (e) {}
});
};
admin._switchTheme = admin.switchTheme;
admin.switchTheme = function (checked) {
admin.isdrak = checked;
admin._switchTheme(checked);
const iframes = document.querySelectorAll('iframe');
iframes.forEach(function (iframe) {
try {
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
if (checked === true || checked === "true") {
iframeDocument.body.classList.add("pear-admin-dark");
} else {
iframeDocument.body.classList.remove("pear-admin-dark");
}
} catch (e) {}
});
};
admin.render();
admin.logout(function () {
let loading = layer.load();
$.ajax({
url: '{{ url_for('system.passport.logout') }}',
dataType: 'json',
async: false,
type: 'post',
success: function (result) {
layer.close(loading);
if (result.success) {
popup.success(result.msg, function () { location.href = '/'; });
return true;
}
}
});
});
});
</script>
-34
View File
@@ -8,14 +8,6 @@
</head>
<!-- 结 构 代 码 -->
<body class="layui-layout-body pear-admin">
<!-- 首屏即折叠侧边栏:避免移动端加载期间 sidebar 展开遮挡内容(后续 admin.js 会再次同步状态) -->
<script>
(function () {
if (window.innerWidth <= 768) {
document.body.classList.add('pear-mini');
}
})();
</script>
<!-- 布 局 框 架 -->
<div class="layui-layout layui-layout-admin">
<!-- 顶 部 样 式 -->
@@ -153,32 +145,6 @@
// 渲染
admin.render();
// 移动端初始化:窗口较窄时默认折叠左侧菜单,避免内容被侧边栏挡住
(function () {
function foldSidebar() {
if ($(window).width() > 768) return;
var body = $('body');
var admin = $('.pear-admin');
// 直接加 class:不依赖 .collapse 按钮是否存在或是否响应点击
if (!admin.hasClass('pear-mini')) {
admin.addClass('pear-mini');
body.addClass('pear-mini');
}
// 同步 menu 实例状态,防止后续交互异常
if (window.PearAdmin && window.PearAdmin.instances && window.PearAdmin.instances.menu) {
try {
window.PearAdmin.instances.menu.collapse();
} catch (e) {}
window.PearAdmin.instances.menu.isCollapse = true;
}
}
// 立即 + 多次延迟:覆盖 admin.js 异步初始化和菜单渲染滞后
foldSidebar();
setTimeout(foldSidebar, 300);
setTimeout(foldSidebar, 800);
setTimeout(foldSidebar, 1500);
})();
// 注销
admin.logout(function () {
let loading = layer.load()