主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发手册 v4.0

作者:zhangwei 管理员 · 5 天前 · 24 浏览 · 46 回复
zhangwei
楼主
管理员
1 楼
版本:4.0(2026-10-13) 定位:既是规范(必须遵守的硬性规则),也是经验(实战中总结的教训),更是手册(可索引、可查阅、可执行的完整参考)。 来源:LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文/论坛插件两轮完整修复战的全部经验。 给 AI 下指令时,请把本文件完整附上。

阅读指引

本手册分 8 个部分,按"从零到一"和"从一到生产"两条路径组织:

你的角色推荐阅读顺序
第一次写插件第 1 部分 → 第 2 部分 → 第 3 部分 → 第 7 部分
排错第 4 部分 → 第 6 部分 → 第 5 部分(按症状查阅)
代码审查 / 学习第 4 部分 → 第 5 部分 → 第 8 部分附录 D
给 AI 下指令第 8 部分附录 E

⚠️ 关键条款标记:

  • 🔴 红线:违反一票否决
  • ⭐ 重点:最容易踩坑的地方
  • 📌 口诀:记忆口诀

第 1 部分:入门

第 1 章 · 快速上手(10 分钟跑通第一个插件)

1.1 目标

创建一个名为 hello 的插件,它能在前台 /hello 页面显示"你好,LiteLog"。

1.2 目录结构

content/plugins/hello/
├── hello.php           ← 主文件
└── views/
    └── main.php        ← 前台视图

1.3 主文件 hello.php

<?php
/**
 * Plugin Name: Hello
 * Description: 最小可运行的示例插件
 * Version: 1.0.0
 * Author: 你的名字
 */

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

/* 定义常量(★ 必须加 defined 守卫) */
if (!defined('HELLO_VER')) define('HELLO_VER', '1.0.0');

/* 注册前台路由 */
if (class_exists('Hook')) {
    try {
        Hook::addAction('dispatch_plugin_route', function ($pluginName, $rest) {
            if ($pluginName !== 'hello') return;
            $GLOBALS['__plugin_routed'] = true;

            $GLOBALS['__hello_active'] = true;
            include __DIR__ . '/views/main.php';
            exit;
        });
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] hello register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

1.4 视图 views/main.php

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

/* 三变量兜底(后面第 20 章详述) */
$page_title  = 'Hello - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>

<div style="padding:60px 20px;text-align:center">
    <h1 style="font-size:32px;color:#2563eb">👋 你好,LiteLog</h1>
    <p style="color:#64748b;margin-top:16px">第一个插件已经跑通了。</p>
</div>

<?php include View::resolve('footer'); ?>

1.5 启用与访问

  1. 把 hello/ 目录上传到 content/plugins/
  2. 后台 → 插件管理 → 启用 Hello
  3. 访问 http://你的域名/hello

⚠️ 前提条件:核心 dispatcher.php 需支持 /hello 伪静态路由。若不支持,请参阅第 9 章《路由系统》。


第 2 章 · 内核架构总览

2.1 分层结构

┌─────────────────────────────────────────────┐
│  前台请求 (/post/123.html)                  │
│  后台请求 (/admin/?action=logs)             │
└──────────────────┬──────────────────────────┘
                   │
                   ▼
      ┌────────────────────────┐
      │  index.php / admin/    │  入口
      └───────────┬────────────┘
                  │
                  ▼
      ┌────────────────────────┐
      │  init.php              │  初始化
      │  · 常量定义            │
      │  · 加载 lib            │
      │  · 建立 PDO            │
      │  · 移动端 router       │ ← ⭐ 会抢跑
      │  · loadActivePlugins() │ ← 你的插件在这里被加载
      └───────────┬────────────┘
                  │
                  ▼
      ┌────────────────────────┐
      │  Dispatcher::dispatch()│  路由分发
      │  · dispatch_start      │
      │  · __plugin_path       │
      │  · 核心 Rewrite URL    │
      │  · PluginRegistry 匹配 │
      │  · public_action_xxx   │
      │  · 控制器 → 视图       │
      │  · dispatch_end        │
      └────────────────────────┘

2.2 核心对象

对象职责关键方法
DatabasePDO 封装query / fetchOne / insert / update / delete
Option键值存储get / set
Hook事件/过滤器addAction / doAction / applyFilters
View视图解析resolve / display
UrlURL 生成log / sort / tag / author / record
CsrfCSRF 防护token / field / verify
Auth权限系统check / can / requireCap
Dispatcher路由分发dispatch
PluginRegistry插件注册表addAdminPage / addFrontendRoute
Output输出缓冲capture / wrap
Widget / WidgetRegistry / WidgetArea小工具系统—
HomepageMode / HomepageModeRegistry首页模式系统—

第 3 章 · 请求生命周期

3.1 前台完整流程

1. index.php 定义 LITELOG_ROOT,require init.php
2. init.php:
   ├─ 定义 CONTENT_PATH / INCLUDE_PATH / ADMIN_PATH
   ├─ require config.php → 定义 DB_PREFIX
   ├─ 启动 Session(HttpOnly + SameSite=Lax)
   ├─ require 所有 lib/*.php
   ├─ 建立 PDO($DB 全局)
   ├─ Option::init() 加载配置
   ├─ require 所有 model / controller
   ├─ 定义 BLOG_URL
   ├─ ★ 移动端 router(isMobileDevice() == true 时加载)
   │   └─ ⚠️ 可能 exit,之后所有代码不执行
   └─ loadActivePlugins()  ← 你的插件在这里加载,注册 Hook
3. Dispatcher::dispatch()
   ├─ Hook::doAction('dispatch_start')
   ├─ ★ 若核心已打补丁:识别 /插件名/xxx → __plugin_path
   ├─ 若 __plugin_path 存在:
   │   └─ Hook::doAction('dispatch_plugin_route', $name, $rest)
   │      └─ 你的插件路由处理器 → exit
   ├─ 匹配核心 Rewrite URL(post/sort/tag/...)
   ├─ PluginRegistry::matchFrontendRoute()
   ├─ public_action_{action}
   ├─ dispatch_matched → 控制器执行
   └─ dispatch_end(有 shutdown 兜底)

3.2 后台完整流程

1. admin/index.php 定义 LITELOG_ROOT,require init.php
2. 加载 helpers.php
3. 处理 action=login / logout
4. checkLogin() 强制登录
5. ★ CSRF 全局校验(POST 请求)
6. Hook::doAction('admin_request', $action)
   └─ 你的插件 admin 钩子在这里触发
7. PluginRegistry::renderAdminPage($action)(若注册过)
8. PluginRegistry::renderPluginSetting($name)(action=plugin_setting)
9. 内置路由 switch

第 2 部分:基础规范

第 4 章 · 目录结构与自动加载

4.1 标准目录结构

content/plugins/{插件名}/
├── {插件名}.php                    ← 【必须】主文件
├── {插件名}_callback.php           ← 【可选】生命周期
├── {插件名}_setting.php            ← 【可选】设置页
├── admin/
│   ├── register.php                ← 【可选】后台注册
│   ├── pages/                      ← 后台视图
│   └── assets/                     ← 后台资源
├── frontend/
│   └── routes.php                  ← 【可选】前台路由
├── views/                          ← 前台视图
├── assets/                         ← 静态资源(CSS/JS)
└── data/                           ← 私有数据(必须带 .htaccess)

4.2 ⭐ 内核自动加载真相

内核 loadActivePlugins() 只做三件事:

① require_once content/plugins/{name}/{name}.php       ← 主文件
② if 存在 admin/register.php → require_once
③ if 存在 frontend/routes.php → require_once
文件自动加载?说明
{插件名}.php✅ 是唯一必须的文件
{插件名}_callback.php❌ 否内核在启用/停用时主动 require
{插件名}_setting.php❌ 否通过 ?action=plugin_setting&name=xxx 访问
frontend/routes.php⚠️ 是(若放在约定路径)但推荐在主文件中显式 require,避免依赖内核细节
admin/register.php⚠️ 同上同上
views/*.php❌ 否由控制器/路由回调主动 include
assets/*❌ 否通过 Hook 注入

4.3 📌 口诀

"主文件自动,子文件自 load;不放约定路径,就要自己 require。"

第 5 章 · 插件主文件与头注释

5.1 标准头注释

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */
if (!defined('LITELOG_ROOT')) exit('error!');

支持字段:Plugin Name / Name / Description / Version / Author / Author URI / Author URL

5.2 主文件的四件事

  1. 常量守卫:if (!defined('XXX')) define('XXX', ...);
  2. 定义工具函数:用 if (!function_exists(...)) 包裹
  3. 注册 Hook:Hook::addAction(...) 包在 class_exists + try/catch 中
  4. require 子文件:frontend/routes.php / admin/register.php 等

5.3 ⭐ PHP 语法安全写法

这是本轮修复战最痛的教训之一。

5.3.1 危险写法:连续两个 <?php

<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<?php
/* 大量代码 */

问题:只要用户在编辑器里粘贴时第二个 <?php 丢失,或被转义成 &lt;?php(从富文本编辑器复制时经常发生),整个文件就会:

syntax error, unexpected '<', expecting end of file

导致整页白屏。

5.3.2 安全写法:合并为一个 PHP 块

<?php
/**
 * 插件名 · 视图名
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/* 所有 PHP 逻辑都在这一个块里 */
$page_title = '...';
$title      = $page_title;

include View::resolve('header');
?>
<!-- 从这里开始输出 HTML -->
<div class="...">...</div>
<?php include View::resolve('footer'); ?>

规则:绝不出现"两个连续 PHP 块"。只在真正要输出 HTML 时才闭合 ?>。

5.3.3 视图文件推荐骨架

<?php
/**
 * 插件名 · 视图名
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/* 1. 前置守卫 */
if (!function_exists('MYPLUGIN_isEnabled')) {
    echo '<div style="padding:40px;text-align:center;color:#b91c1c">插件未加载</div>';
    return;
}

/* 2. 标题准备(三变量兜底,见第 20 章) */
$page_title  = '...';
$title       = $page_title;
$__pageTitle = $page_title;

/* 3. 数据准备 */
$data = ...;

/* 4. 输出 HTML */
include View::resolve('header');
?>
<div class="my-page">...</div>
<?php include View::resolve('footer'); ?>

5.3.4 验证方法

php -l content/plugins/your_plugin/views/detail.php
# 期望:No syntax errors detected

没有 SSH:把文件内容复制到 https://3v4l.org/ 或 https://phpcodechecker.com/。


第 6 章 · 生命周期(callback)

6.1 内核实测行为

时机内核调用你的回调
启用插件callback_init(){插件名}_callback.php
停用插件callback_rm()同上
升级插件callback_up()(需手动触发)同上
卸载插件callback_uninstall()同上

6.2 标准模板

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

if (!defined('MYPLUGIN_VER')) define('MYPLUGIN_VER', '1.0.0');

function callback_init() {
    try {
        global $DB;
        $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_items` (
            `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            `name` VARCHAR(100) NOT NULL DEFAULT '',
            `created_at` INT UNSIGNED NOT NULL DEFAULT 0
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        if (class_exists('Option')) {
            foreach (['enabled' => '1', 'title' => '我的插件'] as $k => $v) {
                $optKey = 'my_plugin_' . $k;
                if (Option::get($optKey, '') === '') Option::set($optKey, $v);
            }
        }
    } catch (Throwable $e) {
        @file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX);
    }
}

