本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/soundplayer插件审计与悬浮窗改造报告_2026-09-30.md。
soundplayer 插件审计与悬浮窗改造报告
日期:2026-09-30
对象:
plugins/soundplayer(音乐播放器 v1.0.0,作者「读到」,category: entertainment)依据:
docs/插件开发规范.md当前状态:
plugin.json/plugins_cache.json均为activated: false(未启用,线上未加载)本报告性质:只读审计 + 改造设计,未改动任何代码
摘要(结论先行)
一、审计结论
功能实现质量中上:上传白名单 + 随机文件名 + 目录防护、CSRF 校验、事务 + 回滚、i18n 三语、URL 协议白名单、checkUrl() 严格校验 —— 这些做得比多数插件扎实。
但存在 2 个 P0 结构性缺陷,不修不能启用:
| 级别 | 问题 | 一句话后果 |
|---|---|---|
| P0-1 | AdminController 只 requireLogin(),无管理员门禁 | 任意登录用户可增删曲目、改配置、上传/删除服务器文件 |
| P0-2 | Plugin.php 缺 class_alias | activate()/deactivate()/uninstall() 被核心静默跳过 → 卸载留孤儿表、上传目录残留 |
另有 P1 三条(每请求 4 条 DDL 无门控 / 后台视图缺 admin-container 作用域 / 孤儿钩子文件)、P2 六条、门禁 FAIL = 1(说明.md 为 CRLF)。
关键背景:插件当前未启用 → 上述风险尚未暴露在线上。这是修 P0 的最佳窗口期。
二、改造结论
钩子切换 layout_body_end 完全可行 —— 规范 §9.1 #43 对该钩子的用途说明原文就包含「播放器」:
| 43 |
layout_body_end|app/Views/layouts/main.php:534| 无 |</body>前。全站所有页面触发(含后台)。给「需要输出 body 末尾 DOM」的插件用:弹窗 / 浮层 / 聊天 / 播放器。 |
需求与规范设计意图完全吻合。
⚠️ 但必须诚实说明一点:「改成悬浮窗」解决不了「切页卡顿」。
核心没有全局 htmx boost(
app/Views/layouts/main.php仅:266一处hx-get做收藏数懒加载),页面导航是整页跳转;而音频元素是
document.createElement('audio')(assets/script.js:76)游离于 DOM 之外。整页跳转 = 整个 JS 上下文销毁 = audio 元素一并销毁 → 音乐必然中断。
position: fixed只改变视觉位置,不改变跳转行为。详见 第八章。
悬浮窗的真实价值是:UI 位置固定(不随右栏消失)、交互状态不被跳转重置、视觉上「一直在播」。要真正做到「切页不断音」,需要 htmx boost 全站改造(高风险,建议单独立项)。
第一部分 审计与修复报告
一、插件概况
1.1 基本信息
| 项 | 值 | 出处 |
|---|---|---|
| 名称 / 版本 | 音乐播放器 / 1.0.0 | plugin.json:2-3 |
| 作者 | 读到 | plugin.json:4 |
| 描述 | 右侧边栏播放卡片,内置精选电台,支持拖拽排序、网易云导入与配置导入导出 | plugin.json:5 |
| 启用状态 | false(未启用) | plugin.json:6,plugins_cache.json 一致 |
| 后台入口 | /admin/soundplayer | plugin.json:9 |
| 权限声明 | route:admin | plugin.json:17-19 |
| 注册钩子 | 5 个 | plugin.json:10-16 |
| 数据表 | sp_tracks / sp_stations / sp_settings | Plugin.php:39-60 |
| 独立库 | plugins/soundplayer/data/soundplayer.sqlite | Plugin.php:18-22 |
1.2 文件与规模
| 文件 | 实际行数 | 职责 |
|---|---|---|
Plugin.php | 387 | 主类:DDL / 配置 / 数据 / 卡片渲染 / URL 校验 / 网易云解析 |
AdminController.php | 554 | 后台控制器,14 个 action 分发 |
views/admin.php | 566 | 后台视图,4 个 Tab + 编辑 <dialog> |
views/index.php | 26 | 前台独立页 /soundplayer |
assets/script.js | 984 | 播放器 + 后台管理两套逻辑 |
assets/style.css | 1030 | 全部样式 |
lang/{zh,zh_tw,en}.php | 各 154 键 | i18n,三语键数完全一致 ✅ |
hook/*.php | 6 个文件 | 见 1.3 |
| 合计 | 4293 行(不含 说明.md) |
1.3 钩子清单(实际)
| 钩子 | 文件 | 是否在 `plugin.json` 声明 | 触发点 |
|---|---|---|---|
init_after | hook/init_after.php | ✅ | 每请求 |
route_register | hook/route_register.php | ✅ | 路由注册期 |
admin_route_register | hook/admin_route_register.php | ✅ | 后台路由注册期 |
layout_head_end | hook/layout_head_end.php | ✅ | main.php:40 |
right_sidebar_after | hook/sidebar_card.php | ✅ | right_sidebar.php:211 |
~~sidebar_above_friend_links~~ | hook/sidebar_above_friend_links.php | ❌ 孤儿文件 | — |
1.4 客观门禁结果
python ~/.workbuddy-ai/skills/flinthub-plugin-dev/scripts/plugin_hygiene.py plugins/soundplayer| # | 检查项 | 结果 |
|---|---|---|
| 1-5 | 各 hook 文件无顶层符号声明 | PASS(6/6) |
| 6 | views/ + hook/ 零内联(无 <style>/<script>/on*=) | PASS |
| 7 | php -l 双版本(8.2 + 8.0) | PASS(14/14 × 2) |
| 8 | 危险函数(unserialize / eval / assert / preg_replace-e) | PASS(0 命中) |
| 9 | plugins_cache.json 一致性 + settings_cache 残留 | PASS |
| — | 无 CRLF(全 LF) | FAIL ← 说明.md 为 CRLF |
结论:FAIL = 1(仅 说明.md 换行符)。另跑 plugin_case_lint.py → PASS(但见 P0-2,该工具只验「路径可加载」,不验「核心硬拼类名能否命中」,扫不出这个缺陷)。
二、问题清单
P0-1 越权:AdminController 无管理员门禁
现象
plugins/soundplayer/AdminController.php:5-9:
use app\Core\Controller; // ← 注意:不是 Admin\BaseController
class AdminController extends Controller
{
public function index()
{
$this->requireLogin(); // ← 只判「是否登录」依据
app/Core/Controller.php:134-142 —— requireLogin() 只做登录判定:
protected function requireLogin()
{
$user = Auth::getCurrentUser();
if (!$user) {
\app\Helpers\Auth::rememberRedirectAfter();
$this->redirect('/login');
}
}真正的后台门禁在 app/Controllers/Admin/BaseController.php:16-23(构造函数):
public function __construct()
{
if (!Auth::isAdmin()) {
\app\Helpers\Auth::rememberRedirectAfter();
\header('Location: .../login');
exit;
}
// 之后还有 admin_verified_at 二次验证超时判定(:25-36)Auth::isAdmin() 口径(app/Helpers/Auth.php:20-27)是 AND 双条件:
return $user !== null
&& ($user['role'] ?? '') === 'admin'
&& (int)($user['group_id'] ?? 1) === 4;⚠️ 记忆红线:「插件自造管理员判定 = 越权高危(已复发 2)」→ 唯一口径就是
Auth::isAdmin()。
soundplayer这次不是「自造判定」,而是根本没做判定,性质更严重。
影响面
任何已登录的普通用户(group_id=1)直接访问 /admin/soundplayer 即可:
- 增删改曲目 / 电台(
sp_tracks/sp_stations) - 改配置(
enabled/default_volume/show_on_mobile/meta_interval) - 上传音频文件到服务器(
uploadsDir()) - 删除服务器上已有音频文件(
deleteLocalFileIfAny()) - 导入 / 导出配置、触发网易云拉取
plugin.json:17-19 只声明了 route:admin 权限 —— 但该权限只影响路由注册,不等于访问控制(规范 §5.3)。声明权限 ≠ 有门禁。
修复方案(二选一)
方案 A(推荐,与核心完全同口径):改继承 Admin\BaseController。
// plugins/soundplayer/AdminController.php
namespace Plugin\Soundplayer;
use app\Controllers\Admin\BaseController; // ← 改
class AdminController extends BaseController
{
public function index()
{
// 门禁已由父类构造函数完成(含 Auth::isAdmin() + 二次验证)
// 原来的 $this->requireLogin(); 可删除
...
}
}⚠️ 实测要点:
Admin\BaseController::__construct()是实例化时执行(不是继承时)。若写 CLI 探针验证,必须显式写
public function __construct(){}覆盖,否则探针会跑门禁 →无管理员态时
header()+exit→ 打印到一半停住、退出码仍 0(记忆红线「CLI 探针两个静默陷阱」①)。
方案 B(轻量):保留 extends Controller,把 $this->requireLogin() 换成 $this->requireAdmin()。
$this->requireAdmin(); // app/Core/Controller.php:144 → requireLogin() + Auth::isAdmin()方案 B 的代价:不含后台二次验证(
admin_verified_at),与核心后台页的安全等级不一致。若站点开启了「管理员二次验证」,方案 B 会成为一个绕过口。推荐方案 A。
验证方法
用非管理员账号(uid=262 之类的小号,或造一个 group_id=1 的测试号)注入 PHPSESSID
请求 GET /admin/soundplayer → 期望 302 → /login(方案 A)或 302 → /(方案 B),
且 sp_tracks 行数零变化(增量守卫)。测完 DELETE 掉测试会话。
P0-2 Plugin.php 缺 class_alias → 生命周期静默跳过
现象
目录名 = soundplayer(全小写),命名空间 = Plugin\Soundplayer(首字母大写)。
Plugin.php 文件末尾没有任何 class_alias(全文 grep 0 命中)。
依据 —— 完整机制链
- 核心按目录名硬拼类名(
app/Helpers/Plugin.php:699):
```php
$className = "\\Plugin\\{$name}\\Plugin"; // $name = 'soundplayer'
```
- 于是
class_exists('Plugin\soundplayer\Plugin')(:700、:751、:847)触发 autoload。 - autoloader 按 PSR-4 找到
plugins/soundplayer/Plugin.php(路径正确,文件能被加载)。 - 但该文件内声明的类名是
Plugin\Soundplayer\Plugin—— 与查询的Plugin\soundplayer\Plugin
不是同一个字符串 → class_exists() 返回 false。
- →
activate()/deactivate()/uninstall()三个分支全部静默跳过:不报错、无日志、HTTP 200。
⚠️ Windows 大小写不敏感 = 本机假通过。本机
file_exists()和 autoloader 都能命中文件,看起来「一切正常」;Linux(宝塔 / nginx)上路径同样能命中(因为目录名确实是小写),
但类名匹配依然失败 —— 这个缺陷与操作系统无关,是纯字符串不匹配。
plugin_case_lint.py之所以报 PASS,是因为它只验「路径能否被自动加载」,不验「核心硬拼的类名能否class_exists」。→ 该工具覆盖不到这一类缺陷,属工具盲区(值得记进规范)。
影响面
| 场景 | 后果 |
|---|---|
| 启用插件 | activate() 被跳过 → 不建表(靠 init_after 每请求 DDL 兜底,见 P1-1)、不建上传目录、不播种 30 个内置电台 |
| 停用插件 | deactivate() 被跳过(本身是 return true 空实现,无实际影响) |
| 卸载插件 | uninstall() 被跳过 → sp_tracks / sp_stations / sp_settings 三张表残留 + 上传目录文件残留 |
修复方案 —— 在 Plugin.php 文件末尾(类定义之后)追加:
/*
* ⚠️ 目录名 `soundplayer` 与命名空间 `Plugin\Soundplayer` 首字母大小写不一致
* → 核心 `Plugin::activate()/deactivate()/uninstall()` 用**目录名**硬拼类名
* (`\Plugin\soundplayer\Plugin`,见 app/Helpers/Plugin.php:699)→ `class_exists()` 恒为 false
* → 三个生命周期方法被静默跳过(不报错、无日志),卸载会留孤儿表。
* 故在此注册别名(规范 §4.1)。第三参传 false 避免触发 autoload 递归。
*/
if (!\class_exists('Plugin\\soundplayer\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\soundplayer\\Plugin', false);
}为什么有效:autoloader 能按 plugins/soundplayer/Plugin.php 找到并加载文件 →
加载瞬间 class_alias 执行 → 别名注册 → 此时 class_exists('Plugin\soundplayer\Plugin') 返回 true → 生命周期恢复。
对照范式:plugins/notice_center/Plugin.php:892-894(同款写法,含完整注释)。
验证方法
# 清缓存 → 重新加载 → 断言别名可用
php -r "require 'index.php'; var_dump(class_exists('Plugin\\soundplayer\\Plugin'));"
# 期望 bool(true)或走后台「插件 → 启用 soundplayer」→ 观察是否真的建表(sqlite_master 查三张表)+ 上传目录 + 电台播种。
P1-1 hook/init_after.php 每请求执行 4 条 DDL,无版本门控
现象
hook/init_after.php:43-46:
// 幂等建表兜底(ensureSchema 每次只接受单个 SQL 字符串)
foreach (\Plugin\Soundplayer\Plugin::ddlStatements() as $sql) {
\app\Helpers\Plugin::ensureSchema('soundplayer', $sql);
}Plugin::ddlStatements() 返回 5 条语句(3 表 + 2 索引,Plugin.php:38-63)。
ensureSchema() 实现(app/Helpers/Plugin.php:86-89):
public static function ensureSchema(string $plugin, string $sql): void
{
self::db($plugin)->exec($sql); // ← 无版本门控、无 marker
}依据
- 规范 §11.4 第 24 条:每页查询预算 ≤ 5
- 规范 §11.4 第 27 条:页面路径零 SQLite
- 规范 §11.15:建表门控带版本号
- 规范高频红线:「建表门控带版本号」
影响面
- 每请求 5 次
exec()(DDL 语句),且Plugin::db()每次调用都要is_dir()/file_exists()探测插件库路径。 CREATE TABLE IF NOT EXISTS/CREATE INDEX IF NOT EXISTS虽幂等,但仍要打开库、解析 SQL、查sqlite_master——
这是纯浪费,且吃掉了本可用于页面渲染的查询预算。
- 与 P0-2 叠加:因为
activate()被跳过,这 5 条 DDL 成了唯一的建表途径 ——
相当于把「一次性建表」降级成「每请求建表」。修了 P0-2 之后,这里应改为 marker 门控。
修复方案
按项目已有的「哈希 marker 门控」范式(同 ensureColumnPatches() 家族),改为一次性:
// Plugin.php
private const SCHEMA_VERSION = 1;
/** 一次性建表:marker 命中则直接返回(页面路径零 SQLite 查询) */
public static function ensureSchemaOnce(): void
{
$marker = self::dataDir() . '/schema.ok'; // plugins/soundplayer/data/schema.ok
if (\is_file($marker)) {
$v = \trim((string)@\file_get_contents($marker));
if ($v === (string)self::SCHEMA_VERSION) {
return; // ← 快路径:1 次 file_exists
}
}
foreach (self::ddlStatements() as $sql) {
\app\Helpers\Plugin::ensureSchema('soundplayer', $sql);
}
if (!\is_dir(self::dataDir())) {
@\mkdir(self::dataDir(), 0755, true);
}
@\file_put_contents($marker, (string)self::SCHEMA_VERSION, LOCK_EX);
}// hook/init_after.php 末尾
\Plugin\Soundplayer\Plugin::ensureSchemaOnce();⚠️ 改
SCHEMA_VERSION时必须手删旧schema.ok,或改为「版本号写入 marker 内容 + 比对」——上面的写法已经是「内容比对」,bump 版本号即可自动重建,无需手删。
验证方法
改后跑 GET / 三次,用 SQLite PRAGMA 或 query 计数探针断言首请求 5 条 DDL、后续请求 0 条。
P1-2 后台视图缺 admin-container 作用域 → 拿不到任何后台样式
现象
plugins/soundplayer/views/admin.php:19:
依据
assets/css/admin.css:4 注释原文:
所有选择器以 .admin-container / .admin-content / .admin-verify-wrapper 为作用域
核心后台视图 + 其他合规插件的统一写法(抽样对照):
// plugins/notice_center/views/admin.php:49-51
<?php $this->include('admin/_sidebar'); ?>
// plugins/netdisk_help/views/admin.php:51-53
<?php $this->include('admin/_sidebar'); ?>
// app/Views/admin/index.php:9-11(核心自身)
...
影响面
soundplayer 后台页没有左侧导航栏(缺 admin/_sidebar),且所有后台表单控件(.admin-table / 输入框 / 按钮 / Tab / 空状态)都拿不到核心样式 ——
因为核心后台 CSS 全部挂在 .admin-container / .admin-content 之下。
后果:后台页看起来是「裸 HTML」,Tab 不切换样式、表格无边框、按钮无样式。
插件自己的 sp-admin 前缀样式仍生效(所以不算全白),但视觉上明显与核心后台不一致。
规范 §11.3 第 22 条同族坑:「后台弹层 markup 必须留在 <main class="admin-content"> 内」(核心表单/按钮样式全是 .admin-content 作用域)。
修复方案
// plugins/soundplayer/views/admin.php:19
-
+
+ <?php $this->include('admin/_sidebar'); ?>
+
+
...(原有内容整体缩进一级)...
+
+
+
⚠️ 收尾时要配对闭合:原文件末尾的 </div> 需补两个 </div> + </main>。
⚠️ 改完必须跑一次 php -l(标签不配平不报语法错,但页面会错乱)。
P1-3 孤儿钩子文件 hook/sidebar_above_friend_links.php
现象
文件存在(5 行,313 字节),但未在 plugin.json 的 hooks 中声明:
// hook/sidebar_above_friend_links.php
// PC 右栏 + 移动侧栏各触发一次 —— ...
$html = \Plugin\Soundplayer\Plugin::cardHtml();
if ($html !== '') echo $html;
依据
规范 §9.1 #15:sidebar_above_friend_links 触发点有两个:
# 钩子名 触发点 说明 15 sidebar_above_friend_links_components/right_sidebar.php:263<br>_components/mobile_sidebar.php:37友链区块上方(PC 右栏 + 移动端侧栏各触发一次)
影响面
- 当前无害:未声明 = 核心不会加载它(
Plugin::hook() 按 plugin.json 声明的文件列表执行)。 - 一旦有人「补声明」就出事:会把播放器卡片注入移动端侧栏 ——
与 right_sidebar_after(PC 右栏)重复渲染同一张卡片,
且与 show_on_mobile 配置项的语义冲突(该配置本意是控制移动端是否显示)。
修复方案
直接删除该文件。理由:
- 它的功能已被
right_sidebar_after(hook/sidebar_card.php)覆盖; - 保留它是「未声明但有副作用潜力」的哑弹;
- 若将来真要支持移动侧栏,应通过
show_on_mobile 配置 + 单一钩子实现,而不是靠「PC + 移动双触发」。
删除属低风险(文件未被引用,删掉不影响任何已声明钩子)。建议连同 说明.md §六的钩子数一并修正。
P2-1 后台 GET/POST 共用 index 动作,偏离独立路由范式
现象(hook/admin_route_register.php):
$router->get('/soundplayer', ['\Plugin\Soundplayer\AdminController', 'index']);
$router->post('/soundplayer', ['\Plugin\Soundplayer\AdminController', 'index']);
两条路由指向同一个方法,动作靠 $_POST['action'] 在 handlePost()(AdminController.php:32 起)里分发。
依据:规范 §11.22 —— 后台路由应独立声明(如 save / delete / sort 各自一条),而非单入口 action 分发。
影响面:可读性 / 可测性下降;但功能正常,且 handlePost() 有 Csrf::verifyOrDie()(:36)兜底。不影响安全。
修复方案:重构为独立路由($router->post('/soundplayer/save', ...) 等)。
代价:要同步改 views/admin.php 的 14 处表单 action + script.js 的 AJAX 端点。建议暂缓,等有实际改动需求时一并做。
P2-2 11 个 --mn-* CSS 变量在 modern.css 中不存在
现象:assets/style.css 引用 21 个 --mn-* 变量,其中 11 个未定义(assets/css/modern.css 共定义 108 个):
幻影变量 style.css 出现次数 --mn-bg-soft10 --mn-radius9 --mn-radius-sm8 --mn-text-subtle7 --mn-fs-xs6 --mn-fs-xxs6 --mn-inverse3 --mn-focus-ring2 --mn-inverse-text2 --mn-error-subtle1 --mn-fs-sm1
依据:记忆红线「**CSS 只用既有 --mn-* 变量**」。
影响面:不致死 —— 全部写成 var(--mn-x, #fallback) 形式,有 fallback 兜底。
但在深色主题下,fallback 是硬编码的浅色值 → 色调可能与主题不搭(例如 --mn-inverse 取到浅色 fallback,在深色主题上对比度不足)。
修复方案:把 11 个变量替换为 modern.css 中语义等价的真实变量(需逐一比对),或直接删除 fallback 外层、只保留真实变量。
建议:随悬浮窗改造一并处理(反正要改 style.css)。
P2-3 说明.md 为 CRLF(门禁 FAIL)
现象:file 说明.md 显示含 \r。全仓其余插件 说明.md 均为 LF。
修复方案:转 LF。
# 用 PHP 转换(本机禁通配删除,单文件操作安全)
php -r "$f='plugins/soundplayer/说明.md'; file_put_contents($f, str_replace(\"\r\n\", \"\n\", file_get_contents($f)));"
P2-4 说明.md 多处内容过期(6 处)
# 位置 现状 实际 1 :101"activated": truefalse2 :287「插件共注册 5 个钩子」 目录里有 6 个 hook 文件(含孤儿,见 P1-3) 3 :122 / :300「right_sidebar_after 是唯一触发放置卡片的钩子」 与孤儿文件描述矛盾 4 :42 / :425声称「跨页续播」 实为 sessionStorage 续播兜底,但整页跳转会中断(详见第八章) 5 :738-749 附录 APlugin.php ~330 / AdminController.php ~430 / admin.php ~370 / script.js ~1300 / style.css ~800实际 387 / 554 / 566 / 984 / 1030 6 :559-566 §11.4只讲了「为什么不用 sidebar_above_friend_links」 未提及该文件实际存在于目录中
修复方案:逐条回读修正。若采纳悬浮窗改造,说明.md 需要大改(钩子清单 + §11.4 + 附录 + 新增悬浮窗章节)。
P2-5 respondOk / respondError 用单一 flash key
现象(AdminController.php:523-547):成功与失败共用 $_SESSION['soundplayer_flash']。
依据:notice_center 的范式(plugins/notice_center/AdminController.php:31-32)区分两个常量:
private const FLASH_ERROR = 'ntc_error';
private const FLASH_SUCCESS = 'ntc_success';
影响面:低。respondOk/Error 都是标准 PRG(写 session → redirect(),:533 / :546),
语义正确;只是后台视图无法区分「成功绿条」与「失败红条」(views/admin.php:23 统一渲染为 .admin-notice)。
修复方案:拆成两个 key,视图按 key 渲染不同 class。优先级最低,可随 P1-2 一起改。
P2-6 views/admin.php 无 section('css'),CSS 由 layout_head_end 向全站输出
现象:views/admin.php / views/index.php 中均无 $this->section('css')(grep 0 命中)。
插件 CSS 全部由 hook/layout_head_end.php:15 输出:
echo '';
注意这一行无条件输出 —— 只有 JS(:21)做了条件判断。
依据:规范 §11.4 —— 插件静态资源应按需加载;section('css') 是后台/独立页的推荐挂载点。
影响面:每个页面(含后台、含不渲染卡片的页面)都会多一个 <link> 请求(~1030 行 CSS)。
虽然浏览器会缓存,但首访 + 每次改版本号后都要下载全量样式。
修复方案:与悬浮窗改造一并做 ——
layout_head_end 改为「仅当会渲染悬浮窗时才输出」(Plugin::shouldRender() 短路,见第九章);
后台/独立页 CSS 改走 section('css')。
P2-7 <dialog> + position: fixed !important 反抗主题
现象:views/admin.php 使用 <dialog class="sound-player-dialog">,style.css 中用 position: fixed !important 强制居中。
依据:规范 §7.3 精神 —— 插件 UI 应融入核心主题,而非对抗。
影响面:低(后台页,管理员可见)。!important 会让主题层无法覆盖。
修复方案:随 P1-2(后台容器修正)一起调整。可暂缓。
三、修复优先级与工作量
优先级 编号 问题 类型 建议时机 P0 P0-1 无管理员门禁 安全 启用前必修 P0 P0-2 缺 class_alias 生命周期 启用前必修 P1 P1-1 每请求 5 条 DDL 性能 启用前修(与 P0-2 联动) P1 P1-2 后台视图缺 admin-container 视觉 启用前修 P1 P1-3 孤儿钩子文件 隐患 启用前删 P2 P2-2 11 个幻影 CSS 变量 主题 随悬浮窗改造 P2 P2-3 说明.md CRLF门禁 随 说明.md 更新 P2 P2-4 说明.md 6 处过期文档 随 说明.md 更新 P2 P2-6 CSS 全站无条件加载 性能 随悬浮窗改造 P3 P2-1 路由共用 index 风格 暂缓 P3 P2-5 单一 flash key 风格 暂缓 P3 P2-7 <dialog> 反主题视觉 暂缓
必修集(P0 + P1)= 5 项,改动面集中在 4 个文件:AdminController.php(1 行)、Plugin.php(追加 + 新增方法)、hook/init_after.php(1 行)、views/admin.php(容器包裹)、删 1 个文件。