写在前面
手里攒了 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/ 子目录。
顺手在同一文件写下 define('MAC_THEME', $TMP_TEMPLATEDIR);,把「最终生效的模板名」变成一个全局常量,后面两处都要用到它。
2. 页面缓存隔离 —— All.php(读 + 写两处)
MacCMS 的整页缓存 key 原本不含模板名。这意味着 A 用 hl018、B 用默认模板访问同一页面时,后到的那个会读到前者的缓存——直接串页。
解决方式是在缓存 key 里拼上 MAC_THEME。要注意读取和写入两处都要改,只改一处等于没改。
3. 浮层注入点 —— 必须在 Cache::set() 之前
浮层 HTML 需要一个注入位置。我选了 All::label_fetch() —— 它是所有模板渲染的必经之路,而且这里已经在做「往 HTML 里注入 polyfill」的动作,加一个注入器顺理成章。
但位置很讲究:必须放在 Cache::set() 之前。因为页面缓存命中时,load_page_cache() 会直接 echo + die,根本不进渲染流程。如果注入放在缓存写入之后,那第一次访问看不到入口,第二次(命中缓存)才看得到——听起来能用,实则第一次访问的访客会以为没这功能。
这样设计的另一个好处是:浮层是自包含的(内联 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 逻辑:
五、把 36 个下载下来的模板全部接入
模板目录从原来的 5 个扩到 42 个。批量接入时有三个具体问题要处理:
| 问题 | 处理 |
|---|---|
| 目录名冲突 | 两个新模板的目录名都叫 conch,会覆盖站点原有模板 → 重命名为 conch_h1 / conch_h2 |
| 跨目录静态资源 | hl073 的模板引用了站点根下的 hltheme/ 目录 → 必须放到站点根,否则样式全丢 |
| 模板结构识别 | 浮层只认 template/<目录>/html/ 这个结构,扫描时按此判定有效模板 |
模板的展示名从每个目录的 info.ini 里读,读不到就退回目录名。清单扫描结果做了 10 分钟缓存,避免每次渲染都去遍历磁盘。
这里埋了个雷
「清单缓存 10 分钟」这个设计在下一节的下架功能里翻了车——后面会讲到怎么用「缓存 key 绑定配置哈希」解决。
六、PHP 8.1 兼容:整整修了约 480 处
装完模板一测,41 个里有十几个直接白屏。看日志全是同一个错:
原因是这些第三方模板写于 PHP 7 时代,用了一堆在 PHP 8 里已经变成致命错误的遗留写法。主要有三类:
| 类型 | 错误写法 | 修正 |
|---|---|---|
| 数组键裸名 | $arr[key] |
$arr['key'] |
| 模板条件里的裸常量 | {if condition="$x==green"} |
{if condition="$x=='green'"} |
| 函数参数裸常量 | date(Y)、in_array($a, arr) |
补引号 |
纯手工改 480 处不现实,写脚本批量正则替换。但一轮搞不定——修完第一轮报新的错,新错误暴露出更多变体,最后迭代了 4 轮:
每一轮改动前都整目录备份,一共留了 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 个:conch、default、hl018、hl045、hl061、hl073、naifei。
下架 ≠ 只是不显示
这一点很重要。如果下架只是「浮层里不列出」,那访客只要手工拼一个 URL 就能切过去。所以 apply() 里做了白名单校验:请求的模板必须存在于当前模板库清单中,否则直接拒绝、不写 Cookie。
实测手工拼 ?tpl=hl001、?tpl=xmy7、?tpl=conch_h1 等已下架模板全部被拦截,Cookie 未被改写。
改配置为什么要清缓存
浮层 HTML 是「渲染时注入、并随整页一起写进页面缓存」的。所以改完黑名单后,之前已经缓存过的页面里仍然留着旧浮层(接口返回的清单已经变了,但页面还是老的)——这个现象一开始很迷惑人。
顺带把之前那个「清单缓存 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 会话,模拟真实访客)。
十、文件清单与回滚
整个功能只动了 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-* 备份。要回滚的话:
想只关掉功能而不删代码,把配置里的 'enabled' => '0' 就行。
十一、这半天踩到的坑,总结成五条
- 改老系统前先摸清链路。 搞明白
Init.php → template_dir → All::label_fetch()这条链之后,就知道该在哪里下手,不用瞎试。 - PHP 8 会把手写习惯变成致命错误。 裸数组键、裸常量在 PHP 7 只是警告,PHP 8 直接 Fatal。批量修复要留备份、要迭代多轮。
- 改完 PHP 一定清编译缓存。 否则你会对着一个错误改半小时,而它其实早就修好了(
runtime/temp/*.php)。 - 注入点要放在缓存写入之前。 缓存命中时框架会直接输出并终止,放在后面的代码根本跑不到。
getBoundingClientRect()会给变换后的坐标。 元素带transform时不能直接拿它做拖拽计算,必须反解matrix()。
这套方案理论上适用于所有 MacCMS v10 站点——核心逻辑只依赖框架的两个固定入口(Init.php 和 All.php),跟你用哪个模板无关。需要回退时删掉 3 个文件、还原 2 个备份就干净了。
本站 MacCMS 站点:maccms.7998888.xyz | 技术问题欢迎在评论区交流。




暂无评论内容