function callback_up() { callback_init(); }
function callback_rm() { /* 保留数据 */ }

function callback_uninstall() {
    global $DB;
    try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_items`"); } catch (Throwable $e) {}
    try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}

6.3 铁律

  • callback_init() 整段 try/catch
  • DDL 无 COMMENT / COLLATE
  • callback_rm() 不删数据
  • callback_uninstall() 先删表 → 再清 option
  • 绝不在 callback 里输出任何内容

第 7 章 · 钩子系统

7.1 API

Hook::addAction($hook, $func, $priority = 10);   // 注册
Hook::doAction($hook, ...$args);                 // 触发
Hook::removeAction($hook, $func = null);         // 移除
Hook::hasAction($hook);                          // 是否挂载
Hook::captureAction($hook, ...$args);            // 捕获输出
Hook::applyFilters($hook, $value, ...$args);     // 过滤器

两种语义:

  • Action:doAction 通知型,返回值忽略;改数据用引用传参
  • Filter:applyFilters 第一个参数是值,返回值替换
// Action 改数据
Hook::addAction('log_save_before', function (&$data, $gid) {
    $data['title'] = '[置顶] ' . $data['title'];
});

// Filter 改数据
$data = Hook::applyFilters('log_save_before', $data, $gid);

7.2 常用钩子清单

生命周期

钩子触发点
plugin_activate / plugin_deactivate / plugin_uninstall插件生命周期

布局锚点(推荐)

钩子触发点用途
layout_head_start<head> 最前早期 meta
layout_head_end</head> 前加载 CSS
layout_body_start<body> 之后顶部横幅
layout_content_start<main> 之后面包屑
layout_content_end内容后、</main> 前相关文章
layout_footer_scripts</body> 前的脚本区加载 JS
layout_body_end</body> 之前悬浮元素

分发路由

钩子参数用途
dispatch_start—分发前
dispatch_plugin_route$pluginName, $rest插件路由
dispatch_matched&$matched路由匹配后
dispatch_end—分发结束(shutdown 兜底)
public_action_{action}—?action=xxx 分发

内容与评论

钩子参数
log_list_query&$cond, $page, $perPage
log_list_result&$logs, $page, $total
log_display_before / log_display_after$log
log_content$log
log_save_before / log_save_after&$data / $gid
comment_save_before&$data
comment_save_after$cid, $data

后台

钩子参数
admin_request$action
admin_menu$activeKey
admin_head / admin_footer—
admin_dashboard_widgets—

扩展点

钩子用途
register_widgets注册小工具
register_homepage_modes注册首页模式

7.3 钩子注册规范

所有 Hook::addAction() 必须:

if (class_exists('Hook')) {
    try {
        Hook::addAction('xxx', 'my_callback');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

插件自己触发供其它插件监听的钩子必须加插件名前缀:

Hook::doAction('my_plugin_after_save', $id, $data);

第 3 部分:核心能力

第 8 章 · PluginRegistry 注册表

8.1 API

方法用途
addAdminPage($action, $config)注册后台页面
addAdminMenu($menu)注册侧边栏菜单
addFrontendRoute($regex, $cb)注册前台路由
addAdminAsset($action, $type, $url)注册后台资源
enqueueAsset($type, $url)手动暂存资源
renderAdminPage($action)渲染后台页面(内核调用)
renderPluginSetting($name)渲染设置页(内核调用)

8.2 注册后台页面

// admin/register.php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addAdminMenu([
        'id'       => 'friend_links',
        'action'   => 'friend_links',
        'title'    => '友情链接',
        'icon'     => 'external',
        'priority' => 30,
    ]);

    PluginRegistry::addAdminPage('friend_links', [
        'title'    => '友情链接管理',
        'callback' => function () {
            if (class_exists('Auth')) Auth::requireCap('plugin.manage');
            include __DIR__ . '/pages/list.php';
        },
    ]);
} catch (Throwable $e) { /* 写日志 */ }

可用 icon:dashboard / file / comment / folder / settings / external / logout / plus / edit / trash / plugin / palette / check / x / refresh

8.3 插件设置页

设置页放在 {插件名}_setting.php,通过核心入口访问:

<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx

内核做的事:

  1. 校验插件名合法
  2. 强制 Auth::requireCap('plugin.manage')
  3. 渲染页面
  4. 自动包上 layout

设置页模板:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('IN_PLUGIN_SETTING')) {
    http_response_code(404);
    exit('Not Found');
}
if (!function_exists('MYPLUGIN_isEnabled')) { echo '插件未加载'; return; }

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (class_exists('Csrf')) Csrf::verify();
    Option::set('my_plugin_title', trim($_POST['title'] ?? ''));
    redirect(BLOG_URL . 'admin/?action=plugin_setting&name=my_plugin&saved=1');
}
?>

<h2>我的插件设置</h2>
<?php if (!empty($_GET['saved'])): ?>
  <div class="card" style="background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46">设置已保存</div>
<?php endif; ?>

<form method="post" class="form">
  <?= Csrf::field() ?>
  <div class="field">
    <label>插件标题</label>
    <input type="text" name="title" value="<?= h(Option::get('my_plugin_title', '')) ?>">
  </div>
  <button class="btn" type="submit">保存</button>
</form>

8.4 前台路由注册

// frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
        if (!function_exists('ICG_isEnabled') || !ICG_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        $GLOBALS['__icg_active'] = true;
        include __DIR__ . '/../views/generator.php';
    });
} catch (Throwable $e) {
    @file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] icon_generator route: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX);
}

第 9 章 · 路由系统

9.1 三种路由方式

方式触发适用
dispatch_plugin_route 钩子核心识别 /插件名/xxx推荐——简洁、自动
PluginRegistry::addFrontendRoute核心匹配正则需要自定义 URL 模式
public_action_xxx 钩子?action=xxx传统方式、兼容旧版本

9.2 ⭐ 核心 dispatcher 的伪静态识别

问题:核心默认不认识 /daily_article/detail/5 这种 URL,会 fallback 到 LogController::display()(显示博客首页),导致:

  • 用户看到博客列表而非插件页面
  • 浏览器标签显示站点名
  • 插件资源钩子不生效

解决方案:给核心 include/lib/dispatcher.php 打补丁(一次性)。

在 dispatch() 方法里,Hook::doAction('dispatch_start'); 之后插入:

/* ============================================================
 * ★ 自动识别 /插件名/xxx 形式 → 转为 __plugin_path
 * ============================================================ */
$uri  = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
$base = parse_url(BLOG_URL, PHP_URL_PATH) ?: '/';
if ($base !== '/' && $base !== '' && strpos($uri, $base) === 0) {
    $uri = substr($uri, strlen($base));
}
$uri = trim($uri, '/');

if (empty($_GET['__plugin_path']) && !isset($_GET['action']) && $uri !== '') {
    $corePrefixes = ['post/', 'sort/', 'tag/', 'author/', 'record/', 'page/'];
    $isCore = false;
    foreach ($corePrefixes as $p) {
        if (strpos($uri, $p) === 0) { $isCore = true; break; }
    }
    if (!$isCore && preg_match('#^([a-zA-Z0-9_-]+)(?:/(.*))?$#', $uri, $m)) {
        $pluginName = $m[1];
        $pluginRest = isset($m[2]) ? trim($m[2], '/') : '';
        $mainFile = CONTENT_PATH . 'plugins/' . $pluginName . '/' . $pluginName . '.php';
        if (is_file($mainFile)) {
            $_GET['__plugin_path'] = $pluginName;
            $_GET['__plugin_rest'] = $pluginRest;
        }
    }
}

优点:

  • 所有走 dispatch_plugin_route 的插件自动获得伪静态支持
  • 时机早于任何 exit,不会被 mobile router 抢跑
  • 生命周期完整(dispatch_end 正常触发)

9.3 插件端路由分发

Hook::addAction('dispatch_plugin_route', function ($pluginName, $rest) {
    if ($pluginName !== 'daily_article') return;
    $GLOBALS['__plugin_routed'] = true;
    $rest = trim((string)$rest, '/');

    if ($rest === '' || $rest === 'list') { (new DailyController())->displayIndex(); return; }
    if ($rest === 'archive')               { (new DailyController())->displayArchive(); return; }
    if (preg_match('|^detail/(\d+)$|', $rest, $m)) {
        $_GET['id'] = (int)$m[1];
        (new DailyController())->displayDetail((int)$m[1]);
        return;
    }
    // ...

    $GLOBALS['__plugin_routed'] = false;
});

注意:

  • 每个分支最后必须 exit 或 return
  • $GLOBALS['__plugin_routed'] = true 放最前
  • 找不到匹配时,设回 false,让核心继续处理

9.4 URL 生成函数

if (!function_exists('my_plugin_url')) {
    function my_plugin_url($sub = '', $params = []) {
        $useRewrite = (Option::get('url_rewrite', 'n') === 'y');
        if ($useRewrite) {
            $base = rtrim(BLOG_URL, '/') . '/my_plugin';
            if ($sub !== '') $base .= '/' . ltrim($sub, '/');
            if (!empty($params)) $base .= '?' . http_build_query($params);
            return $base;
        }
        return BLOG_URL . '?' . http_build_query(
            array_merge(['action' => 'my_plugin'], $params, $sub ? ['sub' => $sub] : [])
        );
    }
}

第 10 章 · 数据库与模型

10.1 Database 类精确签名

方法返回值
query($sql, $params = [])PDOStatement(可 ->rowCount())
fetchAll($sql, $params = [])array(空时 [])
fetchOne($sql, $params = [])array \| false ⚠️
fetchColumn($sql, $params = [])标量 \
insert($table, $data)lastInsertId 字符串(需 (int))
update($table, $data, $where, $whereParams = [])true(不是行数)
delete($table, $where, $params = [])true

10.2 ⭐ 三大陷阱

  1. fetchOne() 失败返回 false,不是 null

B26

  1. insert() 返回字符串 lastInsertId,必须 (int)$newId
  1. update() / delete() 返回 true,要行数用 query()->rowCount()

10.3 使用示例

global $DB;

// 查一条
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "user` WHERE uid=?", [(int)$uid]);
if ($row === false) { /* 不存在 */ }

// 查多条
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `sortid`=? LIMIT 10", [(int)$sid]);

// 标量
$count = (int)$DB->fetchColumn("SELECT COUNT(*) FROM `" . DB_PREFIX . "user` WHERE `status`=1");

// 插入
$newId = (int)$DB->insert('my_items', ['name' => 'test', 'created_at' => time()]);

// 更新
$DB->update('my_items', ['name' => 'new'], 'id = ?', [(int)$id]);

// 删除
$DB->delete('my_items', 'id = ?', [(int)$id]);

// 判断命中
$stmt = $DB->query("UPDATE ... WHERE uid = ? AND credit > 0", [(int)$uid]);
if ($stmt->rowCount() === 0) { /* 未命中 */ }

// IN 查询
$ids = array_map('intval', $ids);
if ($ids) {
    $in = implode(',', array_fill(0, count($ids), '?'));
    $rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid` IN ($in)", $ids);
}

// 事务
$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
    // ...
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    throw $e;
}

10.4 核心表清单

{DB_PREFIX}user

uid(PK) / username / nickname / email / password / role

{DB_PREFIX}blog

gid(PK) / title / content / content_md / alias / sortid / author / date / views / comnum / hide('y'/'n') / type('blog'/'page') / top(TINYINT) / cover / excerpt

{DB_PREFIX}comment

cid(PK) / gid / pid / poster / email / url / comment / ip / agent / date / hide('y'待审/'n'通过)

{DB_PREFIX}sort / {DB_PREFIX}tag / {DB_PREFIX}tag_blog

{DB_PREFIX}option

id(PK) / option_name(UNIQUE) / option_value(TEXT,永远是字符串) / autoload

Option::get() 返回永远是 string,判断用 === '1' / === 'y'。

10.5 Option 配置

Option::set('my_plugin_enabled', '1');
$v = Option::get('my_plugin_enabled', '');   // ★ 第二参数永远 ''

Option::set('my_plugin_config', json_encode(['a' => 1], JSON_UNESCAPED_UNICODE));
$cfg = json_decode(Option::get('my_plugin_config', '{}'), true);

键名强制前缀 {插件名}_。


第 11 章 · 视图与模板

11.1 ⭐ 前台视图必须 include 主题 header/footer

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

/* 三变量兜底(见第 20 章) */
$page_title  = '图标生成器 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>

<div class="my-container">...</div>

<?php include View::resolve('footer'); ?>

原理:View::resolve('header') 解析到当前主题的 header.php,它已经输出了骨架。

移动端优先:View::resolve() 会优先查找 templates/{主题}/mobile/ 子目录,找不到回退到普通目录。

11.2 🔴 前台评论表单必须加 CSRF

<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
  <?= Csrf::field() ?>
  <input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
  <textarea name="comment" required></textarea>
  <button type="submit">提交</button>
</form>

11.3 后台视图

后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,所以后台视图不要 include 主题 header/footer。

11.4 输出转义

<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>

11.5 后台可用核心 CSS 类

.card / .card-title / .btn / .btn-outline / .btn-danger / .btn-sm / .table-wrap / .table / .form / .field / .tag / .empty / .error / .pagination


第 12 章 · 资源加载

12.1 前台资源用 layout_* 注入

if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_head_end', function () {
            if (empty($GLOBALS['__mp_active'])) return;
            $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
            echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
        });

        Hook::addAction('layout_footer_scripts', function () {
            if (empty($GLOBALS['__mp_active'])) return;
            $ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
            echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
        });
    } catch (Throwable $e) { /* 写日志 */ }
}

视图里只需标记:

$GLOBALS['__mp_active'] = true;
include __DIR__ . '/../views/main.php';

12.2 兼容老主题

两个 Hook 都注册,用 static 去重:

Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head',   'MP_injectCss');   // 老主题兜底

12.3 后台资源

PluginRegistry::addAdminAsset('my_plugin', 'css', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css');
PluginRegistry::addAdminAsset('my_plugin', 'js',  BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js');

12.4 数据目录保护

content/plugins/{插件名}/data/ 必须放 .htaccess:

<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order deny,allow
    Deny from all
</IfModule>

第 13 章 · CSRF 与权限

13.1 CSRF 核心 API

$token = Csrf::token();          // 获取当前会话 token
echo Csrf::field();              // 输出隐藏字段
Csrf::verify();                  // 校验(失败直接 403 并输出)
Csrf::verifyJson($payload);      // 从 JSON body 校验

13.2 表单必须加 CSRF

任何 POST 表单必须:

<form method="post" action="...">
  <?php if (class_exists('Csrf')) echo Csrf::field(); ?>
</form>

核心会在 admin/index.php 里对所有 POST 强制 Csrf::verify(),除非该 action 是通过 PluginRegistry::addAdminPage() 注册的插件页(由插件自行校验)。

13.3 AJAX 必须带 X-CSRF-Token

后台 layout.php 已全局包装 window.fetch,同源请求自动加 header。

手动:

fetch(url, {
  method: 'POST',
  headers: {
    'X-CSRF-Token': window.LITELOG_CSRF || '',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(payload)
})

13.4 后台权限系统 Auth

Auth::check();                    // 是否登录
Auth::id();                       // 当前用户 uid
Auth::role();                     // admin / editor / author / guest
Auth::can($cap);                  // 是否有某能力
Auth::requireCap($cap);           // 强制校验

角色能力表:

角色能力
admin*
editorlog. / comment. / sort. / tag. / widget.manage / theme.manage / plugin.manage
authorlog.own / comment.reply

插件后台入口:

if (class_exists('Auth')) Auth::requireCap('plugin.manage');

13.5 前台登录校验

前台不需要 Auth,用 isLoggedIn():

if (!isLoggedIn()) {
    redirect(BLOG_URL . 'admin/?action=login');
}

第 14 章 · 小工具与首页模式

14.1 自定义小工具

class Widget_MyBlock extends Widget {
    public $id          = 'my_block';
    public $name        = '我的模块';
    public $description = '展示自定义内容';
    public $source      = 'plugin:my_plugin';   // 分组用

    public function defaults() {
        return ['title' => '默认标题', 'count' => 5];
    }

    public function form($settings) {
        $s = array_merge($this->defaults(), $settings ?: []);
        $this->fieldText('title', $s['title'], '标题');
        $this->fieldNumber('count', $s['count'], '显示数量', 1, 20);
    }

    public function render($settings, $instanceId) {
        $s = array_merge($this->defaults(), $settings ?: []);
        ob_start();
        echo '<ul>...</ul>';
        $this->output($instanceId, $s['title'], ob_get_clean());
    }
}

Hook::addAction('register_widgets', function () {
    WidgetRegistry::register(new Widget_MyBlock());
});

表单字段必须带 data-setting="键名"(内核 widgets.js 依赖此属性)。

14.2 自定义首页模式

class HomepageMode_MyMode extends HomepageMode {
    public $id = 'my_mode';
    public $name = '我的模式';
    public $theme = 'purple';
    public $icon = '<svg>...</svg>';
    public $priority = 50;

    public function isAvailable() { return true; }

    public function render($ctx = []) {
        echo '<div class="my-mode">...</div>';
    }
}

Hook::addAction('register_homepage_modes', function () {
    HomepageModeRegistry::register(new HomepageMode_MyMode());
});

第 4 部分:安全与陷阱

第 15 章 · 500 免疫

15.1 八宗罪

罪 1 🔴:主文件顶层 define() / function 无守卫

// ❌
define('ICG_VER', '1.0.0');
function ICG_isEnabled() { }

// ✅
if (!defined('ICG_VER')) define('ICG_VER', '1.0.0');
if (!function_exists('ICG_isEnabled')) {
    function ICG_isEnabled() { }
}
📌 口诀:define 判 defined,function 判 exists。

罪 2 🔴:Option::get() 第二参数传 null

$v = Option::get('xxx_enabled', '');   // ★ 永远用 ''

罪 3 🔴:生命周期 DDL 无 try/catch、带 COMMENT/COLLATE

关键三条:① 整段 try/catch;② DDL 无 COMMENT;③ 无 COLLATE。

罪 4 🔴:视图直接调用插件函数,无 function_exists 守卫

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) {
    echo '<div class="error">插件未正确加载</div>';
    return;
}

罪 5 🔴:Hook::addAction() 无 try/catch

罪 6 🔴:PluginRegistry 调用无 class_exists 守卫

罪 7 🔴:frontend/routes.php / admin/register.php 被主文件遗忘

罪 8 🔴:PHP 标签混乱导致 Parse error

"连续两个 <?php"是陷阱,详见第 5.3 节。

15.2 📌 九条口诀

"define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。"

第 16 章 · 五条硬性安全规则

规则 1 · 文件操作,无 basename 不落盘

$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) return false;
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;

规则 2 · 数据库查询,无预处理不执行

// ✅
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?", [(int)$gid]);

// ❌
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");

规则 3 · 输出字符串必须转义

echo '<h1>' . h($log['title']) . '</h1>';   // ✅
echo '<h1>' . $log['title'] . '</h1>';      // ❌

规则 4 · CSRF 保护

见第 13 章。

规则 5 · ⭐ 所有表单/AJAX 的 URL 必须是绝对路径

禁止:action="?action=xxx"
必须:action="<?= BLOG_URL ?>?action=xxx"

详见第 19 章。


第 17 章 · 九条最高级别红线

  1. 🔴 严禁反序列化与动态执行——禁 unserialize() / eval() / assert() / call_user_func($_GET[...])
  2. 🔴 严禁滥用绝对路径——只用 LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / DIR
  3. 🔴 渲染钩子内禁止中断页面——禁 die() / exit() / header();必须 return
  4. 🔴 上传文件必须白名单 + 随机文件名——校验 getimagesize(),名用 bin2hex(random_bytes(8))
  5. 🔴 数量扣减必须原子更新——UPDATE ... WHERE stock>0 + rowCount()
  6. 🔴 IN (...) 必须强转 + 占位符——array_map('intval') + ?
  7. 🔴 PDO 属性不得覆盖
  8. 🔴 AJAX 写操作必须有登录校验
  9. 🔴 所有表单/AJAX 的 URL 必须是绝对路径

第 18 章 · 常见坑清单

18.1 安全

  1. 路径穿越——文件名过 basename()
  2. SQL 注入——占位符绑定
  3. XSS——输出过 h()
  4. IDOR——按 ID 操作必校验资源存在
  5. 上传——扩展名白名单 + 真实图片校验 + 随机文件名 + 像素上限
  6. 开放重定向——redirect($url) 只允许本站 URL
  7. CSRF——所有 POST 请求带 _token 或 X-CSRF-Token
  8. 权限——后台操作前 Auth::requireCap()
  9. 相对 URL——所有表单 action 必须是绝对 URL

18.2 部署兼容

  1. 不硬编码路径——URL 拼 BLOG_URL,路径拼 CONTENT_PATH / DIR
  2. 子目录部署——假设站点可能在 https://example.com/blog/
  3. 大小写敏感——目录名小写下划线
  4. Cookie litelog_view=mobile——PC 也可能走移动模板

18.3 结构规范

  1. 一目录一插件
  2. 不留临时文件
  3. 不改核心文件(除必要的 dispatcher 补丁)
  4. option 加插件名前缀
  5. 生命周期文件不输出内容
  6. UTF-8 无 BOM,LF 行尾,php -l 校验
  7. PHP 标签合并——避免连续两个 <?php

18.4 输出缓冲

  1. 不要用 ob_start('callback')
  2. 回调必须判 is_string($html)
  3. 长度守卫:处理后 < 原始 50% 时返回原始
  4. ob_start 回调内不能 ob_start
  5. 优先用 layout_* 锚点
  6. 优先用 Output::capture()

18.5 兼容性

  1. $DB 是全局
  2. $data 引用参数——log_save_before / comment_save_before 必须 function (&$data, ...)
  3. redirect() 已自带 exit
  4. $_SESSION['uid'] 是登录标识
  5. fetchOne() 失败返回 false
  6. insert() 返回字符串 lastInsertId
  7. update() / delete() 返回 true
  8. Option::get() 第二参数永远 ''
  9. $_REQUEST 受 request_order 影响——显式用 $_POST / $_GET

18.6 标题相关

  1. 主题 header 可能读 $page_title / $title / $__pageTitle
  2. 移动端 header 通常读 $__pageTitle
  3. 三变量都要设置
  4. 主题 header 硬编码时用 JS 覆盖 document.title

第 5 部分:实战技巧

第 19 章 · ⭐ 相对 URL 陷阱(本轮修复战最痛的教训)

19.1 问题描述

论坛插件里有一个"删除帖子"按钮:

<form method="post" action="?action=forum_delete&id=5">

在 URL 为 /forum/thread/5 的页面上,浏览器会把 ?action=... 解析为:

http://li.zgzjw.top/forum/thread/5?action=forum_delete&id=5

后果:

  • 核心 dispatcher 拿到的 $uri = 'forum/thread/5'(不含 query)
  • 某些 Nginx 重写规则会先匹配"查看帖子 5",执行后 exit,删除请求根本没走到 public_action_forum_delete
  • 用户点击删除无反应,或者刷新回同页面

19.2 🔴 铁律

所有表单的 action 必须使用绝对 URL:BLOG_URL . '?...' 或插件的 URL 生成函数。

19.3 正例 / 反例

<!-- ❌ 反例:相对 URL -->
<form method="post" action="?action=forum_delete&id=5">

<!-- ❌ 反例:空 action,会提交到当前 URL -->
<form method="post">

<!-- ✅ 正例:绝对 URL -->
<form method="post" action="<?= BLOG_URL ?>?action=forum_delete&id=5">

<!-- ✅ 正例:使用插件的 URL 生成函数 -->
<form method="post" action="<?= forum_pretty_url('delete/5') ?>">

19.4 JS 动态生成表单同样适用

// ❌ 反例
form.action = '?action=forum_thread_mod';

// ✅ 正例
form.action = '<?= BLOG_URL ?>?action=forum_thread_mod';

19.5 通用 POST 按钮生成器(推荐模板)

if (!function_exists('my_postBtn')) {
    function my_postBtn($action, array $params, $label, $confirm = '', $class = '') {
        $qs = http_build_query(array_merge(['action' => $action], $params));
        $formAction = BLOG_URL . '?' . $qs;   // ★ 绝对 URL

        $on = '';
        if ($confirm !== '') {
            $jsonConfirm = json_encode((string)$confirm,
                JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
            $on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
        }
        $cls = $class !== '' ? ' class="' . h($class) . '"' : '';

        return '<form method="post" action="' . h($formAction) . '" style="display:inline"' . $on . '>'
             . (class_exists('Csrf') ? Csrf::field() : '')
             . '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">'
             . $label
             . '</button></form>';
    }
}

19.6 兜底:显式读 POST/GET

即使 URL 正确,也不要依赖 $_REQUEST(受 request_order 配置影响):

$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);

第 20 章 · ⭐ 页面标题三变量兜底

20.1 问题根源

主题有多套 header:

环境加载文件读取变量
PCtemplates/default/header.php可能是 $page_title / $title / 硬编码站点名
移动端templates/default/mobile/header.php通常是 $__pageTitle

再加上:isMobileDevice() 判断优先级是 ?force_view → Cookie litelog_view → UA,PC 浏览器也可能因为 Cookie 命中而走移动端模板。

20.2 🔴 铁律:视图顶部同时设置三个变量

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

/* ★ 三变量兜底 */
$page_title  = '每日一文 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>
  • $page_title:主流通用变量名
  • $title:LiteLog 规范要求
  • $__pageTitle:移动端模板专用

20.3 终极方案:JS 强制覆盖 document.title

当主题 header 硬编码站点名、或变量名不可预测时,从视图顶部挂 layout_head_end 钩子注入 JS:

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

/* 标题 */
$page_title  = '每日一文 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

/* ★ 终极方案:JS 强制覆盖 document.title */
if (empty($GLOBALS['__da_title_hooked'])) {
    $GLOBALS['__da_title_hooked'] = true;
    $__da_title = $page_title;

    if (class_exists('Hook')) {
        try {
            $__da_js = '<script>(function(){var t='
                . json_encode($__da_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                . ';if(document.title!==t){document.title=t;}})();</script>' . "\n";

            Hook::addAction('layout_head_end', function () use ($__da_js) {
                if (empty($GLOBALS['__da_active'])) return;
                echo $__da_js;
            });
            Hook::addAction('template_head', function () use ($__da_js) {
                if (empty($GLOBALS['__da_active'])) return;
                echo $__da_js;
            });
        } catch (Throwable $e) {}
    }
}

include View::resolve('header');
?>

20.4 为什么这样"绝对有效"

主题 header 读什么效果
$page_title✅ 已经对
$title✅ 已经对
$__pageTitle✅ 已经对
硬编码站点名✅ JS 覆盖 document.title
什么都不输出✅ 有 <title> 才覆盖
用了缓存/CDN✅ 只影响客户端显示,绕过服务端缓存

20.5 插件自己输出完整 HTML 骨架时

如果插件不走主题 header(如论坛插件),在 <title> 后立即注入 JS:

$siteTitle = Option::get('site_title', '');
$pageTitle = trim((string)$title);
if ($pageTitle === '') $pageTitle = '默认标题';
if ($siteTitle !== '' && mb_strpos($pageTitle, $siteTitle) === false) {
    $pageTitle .= ' - ' . $siteTitle;
}
?>
<title><?= h($pageTitle) ?></title>
<script>
(function () {
    var t = <?= json_encode($pageTitle, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    function setTitle() {
        try { if (document.title !== t) document.title = t; } catch (e) {}
    }
    setTitle();
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', setTitle);
    }
    setTimeout(setTitle, 100);
    window.addEventListener('load', function () { setTimeout(setTitle, 200); });
})();
</script>

第 21 章 · ⭐ 三点式日志探针法

21.1 适用场景

页面白屏,但错误日志里没有具体信息,或错误被吞掉。

21.2 原理

在关键位置插入只写日志、不输出的探针:

@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] 标记 ①' . "\n",
    FILE_APPEND | LOCK_EX
);

不输出到页面是关键——避免探针本身触发新的错误。

21.3 三点布设

探针 ①:{插件名}.php 主文件

if (defined('XXX_HOOKS_REGISTERED')) return;
define('XXX_HOOKS_REGISTERED', true);

@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] ① 主文件已加载 URI=' . ($_SERVER['REQUEST_URI'] ?? '') . "\n",
    FILE_APPEND | LOCK_EX
);

探针 ②:控制器方法开头

public function displayDetail($id = 0) {
    @file_put_contents(
        CONTENT_PATH . 'backup/probe.log',
        '[' . date('Y-m-d H:i:s') . '] ② displayDetail 进入 id=' . (int)$id . "\n",
        FILE_APPEND | LOCK_EX
    );
    // ...
}

探针 ③:视图文件顶部

<?php
@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] ③ detail.php 开始执行' . "\n",
    FILE_APPEND | LOCK_EX
);
?>

21.4 结果解读

probe.log 内容含义下一步
文件不存在请求根本没到插件dispatcher 路由没匹配
只有 ①主文件加载了,但控制器没被调用dispatch_plugin_route 没触发
有 ① + ②控制器进入了,但视图没执行控制器内部崩了
有 ① + ② + ③视图开始执行了,崩在视图内部视图有语法或运行时错误
只有 ①,内容重复 20 次插件被重复加载有文件循环 require

第 22 章 · ⭐ 移动端 router 抢跑

22.1 机制

init.php 里的执行顺序:

define('BLOG_URL', ...);

// ★ 移动端 router 检查(先于插件)
if (function_exists('isMobileDevice') && isMobileDevice()) {
    $__mobileRouter = CONTENT_PATH . 'templates/' . getCurrentTemplateName() . '/mobile/router.php';
    if (is_file($__mobileRouter)) {
        try { require_once $__mobileRouter; }
        catch (Throwable $__e) { /* 写日志 */ }
    }
}

// ★ 加载插件
loadActivePlugins();

mobile/router.php 里有很多 exit 分支——一旦命中并 exit,loadActivePlugins() 根本不会执行,插件注册的所有钩子都不会生效。

22.2 影响

  • 移动端访问插件页面 → 插件钩子没注册 → 404 或显示博客首页
  • 前台资源注入(CSS / JS)不生效
  • 插件路由(dispatch_plugin_route)不触发

22.3 应对

方案 A:插件自己不依赖 mobile/router.php。所有路由都用 PluginRegistry::addFrontendRoute() 注册,它是 dispatch() 内部匹配的,不受 mobile router 影响。

方案 B:让 mobile/router.php 支持"未匹配时 fallthrough"(return 而不是 exit)。默认主题的 router 已经这么做了。

方案 C:核心 dispatcher 补丁(第 9.2 节),在 dispatch_start 后立即识别 /插件名/xxx,此时机在所有 router 之后。


第 23 章 · 输出缓冲与响应拦截

23.1 六条铁律

#铁律
1不要用 ob_start('callback') 绑定回调做页面拦截
2不要在 ob_start 的回调里再 ob_start
3回调必须处理 null——!is_string($html) 时 return ''
4优先用 layout_* 锚点,其次 dispatch_end + 手动捕获
5绝不要在 ob_start 和 ob_get_clean 之间抛异常
6用 Output::capture() 而不是裸 ob_start / ob_get_clean

23.2 优先用 layout_* 锚点

99% 的插件不需要碰 ob_start。想往页面上放内容?用 layout_*:

if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_body_start', function () {
            echo '<div class="my-banner">欢迎访问</div>';
        });
    } catch (Throwable $e) { /* 写日志 */ }
}

23.3 Output 类 API

$html = Output::capture(function () use ($data) {
    echo '<ul>';
    foreach ($data as $row) echo '<li>' . h($row) . '</li>';
    echo '</ul>';
});

Output::start('my_region');
echo '...';
$html = Output::end('my_region');

$html = Output::wrap('my_block', function () {
    echo '...';
});

第 6 部分:调试与诊断

第 24 章 · 错误排查方法论

24.1 核心原则

不要猜。要读。

24.2 症状 → 第一步

症状第一步
后台 500打开 display_errors,看报错行号
前台白屏F12 Console 看 JS;Network 看 HTML
路由不生效读 dispatcher.php
标题不对读主题 header.php 和 mobile/header.php
CSS/JS 没加载F12 Network 看状态码
CSRF 校验失败检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token
权限拒绝检查 Auth::can() / Auth::requireCap() 的能力名
删除/修改无效检查表单 action 是不是相对 URL
语法错误白屏php -l 检查每个文件

24.3 打点方法

@file_put_contents(
    CONTENT_PATH . 'backup/debug.log',
    '[' . date('Y-m-d H:i:s') . '] ' . var_export($someVar, true) . "\n",
    FILE_APPEND | LOCK_EX
);

24.4 常用错误日志路径

日志内容
content/backup/plugin-error.log插件主逻辑错误
content/backup/hook-error.log钩子执行错误
content/backup/route-error.log路由注册错误
content/backup/mobile-router-error.log移动端 router 错误
content/backup/db-error.log数据库连接错误
content/backup/query-error.log数据库查询错误

第 25 章 · 独立诊断脚本

25.1 适用场景

用户无法提供错误信息,页面白屏无提示时,放一个独立诊断脚本到根目录是最高效的定位方式。

25.2 完整模板

保存为 _diag_plugin.php,放网站根目录:

<?php
/**
 * 插件诊断脚本
 * 用法:放到网站根目录,浏览器访问 /_diag_plugin.php
 * 用完请删除!
 */

/* ★ 关键 1:关掉所有缓冲 */
while (ob_get_level() > 0) { @ob_end_clean(); }

/* ★ 关键 2:先定义 LITELOG_ROOT,否则 init.php 会 exit('Access Denied') */
if (!defined('LITELOG_ROOT')) {
    define('LITELOG_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
}

/* ★ 关键 3:强制桌面视图,避免移动端 router 提前 exit */
$_GET['force_view'] = 'desktop';
$_COOKIE['litelog_view'] = 'desktop';

/* 打开错误显示 */
@ini_set('display_errors', '1');
@ini_set('display_startup_errors', '1');
@error_reporting(E_ALL);

header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>插件诊断</title>
<style>
body{font-family:-apple-system,Consolas,Menlo,monospace;font-size:13px;
     line-height:1.65;padding:22px;background:#0f172a;color:#e2e8f0;margin:0}
h1{color:#60a5fa;font-size:20px;margin:0 0 6px}
h2{color:#a78bfa;font-size:15px;margin:26px 0 10px;padding-bottom:6px;
   border-bottom:1px solid #334155}
.row{display:flex;gap:10px;align-items:baseline;margin:5px 0}
.tag{display:inline-block;padding:1px 9px;border-radius:5px;font-size:11.5px;
     font-weight:700;flex-shrink:0}
.tag-ok{background:#064e3b;color:#6ee7b7}
.tag-err{background:#7f1d1d;color:#fca5a5}
.tag-warn{background:#78350f;color:#fcd34d}
.tag-info{background:#1e3a8a;color:#93c5fd}
pre{background:#1e293b;padding:11px 14px;border-radius:7px;overflow-x:auto;
    white-space:pre-wrap;word-break:break-all;margin:6px 0;
    font-size:12.5px;border:1px solid #334155}
</style>
</head>
<body>
<h1>🔍 插件诊断报告</h1>
<p><?= date('Y-m-d H:i:s') ?> · PHP <?= PHP_VERSION ?></p>

<?php
function diag_row($tag, $text) {
    echo '<div class="row"><span class="tag tag-' . $tag . '">'
       . strtoupper($tag) . '</span><span>' . $text . '</span></div>';
}
function diag_h2($t) {
    echo '<h2>' . htmlspecialchars($t, ENT_QUOTES, 'UTF-8') . '</h2>';
}

/* ============ 阶段 1:加载核心 ============ */
diag_h2('阶段 1 · 加载 LiteLog 核心');

$initFile = LITELOG_ROOT . 'init.php';
if (!is_file($initFile)) {
    diag_row('err', 'init.php 不存在');
    echo '</body></html>'; exit;
}

try {
    require_once $initFile;
    diag_row('ok', 'init.php 加载完成');
} catch (Throwable $e) {
    diag_row('err', 'init.php 抛异常:' . htmlspecialchars($e->getMessage()));
    echo '<pre>' . htmlspecialchars($e->getFile() . ':' . $e->getLine()) . '</pre>';
    echo '</body></html>'; exit;
}

/* ============ 阶段 2:核心类与常量 ============ */
diag_h2('阶段 2 · 核心类与常量');

foreach (['Option','Hook','View','Csrf','Auth','Database','Dispatcher','PluginRegistry'] as $cls) {
    diag_row(class_exists($cls) ? 'ok' : 'warn', '类 ' . $cls);
}

foreach (['LITELOG_ROOT','BLOG_URL','CONTENT_PATH','DB_PREFIX','TEMPLATE_URL'] as $c) {
    if (defined($c)) {
        diag_row('ok', '常量 ' . $c . ' = ' . htmlspecialchars((string)constant($c)));
    } else {
        diag_row('err', '常量 ' . $c . ' 未定义');
    }
}

/* ============ 阶段 3:插件状态 ============ */
diag_h2('阶段 3 · 插件状态');

$pluginName = 'daily_article';  // ← 修改为你的插件名
$active = (string)Option::get('active_plugins', '');
if (strpos(',' . $active . ',', ',' . $pluginName . ',') !== false) {
    diag_row('ok', $pluginName . ' 已启用');
} else {
    diag_row('err', $pluginName . ' 未启用');
}

diag_row('info', 'url_rewrite = ' . Option::get('url_rewrite', 'n'));
diag_row('info', 'isMobileDevice() = ' . (function_exists('isMobileDevice') && isMobileDevice() ? 'YES' : 'NO'));

/* ============ 阶段 4:视图语法检查 ============ */
diag_h2('阶段 4 · 视图语法检查');

$base = CONTENT_PATH . 'plugins/' . $pluginName . '/';
foreach (['views/detail.php', 'views/index.php', 'controllers/DailyController.php'] as $rel) {
    $path = $base . $rel;
    if (!is_file($path)) {
        diag_row('err', $rel . ' 不存在');
        continue;
    }
    diag_row('ok', $rel . '(' . number_format(filesize($path)) . ' 字节)');

    if (function_exists('exec')) {
        $out = []; $ret = 0;
        @exec('php -l ' . escapeshellarg($path) . ' 2>&1', $out, $ret);
        if ($ret === 0) {
            diag_row('ok', '&nbsp;&nbsp;└ 语法通过');
        } elseif ($ret !== 127) {
            diag_row('err', '&nbsp;&nbsp;└ ' . implode(' ', $out));
        }
    }
}

/* ============ 阶段 5:路由识别 ============ */
diag_h2('阶段 5 · 路由识别');

$dispFile = LITELOG_ROOT . 'include/lib/dispatcher.php';
$dispContent = @file_get_contents($dispFile);
if (strpos($dispContent, 'matchPluginUri') !== false) {
    diag_row('ok', 'dispatcher.php 已含伪静态路由补丁');
} else {
    diag_row('warn', 'dispatcher.php 未含伪静态补丁');
}

/* ============ 阶段 6:错误日志 ============ */
diag_h2('阶段 6 · 最近错误日志');

foreach ([
    'content/backup/plugin-error.log',
    'content/backup/hook-error.log',
    'content/backup/route-error.log',
] as $lf) {
    $path = LITELOG_ROOT . $lf;
    if (!is_file($path) || filesize($path) === 0) continue;
    diag_row('info', $lf);
    $lines = explode("\n", trim(@file_get_contents($path)));
    echo '<pre>' . htmlspecialchars(implode("\n", array_slice($lines, -15))) . '</pre>';
}

echo '<h2>✅ 诊断完成</h2>';
echo '<p>请把整页内容截图发给我。</p>';
echo '<p>⚠️ 用完请删除 _diag_plugin.php</p>';
?>
</body>
</html>

25.3 关键要点

  1. while (ob_get_level() > 0) @ob_end_clean();——先清空所有缓冲
  2. define('LITELOG_ROOT', DIR . DIRECTORY_SEPARATOR)——必须先定义,否则 init.php 会 exit('Access Denied')
  3. 强制桌面视图——$_GET['force_view'] = 'desktop' 避免 mobile router 提前 exit
  4. 每步 try/catch——不让诊断脚本自己崩
  5. php -l 检查语法——用 exec 或 fallback

25.4 输出解读

阶段状态含义
1 全部 OK—核心加载正常
2 类/常量警告—可能版本不兼容
3 插件未启用err去后台启用
4 语法错误err视图/PHP 文件有语法错误
5 未含补丁warn伪静态路由不支持
6 有错误info从日志找到具体报错

第 26 章 · 常见错误诊断表

26.1 问题:浏览器标签显示站点名,不是插件标题

排查步骤说明
1. 读主题 header.php看它 <title> 读什么变量
2. 读主题 mobile/header.php同上
3. 检查视图顶部是否设置了 $page_title / $title / $__pageTitle
4. 检查 Cookie litelog_view可能命中移动端
5. 加 JS 覆盖 document.title终极方案

26.2 问题:插件页面显示博客首页列表

排查步骤说明
1. 检查 dispatcher.php是否含伪静态路由补丁
2. 检查 URL是否形如 /插件名/xxx
3. 打补丁见第 9.2 节

26.3 问题:删除/修改按钮点了没反应

排查步骤说明
1. F12 Network 看请求 URL是不是 /xxx/yyy?action=... 而非 /?action=...
2. 改绝对 URLaction="<?= BLOG_URL ?>?action=..."
3. 检查 CSRF表单是否有 Csrf::field()
4. 检查权限Auth::can() / Forum_checkPerm()

26.4 问题:发帖/评论 403 "缺少 CSRF 令牌"

排查步骤说明
1. 检查表单是否有 <?= Csrf::field() ?>
2. 检查 AJAX是否带 X-CSRF-Token header
3. 检查 Csrf::verify() 时机是否在 POST 处理前

26.5 问题:白屏 + 无错误信息

排查步骤说明
1. 加 ini_set('display_errors', '1')临时打开错误显示
2. 运行诊断脚本看具体阶段失败
3. php -l 检查每个 PHP 文件语法错误
4. 三点式日志探针定位执行到哪一步
5. 读错误日志content/backup/*.log

第 7 部分:交付与质量

第 27 章 · 完整最小骨架

27.1 目录结构

content/plugins/my_plugin/
├── my_plugin.php
├── my_plugin_callback.php
├── my_plugin_setting.php
├── frontend/
│   └── routes.php
├── views/
│   └── main.php
└── assets/
    ├── style.css
    └── script.js

27.2 my_plugin.php

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述功能
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */

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

if (!defined('MP_VER')) define('MP_VER', '1.0.0');

if (!function_exists('MP_conf')) {
    function MP_conf($key, $default = '') {
        if (!class_exists('Option')) return $default;
        try {
            $v = Option::get('my_plugin_' . $key, '');
        } catch (Throwable $e) {
            return $default;
        }
        return $v !== '' ? $v : $default;
    }
}

if (!function_exists('MP_isEnabled')) {
    function MP_isEnabled() {
        return MP_conf('enabled', '1') === '1';
    }
}

/* ★ 通用 POST 按钮生成器(绝对 URL + CSRF) */
if (!function_exists('MP_postBtn')) {
    function MP_postBtn($action, array $params, $label, $confirm = '', $class = '') {
        $qs = http_build_query(array_merge(['action' => $action], $params));
        $formAction = BLOG_URL . '?' . $qs;

        $on = '';
        if ($confirm !== '') {
            $jsonConfirm = json_encode((string)$confirm,
                JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
            $on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
        }
        $cls = $class !== '' ? ' class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . '"' : '';

        return '<form method="post" action="' . htmlspecialchars($formAction, ENT_QUOTES, 'UTF-8') . '" style="display:inline"' . $on . '>'
             . (class_exists('Csrf') ? Csrf::field() : '')
             . '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">'
             . $label
             . '</button></form>';
    }
}

/* Hook 注册 */
if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_head_end', 'MP_injectCss');
        Hook::addAction('template_head',   'MP_injectCss');
        Hook::addAction('layout_footer_scripts', 'MP_injectJs');
        Hook::addAction('body_end',              'MP_injectJs');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

if (!function_exists('MP_injectCss')) {
    function MP_injectCss() {
        static $done = false;
        if ($done) return;
        $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
        echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
    }
}

if (!function_exists('MP_injectJs')) {
    function MP_injectJs() {
        static $done = false;
        if ($done) return;
        $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
        echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
    }
}

/* 加载路由文件 */
if (class_exists('PluginRegistry')) {
    try {
        $__routes = __DIR__ . '/frontend/routes.php';
        if (is_file($__routes)) require_once $__routes;
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin require routes: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

27.3 my_plugin_callback.php

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

function callback_init() {
    try {
        global $DB;
        $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_plugin_items` (
            `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            `name` VARCHAR(100) NOT NULL DEFAULT '',
            `created_at` INT UNSIGNED NOT NULL DEFAULT 0
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        if (class_exists('Option')) {
            foreach (['enabled' => '1', 'title' => '我的插件'] as $k => $v) {
                $optKey = 'my_plugin_' . $k;
                if (Option::get($optKey, '') === '') Option::set($optKey, $v);
            }
        }
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

function callback_up() { callback_init(); }
function callback_rm() { /* 保留数据 */ }

function callback_uninstall() {
    global $DB;
    try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_plugin_items`"); } catch (Throwable $e) {}
    try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}

27.4 frontend/routes.php

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

try {
    PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
        if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        $GLOBALS['__mp_active'] = true;
        include __DIR__ . '/../views/main.php';
    });
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] my_plugin route register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

