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:
@@ -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):
|
||||
|
||||
@@ -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"`。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "移动端侧栏增强",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "修复移动端(≤768px)后台左侧菜单错位与触屏子菜单浮层不消失的问题。不修改 framework 任何文件,通过 after_request 向后台主页注入脚本,折叠动作一律走框架原生 .collapse 入口,保证 body/#side/isCollapse 三处状态同步。"
|
||||
}
|
||||
@@ -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)")
|
||||
@@ -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 覆写为 undefined(falsy)。
|
||||
* 折叠后若 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);
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user