[开发]外链跳转弹窗插件开发计划(`external_link_guard`)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 11:00:15

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.phpshouldInject() 门控 → 只输出 <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-247Settings::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 硬约束(实测,直接决定设计)

  1. 零内联(§7.3):禁内联 <style> / <script> 块、style="" 属性、on 事件属性 → 全部收敛到 assets/,传参走 data-。

> ⚠️ docs/插件审计报告.md:130 已记录过一次同类事故:「内联事件属性违反零内联红线,且受 CSP script-src 策略限制时确认弹窗会失效」—— 本插件是整个功能就是弹窗,踩这条 = 功能直接不可用。

  1. 渲染钩子里 $this 不可用(§11.24):layout_body_end / layout_head_end 运行在闭包作用域 → 一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8') + I18n::get()。
  2. layout_body_end 无参数(§9.1 #43)→ 判「是否后台页」只能靠请求路径 /admin 前缀(notice_center 的 Plugin::isBackend('') 已实现此逻辑,可直接抄)。
  3. layout_body_end 全站触发(含后台) → 必须显式排除后台,否则后台也会注入弹窗。
  4. 改 layout_head_end / layout_body_end 后必须 PageCache::invalidate()(§11.5 第 31 条)—— 后台保存配置后触发。
  5. CSP(app/Core/Controller.php:80):img-src 'self' data: https: → 本插件弹窗不放图,无影响。
  6. Service Worker(§11.5 第 35 条):同源 GET 非导航一律 Cache-First → 本插件纯客户端、零请求,不受影响。
  7. 后台视图容器三件套(§11.3 第 22 条):.admin-container + admin/_sidebar + <main class="admin-content">,弹层必须在 <main> 内。
  8. 开关字段必须用表单哨兵(§11.32 / §11.18):不能用 (int)($_POST[$k] ?? 0)。
  9. 目录名含下划线 → 必须 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\ExternalLinkGuardcamelToSnake('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;
}

豁免清单(逐条说明):

#豁免对象判据理由
①同页锚点 #xxxhref 以 # 开头不离开页面
②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:nonenotice_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)
ARIArole="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_guardPlugin\LinkGuard更短
outbound_guardPlugin\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 + CSShook/*.php / views/ / assets/style.cssHTTP 冒烟:前台页含 .elg-root、后台页不含
P2拦截 JS(判定 + 弹窗控制 + 可访问性)assets/script.jsDOM 桩 + 真实交互(用户自测)
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)
4class_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)
12JS 动态跳转拦不到location.href = …(分享按钮等)不走 click如实告知;不做 beforeunload(会误伤表单提交)
13htmx 局部回包layout_body_end 只在整页触发;htmx 片段不含弹窗无影响 —— 弹窗常驻于整页,局部回包不改它;⚠️ 但禁止把弹窗放进 htmx 片段(§11.27)
14主题覆盖层主题若覆盖 _components/*本插件不用 _components → 无影响
15SEO不改 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.pyPASS
全角标点紧跟 $var.workbuddy-ai/tools/php_var_cjk_check.php零命中
CSS 幻影变量扫描 var(--mn-*) 是否都在 modern.css零幻影
插件卫生plugin_hygiene.pyFAIL = 0
JS 语法node --check assets/script.jsOK

7.2 HTTP 端到端冒烟(必须,skill 红线 3)

⚠️ 「只跑 php -l + i18n + 零内联证明不了拦截逻辑生效」→ 必须补真实 HTTP。

#断言方法
1前台任意页 含 .elg-rootcurl 取 HTML → strpos
2后台页(/admin/*)不含 .elg-root同上
3layout_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点白名单里的域名不弹窗
6Ctrl+点击 外链不弹窗(若决策 4 选放行)
7弹窗打开时按 Tab焦点在弹窗内循环,不跑到页面背后
8手机窄屏卡片适配(max-width:none)
9系统「减少动态效果」开启无动画
10浅色 / 深色主题颜色跟随 --mn-* 变量


八、附:证据索引(本计划全部结论的出处)

结论证据位置
43 个钩子无「内容重写」能力docs/插件开发规范.md:667-798(§9.1 全表)
核心给所有 <a> 加 target=_blank + relapp/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-contentassets/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 天花板 100005docs/全站公告弹窗插件开发方案_2026-09-30.md:114-118
自动加载的 3 条候选路径 + camelToSnakeapp/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 APIapp/Helpers/Settings.php:31(get)、:255(update)
插件名无占用ls plugins/(无 link/external/jump/redirect 目录)


下一步:请就 §四 的 6 个决策点 给出选择(或直接说「按你的建议来」),我再进入 P0。

本文档为纯计划,未产生任何代码改动。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 8 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录