diff --git a/applications/config.py b/applications/config.py index 28e824e..3afb67f 100644 --- a/applications/config.py +++ b/applications/config.py @@ -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): diff --git a/plugins/mobileUI/README.md b/plugins/mobileUI/README.md new file mode 100644 index 0000000..1ad5110 --- /dev/null +++ b/plugins/mobileUI/README.md @@ -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"`。 diff --git a/plugins/mobileUI/__init__.json b/plugins/mobileUI/__init__.json new file mode 100644 index 0000000..1c3586f --- /dev/null +++ b/plugins/mobileUI/__init__.json @@ -0,0 +1,5 @@ +{ + "plugin_name": "移动端侧栏增强", + "plugin_version": "1.0.0", + "plugin_description": "修复移动端(≤768px)后台左侧菜单错位与触屏子菜单浮层不消失的问题。不修改 framework 任何文件,通过 after_request 向后台主页注入脚本,折叠动作一律走框架原生 .collapse 入口,保证 body/#side/isCollapse 三处状态同步。" +} diff --git a/plugins/mobileUI/__init__.py b/plugins/mobileUI/__init__.py new file mode 100644 index 0000000..e332dc2 --- /dev/null +++ b/plugins/mobileUI/__init__.py @@ -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'' + 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"", script_tag + b"")) + except Exception: # 注入失败绝不能影响正常业务响应 + app.logger.exception("mobileUI: inject failed") + return response + + print(" * mobileUI: registered after_request hook (inject mobile.js into admin index)") diff --git a/plugins/mobileUI/static/mobile.js b/plugins/mobileUI/static/mobile.js new file mode 100644 index 0000000..4b82a49 --- /dev/null +++ b/plugins/mobileUI/static/mobile.js @@ -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); + }); +})(); diff --git a/templates/system/common/head.html b/templates/system/common/head.html deleted file mode 100644 index 2922e1d..0000000 --- a/templates/system/common/head.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
- - -
- -
diff --git a/templates/system/common/side.html b/templates/system/common/side.html deleted file mode 100644 index 481fd88..0000000 --- a/templates/system/common/side.html +++ /dev/null @@ -1,74 +0,0 @@ - - -
- -
-
-
-
- - - - - diff --git a/templates/system/index.html b/templates/system/index.html index 22d90dd..ee0f8b4 100644 --- a/templates/system/index.html +++ b/templates/system/index.html @@ -8,14 +8,6 @@ - -
@@ -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()