懒人导航 - 使用与开发文档
懒人导航基于原生 PHP + MySQL 构建,不依赖任何第三方框架。本文档面向三类读者:
| 角色 | 关注章节 | 目标 |
|---|---|---|
| 普通用户 / 站长 | 第一章、第五章 | 安装部署、日常运营、按需启用插件 |
| 主题开发者 | 第二章、第三章 | 自定义页面布局和视觉风格 |
| 插件开发者 | 第二章、第四章 | 扩展功能、注册钩子、管理数据库 |
懒人导航采用按需加载架构:初始安装只创建核心表(sites、categories、settings 等 9 张),所有插件默认关闭。插件启用时才自动创建其所需的表、字段和配置——做到真正的插件单独安装、按需启用。
第一章 快速入门
1.1 系统要求
| 组件 | 最低版本 | 推荐版本 | 说明 |
|---|---|---|---|
| PHP | 7.4 | 8.0+ | PDO、GD、cURL、mbstring、JSON、OpenSSL、Session |
| MySQL | 5.7 | 8.0 | utf8mb4 字符集 |
| Web 服务器 | Nginx / Apache | Nginx | 需支持伪静态 |
PHP 扩展要求:PDO_MYSQL(必需)、GD(必需)、cURL(必需)、mbstring(必需)、JSON(必需)、OpenSSL(必需)、Session(必需)、fileinfo(推荐)。
可通过 php -m 命令查看已安装的扩展列表。
1.2 安装部署
1.2.1 上传文件
- 将程序压缩包解压,上传到 Web 服务器根目录或子目录
- 确保以下目录可写:
data/logs/、data/cache/、templates/、plugins/ - 将
config.php设置为可写(安装程序会自动写入数据库配置)
1.2.2 运行安装向导
- 浏览器访问
http://你的域名/install/ - 按提示填写数据库主机、库名、用户名、密码、表前缀
- 设置管理员账号和密码
- 点击安装,系统自动创建核心表、写入默认配置、生成
config.php - 安装完成后删除
install/目录或重命名
初始安装只创建 9 张核心表和基础配置。所有插件默认关闭,需要到后台「插件管理」中按需启用。插件启用时会自动创建插件所需的表、字段和配置。
1.2.3 伪静态配置(推荐)
安装完成后,进入后台「基础设置 - 伪静态」,选择伪静态模式并复制自动生成的服务器规则到 .htaccess(Apache)或 Nginx 配置文件中。
| 模式 | 示例 URL | 说明 |
|---|---|---|
| dynamic | /index.php?route=category&slug=tech | 动态模式,无需服务器配置 |
| rewrite | /category/tech/ | 伪静态模式,需配置服务器规则 |
1.3 后台使用
安装完成后访问 http://你的域名/admin/ 登录后台。
| 菜单 | 功能说明 |
|---|---|
| 仪表盘 | 站点概况、最近操作、统计数据概览 |
| 站点管理 | 站点增删改查、审核、推荐设置、批量操作 |
| 分类管理 | 分类增删改、排序、SEO 字段设置 |
| 待审核 | 审核前台提交和自动收录的站点 |
| 推荐管理 | 设置全局推荐和分类推荐 |
| 基础设置 | 站点名称、SEO、主题、伪静态、日志开关、各插件配置 Tab |
| 插件管理 | 启用/停用/卸载插件,查看插件数据库信息 |
| 统计报表 | 站点浏览、点击、评分等数据统计 |
基础设置页面采用 Tab 面板设计。核心设置(站点信息、SEO、主题等)固定显示,插件注入的设置 Tab(如广告管理、伪静态格式等)仅在对应插件启用后出现。
1.4 插件管理
后台「插件管理」页面展示所有已扫描到的插件。每个插件有三种状态操作:
| 操作 | 效果 |
|---|---|
| 启用 | 自动执行 ensureSchema():创建插件声明的表、向已有表添加字段、写入默认配置。然后加载插件代码并注册钩子 |
| 停用 | 仅修改启用状态为关闭,保留所有数据库表和配置数据。再次启用时无需重新安装 |
| 卸载 | 停用 + 删除插件自建表 + 删除插件添加的字段 + 清除插件配置。共享表智能判断:仅当所有声明该表的插件都卸载时才删表 |
插件列表中还会显示每个插件的数据库信息,格式如 📋 articles · settings(2配置),表示该插件创建了 articles 表并写入了 2 条配置。
所有内置插件默认关闭。安装完成后,根据需要到插件管理页面逐个启用。插件之间的依赖关系极低,可以按任意顺序启用。
1.5 主题切换
- 进入后台「基础设置」
- 在「主题设置」区域选择已安装的主题
- 保存后前台立即生效
主题文件放在 templates/{主题名}/ 目录下。系统会自动扫描所有含 theme.json 的目录并显示在后台主题列表中。
第二章 系统概述
2.1 目录结构
index.php 前台统一入口
go.php 跳转中间页(记录点击统计后跳转)
config.php 数据库配置(安装时自动生成)
core/ 核心类库
bootstrap.php 应用引导(Session、自动加载、调试模式)
Database.php PDO 单例 + 预处理封装
Security.php 安全模块(XSS/CSRF/频率限制/HTML清洗)
Route.php 路由分发器
Rewrite.php 伪静态系统(URL 解析与生成)
Theme.php 主题系统(扫描/加载/渲染/资源引用)
Plugin.php 插件系统核心(扫描/启用/schema/钩子)
Logger.php 日志工具类
helpers.php 前台辅助函数库
SiteModel.php 站点模型
CategoryModel.php 分类模型
SettingsModel.php 设置模型
FeatureModel.php 推荐模型
WormholeModel.php 虫洞模型
templates/ 主题目录
default/ 默认主题
plugins/ 插件目录
ad/ 广告管理
article/ 文章发布
auto-link/ 友链自动收录
wormhole/ 虫洞联盟
rewrite/ 伪静态设置
notify/ 邮箱通知
... 其他插件
admin/ 后台管理
api/ API 接口入口
install/ 安装程序
data/ 数据目录(日志/缓存/文档)
2.2 核心类一览
| 类 | 文件 | 职责 |
|---|---|---|
Database | core/Database.php | PDO 单例,提供 query()、queryOne()、execute()、insert()、table()、事务 |
Security | core/Security.php | 输入过滤、输出转义、CSRF、频率限制、HTML 清洗 |
Route | core/Route.php | 路由分发:解析 URL 参数,分发到对应模板 |
Rewrite | core/Rewrite.php | 伪静态:URL 解析、生成、服务器规则自动生成 |
Theme | core/Theme.php | 主题扫描、加载、渲染、资源引用、布局片段 |
Plugin | core/Plugin.php | 插件扫描、启用/停用/卸载、schema 安装、钩子系统 |
Logger | core/Logger.php | 按日期分目录、按频道分文件的日志写入 |
SettingsModel | core/SettingsModel.php | 键值对配置读写(settings 表) |
2.3 数据库表概览
初始安装创建以下核心表(不含插件表):
| 表名 | 说明 |
|---|---|
sites | 站点主表:名称、URL、分类、权重、状态、提交者邮箱(submit_email)等 |
categories | 分类表:名称、slug、图标、排序、SEO 字段 |
settings | 配置表:键值对存储全站配置 |
site_features | 推荐关联表:分类推荐站点排序 |
admins | 管理员账号(bcrypt 密码哈希) |
site_ratings | 用户评分(IP 防刷) |
site_feedback | 站点反馈(URL变更/打不开/内容错误) |
deleted_ids | ID 回收队列(删除站点后复用 ID) |
site_daily_stats | 站点每日统计 |
notify_logs | 邮件通知发送记录(notify 插件) |
插件声明的表(如 articles、blacklist)和插件向已有表添加的字段(如 sites 表的 wormhole_status 等)不在初始安装时创建,而是在插件启用时由 Plugin::ensureSchema() 自动安装。卸载插件时会自动清理。
2.4 辅助函数速查(core/helpers.php)
| 函数名 | 参数 | 说明 |
|---|---|---|
setting() | $key, $default=null | 读取配置值(别名 getConfig()) |
redirect() | $url | HTTP 重定向 |
isInstalled() | 无 | 检查系统是否已安装 |
isDebug() | 无 | 判断调试模式 |
parseDomain() | $url | 从 URL 提取域名 |
normalizeSiteUrl() | $url | 补全 URL 协议头 |
formatNumber() | $num | 格式化数字(1k, 1M) |
formatDate() | $date, $format | 格式化日期 |
parseTags() | $json | 解析 JSON 标签为数组 |
renderPagination() | $current, $total, $urlTemplate | 生成分页 HTML |
getMaxBr() | $site | 获取站点最高权重 |
第三章 主题开发
3.1 主题目录结构
主题放在 templates/{主题名}/ 目录下:
theme.json 主题信息文件(必需)
index.php 首页模板(必需)
category.php 分类页模板
site.php 站点详情页模板
search.php 搜索页模板
submit.php 提交站点页模板
header.php 公共头部(可选,通过 partial 加载)
footer.php 公共底部(可选,通过 partial 加载)
404.php 404 错误页
css/ 样式文件
js/ 脚本文件
screenshot.png 主题截图(可选,后台展示用)
3.2 theme.json
每个主题必须包含 theme.json:
{
"name": "mytheme",
"title": "我的主题",
"version": "1.0",
"author": "你的名字",
"description": "主题简介",
"support": ["index", "category", "site", "search", "submit"]
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 目录名(必须与文件夹一致) |
title | 是 | 显示名称 |
version | 是 | 版本号 |
author | 否 | 作者 |
description | 否 | 简介 |
support | 否 | 支持的页面类型列表 |
3.3 模板文件与注入变量
Route::dispatch() 解析请求后,通过 Theme::render() 将变量注入模板作用域(extract()),模板中直接使用变量。
| 模板文件 | 路由 | 注入变量 |
|---|---|---|
index.php | / | $categories, $activeCats, $featuredSites, $currentSites, $settings, $seoTitle, $seoDesc, $ranking, $showWeight, $siteStats |
category.php | category/{slug}/ | $category, $sites, $slug, $sort, $total, $totalPages, $showWeight |
site.php | site/{id}/ | $site, $category, $related, $domain, $tags, $ratingStats |
search.php | search/ | $keyword, $results, $total, $totalPages, $perPage |
submit.php | submit/ | $enable, $needReview, $categories |
3.4 Theme 类方法
| 方法 | 参数 | 说明 |
|---|---|---|
Theme::current() | 无 | 获取当前主题名 |
Theme::set() | $name | 切换主题 |
Theme::scan() | 无 | 扫描所有可用主题 |
Theme::render() | $template, $vars=[] | 渲染模板(注入变量 + require) |
Theme::partial() | $name, $vars=[] | 加载布局片段(如 header/footer),自动回退 default |
Theme::e() | $value | HTML 实体转义(等价 Security::e()) |
Theme::eAttr() | $value | 属性值转义 |
Theme::url() | $type, $params=[] | 生成 URL(自动适配伪静态模式) |
Theme::asset() | $file | 获取主题资源路径(如 css/style.css) |
3.5 钩子列表
主题模板中通过 Plugin::hook('钩子名') 输出插件注入的内容。以下是系统内置的所有前台钩子:
| 钩子名 | 位置 | 参数 | 用途 |
|---|---|---|---|
before_header | header.php 顶部 | 无 | 在 HTML head 之前输出内容 |
after_header | header.php 底部 | 无 | 在 body 开头输出内容 |
search_bar_after | 首页搜索栏之后 | 无 | 搜索栏下方注入内容 |
sidebar_top | 侧边栏顶部 | 无 | 侧边栏上方广告/内容 |
sidebar_bottom | 侧边栏底部 | 无 | 侧边栏下方内容 |
site_list_before | 站点列表前 | 无 | 列表上方广告/内容 |
site_list_after | 站点列表后 | 无 | 列表下方广告/内容 |
before_content | 站点详情内容前 | [$site] | 详情内容上方 |
after_content | 站点详情内容后 | [$site] | 详情内容下方 |
before_footer | footer.php 顶部 | 无 | 页脚之前 |
after_footer | footer.php 底部 | 无 | 页脚之后(常用于 JS 注入) |
后台钩子:
| 钩子名 | 位置 | 参数 | 用途 |
|---|---|---|---|
admin_sidebar | 后台侧边栏 | 无 | 注入后台菜单项 |
admin_settings_nav | 基础设置页 Tab 导航 | [$activeTab] | 注入设置 Tab 标签 |
admin_settings_tabs | 基础设置页 Tab 面板 | [$activeTab] | 注入设置 Tab 内容 |
在模板中调用 <?php Plugin::hook('sidebar_top'); ?> 即可。钩子名是约定好的,主题开发者只需在对应位置放置钩子调用,插件会自动注入内容。
3.6 URL 生成与资源引用
// URL 生成(自动适配伪静态)
<?= Theme::url('home') ?> // 首页
<?= Theme::url('category', ['slug' => $cat['slug']]) ?> // 分类页
<?= Theme::url('site', ['id' => $site['id']]) ?> // 站点详情
<?= Theme::url('search') ?> // 搜索页
<?= Theme::url('submit') ?> // 提交页
// 静态资源引用
<link rel="stylesheet" href="<?= Theme::asset('css/style.css') ?>">
<script src="<?= Theme::asset('js/script.js') ?>"></script>
<img src="<?= Theme::asset('images/logo.png') ?>">
Theme::asset() 返回的路径基于当前主题目录,例如 /templates/mytheme/css/style.css。
3.7 实战案例:创建一个主题
下面以默认主题 default 的实际代码为例,展示从零创建一个主题的完整流程。
步骤 1:创建目录和 theme.json
templates/mytheme/theme.json:
{
"name": "mytheme",
"title": "极简主题",
"version": "1.0",
"author": "懒人导航",
"description": "极简风格,专注内容",
"support": ["index", "category", "site", "search", "submit"]
}
步骤 2:编写 header.php(公共头部)
头部负责 DOCTYPE、meta 标签、CSS 引用和插件钩子。以下是默认主题的实际写法:
<?php
// 确保 SEO 变量有默认值
$fallbackSiteName = $settings['site_name'] ?? '懒人导航';
if (!isset($seoTitle)) $seoTitle = $fallbackSiteName;
if (!isset($seoDesc)) $seoDesc = '';
if (!isset($seoKeywords)) $seoKeywords = '';
?>
<?php Plugin::hook('before_header'); ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= Theme::e($seoTitle) ?></title>
<?php if (!empty($seoDesc)): ?>
<meta name="description" content="<?= Theme::eAttr($seoDesc) ?>">
<?php endif; ?>
<link rel="stylesheet" href="<?= Theme::asset('css/common.css') ?>">
</head>
<body>
<?php Plugin::hook('after_header'); ?>
Plugin::hook('before_header')和Plugin::hook('after_header')是必须的,插件依赖这些钩子注入内容- 所有输出使用
Theme::e()或Theme::eAttr()转义,防止 XSS - CSS 通过
Theme::asset()引用,路径自动适配当前主题
步骤 3:编写 index.php(首页)
<?php
Theme::partial('header');
?>
<div class="container">
<!-- 搜索栏 -->
<div class="search-bar">
<input type="search" id="searchInput" placeholder="搜索站点...">
</div>
<?php Plugin::hook('search_bar_after'); ?>
<!-- 侧边栏 -->
<aside class="sidebar">
<?php Plugin::hook('sidebar_top'); ?>
<?php foreach ($categories as $cat): ?>
<a href="<?= Theme::url('category', ['slug' => $cat['slug']]) ?>">
<?= Theme::e($cat['name']) ?>
</a>
<?php endforeach; ?>
<?php Plugin::hook('sidebar_bottom'); ?>
</aside>
<!-- 站点列表 -->
<main class="site-list">
<?php Plugin::hook('site_list_before'); ?>
<?php foreach ($currentSites as $site): ?>
<a href="<?= Theme::url('site', ['id' => $site['id']]) ?>" class="card">
<span class="card-title"><?= Theme::e($site['name']) ?></span>
<span class="card-desc"><?= Theme::e($site['description']) ?></span>
</a>
<?php endforeach; ?>
<?php Plugin::hook('site_list_after'); ?>
</main>
</div>
<?php Theme::partial('footer'); ?>
注意模板中放置了 5 个钩子调用:search_bar_after、sidebar_top、sidebar_bottom、site_list_before、site_list_after。这些钩子让广告插件、文章插件等能在对应位置注入内容。
步骤 4:编写 footer.php(公共底部)
<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">
<nav class="footer-nav">
<a href="<?= Theme::url('home') ?>">首页</a>
<a href="<?= Theme::url('submit') ?>">提交站点</a>
</nav>
<p><?= Theme::e($settings['site_name'] ?? '') ?></p>
</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
步骤 5:编写其他页面
按同样模式编写 category.php、site.php、search.php、submit.php。每个页面通过 Theme::partial('header') 和 Theme::partial('footer') 引入公共布局,中间放置页面内容。变量由 Route::dispatch() 自动注入(见 3.3 节)。
步骤 6:添加 CSS
在 templates/mytheme/css/common.css 中编写样式。可以通过 Theme::asset('css/common.css') 引用。
步骤 7:后台切换
进入后台「基础设置 - 主题设置」,选择 极简主题 并保存,前台立即生效。
第四章 插件开发
4.1 插件目录结构
插件放在 plugins/{插件名}/ 目录下。一个完整的插件包含以下文件:
plugin.json 元数据声明(必需)
include.php 主文件:函数定义 + 前台钩子注册(必需)
main.php 后台设置面板:注册设置 Tab 钩子(可选)
schema.php 数据库声明:表、字段、配置(可选)
admin.php 独立后台管理页面(可选,通过 /admin/plugin.php?p=myplugin 访问)
css/ 插件样式(可选)
js/ 插件脚本(可选)
- include.php:仅在插件启用时由
Plugin::init()加载,定义类/函数并注册前台钩子 - main.php:仅在插件启用时加载,注册后台设置 Tab 钩子(
admin_settings_nav+admin_settings_tabs) - schema.php:在插件启用和卸载时由
Plugin::loadSchema()加载,返回数组声明 - admin.php:通过
/admin/plugin.php?p=插件名访问时,由分发器独立加载。注意:admin.php 不会自动加载 include.php,如果需要用 include.php 中的函数/类,必须在 admin.php 顶部手动require_once __DIR__ . '/include.php' - 未启用的插件完全不加载,不注册任何钩子,不执行任何代码
4.2 plugin.json
{
"name": "myplugin",
"title": "我的插件",
"version": "1.0",
"author": "你的名字",
"description": "插件功能描述",
"main_file": "include.php",
"config_file": "main.php",
"schema_file": "schema.php",
"hooks": ["sidebar_top", "after_footer"],
"tables": ["mytable"],
"builtin": false
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 插件目录名(必须与文件夹一致) |
title | 是 | 显示名称 |
version | 是 | 版本号 |
author | 否 | 作者 |
description | 否 | 功能描述 |
main_file | 否 | 主文件名(默认 {name}.php,通常设为 include.php) |
config_file | 否 | 设置面板文件名(如 main.php) |
schema_file | 否 | 数据库声明文件名(固定 schema.php) |
hooks | 否 | 声明使用的钩子列表(用于文档展示,不影响实际注册) |
tables | 否 | 声明创建的表名列表(用于卸载时的共享表判断) |
builtin | 否 | 是否为内置插件(默认 true) |
4.3 schema.php 机制
schema.php 是插件数据库声明文件,返回一个包含三个部分的数组:
<?php
return [
// 1. 独立表:插件启用时自动 CREATE TABLE IF NOT EXISTS
'tables' => [
'mytable' => "CREATE TABLE IF NOT EXISTS `{prefix}mytable` (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_title (title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
],
// 2. 向已有表添加字段:插件启用时自动 ALTER TABLE ADD COLUMN(跳过已存在)
'columns' => [
'sites' => [
'my_field' => "VARCHAR(100) DEFAULT '' COMMENT '自定义字段'",
],
],
// 3. 默认配置项:插件启用时写入 settings 表(仅当配置项不存在时写入)
'config' => [
'plugin_myplugin_count' => '5',
'plugin_myplugin_enable' => '1',
],
];
ensureSchema() 执行流程
当插件被启用时,Plugin::setEnabled($name, true) 会自动调用 Plugin::ensureSchema($name):
- 创建独立表:遍历
tables,通过information_schema检查表是否存在,不存在则执行 SQL({prefix}占位符替换为实际表前缀) - 添加字段:遍历
columns,通过information_schema.columns检查字段是否存在,不存在则ALTER TABLE ADD COLUMN - 写入配置:遍历
config,检查 settings 表中是否已存在该 key,不存在则写入默认值
所有操作都是幂等的:表已存在则跳过,字段已存在则跳过,配置已存在则跳过。因此重复启用插件不会出错,也不会覆盖用户已修改的配置。
配置项命名规范
插件配置项使用 plugin_{插件名}_{配置键} 的命名格式存储在 settings 表中。例如插件 myplugin 的 count 配置存储为 plugin_myplugin_count。
但有些历史插件(如 wormhole、auto-link)使用不带 plugin_ 前缀的简短键名(如 wormhole_enable)。这两种方式都能正常工作,新插件建议使用 plugin_ 前缀格式。
4.4 include.php 与钩子注册
include.php 是插件主文件,负责定义函数/类和注册钩子:
<?php
// 安全检查:阻止直接访问
if (!defined('APP_VERSION') && !class_exists('Database')) {
die('Forbidden');
}
/**
* 输出插件内容
*/
function myplugin_render(): void
{
$count = (int)Plugin::config('myplugin', 'count', '5');
// ... 业务逻辑
echo '<div class="myplugin">' . $content . '</div>';
}
// 注册前台钩子
Plugin::registerHook('sidebar_top', function () {
myplugin_render();
});
// 注册后台侧边栏菜单(如有 admin.php)
Plugin::registerHook('admin_sidebar', function () {
$cls = ($GLOBALS['currentPage'] ?? '') === 'myplugin' ? 'active' : '';
echo '<a href="/admin/plugin.php?p=myplugin" class="nav-item ' . $cls . '">'
. '<i class="ti ti-star"></i><span>我的插件</span></a>';
});
- 文件开头必须有安全检查,阻止直接访问
- 通过
Plugin::registerHook(钩子名, 回调函数)注册钩子 - 通过
Plugin::config('插件名', '配置键', 默认值)读取插件配置 - 钩子回调函数中的
echo内容会直接输出到页面 - 可注册多个钩子,同一钩子可被多个插件注册
4.5 main.php 设置面板
main.php 通过注册两个后台钩子,在基础设置页面注入自定义 Tab:
<?php
if (!defined('APP_VERSION') && !class_exists('Database')) {
die('Forbidden');
}
// 钩子1:注入 Tab 导航标签
Plugin::registerHook('admin_settings_nav', function ($activeTab) {
$cls = $activeTab === 'myplugin' ? 'active' : '';
echo '<a href="#tab-myplugin" class="settings-tab ' . $cls . '"'
. ' onclick="switchTab(\'myplugin\', this)">我的插件</a>';
});
// 钩子2:注入 Tab 内容面板
Plugin::registerHook('admin_settings_tabs', function ($activeTab) {
$cls = $activeTab === 'myplugin' ? 'active' : '';
?>
<div id="tab-myplugin" class="tab-panel <?= $cls ?>">
<div class="card">
<div class="card-header"><span class="card-title">我的插件设置</span></div>
<form method="POST" action="/admin/settings.php">
<input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
<input type="hidden" name="section" value="myplugin">
<input type="hidden" name="tab" value="myplugin">
<div class="form-group">
<label>显示数量</label>
<input type="number" name="plugin_myplugin_count"
value="<?= Security::e(Plugin::config('myplugin', 'count', '5')) ?>">
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<?php
});
- 表单 POST 到
/admin/settings.php,需要带csrf_token和section字段 - 配置项的
name属性必须与 schema.php 中声明的配置键一致 - Tab 的
id必须为tab-{插件名},与导航标签的href对应 switchTab()是后台已有的 JS 函数,用于 Tab 切换
4.6 Plugin 类 API
| 方法 | 参数 | 说明 |
|---|---|---|
Plugin::isEnabled() | $name | 检查插件是否已启用 |
Plugin::setEnabled() | $name, $enabled | 启用/停用插件(启用时自动 ensureSchema) |
Plugin::ensureSchema() | $name | 执行建表、加字段、写配置 |
Plugin::loadSchema() | $name | 加载 schema.php 返回数组 |
Plugin::uninstall() | $name | 卸载:停用 + 删表 + 删字段 + 清配置 |
Plugin::registerHook() | $hook, $callback, $priority=10 | 注册钩子回调 |
Plugin::hook() | $hook, $args=[] | 执行动作钩子(输出模式) |
Plugin::filter() | $hook, $value, $args=[] | 执行过滤钩子(返回模式) |
Plugin::config() | $plugin, $key, $default=null | 读取插件配置 |
Plugin::setConfig() | $plugin, $key, $value | 写入插件配置 |
Plugin::asset() | $plugin, $file | 获取插件资源 URL |
Plugin::getDir() | $name | 获取插件目录路径 |
Plugin::scan() | 无 | 扫描所有可用插件 |
Plugin::getInfo() | $name | 获取插件元数据 |
4.7 共享表与卸载
当多个插件声明同一张表时(如 blacklist 表被 wormhole 和 auto-link 共享),系统会智能处理:
- 启用时:
ensureSchema()使用CREATE TABLE IF NOT EXISTS,重复执行安全 - 卸载时:
Plugin::uninstall()检查是否还有其他插件声明该表。如果有,则跳过删表(仅清除当前插件的配置和字段) - 全部卸载时:当最后一个声明该表的插件被卸载时,才真正执行
DROP TABLE
卸载操作还会:
- 删除插件向已有表添加的字段(
ALTER TABLE DROP COLUMN) - 清除插件的所有配置项(通过
LIKE 'plugin_{name}_%'通配匹配 + schema.php 声明的 key) - 记录卸载日志(删了哪些表、哪些字段、清了多少条配置)
4.8 实战案例:每日一言插件
下面创建一个完整的「每日一言」插件,展示从 plugin.json 到 schema.php、include.php、main.php 的完整开发流程。
功能:前台侧边栏显示每日一条名言,后台可管理名言列表和显示设置。
4.8.1 创建目录
plugins/daily-quote/
plugin.json
schema.php
include.php
main.php
admin.php
4.8.2 plugin.json
{
"name": "daily-quote",
"title": "每日一言",
"version": "1.0",
"author": "懒人导航",
"description": "前台侧边栏显示每日名言,后台可管理名言列表。",
"main_file": "include.php",
"config_file": "main.php",
"schema_file": "schema.php",
"hooks": ["sidebar_bottom", "admin_sidebar"],
"tables": ["quotes"],
"builtin": false
}
4.8.3 schema.php
声明一张独立的 quotes 表和 2 个默认配置项:
<?php
/**
* 每日一言插件 - 数据库声明
* 启用插件时自动创建 quotes 表并写入默认配置
*/
return [
// 独立表
'tables' => [
'quotes' => "CREATE TABLE IF NOT EXISTS `{prefix}quotes` (
id INT PRIMARY KEY AUTO_INCREMENT,
content VARCHAR(500) NOT NULL COMMENT '名言内容',
author VARCHAR(100) DEFAULT '' COMMENT '作者',
status TINYINT DEFAULT 1 COMMENT '状态(1=启用,0=禁用)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日言表';",
],
// 向已有表添加字段(无)
'columns' => [],
// 默认配置项
'config' => [
'plugin_daily-quote_count' => '1',
'plugin_daily-quote_template' => 'card',
],
];
{prefix}占位符在执行时被替换为实际表前缀(如nav_)- 配置项 key 使用
plugin_{插件名}_{配置键}格式 - 表和字段声明中包含
COMMENT方便数据库管理
4.8.4 include.php
定义 Model 类、注册前台钩子和后台侧边栏入口:
<?php
/**
* 每日一言插件 - 主文件
*/
if (!defined('APP_VERSION') && !class_exists('Database')) {
die('Forbidden');
}
/**
* 每日一言模型
*/
class DailyQuoteModel
{
/**
* 获取随机名言
*/
public function getRandom(int $limit = 1): array
{
$tbl = Database::table('quotes');
return Database::query(
"SELECT * FROM {$tbl} WHERE status = 1 ORDER BY RAND() LIMIT ?",
[$limit]
);
}
/**
* 获取全部名言
*/
public function getAll(): array
{
$tbl = Database::table('quotes');
return Database::query("SELECT * FROM {$tbl} ORDER BY id DESC");
}
/**
* 创建名言
*/
public function create(string $content, string $author = ''): int
{
$tbl = Database::table('quotes');
return Database::insert(
"INSERT INTO {$tbl} (content, author) VALUES (?, ?)",
[Security::cleanString($content, 500), Security::cleanString($author, 100)]
);
}
/**
* 删除名言
*/
public function delete(int $id): bool
{
$tbl = Database::table('quotes');
return Database::execute("DELETE FROM {$tbl} WHERE id = ?", [$id]) > 0;
}
/**
* 切换状态
*/
public function toggleStatus(int $id): bool
{
$tbl = Database::table('quotes');
return Database::execute(
"UPDATE {$tbl} SET status = 1 - status WHERE id = ?",
[$id]
) > 0;
}
}
// ========== 钩子注册 ==========
// 前台侧边栏:显示每日一言
Plugin::registerHook('sidebar_bottom', function () {
$count = (int)Plugin::config('daily-quote', 'count', '1');
$model = new DailyQuoteModel();
$quotes = $model->getRandom($count);
if (empty($quotes)) {
return;
}
?>
<div class="daily-quote-widget" style="margin-top:16px;padding:12px;border-radius:8px;background:#f0f4ff;">
<div style="font-size:13px;color:#999;margin-bottom:8px;">
<i class="ti ti-quote"></i> 每日一言
</div>
<?php foreach ($quotes as $q): ?>
<div class="quote-item" style="margin-bottom:8px;">
<p style="font-size:14px;color:#333;line-height:1.6;">
<?= Theme::e($q['content']) ?>
</p>
<?php if (!empty($q['author'])): ?>
<p style="font-size:12px;color:#999;text-align:right;">
—— <?= Theme::e($q['author']) ?>
</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
});
// 后台侧边栏:注入管理入口
Plugin::registerHook('admin_sidebar', function () {
$cls = ($GLOBALS['currentPage'] ?? '') === 'daily-quote' ? 'active' : '';
echo '<a href="/admin/plugin.php?p=daily-quote" class="nav-item ' . $cls . '">'
. '<i class="ti ti-quote"></i><span>每日一言</span></a>';
});
4.8.5 main.php(后台设置 Tab)
<?php
/**
* 每日一言插件 - 设置面板
*/
if (!defined('APP_VERSION') && !class_exists('Database')) {
die('Forbidden');
}
// 注入 Tab 导航
Plugin::registerHook('admin_settings_nav', function ($activeTab) {
$cls = $activeTab === 'daily-quote' ? 'active' : '';
echo '<a href="#tab-daily-quote" class="settings-tab ' . $cls . '"'
. ' onclick="switchTab(\'daily-quote\', this)">每日一言</a>';
});
// 注入 Tab 内容
Plugin::registerHook('admin_settings_tabs', function ($activeTab) {
$cls = $activeTab === 'daily-quote' ? 'active' : '';
?>
<div id="tab-daily-quote" class="tab-panel <?= $cls ?>">
<div class="card">
<div class="card-header"><span class="card-title">每日一言设置</span></div>
<form method="POST" action="/admin/settings.php">
<input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
<input type="hidden" name="section" value="daily-quote">
<input type="hidden" name="tab" value="daily-quote">
<div class="form-group">
<label>显示数量</label>
<input type="number" name="plugin_daily-quote_count" min="1" max="10"
value="<?= Security::e(Plugin::config('daily-quote', 'count', '1')) ?>">
<p class="form-help">侧边栏每次显示几条名言</p>
</div>
<div class="text-right">
<button type="submit" class="btn btn-primary">保存设置</button>
</div>
</form>
</div>
</div>
<?php
});
4.8.6 admin.php(名言管理页面)
<?php
/**
* 每日一言插件 - 后台管理页面
* 通过 /admin/plugin.php?p=daily-quote 访问
*/
$model = new DailyQuoteModel();
// 处理 POST 操作
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Security::verifyCSRFToken($_POST['csrf_token'] ?? '')) {
die('CSRF 校验失败');
}
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$model->create($_POST['content'] ?? '', $_POST['author'] ?? '');
echo '<div class="alert alert-success">添加成功</div>';
} elseif ($action === 'delete') {
$model->delete((int)($_POST['id'] ?? 0));
echo '<div class="alert alert-success">删除成功</div>';
} elseif ($action === 'toggle') {
$model->toggleStatus((int)($_POST['id'] ?? 0));
}
}
$quotes = $model->getAll();
?>
<div class="card">
<div class="card-header"><span class="card-title">添加名言</span></div>
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
<input type="hidden" name="action" value="create">
<div class="form-group">
<label>内容</label>
<textarea name="content" rows="3" required></textarea>
</div>
<div class="form-group">
<label>作者(可选)</label>
<input type="text" name="author">
</div>
<button type="submit" class="btn btn-primary">添加</button>
</form>
</div>
<div class="card" style="margin-top:16px;">
<div class="card-header"><span class="card-title">名言列表</span></div>
<table>
<thead><tr><th>ID</th><th>内容</th><th>作者</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($quotes as $q): ?>
<tr>
<td><?= (int)$q['id'] ?></td>
<td><?= Security::e($q['content']) ?></td>
<td><?= Security::e($q['author']) ?></td>
<td><?= $q['status'] ? '启用' : '禁用' ?></td>
<td>
<form method="POST" style="display:inline">
<input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int)$q['id'] ?>">
<button type="submit" onclick="return confirm('确认删除?')">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
4.8.7 启用插件
- 将
daily-quote目录放入plugins/ - 进入后台「插件管理」,找到「每日一言」
- 点击「启用」——系统自动创建
quotes表、写入 2 条默认配置、加载插件代码 - 前台首页侧边栏底部出现每日一言
- 后台侧边栏出现「每日一言」管理入口,可添加/删除名言
- 后台「基础设置」出现「每日一言」Tab,可修改显示数量
这个案例展示了插件开发的完整流程:plugin.json 声明元数据 → schema.php 声明数据库 → include.php 定义业务逻辑和钩子 → main.php 后台设置面板 → admin.php 后台管理页面。启用后自动安装,卸载后自动清理。
第五章 内置插件
5.1 插件一览
系统内置 10 个插件,全部默认关闭。安装后根据需要在后台「插件管理」中启用。
| 插件 | 功能 | 数据库影响 |
|---|---|---|
| 广告管理 (ad) | 后台配置广告位 HTML,前台多个位置展示 | 6 条配置,无独立表 |
| 文章发布 (article) | 后台文章管理,前台文章列表和详情 | articles 表 + 2 条配置 |
| 虫洞联盟 (wormhole) | 站点互推、随机传送、定时检测 | blacklist 表(共享)+ sites 表 5 字段 + 5 条配置 |
| 友链自动收录 (auto-link) | 检测来路、验证回链、自动收录 | blacklist 表(共享)+ 4 条配置 |
| 伪静态设置 (rewrite) | URL 格式配置,自动生成服务器规则 | 10 条配置 |
| 提交网站收录 (submit) | 前台提交入口、表单、审核流程 | 6 条配置 |
| 站点地图 (sitemap) | 自动生成 sitemap.xml | 1 条配置 |
| 图片灯箱 (lightbox) | 详情页图片点击放大 | 无数据库影响 |
| 图片ALT (auto-alt) | 自动给图片添加 alt 属性 | 无数据库影响 |
| 邮箱通知 (notify) | SMTP 发送站点提交/审核/反馈邮件通知 | notify_logs 表 + sites.submit_email 字段 + 11 条配置 |
5.2 广告管理 (ad)
后台可配置 6 个广告位的 HTML 代码,前台在对应位置展示。
| 广告位 | 钩子位置 |
|---|---|
| 首页列表前 | site_list_before |
| 首页列表后 | site_list_after |
| 侧边栏顶部 | sidebar_top |
| 侧边栏底部 | sidebar_bottom |
| 详情内容前 | before_content |
| 详情内容后 | after_content |
启用后在后台「基础设置 - 广告管理」Tab 填写 HTML 代码。广告 HTML 经过 Security::cleanHtml() 过滤后输出。
主题模板中的挂接代码
广告插件需要主题在对应位置放置钩子调用。以下是默认主题 templates/default/ 中的实际代码示例:
1. before_header / after_header(公共头部)
文件:templates/default/header.php
<?php Plugin::hook('before_header'); ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
...
</head>
<body>
<?php Plugin::hook('after_header'); ?>
2. search_bar_after(搜索栏后)
文件:templates/default/index.php
<div class="search-bar">
<div class="search-wrap">
<i class="ti ti-search"></i>
<input type="search" placeholder="搜索..." id="searchInput">
</div>
<div class="hot-tags">
...
<?php Plugin::hook('search_bar_after'); ?>
</div>
</div>
3. sidebar_top / sidebar_bottom(侧边栏)
文件:templates/default/index.php
<aside class="sidebar">
<div class="sidebar-header">全部分类</div>
<?php Plugin::hook('sidebar_top'); ?>
<?php foreach ($categories as $cat): ?>
<a href="...">...</a>
<?php endforeach; ?>
<?php Plugin::hook('sidebar_bottom'); ?>
</aside>
4. site_list_before / site_list_after(站点列表)
文件:templates/default/index.php
<div class="card-grid" id="site-grid">
<?php Plugin::hook('site_list_before'); ?>
<?= renderSiteCards($currentSites ?? [], $showWeight) ?>
<?php Plugin::hook('site_list_after'); ?>
</div>
5. before_content / after_content(详情内容)
文件:templates/default/site.php
<div class="site-details">
<?php Plugin::hook('before_content', [$site ?? []]); ?>
<div class="detail-row">
<div class="detail-label">描述:</div>
<div class="detail-value">...</div>
</div>
...
</div>
<?php Plugin::hook('after_content', [$site ?? []]); ?>
6. before_footer / after_footer(页脚)
文件:templates/default/footer.php
<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">
...
</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
开发自定义主题时,需要在上述 8 个位置放置对应的 Plugin::hook() 调用,否则广告插件启用后也不会显示广告。钩子参数说明:
before_content和after_content需要传入站点数组[$site ?? []],方便插件访问当前站点数据- 其他钩子不需要参数,直接调用
Plugin::hook('钩子名')即可
5.3 文章发布 (article)
启用后前台侧边栏出现「文章专栏」入口,后台侧边栏出现「文章管理」菜单。支持 Markdown/HTML 内容、分类、标签、草稿/发布状态。
数据库:创建 articles 表(id、title、slug、content、excerpt、author、category、tags、status、views、created_at、updated_at)+ 2 条配置(每页显示数、是否允许投稿)。
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
sidebar_bottom | 前台 | 在侧边栏底部注入「文章专栏」入口链接 |
admin_sidebar | 后台 | 在后台侧边栏注入「文章管理」导航链接 |
sidebar_bottom 挂接代码
文件:templates/default/index.php
<aside class="sidebar">
<div class="sidebar-header">全部分类</div>
<?php Plugin::hook('sidebar_top'); ?>
...
<?php Plugin::hook('sidebar_bottom'); ?>
</aside>
sidebar_bottom 被多个插件共享使用(广告、虫洞联盟、每日一言等都在此处注册)。主题只需调用一次 Plugin::hook('sidebar_bottom'),所有注册的插件内容都会按优先级顺序输出。
5.4 虫洞联盟 (wormhole)
站点互推机制:联盟成员在页面嵌入 JS,互相展示链接实现流量互传。
| 状态 | 说明 |
|---|---|
none | 未加入联盟 |
manual | 后台手动加入(不检测) |
auto | JS 上报自动加入(每日检测) |
pending | 待审核 |
broken | 连续检测失败 3 次,已移出 |
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
sidebar_bottom | 前台 | 在侧边栏底部注入虫洞联盟入口链接("🌀 虫洞联盟") |
admin_sidebar | 后台 | 在后台侧边栏注入「虫洞联盟」管理入口 |
sidebar_bottom 挂接代码
文件:templates/default/index.php
<aside class="sidebar">
...
<?php Plugin::hook('sidebar_bottom'); ?>
</aside>
虫洞联盟入口(主题中硬编码链接)
默认主题在 footer.php 中还硬编码了一个虫洞联盟入口链接:
<!-- templates/default/footer.php -->
<a href="<?= Theme::eAttr(Rewrite::url('wormhole')) ?>" target="_blank">🌀 虫洞联盟</a>
外站嵌入代码
联盟成员需要在页面中嵌入以下 JS:
<script>
(function(){
var d=document,s=d.createElement('script');
s.src='https://你的主站/api/?endpoint=wormhole.js';
s.async=1;
d.body.appendChild(s);
})();
</script>
定时检测:每天凌晨 3 点执行 core/cron_wormhole_check.php,抓取 auto 成员检查是否包含联盟代码。
0 3 * * * php /path/to/core/cron_wormhole_check.php
5.5 友链自动收录 (auto-link)
当用户从挂了导航站友链的外站点击进入时,系统自动检测来路、验证回链、抓取 TDK、检查违禁词,通过后自动收录。
工作流程:PHP 渲染首页时捕获 Referer → 过滤本站和搜索引擎 → JS 延迟 2 秒发送 /api/?endpoint=auto-link&ref=xxx → 后端抓取对方首页验证回链 → 抓取 TDK → 检查违禁词 → 插入数据库。
配置项:autolink_enable(开关)、autolink_review(是否需要审核)、autolink_cat_id(默认分类)、autolink_ban_words(违禁词)、block_all_ip(全局IP屏蔽)。
安全机制:前端 Referer 预过滤、搜索引擎排除、内网地址防护、黑名单检查、频率限制(6小时3次)、回链验证、违禁词检查、重复域名检查。
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
after_footer | 前台 | 在页面底部注入自动收录检测 JS(延迟2秒异步执行) |
admin_settings_nav | 后台 | 在基础设置页注入「友链收录」Tab 导航 |
admin_settings_tabs | 后台 | 在基础设置页注入「友链收录」Tab 内容面板 |
after_footer 挂接代码
文件:templates/default/footer.php
<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">...</footer>
<?php echo $chartJs; ?>
<?php echo $siteJs; ?>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
自动收录 JS 注入效果
插件通过 after_footer 钩子注入一段延迟执行的 JS:
<script>
(function(){
var referer = '捕获的HTTP_REFERER值';
if (!referer) return;
setTimeout(function(){
var img = new Image();
img.src = '/api/?endpoint=auto-link&ref=' + referer + '&_t=' + Date.now();
}, 2000);
})();
</script>
主题 footer.php 中必须调用 Plugin::hook('after_footer'),否则自动收录插件启用后不会注入检测 JS,导致功能无法工作。钩子需要放在 </body> 标签之前。
5.6 伪静态设置 (rewrite)
URL 伪静态模式配置,支持动态和伪静态两种模式。可自定义各页面 URL 格式,自动生成 Apache .htaccess 和 Nginx 配置规则。
启用后在后台「基础设置 - 伪静态」Tab 配置。支持子目录部署和自定义 URL 格式。
Rewrite.php 内置 URL 格式默认值($defaults 数组),不依赖 settings 表,插件启用时才将配置写入数据库。
5.7 提交网站收录 (submit)
前台提交网站入口,支持表单提交、验证码、TDK 自动获取、审核流程控制。
配置项:提交开关、是否需要审核、默认分类、违禁词、频率限制、提交说明文本。
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
search_bar_after | 前台 | 在搜索栏右侧注入「提交站点」按钮 |
search_bar_after 挂接代码
文件:templates/default/index.php
<div class="hot-tags">
<span class="hot-tag" onclick="searchTag('AI')">AI</span>
...
<?php Plugin::hook('search_bar_after'); ?>
</div>
插件注入效果
插件注册 search_bar_after 钩子,输出「提交站点」按钮 HTML:
<a href="/submit/" class="submit-btn">
<i class="ti ti-plus"></i> 提交站点
</a>
5.8 站点地图 (sitemap)
自动生成 sitemap.xml,帮助搜索引擎收录站点。配置项:是否包含已下线站点。
此插件无前台钩子,在后台「基础设置」中通过 admin_settings_nav 和 admin_settings_tabs 注入设置 Tab。访问 /sitemap.xml 即可获取动态生成的站点地图。
5.9 图片灯箱 (lightbox)
纯前端钩子插件,无数据库影响。详情页图片点击放大,自动给图片加 data-lightbox 属性。内置轻量灯箱实现,无需外部依赖。
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
after_footer | 前台 | 在页面底部注入灯箱 CSS + JS |
after_footer 挂接代码
文件:templates/default/footer.php
<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">...</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
插件注入效果
灯箱插件通过 after_footer 钩子注入一段 CSS + JS,自动将 .site-details img 和 .article-content img 的点击事件绑定为灯箱展开:
<style>
.lightbox-overlay { display:none; position:fixed; ... }
.lightbox-overlay.active { display:flex; }
</style>
<div class="lightbox-overlay" id="lightboxOverlay">...</div>
<script>
(function(){
var selector = '.site-details img, .article-content img';
var imgs = document.querySelectorAll(selector);
imgs.forEach(function(img){
img.setAttribute('data-lightbox', 'plugin');
img.style.cursor = 'zoom-in';
img.addEventListener('click', function(e){
openLightbox(this.src);
});
});
})();
</script>
支持键盘 ESC 键关闭灯箱,无需额外配置。
5.11 邮箱通知 (notify)
通过原生 PHP socket 实现 SMTP 邮件发送,在站点提交、审核通过/拒绝、用户反馈时自动通知管理员和提交者。
触发场景
| 场景 | 触发钩子 | 通知对象 | 说明 |
|---|---|---|---|
| 前台提交站点 | site_submitted | 管理员(always) | 始终通知站长,不受邮箱配置影响 |
| 审核通过 | site_approved | 管理员 + 提交者(如有邮箱) | "通过"按钮或"编辑并发布"都会触发 |
| 审核拒绝 | site_rejected | 管理员 + 提交者(如有邮箱) | 拒绝操作触发,提交者无邮箱则跳过 |
| 用户反馈 | feedback_submitted | 管理员(always) | 收到反馈后通知站长 |
数据库影响
| 影响 | 说明 |
|---|---|
notify_logs 表 | 记录每次邮件发送的状态、收件人、主题和失败原因 |
sites.submit_email | 向 sites 表添加字段,存储前台提交者填写的联系邮箱 |
| 11 条配置项 | SMTP 服务器、端口、用户名、密码、发件人、收件人、4个通知开关 |
SMTP 配置
启用后进入后台「基础设置 - 邮箱通知」Tab 配置:
| 配置项 | 说明 |
|---|---|
| SMTP 服务器 | 如 smtp.qq.com、smtp.163.com |
| 端口 | 常用 465(SSL)或 587(TLS) |
| 用户名/密码 | 邮箱账号和授权码(非登录密码) |
| 加密方式 | ssl / tls / none |
| 发件人邮箱/名称 | 邮件中显示的发件人 |
| 收件人邮箱 | 管理员通知邮箱,多个用英文逗号分隔 |
| 通知开关 | 分别控制提交/反馈/通过/拒绝四种通知 |
测试发送
插件管理页面(/admin/plugin.php?p=notify)提供:
- SMTP 测试发送:输入任意邮箱地址,立即发送测试邮件验证配置是否正确
- 发送日志:分页查看所有通知记录,支持按类型/状态筛选
- 清空日志:一键清空历史发送记录
通知行为细节
- 无邮箱的提交者:通过/拒绝通知只发送给管理员,不尝试通知提交者(不会报错或空发)
- 审核通过:在「待审核」页直接点「通过」、点「编辑并发布」、或在「站点管理」编辑 pending 站点为 published,都会触发 site_approved 钩子
- HTML 邮件模板:内置响应式邮件模板,含站点名称、URL、审核结果和操作按钮
- 失败重试:SMTP 连接失败时记录错误日志但不中断页面流程
提交者邮箱采集
前台提交表单(/templates/default/submit.php)已增加邮箱输入框。API 端点 /api/?endpoint=submit 接收并写入 sites.submit_email 字段。没有邮箱的提交不影响正常收录。
第六章 参考文档
6.1 API 接口
所有 API 通过 /api/?endpoint={端点名} 访问。
| 端点 | 方法 | 说明 | CSRF |
|---|---|---|---|
sites | GET | 获取分类站点列表 | 否 |
featured | GET | 获取推荐站点 | 否 |
site | GET | 获取站点详情 | 否 |
search | GET | 搜索站点 | 否 |
submit | POST | 提交站点 | 是 |
click | POST | 记录点击 | 否 |
fetch-tdk | POST | 获取 TDK + 权重 | 是 |
update-meta | POST | 更新站点 TDK + 权重 | 是 |
rate | POST | 提交评分 | 否 |
feedback | POST | 提交反馈 | 否 |
wormhole | GET | 联盟成员列表(需启用虫洞插件) | 否 |
wormhole.js | GET | 联盟 JS 脚本 | 否 |
wormhole-teleport | GET | 虫洞传送 | 否 |
wormhole-join | GET | 加入上报 | 否 |
auto-link | GET | 友链自动收录(需启用插件) | 否 |
wormhole、auto-link、submit 相关的 API 端点在对应插件未启用时会返回 403 或透明 GIF,不会报错。
6.2 日志系统
通过 Logger::log($channel, $message) 写入日志,按日期分目录、按频道分文件存储。
// 写单条日志
Logger::log('admin_site', "[编辑] 站点ID={$siteId},结果=成功");
// 批量写日志
Logger::logs('wormhole_check', [
"检测通过:site_id=1",
"检测失败:site_id=3,原因=404",
]);
// 获取日志文件路径
$file = Logger::getLogFile('wormhole_join');
// 返回:data/logs/20260808/wormhole_join.log
日志目录:data/logs/YYYYMMDD/{channel}.log
全局开关:log_global(设为 0 关闭所有日志)。各频道独立开关:log_{channel}。
常用频道:admin_auth(登录审计)、admin_site(站点操作)、database_error(SQL错误)、security_ratelimit(频率限制)、wormhole_check(虫洞检测)、autolink(自动收录)、plugin_error(插件错误)、plugin_info(插件信息)。
6.3 伪静态配置
伪静态系统支持两种模式:
| 模式 | 首页 | 分类页 | 详情页 |
|---|---|---|---|
| dynamic | / | /index.php?route=category&slug=tech | /index.php?route=site&id=1 |
| rewrite | / | /category/tech/ | /site/1/ |
启用 rewrite 插件后,后台自动生成 Apache .htaccess 和 Nginx 配置规则,可一键复制。
6.4 安全规范
- 输出转义:所有输出到 HTML 的内容必须使用
Theme::e()或Security::e();属性值使用Theme::eAttr() - URL 生成:必须使用
Theme::url()生成 URL,不能硬编码 - CSRF 防护:所有 POST 表单必须包含
Security::csrfField(),后端使用Security::verifyCSRFToken()校验 - 输入过滤:使用
Security::cleanString()清洗字符串、Security::int()清洗整数、Security::cleanHtml()清洗 HTML - 频率限制:使用
Security::rateLimit($key, $maxCount, $windowSeconds)防止刷接口 - SQL 注入:所有数据库查询使用 PDO 预处理(
Database::query()、Database::execute()等),禁止拼接 SQL - 文件安全:
data/logs/目录不可通过 Web 直接访问(已默认受 .htaccess / Nginx 规则保护)
如需了解更多细节,请查阅 core/ 目录下的源代码,或查看 plugins/ 目录中内置插件的实际实现。