27.5 views/main.php(★ 三变量兜底 + JS 覆盖)

<?php
/**
 * 我的插件 · 主页
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/* 前置守卫 */
if (!function_exists('MP_isEnabled')) {
    echo '<div style="max-width:600px;margin:80px auto;padding:20px;text-align:center;'
       . 'border:1px solid #fca5a5;border-radius:8px;color:#b91c1c;background:#fef2f2;font-family:sans-serif">'
       . '插件未正确加载</div>';
    return;
}

/* ★ 三变量兜底 */
$page_title  = '我的插件 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

/* ★ 终极方案:JS 强制覆盖 document.title */
if (empty($GLOBALS['__mp_title_hooked'])) {
    $GLOBALS['__mp_title_hooked'] = true;
    $__mp_title = $page_title;

    if (class_exists('Hook')) {
        try {
            $__mp_js = '<script>(function(){var t='
                . json_encode($__mp_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                . ';if(document.title!==t){document.title=t;}})();</script>' . "\n";
            Hook::addAction('layout_head_end', function () use ($__mp_js) {
                if (empty($GLOBALS['__mp_active'])) return;
                echo $__mp_js;
            });
            Hook::addAction('template_head', function () use ($__mp_js) {
                if (empty($GLOBALS['__mp_active'])) return;
                echo $__mp_js;
            });
        } catch (Throwable $e) {}
    }
}

include View::resolve('header');
?>

<div class="mp-container">
    <h1>我的插件</h1>

    <form method="post" action="<?= BLOG_URL ?>?action=my_plugin_ajax">
        <?= Csrf::field() ?>
        <input type="text" name="name" placeholder="名称">
        <button type="submit">提交</button>
    </form>
</div>

<?php include View::resolve('footer'); ?>

27.6 my_plugin_setting.php

见第 8.3 节。

27.7 assets/style.css / assets/script.js

空文件也必须存在(否则 filemtime() 会 warning)。


第 28 章 · 交付前自检清单

结构

  • [ ] 目录名 = 主文件名(小写下划线)
  • [ ] 有 {插件名}.php + {插件名}_callback.php
  • [ ] 有 frontend/routes.php(如需要)
  • [ ] 有 views/ 目录
  • [ ] 有 assets/ 目录(哪怕空文件)
  • [ ] 有 {插件名}_setting.php(如需设置页)

安全

  • [ ] 所有 SQL 用占位符
  • [ ] 所有输出过 h()
  • [ ] 无文件操作,或过 basename()
  • [ ] AJAX 写操作有登录校验
  • [ ] 原子扣减用 query()->rowCount()
  • [ ] 无 unserialize / eval
  • [ ] IN (...) 用 array_map('intval') + 占位符
  • [ ] 未覆盖 PDO 属性
  • [ ] 所有 POST 表单有 Csrf::field()
  • [ ] 所有 AJAX 带 X-CSRF-Token
  • [ ] 后台入口调 Auth::requireCap()
  • [ ] 所有表单 action 是绝对 URL

⭐ 500 免疫

  • [ ] 主文件每个 define() 有 if (!defined(...))
  • [ ] 主文件每个 function 有 if (!function_exists(...))
  • [ ] 所有 Option::get() 第二参数是 ''
  • [ ] callback_init() 整段 try/catch,DDL 无 COMMENT / COLLATE
  • [ ] 视图顶部有 function_exists() 守卫
  • [ ] 所有 Hook::addAction() 包在 class_exists('Hook') + try/catch
  • [ ] PluginRegistry 调用前有 class_exists() 守卫
  • [ ] frontend/routes.php 在主文件里被 require_once
  • [ ] 每个 .php 文件都通过 php -l

⭐ 视图与资源

  • [ ] 前台视图 include View::resolve('header') 和 footer
  • [ ] 前台视图设置三个标题变量 $page_title / $title / $__pageTitle
  • [ ] 前台视图不自建 <!DOCTYPE html>
  • [ ] CSS/JS 走 layout_head_end / layout_footer_scripts Hook 注入
  • [ ] 资源 URL 拼 BLOG_URL,带 ?v=filemtime
  • [ ] PHP 标签合并,避免连续两个 <?php

⭐ 输出缓冲

  • [ ] 没有使用 ob_start('callback') 做页面拦截
  • [ ] 如果必须用输出缓冲,用 Output::capture()
  • [ ] 如果注册了 dispatch_end,逻辑里有长度守卫
  • [ ] 如果注册了 dispatch_end,逻辑里有非字符串输入处理

API 使用

  • [ ] $DB->fetchOne() 判 false
  • [ ] $DB->insert() 返回值 (int) 转换
  • [ ] 需要行数用 query()->rowCount()
  • [ ] 用户名用 nickname ?: username
  • [ ] URL 拼接不重复加斜杠
  • [ ] 函数/常量/表名有插件前缀
  • [ ] AJAX 端点九条铁律全过
  • [ ] $_REQUEST 换成显式 $_POST / $_GET

验证

  • [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
  • [ ] 浏览器标签页标题正确
  • [ ] F12 Console 无报错,Network 全 200
  • [ ] 卸载后表删除、option 清空
  • [ ] 用 _diag_plugin.php 走查一遍

第 29 章 · 三十条铁律

#教训
1规范是地图,内核是地形。地图和地形冲突时,信地形
2不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜
3有可工作参考实例时,先"逐行结构对齐",再谈业务
4导出/文档没看到某物 ≠ 某物不存在;先问,再断言
5每条规范都有场景前提,问清上下文再套用
6少说"一定";多说"请验证" / "如未生效,查这里"
7"多键兜底""兼容写法"往往是懒惰的遮羞布
8视图不能自建 HTML 骨架
9CSS/JS 走 Hook 注入
10内核只自动加载主文件;子文件要主文件主动 require
11不要用 ob_start('callback') 做页面拦截
12ob_start 回调里返回 null 会清空整个页面
13在 ob_start 回调里再 ob_start 是 PHP 明确禁止的
14处理 HTML 输出时,必须有"长度守卫"
15优先用 layout_* 布局锚点做页面注入
16每一个 Hook::addAction() 都包 try/catch
17用户报告的"XX 无效",先复现、先看日志,再排查
18修复 bug 时,先加日志,再改代码
19所有 POST 请求默认被 CSRF 拦截
20插件后台页面跳过核心全局 CSRF,需自行校验
21后台操作前调 Auth::requireCap()
22Option::get() 第二参数永远是 ''
23所有表单 action 必须是绝对 URL
24JS 动态生成表单的 action 也必须是绝对 URL
25视图顶部同时设置 $page_title / $title / $__pageTitle
26主题 header 硬编码时,用 JS 强制覆盖 document.title
27连续两个 <?php 是陷阱——合并为一个大 PHP 块
28诊断脚本必须先 define('LITELOG_ROOT', ...)
29移动端 router.php 会抢跑并 exit
30$_REQUEST 受 request_order 影响——显式用 $_POST / $_GET

常见错误推理链

❌ 错误链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题

✅ 正确链:读 dispatcher.php → 发现路由在核心匹配 → 检查 PluginRegistry::matchFrontendRoute → 检查自己的注册代码 → 修复

❌ 错误链 B:CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists 判断 → 还是错

✅ 正确链:CSRF 报错 → 看报错是"缺少令牌"还是"不匹配" → "缺少"就检查表单/AJAX 是否带 token → "不匹配"就检查 session 是否丢了

❌ 错误链 C:删除按钮点了没反应 → "可能权限问题" → 加日志 → 还是没反应

✅ 正确链:查看 F12 Network → 表单提交到了哪个 URL → 如果是 /xxx/yyy?action=... 而不是 /?action=... → 相对 URL 陷阱

❌ 错误链 D:标题不对 → 改 $page_title → 无效 → 改 $title → 无效 → 改 $__pageTitle → 无效 → "放弃"

✅ 正确链:读主题 header.php → 看它读哪个变量 → 如果硬编码站点名 → 用 JS 覆盖 document.title

❌ 错误链 E:白屏 → "可能数据库问题" → 检查 DB → 正常 → "可能缓存问题" → 清缓存 → 还白屏

✅ 正确链:白屏 → 用 _diag_plugin.php → 阶段 4 显示 syntax error on line 17 → 视图文件有语法错误 → php -l 验证 → 修复


第 8 部分:附录

附录 A · API 速查表

Database

$DB->query($sql, $params)         // PDOStatement
$DB->fetchAll($sql, $params)      // array
$DB->fetchOne($sql, $params)      // array | false
$DB->fetchColumn($sql, $params)   // scalar | false
$DB->insert($table, $data)        // string lastInsertId
$DB->update($table, $data, $where, $params)  // true
$DB->delete($table, $where, $params)         // true
$DB->pdo()                        // PDO
$DB->prefix()                     // 表前缀

Option

Option::get($key, '')             // string
Option::set($key, $value)         // void
Option::all()                     // array

Hook

Hook::addAction($hook, $func, $priority = 10)
Hook::doAction($hook, ...$args)
Hook::removeAction($hook, $func = null)
Hook::hasAction($hook)
Hook::captureAction($hook, ...$args)
Hook::applyFilters($hook, $value, ...$args)

View

View::resolve($name)              // file path
View::display($name, $data = [])

Url

Url::log($gid, $alias)
Url::sort($sid, $alias)
Url::tag($name)
Url::author($uid)
Url::record($date)

Csrf

Csrf::token()                     // string
Csrf::field()                     // HTML input
Csrf::verify()                    // void(失败 403)
Csrf::verifyJson($payload)

Auth

Auth::check()
Auth::id()
Auth::role()
Auth::can($cap)
Auth::requireCap($cap)
Auth::canEditLog($log)

PluginRegistry

PluginRegistry::addAdminPage($action, $config)
PluginRegistry::addAdminMenu($menu)
PluginRegistry::addFrontendRoute($regex, $cb)
PluginRegistry::addAdminAsset($action, $type, $url)
PluginRegistry::enqueueAsset($type, $url)

Output

Output::capture($callable, ...$args)
Output::start($name)
Output::end($name)
Output::wrap($name, $callable, ...$args)
Output::discard($name)
Output::level()

辅助函数

h($s)                             // HTML 转义
redirect($url)                    // 跳转 + exit
isLoggedIn()                      // 是否登录
checkLogin()                      // 强制登录
dateFmt($ts) / datetimeFmt($ts)
getExcerpt($html, $len)
makeAlias($str)
pagination($page, $total, $perPage)
getPageNumber()

附录 B · 命名约定

符号命名示例
函数{前缀}_{CamelCase}ICG_isEnabled()
常量{前缀}_UPPERICG_VER
类{PrefixCamel} / {Prefix_Camel}Widget_IconGen
表名{DB_PREFIX}{插件名}_{表名}lt_icon_generator_items
Option 键{插件名}_{键名}icon_generator_enabled
钩子{插件名}_{事件}my_plugin_after_save

核心已占用前缀:Forum_* / UserGroup_* / Uc_* / Widget_* / HomepageMode_* / DIY_* / Output / Csrf / Auth

路径常量:

常量结尾斜杠
LITELOG_ROOT✅
CONTENT_PATH✅
ADMIN_PATH✅
INCLUDE_PATH✅
BLOG_URL✅
DB_PREFIX❌

附录 C · 系统钩子清单

生命周期

plugin_activate / plugin_deactivate / plugin_uninstall

前台布局

layout_head_start / layout_head_end / layout_body_start / layout_content_start / layout_content_end / layout_footer_scripts / layout_body_end

传统布局

template_head / body_start / header_before / header_after / before_content / after_content / footer_before / footer_after / template_footer / body_end

视图

view_before / view_after

分发

dispatch_start / dispatch_plugin_route / dispatch_matched / dispatch_end / public_action_{action}

内容

log_list_query / log_list_result / log_display_before / log_display_after / log_content / log_save_before / log_save_after / comment_save_before / comment_save_after / comment_section_before / comment_section_after

后台

admin_request / admin_menu / admin_head / admin_footer / admin_dashboard_widgets

扩展

register_widgets / register_homepage_modes

系统事件

user_login / user_logout / theme_activate / option_save


附录 D · 案例集

D.1 案例:相对 URL 陷阱导致删除按钮失效

症状:论坛插件点击"删除主题"按钮,页面刷新但帖子还在。

排查过程:

  1. F12 Network 看请求 URL:POST /forum/thread/5?action=forum_delete&id=5
  2. 读 Forum_routeDelete() 代码,逻辑正确
  3. 检查表单 HTML:<form method="post" action="?action=forum_delete&id=5"> ← 相对 URL
  4. 确认根因:浏览器把 ?action=... 解析成"当前路径 + query",被 Nginx 重写规则抢跑

修复:把 action="?..." 改为 action="<?= BLOG_URL ?>?..."。

教训:见第 19 章。

D.2 案例:标题不显示插件名

症状:访问 /daily_article/detail/5,浏览器标签显示站点名。

排查过程:

  1. 读主题 header.php,<title> 读 $page_title
  2. 视图顶部设置了 $page_title —— 理论正确
  3. 用诊断脚本抓 View::resolve('header') 的路径 —— 是 mobile/header.php
  4. Cookie litelog_view=mobile 导致浏览器被判定为移动端
  5. 移动端 header 读 $__pageTitle,没设置

修复:

  1. 视图顶部同时设置三变量
  2. 加 JS 强制覆盖 document.title(终极方案)

教训:见第 20 章。

D.3 案例:视图白屏无错误

症状:访问 /daily_article/detail/5,页面完全空白,无任何提示。

排查过程:

  1. ?__debug_errors=1 打开错误显示 —— 仍空白
  2. 加 ini_set('display_errors', 1) —— 仍空白
  3. 用诊断脚本 —— 阶段 4 显示:

B84

  1. 打开文件第 17 行 —— 发现连续两个 <?php

修复:合并为一个 PHP 块(见第 5.3 节)。

教训:编辑器粘贴容易破坏 PHP 标签,视图顶部结构要简化。

D.4 案例:插件路由 404

症状:访问 /daily_article/detail/5 显示博客首页列表。

排查过程:

  1. 检查 active_plugins —— daily_article 已启用
  2. 检查 daily_article.php —— 注册了 dispatch_plugin_route
  3. 读核心 dispatcher.php —— 发现只匹配 __plugin_path 和标准 Rewrite URL
  4. 核心不认识 /daily_article/xxx 格式
  5. $uri = 'daily_article/detail/5' 匹配不到任何前缀 → fallback 到 LogController::display()

修复:给核心 dispatcher 打伪静态路由补丁(见第 9.2 节)。

教训:核心默认不支持任意插件名作为 URL 前缀,需要一次性补丁。

D.5 案例:AJAX 403 "缺少 CSRF 令牌"

症状:每日一文插件后台发文章,返回 403 错误。

排查过程:

  1. 阅读 admin/index.php,发现对所有 POST 请求强制执行 Csrf::verify()
  2. 检查插件表单 —— 没有 Csrf::field()
  3. 每次 POST 都被拦截

修复:给所有 POST 表单加 <?= Csrf::field() ?>。

教训:见第 13.2 节。


附录 E · 给 AI 的指令模板

E.1 从零开发新插件

请按 LiteLog 插件开发手册 v4.0 开发一个 XXX 插件:

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……

严格遵守本文件所有条款,特别是:
- 第 5.3 节「PHP 语法安全写法」
- 第 9 章「路由系统」
- 第 15 章「500 免疫」
- 第 16 章「五条硬性安全规则」(尤其规则 5 绝对 URL)
- 第 19 章「相对 URL 陷阱」
- 第 20 章「标题三变量兜底」
- 第 27 章「完整最小骨架」
- 第 28 章「交付前自检清单」

不要修改任何核心文件(除非明确需要 dispatcher 补丁)。完成后给出完整文件列表和每个文件的完整代码。

E.2 排查 500 错误

我的插件启用后后台 500。请根据手册第 15 章排查。

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

报错信息:……

E.3 排查 CSRF 错误

我的插件出现 CSRF 校验失败:……

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)

请按第 13 章排查。

E.4 排查"按钮点了没反应"

我的插件里有一个删除按钮,点击后页面刷新了但目标没被删除。

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

当前表单代码:……
浏览器 Network 面板显示的请求 URL:……

请按第 19 章「相对 URL 陷阱」排查。

E.5 排查"标题不对"

我的插件页面浏览器标签始终显示站点名,不是插件标题。

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

主题 header.php 的 <title> 那行代码:……
插件视图顶部代码:……

请按第 20 章排查。

E.6 排错通用模板

我的插件出现以下症状:……

【附件:LiteLog 插件开发手册 v4.0 完整版.md】

当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)

