插件开发规范(更新)
目录
- 快速开始
- 目录结构
- 头部注释规范
- 生命周期回调
- 钩子机制
- 可用 API
- 伪静态路由
- 前台页面
- 后台页面
- 插件设置页
- 数据存储
- 富文本编辑器
- 图片与附件上传
- 安全规范
- 完整示例
- 常见问题
一、快速开始
最小可运行插件(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() |
| 类名 | CamelCase | ForumHelper |
| 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_option | option_name, option_value, autoload |
lt_user | uid, username, password, nickname, role |
lt_sort | sid, sortname, alias, pid |
lt_blog | gid, title, date, content, content_md, cover, author, sortid, views, comnum, hide |
lt_comment | cid, gid, pid, date, poster, comment, email, hide |
lt_tag | tid, tagname |
lt_tag_blog | gid, tid |
lt_link | id, 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"></></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。
使用
- 上传
content/plugins/guestbook/目录 - 后台 → 插件 → 启用"留言板"
- 访问
https://你的域名/guestbook
十六、常见问题
Q1:插件启用后没反应
- 检查目录结构:
content/plugins/你的插件名/你的插件名.php - 检查后台插件列表是否显示"已启用"
- 临时打开
display_errors看报错 - 检查钩子名拼写
Q2:钩子函数没被调用
- 钩子名是否完全一致(大小写敏感)
- 参数列表是否匹配(引用参数用
&$var) - 插件是否真的启用了
Q3:想在钩子里修改数据,但没生效
钩子参数中标注 &$var 的是引用传参,可以修改;其他是值传递。检查你注册的回调里参数是否带了 &。
Q4:伪静态 404
- Nginx 通用规则是否配置
- 插件是否注册了
dispatch_plugin_route - 插件名是否与目录名一致
- 测试:
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:如何调试插件?
- 临时打开错误显示:编辑
init.php,把error_reporting(E_ALL); ini_set('display_errors', 1); - 查看错误日志:
content/backup/plugin-error.log var_dump()+exit:在可疑位置打断点- F12 看 Console:检查 JS 报错和网络请求
Q8:插件和插件之间怎么交互?
两个插件可以互相触发自定义钩子:
php
// 插件 A:触发
Hook::doAction('my_shared_hook', $data);
// 插件 B:监听
Hook::addAction('my_shared_hook', 'PluginB_handler');
约定:自定义钩子名建议 插件名_xxx 前缀,避免与内核冲突。
Q9:怎么打包分发插件?
- 确保
插件名.php头部注释完整 - 目录结构规范
- 压缩为
.zip,根目录为插件目录 - 使用时解压到
content/plugins/即可
Q10:插件升级怎么做?
- 改
Plugin Name注释里的Version - 在
callback_up()里写数据迁移逻辑 - 更新表结构用
CREATE TABLE IF NOT EXISTS和ALTER TABLE - 用户重新启用即可触发
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)
发表评论