懒人导航 - 使用与开发文档
懒人导航基于原生 PHP + MySQL 构建,不依赖任何第三方框架。本文档面向三类读者:
| 角色 | 关注章节 | 目标 |
|---|---|---|
| 普通用户 / 站长 | 第一章、第五章 | 安装部署、日常运营、按需启用插件 |
| 主题开发者 | 第二章、第三章、6.5 | 自定义页面布局和视觉风格;向应用中心发布主题 |
| 插件开发者 | 第二章、第四章、6.5 | 扩展功能、注册钩子、管理数据库;向应用中心发布扩展 |
懒人导航采用按需加载架构:初始安装只创建核心表(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/backups/、config.php(在线更新、备份与日志功能需要) - 将
config.php设置为可写(安装程序会自动写入数据库配置)
1.2.2 运行安装向导
- 浏览器访问
http://你的域名/install/ - 按提示填写数据库主机、库名、用户名、密码、表前缀
- 设置管理员账号和密码
- 点击安装,系统自动创建核心表、写入默认配置、生成
config.php - 安装完成后删除
install/目录或重命名
初始安装只创建 9 张核心表和基础配置。所有插件默认关闭,需要到后台「插件管理」中按需启用。插件启用时会自动创建插件所需的表、字段和配置。
1.2.3 伪静态配置(可选,推荐)
系统支持三种 URL 模式,默认 dynamic(动态模式,无需任何服务器配置即可运行)。需要伪静态时:
- 到后台「插件管理」启动
rewrite插件(其设置 Tab 才会出现在基础设置中) - 进入后台「基础设置 - 伪静态」,选择
rewrite或index模式,可自定义各页面 URL 格式 - 复制自动生成的服务器规则到
.htaccess(Apache)或 Nginx 配置文件中
| 模式 | 示例 URL | 说明 |
|---|---|---|
| dynamic | /index.php?route=category&slug=tech | 动态模式,无需服务器配置 |
| rewrite | /category/tech/ | 伪静态模式,需配置服务器规则 |
| index | /index.php/category/tech/ | URL 中带 index.php 的兼容模式 |
1.3 后台使用
安装完成后访问 http://你的域名/admin/ 登录后台。
| 菜单 | 功能说明 |
|---|---|
| 仪表盘 | 站点概况、登录日志提示、统计数据概览 |
| 站点管理 | 站点增删改查、审核发布、批量操作 |
| 分类管理 | 分类增删改、排序、SEO 字段设置 |
| 推荐管理 | 设置全局推荐和分类推荐位 |
| 提交审核 | 审核前台提交和自动收录的站点,通过/拒绝操作会触发邮箱通知钩子 |
| 数据统计 | 站点浏览、点击、评分等数据统计 |
| 基础设置 | 基础信息(站点信息、SEO、日志设置等)、修改密码,以及各已启用插件注入的设置 Tab |
| 主题管理 | 查看可用主题、一键切换当前主题 |
| 插件管理 | 启动/停用/卸载插件,查看插件钩子与数据库影响 |
| API 密钥 | 开放 API(open/*)的 API Key 管理与调用频率限制 |
| 程序更新 | 检查并在线更新程序(侧边栏底部) |
基础设置页采用 Tab 面板设计。「基础信息」固定显示站点信息、SEO、日志设置(含全局总开关与各频道独立开关);「修改密码」固定显示;其余 Tab(广告管理、友链收录、伪静态设置等)由对应插件在启用后通过钩子注入。
主题切换不在基础设置中,而是在「主题管理」页面完成(对应设置项 current_theme)。
1.4 插件管理
后台「插件管理」页面(/admin/plugins.php)展示所有已扫描到的插件(内置 13 个),列表显示插件名称、描述、版本、状态、声明钩子与数据库影响。每个插件有行级操作按钮:
| 操作 | 效果 |
|---|---|
| 启动 | 自动执行 Plugin::ensureSchema():创建 schema.php 声明的表、向已有表添加字段、写入默认配置;之后该插件的 include.php / main.php 才会被加载并注册钩子 |
| 停用 | 仅修改启用状态(plugin_{name}_enabled=0)为关闭,保留所有数据库表和配置数据。再次启动时无需重新安装 |
| 卸载 | 停用 + 删除插件自建表 + 删除插件向已有表添加的字段 + 清除插件配置(plugin_{name}_*)。共享表智能判断:仅当所有声明该表的插件都卸载时才删表。插件文件不会被删除,可随时重新启动 |
插件若启用且其目录下存在 admin.php,管理页面会显示「管理」按钮(跳转 /admin/plugin.php?p=插件名);若只有 config_file(如 main.php)则显示「设置」按钮(跳转 settings.php?tab={config_tab 或插件名})。
列表中的数据库信息格式如 articles · settings(2配置),表示该插件创建了 articles 表并声明写入 2 条配置;向已有表添加字段时显示为 sites(5字段)。
所有内置插件默认关闭(安装时即写入 plugin_{name}_enabled=0)。安装完成后,根据需要到插件管理页面逐个启动。插件之间的依赖关系极低,可以按任意顺序启动。
1.5 主题切换
- 进入后台「主题管理」
- 在可用主题列表中点击「启用」选择要使用的主题
- 保存后前台立即生效
主题文件放在 templates/{主题名}/ 目录下。系统通过 Theme::scan() 扫描所有含 index.php 的子目录并读取其 theme.json 展示在后台主题列表中。切换结果写入 current_theme 配置。
第二章 系统概述
2.1 目录结构
index.php 前台统一入口(路由分发由 core/Route.php 完成)
go.php 跳转中间页(记录点击统计后跳转)
config.php 数据库配置(安装时自动生成,可写权限要求)
.htaccess 伪静态规则(后台可自动生成,需 rewrite 插件)
core/ 核心类库
bootstrap.php 应用引导(Session、自动加载、插件初始化、调试模式)
Database.php PDO 单例 + 预处理封装(query/queryOne/execute/insert/scalar/table/事务)
Security.php 安全模块(XSS/CSRF/频率限制/HTML清洗/Referer校验)
Route.php 路由分发器(home/category/site/search/submit/wormhole/article*/sitemap/robots/api)
Rewrite.php 伪静态系统(URL 解析/生成/服务器规则生成)
Theme.php 主题系统(扫描/加载/渲染/片段/资源引用)
Plugin.php 插件系统核心(扫描/启停/schema/钩子/卸载)
Logger.php 日志工具类(按天/按频道写文件,支持开关)
helpers.php 前台辅助函数库(setting/renderSiteCards/renderPagination 等)
SiteModel.php 站点模型
CategoryModel.php 分类模型
SettingsModel.php 设置模型(键值对读写)
FeatureModel.php 推荐模型
WormholeModel.php 虫洞联盟模型
AutoLinkModel.php 友链自动收录模型
BlacklistModel.php 黑名单模型(wormhole / auto-link 共享)
ApiKeyModel.php 开放 API Key 模型
SitemapModel.php Sitemap 生成模型
Updater.php 在线更新逻辑
cron_wormhole_check.php 虫洞联盟每日检测脚本(可配 crontab)
templates/ 主题目录
default/ 默认主题(index/category/site/search/submit/wormhole/article_list/article_detail/error/404 等)
plugins/ 插件目录(appcenter/ad/article/auto-alt/auto-link/dbtool/friendlink/lightbox/notify/rewrite/sitemap/spider/submit/wormhole)
admin/ 后台管理(settings.php/plugins.php/themes.php/api_keys.php/plugin.php 等)
api/ API 接口入口(index.php 分发,含 open/* 开放接口)
assets/ 全局静态资源(css/tabler 图标字体等)
install/ 安装程序
data/ 数据目录(logs 日志 / backups 数据库备份 / docs 本文档)
2.2 核心类一览
| 类 | 文件 | 职责与常用方法 |
|---|---|---|
Database | core/Database.php | PDO 单例:query()、queryOne()、execute()、insert()、scalar()、table()、事务(beginTransaction/commit/rollback) |
Security | core/Security.php | 输入清洗、输出转义、CSRF、频率限制、HTML 清洗、Referer 校验(详见 6.4) |
Route | core/Route.php | 路由分发:解析 URL 参数,收集模板变量后调用 Theme::render() |
Rewrite | core/Rewrite.php | 伪静态:url()、getConfig()、parseRequest()、generateHtaccess()、generateNginx() |
Theme | core/Theme.php | 主题扫描、加载、渲染、片段、资源引用(详见 3.4) |
Plugin | core/Plugin.php | 插件扫描、启停/卸载、schema 安装、钩子系统(详见 4.6) |
Logger | core/Logger.php | 日志写入:log()、logs()、isEnabled()、getLogFile()(详见 6.2) |
SettingsModel | core/SettingsModel.php | settings 表键值对读写:loadAll()、get()、set()、setMany()、delete()、clearCache() |
SiteModel | core/SiteModel.php | 站点数据:查询/统计/搜索/评分/反馈/点击浏览统计等 |
CategoryModel | core/CategoryModel.php | 分类数据:getAll()、getSidebarCategories()、getBySlug() 等 |
FeatureModel | core/FeatureModel.php | 推荐位(site_features)管理 |
WormholeModel | core/WormholeModel.php | 虫洞联盟成员与统计 |
AutoLinkModel | core/AutoLinkModel.php | 友链自动收录全流程(auto-link 插件) |
BlacklistModel | core/BlacklistModel.php | 黑名单管理(wormhole / auto-link 插件共享) |
ApiKeyModel | core/ApiKeyModel.php | 开放 API Key 校验与限流 |
SitemapModel | core/SitemapModel.php | Sitemap / robots.txt 生成(sitemap 插件) |
所有核心类由 core/bootstrap.php 注册的 PSR-0 风格自动加载器按需加载(core/{类名}.php),插件代码可直接使用,无需手动 include。
2.3 数据库表概览
安装程序(install/do_install.php)初始创建以下 9 张核心表(不含插件表):
| 表名 | 说明 |
|---|---|
sites | 站点主表:名称、URL、分类、br_pc/br_mobile/br_360/br_shenma 权重、状态、标签、提交者邮箱(submit_email,notify 插件添加)等 |
categories | 分类表:名称、slug、图标、排序、seo_title/seo_desc 等 SEO 字段 |
settings | 配置表:setting_key / setting_value 键值对,存储全站配置与插件配置 |
site_features | 推荐位关联表:全局推荐与分类推荐 |
admins | 管理员账号(password_hash 使用 password_hash()) |
site_ratings | 用户评分(IP 防刷) |
site_feedback | 站点反馈(网址变更/打不开/内容错误) |
deleted_ids | ID 回收队列(删除站点后复用 ID) |
site_daily_stats | 站点每日浏览/点击统计(趋势图数据源) |
插件启用的表(由 Plugin::ensureSchema() 自动创建,卸载时智能清理):
| 表名 | 创建插件 | 说明 |
|---|---|---|
articles | article | 文章(标题、slug、内容、分类、标签、状态、浏览) |
blacklist | wormhole / auto-link | 黑名单共享表(两个插件都声明,最后一个卸载时才删表) |
notify_logs | notify | 邮件发送记录(notify 同时向 sites 表添加 submit_email 字段) |
friendlinks | friendlink | 友情链接 |
spider_visits | spider | 搜索引擎蜘蛛来访记录 |
插件声明的表(schema.php 的 tables)以及向已有表添加的字段(columns,如 wormhole 向 sites 添加联盟字段)不在初始安装时创建,而是在插件启动时由 Plugin::ensureSchema() 自动安装(幂等:已存在则跳过),卸载时自动清理。
2.4 辅助函数速查(core/helpers.php)
以下函数在引导阶段自动加载(core/bootstrap.php 引入 helpers.php),主题模板与插件代码中可直接调用:
| 函数名 | 参数 | 说明 |
|---|---|---|
setting() | $key, $default=null | 读取配置(别名 getConfig()) |
getConfig() | $key, $default=null | 读取配置(同 setting) |
isInstalled() | 无 | 检查系统是否已安装 |
isDebug() | 无 | 判断调试模式(APP_DEBUG 或 debug_mode 配置) |
redirect() | $url | HTTP 重定向并终止 |
getSiteUrl() | $path='' | 获取站点完整 URL(支持子路径拼接) |
getCurrentSiteUrl() | 无 | 从请求推导当前站点 URL |
getDisplayDomain() | $url | 从 URL 提取域名(显示用) |
parseDomain() | $url | 同 getDisplayDomain |
getCategoryUrl() | $slug | 生成分类页 URL(等价 Rewrite::url('category')) |
normalizeSiteUrl() | $url | URL 无协议时补全 https:// |
getWeightBadgeClass() | $br | 权重数值 → CSS 类(weight-0 ~ weight-9) |
getBrColor() | $br | 权重数值 → 颜色 |
renderSiteIcon() | $name, $size=36 | 渲染首字符站点图标 HTML |
getSiteColor() | $name | 按名称稳定取色 |
renderPagination() | $current, $total, $urlTemplate | 生成分页 HTML(%d / {%page%} 占位符均可) |
renderSiteCards() | $sites, $showWeight=1 | 批量渲染站点卡片列表 HTML |
formatNumber() | $num | 格式化数字(1000→1k,1000000→1M) |
formatDate() | $date, $format='Y-m-d' | 格式化日期 |
parseTags() | $tags | 解析 tags(JSON 字符串/数组/空)为数组 |
tagsToKeywords() | $tags | 标签数组 → SEO 关键词字符串 |
table() | $name | 获取带前缀的表名(等价 Database::table) |
getMaxBr() | $site | 获取站点 PC/移动/360/神马最高权重 |
extractMainTitle() | $title | 从网站标题智能提取主标题(≤6 字) |
第三章 主题开发
3.1 主题目录结构
主题放在 templates/{主题名}/ 目录下。判定一个目录是否为可用主题:目录存在且包含 index.php(Theme::exists()),theme.json 提供展示信息。
theme.json 主题信息(名称/标题/版本/作者/简介/预览图)
index.php 首页模板(必需,判定主题存在与否的依据)
category.php 分类页模板(可选,缺失时回退 default 主题)
site.php 站点详情页模板
search.php 搜索页模板
submit.php 提交站点页模板
wormhole.php 虫洞联盟页模板
article_list.php 文章列表页模板(article 插件启用后生效)
article_detail.php 文章详情页模板
error.php 错误页模板(收到 $code / $message)
header.php 公共头部片段(可选;Theme::partial('header') 加载)
footer.php 公共底部片段(可选;含 before_footer / after_footer 钩子)
404.php 404 兼容页(可选,参考模板)
css/ 样式文件(Theme::asset('css/xxx.css') 引用)
js/ 脚本文件(Theme::asset('js/xxx.js') 引用)
screenshot.png 主题截图(可选,后台主题管理展示)
admin.php 主题自带后台设置页(可选;存在时主题管理当前主题卡片出现「设置」,见 3.8)
缺失的模板文件会自动回退到 templates/default/ 对应文件(Theme::render() / Theme::partial() 均支持回退),因此可以只覆盖想自定义的页面。直接复制 templates/default/ 目录作为新主题起点是最快的做法。
3.2 theme.json
每个主题可包含 theme.json,由 Theme::getInfo() 读取并合并到默认值(名称/标题/版本/作者/简介),供后台主题管理展示;缺失时使用目录名等默认值。
{
"name": "default",
"title": "我的主题",
"version": "1.0",
"author": "你的名字",
"description": "主题简介",
"preview": ""
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 否 | 主题标识(默认取目录名,后台展示用) |
title | 否 | 显示名称(默认取目录名) |
version | 否 | 版本号(默认 1.0) |
author | 否 | 作者 |
description | 否 | 简介 |
preview | 否 | 预览图 URL 或留空(screenshot.png 会被自动识别为截图) |
除 theme.json 外,系统还会自动检测 index.php / category.php / site.php / search.php / submit.php 是否存在并记录到 files 字段,供后台展示文件齐全度。
3.3 模板文件与注入变量
Route::dispatch() 解析请求后,将 core/Route.php 中各页面方法收集的变量交给 Theme::render('模板名', $vars),通过 extract() 注入模板作用域,模板中直接使用变量(未注入的变量需用 ?? 兜底,默认主题即如此)。
| 模板文件 | 路由(rewrite 模式示例) | 注入变量($data = compact(...)) |
|---|---|---|
index.php | /(home) | $categories, $activeCats, $featuredSites, $seoTitle, $seoDesc, $seoKeywords, $siteStats, $showWeight, $perCategory, $currentCat, $currentSites, $settings, $ranking |
category.php | category/{slug}/(分页 page-{n}) | $category, $sites, $slug, $page, $sort, $total, $totalPages, $perPage, $seoTitle, $seoDesc, $seoKeywords, $showWeight, $categories, $settings |
site.php | site/{id}/ | $site, $category, $related, $categories, $settings, $showWeight, $seoTitle, $seoDesc, $seoKeywords, $ratingStats, $trendData |
search.php | search/?q=关键词 | $keyword, $sites, $page, $total, $totalPages, $perPage, $categories, $settings, $seoTitle, $seoDesc, $seoKeywords |
submit.php | submit/ | $categories, $siteStats, $settings, $enable, $needReview, $seoTitle, $seoDesc, $seoKeywords |
wormhole.php | wormhole/ | $categories, $siteStats, $wormholeStats, $members, $settings, $seoTitle, $seoDesc, $seoKeywords(成员/统计数据依赖 wormhole 插件启用) |
article_list.php | articles/(article 插件启用后) | $articles, $page, $total, $totalPages, $perPage, $categories, $settings, $seoTitle, $seoDesc, $seoKeywords |
article_detail.php | article/{id}/(article 插件启用后) | $article, $categories, $settings, $seoTitle, $seoDesc, $seoKeywords |
error.php | 任意错误(404 等) | $code, $message, $settings |
- 转义:
Theme::e()/Theme::eAttr()(等同Security::e()/Security::eAttr()) - URL 生成:
Theme::url()(自动适配三种模式,见 3.6) - 资源引用:
Theme::asset() - 布局片段:
Theme::partial('header')/Theme::partial('footer')(片段内继承页面注入的全部变量,也可显式传参Theme::partial('header', ['title'=>'xx'])) - 钩子输出:
Plugin::hook('钩子名', [参数])(见 3.5) - 全局设置:
$settings['site_name']等(也可用setting('site_name')) - 辅助函数:
setting()、renderSiteCards()、renderSiteIcon()、renderPagination()、formatNumber()、parseTags()、getDisplayDomain()、getMaxBr()、getWeightBadgeClass()、getCategoryUrl()等(完整清单见 2.4)
3.4 Theme 类方法
全部为静态方法,主题模板与插件中可直接调用。完整清单(core/Theme.php):
| 方法 | 参数 | 返回 / 说明 |
|---|---|---|
Theme::current() | 无 | string 当前主题名(读取 current_theme,不存在则回退 default) |
Theme::set() | $name | bool 切换主题并写入 current_theme(主题不存在返回 false) |
Theme::scan() | 无 | array 扫描 templates/ 下所有可用主题 [name => info](按名称排序) |
Theme::getInfo() | $name | array 主题信息:name/title/version/author/description/preview/screenshot/files 等 |
Theme::exists() | $name | bool 目录存在且含 index.php |
Theme::render() | $template, $vars=[] | 渲染模板(当前主题缺失自动回退 default,仍缺失输出 500 提示);由 Route 调用,开发者一般不直接调用 |
Theme::path() | $template | string 当前主题下模板文件的绝对路径 |
Theme::partial() | $name, $vars=[] | 加载布局片段(header/footer 等),自动继承页面变量、显式参数优先;当前主题缺失回退 default,再缺失静默跳过 |
Theme::e() | $value | string HTML 实体转义(等价 Security::e()) |
Theme::eAttr() | $value | string HTML 属性值转义(等价 Security::eAttr()) |
Theme::url() | $type, $params=[] | string 生成 URL(内部委托 Rewrite::url(),自动适配动态/伪静态模式) |
Theme::asset() | $file | string 主题资源 URL(/templates/{当前主题}/{file}) |
Theme::config() | $key, $default=null | 读取当前主题配置值(settings 表,前缀 theme_{主题名}_,见 3.8) |
Theme::setConfig() | array $data | 批量保存当前主题配置(自动加 theme_{主题名}_ 前缀) |
Theme::hasSettingsPage() | $name | bool 主题目录是否含 admin.php(决定后台「设置」入口是否显示) |
模板中未注入变量时建议兜底默认值,例如默认主题 header.php 中:$seoTitle = $seoTitle ?? $settings['site_name'] ?? '懒人导航';。
3.5 钩子列表
钩子分三类:前台模板钩子(主题模板中放置 Plugin::hook() 调用点,插件在此输出内容)、业务事件钩子(核心/插件在事件发生时触发,主题通常不感知)、后台钩子(后台页面提供,供插件注入菜单与设置 Tab)。
前台模板钩子(主题开发必放)
| 钩子名 | 所在文件 / 位置 | 参数 | 用途示例 |
|---|---|---|---|
before_header | header.php:<!DOCTYPE html> 之前 | 无 | head 之前注入内容/统计代码 |
after_header | header.php:<body> 之后 | 无 | body 开头注入横幅 |
search_bar_after | index.php:搜索栏之后 | 无 | 「提交站点」按钮等 |
site_list_before | index.php:站点卡片网格之前 | 无 | 列表上方广告/内容 |
sidebar_top | index.php:侧边栏分类列表前 | 无 | 侧边栏顶部广告 |
sidebar_bottom | index.php:侧边栏分类列表后 | 无 | 文章入口、虫洞联盟入口、广告等(多插件共享) |
site_list_after | index.php:站点卡片网格之后 | 无 | 列表下方广告/内容 |
before_content | site.php:详情内容前 | [$site](当前站点数组) | 详情上方广告/内容 |
after_content | site.php:详情内容后 | [$site](当前站点数组) | 详情下方广告/内容 |
before_footer | footer.php:<footer> 之前 | 无 | 页脚前内容 |
after_footer | footer.php:</body> 之前 | 无 | JS 注入(灯箱、自动收录等) |
调用方式(带参数钩子可直接传当前数据,便于插件使用):
<?php Plugin::hook('sidebar_top'); ?>
<?php Plugin::hook('before_content', [$site ?? []]); ?>
要让内置插件(广告 ad、文章 article、友链自动收录 auto-link、虫洞联盟 wormhole、灯箱 lightbox、图片ALT auto-alt、提交收录 submit 等)在自定义主题中全部生效,需要在新主题的 header.php、index.php、site.php、footer.php 中放置上述全部 11 处钩子调用。最稳妥的方式是直接复制默认主题再改样式。
业务事件钩子(插件间事件通知)
| 钩子名 | 触发点 | 参数 | 说明 |
|---|---|---|---|
site_submitted | 前台提交站点 API(api/index.php) | [['id','name','url','category_id','status','ip','email']] | 站点提交成功时触发 |
site_approved | 后台审核通过(admin/review.php、admin/sites.php) | [['id','submit_email']] | 站点审核通过时触发 |
site_rejected | 后台审核拒绝(admin/review.php) | [['id','submit_email']] | 站点审核拒绝时触发 |
feedback_submitted | 前台反馈提交 API(api/index.php) | [['site_id','type','content','email','ip']] | 用户提交问题反馈时触发 |
article_editor_before / article_editor_after | article 插件后台编辑表单(plugins/article/admin.php) | 无 | 供其他插件向文章编辑器扩展字段 |
后台钩子(供插件注入)
| 钩子名 | 位置 | 参数 | 用途 |
|---|---|---|---|
admin_sidebar | 后台侧边栏导航(admin/bootstrap.php) | 无 | 注入后台菜单项(用 $GLOBALS['currentPage'] 高亮) |
admin_settings_nav | 基础设置页 Tab 导航(admin/settings.php) | [$activeTab] | 注入设置 Tab 标签 |
admin_settings_tabs | 基础设置页 Tab 面板 | [$activeTab] | 注入设置 Tab 内容面板 |
3.6 URL 生成与资源引用
所有前台 URL 必须通过 Theme::url() / Rewrite::url() 生成,系统会自动适配 dynamic / rewrite / index 三种模式,主题内禁止硬编码链接。
// URL 生成(自动适配当前伪静态模式)
<?= Theme::url('home') ?> // 首页
<?= Theme::url('category', ['slug' => $cat['slug']]) ?> // 分类页
<?= Theme::url('site', ['id' => $site['id'], 'slug' => $site['category_slug'] ?? '']) ?> // 站点详情
<?= Theme::url('search', ['q' => 'AI']) ?> // 搜索页
<?= Theme::url('submit') ?> // 提交页
<?= Theme::url('wormhole') ?> // 虫洞联盟页
<?= Theme::url('article_list') ?> // 文章列表页
<?= Theme::url('article', ['id' => 1]) ?> // 文章详情页
<?= Theme::url('category_page', ['slug' => $slug, 'page' => 2, 'sort' => 'br']) ?> // 分类分页
// 静态资源引用(基于当前主题目录)
<link rel="stylesheet" href="<?= Theme::asset('css/style.css') ?>">
<script src="<?= Theme::asset('js/script.js') ?>"></script>
<img src="<?= Theme::asset('images/logo.png') ?>">
URL 类型与默认格式(rewrite 模式,可在后台 rewrite 插件中自定义 url_format_*):
| type | 默认格式(rewrite 模式) | 占位符 / 说明 |
|---|---|---|
home | / | 首页 |
category | /category/{%slug%}/ | {%slug%} 分类识别名;page>1 时自动切换为分页格式 |
category_page | /category/{%slug%}/page-{%page%}/ | {%slug%}、{%page%} 页码 |
site | /site/{%id%}/ | {%id%} 站点 ID |
search | /search/ | 关键词 q 以查询串传递 |
submit | /submit/ | 提交收录页 |
wormhole | /wormhole/ | 虫洞联盟页 |
article_list | /articles/ | 文章列表页 |
article | /article/{%id%}/ | {%id%} 文章 ID |
dynamic 模式生成的等价 URL 形如 /index.php?route=category&slug=tech。分类分页模板中通常这样配合分页函数:
<?php
$pgTemplate = Theme::url('category_page', ['slug' => $slug, 'page' => '%d', 'sort' => $sort]);
echo renderPagination($page, $totalPages, $pgTemplate);
?>
Rewrite::url() 与 Theme::url() 等价,插件或 PHP 逻辑中可直接使用 Rewrite::url();Theme::asset() 返回 /templates/{当前主题}/{file} 形式的路径。
3.7 实战案例:创建一个主题
下面以默认主题 default 的实现模式为参照,从零创建一个可用的「极简主题」。
步骤 1:创建目录和 theme.json
templates/mytheme/theme.json:
{
"name": "mytheme",
"title": "极简主题",
"version": "1.0",
"author": "懒人导航",
"description": "极简风格,专注内容",
"preview": ""
}
步骤 2:编写 header.php(公共头部片段)
头部负责 SEO 变量兜底、meta 标签、CSS 引用和插件钩子:
<?php
// 片段被多页面复用,先兜底 SEO 变量($settings 由 Route 注入)
if (!isset($seoTitle)) $seoTitle = $settings['site_name'] ?? '懒人导航';
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">
<meta name="csrf-token" content="<?= Security::eAttr(Security::generateCSRFToken()) ?>">
<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'); ?>
步骤 3:编写 index.php(首页)
首页用 Theme::partial('header') / Theme::partial('footer') 引入公共片段,中间按默认主题的方式放置钩子调用与列表渲染($currentSites、$categories、$ranking、$siteStats、$showWeight 等变量见 3.3):
<?php Theme::partial('header'); ?>
<div class="container">
<!-- 搜索栏(JS 搜索依赖 #searchInput,可参考默认主题 footer.php 的实现) -->
<div class="search-bar">
<input type="search" id="searchInput" placeholder="搜索站点...">
<?php Plugin::hook('search_bar_after'); ?>
</div>
<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']) ?> (<?= (int)$cat['site_count'] ?>)
</a>
<?php endforeach; ?>
<?php Plugin::hook('sidebar_bottom'); ?>
</aside>
<main>
<?php Plugin::hook('site_list_before'); ?>
<?= renderSiteCards($currentSites ?? [], $showWeight) ?>
<?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。默认主题还提供排行榜切换、分类就地切换等 JS 交互(见 templates/default/js/site.js 与 footer.php 内联脚本),追求功能完整可直接复用。
步骤 4:编写 footer.php(公共底部片段)
<?php Plugin::hook('before_footer'); ?>
<footer>
<a href="<?= Theme::url('home') ?>">首页</a> |
<a href="<?= Theme::url('submit') ?>">提交站点</a>
<p><?= Theme::e($settings['site_name'] ?? '') ?></p>
</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
before_footer/after_footer必须放在</body>之前- 友链自动收录、灯箱、图片ALT 等插件都通过
after_footer注入 JS;缺失则这些插件功能失效 - 广告插件(ad)依赖
site_list_before/site_list_after/sidebar_top/sidebar_bottom/before_content/after_content钩子
步骤 5:编写其他页面
按同样模式编写 category.php、site.php、search.php、submit.php 等页面。变量由 Route::dispatch() 自动注入(见 3.3)。站点详情页记得在内容前后放置带站点参数的钩子:
<?php Plugin::hook('before_content', [$site ?? []]); ?>
<!-- 详情内容 -->
<?php Plugin::hook('after_content', [$site ?? []]); ?>
步骤 6:添加 CSS 并在后台切换
在 templates/mytheme/css/common.css 中编写样式并通过 Theme::asset('css/common.css') 引用。进入后台「主题管理」,主题列表中会出现「极简主题」,点击启用后前台立即生效。
复制 templates/default/ 整个目录改名为新主题再修改,可保证页面齐全(error.php、wormhole.php、article 相关页面等)且不会遗漏钩子与 JS 交互。
3.8 主题自带后台设置(配置项)
主题可以像插件一样自带一个后台设置页,让站点管理员在后台配置该主题的专属选项(自定义 CSS、统计代码、开关等)。这是可选的:主题目录下存在 admin.php 即自动生效,无需在 theme.json 声明任何字段。
入口与访问规则
- 后台 → 主题管理 → 当前使用中的主题 卡片右侧出现「设置」按钮(
Theme::hasSettingsPage()检测到templates/{主题名}/admin.php时显示); - 点击进入
/admin/theme.php?name={主题名}(分发器admin/theme.php,与插件分发器admin/plugin.php同构),加载该主题的admin.php并自动包裹后台页头尾(adminHeader()/adminFooter()); - 安全规则:必须已登录,且只有「当前正在使用的主题」能进入设置页(与插件「未启用不能进设置」一致),未切换会提示先切换。
配置项读写(core/Theme.php)
主题配置与插件配置共用 settings 存储层,key 前缀 theme_{主题名}_(例如当前主题为 default 时,custom_css 实际存储为 theme_default_custom_css)。配置按主题隔离、存在数据库里,不随程序升级或主题文件覆盖而丢失。
| 方法 | 参数 | 说明 |
|---|---|---|
Theme::config() | $key, $default=null | 读取当前主题配置值(自动加 theme_{主题名}_ 前缀) |
Theme::setConfig() | array $data(原始键 => 值) | 批量保存当前主题配置(自动加前缀并转字符串) |
Theme::hasSettingsPage() | $name | bool 目录是否含 admin.php(后台「设置」入口是否显示) |
编写 admin.php(完整示例见 templates/default/admin.php)
分发器已输出 adminHeader() / adminFooter(),admin.php 只需:
- 开头加访问守卫:
<?php if (!defined('APP_VERSION') || !class_exists('Database')) { die('Direct access denied'); } ?>,防止被直接访问执行; - 文件顶部处理 POST 保存:
Security::verifyCSRFToken($_POST['csrf_token'] ?? '')校验 →Theme::setConfig()保存 →redirect()回本页(PRG,避免刷新重复提交); - 输出
<div class="card">结构的内容,复用后台 CSS 类(form-group / form-input / btn 等);表单必须带name="csrf_token"隐藏字段(值取$_SESSION['csrf_token']); - 读取已存值用
Theme::config(),输出到表单用Security::e()转义。
<?php
// templates/mytheme/admin.php(骨架;完整示例照抄 templates/default/admin.php)
if (!defined('APP_VERSION') || !class_exists('Database')) { die('Direct access denied'); }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Security::verifyCSRFToken($_POST['csrf_token'] ?? '')) {
redirect('/admin/theme.php?name=' . urlencode(Theme::current()) . '&err=' . urlencode('CSRF验证失败'));
}
Theme::setConfig([
'custom_css' => Security::cleanString($_POST['custom_css'] ?? '', 50000),
'footer_code' => Security::cleanString($_POST['footer_code'] ?? '', 50000),
]);
redirect('/admin/theme.php?name=' . urlencode(Theme::current()) . '&ok=' . urlencode('主题设置已保存'));
}
$customCss = (string)Theme::config('custom_css', '');
$footerCode = (string)Theme::config('footer_code', '');
?>
<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'] ?? '') ?>">
<div class="form-group">
<label>自定义 CSS</label>
<textarea name="custom_css" class="form-input" rows="8"><?= Security::e($customCss) ?></textarea>
<div class="form-help">输出到前台 <head></div>
</div>
<div class="form-group">
<label>页脚统计代码</label>
<textarea name="footer_code" class="form-input" rows="4"><?= Security::e($footerCode) ?></textarea>
</div>
<button type="submit" class="btn btn-primary">保存设置</button>
</form>
</div>
在主题模板中消费配置
主题模板(header/footer 等)用 Theme::config() 读取并输出。以默认主题为例:
header.php:在</head>前把custom_css包进<style>输出(先对内容做str_ireplace('</style', '<\/style', ...)转义,防止提前闭合样式块);footer.php:在</body>前输出页脚代码(统计代码用Security::cleanHtml()过滤,允许 script 但剔除 on* 事件与危险协议)。
- 配置存数据库(settings 表),后台保存、前台立即生效,程序升级 / 主题覆盖不丢失;
- 配置按主题隔离,切换主题互不影响;
- 第三方主题开发者复制
templates/default/admin.php即可快速提供专属设置页。
第四章 插件开发
4.1 插件目录结构
插件放在 plugins/{插件名}/ 目录下,目录名即插件名(小写字母/数字/连字符)。一个完整插件可包含以下文件:
plugin.json 元数据声明(必需)
include.php 主文件:类/函数定义 + 钩子注册(可选,由 plugin.json 的 main_file 指定)
main.php 后台设置面板:注册设置 Tab 钩子(可选,由 config_file 指定)
schema.php 数据库声明:表、字段、默认配置(可选,固定文件名)
api.php 开放 API 接口声明(可选):需在 plugin.json 声明 api_file 才会被加载,启用后自动注册 /api/open/* 接口并出现在后台「API 密钥」文档(见 4.2 与 data/docs/api-guide.md)
admin.php 独立后台管理页面(可选;插件目录存在该文件时后台自动显示「管理」按钮)
settings.php 插件内部自用页面/片段(可选,按需命名,如 article/spider 插件)
css/ 插件样式(可选,Plugin::asset() 引用)
js/ 插件脚本(可选)
- include.php:仅在插件启用时由
Plugin::init()(core/bootstrap.php 调用)加载,用于定义类/函数并注册前台与后台钩子 - main.php:仅在插件启用时随 include.php 一起加载,用于注册设置 Tab 钩子(
admin_settings_nav+admin_settings_tabs) - schema.php:仅在插件启动(ensureSchema)与卸载(uninstall)时由
Plugin::loadSchema()加载,返回声明数组 - api.php:由
core/OpenApi.php按需加载。插件启用后,其中声明的接口自动注册到/api/open/*(需 API Key),并自动出现在后台「API 密钥」使用说明;停用后接口失效(返回 403 / 40301)。声明格式与内置参考见data/docs/plugin-dev.md与plugins/article/api.php - admin.php:通过
/admin/plugin.php?p=插件名访问。分发器会先校验插件已启用并输出后台公共头尾;由于引导阶段已执行Plugin::init(),include.php 中定义的类/函数可直接使用,无需手动 include - 未启用的插件完全不加载,不注册任何钩子、不执行任何代码;其 admin.php 也无法访问
所有 PHP 文件开头建议加安全检查,阻止被直接 URL 访问(各内置插件统一写法):
<?php
if (!defined('APP_VERSION') || !class_exists('Database')) {
die('Forbidden');
}
4.2 plugin.json
每个插件根目录必须有 plugin.json,由 Plugin::getInfo() 读取并与默认值合并:
{
"name": "myplugin",
"title": "我的插件",
"version": "1.0",
"author": "你的名字",
"description": "插件功能描述",
"main_file": "include.php",
"config_file": "main.php",
"config_tab": "myplugin",
"schema_file": "schema.php",
"hooks": ["sidebar_top", "after_footer"],
"tables": ["mytable"],
"builtin": true
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 插件目录名(必须与文件夹一致),用于启用状态键 plugin_{name}_enabled 与配置前缀 |
title | 是 | 显示名称(默认取目录名) |
version | 否 | 版本号(默认 1.0) |
author | 否 | 作者 |
description | 否 | 功能描述(后台插件列表展示) |
main_file | 否 | 主文件名(默认 {name}.php,内置插件统一设为 include.php) |
config_file | 否 | 后台设置面板文件名(如 main.php);无设置项则不填 |
api_file | 否 | 开放 API 声明文件名(如 api.php)。必须显式声明,core/OpenApi.php 才会加载它以注册 open/* 接口;未声明一律不加载——防止把"请求处理器"型文件(如 appcenter 的 api.php)误当作声明文件 include |
config_tab | 否 | 设置 Tab 的 ID(默认等于插件名);当 Tab ID 与插件名不同时需指定,后台「设置」按钮据此跳转(admin/plugins.php) |
schema_file | 否 | 信息性字段——系统实际固定读取 schema.php,无需配置 |
hooks | 否 | 声明使用的钩子列表(后台插件列表展示,不影响实际注册——注册靠 include.php/main.php 中的 Plugin::registerHook()) |
tables | 否 | 声明创建的表名列表(卸载共享表判断的补充来源) |
builtin | 否 | 是否为内置插件(默认 true,后台列表显示「内置」标签) |
注意:插件启用状态不写在 plugin.json 中,而是由系统管理在 settings 表(plugin_{name}_enabled,安装时写入 0)。
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 表中(schema.php 的 config 中直接写完整 key,如 'plugin_myplugin_count'),并配合 Plugin::config('myplugin', 'count', 默认值) 读取——该方法内部即拼接 plugin_{plugin}_{key} 前缀。
少数早期插件(如 auto-link 的 autolink_enable、autolink_need_review、autolink_default_category、autolink_banned_words)仍沿用不带前缀的键名,由后台 admin/settings.php 对应 case 直接保存。两种方式都可用,新插件请统一使用 plugin_ 前缀。
4.4 include.php 与钩子注册
include.php 是插件主文件(main_file),负责定义类/函数并注册钩子:
<?php
// 安全检查:阻止直接访问(所有插件文件统一写法)
if (!defined('APP_VERSION') || !class_exists('Database')) {
die('Forbidden');
}
/**
* 输出插件内容(可用 Plugin::config 读取配置)
*/
function myplugin_render(): void
{
$count = (int)Plugin::config('myplugin', 'count', '5');
// ... 业务逻辑(可用 Database::query 等核心类)
echo '<div class="myplugin">内容</div>';
}
// 注册前台动作钩子(第 3 个参数为优先级,数字越小越先执行,默认 10)
Plugin::registerHook('sidebar_top', function () {
myplugin_render();
});
// 注册过滤钩子(返回模式:返回值作为下一个回调的入参)
Plugin::registerHook('filter_title', function ($title) {
return $title . ' - 我的插件';
});
// 注册后台侧边栏菜单(进入独立管理页 /admin/plugin.php?p=myplugin)
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($hook, $callback, $priority = 10);执行动作钩子Plugin::hook($hook, $args = []);执行过滤钩子Plugin::filter($hook, $value, $args = []) - 模板/页面触发钩子时传入的参数数组会原样展开传给每个回调,例如
Plugin::hook('before_content', [$site])中回调收到$site;回调可忽略多余参数 - 同一钩子可被多个插件注册,按优先级排序执行;单个回调抛异常只记录
plugin_error日志,不影响其他回调 Plugin::hasHook($hook)可判断钩子是否已有注册回调;Plugin::addFilter()是registerHook的语义别名- echo 的内容会直接输出到页面;事件类钩子(site_submitted 等)回调也可不输出,仅做逻辑处理(参考 notify 插件)
可用钩子全集与参数见 3.5 节。
4.5 main.php 设置面板
main.php(config_file)通过注册两个后台钩子,在基础设置页面注入自定义 Tab:
<?php
if (!defined('APP_VERSION') || !class_exists('Database')) {
die('Forbidden');
}
// 钩子1:注入 Tab 导航标签(tab ID 默认等于插件名)
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" class="form-input" name="plugin_myplugin_count"
value="<?= Security::eAttr(Plugin::config('myplugin', 'count', '5')) ?>">
</div>
<div class="text-right">
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<?php
});
- Tab 面板表单 POST 到
/admin/settings.php,必须带csrf_token、section(插件名)、tab三个隐藏字段 admin/settings.php的switch ($section)需要存在对应的case 'myplugin'分支来读取并保存字段(内置插件如 rewrite/sitemap/ad/autolink/submit/notify 均在其中有对应 case);没有对应 case 时表单内容不会被保存- 保存时用
$settingsModel->setMany([...])或Plugin::setConfig('myplugin', 'count', $value)(等价写入plugin_myplugin_count) - Tab 内容面板
id必须为tab-{tabId},与导航标签href="#tab-{tabId}"对应;tabId 默认插件名,不同时在 plugin.json 用config_tab声明 switchTab()是后台内置 JS 函数;面板类名沿用card / card-header / card-title / form-group / form-input / text-right / btn btn-primary等后台样式
4.6 Plugin 类 API
以下为 core/Plugin.php 的全部公开静态方法(插件代码与模板中均可调用):
| 方法 | 参数 | 返回 / 说明 |
|---|---|---|
Plugin::init() | 无 | 初始化:扫描并加载全部已启用插件的 include.php / main.php(bootstrap 自动调用,勿手动重复调用) |
Plugin::scan() | 无 | array 扫描 plugins/ 下所有含 plugin.json 的插件 [name => info](含 enabled/dir 等) |
Plugin::getInfo() | $name | ?array 插件元数据(无 plugin.json 返回 null) |
Plugin::isEnabled() | $name | bool 检查 plugin_{name}_enabled 是否为 1 |
Plugin::setEnabled() | $name, $enabled | 启停插件(启动时自动 ensureSchema()) |
Plugin::ensureSchema() | $name | 安装数据库结构:建表 / 加字段 / 写默认配置(幂等) |
Plugin::loadSchema() | $name | array 加载 schema.php,返回 ['tables','columns','config'] |
Plugin::ensureTables() | $name | 旧接口别名(内部转发 ensureSchema,已弃用) |
Plugin::uninstall() | $name | array 卸载:停用 + 删自建表(共享表跳过)+ 删字段 + 清配置,返回 ['success','dropped_tables','dropped_columns','cleared_keys'] |
Plugin::getEnabledPlugins() | 无 | array 所有已启用插件 [name => info] |
Plugin::registerHook() | $hook, $callback, $priority=10 | 注册钩子回调(动作/过滤通用) |
Plugin::addFilter() | $hook, $callback, $priority=10 | registerHook 的语义别名 |
Plugin::hook() | $hook, $args=[] | 执行动作钩子:按优先级依次调用回调并输出 |
Plugin::filter() | $hook, $value, $args=[] | mixed 执行过滤钩子:值链式经过所有回调后返回 |
Plugin::hasHook() | $hook | bool 钩子是否已有注册回调 |
Plugin::config() | $plugin, $key, $default=null | 读取插件配置(拼接 plugin_{plugin}_{key}) |
Plugin::setConfig() | $plugin, $key, $value | 写入插件配置 |
Plugin::getDir() | $name | string 插件目录绝对路径 |
Plugin::asset() | $plugin, $file | string 插件资源 URL(/plugins/{plugin}/{file}) |
Plugin::clearCache() | 无 | 清除扫描缓存(后台启停/卸载后调用) |
数据库与安全的通用调用(插件内最常用):Database::table('表名')(带前缀表名)、Database::query() / queryOne() / execute() / insert() / scalar()、Security::e() / eAttr() / cleanString() / cleanHtml() / int()、setting() / Rewrite::url()。
4.7 共享表与卸载
当多个插件声明同一张表时(如 blacklist 表被 wormhole 和 auto-link 共同声明),系统会智能处理:
- 启动时:
ensureSchema()使用CREATE TABLE IF NOT EXISTS,重复执行安全 - 卸载时:
Plugin::uninstall()通过getPluginsDeclaringTable()检查是否还有其他插件(无论启用与否)声明该表(schema.php tables + plugin.json tables 均计入)。只要有,就跳过删表 - 全部卸载时:当最后一个声明该表的插件被卸载时,才真正执行
DROP TABLE
Plugin::uninstall($name) 的完整流程与返回:
- 停用插件(
plugin_{name}_enabled = 0) - 删除自建表(智能处理共享表,见上)
- 删除 schema.php
columns声明添加到已有表的字段(ALTER TABLE DROP COLUMN) - 清除配置:删除
plugin_{name}_%前缀通配命中项 + schema.phpconfig声明的 key - 清除扫描缓存,写
plugin_uninstall频道日志 - 返回
['success' => bool, 'dropped_tables' => [], 'dropped_columns' => [], 'cleared_keys' => int](后台插件管理页据此展示结果)
插件文件本身不会被删除,卸载后可随时重新启动(重新执行 ensureSchema 安装结构与默认配置)。
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
});
Tab 表单 POST 到 /admin/settings.php 后,需要在 admin/settings.php 的 switch ($section) 中添加 case 'daily-quote' 分支读取 plugin_daily-quote_count 并保存(参考 4.5 节的保存约定)。
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,可修改显示数量(保存分支需在 settings.php 增加 case,见 4.5)
这个案例展示了插件开发的完整流程:plugin.json 声明元数据 → schema.php 声明数据库 → include.php 定义业务逻辑和钩子 → main.php 后台设置面板 → admin.php 后台管理页面。启用后自动安装,卸载后自动清理。
第五章 内置插件
5.1 插件一览
系统内置 13 个插件,全部默认关闭(安装时写入 plugin_{name}_enabled=0)。安装后根据需要在后台「插件管理」中启动,启用时才执行建表/加字段/写默认配置。
| 插件 | 功能 | 数据库影响 | 主要钩子 |
|---|---|---|---|
| 广告管理 (ad) | 后台配置 6 个广告位 HTML,前台对应位置展示 | 6 条配置(plugin_ad_*),无独立表 | site_list_before/after、sidebar_top/bottom、before/after_content |
| 文章发布 (article) | 后台文章管理,前台文章列表/详情 | articles 表 + 2 条配置 | sidebar_bottom、before_footer、admin_sidebar |
| 虫洞联盟 (wormhole) | 站点互推、随机传送、定时检测、黑名单 | blacklist 表(与 auto-link 共享)+ sites 表 10 字段 + 5 条配置 | sidebar_bottom、admin_sidebar |
| 友链自动收录 (auto-link) | 检测来路、验证回链、抓取 TDK、自动收录 | blacklist 表(共享)+ 4 条配置(autolink_*) | after_footer、admin_settings_nav/tabs |
| 伪静态设置 (rewrite) | URL 模式与格式配置,自动生成服务器规则 | 10 条配置(rewrite_mode + 9 个 url_format_*) | admin_settings_nav/tabs |
| 提交网站收录 (submit) | 前台提交入口/表单、审核流程、频率限制 | 9 条配置(plugin_submit_*) | search_bar_after、admin_settings_nav/tabs |
| 网站地图 (sitemap) | 生成 sitemap.xml / robots.txt,支持缓存与分片 | 少量配置 | admin_settings_nav/tabs |
| 图片灯箱 (lightbox) | 详情/文章页图片点击放大 | 无数据库影响 | after_footer |
| 图片ALT (auto-alt) | 自动给无 alt 的 img 补填描述 | 无数据库影响 | after_footer |
| 邮箱通知 (notify) | SMTP 邮件:提交/审核/反馈事件自动通知 | notify_logs 表 + sites.submit_email 字段 + 配置 | site_submitted / site_approved / site_rejected / feedback_submitted、admin_settings_nav/tabs |
| 友情链接 (friendlink) | 友链管理,前台底部展示 | friendlinks 表 + 配置 | before_footer、after_footer、admin_sidebar |
| 蜘蛛来访 (spider) | 搜索引擎蜘蛛来访统计(30 天保留) | spider_visits 表 + 配置 | before_header、admin_sidebar |
| 数据库备份 (dbtool) | 一键备份/恢复/下载/导入数据库 SQL | 无自建表 | 无(后台经插件管理「管理」按钮进入) |
钩子列 = 插件实际注册的钩子;主题需要为前台钩子保留调用点(见 3.5),后台钩子(admin_*)由系统页面自动触发。
5.2 广告管理 (ad)
后台可配置 6 个广告位的 HTML 代码,前台在对应位置展示。
| 广告位 | 钩子位置 |
|---|---|
| 首页列表前 | site_list_before |
| 首页列表后 | site_list_after |
| 侧边栏顶部 | sidebar_top |
| 侧边栏底部 | sidebar_bottom |
| 详情内容前 | before_content |
| 详情内容后 | after_content |
启用后在后台「基础设置 - 广告管理」Tab(ad/main.php 注入)为每个广告位填写 HTML。内容存储为 plugin_ad_{位置} 配置,输出前经 Security::cleanHtml() 清洗。ad/include.php 在前台注册了上表全部 6 个钩子,内容直接 echo 到对应位置。
广告内容能否显示取决于主题是否在对应位置调用 Plugin::hook()(含 before_content/after_content 传 [$site])。钩子调用点清单与示例见 3.5、3.7 节,直接复制默认主题即可保证兼容。
5.3 文章发布 (article)
启用后前台出现文章列表/详情页(路由 article_list / article,模板 article_list.php / article_detail.php),侧边栏注入「文章专栏」入口;后台侧边栏出现「文章管理」入口(/admin/plugin.php?p=article,plugins/article/admin.php)。支持分类、标签、发布/草稿/待审状态与浏览量统计;编辑器支持扩展钩子(article_editor_before / article_editor_after)供其他插件加字段。
数据库:articles 表(title、slug、content(HTML)、excerpt、author、category、tags、status、views、created_at、updated_at)。默认配置:plugin_article_per_page(列表每页条数)、plugin_article_enable_submit(是否开放投稿)。
| 钩子 | 位置 | 说明 |
|---|---|---|
sidebar_bottom | 前台首页侧边栏 | 注入「文章专栏」入口(含文章数统计) |
before_footer | 前台公共底部 | 注入插件自定义 CSS(plugin_article_custom_css) |
admin_sidebar | 后台侧边栏 | 注入「文章管理」导航入口 |
文章相关页面未启用该插件时访问返回 404;article_list / article_detail 模板仅在该插件启用后有意义。
5.4 虫洞联盟 (wormhole)
站点互推机制:联盟成员在页面嵌入 JS(/api/?endpoint=wormhole.js),互相展示成员站点实现流量互传;内置随机传送(teleport)、每日检测与黑名单管理。
数据库影响:创建 blacklist 表(与 auto-link 共享);向 sites 表添加联盟字段(wormhole_status、wormhole_joined_at、wormhole_last_check、wormhole_check_fail、wormhole_source_domain、wormhole_quality_score、wormhole_click_in/out、wormhole_last_content_update、wormhole_quality_updated_at 等);默认配置:wormhole_enable、wormhole_need_review、wormhole_fallback_category、plugin_wormhole_rate_limit、block_all_ip。
| 状态 | 说明 |
|---|---|
none | 未加入联盟 |
manual | 后台手动加入(不检测) |
auto | JS 上报自动加入(每日检测) |
pending | 待审核 |
broken | 连续检测失败达阈值,已移出 |
| 钩子 | 位置 | 说明 |
|---|---|---|
sidebar_bottom | 前台首页侧边栏 | 注入「🌀 虫洞联盟」入口(含成员数,点击前往 wormhole 页) |
admin_sidebar | 后台侧边栏 | 注入「虫洞联盟」管理入口(/admin/plugin.php?p=wormhole,成员管理/联盟设置/检测/黑名单) |
外站嵌入代码
联盟成员需要在页面中嵌入以下 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>
定时检测:可配 crontab 每天执行 core/cron_wormhole_check.php(抓取 auto 成员页面检查是否仍含联盟代码,失败累计达阈值标记 broken):
0 3 * * * php /path/to/core/cron_wormhole_check.php
相关 API 端点:wormhole(成员列表)、wormhole.js(嵌入脚本)、wormhole-teleport(随机传送)、wormhole-join(加入上报,返回 GIF);插件未启用时这些端点返回 403(join 返回透明 GIF)。
5.5 友链自动收录 (auto-link)
当用户从挂了本站友链的外站点击进入时,系统自动检测来路、验证回链、抓取 TDK、检查违禁词与黑名单,通过后自动收录。
工作流程:PHP 渲染页面时捕获 Referer → 过滤本站与搜索引擎 → 插件在 after_footer 钩子注入 JS,延迟 2 秒发送 /api/?endpoint=auto-link&ref=xxx → 后端 AutoLinkModel::process() 抓取对方首页验证回链、抓取 TDK、检查违禁词/黑名单/频率限制/重复域名 → 插入数据库。
配置项(基础设置「友链收录」Tab,auto-link/main.php 注入;键名沿用 autolink_* 兼容旧版):autolink_enable(开关)、autolink_need_review(是否需审核)、autolink_default_category(默认分类)、autolink_banned_words(违禁词)。
| 钩子 | 位置 | 说明 |
|---|---|---|
after_footer | 前台公共底部 | 在 </body> 前注入检测 JS(仅 autolink_enable=1 时输出) |
admin_settings_nav / admin_settings_tabs | 后台基础设置 | 注入「友链收录」设置 Tab |
检测 JS 由插件自身通过 after_footer 钩子注入,主题无需再硬编码任何自动收录代码——但主题 footer.php 必须调用 Plugin::hook('after_footer')(放在 </body> 之前),否则该插件启用后功能不生效。
安全机制:Referer 预过滤、搜索引擎排除、内网地址防护(Security::isInternalHost())、黑名单检查(blacklist 表,与 wormhole 共享)、频率限制、回链验证、违禁词检查、重复域名检查。插件未启用时访问 auto-link 端点返回 1x1 透明 GIF。
5.6 伪静态设置 (rewrite)
URL 模式与格式配置。启动后在后台「基础设置 - 伪静态」Tab(rewrite/main.php 注入)配置:
- 模式:
dynamic(默认,无需服务器规则)/rewrite(伪静态,需服务器规则)/index(URL 含 index.php 的兼容模式) - URL 格式:9 个页面(home/category/category_page/site/search/submit/wormhole/article_list/article)可自定义模板,占位符
{%slug%}、{%id%}、{%page%}(见 3.6) - 规则生成:一键生成并复制 Apache
.htaccess/ Nginx 规则(Rewrite::generateHtaccess()/generateNginx(),含敏感目录与模板文件防护),支持写入项目根 .htaccess
Rewrite.php 内置默认格式($defaults),配置存于 settings(rewrite_mode + url_format_*);未启用插件时前台仍可用 dynamic 模式正常工作。
钩子:admin_settings_nav / admin_settings_tabs(注入设置 Tab)。
5.7 提交网站收录 (submit)
前台提交入口与收录审核。配置在后台「基础设置 - 提交收录」Tab(submit/main.php 注入):允许提交、需审核、前台是否显示权重、默认分类、是否强制选分类、收录分类白名单(category_ids)、提交频率限制、TDK 抓取频率限制、提交说明文本;存储为 plugin_submit_* 配置(如 plugin_submit_enable_submit、plugin_submit_need_review、plugin_submit_show_weight 等)。
前台提交页由路由 submit 渲染(模板 submit.php,注入 $enable / $needReview);表单提交到 /api/?endpoint=submit(需 CSRF,submit 插件未启用返回 403),支持 TDK 自动抓取与邮箱采集(sites.submit_email,供 notify 插件使用)。提交后进入后台「提交审核」处理。
| 钩子 | 位置 | 说明 |
|---|---|---|
search_bar_after | 前台首页搜索栏后 | 注入「提交站点」入口按钮 |
admin_settings_nav / admin_settings_tabs | 后台基础设置 | 注入「提交收录」设置 Tab |
5.8 站点地图 (sitemap)
自动生成 sitemap.xml 与 robots.txt,收录首页、分类页、站点详情页与文章页;支持缓存、分片(sitemap-{n}.xml)与手动重新生成。站点地图由路由层直接输出(Route::sitemap() / Route::robots(),无需该插件也能访问,但生成逻辑与后台按钮由插件提供)。
后台「基础设置 - 网站地图」Tab(sitemap/main.php 注入)可查看状态、缓存并手动生成;此插件无前台模板钩子。
5.9 图片灯箱 (lightbox)
纯前端钩子插件,无数据库影响。详情页图片点击放大,自动给图片加 data-lightbox 属性。内置轻量灯箱实现,无需外部依赖。
钩子与模板挂接
| 钩子 | 位置 | 说明 |
|---|---|---|
after_footer | 前台 | 在页面底部注入灯箱 CSS + JS |
插件注入效果
插件通过 after_footer 钩子注入轻量 CSS + JS(无需外部依赖),自动为 .site-details img 与 .article-content img 绑定点击放大,支持键盘 ESC 关闭;不改变原图 DOM 与链接行为。
5.10 图片ALT (auto-alt)
纯前端钩子插件,无数据库影响。通过 after_footer 钩子注入 JS,自动给页面中缺少 alt 属性的 <img> 补填站点名称或描述,提升 SEO 与无障碍访问。
| 钩子 | 位置 | 说明 |
|---|---|---|
after_footer | 前台公共底部 | 注入补 alt 脚本 |
与灯箱(lightbox)插件一样依赖主题在 footer.php 中调用 Plugin::hook('after_footer')。
5.11 邮箱通知 (notify)
通过原生 PHP socket 实现 SMTP 邮件发送,在站点提交、审核通过/拒绝、用户反馈时自动通知管理员和提交者。
触发场景
| 场景 | 触发钩子 | 通知对象 | 说明 |
|---|---|---|---|
| 前台提交站点 | site_submitted | 管理员(always) | 始终通知站长,不受邮箱配置影响 |
| 审核通过 | site_approved | 管理员 + 提交者(如有邮箱) | "通过"按钮或"编辑并发布"都会触发 |
| 审核拒绝 | site_rejected | 管理员 + 提交者(如有邮箱) | 拒绝操作触发,提交者无邮箱则跳过 |
| 用户反馈 | feedback_submitted | 管理员(always) | 收到反馈后通知站长 |
数据库影响
| 影响 | 说明 |
|---|---|
notify_logs 表 | 记录每次邮件发送的状态、收件人、主题和失败原因 |
sites.submit_email | 向 sites 表添加字段,存储前台提交者填写的联系邮箱 |
| 13 条配置项 | 总开关(plugin_notify_enabled)、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 字段。没有邮箱的提交不影响正常收录。
5.12 友情链接 (friendlink)
友情链接管理:后台可快速添加友链(名称 + 链接 + 自定义 CSS 类 + 图标),前台底部区块展示(支持设置区块标题、打开方式、最大显示数量)。
数据库:friendlinks 表(name、url、css_class、icon、sort_order、status)。配置:plugin_friendlink_title、plugin_friendlink_target、plugin_friendlink_max_display。
| 钩子 | 位置 | 说明 |
|---|---|---|
before_footer | 前台公共底部 | 渲染友链区块 HTML |
after_footer | 前台公共底部 | 注入友链管理弹窗所需 CSS + JS |
admin_sidebar | 后台侧边栏 | 注入「友情链接」管理入口(/admin/plugin.php?p=friendlink) |
5.13 蜘蛛来访 (spider)
统计各大搜索引擎蜘蛛来访记录:按 User-Agent 识别百度/Google/Bing/搜狗/360/字节/Yandex 等引擎,支持按引擎开关、今日/昨日/近 7 日/近 30 日趋势与汇总图表,数据自动保留 30 天(可配置)。
数据库:spider_visits 表。配置:plugin_spider_engines(启用的引擎)、plugin_spider_retention_days(保留天数,默认 30)。
| 钩子 | 位置 | 说明 |
|---|---|---|
before_header | 前台页面 head 之前 | 检测 UA 并记录蜘蛛来访 |
admin_sidebar | 后台侧边栏 | 注入「蜘蛛来访」管理入口(/admin/plugin.php?p=spider,含趋势图表) |
5.14 数据库备份 (dbtool)
数据库备份与恢复工具:一键备份(纯 PHP PDO 导出当前表前缀下全部表结构与数据为 SQL)、下载备份到本地、删除备份、从备份文件恢复、上传 SQL 文件导入。备份存储于 data/backups/,文件名严格校验并防路径穿越。
无任何钩子:后台入口由插件管理页自动提供——该插件目录存在 admin.php,启用后在「插件管理」点击「管理」进入(/admin/plugin.php?p=dbtool),不会出现在侧边栏导航中。
第六章 参考文档
6.1 API 接口
所有 API 通过 /api/?endpoint={端点名}(或伪静态 /api/{端点名})访问。POST 接口默认需要 CSRF 校验(Token 放在 X-CSRF-Token 头或 csrf_token 字段,页面模板会输出 csrf-token meta),click/rate/feedback 三个公开端点豁免。
| 端点 | 方法 | 说明 | 鉴权 |
|---|---|---|---|
sites | GET | 分类站点列表(category=slug、page、sort=br/newest/views/clicks) | 无 |
featured | GET | 推荐站点 | 无 |
site | GET | 站点详情 | 无 |
search | GET | 搜索站点 | 无 |
submit | POST | 提交站点(CSRF;submit 插件未启用返回 403) | CSRF |
click | POST | 记录点击 | 无 |
fetch-tdk | POST | 获取 TDK + 权重(CSRF;支持 internal HMAC 签名) | CSRF |
update-meta | POST | 更新站点 TDK + 权重 | CSRF |
rate | POST | 提交评分(id、rating=1~5,IP 防刷) | 无 |
feedback | POST | 提交问题反馈 | 无 |
wormhole | GET | 联盟成员列表(需启用 wormhole 插件) | 无 |
wormhole.js | GET | 联盟嵌入 JS 脚本 | 无 |
wormhole-teleport | GET | 虫洞随机传送 | 无 |
wormhole-join | GET | 加入上报(返回透明 GIF) | 无 |
auto-link | GET | 友链自动收录触发(ref 参数,返回透明 GIF) | 无 |
开放 API(API Key 鉴权,open/*)
需在后台「API 密钥」创建 Key,请求头携带 X-API-Key(或参数 api_key,POST 也支持 JSON 请求体中的 api_key 字段),响应头返回 X-RateLimit-* 限流信息。API Key 视为受信凭证:除查询外,还提供站点发布 / 编辑 / 删除与分类新增 / 编辑 / 删除,App、小程序、前台自定义提交页可直接调用(免 CSRF)。
📄 完整《开放 API 对接文档》(含各接口请求/响应示例与代码示例):data/docs/api-guide.md;后台「API 密钥」页面的使用说明为实时清单(已启用插件接口自动出现)。
站点查询
| 端点 | 方法 | 说明 |
|---|---|---|
open/sites | GET | 开放站点列表(category/page/limit/sort=views|clicks|br|newest|name) |
open/site | GET | 开放站点详情(id) |
open/site/check | GET | 网址收录/审核状态查询(url,App 查收录进度、提交前查重用) |
open/site/related | GET | 相关站点(id、limit) |
open/featured | GET | 推荐位站点(limit) |
open/rank | GET | 开放排行榜(type=views|clicks|br_pc|br_mobile|newest) |
open/search | GET | 开放搜索(q) |
open/stats | GET | 开放统计 |
站点发布 / 编辑 / 删除(写接口)
| 端点 | 方法 | 说明 |
|---|---|---|
open/submit | POST | 发布/提交站点(name/url/category_id 必填;默认 published,可传 status=pending) |
open/site/update | POST | 编辑站点(id + 部分更新:名称/网址/分类/描述/标签/权重/状态/推荐/排序) |
open/site/delete | POST | 删除站点(id) |
分类管理
| 端点 | 方法 | 说明 |
|---|---|---|
open/categories | GET | 开放分类列表(含站点数与 SEO 信息) |
open/category/create | POST | 新增分类(name/slug 必填,slug 唯一) |
open/category/update | POST | 编辑分类(id + 部分更新) |
open/category/delete | POST | 删除分类(分类下仍有站点时返回 40901) |
系统查询
| 端点 | 方法 | 说明 |
|---|---|---|
open/plugins | GET | 插件列表与启用状态(判断哪些插件接口可用) |
内置插件接口(插件启用后自动注册)
内置插件在各自目录提供 api.php 接口声明:插件启用后其 open/插件/* 接口自动注册并在后台「API 密钥」使用说明中自动展示查询案例与说明;停用后接口失效(返回 403 / 40301)。
| 插件 | 端点(节选) | 方法 | 说明 |
|---|---|---|---|
| 文章 | open/article/list / open/article/detail | GET | 文章列表/详情 |
| 文章 | open/article/publish / open/article/update / open/article/delete | POST | 文章发布/编辑/删除 |
| 虫洞联盟 | open/wormhole/members / open/wormhole/stats / open/wormhole/random | GET | 联盟成员/统计/随机成员 |
| 友情链接 | open/friendlinks | GET | 友链列表 |
| 友情链接 | open/friendlink/create / open/friendlink/update / open/friendlink/delete | POST | 友链新增/编辑/删除 |
| 蜘蛛来访 | open/spider/stats / open/spider/trend / open/spider/visits | GET | 蜘蛛来访汇总/趋势/明细 |
成功响应 {success:true, code:0, message:"ok", data:{...}};失败 {success:false, code:错误码, message:"原因"}。错误码:40101 缺 Key、40102 Key 无效、42901 超限、40001 参数错误、40301 插件未启用、40401 资源不存在、40901 冲突。列表接口分页字段 data.total/page/limit/total_pages。写接口默认操作任意站点(受信凭证),请勿将 Key 交给不可信方;接口被调用时会写入 open_api 日志频道(后台「日志设置」可开关)。
6.2 日志系统
通过 Logger::log($channel, $message) 写入日志,按日期分目录、按频道分文件存储。
// 写单条日志
Logger::log('admin_site', "[编辑] 站点ID={$siteId},结果=成功");
// 批量写日志
Logger::logs('wormhole_check', [
"检测通过:site_id=1",
"检测失败:site_id=3,原因=404",
]);
// 判断某频道是否开启(可在耗时日志前使用)
if (Logger::isEnabled('autolink')) {
Logger::log('autolink', $detail);
}
// 获取日志文件路径
$file = Logger::getLogFile('wormhole_join');
// 返回:data/logs/20260808/wormhole_join.log
日志目录:data/logs/YYYYMMDD/{channel}.log,单行格式 [HH:MM:SS] 内容。
开关配置(后台操作)
后台「基础设置 - 基础信息 - 日志设置」提供完整开关界面:
- 日志总开关
log_global:关闭后所有日志停止写入;关闭时不会改动各频道开关值,重新开启后按原频道设置生效 - 频道独立开关
log_{channel}:总开关开启后自动展开,可按频道单独开启/关闭(默认全部开启)
全部频道(分组与后台界面一致):
| 分组 | 频道 |
|---|---|
| 虫洞联盟 | wormhole_join、wormhole_check、wormhole_model、wormhole_display |
| 友链自动收录 | autolink |
| 安全风控 | security_ratelimit、security_csrf、security_referer |
| 跳转与 API | go_jump、api_5118、api_tdk、open_api |
| 后台管理审计 | admin_auth、admin_site、admin_category、admin_feature、admin_blacklist、admin_setting、admin_wormhole、admin_api_key |
| 系统与数据库 | database_error、plugin_error、plugin_info、plugin_uninstall、search_fallback |
除内置频道外任意频道均可写入(如 Logger::log('my_channel', ...)),未配置开关的频道默认开启。
6.3 伪静态配置
伪静态系统支持三种模式(配置入口:rewrite 插件启动后的后台「基础设置 - 伪静态」Tab):
| 模式 | 首页 | 分类页 | 详情页 |
|---|---|---|---|
| dynamic | / | /index.php?route=category&slug=tech | /index.php?route=site&id=1 |
| rewrite | / | /category/tech/ | /site/1/ |
| index | /index.php | /index.php/category/tech/ | /index.php/site/1/ |
分类页分页格式 category/{slug}/page-{n}/;9 个页面的 URL 格式均可自定义(占位符 {%slug%}、{%id%}、{%page%})。后台可按当前配置自动生成 Apache .htaccess 与 Nginx 规则(含 core/、config/、install/、.git/、模板 PHP 文件的访问防护),并可一键写入项目根 .htaccess。
6.4 安全规范
- 输出转义:所有输出到 HTML 的内容必须使用
Theme::e()/Security::e();HTML 属性值使用Theme::eAttr()/Security::eAttr() - URL 生成:必须使用
Theme::url()/Rewrite::url()生成 URL,不能硬编码 - CSRF 防护:所有 POST 表单必须包含
Security::csrfField()(或$_SESSION['csrf_token']),后端使用Security::verifyCSRFToken()校验 - 输入过滤:
Security::cleanString()清洗字符串、Security::int()清洗整数、Security::enum()枚举白名单、Security::validateSlug()校验 slug、Security::validateUrl()校验 URL、Security::cleanHtml()清洗富文本、Security::cleanTags()清洗标签 - 频率限制:
Security::rateLimit($key, $maxCount, $windowSeconds)防刷接口 - Referer 校验:
Security::checkReferer()(后台接口按需启用) - SQL 注入:一律使用 PDO 预处理(
Database::query()/queryOne()/execute()/insert()/scalar()),禁止拼接 SQL - 文件安全:
data/logs/、data/backups/等目录不可通过 Web 直接访问(后台生成的 .htaccess / Nginx 规则已默认防护core/、templates/*.php等)
6.5 应用中心(扩展分发)
「应用中心」是懒人导航的在线扩展分发通道:站长在后台(插件管理 → 应用中心)浏览目录,
对插件 / 主题一键安装、升级。开发者只需把符合规范的扩展文件夹上传到发布服务器
的 apps/plugins/ 或 apps/themes/ 目录即自动生效——无需维护清单、无需手动打包 ZIP。
- 站长侧:内置插件
plugins/appcenter/(启动后侧边栏出现「应用中心」入口;目录协议与安全边界见plugins/appcenter/README.md) - 发布侧:服务端
appcenter-server/(list.php自动扫描 +download.php按需打包;部署运维见appcenter-server/README.md) - 开发者:插件 / 主题发布规范、元数据字段、版本与升级规则、自测清单、FAQ —— 📄 完整《应用中心开发者接入指南》:
data/docs/appcenter.md
写好插件 / 主题并在本地验证 → 文件夹(含 plugin.json / theme.json)上传到
apps/plugins/ 或 apps/themes/ → 浏览器访问 list.php 确认目录出现 →
站长后台即可一键安装;改 version 重新上传即自动升级。
如需了解更多细节,请查阅 core/ 目录下的源代码,或查看 plugins/ 目录中内置插件的实际实现。