请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法

如果需要诊断脚本,请直接给我一个完整的 `_diag_plugin.php`。

附录 F · 更新日志

v4.0(2026-10-13)

新增:

  • 第 5.3 节 PHP 语法安全写法
  • 第 9.2 节 核心 dispatcher 伪静态路由补丁
  • 第 19 章 相对 URL 陷阱
  • 第 20 章 页面标题三变量兜底
  • 第 21 章 三点式日志探针法
  • 第 22 章 移动端 router 抢跑
  • 第 25 章 独立诊断脚本
  • 第 26 章 常见错误诊断表
  • 附录 D 案例集(5 个真实案例)

改进:

  • 手册整体结构重构为 8 部分
  • 加入"阅读指引"
  • 铁律从 22 条扩到 30 条
  • 增加"常见错误推理链"
  • 附录新增 API 速查表

v3.2(2026-10-13)

在 v3.1 基础上补充相对 URL、三变量兜底、伪静态路由、PHP 标签、诊断脚本、三点式探针、移动端 router 抢跑等经验。

v3.1(2026-10-12)

在 v3.0 基础上新增 CSRF 全局防护、后台权限系统 Auth、前台评论 CSRF、插件设置页统一入口、AJAX 九条铁律、会话安全强化。

v3.0(2026-10-11)

