给 MacCMS 站点做「前台访客一键换肤」:从 36 个模板到可拖动悬浮面板

写在前面

手里攒了 36 个从模板站下载的 MacCMS 主题,一直躺在硬盘里吃灰。某天突然想到:既然都下下来了,为什么不干脆让访客自己挑?点一下换个皮,不用我一个个做成演示站。

于是花了一上午,给站点做了一个前台访客一键换肤功能——右下角一个橙色悬浮球,点开是模板库,选中即全站生效,按钮还能拖着走。

过程比想象中曲折:踩了 PHP 8 的兼容坑、缓存注入点的坑、还有一个把拖动精度搞崩的 CSS transform 坑。这篇把完整实现过程记录下来,包括所有失败的尝试。

成果速览
站点模板总数 41 个(自 5 个扩到 41 个)· 模板库在售 7 个 · 黑名单隐藏 34 个 · 新增文件 3 个 / 改动 2 个 · 修复 PHP 8 不兼容写法 约 480 处 · 实现耗时 半天


一、需求:让访客自己挑模板

站点是 MacCMS v10 搭的影视资源站,跑在一台新加坡节点的服务器上,PHP 8.1 + MySQL,域名 maccms.7998888.xyz

模板来源是之前批量下载的一批第三方主题(hl001 ~ hl073 系列,共 36 个 zip)。本来的想法很朴素:全部装进去,然后让访客在前台自由切换。

MacCMS 官方的做法是「后台 → 模板管理 → 选定模板」,问题是这改的是全局配置 site.template_dir

  • 一次只能生效一个模板,改了对所有访客同时生效
  • 只切 PC 模板,手机模板不会跟着换
  • 切完还得手动清缓存,否则看到的还是老页面

所以我需要的是一套访客级的换肤:每个浏览器各自记住自己的选择,互不干扰。


二、动手前先把站点摸清楚

改任何老系统之前,先花二十分钟搞清楚它的运行链路。这一步省下来的时间,会在后面加倍还给你。

勘察项 结论
框架 MacCMS v10,底层 ThinkPHP 5.x
模块绑定 单模块绑定BIND_MODULE=index),因此前台 URL 不带 index
模板解析 application/common/behavior/Init.php 里把配置赋值给 $TMP_TEMPLATEDIR
渲染出口 All::label_fetch() 是唯一的「渲染 + 注入」汇聚点
门禁 站点开了「访问验证」,Base 控制器全部被拦
缓存 整页静态缓存,命中后直接 echo + die,不再走渲染

第一个坑:URL 里没有 index
第一次测试我按惯例请求 /index.php/index/theme/catalog,返回的却是访问验证页。排查半天才反应过来:站点是单模块绑定,前台路由没有模块名这一层。正确路径是 /index.php/theme/catalog


三、方案选型

三条路摆在面前,最终选了第三条:

方案 致命问题
改全局 template_dir 影响所有访客;手机模板不跟随
每种模板建一个演示子站 41 个模板 = 41 份站点,数据库和磁盘都吃不消
Cookie 覆盖模板目录 ✅ 采用:访客级隔离、PC/手机同步、可随时关停

核心思路就一句话:在框架读取模板目录配置的那一刻,用 Cookie 里的值把它覆盖掉。

剩下要解决的是三个衍生问题:缓存怎么办(不同模板的页面不能互相污染)、入口怎么注入(浮层从哪塞进页面)、门禁怎么绕(切换接口不能被访问验证拦住)。


四、核心实现:3 个新文件 + 2 处改动

1. 模板解析入口 —— Init.php

$TMP_TEMPLATEDIR 被赋值之前插入一段 Cookie 覆盖逻辑。这里做了三层防护:只在前台生效(ENTRANCE == 'index')、正则过滤非法字符防目录穿越、最后校验目录真实存在且含 html/ 子目录。

application/common/behavior/Init.php
// 前台访客换肤:cookie 指定模板(仅前台生效)
if (defined('ENTRANCE') && ENTRANCE == 'index' && !empty($_COOKIE['mac_theme'])) {
    $__mt = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $_COOKIE['mac_theme']);
    if ($__mt !== '' && is_dir(ROOT_PATH . 'template/' . $__mt . '/html')) {
        $config['site']['template_dir']     = $__mt;
        $config['site']['mob_template_dir'] = $__mt;   // 手机端同步
    }
}

顺手在同一文件写下 define('MAC_THEME', $TMP_TEMPLATEDIR);,把「最终生效的模板名」变成一个全局常量,后面两处都要用到它。

2. 页面缓存隔离 —— All.php(读 + 写两处)

