[审计]soundplayer 插件审计与悬浮窗改造报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:40:44

本文由 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-1AdminController 只 requireLogin(),无管理员门禁任意登录用户可增删曲目、改配置、上传/删除服务器文件
P0-2Plugin.php 缺 class_aliasactivate()/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.0plugin.json:2-3
作者读到plugin.json:4
描述右侧边栏播放卡片,内置精选电台,支持拖拽排序、网易云导入与配置导入导出plugin.json:5
启用状态false(未启用)plugin.json:6,plugins_cache.json 一致
后台入口/admin/soundplayerplugin.json:9
权限声明route:adminplugin.json:17-19
注册钩子5 个plugin.json:10-16
数据表sp_tracks / sp_stations / sp_settingsPlugin.php:39-60
独立库plugins/soundplayer/data/soundplayer.sqlitePlugin.php:18-22

1.2 文件与规模

文件实际行数职责
Plugin.php387主类:DDL / 配置 / 数据 / 卡片渲染 / URL 校验 / 网易云解析
AdminController.php554后台控制器,14 个 action 分发
views/admin.php566后台视图,4 个 Tab + 编辑 <dialog>
views/index.php26前台独立页 /soundplayer
assets/script.js984播放器 + 后台管理两套逻辑
assets/style.css1030全部样式
lang/{zh,zh_tw,en}.php各 154 键i18n,三语键数完全一致 ✅
hook/*.php6 个文件见 1.3
合计4293 行(不含 说明.md)

1.3 钩子清单(实际)

钩子文件是否在 `plugin.json` 声明触发点
init_afterhook/init_after.php✅每请求
route_registerhook/route_register.php✅路由注册期
admin_route_registerhook/admin_route_register.php✅后台路由注册期
layout_head_endhook/layout_head_end.php✅main.php:40
right_sidebar_afterhook/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)
6views/ + hook/ 零内联(无 <style>/<script>/on*=)PASS
7php -l 双版本(8.2 + 8.0)PASS(14/14 × 2)
8危险函数(unserialize / eval / assert / preg_replace-e)PASS(0 命中)
9plugins_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 命中)。

依据 —— 完整机制链

  1. 核心按目录名硬拼类名(app/Helpers/Plugin.php:699):

```php

$className = "\\Plugin\\{$name}\\Plugin"; // $name = 'soundplayer'

```

  1. 于是 class_exists('Plugin\soundplayer\Plugin')(:700、:751、:847)触发 autoload。
  2. autoloader 按 PSR-4 找到 plugins/soundplayer/Plugin.php(路径正确,文件能被加载)。
  3. 但该文件内声明的类名是 Plugin\Soundplayer\Plugin —— 与查询的 Plugin\soundplayer\Plugin

不是同一个字符串 → class_exists() 返回 false。

  1. → 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:建表门控带版本号
  • 规范高频红线:「建表门控带版本号」

影响面

  1. 每请求 5 次 exec()(DDL 语句),且 Plugin::db() 每次调用都要 is_dir() / file_exists() 探测插件库路径。
  2. CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS 虽幂等,但仍要打开库、解析 SQL、查 sqlite_master ——

这是纯浪费,且吃掉了本可用于页面渲染的查询预算。

  1. 与 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 触发点有两个:

#钩子名触发点说明
15sidebar_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": truefalse
2: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(后台容器修正)一起调整。可暂缓。


三、修复优先级与工作量

优先级编号问题类型建议时机
P0P0-1无管理员门禁安全启用前必修
P0P0-2缺 class_alias生命周期启用前必修
P1P1-1每请求 5 条 DDL性能启用前修(与 P0-2 联动)
P1P1-2后台视图缺 admin-container视觉启用前修
P1P1-3孤儿钩子文件隐患启用前删
P2P2-211 个幻影 CSS 变量主题随悬浮窗改造
P2P2-3说明.md CRLF门禁随 说明.md 更新
P2P2-4说明.md 6 处过期文档随 说明.md 更新
P2P2-6CSS 全站无条件加载性能随悬浮窗改造
P3P2-1路由共用 index风格暂缓
P3P2-5单一 flash key风格暂缓
P3P2-7<dialog> 反主题视觉暂缓

必修集(P0 + P1)= 5 项,改动面集中在 4 个文件:AdminController.php(1 行)、Plugin.php(追加 + 新增方法)、hook/init_after.php(1 行)、views/admin.php(容器包裹)、删 1 个文件。


轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 2 | Replies 0

All Replies (0)

No replies yet

Please Log in