基础版本,包含 500 免疫、三条硬性安全规则、八条红线、目录结构、钩子系统、数据库、视图、资源、调试、骨架、自检清单、18 条铁律。


收尾:一句话总结

📌 "define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。"

核心原则 16 条

v3.0 五条:

① 视图 include 主题 header/footer ② 资源走 layout_* Hook 注入 ③ 不要用 ob_start('callback') 拦截页面 ④ 必须用输出缓冲时,用 Output::capture() + 长度守卫 ⑤ 机制不明,先读源码

v3.1 三条:

⑥ 所有 POST 表单加 Csrf::field(),AJAX 带 X-CSRF-Token ⑦ 后台操作前调 Auth::requireCap() ⑧ 插件设置页放 {插件名}_setting.php

v3.2 五条:

⑨ 所有表单 action 必须是绝对 URL ⑩ 标题三变量兜底 + 必要时 JS 覆盖 document.title ⑪ PHP 标签合并,避免连续两个 <?php ⑫ 诊断脚本必须先 define('LITELOG_ROOT') ⑬ 三点式日志探针法定位白屏崩在哪一步

v4.0 三条:

⑭ 核心 dispatcher 需打伪静态路由补丁,让所有插件自动支持 /插件名/xxx ⑮ 移动端 router 会抢跑并 exit——init.php 里它在 loadActivePlugins() 之前 ⑯ $_REQUEST 换成显式 $_POST / $_GET,不受 request_order 影响