MacCMS 的整页缓存 key 原本不含模板名。这意味着 A 用 hl018、B 用默认模板访问同一页面时,后到的那个会读到前者的缓存——直接串页。

解决方式是在缓存 key 里拼上 MAC_THEME。要注意读取和写入两处都要改,只改一处等于没改。

application/common/controller/All.php
$cach_name = $_SERVER['HTTP_HOST'] . '_' . MAC_MOB . '_'
           . (defined('MAC_THEME') ? MAC_THEME : '') . '_' . ...

3. 浮层注入点 —— 必须在 Cache::set() 之前

浮层 HTML 需要一个注入位置。我选了 All::label_fetch() —— 它是所有模板渲染的必经之路,而且这里已经在做「往 HTML 里注入 polyfill」的动作,加一个注入器顺理成章。

但位置很讲究:必须放在 Cache::set() 之前。因为页面缓存命中时,load_page_cache() 会直接 echo + die,根本不进渲染流程。如果注入放在缓存写入之后,那第一次访问看不到入口,第二次(命中缓存)才看得到——听起来能用,实则第一次访问的访客会以为没这功能。

注入逻辑
if (defined('ENTRANCE') && ENTRANCE == 'index') {
    $html = \app\common\util\ThemeSwitcher::inject($html);   // 在 Cache::set() 之前
}

这样设计的另一个好处是:浮层是自包含的(内联 CSS + 原生 JS,不依赖 jQuery、layui 或任何图标库),所以它对全部 41 个模板通用,不需要改动任何一个模板文件

4. 切换接口 —— Theme.php 继承 All 而非 Base

切换接口要解决的问题是「门禁」。站点开了访问验证,所有继承 Base 的控制器都会被拦在验证页前面——但访客点「切换模板」时不该再跳一次验证(人家刚验证完)。

做法是让 Theme 控制器直接继承 All(渲染基类),绕过门禁。apply() 里做了三件安全事:

  • 参数清洗tpl 参数用 preg_replace('/[^A-Za-z0-9_\-]/', '', ...) 洗一遍
  • 白名单校验:必须命中模板库清单(见下文下架机制)
  • 回跳校验:Referer 必须是同站,防止被做成开放重定向跳板

写入 Cookie mac_theme(有效期一年);传空值则清除,回到系统默认模板。

5. 配置开关 —— theme_switch.php

新增一个独立配置文件,把「开关」和「黑名单」从代码里抽出来,改配置不需要碰 PHP 逻辑:

application/extra/theme_switch.php
return [
    'enabled'   => '1',            // 总开关,改成 0 即可下线整个功能
    'blacklist' => [ ... ],        // 不在模板库展示的模板目录名
];

五、把 36 个下载下来的模板全部接入

模板目录从原来的 5 个扩到 42 个。批量接入时有三个具体问题要处理:

问题 处理
目录名冲突 两个新模板的目录名都叫 conch,会覆盖站点原有模板 → 重命名为 conch_h1 / conch_h2
跨目录静态资源 hl073 的模板引用了站点根下的 hltheme/ 目录 → 必须放到站点根,否则样式全丢
模板结构识别 浮层只认 template/<目录>/html/ 这个结构,扫描时按此判定有效模板

模板的展示名从每个目录的 info.ini 里读,读不到就退回目录名。清单扫描结果做了 10 分钟缓存,避免每次渲染都去遍历磁盘。

这里埋了个雷
「清单缓存 10 分钟」这个设计在下一节的下架功能里翻了车——后面会讲到怎么用「缓存 key 绑定配置哈希」解决。


六、PHP 8.1 兼容:整整修了约 480 处

装完模板一测,41 个里有十几个直接白屏。看日志全是同一个错:

PHP Fatal error
Undefined constant "green"

原因是这些第三方模板写于 PHP 7 时代,用了一堆在 PHP 8 里已经变成致命错误的遗留写法。主要有三类:

类型 错误写法 修正
数组键裸名 $arr[key] $arr['key']
模板条件里的裸常量 {if condition="$x==green"} {if condition="$x=='green'"}
函数参数裸常量 date(Y)in_array($a, arr) 补引号

纯手工改 480 处不现实,写脚本批量正则替换。但一轮搞不定——修完第一轮报新的错,新错误暴露出更多变体,最后迭代了 4 轮:

