rd`)
date: 2026-09-30
category: 插件开发
source: WWW/docs/外链跳转弹窗插件开发计划_2026-09-30.md
外链跳转弹窗插件开发计划(external_link_guard)
编制:2026-09-30 | 状态:📋 计划待拍板 —— 未动手,零代码改动
一句话:前台点击站外链接 → 弹「即将离开本站」确认框 → 用户确认后跳转;纯客户端事件委托拦截,零网络请求、零卡顿风险。
计划速览
| 项 | 结论 |
|---|---|
| 形态 | 独立插件目录,不改核心(零核心 diff) |
| 技术路线 | 客户端 JS 拦截(layout_body_end 直出空壳 DOM + layout_head_end 引资源) |
| 为什么不是服务端 | 43 个钩子全表没有任何「内容输出后重写 HTML」的钩子(§2.1 实测)→ 不改核心就做不到 |
| 外链怎么判 | a.host ↔ location.host 比对。⚠️ 不能用 target="_blank" —— 核心给所有 <a> 都加了它(§2.2) |
| UI 一致性 | 复用 notice_center 的弹窗范式(遮罩 + 居中卡片 + z-index:100010)+ 核心 .mn-btn / --mn-* 变量 |
| 性能 | 服务端零查询(空壳 DOM + 静态配置);客户端仅一次 document 级事件委托 |
| 待拍板 | 6 个决策点 → §四 |
一、结论摘要
1.1 做什么
| 能力 | 说明 |
|---|---|
| 外链拦截 | 前台任意页面,点击站外链接时不立即跳转,先弹确认框 |
| 确认框 | 遮罩 + 居中卡片:「即将离开本站」+ 目标域名展示 + 「继续访问」/「留在本站」两个按钮 |
| 可配置白名单 | 站长可配置「不拦截的域名」(友链、自家附属站点) |
| 总开关 | 后台可一键关闭(关闭时前台零注入) |
| 后台配置页 | 与现有插件后台同款(admin-container 三件套 + 左侧导航高亮 plugins) |
1.2 与现有资产的边界(先划清,避免重叠)
| 资产 | 现状 | 与本插件的关系 |
|---|---|---|
核心 Content::formatPostContent() | Content.php:123-126 已给所有 <a> 加 rel="noopener noreferrer nofollow" + target="_blank" | 不改它。本插件只加「点击确认」,rel/target 保持核心口径 |
friend_links 插件 | 友链区块,输出外链 <a> | 不替代。友链域名可加进白名单,两者共存 |
seo 插件 | SEO 相关(含 rel 处理可能) | 不重叠。本插件不动 rel,只做点击拦截 |
notice_center 插件 | 全站公告弹窗(layout_body_end 直出) | 同范式、不同用途。本插件复用其弹窗 CSS 结构与 z-index 口径,但互不干扰(各自独立根容器 + 各自关闭逻辑) |
⚠️ 与
notice_center的共存细节:两者都在layout_body_end直出 DOM、都用z-index:100010。同 z-index 下后出现的 DOM 覆盖前者。对策:本插件根容器 z-index 取 100020(高于
notice_center的 100010),保证「公告弹窗 + 外链确认框」同时出现时确认框在最上层(详见 §3.5)。
1.3 不做什么
- ❌ 不改核心(不新增钩子、不动
Content.php) - ❌ 不做服务端 302 中转页(那是
§11.30的场景,用于点击计数,本插件不需要计数) - ❌ 不做点击统计 / 埋点(避免 §11.31 的「每请求写库」性能坑)
- ❌ 不做「外链在编辑器预览区也拦截」(编辑器预览是后台页面,已整体排除)
- ❌ 不做 A/B、不做多语言文案以外的个性化
1.4 需你拍板 → §四(6 个点)
二、现状核查(实测,非推断)
2.1 ★ 钩子层:43 个钩子没有「内容重写」能力 → 服务端方案出局
$ grep -nE "^#{2,3} 9\." docs/插件开发规范.md
630:### 9.0 触发机制(实现细节)
667:### 9.1 核心钩子全表(43 个,按生命周期排序)
802:### 9.2 已废弃 / 陷阱钩子(勿使用)逐组核对 §9.1 全表(docs/插件开发规范.md:667-798):
| 组 | 钩子 | 能否用来「重写正文里的外链」 |
|---|---|---|
| A 应用与路由(#1~#5) | init_after / route_register / route_before_dispatch / route_after_dispatch / admin_route_register | ❌ 都不接触渲染产物 |
| B 视图渲染(#6~#13、#43) | controller_view_before / layout_head_end / 各导航钩子 / layout_body_end | ⚠️ controller_view_before(#6)只能改 $data(渲染前的数据),改不到渲染后的 HTML;其余是锚点插入,不能改写已输出内容 |
| C 内容展示(#17~#25) | announcements_display / thread_redpacket_area / post_operation_after … | ❌ 全是往指定位置插 DOM,不是过滤器 |
| D 写操作后置(#26~#38) | post_create_after / vote_after … | ❌ 写库后事件,与渲染无关 |
| F/G(#41、#42) | thread_show_top / right_sidebar_user_actions_above | ❌ 锚点插入 |
结论(硬结论):
核心没有提供任何「正文 HTML 输出后重写」的钩子(无 filter / 无 output buffer 回调)。
要做服务端重写,只有两条路:
① 改核心
Content.php(用户明确「形态:插件」→ 否决);② 输出缓冲(
ob_start+ 回调替换)—— ⚠️ 核心没有暴露任何ob_start钩子,插件若自己在
init_after里ob_start(),会与核心的PageCache(app/Helpers/PageCache.php)和
header_register_callback(soundplayer用过)互相打架,风险不可控 → 否决。→ 唯一可行路线 = 客户端 JS 拦截(在浏览器里拦
click事件)。
2.2 ★ 核心已给所有 <a> 加 target="_blank" + rel → 不能靠 target 判外链
app/Helpers/Content.php:123-126(DOM 净化后统一加):
if ($tag === 'a') {
$node->setAttribute('rel', 'noopener noreferrer nofollow');
$node->setAttribute('target', '_blank');
}app/Helpers/Content.php:577-584(纯文本自动链接):
return '' . $safe . '';关键推论(本方案的核心技术点):
| 直觉做法 | 为什么不行 |
|---|---|
「target="_blank" 的就是外链」 | ❌ 核心给站内链接也加了 target="_blank" → 会误拦站内链接 |
「rel 里含 nofollow 的就是外链」 | ❌ 同上,所有 <a> 都有 |
| 「靠 class 区分」 | ❌ 核心没加任何 class(Content.php:123-126 只加两个属性) |
→ 唯一可靠判据 = 比对 host:new URL(a.href).host !== location.host。
附带发现(安全相关):Content.php:116 的协议白名单是
if (!\preg_match('/^(https?:\/\/|mailto:|tel:|\/|#)/i', $value)) { $removeAttrs[] = $name; continue; }其中 \/ 会放行协议相对 URL //evil.com(Content.php:149 自己都把它当外链:#^(?:https?:)?//#i)。
本插件的 host 比对天然会把它判为外链(URL('//evil.com').host = 'evil.com' ≠ 本站)→ 属于顺带加固。
2.3 弹窗 UI 基线:notice_center 全套可复用(实测)
plugins/notice_center/ 是同一天落地的同类弹窗,其结构可直接复用:
| 层 | 文件 | 可复用内容 |
|---|---|---|
| 资源注入 | hook/layout_head_end.php | shouldInject() 门控 → 只输出 <link> + <script defer>;?v=filemtime() 破缓存(asset() 走 immutable,URL 变化是唯一破缓存手段) |
| DOM 直出 | hook/layout_body_end.php | 一行 echo Plugin::renderPopup(); |
| 弹窗标记 | views/popup.php | <div class="ntc-root" hidden> + .ntc-mask + .ntc-dialog[role=dialog][aria-modal=true] + .ntc-close + .ntc-actions |
| 样式 | assets/style.css(234 行) | position:fixed 全屏 + z-index:100010 + --mn-comp-overlay-bg 遮罩 + --mn-bg-card 卡片 + border-radius:14px + --mn-shadow-float + prefers-reduced-motion + max-width:480px 断点 |
| 脚本 | assets/script.js(257 行) | IIFE + 'use strict' + root.hidden 起步 + 事件委托 + Esc 关闭 + 焦点陷阱(Tab 循环) + lastFocus 还原 + try/catch 包 localStorage |
plugins/notice_center/assets/style.css 头部注释里那三条硬约束同样适用于本插件:
/* ⚠️ 只用核心**已定义**的 `--mn-*` 变量
* ⚠️ 隐藏工具类一律 `display: none !important`:插件 CSS 在 `main.php:40` 注入,
* **早于**核心 `editor.css` → 同权重核心后胜;不给 `!important` 会被静默压掉。
* ⚠️ z-index 取 100010 —— 核心浮层天花板实测为 100005(`.image-lightbox` = 99999)。
*/2.4 核心没有弹窗组件(实测)
$ grep -nE "^\.mn-(btn|panel|modal|dialog|overlay)\b" assets/css/modern.css
522:.mn-panel ← 有
531:.mn-panel-title ← 有
1067:.mn-btn ← 有
1089:.mn-btn-primary ← 有
1100:.mn-btn-sm ← 有
2116:.mn-btn-block ← 有
$ grep -cE "mn-modal|mn-dialog" assets/css/modern.css
0 ← 弹窗类**不存在**$ ls -1 assets/js/
auth.js editor.js lightbox.js marked.min.js pow.js thread.js turndown*.js
↑ 核心**没有**通用 modal / confirm 工具→ 弹窗容器必须自建;但按钮 / 面板类可复用(这正是「与现有 UI 一致」的落点,见 §3.5)。
2.5 z-index 天花板 = 100005(沿用 notice_center 实测)
docs/全站公告弹窗插件开发方案_2026-09-30.md:114-118 实测:
assets/css/*.css的 z-index 分布:9999 ×1、99999 ×2、100000 ×4、100005 ×1→ 弹窗取 100010(必须高于全部核心浮层,否则被灯箱/顶栏盖住)。
本插件取 100020(比 notice_center 高一级,见 §1.2)。
2.6 正文容器 = .post-text-content(决定「拦截范围」选项)
| 页面 | 容器 | 位置 |
|---|---|---|
| 帖子详情 | <div class="post-text-content thread-body …"> | app/Views/thread/show.php:167(正文 $renderedContent 在 :170 输出) |
| 博客详情 | <div class="post-text-content mn-fs-15 mn-lh-18 post-text-color"> | app/Views/blog/detail.php:50(正文在 :53) |
| 核心样式 | .post-text-content { line-height: 1.8; color: var(--mn-content-text); font-size: 15px; overflow-x: auto; } | assets/css/modern.css:1656 |
→ 若决策 1 选「仅正文内拦截」,选择器就是 .post-text-content(核心类,稳定,非插件自造)。
2.7 可复用资产(省事清单)
| 资产 | 位置 | 用途 |
|---|---|---|
| 弹窗完整实现 | plugins/notice_center/{hook,views,assets}/ | 结构 / CSS / JS 三重参照(本插件最主要的模板) |
| 后台设置存取 | plugins/notice_center/Plugin.php:230-247 | Settings::get() / Settings::update() + 表单哨兵防「字段缺失静默写最小值」 |
| 后台 CRUD + PRG 范式 | plugins/notice_center/AdminController.php:26-50 | 继承 Admin\BaseController + flash 读后即焚 + __nav_active => 'plugins' |
| 后台路由注册 | plugins/notice_center/hook/admin_route_register.php | ⚠️ 不写 /admin 前缀(核心已 group('/admin'),写了 404 —— §11.22) |
class_alias 范式 | plugins/notice_center/Plugin.php:892-894 | 目录名含下划线必须加(见 §3.2) |
| 建表 marker 门控 | plugins/soundplayer/Plugin.php ensureSchemaOnce() | 若本插件需要表(本方案不需要,见 §3.10) |
| CLI 引导范式 | cli/*.php + _guard.php | 探针脚本 |
2.8 硬约束(实测,直接决定设计)
- 零内联(§7.3):禁内联
<style>/<script>块、style=""属性、on事件属性 → 全部收敛到assets/,传参走data-。
> ⚠️ docs/插件审计报告.md:130 已记录过一次同类事故:「内联事件属性违反零内联红线,且受 CSP script-src 策略限制时确认弹窗会失效」—— 本插件是整个功能就是弹窗,踩这条 = 功能直接不可用。
- 渲染钩子里
$this不可用(§11.24):layout_body_end/layout_head_end运行在闭包作用域 → 一律htmlspecialchars($s, ENT_QUOTES, 'UTF-8')+I18n::get()。 layout_body_end无参数(§9.1 #43)→ 判「是否后台页」只能靠请求路径/admin前缀(notice_center的Plugin::isBackend('')已实现此逻辑,可直接抄)。layout_body_end全站触发(含后台) → 必须显式排除后台,否则后台也会注入弹窗。- 改
layout_head_end/layout_body_end后必须PageCache::invalidate()(§11.5 第 31 条)—— 后台保存配置后触发。 - CSP(
app/Core/Controller.php:80):img-src 'self' data: https:→ 本插件弹窗不放图,无影响。 - Service Worker(§11.5 第 35 条):同源 GET 非导航一律 Cache-First → 本插件纯客户端、零请求,不受影响。
- 后台视图容器三件套(§11.3 第 22 条):
.admin-container+admin/_sidebar+<main class="admin-content">,弹层必须在<main>内。 - 开关字段必须用表单哨兵(§11.32 / §11.18):不能用
(int)($_POST[$k] ?? 0)。 - 目录名含下划线 → 必须
class_alias(§4.1 / §11.12)。
三、技术设计
3.1 ★ 方案选型
| 方案 | 做法 | 评价 |
|---|---|---|
| ① 服务端重写正文 | 钩子拿到渲染后 HTML,把 <a href="http://外站"> 改写成中转链接 | ❌ 不可行:无此钩子(§2.1);要做得改核心或自建 ob_start(与 PageCache 打架) |
| ② 服务端中转页 | 外链改写成 /go?url=…,中转页 302 | ❌ 需服务端重写正文(同 ① 的前置);且引入开放重定向风险(§11.30);改变 URL 结构,分享出去的链接不再是原文 |
| ③ 客户端 JS 拦截(本方案) | layout_body_end 直出空壳弹窗 + document 级事件委托拦 click | ✅ 零核心改动、零额外请求、零服务端查询;不改 HTML 结构(SEO 与分享不受影响) |
选 ③ 的决定性理由:
- 唯一不改核心就能落地**的路线(① ② 都卡在「没有内容重写钩子」);
- 零卡顿风险 —— 这是上一次
soundplayer悬浮窗被砍的原因。本插件:服务端不查库(空壳 DOM)、客户端只挂一个
document监听器、无轮询、无音频解码、无定时器。开销 ≈ 一次
addEventListener,可忽略。
③ 的固有代价(如实列出):
| 代价 | 说明 | 对策 |
|---|---|---|
| 依赖 JS | 用户禁用 JS → 弹窗不出现,外链直接跳转(= 无插件行为,不产生 bug) | 可接受(降级安全) |
| 只能拦「点击」 | 右键「在新标签打开」/ 拖拽 / 中键点击不走 click 事件 | 见决策 4;可额外监听 auxclick 覆盖中键 |
| 拦不到 JS 动态跳转 | location.href = …(如某些分享按钮) | 见 §6.8 风险清单 |
| 首屏脚本 | script.js 需下载(defer,不阻塞渲染) | 体积目标 < 4 KB |
3.2 目录结构
plugins/external_link_guard/
├── plugin.json # 清单(activated:false 起步,待你确认后启用)
├── Plugin.php # 主类:设置读写 / 渲染 / 判定
├── AdminController.php # 后台配置页(继承 Admin\BaseController)
├── hook/
│ ├── layout_head_end.php # </head> 前:注入 CSS/JS(带 shouldInject 门控)
│ └── layout_body_end.php # </body> 前:直出弹窗空壳 DOM
├── views/
│ └── admin.php # 后台配置页(容器三件套)
├── assets/
│ ├── style.css # 弹窗样式(复用 notice_center 范式)
│ └── script.js # 事件委托拦截 + 弹窗控制
├── lang/
│ ├── zh.php / zh_tw.php / en.php # 三语(键集合零差异 + 键序一致)
└── 说明.md # 插件文档(仓库约定,每个插件根目录一份)命名空间 ↔ 目录名(⚠️ 最易踩的坑):
| 项 | 值 | 校验 |
|---|---|---|
| 目录名 | external_link_guard | — |
| 命名空间 | Plugin\ExternalLinkGuard | camelToSnake('ExternalLinkGuard') = external_link_guard ✅ 逐字节等于目录名 |
| 自动加载 | app/Core/Autoloader.php:45-49 第 2 条候选路径命中 | external_link_guard/Plugin.php |
class_alias | 必须加 | 目录名含 _ → 核心生命周期按目录名硬拼 \Plugin\external_link_guard\Plugin(app/Helpers/Plugin.php:699)→ 与真实命名空间不同 → class_exists() 恒 false → 卸载留孤儿 |
// Plugin.php 末尾(照抄 notice_center/Plugin.php:892-894)
if (!\class_exists('Plugin\\external_link_guard\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\external_link_guard\\Plugin', false);
}⚠️
class_alias之外还有一层:§11.34(2026-09-30 新增)—— 命名空间首段必须逐字节等于目录名。上面「目录名
external_link_guard+ 命名空间ExternalLinkGuard」的组合满足该条(靠camelToSnake映射)。反面教材:
Plugin\AdSlot→camelToSnake得ad_slot≠adslot→ Windows 假通过、Linux 500。
3.3 ★ 外链判定规则
核心函数(客户端):
/* 判定一个 是否「站外链接」。返回 true = 需要弹窗确认。 */
function isExternalLink(a) {
var raw = a.getAttribute('href') || '';
if (raw === '' || raw.charAt(0) === '#') { // ① 锚点 / 空
return false;
}
if (/^(javascript|data|vbscript):/i.test(raw)) { // ② 伪协议(核心已拦,纵深防御)
return false;
}
var url;
try {
url = new URL(a.href, location.href); // a.href 已是解析后的绝对 URL
} catch (e) {
return false; // 解析失败 → 放行(不阻断用户)
}
if (url.protocol === 'mailto:' || url.protocol === 'tel:') {
return false; // ③ 邮件 / 电话 → 见决策 4
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false; // ④ 只拦 http(s)
}
if (url.host === location.host) { // ⑤ 同域豁免(host 含端口,比 hostname 精确)
return false;
}
if (isWhitelisted(url.hostname)) { // ⑥ 站长白名单
return false;
}
return true;
}豁免清单(逐条说明):
| # | 豁免对象 | 判据 | 理由 |
|---|---|---|---|
| ① | 同页锚点 #xxx | href 以 # 开头 | 不离开页面 |
| ② | javascript: / data: / vbscript: | 协议正则 | 核心已在净化层拦截(Content.php:116),此处为纵深防御 |
| ③ | mailto: / tel: | 协议 | 见决策 4(默认放行:拦它体验差,且「发邮件」通常不是「流失」) |
| ④ | 非 http(s) 协议 | 协议白名单 | 避免误拦自定义协议 |
| ⑤ | 同域链接 | url.host === location.host | ⚠️ 最关键:核心给所有 <a> 加了 target="_blank",站内链接必须靠 host 豁免,否则会误拦 |
| ⑥ | 白名单域名 | Settings 配置 | 友链、自家附属站点 |
| ⑦ | 下载链接 | a.hasAttribute('download') | 见决策 5(下载不是「离开本站看内容」) |
白名单匹配规则(建议):
/* 支持精确域名与一级子域通配:example.com 匹配 example.com / www.example.com */
function isWhitelisted(hostname) {
hostname = hostname.toLowerCase();
for (var i = 0; i < WHITELIST.length; i++) {
var d = WHITELIST[i];
if (hostname === d || hostname.slice(-(d.length + 1)) === '.' + d) {
return true;
}
}
return false;
}⚠️ 不要用
endsWith(d)裸匹配 ——evil-example.com.endsWith(example.com) 会误放行。必须带
.前缀('.' + d)才能保证是子域而非「后缀相同的另一个域」。
子域问题(决策 1 的延伸):本站若部署在 www.example.com,而站内有指向 bbs.example.com 的链接,
按 ⑤ 会被判为外链。对策:白名单里加 example.com 即同时覆盖两个(靠上面的子域通配)。
3.4 DOM 注入链路
沿用 notice_center 的双钩子范式(plugins/notice_center/hook/*.php):
main.php:40 layout_head_end ──┐
├─→ Plugin::shouldInject() ← 总开关 + 非后台 判定
└─→ + 为什么弹窗 DOM 是「空壳」(本插件比 notice_center 更简单):
| 维度 | `notice_center` | 本插件 |
|---|---|---|
| DOM 内容 | 逐条公告(含富文本、按钮、已读 id 列表) | 一个固定骨架(标题 + 域名占位 + 两个按钮) |
| 用户态数据 | 已读 id / CSRF(只对登录用户输出,防游客缓存串号) | 无(连 CSRF 都不需要 —— 无写操作) |
| 服务端查询 | 候选集文件缓存 + 已读查库 | 零(配置项走 Settings,已缓存) |
| 游客整页缓存 | 需小心(用户态数据不能进) | ✅ 完全安全(DOM 与用户无关) |
**注入参数(data-*,零内联要求)**:
data-elg-mode="all">
3.5 弹窗 UI(与现有 UI 一致)
「一致」的落点 —— 逐条对应:
| 视觉要素 | 复用来源 | 实测位置 |
|---|---|---|
| 遮罩色 | var(--mn-comp-overlay-bg) | modern.css(notice_center/assets/style.css:48 已在用) |
| 卡片底 / 边框 / 圆角 / 阴影 | var(--mn-bg-card) / var(--mn-border) / border-radius:14px / var(--mn-shadow-float) | 同上 |
| 标题字色 | var(--mn-text) | — |
| 正文字色 | var(--mn-text-secondary) | — |
| 目标域名强调色 | var(--mn-text-link) | — |
| 主按钮 | .mn-btn.mn-btn-primary(核心类,直接用) | assets/css/modern.css:1067 / :1089 |
| 次要按钮 | .mn-btn(核心类) | assets/css/modern.css:1067 |
| 关闭按钮 | 照 notice_center 的 .ntc-close(30×30、--mn-text-muted、hover 变 --mn-bg-row-hover) | notice_center/assets/style.css:69-92 |
| 入场动画 | fade-in + translateY(8px) scale(.98) 弹入 | notice_center/assets/style.css:197-205 |
| 减少动效 | @media (prefers-reduced-motion: reduce) { animation: none } | notice_center/assets/style.css:208-213 |
| 窄屏断点 | @media (max-width:480px):padding:12px + 卡片 max-width:none | notice_center/assets/style.css:217-234 |
| 锁滚动 | body.elg-lock { overflow: hidden } | notice_center/assets/style.css:36-38 |
⚠️ 不自己造按钮样式:核心
.mn-btn/.mn-btn-primary是全局类(modern.css无作用域前缀,任意页面可用)→ 直接用即可,天然与全站按钮一致,且核心将来改按钮样式会自动跟随。
z-index 口径:.elg-root { z-index: 100020 }(高于核心天花板 100005 与 notice_center 的 100010)。
隐藏类写法(照 notice_center 的教训):
.elg-root[hidden] { display: none !important; }⚠️
!important不能省:插件 CSS 在main.php:40注入,早于核心editor.css(:65)→ 同权重时核心后胜,不给!important会被静默压掉(notice_center/assets/style.css:6-7原话)。
3.6 交互细节
| 项 | 设计 | 依据 |
|---|---|---|
| 绑定时机 | document.addEventListener('click', handler, true)(捕获阶段) | 捕获阶段先于冒泡,且不受其他监听器 stopPropagation() 影响 |
不 stopPropagation | 只 e.preventDefault() | 避免破坏核心 / 其他插件的事件链(如 htmx、thread.js) |
| 仅左键 | if (e.button !== 0) return; | 中键走 auxclick(见决策 4) |
| 修饰键 | `if (e.metaKey \ | \ |
| 关闭 | 遮罩点击 / 关闭按钮 / Esc / 「留在本站」 | 照 notice_center/assets/script.js:198-238 |
| 焦点管理 | 弹窗打开 → 焦点移入「继续访问」;关闭 → 焦点还原 lastFocus | 同上(:174-178 / :189-191) |
| 焦点陷阱 | Tab 在弹窗内循环 | 同上(:219-238) |
| ARIA | role="dialog" + aria-modal="true" + aria-labelledby | 同上(views/popup.php:28) |
| 重复点击 | 弹窗已开 → 不再响应(if (!root.hidden) return;) | 防叠加 |
| 拦截失败兜底 | try/catch 包整个 handler,异常时放行(不阻断用户) | 降级安全 |
「只拦一次」的边界:用户点「继续访问」→ 跳转 → 用户点浏览器「后退」回到本站 →
弹窗状态必须已复位(root.hidden = true)。由于跳转是新标签(target="_blank"),
本站页面从未离开,弹窗保持打开状态 → ⚠️ 需要在确认跳转时立即关闭弹窗(见 §3.7)。
3.7 ★ 「确认后跳转」的实现(关键,含弹窗拦截器风险)
三种做法对比:
| # | 做法 | 风险 |
|---|---|---|
| ① | window.open(url, '_blank') | ❌ 可能被浏览器弹窗拦截器拦掉 —— 它在异步回调(用户点「继续」)里执行,已脱离「用户手势」上下文,浏览器判定为「非用户触发的弹窗」→ 静默失败,用户点了没反应 |
| ② | location.href = url | ⚠️ 当前页跳转 → 与核心 target="_blank" 口径不一致,且「防流失」语义下用户离开本站就回不来 |
| ③(推荐) | 「继续访问」按钮本身就是一个 <a href="{目标}" target="_blank" rel="…">,点击 = 真实用户手势 | ✅ 一定不被弹窗拦截器拦;✅ 与核心 target="_blank" 口径完全一致;✅ 不需要 JS 参与跳转(JS 只负责把 href 填进去) |
采用 ③ 的完整流程:
用户点外链
│
├─ 捕获阶段 handler:isExternalLink() → true
│ ├─ e.preventDefault() ← 阻止本次默认跳转
│ ├─ 记录 elgPendingUrl = a.href
│ ├─ root.hidden = false ← 显示弹窗
│ └─ document.getElementById('elgGo').href = a.href ← 填进「继续访问」
│
└─ 用户点「继续访问」(真实手势 → 新标签打开,不被拦截)
├─ 立即 root.hidden = true ← ⚠️ 关弹窗(本站页面不会离开)
└─ 浏览器新标签打开目标⚠️ 为什么必须「点确认时立即关弹窗」:因为跳转发生在新标签,本站页面始终在原标签。
若不关,用户回到原标签会看到弹窗还开着,且
body.elg-lock还锁着滚动 → 页面卡死观感。
「留在本站」按钮:<button data-elg-close="1"> → 只关弹窗,不跳转(elgPendingUrl 丢弃)。
为什么不用 window.open:见上表 ①。这是本方案最容易踩的坑 —— 本地开发(localhost)往往不拦弹窗,
生产环境(真实用户浏览器设置)会拦,属「本机测不出、线上才暴露」类故障。
3.8 后台配置页
路由(hook/admin_route_register.php):
if (!isset($router)) {
return;
}
// ⚠️ 不写 /admin 前缀(核心已 group('/admin'),写了 404 —— §11.22)
$router->get('/external-link-guard', ['\Plugin\ExternalLinkGuard\AdminController', 'index']);
$router->post('/external-link-guard', ['\Plugin\ExternalLinkGuard\AdminController', 'save']);控制器:继承 app\Controllers\Admin\BaseController(门禁在构造函数,§11.3)+ PRG + flash 读后即焚。
视图:容器三件套(§11.3 第 22 条)
<?php $this->include('admin/_sidebar'); ?>
… 表单 …
配置项(建议):
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
external_link_guard_enabled | 开关 | '1'(开) | 总开关 |
external_link_guard_whitelist | 文本域 | '' | 白名单域名,逗号或换行分隔 |
external_link_guard_mode | 单选 | 'all' | 拦截范围:全站 / 仅正文(决策 1) |
保存逻辑(⚠️ 开关字段的坑):
public static function saveSettings(array $post): void
{
// ⚠️ 不能用 (int)($post['enabled'] ?? 0) —— 字段缺失会被静默写成最小值
// (302 + 提示成功,但 DB 与 settings_cache.php 都不变、error.log 无记录 —— §11.32)
// 必须用「表单哨兵」判断表单是否提交过。
if (!\array_key_exists(self::FORM_SENTINEL, $post)) {
return;
}
Settings::update(self::K_ENABLED, !empty($post['enabled']) ? '1' : '0', self::PLUGIN);
Settings::update(self::K_WHITELIST, self::normWhitelist((string)($post['whitelist'] ?? '')), self::PLUGIN);
Settings::update(self::K_MODE, self::normMode((string)($post['mode'] ?? 'all')), self::PLUGIN);
}白名单归一化(服务端也做一遍,纵深防御):
/* 只留合法域名:小写、去掉协议/路径/端口、去重 */
private static function normWhitelist(string $raw): string
{
$out = [];
foreach (\preg_split('/[\s,,;;]+/u', $raw) as $d) {
$d = \strtolower(\trim($d));
$d = \preg_replace('#^[a-z]+://#', '', $d); // 去协议
$d = \strtok($d, '/'); // 去路径
$d = \preg_replace('/:\d+$/', '', (string)$d); // 去端口
if ($d !== '' && \preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $d)) {
$out[$d] = true;
}
}
return \implode(',', \array_keys($out));
}保存后必须清缓存(§11.5 第 31 条):PageCache::invalidate()(否则游客拿到旧配置的整页缓存)。
3.9 i18n
前缀 plugin.external_link_guard.(§11.3 第 19 条)。三语键集合零差异 + 键序一致(用单一数据源生成)。
预估键表(前台 8 + 后台 12 ≈ 20 键):
| 键 | 简体中文(示例值) |
|---|---|
plugin.external_link_guard.front_title | 即将离开本站 |
plugin.external_link_guard.front_desc | 你要访问的是站外地址: |
plugin.external_link_guard.front_go | 继续访问 |
plugin.external_link_guard.front_stay | 留在本站 |
plugin.external_link_guard.front_close | 关闭 |
plugin.external_link_guard.front_hint | 请确认该地址可信后再继续 |
plugin.external_link_guard.title | 外链跳转提醒 |
plugin.external_link_guard.set_enabled | 启用外链跳转提醒 |
plugin.external_link_guard.set_enabled_hint | 关闭后前台不再注入弹窗 |
plugin.external_link_guard.set_whitelist | 白名单域名 |
plugin.external_link_guard.set_whitelist_hint | 每行一个(或用逗号分隔)。这些域名的链接不弹确认框 |
plugin.external_link_guard.set_mode | 拦截范围 |
plugin.external_link_guard.mode_all | 全站所有外链 |
plugin.external_link_guard.mode_content | 仅帖子/博客正文内的外链 |
plugin.external_link_guard.save_settings | 保存设置 |
plugin.external_link_guard.msg_saved | 已保存 |
| … | (实施时按后台实际字段补齐) |
⚠️ 前台文案是弹窗骨架的一部分(服务端直出)→ 改文案后必须清页面缓存。
3.10 明确不做的(极简取向)
- ❌ 不建数据表 —— 本插件零持久化数据(配置走
Settings,前端状态无)→ 不需要ddlStatements()/ensureSchemaOnce()/uninstall()删表。 - ❌ 不做「不再提醒此站点」的持久化(可选,见决策 3)—— 若做,用
localStorage(游客安全)或Settings(不需要)。 - ❌ 不做点击统计(§11.31 性能坑)。
- ❌ 不做「复制链接」按钮(可选增强,先不做;用户审美 = 没有实际用途的控件不出现)。
- ❌ 不做「外链在编辑器预览区拦截」(后台已排除)。
- ❌ 不注册前台路由(无中转页需求 → 不需要
route_register钩子)。 - ❌ 不注册
nav_plugin_links(无前台入口需求)。
四、需你拍板的 6 个点
决策 1:拦截范围 —— 全站所有外链,还是仅正文内?
| 选项 | 说明 | 代价 |
|---|---|---|
| A. 全站所有外链 | 导航、页脚、友链、公告、正文……凡是站外点击都弹 | 友链点击也弹 → 站长自己放的外链被拦,可能烦 |
B. 仅正文内(.post-text-content) | 只拦帖子/博客正文里的外链 | 覆盖不到「公告 / 友链 / 自定义区块」里的外链 |
| C. 可配置(推荐) | 后台下拉选 A 或 B,默认 A | 多一点配置代码(+1 个 Settings 键 + 1 个后台字段) |
我的建议:C。因为「防流失」的真实场景(用户点正文里的外链走了)主要在正文,
但友链/公告的拦截需求因站长而异 → 给开关比替你选更合适。
决策 2:插件目录名
| 候选 | 命名空间 | 说明 |
|---|---|---|
external_link_guard(推荐) | Plugin\ExternalLinkGuard | 语义清晰(外链守卫);含下划线 → 需 class_alias(已规划) |
link_guard | Plugin\LinkGuard | 更短 |
outbound_guard | Plugin\OutboundGuard | 「出站」语义更准 |
检查过:
plugins/下无link/external/jump/redirect相关目录 → 无占用冲突。
决策 3:是否提供「不再提醒此站点」?
| 选项 | 说明 |
|---|---|
| A. 不提供(推荐,极简) | 每次点都弹。符合你「没有实际用途的控件不出现」的取向 |
B. 提供(localStorage 记域名) | 用户可勾「不再提醒」→ 该域名永久放行。代价:多一个控件 + 多一段状态逻辑 |
决策 4:mailto: / tel: / 修饰键 / 中键 要不要拦?
| 对象 | 建议 | 理由 |
|---|---|---|
mailto: / tel: | 放行 | 拦「发邮件」体验差,且不算「内容流失」 |
Ctrl/Cmd/Shift + 点击 | 放行 | 用户明确要新标签 → 弹窗反而打断 |
中键点击(auxclick) | 放行 | 同上(需额外监听 auxclick 才能拦,成本更高) |
| 右键「在新标签打开」 | 拦不到(浏览器菜单不走 click) | 技术上无解,如实告知 |
决策 5:带 download 属性的链接要不要拦?
| 选项 | 说明 |
|---|---|
| A. 放行(推荐) | 下载文件 ≠ 离开本站浏览;且下载通常指向自己的 CDN |
| B. 拦截 | 一律弹窗 |
决策 6:弹窗里要不要显示完整 URL(而非只显示域名)?
| 选项 | 说明 |
|---|---|
| A. 只显示域名(推荐) | out.example.com —— 简洁,且避免超长 URL 撑破卡片 |
| B. 显示完整 URL | 信息更全,但需 word-break 处理长 URL |
| C. 域名 + 完整 URL(折行) | 最全,视觉最重 |
五、实施阶段(P0 ~ P4,每阶段末停下确认)
| 阶段 | 内容 | 产出 | 验证 |
|---|---|---|---|
| P0 | 骨架 + 清单 + class_alias + 语言包(三语) | plugin.json / Plugin.php / lang/* | php -l 双版本;i18n 键集合比对;plugin_case_lint.py |
| P1 | 前台注入链路(双钩子)+ 弹窗 DOM + CSS | hook/*.php / views/ / assets/style.css | HTTP 冒烟:前台页含 .elg-root、后台页不含 |
| P2 | 拦截 JS(判定 + 弹窗控制 + 可访问性) | assets/script.js | DOM 桩 + 真实交互(用户自测) |
| P3 | 后台配置页(三件套 + 哨兵保存 + 清缓存) | AdminController.php / views/admin.php | 后台页渲染断言 + 保存后回读 DB + settings_cache.php |
| P4 | 插件文档 + 全量回归 | 说明.md | 全套探针 + 零内联扫描 + 幻影变量扫描 |
⚠️ 每阶段末停下等你确认(沿用你的协作习惯)。
⚠️
plugin.json的activated默认false—— 由你决定何时启用。
六、风险与坑(逐条对应规范)
| # | 风险 | 说明 | 对策 |
|---|---|---|---|
| 1 | 弹窗拦截器(最高风险) | window.open 在异步回调里被浏览器拦 | 用 <a> 按钮(§3.7 做法 ③)—— 真实手势,一定不被拦 |
| 2 | 误拦站内链接 | 核心给所有 <a> 加 target="_blank",靠它判必错 | host 比对(§3.3 第 ⑤ 条) |
| 3 | 白名单后缀误匹配 | evil-example.com 被 endsWith('example.com') 放行 | 用 '.' + d 前缀匹配(§3.3) |
| 4 | class_alias 缺失 | 目录名含 _ → 生命周期静默跳过 → 卸载留孤儿 | 文件末尾加 class_alias(§3.2) |
| 5 | 命名空间首段 ≠ 目录名 | Linux Class not found → 500(Windows 假通过) | ExternalLinkGuard ↔ external_link_guard(§3.2);跑 plugin_case_lint.py |
| 6 | 后台也注入弹窗 | layout_body_end 全站触发(含后台) | Plugin::isBackend('') 判 /admin 前缀 |
| 7 | 零内联 | 内联 on* / style="" → CSP script-src 拦 → 弹窗直接不可用 | 全收敛到 assets/;docs/插件审计报告.md:130 有前车之鉴 |
| 8 | 插件 CSS 被核心压掉 | 插件 CSS 在 main.php:40、早于核心 editor.css(:65)→ 同权重核心后胜 | 隐藏类 display:none !important;只用核心已定义的 --mn-* 变量 |
| 9 | 开关字段静默写最小值 | (int)($_POST[$k] ?? 0) → 302 成功但 DB 不变 | 表单哨兵(§11.32) |
| 10 | 游客整页缓存 | 配置改了但游客拿到旧缓存 | 保存后 PageCache::invalidate()(§11.5 第 31 条) |
| 11 | 焦点 / 滚动残留 | 确认跳转后弹窗没关 → 原标签锁滚动,观感卡死 | 确认时立即关弹窗(§3.7) |
| 12 | JS 动态跳转拦不到 | location.href = …(分享按钮等)不走 click | 如实告知;不做 beforeunload(会误伤表单提交) |
| 13 | htmx 局部回包 | layout_body_end 只在整页触发;htmx 片段不含弹窗 | 无影响 —— 弹窗常驻于整页,局部回包不改它;⚠️ 但禁止把弹窗放进 htmx 片段(§11.27) |
| 14 | 主题覆盖层 | 主题若覆盖 _components/* | 本插件不用 _components → 无影响 |
| 15 | SEO | 不改 HTML 结构、不动 rel/target | ✅ 无影响(这是选 ③ 而非 ② 的关键理由) |
| 16 | 多插件 z-index 冲突 | 与 notice_center(100010)同层 | 本插件取 100020(§1.2) |
| 17 | 性能 | 上次 soundplayer 因卡顿砍掉悬浮窗 | 本插件:零服务端查询 + 单个 document 监听器 + defer 脚本 + 体积 < 4 KB |
七、验证清单
7.1 静态检查(每次改动后)
| 检查 | 命令 / 工具 | 期望 |
|---|---|---|
| PHP 语法(双版本) | php8.2.9nts -l + php8.0.2nts -l | 全 PASS |
| 零内联 | `grep -nE '<style\ | <script>\ |
| i18n 三语 | 键集合比对脚本 | 三语集合零差异 + 键序一致 |
| 命名空间/目录名 | .workbuddy-ai/tools/plugin_case_lint.py | PASS |
全角标点紧跟 $var | .workbuddy-ai/tools/php_var_cjk_check.php | 零命中 |
| CSS 幻影变量 | 扫描 var(--mn-*) 是否都在 modern.css | 零幻影 |
| 插件卫生 | plugin_hygiene.py | FAIL = 0 |
| JS 语法 | node --check assets/script.js | OK |
7.2 HTTP 端到端冒烟(必须,skill 红线 3)
⚠️ 「只跑
php -l+ i18n + 零内联证明不了拦截逻辑生效」→ 必须补真实 HTTP。
| # | 断言 | 方法 |
|---|---|---|
| 1 | 前台任意页 含 .elg-root | curl 取 HTML → strpos |
| 2 | 后台页(/admin/*)不含 .elg-root | 同上 |
| 3 | layout_head_end 输出的 <link> / <script> 带 ?v= | 正则 |
| 4 | 总开关关闭 → 前台零注入(连 <link> 都没有) | 改 Settings 后重取 |
| 5 | 白名单域名出现在 data-elg-whitelist | 断言属性值 |
| 6 | 弹窗 DOM 在 </body> 前、且只有一个 | substr_count |
| 7 | 响应头 Content-Security-Policy 不含 unsafe-inline 依赖(即插件确实零内联) | 断言响应头 |
| 8 | 后台保存后 → 回读 DB + settings_cache.php 双确认 | 探针 |
7.3 浏览器交互(你自测 —— 界面与观感)
| # | 场景 | 期望 |
|---|---|---|
| 1 | 点正文里的外链 | 弹窗出现,不跳转 |
| 2 | 点「继续访问」 | 新标签打开目标;原标签弹窗关闭、滚动恢复 |
| 3 | 点「留在本站」/ 遮罩 / Esc | 弹窗关闭,不跳转 |
| 4 | 点站内链接 | 不弹窗,正常跳转(回归验证) |
| 5 | 点白名单里的域名 | 不弹窗 |
| 6 | Ctrl+点击 外链 | 不弹窗(若决策 4 选放行) |
| 7 | 弹窗打开时按 Tab | 焦点在弹窗内循环,不跑到页面背后 |
| 8 | 手机窄屏 | 卡片适配(max-width:none) |
| 9 | 系统「减少动态效果」开启 | 无动画 |
| 10 | 浅色 / 深色主题 | 颜色跟随 --mn-* 变量 |
八、附:证据索引(本计划全部结论的出处)
| 结论 | 证据位置 |
|---|---|
| 43 个钩子无「内容重写」能力 | docs/插件开发规范.md:667-798(§9.1 全表) |
核心给所有 <a> 加 target=_blank + rel | app/Helpers/Content.php:123-126、:577-584 |
href 协议白名单(放行 //host) | app/Helpers/Content.php:116、:149 |
| 弹窗 UI 基线(结构/CSS/JS) | plugins/notice_center/views/popup.php、assets/style.css、assets/script.js |
| 双钩子注入范式 | plugins/notice_center/hook/layout_head_end.php、hook/layout_body_end.php |
layout_body_end 定义(#43,无参数) | docs/插件开发规范.md:697;触发点 app/Views/layouts/main.php:534 |
| 核心无弹窗组件 | assets/css/modern.css(mn-modal 计数 0);assets/js/(无 modal) |
| 核心按钮类可复用 | assets/css/modern.css:1067(.mn-btn)、:1089(.mn-btn-primary) |
正文容器 .post-text-content | assets/css/modern.css:1656;app/Views/thread/show.php:167;app/Views/blog/detail.php:50 |
--mn-* 变量存在性(11 个) | 逐一 grep assets/css/modern.css(全部命中 1 次) |
| z-index 天花板 100005 | docs/全站公告弹窗插件开发方案_2026-09-30.md:114-118 |
自动加载的 3 条候选路径 + camelToSnake | app/Core/Autoloader.php:10-49 |
class_alias 必要性 | app/Helpers/Plugin.php:699;plugins/notice_center/Plugin.php:886-894 |
| 后台门禁在构造函数 | app/Controllers/Admin/BaseController.php:16-43 |
后台路由不写 /admin 前缀 | docs/插件开发规范.md:1335(§11.22) |
后台弹层必须在 <main class="admin-content"> 内 | docs/插件开发规范.md:1061-1089(§11.3 第 22 条) |
| 开关字段必须用表单哨兵 | docs/插件开发规范.md:1681(§11.32);plugins/notice_center/Plugin.php:235-247 |
| 零内联 + CSP 导致弹窗失效的前车之鉴 | docs/插件审计报告.md:130;docs/插件开发规范.md:573-593(§7.3) |
渲染钩子里 $this 不可用 | docs/插件开发规范.md:1382(§11.24) |
| 外链中转页契约(服务端场景,本方案未采用) | docs/插件开发规范.md:1618-1652(§11.30) |
| 命名空间首段必须逐字节等于目录名 | docs/插件开发规范.md:1731(§11.34) |
| SW 缓存陷阱 | docs/插件开发规范.md:1129(§11.5 第 35 条) |
Settings API | app/Helpers/Settings.php:31(get)、:255(update) |
| 插件名无占用 | ls plugins/(无 link/external/jump/redirect 目录) |
下一步:请就 §四 的 6 个决策点 给出选择(或直接说「按你的建议来」),我再进入 P0。
本文档为纯计划,未产生任何代码改动。