掌握这 16 条 + 五条硬性规则 + 九条红线 + 30 条铁律,99% 的插件能一次跑通。


本手册基于 LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文/论坛插件两轮完整修复战整理。

v4.0 相对 v3.2 的主要新增:

  • 手册结构重构为 8 部分
  • 相对 URL 陷阱独立成章
  • 标题三变量兜底独立成章
  • 三点式日志探针法独立成章
  • 独立诊断脚本独立成章
  • 常见错误诊断表
  • 5 个真实案例集
  • API 速查表
  • 16 条核心原则汇总

若内核继续更新,请同步更新第 9 章「路由系统」、第 10 章「数据库」、第 13 章「CSRF 与权限」、第 17 章「红线」、第 29 章「三十条铁律」五个章节。

与
与你共赴山海
用户
2 楼 · 5 天前

看了这份 LiteLog 插件开发手册,感觉对新手来说挺实用的。特别是那个快速上手的部分,步骤清晰,跟着做一遍应该能快速掌握。我也准备试试自己写个插件,先从简单的做起,看能不能做出点有趣的东西来。希望这份手册能帮我顺利入门!

沉
沉默是金
用户
3 楼 · 4 天前

这个 LiteLog 插件开发手册 v4.0 看起来真的很实用,我最近刚好在研究插件开发。第一章的快速上手部分很吸引我,打算跟着试试看用 10 分钟创建一个 hello 插件。有没有朋友试过,分享下经验吗?