修复流程
第 1 轮 127 处:数组键裸名(正则匹配 $var[裸名]第 2 轮 347 处:date(Y) 这类函数参数 + == 裸常量
第 3 轮  若干处:in_array(变量, 裸名) 等漏网之鱼
第 4 轮     收尾:加空白感知的正则,覆盖 ==green / == levels 等带空格写法

每一轮改动前都整目录备份,一共留了 5 个批次,方便随时回退。最后 12 个原本报错的模板恢复正常渲染。

最浪费时间的一个坑
改完 PHP 文件、刷新页面,看到的还是老错误。以为正则没生效,反复改了好几遍。真相是 ThinkPHP 把编译后的模板缓存在 runtime/temp/*.php。改完源码必须清一次编译缓存,否则永远在读旧的。
后来我把这条加进每一次测试的前置步骤:
sudo rm -f runtime/temp/*.php

另外还有 4 个模板(hl054 ~ hl057)比较特殊:能正常返回 200,但输出只有约 768 字节的空壳。原因是它们依赖各自主题后台(template/<dir>/admin/)保存的配置数组,没进过后台保存一次就没有内容——属于主题自身的设计,不是兼容问题。


七、模板库下架机制

模板太多了(41 个),浮层里翻起来费劲,有些质量也不满意。所以做了一个下架机制:只从模板库列表里移除,不删磁盘文件,随时可以重新上架。

最终下架了 34 个(站长指定 28 个 + 不兼容 6 个),模板库保留 7 个:conchdefaulthl018hl045hl061hl073naifei

下架 ≠ 只是不显示

这一点很重要。如果下架只是「浮层里不列出」,那访客只要手工拼一个 URL 就能切过去。所以 apply() 里做了白名单校验:请求的模板必须存在于当前模板库清单中,否则直接拒绝、不写 Cookie。

实测手工拼 ?tpl=hl001?tpl=xmy7?tpl=conch_h1 等已下架模板全部被拦截,Cookie 未被改写。

改配置为什么要清缓存

浮层 HTML 是「渲染时注入、并随整页一起写进页面缓存」的。所以改完黑名单后,之前已经缓存过的页面里仍然留着旧浮层(接口返回的清单已经变了,但页面还是老的)——这个现象一开始很迷惑人。

改完黑名单后的固定动作
sudo rm -rf /www/wwwroot/maccms/runtime/cache/* /www/wwwroot/maccms/runtime/temp/*.php

顺带把之前那个「清单缓存 10 分钟」的雷也排了:给 ThemeSwitcher 加了 cacheKey(),把黑名单的 MD5 拼进缓存 key —— 配置一改,key 就变,立即生效,不用等 10 分钟过期,也不用担心读到脏清单。


八、把按钮做漂亮,并且能拖着走

功能跑通之后开始抠外观。目标是:在任意一个模板里都不违和,且不和主题既有前端库打架。

所以定了一条硬规则:浮层完全自包含——图标用内联 SVG 画,动画用 CSS 写,交互用原生 Pointer Events 实现,零外部依赖。这样不管模板用的是 jQuery、layui 还是 Vue,都不会冲突。

部件 设计
悬浮按钮 54px 圆形,三段橙色渐变 + 内高光 + 呼吸脉冲环;悬停上浮放大并弹出「换模板」文字气泡
面板 毛玻璃卡片(backdrop-filter: blur(18px))、18px 圆角、顶部 3px 渐变条、双层投影
列表项 圆点指示器 + 名称/目录双行;当前项橙色渐变底 + 「当前」胶囊角标
动画 展开用 opacity + scale(.97) + translateY(10px),向上弹出

拖动是怎么做的

用原生 Pointer Events 统一处理鼠标与触屏:pointerdown / pointermove / pointerup + setPointerCapture,一套代码两种设备都能用;被拖元素加 touch-action:none,防止手机上拖动时页面跟着滚。

细节 实现
点击/拖动区分 位移 > 3px 才算拖动;结果写进标记变量,click 处理器据此决定是否吞掉这次点击 —— 拖完松手不会顺手把面板打开
位置持久化 松手后把坐标存进 localStorage,刷新、翻页后仍在原处
视口钳制 拖动和窗口 resize 时都调用 pin(),限制在 6px 边距内,不会拖出屏幕找不回来
面板跟随 打开时以按钮为基准,优先放上方并右对齐;上方空间不够自动改放下方。按钮被拖动后丢弃面板旧坐标重新贴合
位置复位 双击面板标题栏 → 清空本地记录,按钮回右下角

最隐蔽的一个坑:transform 污染了拖动坐标
按钮悬停时带 transform: translateY(-2px) scale(1.06)。我一开始直接拿 getBoundingClientRect() 当拖动起点 —— 而这个方法返回的是变换后的视觉坐标,包含了缩放和位移。
后果是:每拖一次就偏 2~4px,反复拖动会持续漂移(实测偏差 −2px)。鼠标动 121px,按钮实际只走 119px,越拖越歪。
修法是把变换反解回去:从 getComputedStyle 里读出 matrix()transform-origin,算出真实布局坐标。修完后复测:鼠标位移 +121 / +91,元素位移也精确是 +121 / +91,零漂移

另一个小坑:面板隐藏原本用 display:none,但这样 offsetWidth 为 0,无法计算「放上面还是下面」。改成 opacity / visibility 隐藏,元素仍可测量。


九、端到端验证

功能做完没有直接收工,写了两套自动化脚本跑真实 HTTP 全链路验证(带 Cookie 会话,模拟真实访客)。

换肤功能回归测试
# 1) 通过访问验证,拿到会话
POST 访问密码            -> 200 {"code":1,"msg":"ok"}

# 2) 首页渲染 + 浮层检查
首页                     -> 200  123 KB
  浮层模板项数           : 7  (期望 7)  ✅
  隐藏提示               : 34 个          ✅
  下架模板泄漏           : 无              ✅

# 3) 模拟点击切换 4 个不同模板
hl018 / hl045 / hl073 / naifei
  → apply 200 / Cookie 写入 / 首页 data-cur 一致 / 当前项高亮  (4/4 通过)

# 4) 手工拼 URL 尝试切到已下架模板
hl001 / xmy7 / hl071 / conch_h1 / conch_v2
  → 全部被白名单拦截,Cookie 未被改写        ✅

# 5) 恢复默认
Cookie 已清除,当前模板回到 conch          ✅

结论: PASS
拖动交互测试
初始位置          : 右下角 [1370, 830]
拖动后位置        : [968, 604]              位移生效         ✅
  拖动时未误触面板                            ✅
  本地记录        : {"fab":[970,610]}         ✅
  位移精度 (期望 +121,+91) : (121, 91)       无漂移           ✅
刷新后位置保持    : [1089, 693]              面板跟随按钮      ✅
双击标题栏复位    : [1370, 830] 回右下        本地记录已清      ✅
关键字过滤 "hl0"  : 命中 4 项                 ✅

十、文件清单与回滚

整个功能只动了 5 个文件,其中 3 个是新增:

路径 说明
application/common/util/ThemeSwitcher.php 新增 模板清单扫描 + 浮层渲染/注入 + 按钮拖拽
application/index/controller/Theme.php 新增 切换接口 apply / catalog
application/extra/theme_switch.php 新增 开关与黑名单配置
application/common/behavior/Init.php 改动:Cookie 覆盖模板目录
application/common/controller/All.php 改动:缓存 key 隔离 + 浮层注入

两处改动都留了带时间戳的 .bak-* 备份。要回滚的话:

一键回滚
cd /www/wwwroot/maccms/application

# 1) 还原两处核心改动
sudo cp common/behavior/Init.php.bak-themeswitch-* common/behavior/Init.php
sudo cp common/controller/All.php.bak-themeswitch-* common/controller/All.php

# 2) 移除新增文件
sudo rm -f common/util/ThemeSwitcher.php index/controller/Theme.php extra/theme_switch.php

# 3) 清缓存
sudo rm -rf runtime/temp/*.php runtime/cache/*

想只关掉功能而不删代码,把配置里的 'enabled' => '0' 就行。


十一、这半天踩到的坑,总结成五条

  1. 改老系统前先摸清链路。 搞明白 Init.php → template_dir → All::label_fetch() 这条链之后,就知道该在哪里下手,不用瞎试。
  2. PHP 8 会把手写习惯变成致命错误。 裸数组键、裸常量在 PHP 7 只是警告,PHP 8 直接 Fatal。批量修复要留备份、要迭代多轮。
  3. 改完 PHP 一定清编译缓存。 否则你会对着一个错误改半小时,而它其实早就修好了(runtime/temp/*.php)。
  4. 注入点要放在缓存写入之前。 缓存命中时框架会直接输出并终止,放在后面的代码根本跑不到。
  5. getBoundingClientRect() 会给变换后的坐标。 元素带 transform 时不能直接拿它做拖拽计算,必须反解 matrix()

这套方案理论上适用于所有 MacCMS v10 站点——核心逻辑只依赖框架的两个固定入口(Init.phpAll.php),跟你用哪个模板无关。需要回退时删掉 3 个文件、还原 2 个备份就干净了。

本站 MacCMS 站点:maccms.7998888.xyz | 技术问题欢迎在评论区交流。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容