LiteLog 小工具(Widget)开发文档
版本:1.0 适用范围:LiteLog 内核 ≥ 1.0 更新日期:2026-10-08
目录
1. 系统概述
LiteLog 小工具(Widget)系统仿照 WordPress 小工具概念,允许用户从后台通过拖拽方式,把可复用的功能块添加到网站的侧栏区域。
核心特性:
- ✅ 注册即用:定义类 + 一行注册代码,后台自动出现
- ✅ 多实例:同一小工具可在同一区域重复添加,各自独立配置
- ✅ 拖拽排序:后台拖拽调整顺序
- ✅ 配置面板:每个实例独立配置参数
- ✅ 条件显示:根据其他配置项动态显示/隐藏表单字段
- ✅ 区域化:支持多个区域(左栏、右栏),可扩展
- ✅ 零污染:样式与数据完全隔离,不影响主题与其他插件
2. 核心概念
| 概念 | 说明 |
|---|---|
| Widget | 一个小工具实例的类定义,继承 Widget 基类 |
| WidgetRegistry | 全局注册表,收集所有已注册的小工具 |
| WidgetArea | 小工具区域(如 left_sidebar、right_sidebar) |
| Instance | 用户拖拽到区域中的一个小工具"副本",拥有独立 ID 与配置 |
| Settings | 某个实例的配置数据(键值对数组) |
数据流
插件加载
→ 定义 Widget 类
→ Hook::addAction('register_widgets', 回调)
→ WidgetRegistry::init() 触发钩子
→ 注册 Widget 到注册表
后台小工具页
→ WidgetRegistry::all() 获取全部可用小工具
→ 用户拖拽到区域 → 保存到 Option(JSON)
前台渲染
→ WidgetArea::render($area)
→ 读取该区域的实例列表
→ 逐个调用 widget->render($settings, $instanceId)
3. 目录结构
推荐在插件的独立文件中定义 Widget:
content/plugins/your-plugin/
├── your-plugin.php # 插件主文件
├── widget_your.php # 小工具定义(推荐)
└── assets/
└── your-widget.css # 可选:外部样式
在插件主文件中注册:
Hook::addAction('register_widgets', 'YourPlugin_registerWidgets');
function YourPlugin_registerWidgets() {
if (!class_exists('WidgetRegistry')) return;
$file = __DIR__ . '/widget_your.php';
if (is_file($file)) require_once $file;
if (class_exists('Your_Widget')) {
WidgetRegistry::register(new Your_Widget());
}
}
4. Widget 基类 API
4.1 可覆写的属性
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
$id | string | ✅ | 唯一标识,仅允许 [a-z0-9_],建议 插件名_功能名 |
$name | string | ✅ | 后台显示名称 |
$description | string | ⬜ | 后台显示描述 |
$author | string | ⬜ | 作者名 |
$version | string | ⬜ | 版本号 |
$source | string | ⬜ | 来源标识,建议 plugin:插件名 |
4.2 必须实现的方法
abstract public function render($settings, $instanceId);
| 参数 | 类型 | 说明 |
|---|---|---|
$settings | array | 当前实例的配置数据 |
$instanceId | string | 当前实例的唯一 ID(用于 DOM 定位、事件绑定) |
4.3 可覆写的方法
// 默认配置(后台首次添加时使用)
public function defaults() { return []; }
// 后台配置表单(HTML 输出)
public function form($settings) { echo '<p>无可配置项</p>'; }
// 当前是否可用(如依赖插件未启用则返回 false)
public function isAvailable() { return true; }
4.4 父类提供的辅助方法
| 方法 | 说明 |
|---|---|
$this->h($str) | HTML 转义 |
$this->output($instanceId, $title, $content, $extraClass = '') | 输出标准外壳(含标题 + 内容) |
$this->fieldText($name, $value, $label = '', $placeholder = '') | 生成文本输入字段 |
$this->fieldNumber($name, $value, $label = '', $min = 1, $max = 100) | 生成数字输入字段 |
$this->fieldSelect($name, $value, $options, $label = '') | 生成下拉选择字段 |
5. 快速开始:第一个小工具
一个「站点公告」小工具,显示一段可配置的公告文字。
<?php
// content/plugins/your-plugin/widget_notice.php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('Widget')) return;
class Widget_Notice extends Widget {
public $id = 'notice';
public $name = '站点公告';
public $description = '显示一段可自定义的公告文字';
public $version = '1.0.0';
public $source = 'plugin:your-plugin';
// 默认配置
public function defaults() {
return [
'title' => '公告',
'content' => '欢迎来到本站!',
'style' => 'info',
];
}
// 后台配置表单
public function form($s) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
$this->fieldText('title', $s['title'], '标题');
echo '<p class="wg-field">';
echo '<label>公告内容</label>';
echo '<textarea data-setting="content" rows="3" '
. 'style="width:100%;padding:8px 11px;border:1px solid #dfe3e8;border-radius:7px;'
. 'font-family:inherit;font-size:13.5px;box-sizing:border-box">'
. $this->h($s['content']) . '</textarea></p>';
$this->fieldSelect('style', $s['style'], [
'info' => '普通提示',
'warning' => '警告',
'success' => '成功',
], '样式');
}
// 前台渲染
public function render($s, $instanceId) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
$cls = 'wg-notice wg-notice-' . preg_replace('/[^a-z]/', '', $s['style']);
$body = '<div class="' . $cls . '">'
. nl2br($this->h($s['content']))
. '</div>';
$this->output($instanceId, $s['title'], $body);
}
}
在插件主文件中注册:
Hook::addAction('register_widgets', function () {
if (!class_exists('WidgetRegistry')) return;
require_once __DIR__ . '/widget_notice.php';
WidgetRegistry::register(new Widget_Notice());
});
效果:
- 后台「小工具」页面出现「站点公告」
- 拖拽到任意区域 → 可配置标题、内容、样式
- 前台侧栏按配置渲染
6. 表单字段规范
6.1 data-setting 属性(核心)
所有配置项必须添加 data-setting="键名" 属性。后台 JS 通过这个属性读写配置。
<input type="text" data-setting="title" value="...">
<select data-setting="style">...</select>
<textarea data-setting="content">...</textarea>
<input type="number" data-setting="limit" value="10">
<input type="checkbox" data-setting="show_date">
6.2 使用内置辅助方法
文本输入:
$this->fieldText('title', $s['title'], '标题', '请输入标题');
数字输入:
$this->fieldNumber('limit', $s['limit'], '显示数量', 1, 30);
下拉选择:
$this->fieldSelect('order', $s['order'], [
'latest' => '最新',
'hot' => '热门',
], '排序方式');
多行文本(无内置方法,手写):
echo '<p class="wg-field">';
echo '<label>公告内容</label>';
echo '<textarea data-setting="content" rows="3">' . $this->h($s['content']) . '</textarea>';
echo '</p>';
复选框(多选组):
$opts = ['a' => '选项A', 'b' => '选项B'];
$checked = (array)($s['enabled'] ?? []);
echo '<p class="wg-field"><label>启用项</label>';
foreach ($opts as $k => $label) {
$ck = in_array($k, $checked) ? ' checked' : '';
echo '<label style="display:block">';
echo '<input type="checkbox" data-setting="enabled_' . $k . '"' . $ck . '> ';
echo $this->h($label) . '</label>';
}
echo '</p>';
⚠️ 由于系统以键名 → 值平铺存储,多选组建议拆成多个独立的enabled_xxx布尔键。
6.3 字段值类型
| 表单类型 | 存储类型 | 说明 |
|---|---|---|
text / textarea | string | 原样存储 |
number | int | JS 会 parseInt |
checkbox | 0 / 1 | 未勾选为 0,勾选为 1 |
select | string | 选项 value 原样存储 |
7. 条件显示
当表单字段之间存在依赖关系时(例如"帖子模式"下才需要"排序方式"),使用 data-show-when 属性。
7.1 语法
<!-- 当 setting 的 mode 值为 "threads" 时显示 -->
<p class="wg-field" data-show-when="mode=threads">...</p>
<!-- 当 mode 值为 "threads" 或 "boards" 时显示 -->
<p class="wg-field" data-show-when="mode=threads,boards">...</p>
<!-- 同时满足多个条件(用 ; 分隔) -->
<p class="wg-field" data-show-when="mode=threads;show_author=1">...</p>
7.2 工作原理
- 后台 JS 监听所有
data-setting字段的input/change事件 - 每次变化时,遍历当前卡片内的
data-show-when字段 - 收集当前所有 setting 值,判断条件是否满足
- 满足则
display:'',否则display:none
7.3 完整示例
public function form($s) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
?>
<p class="wg-field">
<label>显示模式</label>
<select data-setting="mode">
<option value="threads" <?= $s['mode'] === 'threads' ? 'selected' : '' ?>>帖子列表</option>
<option value="boards" <?= $s['mode'] === 'boards' ? 'selected' : '' ?>>版块列表</option>
</select>
</p>
<!-- 仅"帖子列表"模式显示 -->
<p class="wg-field" data-show-when="mode=threads">
<label>排序方式</label>
<select data-setting="order">
<option value="latest">最新</option>
<option value="hot">热门</option>
</select>
</p>
<!-- 仅"版块列表"模式显示 -->
<p class="wg-field" data-show-when="mode=boards">
<label>显示版块数</label>
<input type="number" min="1" max="30" data-setting="limit" value="<?= (int)$s['limit'] ?>">
</p>
<?php
}
8. 多实例支持
核心原则:render() 内不要依赖全局状态,所有数据从 $settings 读取。
❌ 错误做法
public function render($s, $instanceId) {
static $count = 0; // ❌ 静态变量在多实例间共享
$count++;
echo "第 {$count} 个实例";
}
public function render($s, $instanceId) {
global $currentLimit; // ❌ 全局变量可能被其他实例覆盖
}
✅ 正确做法
public function render($s, $instanceId) {
// 从 $settings 读,不用全局状态
$limit = max(1, (int)($s['limit'] ?? 5));
// 需要 ID 时用 $instanceId
echo '<div id="my-widget-' . $this->h($instanceId) . '">';
echo '...';
echo '</div>';
}
样式隔离(多实例)
每个实例的 DOM 用 $instanceId 或唯一 class 区分,需要 JS 时使用事件委托:
public function render($s, $instanceId) {
$uid = 'wgt_' . preg_replace('/[^a-z0-9]/i', '', $instanceId);
?>
<div class="my-widget" data-instance="<?= $this->h($instanceId) ?>">
<button class="my-widget-btn" data-uid="<?= $this->h($uid) ?>">点击</button>
</div>
<script>
// 使用事件委托,对所有实例都生效
document.addEventListener('click', function (e) {
var btn = e.target.closest('.my-widget-btn');
if (!btn) return;
// 通过 data-uid 定位到具体实例
var uid = btn.getAttribute('data-uid');
var root = document.querySelector('[data-instance="' + uid + '"]');
// ...
});
</script>
<?php
}
💡 提示:<script>标签只在首次渲染时输出一次即可。可以像HomepageMode_Forum那样用静态标记:
private static $styleRendered = false;
private function outputStyle() {
if (self::$styleRendered) return;
self::$styleRendered = true;
echo '<style>...</style>';
}
9. 样式规范
9.1 命名前缀
为避免与其他插件冲突,所有 class 必须以你的小工具 ID 为前缀:
/* 小工具 id = "my_notice" */
.wg-my_notice { /* 外壳 */ }
.wg-my_notice-title { /* 标题 */ }
.wg-my_notice-body { /* 内容 */ }
9.2 复用主题样式
LiteLog 主题已内置通用 Widget 样式:
.widget /* 卡片外壳(白底、圆角、阴影) */
.widget-title /* 标题(带蓝色竖线) */
.widget-content /* 内容区 */
.wg-list /* 列表 */
.wg-list li /* 列表项 */
.wg-list li a /* 列表链接 */
.wg-tag-cloud /* 标签云 */
.wg-tag-cloud a /* 标签 */
.wg-empty /* 空状态 */
推荐直接复用:
public function render($s, $instanceId) {
$logs = LogModel::latest(5);
ob_start();
if (empty($logs)) {
echo '<p class="wg-empty">暂无内容</p>';
} else {
echo '<ul class="wg-list">';
foreach ($logs as $l) {
echo '<li><a href="' . $this->h(Url::log($l['gid'], $l['alias'])) . '">'
. $this->h($l['title']) . '</a></li>';
}
echo '</ul>';
}
$this->output($instanceId, $s['title'], ob_get_clean());
}
9.3 CSS 输出方式
方式一:内联输出(推荐简单场景)
private static $cssDone = false;
private function outputCss() {
if (self::$cssDone) return;
self::$cssDone = true;
echo '<style>.wg-my_notice { ... }</style>';
}
方式二:外部文件(推荐复杂场景)
// 在 render 中注入
if (!defined('MY_WIDGET_CSS_LOADED')) {
define('MY_WIDGET_CSS_LOADED', true);
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/your-plugin/assets/widget.css">';
}
10. 完整示例
示例 1:最新文章(带数量配置)
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('Widget')) return;
class Widget_RecentPosts extends Widget {
public $id = 'recent_posts';
public $name = '最新文章';
public $description = '显示最新发布的文章';
public $version = '1.0.0';
public function defaults() {
return ['title' => '最新文章', 'limit' => 5, 'show_date' => 0];
}
public function form($s) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
$this->fieldText('title', $s['title'], '标题');
$this->fieldNumber('limit', $s['limit'], '显示数量', 1, 20);
$this->fieldSelect('show_date', $s['show_date'], [
0 => '不显示日期',
1 => '显示日期',
], '日期显示');
}
public function render($s, $instanceId) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
$limit = max(1, min(20, (int)$s['limit']));
$logs = LogModel::latest($limit);
ob_start();
if (empty($logs)) {
echo '<p class="wg-empty">暂无文章</p>';
} else {
echo '<ul class="wg-list">';
foreach ($logs as $l) {
echo '<li>';
echo '<a href="' . $this->h(Url::log($l['gid'], $l['alias'])) . '">'
. $this->h($l['title']) . '</a>';
if (!empty($s['show_date'])) {
echo '<span class="wg-date">' . date('m-d', $l['date']) . '</span>';
}
echo '</li>';
}
echo '</ul>';
}
$this->output($instanceId, $s['title'], ob_get_clean());
}
}
示例 2:带模式切换的通用列表
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('Widget')) return;
class Widget_ContentList extends Widget {
public $id = 'content_list';
public $name = '内容列表';
public $description = '可选择显示文章 / 分类 / 标签';
public function defaults() {
return [
'title' => '内容',
'source' => 'posts',
'limit' => 8,
'show_count' => 1,
];
}
public function form($s) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
?>
<p class="wg-field">
<label>标题</label>
<input type="text" data-setting="title" value="<?= $this->h($s['title']) ?>">
</p>
<p class="wg-field">
<label>内容来源</label>
<select data-setting="source">
<option value="posts" <?= $s['source'] === 'posts' ? 'selected' : '' ?>>文章</option>
<option value="sorts" <?= $s['source'] === 'sorts' ? 'selected' : '' ?>>分类</option>
<option value="tags" <?= $s['source'] === 'tags' ? 'selected' : '' ?>>标签</option>
</select>
</p>
<p class="wg-field" data-show-when="source=posts">
<label>显示数量</label>
<input type="number" min="1" max="30" data-setting="limit" value="<?= (int)$s['limit'] ?>">
</p>
<p class="wg-field" data-show-when="source=sorts,tags">
<label>显示数量徽章</label>
<select data-setting="show_count">
<option value="1" <?= !empty($s['show_count']) ? 'selected' : '' ?>>显示</option>
<option value="0" <?= empty($s['show_count']) ? 'selected' : '' ?>>不显示</option>
</select>
</p>
<?php
}
public function render($s, $instanceId) {
$s = array_merge($this->defaults(), is_array($s) ? $s : []);
$source = $s['source'];
$limit = max(1, min(30, (int)$s['limit']));
$showCount = !empty($s['show_count']);
ob_start();
switch ($source) {
case 'sorts':
$items = SortModel::all();
if (empty($items)) {
echo '<p class="wg-empty">暂无分类</p>';
break;
}
echo '<ul class="wg-list">';
foreach ($items as $it) {
echo '<li><a href="' . $this->h(Url::sort($it['sid'], $it['alias'])) . '">'
. $this->h($it['sortname']) . '</a>';
if ($showCount) {
echo '<span class="wg-count">'
. SortModel::countInSort($it['sid']) . '</span>';
}
echo '</li>';
}
echo '</ul>';
break;
case 'tags':
$items = TagModel::all();
if (empty($items)) {
echo '<p class="wg-empty">暂无标签</p>';
break;
}
echo '<div class="wg-tag-cloud">';
foreach ($items as $it) {
echo '<a href="' . $this->h(Url::tag($it['tagname'])) . '">'
. $this->h($it['tagname']) . '</a>';
}
echo '</div>';
break;
case 'posts':
default:
$items = LogModel::latest($limit);
if (empty($items)) {
echo '<p class="wg-empty">暂无文章</p>';
break;
}
echo '<ul class="wg-list">';
foreach ($items as $it) {
echo '<li><a href="' . $this->h(Url::log($it['gid'], $it['alias'])) . '">'
. $this->h($it['title']) . '</a></li>';
}
echo '</ul>';
}
$this->output($instanceId, $s['title'], ob_get_clean());
}
}
示例 3:纯展示型小工具(无配置)
class Widget_Copyright extends Widget {
public $id = 'copyright';
public $name = '版权声明';
public $description = '显示站点版权信息';
public function form($s) {
echo '<p class="wg-no-options">此小工具没有可配置项</p>';
}
public function render($s, $instanceId) {
$year = date('Y');
$site = Option::get('site_title');
$body = '<div style="font-size:12.5px;color:#94a3b8;line-height:1.8">'
. '© ' . $year . ' ' . $this->h($site) . '<br>'
. 'Powered by LiteLog'
. '</div>';
$this->output($instanceId, '', $body);
}
}
11. 高级话题
11.1 依赖检测
如果小工具依赖其他插件,用 isAvailable() 声明:
public function isAvailable() {
return class_exists('ForumThread')
&& Option::get('forum_enable', 'y') === 'y';
}
后台配置页面会自动禁用不可用的小工具,并提示用户。
11.2 缓存
如果查询较重,可在 render() 内使用 Cache:
public function render($s, $instanceId) {
$cache = new Cache(CONTENT_PATH . 'cache/');
$key = 'my_widget_' . md5(serialize($s));
$data = $cache->read($key);
if ($data === false) {
$data = heavyQuery();
$cache->write($key, $data); // 默认按需
}
// ...
}
11.3 钩子扩展
在自己的 Widget 渲染中支持二级钩子,让其他插件可插入内容:
public function render($s, $instanceId) {
ob_start();
// ...
Hook::doAction('my_widget_before_content', $s);
echo '主体内容';
Hook::doAction('my_widget_after_content', $s);
// ...
}
11.4 动态区域
系统内置两个区域:
WidgetArea::AREA_LEFT // 'left_sidebar'
WidgetArea::AREA_RIGHT // 'right_sidebar'
如需新区域,在插件启动时注册:
Hook::addAction('init_widget_areas', function () {
WidgetArea::registerArea('footer_widgets', [
'name' => '页脚区域',
'desc' => '显示在页面底部',
]);
});
然后在主题中调用:
WidgetArea::render('footer_widgets');
12. 常见问题
Q1:小工具在后台不显示?
排查步骤:
- 检查插件主文件的
Hook::addAction('register_widgets', ...)是否执行 - 检查
widget_xxx.php是否被require_once加载 - 检查类是否继承了
Widget且$id不为空 - 检查是否已启用插件
Q2:多个实例配置相互干扰?
原因:通常是在 render() 或 form() 中使用了静态变量或全局状态。
解决:
- 所有配置从
$settings读取 - 需要唯一 ID 时用
$instanceId - 不要用
static $x保存状态
Q3:切换模式时字段没隐藏?
检查:
- 字段是否添加了
data-show-when="key=value"属性? - 被依赖的字段是否有
data-setting="key"? value与配置项的实际值是否一致(注意字符串 vs 数字)?
Q4:表单修改没保存?
检查:
- 所有可编辑字段是否有
data-setting属性? - 表单元素是否是
input/select/textarea? - 浏览器控制台是否有 JS 报错?
Q5:前台渲染的样式与主题不一致?
方案:
- 复用主题通用 class:
.widget、.widget-title、.wg-list、.wg-tag-cloud - 用小工具 ID 作为前缀(如
.wg-your_id) - 避免使用
!important覆盖主题
Q6:如何让某个小工具支持多语言?
在 form() 和 render() 中统一用 __() 或自定义翻译函数:
$this->fieldText('title', $s['title'], __('Title', 'your-plugin'));
LiteLog 内核不内置 i18n 系统,需要插件自行实现。
Q7:如何在内核更新后保持兼容?
- 只使用文档中列出的公开 API
- 避免直接访问
WidgetRegistry/WidgetArea的私有属性 - 遵循版本号语义:内核可能新增方法,但不会破坏现有方法签名
附录 A:完整 API 速查表
Widget 属性
public $id;
public $name;
public $description;
public $author;
public $version;
public $source;
Widget 方法
abstract public function render($settings, $instanceId);
public function form($settings);
public function defaults();
public function isAvailable();
// 辅助方法
protected function h($str);
protected function output($instanceId, $title, $content, $extraClass = '');
protected function fieldText($name, $value, $label = '', $placeholder = '');
protected function fieldNumber($name, $value, $label = '', $min = 1, $max = 100);
protected function fieldSelect($name, $value, $options, $label = '');
WidgetRegistry 静态方法
WidgetRegistry::register($widget);
WidgetRegistry::all(); // 返回 [id => Widget]
WidgetRegistry::get($id); // 返回 Widget 或 null
WidgetRegistry::exists($id); // 返回 bool
WidgetArea 静态方法
WidgetArea::render($area); // 渲染某个区域
WidgetArea::hasVisibleWidgets($area); // 该区域是否有可见小工具
WidgetArea::getData($area); // 获取原始数据(数组或 null)
WidgetArea::setData($area, $list); // 保存数据
WidgetArea::getEffectiveData($area); // 获取生效数据(含默认值)
相关钩子
| 钩子 | 时机 |
|---|---|
register_widgets | 注册表初始化时触发,插件注册小工具的入口 |
附录 B:命名建议
| 场景 | 推荐 | 避免 |
|---|---|---|
| Widget ID | forum_latest、novel_ranking | my_widget_1、w1 |
| 类名 | Widget_ForumLatest | Latest、WL |
| CSS 前缀 | .wg-forum_latest | .latest、.box |
| 数据键 | title、limit、show_count | t、n、flag |
结语
小工具系统是 LiteLog 生态的扩展基石。遵循本规范,你的小工具将:
- 与内核、其他插件、主题完全兼容
- 支持任意数量的实例,互不干扰
- 拥有开箱即用的后台配置体验
- 大幅降低用户使用门槛
如遇文档未覆盖的问题,欢迎提交 Issue 或查阅内核源码 include/lib/widget.php。
版本历史
| 版本 | 日期 | 变更 |
|---|---|---|
| 1.0 | 2026-10-08 | 首次发布 |