清
清晨煮茶
用户
4 楼 · 4 天前

看到 LiteLog 插件开发手册更新到 v4.0 真是挺开心的,特别是入门部分,快速上手的部分很实用。我现在正在尝试自己开发插件,这个手册里提到的前台视图文件结构和代码写法对我很有帮助。刚刚照着手册写了 hello 插件,现在前台能显示了“你好,LiteLog”,有点成就感呢。👍

淡
淡然处世
用户
5 楼 · 4 天前

刚看到这个 LiteLog 插件开发手册,版本更新到 4.0 真不错。我正好在做一个类似的项目,看到你们总结了这么多实战经验,特别有参考价值。特别是那个从零到一的快速上手教程,我觉得对新手特别有帮助。我已经迫不及待想要试试了,不知道里面有没有什么排错技巧能解决我现在的困扰。

慢
慢生活
用户
6 楼 · 4 天前

这LiteLog插件开发手册真不错,尤其是对新手来说,从零开始到生产都有详细的步骤。我最近在做一个网站开发项目,正好需要用到插件,这手册里的入门部分看起来很实用,准备先试试看。😊

雨
雨后的天
用户
7 楼 · 4 天前

看到这份开发手册,我顿时觉得学插件的信心满满!刚好我在做一个小型博客网站,这插件看起来挺适合我。尤其是那快速上手部分,十分钟就能跑通一个插件,真是效率高!我想尝试一下,先做一个简单的“欢迎”页面。对了,手册里提到的每日一文/论坛插件修复战的经验分享,我特别想看看,能学到不少实战技巧呢!

