插件开发规范(更新)

2026-10-06 分类:Lite 作者:zhangwei 浏览 74

目录

  1. 快速开始
  2. 目录结构
  3. 头部注释规范
  4. 生命周期回调
  5. 钩子机制
  6. 可用 API
  7. 伪静态路由
  8. 前台页面
  9. 后台页面
  10. 插件设置页
  11. 数据存储
  12. 富文本编辑器
  13. 图片与附件上传
  14. 安全规范
  15. 完整示例
  16. 常见问题

一、快速开始

最小可运行插件(5 行)

在 content/plugins/hello/hello.php 创建:

php

<?php
/**
 * Plugin Name: Hello World
 * Description: 我的第一个插件
 * Version: 1.0.0
 * Author: 你的名字
 */
if (!defined('LITELOG_ROOT')) exit('error!');

Hook::addAction('template_footer', function() {
    echo '<div style="text-align:center;padding:10px;color:#999">Hello from my plugin!</div>';
});

启用:后台 → 插件 → 找到"Hello World" → 点击"启用"。


二、目录结构

标准结构

text

content/plugins/你的插件名/         ← 目录名
├── 你的插件名.php                 ← 主文件(必需)
├── 你的插件名_callback.php        ← 生命周期(可选)
├── 你的插件名_setting.php         ← 后台设置页(可选)
├── 你的插件名_show.php            ← 前台页面(可选)
├── assets/                        ← 静态资源(可选)
│   ├── style.css
│   └── editor.js
└── preview.jpg                    ← 插件图标(可选)

命名规则

项规则示例
目录名小写字母、数字、下划线、短横线forum、my-plugin
主文件名必须与目录名完全一致forum.php
函数名前缀 + 下划线Forum_render()
类名CamelCaseForumHelper
CSS 类插件名-xxx.forum-card

禁止事项

  • ❌ 不要用中文、空格、特殊符号命名
  • ❌ 不要把插件文件直接放在 content/plugins/ 根目录
  • ❌ 不要修改 LiteLog 内核文件

三、头部注释规范

主文件最顶部必须包含注释头:

php

<?php
/**
 * Plugin Name: 插件显示名称
 * Description: 一句话描述插件功能
 * Version: 1.0.0
 * Author: 作者名
 * Author URI: https://example.com
 */

字段说明

字段必填说明
Plugin Name✅后台显示的名称(也接受 Name)
Description否一句话描述
Version否版本号
Author否作者名
Author URI否作者主页(也接受 Author URL)

格式要求

  • 冒号必须是英文 :
  • 注释块必须在文件最前面
  • 用 /** ... / 或 / ... */
  • 描述中不要含 */

四、生命周期回调

在 插件名_callback.php 里定义:

php

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

/**
 * 插件启用时调用
 * 适合:初始化默认配置、建表
 */
