本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/soundplayer插件审计与悬浮窗改造报告_2026-09-30.md。
四、审计结论
- 不能以现状启用 —— P0-1 越权是硬伤,普通用户可改播放列表 + 上传文件到服务器。
- 功能实现本身质量中上 —— 上传安全、CSRF、事务、i18n、URL 校验都规范,不需要推倒重来。
plugin_case_lint.py有工具盲区 —— 它验不出 P0-2(class_alias),建议在规范中补记:
> 「目录名与命名空间首字母大小写不一致」这类缺陷,必须用 class_exists('\Plugin\{目录名}\Plugin') 实测断言,
> 静态路径检查工具覆盖不到。
- 插件当前未启用 = 最佳修复窗口 —— 修完 P0/P1 再启用,风险可控。
第二部分 悬浮窗改造报告
五、需求拆解
用户原话:
「我想把这个插件改成全局显示的悬浮窗,这样在播放时就不会因为切换页面而卡顿,
钩子位置跟公告弹窗那里一样即可。悬浮窗放在 PC 端的右侧栏的位置即可,可以拖动,可以关闭。」
拆成 5 条可验收的需求:
| # | 需求 | 验收标准 |
|---|---|---|
| R1 | 全局显示 | 所有前台页面都能看到(不只渲染右栏的页面) |
| R2 | 钩子同公告弹窗 | 使用 layout_body_end(与 notice_center 一致) |
| R3 | PC 端右侧栏位置 | position: fixed,贴右侧栏视觉位置(right: 16px 附近) |
| R4 | 可拖动 | 鼠标拖动移动位置,刷新后位置保留 |
| R5 | 可关闭 | 有明确关闭按钮,关闭后本次会话不再出现 |
隐含需求 R6:播放不中断(用户的核心动机)→ 见第八章,这一条无法通过悬浮窗达成。
六、钩子方案
6.1 钩子变更对照
| 钩子 | 改造前 | 改造后 | 说明 |
|---|---|---|---|
init_after | ✅ 每请求 | ✅ 保留 | CSP media-src 注入必需,不能动 |
route_register | ✅ | ✅ 保留 | 前台独立页 /soundplayer |
admin_route_register | ✅ | ✅ 保留 | 后台路由 |
layout_head_end | ✅ 无条件输出 CSS + 条件输出 JS | ✅ 降级为「仅资源」+ 短路 | 无悬浮窗时零输出(对齐 notice_center 范式) |
layout_body_end | ❌ 未注册 | ✅ 新增 | 悬浮窗 DOM 直出 |
right_sidebar_after | ✅ hook/sidebar_card.php | ⚠️ 待定(见 6.3) | 与悬浮窗职责重叠 |
~~sidebar_above_friend_links~~ | 孤儿 | 🗑️ 删除 | P1-3 |
6.2 layout_body_end 的关键约束(逐条对照 notice_center 的踩坑记录)
| 约束 | 说明 | 应对 |
|---|---|---|
| 无参数 | 规范 §9.1 #43 明确「注入参数:无」 | 判后台只能靠请求路径(/admin 前缀),不能靠 template |
| 全站触发(含后台) | 同上 | 必须做 isBackend() 短路,否则后台页也会冒悬浮窗 |
| 闭包作用域 | 规范 §11.24:渲染钩子里 $this 不可用 | 所有转义 / i18n 走 Plugin:: 静态方法 + htmlspecialchars() + I18n::get() |
| 禁顶层符号 | 规范 §9.0 | hook 文件内不声明 function / class |
| 游客整页缓存 | PageCache 仅缓存列表页 + 仅游客 | 悬浮窗 DOM 里只能有公共内容;任何用户态(csrf / 已读 / uid)必须只对登录用户输出 |
6.3 right_sidebar_after 的处置 —— 需要用户拍板
现状:hook/sidebar_card.php 在 PC 右栏输出 cardHtml()。
问题:若同时注册 layout_body_end,页面里会出现两个 .sound-player-card,而:
// assets/script.js:25-26
var root = document.querySelector('.sound-player-card'); // ← 单元素查询
if (!root) return;querySelector 只取第一个 → 第二个卡片是死 DOM(无事件、无状态),视觉上却存在。
三个选项:
| 选项 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | 移除 right_sidebar_after,只保留悬浮窗 | 干净、无重复、querySelector 单元素假设成立 | PC 右栏不再有卡片(用户需求本就是「改成悬浮窗」) |
| B | 保留右栏卡片 + 悬浮窗,改 JS 支持多实例 | 双入口 | JS 改造量大(984 行里 root 被引用 40+ 次),状态同步复杂 |
| C | 保留右栏卡片,悬浮窗仅在无右栏的页面显示 | 看似两全 | 判定「本页是否有右栏」在 layout_body_end 里做不到(钩子执行时右栏已渲染完,但拿不到标记)→ 不可行 |
建议 A。用户说的是「改成悬浮窗」,语义就是替换。
七、关键技术澄清:为什么悬浮窗 ≠ 不卡顿
这一节是本次改造最重要的部分。如果不先对齐预期,改造完用户会发现「还是卡」。
7.1 事实链
① 核心没有全局 htmx boost —— 导航是整页跳转
app/Views/layouts/main.php 全文只有 2 处 htmx 痕迹:
:90注释<!-- FhAjax (htmx) AJAX 请求 -->:266一处hx-get="/api/favorites-count"(收藏数懒加载)
没有 hx-boost、没有 pjax、没有 Turbo。所有 <a href> 都是浏览器整页导航。
② 音频元素是游离的,不在 DOM 里
// assets/script.js:75-81
/* audio 元素:与原插件一致,只设 preload。 */
var audio = document.createElement('audio');
audio.preload = 'metadata';createElement 之后从未 appendChild 到任何容器 —— 它是纯 JS 对象。
③ 整页跳转 = JS 上下文销毁
浏览器整页导航 → 旧 document 完全卸载 → 所有 JS 变量、事件监听、游离 DOM 节点一并销毁 →
audio 元素随之消失 → 声音中断。
④ 现有「续播」是兜底,不是无缝
// assets/script.js:432-443
function readLive() {
try { return JSON.parse(sessionStorage.getItem(LIVE_KEY) || 'null'); } catch (e) { return null; }
}
function saveLiveNow() {
...
sessionStorage.setItem(LIVE_KEY, JSON.stringify({
url: ...,
position: isFinite(audio.currentTime) ? Math.floor(audio.currentTime) : 0,
...新页面加载后从 sessionStorage 读回 {url, position} → 重建 audio → audio.src = url; audio.load()(:380-381)
→ 等 loadedmetadata 才能 seek(:469-473)→ 重新建立连接 + 重新缓冲。
用户感受到的「卡顿」,就是这段「重建 + 重连 + 重缓冲」的空白期。
7.2 结论
| 目标 | 悬浮窗能否达成 |
|---|---|
| UI 位置固定(不随右栏消失) | ✅ 能 |
| 交互状态不被跳转重置(音量 / 模式 / 展开态) | ✅ 能(且 localStorage 已在做) |
| 切页不断音 | ❌ 不能 |
7.3 真要「不断音」,只有两条路
| 方案 | 做法 | 代价 |
|---|---|---|
| htmx boost 全站 | 给 <body> 加 hx-boost="true",导航变局部刷新 | ⚠️ 极高风险:影响全站所有链接 / 表单 / CSRF / 插件钩子 / PageCache / 缓存 / SEO。核心 main.php:266 的 htmx 用法是局部的,全站 boost 是另一个量级 |
| iframe 承载播放器 | 播放器放独立 iframe | ⚠️ 在本场景不成立 —— 父页面整页跳转时 iframe 同样被销毁,除非配合 window.name 之类的 hack,脆弱且难维护 |
我的建议:先做 UI 改造(低风险、用户明确要的),把「切页不断音」单独立项评估。
如果用户的实际症状只是「切页后要等 1-2 秒才续上」,那属于可接受的续播代价,不值得为它冒全站 htmx 改造的风险。
⚠️ 需要向用户确认:您说的「卡顿」,是①声音完全断掉重来,还是②断 1-2 秒后续上,还是③UI 闪一下/位置跳变?
三者的解法完全不同:①③ 悬浮窗能改善,② 需要 htmx。
八、悬浮窗实现设计
8.1 DOM 结构
音乐播放器
×
设计要点
<div class="sp-float">是新增外壳,卡片本身(.sound-player-card)保持原样 →
script.js:25 的 querySelector('.sound-player-card') 继续可用,JS 改造量最小化。
hidden起步? —— ❌ 不能。notice_center的弹窗用hidden起步是因为有「已读」判定要等 JS;
播放器没有这个需求,服务端直出即可见 → 避免「JS 挂了就永远不显示」。
关闭态由 CSS class + JS 控制。
data-sp-float-state由 JS 读写(open/closed),初始值服务端给open。
8.2 CSS 设计(追加到 style.css,不改动 .sound-player-card 原有规则)
/* ===== 悬浮窗外壳(PC 端右侧栏位置) ===== */
.sp-float {
position: fixed;
right: var(--sp-float-x, 16px); /* 拖动改写 */
bottom: var(--sp-float-y, 24px);
width: 300px;
z-index: 100010; /* 核心天花板 100005(与 notice_center 同层) */
/* 视觉:与卡片一致的面板感 */
}
.sp-float[data-sp-float-state="closed"] { display: none; }
/* 拖动把手 */
.sp-float-head { cursor: grab; user-select: none; }
.sp-float-head:active { cursor: grabbing; }
/* 移动端:默认不显示(PC 端专属,见 8.5) */
@media (max-width: 1024px) {
.sp-float { display: none; }
}
/* 无障碍:尊重减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
.sp-float { transition: none !important; }
}为什么用 right + bottom 而不是 left + top?
right/bottom天然贴合「右侧栏位置」需求,且不受窗口宽度变化影响(右侧栏宽度固定)。- 拖动时 JS 改写
--sp-float-x/--sp-float-y两个 CSS 变量(不直接改style.left,避免与媒体查询打架)。
⚠️ 记忆红线:**CSS 只用既有
--mn-*变量**。上面的--sp-float-*是插件自有变量(--sp-前缀,不与核心冲突),不违反该红线。但不要在
sp-float里引用那 11 个幻影--mn-*变量(见 P2-2)。
8.3 拖动实现(**必须用 pointerdown,禁止 on* 内联**)
// 追加到 assets/script.js 的 soundplayer_main() 内
(function initFloat() {
var wrap = document.querySelector('[data-sp-float]');
if (!wrap) return;
var handle = wrap.querySelector('[data-sp-drag-handle]');
var DRAG_KEY = 'soundplayer_float_pos_v1';
var CLOSE_KEY = 'soundplayer_float_closed_v1';
// ---- 恢复上次位置(localStorage) ----
try {
var pos = JSON.parse(localStorage.getItem(DRAG_KEY) || 'null');
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
wrap.style.setProperty('--sp-float-x', pos.x + 'px');
wrap.style.setProperty('--sp-float-y', pos.y + 'px');
}
} catch (e) {}
// ---- 拖动:pointerdown / move / up ----
if (handle) {
var sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
handle.addEventListener('pointerdown', function (e) {
dragging = true;
sx = e.clientX; sy = e.clientY;
var cs = getComputedStyle(wrap);
ox = parseFloat(cs.right) || 16;
oy = parseFloat(cs.bottom) || 24;
handle.setPointerCapture(e.pointerId); // ← 关键:防止拖出窗口丢事件
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
// 右/下为正方向:鼠标右移 → right 减小
var nx = Math.max(0, Math.min(window.innerWidth - 40, ox - (e.clientX - sx)));
var ny = Math.max(0, Math.min(window.innerHeight - 40, oy - (e.clientY - sy)));
wrap.style.setProperty('--sp-float-x', nx + 'px');
wrap.style.setProperty('--sp-float-y', ny + 'px');
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
try {
handle.releasePointerCapture(e.pointerId);
} catch (err) {}
var cs = getComputedStyle(wrap);
try {
localStorage.setItem(DRAG_KEY, JSON.stringify({
x: parseFloat(cs.right) || 16,
y: parseFloat(cs.bottom) || 24
}));
} catch (err) {}
}
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
}
// ---- 关闭 ----
var closeBtn = wrap.querySelector('[data-sp-float-close]');
if (closeBtn) {
closeBtn.addEventListener('click', function () {
wrap.setAttribute('data-sp-float-state', 'closed');
try { sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) {}
// ⚠️ 关闭时**不暂停播放** —— 用户意图是「收起 UI」,不是「停止音乐」
});
}
try {
if (sessionStorage.getItem(CLOSE_KEY) === '1') {
wrap.setAttribute('data-sp-float-state', 'closed');
}
} catch (e) {}
})();要点说明
| 要点 | 理由 |
|---|---|
pointerdown/move/up 而非 mousedown/mousemove | 同时支持鼠标 + 触屏 + 触控笔;pointercancel 兜住系统手势打断 |
setPointerCapture | 防止鼠标移出把手后 pointermove 丢失(不加这个,快速拖动会「掉」) |
写 CSS 变量而非 style.left | 与 right/bottom 定位策略一致,不产生 left+right 双约束冲突 |
边界钳制 Math.max/min | 防止拖出视口后找不回来 |
位置存 localStorage | 跨会话保留(用户期望「拖过一次就一直在那」) |
关闭态存 sessionStorage | 本次会话不再打扰,下次访问恢复(比 localStorage 更符合「关闭」语义) |
**零 on* 内联** | 规范 §十二:views/ + hook/ 零内联;本段在 .js 文件里,用 addEventListener |
| 关闭不暂停播放 | 关闭的是 UI,不是播放器 —— 需向用户确认这一行为(见第十三章 D3) |
8.4 服务端渲染(hook/layout_body_end.php)
<?php
/**
* hook/layout_body_end.php —— 输出前台悬浮窗 DOM
*
* 触发点:`app/Views/layouts/main.php:534`(`</body>` 前)。**本钩子无参数**
* (规范 §9.1 #43)。
*
* ⚠️ 无 `template` 参数 → 「是否后台页」只能靠请求路径判定(`/admin` 前缀)。
* ⚠️ 渲染钩子在闭包作用域执行,`$this` 不可用(规范 §11.24)。
* ⚠️ 本文件禁止声明顶层 function / class / interface / trait(规范 §9.0)。
*
* @file plugins/soundplayer/hook/layout_body_end.php
*/
echo \Plugin\Soundplayer\Plugin::renderFloat();Plugin::renderFloat() 关键逻辑(与 notice_center::renderPopup() 同构):
public static function renderFloat(): string
{
// ① 后台页不输出(layout_body_end 全站触发,含后台)
if (self::isBackend()) {
return '';
}
// ② 无曲目/电台 → 不输出(与 cardHtml 同判据)
$cfg = self::config();
if (!$cfg['enabled']) {
return '';
}
$tracks = self::tracks();
$stations = self::stations();
if (!$tracks && !$stations) {
return '';
}
// ③ 渲染
\ob_start();
include __DIR__ . '/views/float.php';
return (string)\ob_get_clean();
}isBackend() 实现(照抄 notice_center/Plugin.php:614-621):
public static function isBackend(): bool
{
$path = (string)\parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
return $path === '/admin' || \strpos($path, '/admin/') === 0;
}⚠️
notice_center的isBackend()还接受$template参数(它有layout_head_end带参可复用)。
soundplayer的layout_body_end无参数 → 简化为纯路径判定即可。两处判定口径必须一致(
layout_head_end短路 与layout_body_end输出),否则会出现「CSS 加载了但 DOM 没有」或反之。
8.5 移动端处置 —— 需要用户拍板
用户明确说「PC 端的右侧栏」。移动端有三条路:
| 选项 | 做法 | 说明 |
|---|---|---|
| A | 移动端完全不显示(@media (max-width: 1024px) { display: none }) | 最简单,但移动端失去播放器入口(原 show_on_mobile 的右栏卡片被移除后) |
| B | 移动端显示为底部固定条(bottom: 0; left: 0; right: 0,不可拖动) | 保持移动端可用;额外 CSS |
| C | 移动端沿用原移动侧栏卡片(保留 sidebar_above_friend_links 但改为条件输出) | 与 P1-3「删孤儿文件」冲突 |
建议 A(贴合用户原话)。若用户希望移动端也能播,选 B。
⚠️ 若选 A,
show_on_mobile配置项将失去意义 → 建议一并下线(或改名为show_float)。
九、风险清单
| # | 风险 | 级别 | 应对 |
|---|---|---|---|
| 1 | layout_body_end 全站触发含后台 → 后台页冒出悬浮窗 | 高 | isBackend() 短路(8.4) |
| 2 | 双卡片:right_sidebar_after + 悬浮窗同时输出 → querySelector 取到死 DOM | 高 | 移除 right_sidebar_after(6.3 选项 A) |
| 3 | layout_head_end 与 layout_body_end 判定口径不一致 | 中 | 两处都调同一个 shouldRender(),单次解析共用 |
| 4 | 后台页加载 JS 但无悬浮窗 → root 为 null | 中 | 现有 if (!root) return;(script.js:26)已兜住;但需确认后台 JS 仍按需加载(layout_head_end:20 的 $needJs 逻辑保留) |
| 5 | 游客整页缓存串号 | 中 | 悬浮窗 DOM 内不得含用户态(csrf / uid / 已读)。位置与关闭态走 localStorage / sessionStorage(客户端),不烘焙进缓存 |
| 6 | z-index 冲突 | 中 | 用 100010(核心天花板 100005,notice_center 同层)。⚠️ 若同页既有公告弹窗又有悬浮窗,两者会重叠 → 需确认遮挡关系(悬浮窗在下、弹窗在上) |
| 7 | 拖动与页面滚动冲突 | 低 | handle.setPointerCapture + e.preventDefault()(8.3) |
| 8 | localStorage 被禁用(隐私模式) | 低 | 全部包 try/catch,失败则退化为「不记忆位置」 |
| 9 | plugin.json 改 hooks 后缓存滞后 | 高 | 必须 Plugin::refresh()(app/Helpers/Plugin.php:819)或后台「插件」页刷新,否则 layout_body_end 不生效且无任何报错 |
| 10 | 说明.md 需同步大改 | 低 | 钩子清单 + §11.4 + 附录 A + 新增悬浮窗章节 |
| 11 | **--sp-float-* 变量与主题冲突** | 低 | 用 --sp- 前缀(插件自有),不碰 --mn-* |
十、验证清单
改造完成后需跑(逻辑由我跑,界面由您看 —— 按既有分工):
10.1 静态门禁
| 检查 | 命令 / 方法 | 期望 |
|---|---|---|
| PHP 语法双版本 | php -l × 14 文件 × (8.2 / 8.0) | 全通过 |
| JS 语法 | node --check assets/script.js | 通过 |
| 零内联 | plugin_hygiene.py | PASS |
| 无顶层符号 | plugin_hygiene.py | PASS |
全角标点 + $var | .workbuddy-ai/tools/php_var_cjk_check.php plugins/soundplayer | 0 命中 |
| CRLF | plugin_hygiene.py | PASS(含 说明.md) |
| i18n | 三语键集合比对 | 零差异 + 键序一致 |
| 缓存一致 | plugin_hygiene.py 第 9 项 | PASS |
10.2 逻辑验证(探针)
| # | 用例 | 断言 |
|---|---|---|
| T1 | class_exists('\Plugin\soundplayer\Plugin') | true(P0-2 修复验证) |
| T2 | 非管理员访问 /admin/soundplayer | 302 → /login(或 /),且 sp_tracks 行数零变化(增量守卫) |
| T3 | 管理员访问 /admin/soundplayer | 200,含 admin-container / admin-content |
| T4 | GET / 前台 | 含 data-sp-float,且 z-index:100010 在 CSS 中 |
| T5 | GET /admin 后台 | 不含 data-sp-float(后台短路) |
| T6 | GET / 连续 3 次 → DDL 计数 | 首请求 5 条,后续 0 条(P1-1 验证) |
| T7 | 游客页面缓存 | data-sp-* 中无 csrf / uid 残留 |
| T8 | 禁用插件(enabled=false) | layout_body_end 输出空串,layout_head_end 无 <link> |
| T9 | 无曲目 + 无电台 | 悬浮窗不输出 |
| T10 | 移除 right_sidebar_after 后 | 页面内 .sound-player-card 仅 1 个 |
10.3 交互验证(需真实浏览器 / 用户自测)
| # | 用例 | 说明 |
|---|---|---|
| U1 | 拖动悬浮窗 | 位置跟手,松手后保持 |
| U2 | 刷新页面 | 位置保留(localStorage) |
| U3 | 点关闭 | 悬浮窗消失,音乐继续播(若确认此行为) |
| U4 | 关闭后切换页面 | 本次会话不再出现(sessionStorage) |
| U5 | 新开标签页 | 位置保留、关闭态重置为显示 |
| U6 | 窄窗口(< 1024px) | 悬浮窗隐藏(若选移动端选项 A) |
| U7 | 与公告弹窗同页 | 层级不冲突,都能操作 |
| U8 | 切页后 | 观察音频中断时长(这是第七章的关键观测点) |
⚠️ CSP 注意:核心 CSP(
app/Core/Controller.php:80)为img-src 'self' data: https:,不含blob:。本改造不涉及图片预览,无影响。但若后续给悬浮窗加「封面预览」,必须用
FileReader.readAsDataURL()。
十一、回滚路径
改造全部集中在插件目录内,不触碰核心:
| 改动 | 回滚方式 |
|---|---|
plugin.json hooks | 还原 5 个钩子 + Plugin::refresh() |
hook/layout_body_end.php | 删除文件 |
hook/sidebar_card.php | 恢复(若移除过) |
Plugin.php | 删除 renderFloat() / isBackend() / ensureSchemaOnce() |
views/float.php | 删除文件 |
assets/script.js / style.css | 追加的代码块删除 |
关键:Plugin.php 的 class_alias(P0-2)与 ensureSchemaOnce()(P1-1)属修复项,不回滚。
数据无风险:改造不涉及任何表结构变更、不迁移数据。sp_* 三表原样。
十二、待确认决策点
改造前需要您拍板 4 件事:
| # | 决策点 | 选项 | 我的建议 |
|---|---|---|---|
| D1 | 「卡顿」的实际症状是什么?(第七章) | ①声音完全断掉重来 ②断 1-2 秒后续上 ③UI 闪一下/位置跳变 | 请先描述症状 —— 这决定要不要评估 htmx boost。若只是③,悬浮窗就够了 |
| D2 | right_sidebar_after 如何处置?(6.3) | A 移除,只留悬浮窗 / B 双入口(改 JS 支持多实例) | A(用户说「改成悬浮窗」= 替换) |
| D3 | 点「关闭」后音乐是否继续播? | A 继续播(只收 UI) / B 一并暂停 | A —— 「关闭悬浮窗」语义上是收 UI,不是停止播放 |
| D4 | 移动端怎么办?(8.5) | A 不显示 / B 底部固定条 / C 沿用移动侧栏 | A(贴合「PC 端」原话);若需移动端可播则 B |
另有一个前置建议:先修 P0/P1 再改造,还是一次性做完?
- 若「先修后改」:可以先让插件安全启用,验证播放功能正常,再做悬浮窗 —— 风险更低、可分阶段验收
- 若「一次性」:改动集中在同几个文件,一次验收 —— 更快,但问题定位会变难
按您既有的「分阶段推进 + 阶段末暂停确认」习惯,我建议 先修后改。
附录 证据索引
| 结论 | 证据位置 |
|---|---|
requireLogin() 只判登录 | app/Core/Controller.php:134-142 |
真门禁在 Admin\BaseController 构造函数 | app/Controllers/Admin/BaseController.php:16-36 |
Auth::isAdmin() AND 双条件 | app/Helpers/Auth.php:20-27 |
| 核心按目录名硬拼类名 | app/Helpers/Plugin.php:699 |
class_exists 三个调用点 | app/Helpers/Plugin.php:700 / :751 / :847 |
ensureSchema = exec($sql) | app/Helpers/Plugin.php:86-89 |
Plugin::refresh() 存在 | app/Helpers/Plugin.php:819 |
layout_body_end 定义与用途 | docs/插件开发规范.md §9.1 #43;app/Views/layouts/main.php:532-534 |
right_sidebar_after 触发点 | app/Views/_components/right_sidebar.php:211 |
sidebar_above_friend_links 双触发 | right_sidebar.php:263 + mobile_sidebar.php:37 |
| 后台 CSS 作用域 | assets/css/admin.css:4 |
| 后台视图标准容器写法 | notice_center/views/admin.php:49-51、netdisk_help/views/admin.php:51-53 |
class_alias 范式 | plugins/notice_center/Plugin.php:892-894 |
layout_body_end 消费者范式 | plugins/notice_center/hook/layout_body_end.php |
shouldInject / isBackend / renderPopup | plugins/notice_center/Plugin.php:837 / :614 / :856 |
核心 CSP(无 media-src) | app/Core/Controller.php:80 |
| 无全局 htmx boost | app/Views/layouts/main.php 全文仅 :90 注释 + :266 一处 hx-get |
| audio 元素游离创建 | plugins/soundplayer/assets/script.js:75-81 |
| 续播兜底(sessionStorage) | assets/script.js:432-473 |
root 单元素查询 | assets/script.js:25-26 |
| 幻影 CSS 变量 11 个 | assets/style.css × assets/css/modern.css(108 定义)交叉比对 |
| 门禁 FAIL = 1(CRLF) | plugin_hygiene.py plugins/soundplayer |