坐
坐看云起时
用户
8 楼 · 4 天前

哇,看标题就知道这手册得好好研究一番。刚入门网站开发,看到这从零到一的部分很实用,尤其是10分钟跑通第一个插件的目标,正符合我现在的学习需求。打算先试着按照这个快速上手,看能不能顺利弄出一个hello插件来。

巷
巷口小卖部
用户
9 楼 · 4 天前

哇,这个LiteLog插件开发手册版本更新到4.0了,正好最近在捣鼓网站开发,看到这个感觉收获满满啊!我正愁不知道从何下手写插件,现在有了这个手册感觉有方向了。特别是那“从零到一”的路径,看着步骤挺清晰的。准备先按着入门部分试一试,看看能不能在10分钟内跑通第一个插件。

半
半盏清茶
用户
10 楼 · 4 天前

这 LiteLog 插件开发手册看起来很全面啊,正好我最近在弄网站开发,这个手册应该能帮上大忙。第一章的快速上手看起来挺简单的,我想试试看能不能按照步骤做出来一个小插件。希望后面的部分也能这么易懂!

一
一苇以航
用户
11 楼 · 4 天前

看到这份开发手册,我觉得对于初学者来说是个很好的资源。特别是快速上手部分,对于像我这样的新手来说非常实用。1.1 目标里的创建hello插件,感觉就像是入门的敲门砖。---

水
水边人家
用户
12 楼 · 4 天前

我正在尝试开发一个类似功能的插件,但遇到了一些问题。第1.3 主文件 hello.php 中提到的定义常量的部分,我想知道具体应该如何操作,有没有具体的例子可以参考?

小
小确幸
用户
13 楼 · 4 天前

这份手册的结构真的很清晰,从零到一再到生产,这样的路径设计对学习很有帮助。尤其是附录部分,感觉像是给AI下指令的指南,这个功能对于自动化开发很有用。---

慢
慢生活
用户
14 楼 · 4 天前

嘿,我刚刚看到这个LiteLog插件开发手册,感觉挺实用的。我正打算自己动手开发个插件,看到这里有详细的步骤和案例,心里有点小激动呢。你呢,有没有试过按照这个手册开发插件?遇到了什么困难没?

巷
巷口小卖部
用户
15 楼 · 3 天前

这个手册的结构清晰,从入门到生产都有详细的步骤。对于初学者来说,这样的结构很有帮助。不过,我觉得在入门部分多加入一些基础知识的讲解会更好,比如PHP的基础。

手
手帐记录者
用户
16 楼 · 3 天前

我注意到你提到的红线、重点和口诀,这些标记方式很实用。在实际开发中,这样的提示能够帮助我们快速识别和避免常见错误。不过,对于初学者来说,这些标记是否可以再详细一些呢?

远
远山如黛
用户
17 楼 · 3 天前

看到你们团队通过实战经验来完善手册,我深感敬佩。我也在开发自己的插件,虽然目前还处于初级阶段。请问你们有没有什么经验或者建议,可以分享给我这个新手呢?

傍
傍晚散步
用户
18 楼 · 3 天前

这个 LiteLog 插件开发手册看起来非常全面,我特别感兴趣的是第 1 部分的快速上手部分。10 分钟就能跑通第一个插件,这对于初学者来说是个很大的吸引力。不过,我有点好奇,这个插件是否支持自定义样式?`---`

在阅读指引中,看到“按症状查阅”的部分,这对我来说是个不错的提示。在开发过程中,遇到问题能够快速定位并解决问题是非常关键的。不知道这部分是否包含了常见的错误和解决方案?`---`

第 1 章提到的“定义常量”部分,我注意到了“必须加 defined 守卫”的要求。这是为了防止常量在脚本中重复定义,导致潜在的问题。在网站开发中,这种细节往往容易被忽视。`---`

关于第 1 部分的“快速上手”,我之前也有过类似的经验。创建一个简单的插件来熟悉环境是个不错的方法。不过,我想知道,这个手册中是否有关于如何进行插件性能优化的内容?`---`

看到“红线:违反一票否决”的部分,我意识到在开发过程中遵守规范的重要性。这让我想到,是否可以在手册中增加一些关于代码规范和最佳实践的内容?`---`

对于想要学习插件开发的初学者来说,这个手册的分步指导和示例代码非常有帮助。不过,我很好奇,手册中是否有关于如何进行插件测试和调试的部分?这对于确保插件稳定运行非常重要。`---`

阅读完手册的目录结构,我发现它非常清晰,按照“从零到一”和“从一到生产”的路径组织,非常适合不同阶段的开发者。我想知道,手册中是否提供了关于如何将插件集成到现有网站中的指导?这对于实际应用来说非常有价值。`---`

行
行到水穷处
用户
19 楼 · 3 天前

哇,看这 LiteLog 插件开发手册 v4.0,感觉内容超级全面,从零基础到生产实战都有了。我刚接触网站开发,这种手册真是帮了大忙了!特别是那10分钟跑通第一个插件的教程,我试试看能不能快速入门。🚀👨‍💻

青
青梅煮酒
用户
20 楼 · 3 天前

刚刚看到这个开发手册,感觉非常详细实用。特别是入门部分,从零开始教怎么制作一个简单的插件,非常适合新手。我正准备学习网站开发,这对我很有帮助。打算先按推荐的顺序,一步步实践起来。

一
一苇以航
用户
21 楼 · 3 天前

看了这个开发手册,感觉LiteLog插件开发流程挺清晰的。特别是快速上手部分,对于新手来说,10分钟就能跑通第一个插件,挺吸引人的。我也想尝试开发插件,等有空了好好研读一下。😊

路
路过人间
用户
22 楼 · 3 天前

哇,看到 LiteLog 插件开发手册更新到 v4.0 真是兴奋啊!我最近刚入门插件开发,看到这份手册分步骤讲解,感觉心里有底多了。特别是那个“快速上手”部分,10 分钟就能跑通第一个插件,这对我来说太有吸引力了。我已经迫不及待想试试了,希望能在实践中学到更多!😄

山
山间的雾
用户
23 楼 · 3 天前

看了 LiteLog 插件开发手册,感觉内容挺全的,特别是对新手来说很友好。那个快速上手部分挺实用的,10 分钟就能跑通第一个插件,这对我来说很有吸引力。最近在学习网站开发,正愁找不到好的插件开发资料呢。👍

周
周末赖床
用户
24 楼 · 3 天前

哈哈,刚刚看到这个开发手册,正好想开发个插件,这手册里的入门教程看起来很实用呢。第一章的快速上手教程很到位,10分钟跑通第一个插件听起来就很有成就感。我是新手,先从最简单的开始练习,等熟练了再一步步来,希望能在技术分享的过程中不断进步。

清
清风徐来
用户
25 楼 · 3 天前

看到这份手册,我对于快速上手LiteLog插件开发有了信心。特别是1.1章节提到的创建hello插件的目标,感觉非常适合新手入门。请问有人能分享一些实战中遇到的问题和解决方法吗?---

红线的标记提醒了我,在开发过程中一定要小心处理这些硬性规则。特别是定义常量的那部分,感觉是很多初学者容易忽视的细节。关于这个,手册里有没有更详细的解释呢?---

在阅读指引中,我注意到“从一到生产”的路径,感觉这是一个很好的学习路径。我想知道,完成第7部分的排错后,如何保证插件在生产环境中的稳定运行?有没有相应的经验和建议?---

我开发过一个类似的插件,对于入门部分的内容很有共鸣。特别是关于目录结构的说明,我觉得特别清晰。有没有人可以分享一些自己开发的插件,供大家学习交流呢?---

路
路过人间一趟
用户
26 楼 · 3 天前

看到 LiteLog 插件开发手册,真是太好了。我在网站上搞了个小项目,正愁找不到合适的插件开发资料呢。特别是第一部分入门部分,说10分钟就能上手,这不就是我这种小白的最爱吗?我已经迫不及待想尝试一下了,等下试试看。🚀🔧

故
故人具鸡黍
用户
27 楼 · 3 天前

这 LiteLog 插件开发手册看起来真的挺详细的,特别是那种从零到一的学习路径,对新手来说应该是很有帮助的。特别是那个10分钟就能跑通第一个插件的快速上手教程,感觉挺实用的。我刚刚开始学习插件开发,打算跟着这个手册试试水。😊希望可以顺利!

愿
愿无岁月可回首
用户
28 楼 · 3 天前

哇,这 LiteLog 插件开发手册真详细啊!看了目录结构,感觉入门部分挺适合初学者的。我最近刚接触插件开发,正愁找不到好的入门指南呢。特别是快速上手那一章,10 分钟就能跑通第一个插件,听起来好简单!打算先从这里开始学习。👍

山
山间的雾
用户
29 楼 · 1 天前

这个 LiteLog 插件开发手册看起来非常全面,特别是提到了从零到一和从一到生产的路径组织,对于初学者来说非常友好。请问这个手册是否包含对常见错误的案例分析?

浮
浮生若梦
用户
30 楼 · 1 天前

我正在开发一个类似的插件,看到这个手册里提到的快速上手章节很感兴趣。能否详细介绍一下“快速上手”部分的具体内容?

雾
雾锁江南
用户
31 楼 · 1 天前

这个手册中提到的“关键条款标记”很有用,特别是红线和重点标记,对于避免在开发过程中犯错误非常有帮助。有没有具体例子说明这些标记是如何应用的?

登录 或 后台登录 后参与讨论