function callback_init() {
    global $DB;

    // 建表
    $P = DB_PREFIX;
    $DB->query("CREATE TABLE IF NOT EXISTS `{$P}my_table` (
        `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        `title` VARCHAR(255),
        `create_time` BIGINT DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    // 默认配置
    if (Option::get('my_plugin_installed', '') === '') {
        Option::set('my_plugin_installed', time());
    }
}

/**
 * 插件更新时调用
 */
function callback_up() {
    // 版本迁移逻辑
}

/**
 * 插件删除时调用
 */
function callback_rm() {
    // 谨慎:这里可以清理数据
    // 建议保留数据,避免用户重新启用后丢失
}

关键约定

  • callback_init() 必须幂等:多次调用不产生副作用
  • 建表用 CREATE TABLE IF NOT EXISTS
  • 配置检查后再写入:if (Option::get('key', '') === '')
  • 核心自动执行:admin/index.php 的 plugin_toggle 会调用 callback_init()

双保险(推荐)

主文件加载时自查表是否存在,不存在就建表:

php

if (!function_exists('MyPlugin_ensureTables')) {
    function MyPlugin_ensureTables() {
        static $done = false;
        if ($done) return;
        $done = true;

        $flag = Option::get('my_plugin_ver', '');
        if ($flag === '1.0.0') return;

        $cb = __DIR__ . '/my_plugin_callback.php';
        if (is_file($cb)) {
            require_once $cb;
            if (function_exists('callback_init')) {
                try {
                    callback_init();
                    Option::set('my_plugin_ver', '1.0.0');
                } catch (Exception $e) {
                    @file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
                        '[' . date('Y-m-d H:i:s') . "] install failed\n" . $e->getMessage() . "\n\n",
                        FILE_APPEND | LOCK_EX);
                }
            }
        }
    }
}
MyPlugin_ensureTables();

五、钩子机制

5.1 基本用法

php

// 注册钩子
Hook::addAction($hookName, $callback, $priority = 10);

// 触发钩子
Hook::doAction($hookName, ...$args);

// 移除钩子
Hook::removeAction($hookName, $callback = null);

5.2 注册示例

php

// 匿名函数
Hook::addAction('template_footer', function() {
    echo '<p>页脚附加内容</p>';
});

// 普通函数
Hook::addAction('log_content', 'MyPlugin_renderLogFooter');
function MyPlugin_renderLogFooter($log) {
    echo '<div>文章 ID:' . (int)$log['gid'] . '</div>';
}

// 带优先级(数字小的先执行)
Hook::addAction('template_head', 'MyPlugin_css', 5);
Hook::addAction('template_head', 'MyPlugin_js',  20);

5.3 修改数据(引用传参)

php

Hook::addAction('log_list_query', function(&$cond, $page, $perPage) {
    // 首页只显示指定分类的文章
    if (empty($cond['sortid'])) {
        $cond['sortid'] = 2;
    }
});

5.4 完整钩子清单

见《LiteLog 程序说明文档》第十二节。

常用钩子速查:

场景钩子
页脚输出template_footer
文章内容前后log_content_before / log_content_after
列表查询前log_list_query
列表查询后log_list_result
后台菜单admin_menu
后台请求admin_request
插件路由dispatch_plugin_route
文章保存前log_save_before
评论提交前comment_save_before

六、可用 API

6.1 常量

php

BLOG_URL        // https://example.com/
TEMPLATE_URL    // https://example.com/content/templates/default/
CONTENT_PATH    // /www/site/content/
INCLUDE_PATH    // /www/site/include/
ADMIN_PATH      // /www/site/admin/
LITELOG_ROOT    // /www/site/
DB_PREFIX       // lt_

6.2 配置:Option

php

// 读取(带默认值)
$val = Option::get('site_title', 'Default');

// 写入
Option::set('my_plugin_key', 'value');

// 读取所有 autoload=y 的配置
$all = Option::all();

约定:插件配置项加插件前缀,如 my_plugin_xxx。

6.3 数据库

php

global $DB;

// 查询(预处理防注入)
$rows = $DB->fetchAll("SELECT * FROM " . DB_PREFIX . "blog WHERE hide='n' LIMIT 10");
$row  = $DB->fetchOne("SELECT * FROM " . DB_PREFIX . "blog WHERE gid=?", [1]);
$cnt  = $DB->fetchColumn("SELECT COUNT(*) FROM " . DB_PREFIX . "comment");

// 插入
$id = $DB->insert('blog', ['title' => '标题', 'date' => time()]);

// 更新
$DB->update('blog', ['title' => '新标题'], 'gid=?', [1]);

// 删除
$DB->delete('blog', 'gid=?', [1]);

// 原始查询
$stmt = $DB->query("SELECT * FROM " . DB_PREFIX . "user WHERE role=?", ['admin']);

注意:表名不要写 lt_blog,永远用 DB_PREFIX 拼接。

6.4 内置数据模型

php

// 文章
LogModel::query($page, $perPage, $cond, &$total);
LogModel::count();
LogModel::getOne($gid);
LogModel::getByAlias($alias);
LogModel::addView($gid);
LogModel::add($data);
LogModel::update($gid, $data);
LogModel::delete($gid);
LogModel::latest($limit = 10);
LogModel::prev($gid);
LogModel::next($gid);

// 分类
SortModel::all();
SortModel::getOne($sid);
SortModel::getByAlias($alias);
SortModel::countInSort($sid);

// 标签
TagModel::all();
TagModel::getByName($name);
TagModel::getByGid($gid);
TagModel::attach($gid, 'tag1,tag2');
TagModel::detach($gid);

// 评论
CommentModel::byLog($gid);
CommentModel::countByLog($gid);
CommentModel::add($data);
CommentModel::latest($limit = 10);

// 用户
UserModel::getOne($uid);
UserModel::getByUsername($username);
UserModel::login($username, $password);

6.5 URL 生成

php

Url::log($gid, $alias);    // https://ex.com/post/123.html
Url::sort($sid, $alias);   // https://ex.com/sort/tech
Url::tag($name);           // https://ex.com/tag/php
Url::author($uid);         // https://ex.com/author/1
Url::record('2026-10');    // https://ex.com/record/2026-10

6.6 通用函数

php

h($str);                     // HTML 转义输出
redirect($url);              // 302 跳转并退出
makeAlias($str);             // 生成 URL 友好字符串
dateFmt($timestamp);         // Y-m-d
datetimeFmt($timestamp);     // Y-m-d H:i
stripTags($html, $len);      // 去标签 + 截断
getExcerpt($html, $len);     // 提取摘要
isLoggedIn();                // 是否已登录
checkLogin();                // 未登录则跳转
getPageNumber();             // 当前页码
pagination($page, $total, $perPage);  // 分页 HTML

6.7 系统数据表

表主要字段
lt_optionoption_name, option_value, autoload
lt_useruid, username, password, nickname, role
lt_sortsid, sortname, alias, pid
lt_bloggid, title, date, content, content_md, cover, author, sortid, views, comnum, hide
lt_commentcid, gid, pid, date, poster, comment, email, hide
lt_tagtid, tagname
lt_tag_bloggid, tid
lt_linkid, sitename, siteurl, description

七、伪静态路由

7.1 目标

让插件通过 https://ex.com/插件名/子路径 访问,不需要改动 Nginx 配置。

7.2 前置:通用 Nginx 规则(一次性配置)

nginx

if (-f $request_filename) { break; }
if (-d $request_filename) { break; }

# LiteLog 核心路由
rewrite ^/post/(\d+)\.html$ /index.php?post=$1 last;
rewrite ^/post/([^/]+)\.html$ /index.php?alias=$1 last;
rewrite ^/sort/([^/]+)/?$ /index.php?sort_alias=$1 last;
rewrite ^/tag/([^/]+)/?$ /index.php?tag=$1 last;
rewrite ^/author/(\d+)/?$ /index.php?author=$1 last;
rewrite "^/record/(\d{4}-\d{2})/?$" /index.php?record=$1 last;

# 通用插件路由
rewrite ^/([a-zA-Z][a-zA-Z0-9_-]*)/?$ /index.php?__plugin_path=$1 last;
rewrite ^/([a-zA-Z][a-zA-Z0-9_-]*)/(.+)$ /index.php?__plugin_path=$1&__plugin_rest=$2 last;

7.3 插件实现路由

在主文件里:

php

Hook::addAction('dispatch_plugin_route', 'MyPlugin_dispatchRoute');

function MyPlugin_dispatchRoute($pluginName, $rest) {
    // ★ 改成你的插件目录名
    if ($pluginName !== 'my_plugin') return;

    // 标记已处理(防止核心误判为 404)
    $GLOBALS['__plugin_routed'] = true;

    // 首页:/my_plugin
    if ($rest === '') {
        MyPlugin_routeIndex();
        exit;
    }

    // /my_plugin/detail/123
    if (preg_match('|^detail/(\d+)$|', $rest, $m)) {
        $_GET['id'] = (int)$m[1];
        MyPlugin_routeDetail();
        exit;
    }

    // /my_plugin/edit/123
    if (preg_match('|^edit/(\d+)$|', $rest, $m)) {
        $_GET['id'] = (int)$m[1];
        MyPlugin_routeEdit();
        exit;
    }

    // 未匹配的子路径 → 交回核心(返回 404)
    $GLOBALS['__plugin_routed'] = false;
}

7.4 兼容老 URL

如果还想支持 ?action=my_plugin_xxx,可以同时注册 public_action 钩子:

php

Hook::addAction('public_action_my_plugin',      'MyPlugin_routeIndex');
Hook::addAction('public_action_my_plugin_list', 'MyPlugin_routeList');

7.5 路由生成函数

php

function myplugin_url($sub = '', $params = []) {
    $base = rtrim(BLOG_URL, '/') . '/my_plugin';
    if ($sub !== '') $base .= '/' . ltrim($sub, '/');
    if (!empty($params)) $base .= '?' . http_build_query($params);
    return $base;
}

// 使用
myplugin_url();                    // https://ex.com/my_plugin
myplugin_url('detail/123');        // https://ex.com/my_plugin/detail/123
myplugin_url('edit/123');          // https://ex.com/my_plugin/edit/123

八、前台页面

8.1 完整示例

php

function MyPlugin_routeIndex() {
    global $DB;

    $items = $DB->fetchAll("SELECT * FROM " . DB_PREFIX . "my_plugin_item ORDER BY id DESC LIMIT 20");

    MyPlugin_frontHeader('我的插件');
    MyPlugin_viewIndex(['items' => $items]);
    MyPlugin_frontFooter();
    exit;
}

function MyPlugin_frontHeader($title) {
    ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title>
<style>
/* 你的 CSS */
</style>
</head>
<body>
<header>
  <a href="<?= BLOG_URL ?>">返回主站</a>
  <a href="<?= myplugin_url() ?>">首页</a>
</header>
<main>
    <?php
}

function MyPlugin_frontFooter() {
    ?>
</main>
<footer>© <?= date('Y') ?> <?= Option::get('site_title') ?></footer>
</body>
</html>
    <?php
}

function MyPlugin_viewIndex($d) {
    extract($d, EXTR_SKIP);
    ?>
<h1>我的插件</h1>
<ul>
  <?php foreach ($items as $item): ?>
    <li><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></li>
  <?php endforeach; ?>
</ul>
    <?php
}

8.2 页面风格约定

推荐与博客/主题风格保持一致:

  • 顶部导航:浅色背景 + 返回主站按钮 + 插件 LOGO + 菜单 + 搜索
  • 内容区:浅色卡片,圆角 12~14px,柔和阴影
  • 主色:#2563eb(蓝)+ #7c3aed(紫)渐变
  • 文字色:#0f172a(深)、#64748b(中)、#94a3b8(浅)

8.3 返回主站按钮

html

<a class="myplugin-back-site" href="<?= BLOG_URL ?>" title="返回主站">
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2">
    <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
    <polyline points="9 22 9 12 15 12 15 22"/>
  </svg>
  <span>返回主站</span>
</a>

九、后台页面

9.1 添加菜单

php

Hook::addAction('admin_menu', 'MyPlugin_adminMenu');

function MyPlugin_adminMenu($activeKey) {
    $keys = ['my_plugin_list', 'my_plugin_edit'];
    $active = in_array($activeKey, $keys, true) ? ' active' : '';
    echo '<a class="nav-link' . $active . '" href="' . BLOG_URL . 'admin/?action=my_plugin_list">'
       . '<span class="nav-icon">🔧</span>'
       . '<span class="nav-label">我的插件</span></a>';
}

9.2 处理后台请求

php

Hook::addAction('admin_request', 'MyPlugin_adminRoute');

function MyPlugin_adminRoute($action) {
    if (strpos($action, 'my_plugin_') !== 0) return;

    switch ($action) {
        case 'my_plugin_list':   MyPlugin_adminList();   break;
        case 'my_plugin_edit':   MyPlugin_adminEdit();   break;
        case 'my_plugin_delete': MyPlugin_adminDelete(); break;
    }
}

9.3 后台页面示例

php

function MyPlugin_adminList() {
    global $DB;

    $page = max(1, (int)($_GET['page'] ?? 1));
    $perPage = 15;
    $total = (int)$DB->fetchColumn("SELECT COUNT(*) FROM " . DB_PREFIX . "my_plugin_item");
    $offset = ($page - 1) * $perPage;
    $items = $DB->fetchAll(
        "SELECT * FROM " . DB_PREFIX . "my_plugin_item
         ORDER BY id DESC LIMIT {$offset}, {$perPage}");

    ob_start();
    ?>
    <div class="table-wrap">
      <table class="table">
        <thead>
          <tr>
            <th style="width:60px">ID</th>
            <th>标题</th>
            <th style="width:160px">操作</th>
          </tr>
        </thead>
        <tbody>
          <?php if (empty($items)): ?>
            <tr><td colspan="3" style="text-align:center;color:#94a3b8;padding:40px">
              还没有数据
            </td></tr>
          <?php else: foreach ($items as $it): ?>
            <tr>
              <td><?= (int)$it['id'] ?></td>
              <td><?= h($it['title']) ?></td>
              <td class="row-actions">
                <a href="?action=my_plugin_edit&id=<?= (int)$it['id'] ?>">编辑</a>
                <a class="danger" href="?action=my_plugin_delete&id=<?= (int)$it['id'] ?>"
                   onclick="return confirm('确认删除?')">删除</a>
              </td>
            </tr>
          <?php endforeach; endif; ?>
        </tbody>
      </table>
    </div>
    <?= pagination($page, $total, $perPage) ?>
    <?php
    $content = ob_get_clean();
    $headerActions = '<a class="btn" href="?action=my_plugin_edit">+ 新增</a>';
    include ADMIN_PATH . 'views/layout.php';
    exit;
}

9.4 关键要点

  • 视图用 ob_start() 缓冲
  • 结尾 $content = ob_get_clean();
  • include ADMIN_PATH . 'views/layout.php';
  • $headerActions 可选,放右上角按钮
  • 所有输出用 h() 转义
  • 保存后 redirect() 防重复提交

十、插件设置页

10.1 文件

content/plugins/我的插件/我的插件_setting.php

10.2 完整示例

php

<?php
define('LITELOG_ROOT', dirname(__DIR__, 3) . '/');
require_once LITELOG_ROOT . 'init.php';
require_once ADMIN_PATH . 'helpers.php';

checkLogin();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    Option::set('my_plugin_color', trim($_POST['color'] ?? '#2563eb'));
    Option::set('my_plugin_text',  trim($_POST['text']  ?? ''));
    Option::set('my_plugin_count', (int)($_POST['count'] ?? 10));
    redirect(BLOG_URL . 'admin/?action=plugins&saved=1');
}

$color = Option::get('my_plugin_color', '#2563eb');
$text  = Option::get('my_plugin_text',  '欢迎访问本站');
$count = (int)Option::get('my_plugin_count', 10);

ob_start();
?>
<div class="card" style="max-width:640px">
  <div class="card-title">我的插件设置</div>
  <form method="post" class="form">
    <label>主题色</label>
    <input type="text" name="color" value="<?= h($color) ?>" placeholder="#2563eb">

    <label>展示文案</label>
    <input type="text" name="text" value="<?= h($text) ?>">

    <label>显示数量</label>
    <input type="number" name="count" value="<?= $count ?>" min="1" max="100">

    <div style="margin-top:20px">
      <button class="btn" type="submit">保存设置</button>
    </div>
  </form>
</div>
<?php
$content = ob_get_clean();
include ADMIN_PATH . 'views/layout.php';

10.3 关键点

  • dirname(DIR, 3) 从 content/plugins/插件名/ 往上 3 层到项目根
  • 必须 checkLogin() 防止未登录访问
  • 用 admin/helpers.php 提供的 adminIcon()、navLink() 保持一致风格

十一、数据存储

11.1 使用 Option(简单配置)

php

Option::set('my_plugin_key', 'value');
$val = Option::get('my_plugin_key', 'default');

适合:颜色、开关、文本等简单配置。
不适合:大量数据。

11.2 使用自定义表

在 callback_init() 里建表:

php

function callback_init() {
    global $DB;
    $P = DB_PREFIX;

    $DB->query("CREATE TABLE IF NOT EXISTS `{$P}my_plugin_item` (
        `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        `title` VARCHAR(255) NOT NULL,
        `content` TEXT,
        `uid` INT UNSIGNED DEFAULT 0,
        `status` TINYINT DEFAULT 1,
        `create_time` BIGINT DEFAULT 0,
        KEY `idx_uid` (`uid`),
        KEY `idx_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}

表名规范:{DB_PREFIX}插件名_表名,例如 lt_forum_thread。

11.3 文件存储

  • 上传文件:CONTENT_PATH . 'uploads/插件名/'
  • 缓存:CONTENT_PATH . 'cache/插件名_xxx.php'

十二、富文本编辑器

12.1 前端使用

html

<div class="forum-editor" data-editor="unique-id">
  <!-- 工具栏 -->
  <div class="forum-editor-toolbar">
    <button type="button" data-cmd="bold" title="加粗"><b>B</b></button>
    <button type="button" data-cmd="italic" title="斜体"><i>I</i></button>
    <button type="button" data-cmd="strikeThrough" title="删除线"><s>S</s></button>
    <span class="forum-tb-sep"></span>
    <button type="button" data-cmd="formatBlock" data-value="h3" title="标题">H</button>
    <button type="button" data-cmd="formatBlock" data-value="blockquote" title="引用">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3v3c0 2-2 3-4 3"/>
        <path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3v3c0 2-2 3-4 3"/>
      </svg>
    </button>
    <button type="button" data-cmd="inlineCode" title="行内代码">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <polyline points="16 18 22 12 16 6"/>
        <polyline points="8 6 2 12 8 18"/>
      </svg>
    </button>
    <span class="forum-tb-sep"></span>
    <button type="button" data-cmd="insertUnorderedList" title="无序列表">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <line x1="8" y1="6" x2="21" y2="6"/>
        <line x1="8" y1="12" x2="21" y2="12"/>
        <line x1="8" y1="18" x2="21" y2="18"/>
        <circle cx="4" cy="6" r="1"/>
        <circle cx="4" cy="12" r="1"/>
        <circle cx="4" cy="18" r="1"/>
      </svg>
    </button>
    <button type="button" data-cmd="createLink" title="链接">🔗</button>
    <button type="button" data-cmd="uploadImage" title="插入图片">🖼</button>
    <button type="button" data-cmd="uploadAttachment" title="上传附件">📎</button>
    <span class="forum-tb-sep"></span>
    <button type="button" data-cmd="removeFormat" title="清除格式">⌫</button>
  </div>
  <!-- 编辑区 -->
  <div class="forum-editor-body" contenteditable="true"
       data-placeholder="请输入内容..."></div>
</div>

12.2 引入 JS

html

<script>
window.MY_UPLOAD_URL     = "<?= BLOG_URL ?>admin/upload.php";
window.MY_ATTACHMENT_URL = "<?= myplugin_url('attachment') ?>";
</script>
<script src="<?= BLOG_URL ?>content/plugins/forum/assets/editor.js"></script>

12.3 表单提交

html

<form method="post" id="myForm">
  <input type="hidden" name="content" id="myContent" value="">
  <div class="forum-editor" data-editor="new">
    <!-- 工具栏和编辑区 -->
  </div>
  <button type="submit">提交</button>
</form>

<script>
document.getElementById('myForm').addEventListener('submit', function(e) {
    var editor = document.querySelector('[data-editor="new"]');
    if (editor && editor.__liteEditor) {
        var html = editor.__liteEditor.getHtml();
        document.getElementById('myContent').value = html;
        if (!html.replace(/<[^>]+>/g, '').trim()) {
            e.preventDefault();
            alert('请输入内容');
        }
    }
});
</script>

12.4 编辑器 API

方法说明
getHtml()获取 HTML 内容(去除末尾空白)
setHtml(html)设置 HTML 内容
focus()聚焦编辑器

12.5 支持的工具栏命令

data-cmd说明
bold加粗
italic斜体
underline下划线
strikeThrough删除线
formatBlock(配合 data-value)标题(h3)/ 引用(blockquote)
inlineCode行内代码
insertUnorderedList无序列表
insertOrderedList有序列表
createLink插入链接
uploadImage上传图片
uploadAttachment上传附件
removeFormat清除格式

12.6 图标规范(与核心一致)

元素尺寸
工具栏按钮44 × 44 px
B/I/S 字号18px Georgia
SVG 图标20 × 20 px
按钮圆角8px
悬停背景#e8eef7
悬停颜色#2563eb
按下效果scale(.95)

工具栏 CSS:

css

.forum-editor{background:#fff;border:1px solid #e5e9f0;border-radius:10px;box-shadow:0 1px 3px rgba(15,23,42,.06)}
.forum-editor-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:2px;padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e5e9f0;user-select:none;border-radius:10px 10px 0 0;position:relative;z-index:1}
.forum-editor-toolbar button{background:transparent;border:0;border-radius:8px;min-width:44px;height:44px;padding:0 12px;cursor:pointer;color:#475569;font-size:16px;font-weight:600;font-family:inherit;line-height:1;display:inline-flex;align-items:center;justify-content:center;transition:background .15s,color .15s,transform .08s}
.forum-editor-toolbar button:hover{background:#e8eef7;color:#2563eb}
.forum-editor-toolbar button:active{background:#d5e2f5;transform:scale(.95)}
.forum-editor-toolbar button b,.forum-editor-toolbar button i,.forum-editor-toolbar button u,.forum-editor-toolbar button s{font-size:18px;font-family:Georgia,"Times New Roman",serif}
.forum-editor-toolbar button svg{width:20px;height:20px}
.forum-editor-toolbar .forum-tb-sep{width:1px;height:24px;background:#dde3ec;margin:0 6px;display:inline-block}
.forum-editor-body{min-height:200px;padding:18px 22px;font-size:15px;line-height:1.8;color:#1e293b;outline:0;overflow-y:auto;max-height:500px;cursor:text;border-radius:0 0 10px 10px}
.forum-editor-body:empty::before{content:attr(data-placeholder);color:#cbd5e1;pointer-events:none}

重要:.forum-editor 不要 overflow: hidden,否则工具栏会遮挡编辑区。


十三、图片与附件上传

13.1 图片上传(复用核心)

html

<script>
window.MY_UPLOAD_URL = "<?= BLOG_URL ?>admin/upload.php";
</script>

核心 admin/upload.php 已支持:

  • jpg/jpeg/png/gif/webp/bmp
  • 最大 5MB
  • getimagesize() 校验
  • 返回 {ok: true, url: '...'}

13.2 附件上传(自己实现)

路由:

php

if ($rest === 'attachment') { MyPlugin_routeAttachment(); exit; }

处理:

php

function MyPlugin_routeAttachment() {
    header('Content-Type: application/json; charset=utf-8');

    if (empty($_SESSION['uid'])) {
        echo json_encode(['ok' => false, 'msg' => '请先登录'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    if (empty($_FILES['file'])) {
        echo json_encode(['ok' => false, 'msg' => '没有文件'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    $f = $_FILES['file'];
    if ($f['error'] !== UPLOAD_ERR_OK) {
        echo json_encode(['ok' => false, 'msg' => '上传失败'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($f['size'] > 20 * 1024 * 1024) {
        echo json_encode(['ok' => false, 'msg' => '超过 20MB'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));

    // 黑名单
    $blacklist = ['php','php3','phtml','jsp','asp','aspx','cgi','exe','sh','bat','js','html','htm','svg'];
    if (in_array($ext, $blacklist, true)) {
        echo json_encode(['ok' => false, 'msg' => '禁止上传 .' . $ext], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // 白名单
    $whitelist = ['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','csv',
                  'zip','rar','7z','tar','gz','bz2',
                  'jpg','jpeg','png','gif','webp','bmp',
                  'mp3','wav','ogg','m4a','mp4','webm','mov'];
    if (!in_array($ext, $whitelist, true)) {
        echo json_encode(['ok' => false, 'msg' => '不支持 .' . $ext], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // 文件名清洗
    $origName = $f['name'];
    $baseName = preg_replace('/[\x00-\x1f\x7f\/\\\\:*?"<>|]/', '_', pathinfo($origName, PATHINFO_FILENAME));

    // 存储
    $sub = 'my_plugin/' . date('Y/m');
    $dir = CONTENT_PATH . 'uploads/' . $sub;
    if (!is_dir($dir)) @mkdir($dir, 0755, true);

    $storedName = date('YmdHis') . '_' . substr(md5(uniqid('', true)), 0, 8) . '.' . $ext;
    if (!move_uploaded_file($f['tmp_name'], rtrim($dir, '/') . '/' . $storedName)) {
        echo json_encode(['ok' => false, 'msg' => '保存失败'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    echo json_encode([
        'ok'   => true,
        'url'  => BLOG_URL . 'content/uploads/' . $sub . '/' . $storedName,
        'name' => $origName,
        'size' => $f['size'],
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

13.3 前端自动处理

editor.js 已经封装好:

  • 点击 📎 → 弹文件选择
  • 拖拽文件到编辑器 → 自动上传
  • 上传成功后插入附件卡片

前提:设置 window.MY_ATTACHMENT_URL。


十四、安全规范

14.1 SQL

php

// ✅ 正确
$DB->fetchOne("SELECT * FROM " . DB_PREFIX . "user WHERE uid=?", [$uid]);

// ❌ 错误
$DB->fetchOne("SELECT * FROM " . DB_PREFIX . "user WHERE uid=" . $uid);

14.2 HTML 输出

php

// ✅ 正确
echo h($userInput);

// ❌ 错误
echo $userInput;

14.3 HTML 白名单

接收用户提交的 HTML 时,必须清洗:

php

function myplugin_clean_html($html) {
    // 移除危险标签
    $html = preg_replace('/<(script|style|iframe|object|embed|form|input|button)\b[^>]*>.*?<\/\1>/is', '', $html);

    if (!class_exists('DOMDocument')) {
        $html = preg_replace('/\s+on\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html);
        return trim($html);
    }

    $allowed = ['b','strong','i','em','u','s','del','code','pre','blockquote',
                'a','img','p','br','ul','ol','li','h2','h3','h4','span','div','hr'];

    $doc = new DOMDocument();
    libxml_use_internal_errors(true);
    $doc->loadHTML('<?xml encoding="UTF-8"><div id="__fw">' . $html . '</div>',
        LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    libxml_clear_errors();

    $wrap = $doc->getElementById('__fw');
    if (!$wrap) return '';

    myplugin_clean_node($wrap, $allowed);

    $out = '';
    foreach ($wrap->childNodes as $child) {
        $out .= $doc->saveHTML($child);
    }
    return trim($out);
}

function myplugin_clean_node($node, $allowed) {
    $children = [];
    foreach ($node->childNodes as $child) $children[] = $child;
    foreach ($children as $child) {
        if ($child->nodeType === XML_ELEMENT_NODE) {
            $tag = strtolower($child->nodeName);
            if (!in_array($tag, $allowed, true)) {
                while ($child->firstChild) $node->insertBefore($child->firstChild, $child);
                $node->removeChild($child);
                continue;
            }
            if ($child->hasAttributes()) {
                $remove = [];
                foreach ($child->attributes as $attr) {
                    $name = strtolower($attr->name);
                    $keep = false;
                    if ($tag === 'a' && in_array($name, ['href','title'], true)) $keep = true;
                    if ($tag === 'img' && in_array($name, ['src','alt','title'], true)) $keep = true;
                    if (!$keep) $remove[] = $attr->name;
                }
                foreach ($remove as $rn) $child->removeAttribute($rn);
            }
            myplugin_clean_node($child, $allowed);
        }
    }
}

14.4 权限校验

php

// 登录校验
if (empty($_SESSION['uid'])) redirect(BLOG_URL . 'admin/?action=login');

// 管理员校验
if (($_SESSION['role'] ?? '') !== 'admin') exit('无权操作');

// 所有权校验
$item = $DB->fetchOne("SELECT * FROM " . DB_PREFIX . "my_item WHERE id=?", [$id]);
if ($item['uid'] != $_SESSION['uid']) exit('无权编辑');

14.5 文件上传

  • 扩展名白名单 + 黑名单二次过滤
  • 文件名清洗:去掉 \x00-\x1f / \ : * ? " < > |
  • 随机存储名:时间戳 + hash
  • 图片校验:getimagesize()
  • 大小限制:服务器端和前端双重校验

14.6 目录名校验

php

function isValidName($name) {
    return preg_match('/^[a-zA-Z0-9_\-]+$/', $name) === 1;
}

十五、完整示例

功能:留言板插件。

目录结构

text

content/plugins/guestbook/
├── guestbook.php
├── guestbook_callback.php
├── guestbook_setting.php
└── assets/editor.js

guestbook.php

php

<?php
/**
 * Plugin Name: 留言板
 * Description: 一个简单的留言板,支持富文本编辑
 * Version: 1.0.0
 * Author: LiteLog
 */

if (!defined('LITELOG_ROOT')) exit('error!');

define('GUESTBOOK_VER', '1.0.0');

if (!function_exists('Guestbook_ensureTables')) {
    function Guestbook_ensureTables() {
        static $done = false;
        if ($done) return;
        $done = true;
        if (Option::get('guestbook_ver', '') === GUESTBOOK_VER) return;
        $cb = __DIR__ . '/guestbook_callback.php';
        if (is_file($cb)) {
            require_once $cb;
            if (function_exists('callback_init')) {
                try {
                    callback_init();
                    Option::set('guestbook_ver', GUESTBOOK_VER);
                } catch (Exception $e) {}
            }
        }
    }
}
Guestbook_ensureTables();

Hook::addAction('dispatch_plugin_route', 'Guestbook_route');
Hook::addAction('public_action_guestbook', 'Guestbook_routeIndex');

function Guestbook_url($sub = '') {
    return rtrim(BLOG_URL, '/') . '/guestbook' . ($sub ? '/' . $sub : '');
}

function Guestbook_route($pluginName, $rest) {
    if ($pluginName !== 'guestbook') return;
    $GLOBALS['__plugin_routed'] = true;

    if ($rest === '') { Guestbook_routeIndex(); exit; }
    if ($rest === 'post') { Guestbook_routePost(); exit; }

    $GLOBALS['__plugin_routed'] = false;
}

function Guestbook_routeIndex() {
    global $DB;
    $page = max(1, (int)($_GET['page'] ?? 1));
    $perPage = (int)Option::get('guestbook_per_page', 20);
    $total = (int)$DB->fetchColumn("SELECT COUNT(*) FROM " . DB_PREFIX . "guestbook WHERE status=1");
    $offset = ($page - 1) * $perPage;
    $items = $DB->fetchAll(
        "SELECT * FROM " . DB_PREFIX . "guestbook WHERE status=1
         ORDER BY id DESC LIMIT {$offset}, {$perPage}");

    Guestbook_frontHeader('留言板');
    ?>
    <div class="guestbook-wrap">
      <h1>留言板</h1>

      <?php if (!empty($_SESSION['uid'])): ?>
        <form method="post" action="<?= Guestbook_url('post') ?>" id="guestbookForm">
          <input type="hidden" name="content" id="guestbookContent" value="">
          <div class="forum-editor" data-editor="guestbook">
            <div class="forum-editor-toolbar">
              <button type="button" data-cmd="bold"><b>B</b></button>
              <button type="button" data-cmd="italic"><i>I</i></button>
              <button type="button" data-cmd="strikeThrough"><s>S</s></button>
              <span class="forum-tb-sep"></span>
              <button type="button" data-cmd="formatBlock" data-value="blockquote">❝</button>
              <button type="button" data-cmd="inlineCode">&lt;/&gt;</button>
              <span class="forum-tb-sep"></span>
              <button type="button" data-cmd="insertUnorderedList">•</button>
              <button type="button" data-cmd="createLink">🔗</button>
              <button type="button" data-cmd="uploadImage">🖼</button>
            </div>
            <div class="forum-editor-body" contenteditable="true"
                 data-placeholder="写留言..."></div>
          </div>
          <button class="guestbook-btn" type="submit">提交留言</button>
        </form>
      <?php else: ?>
        <p>请先<a href="<?= BLOG_URL ?>admin/?action=login">登录</a></p>
      <?php endif; ?>

      <div class="guestbook-list">
        <?php foreach ($items as $item): ?>
          <div class="guestbook-item">
            <div class="guestbook-item-head">
              <strong><?= h($item['poster']) ?></strong>
              <span><?= date('Y-m-d H:i', $item['create_time']) ?></span>
            </div>
            <div class="guestbook-item-content"><?= $item['content'] ?></div>
          </div>
        <?php endforeach; ?>
      </div>

      <?= pagination($page, $total, $perPage) ?>
    </div>

    <script>
    window.GUESTBOOK_UPLOAD_URL = "<?= BLOG_URL ?>admin/upload.php";
    </script>
    <script src="<?= BLOG_URL ?>content/plugins/guestbook/assets/editor.js"></script>
    <script>
    document.getElementById('guestbookForm') && document.getElementById('guestbookForm').addEventListener('submit', function(e){
        var editor = document.querySelector('[data-editor="guestbook"]');
        if (editor && editor.__liteEditor) {
            document.getElementById('guestbookContent').value = editor.__liteEditor.getHtml();
        }
    });
    </script>
    <?php
    Guestbook_frontFooter();
    exit;
}

function Guestbook_routePost() {
    if (empty($_SESSION['uid'])) redirect(Guestbook_url());

    $content = $_POST['content'] ?? '';
    // 简单清洗
    $content = preg_replace('/<script[\s\S]*?<\/script>/i', '', $content);
    $content = preg_replace('/\s+on\w+\s*=\s*"[^"]*"/i', '', $content);
    $content = trim($content);

    if ($content !== '' && mb_strlen(strip_tags($content), 'UTF-8') >= 1) {
        global $DB;
        $u = UserModel::getOne($_SESSION['uid']);
        $DB->insert('guestbook', [
            'uid'         => (int)$_SESSION['uid'],
            'poster'      => $u ? ($u['nickname'] ?: $u['username']) : '用户',
            'content'     => $content,
            'status'      => 1,
            'create_time' => time(),
        ]);
    }
    redirect(Guestbook_url());
}

function Guestbook_frontHeader($title) {
    ?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title>
<style>
*{box-sizing:border-box}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#f6f7f9;margin:0;line-height:1.7;color:#333}
.guestbook-wrap{max-width:800px;margin:0 auto;padding:40px 20px}
h1{font-size:26px;margin:0 0 20px}
.guestbook-btn{display:inline-block;padding:10px 24px;background:#2563eb;color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:14px;font-family:inherit;margin-top:12px}
.guestbook-list{margin-top:28px}
.guestbook-item{background:#fff;border-radius:12px;padding:20px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.guestbook-item-head{display:flex;justify-content:space-between;color:#94a3b8;font-size:13px;margin-bottom:10px}
.guestbook-item-head strong{color:#0f172a;font-size:14.5px}
</style>
</head>
<body>
    <?php
}

function Guestbook_frontFooter() {
    ?>
</body>
</html>
    <?php
}

guestbook_callback.php

php

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

function callback_init() {
    global $DB;
    $P = DB_PREFIX;
    $DB->query("CREATE TABLE IF NOT EXISTS `{$P}guestbook` (
        `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        `uid` INT UNSIGNED DEFAULT 0,
        `poster` VARCHAR(60) DEFAULT '',
        `content` LONGTEXT,
        `status` TINYINT DEFAULT 1,
        `create_time` BIGINT DEFAULT 0,
        KEY `idx_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    if (Option::get('guestbook_per_page', '') === '') {
        Option::set('guestbook_per_page', '20');
    }
}

guestbook_setting.php

php

<?php
define('LITELOG_ROOT', dirname(__DIR__, 3) . '/');
require_once LITELOG_ROOT . 'init.php';
require_once ADMIN_PATH . 'helpers.php';

checkLogin();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    Option::set('guestbook_per_page', (int)($_POST['per_page'] ?? 20));
    redirect(BLOG_URL . 'admin/?action=plugins&saved=1');
}

ob_start();
?>
<div class="card" style="max-width:640px">
  <div class="card-title">留言板设置</div>
  <form method="post" class="form">
    <label>每页显示数量</label>
    <input type="number" name="per_page" value="<?= (int)Option::get('guestbook_per_page', 20) ?>" min="1">
    <button class="btn" type="submit">保存</button>
  </form>
</div>
<?php
$content = ob_get_clean();
include ADMIN_PATH . 'views/layout.php';

assets/editor.js

从论坛插件拷贝 content/plugins/forum/assets/editor.js。

使用

  1. 上传 content/plugins/guestbook/ 目录
  2. 后台 → 插件 → 启用"留言板"
  3. 访问 https://你的域名/guestbook

十六、常见问题

Q1:插件启用后没反应

  1. 检查目录结构:content/plugins/你的插件名/你的插件名.php
  2. 检查后台插件列表是否显示"已启用"
  3. 临时打开 display_errors 看报错
  4. 检查钩子名拼写

Q2:钩子函数没被调用

  1. 钩子名是否完全一致(大小写敏感)
  2. 参数列表是否匹配(引用参数用 &$var)
  3. 插件是否真的启用了

Q3:想在钩子里修改数据,但没生效

钩子参数中标注 &$var 的是引用传参,可以修改;其他是值传递。检查你注册的回调里参数是否带了 &。

Q4:伪静态 404

  1. Nginx 通用规则是否配置
  2. 插件是否注册了 dispatch_plugin_route
  3. 插件名是否与目录名一致
  4. 测试:https://ex.com/index.php?__plugin_path=你的插件名

Q5:编辑器工具栏遮挡编辑区

原因:.forum-editor 有 overflow: hidden,与工具栏的 position: sticky 冲突。

解决:

  • 去掉 .forum-editor 的 overflow: hidden
  • 工具栏用 position: relative 而不是 sticky

Q6:编辑器图标太小

使用规范里的 CSS:

css

.forum-editor-toolbar button {
    min-width: 44px;
    height: 44px;
    font-size: 16px;
}
.forum-editor-toolbar button svg { width: 20px; height: 20px; }
.forum-editor-toolbar button b,
.forum-editor-toolbar button i,
.forum-editor-toolbar button s { font-size: 18px; font-family: Georgia, serif; }

Q7:如何调试插件?

  1. 临时打开错误显示:编辑 init.php,把 error_reporting(E_ALL); ini_set('display_errors', 1);
  2. 查看错误日志:content/backup/plugin-error.log
  3. var_dump() + exit:在可疑位置打断点
  4. F12 看 Console:检查 JS 报错和网络请求

Q8:插件和插件之间怎么交互?

两个插件可以互相触发自定义钩子:

php

// 插件 A:触发
Hook::doAction('my_shared_hook', $data);

// 插件 B:监听
Hook::addAction('my_shared_hook', 'PluginB_handler');

约定:自定义钩子名建议 插件名_xxx 前缀,避免与内核冲突。

Q9:怎么打包分发插件?

  1. 确保 插件名.php 头部注释完整
  2. 目录结构规范
  3. 压缩为 .zip,根目录为插件目录
  4. 使用时解压到 content/plugins/ 即可

Q10:插件升级怎么做?

  1. 改 Plugin Name 注释里的 Version
  2. 在 callback_up() 里写数据迁移逻辑
  3. 更新表结构用 CREATE TABLE IF NOT EXISTS 和 ALTER TABLE
  4. 用户重新启用即可触发 callback_up()

附录:规范检查清单

开发完插件后,请自查:

  • □
    目录名 = 主文件名,纯英文小写
  • □
    主文件顶部有 Plugin Name 等注释
  • □
    主文件有 if (!defined('LITELOG_ROOT')) exit('error!');
  • □
    建表逻辑在 _callback.php 的 callback_init()
  • □
    主文件顶部有自检建表逻辑(双保险)
  • □
    所有 SQL 用预处理
  • □
    所有用户可控输出用 h()
  • □
    所有输入 HTML 用白名单清洗
  • □
    后台页面 checkLogin()
  • □
    编辑/删除操作校验所有权
  • □
    文件上传用白名单 + 文件名清洗
  • □
    伪静态路由用 dispatch_plugin_route 钩子
  • □
    老 URL 用 public_action_xxx 兜底
  • □
    前台页面有"返回主站"按钮
  • □
    编辑器图标尺寸与核心一致(44px/20px/18px)
  • □
    设置页保存后 redirect() 防重复提交
  • □
    插件错误写入 content/backup/plugin-error.log
  • □
    卸载时数据保留(不主动删除,避免误删)

评论 (0)

还没有评论,来说点什么吧~

发表评论

正在回复
已有账号?登录后评论