懒人导航 - 使用与开发文档

懒人导航基于原生 PHP + MySQL 构建,不依赖任何第三方框架。本文档面向三类读者:

角色关注章节目标
普通用户 / 站长第一章、第五章安装部署、日常运营、按需启用插件
主题开发者第二章、第三章、6.5自定义页面布局和视觉风格;向应用中心发布主题
插件开发者第二章、第四章、6.5扩展功能、注册钩子、管理数据库;向应用中心发布扩展
核心设计理念

懒人导航采用按需加载架构:初始安装只创建核心表(sites、categories、settings 等 9 张),所有插件默认关闭。插件启用时才自动创建其所需的表、字段和配置——做到真正的插件单独安装、按需启用。

第一章 快速入门

1.1 系统要求

组件最低版本推荐版本说明
PHP7.48.0+PDO、GD、cURL、mbstring、JSON、OpenSSL、Session
MySQL5.78.0utf8mb4 字符集
Web 服务器Nginx / ApacheNginx需支持伪静态

PHP 扩展要求:PDO_MYSQL(必需)、GD(必需)、cURL(必需)、mbstring(必需)、JSON(必需)、OpenSSL(必需)、Session(必需)、fileinfo(推荐)。

可通过 php -m 命令查看已安装的扩展列表。

1.2 安装部署

1.2.1 上传文件

  1. 将程序压缩包解压,上传到 Web 服务器根目录或子目录
  2. 确保以下目录可写:data/logs/data/backups/config.php(在线更新、备份与日志功能需要)
  3. config.php 设置为可写(安装程序会自动写入数据库配置)

1.2.2 运行安装向导

  1. 浏览器访问 http://你的域名/install/
  2. 按提示填写数据库主机、库名、用户名、密码、表前缀
  3. 设置管理员账号和密码
  4. 点击安装,系统自动创建核心表、写入默认配置、生成 config.php
  5. 安装完成后删除 install/ 目录或重命名
说明

初始安装只创建 9 张核心表和基础配置。所有插件默认关闭,需要到后台「插件管理」中按需启用。插件启用时会自动创建插件所需的表、字段和配置。

1.2.3 伪静态配置(可选,推荐)

系统支持三种 URL 模式,默认 dynamic(动态模式,无需任何服务器配置即可运行)。需要伪静态时:

  1. 到后台「插件管理」启动 rewrite 插件(其设置 Tab 才会出现在基础设置中)
  2. 进入后台「基础设置 - 伪静态」,选择 rewriteindex 模式,可自定义各页面 URL 格式
  3. 复制自动生成的服务器规则到 .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 主题切换

  1. 进入后台「主题管理」
  2. 在可用主题列表中点击「启用」选择要使用的主题
  3. 保存后前台立即生效

主题文件放在 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 核心类一览

文件职责与常用方法
Databasecore/Database.phpPDO 单例:query()queryOne()execute()insert()scalar()table()、事务(beginTransaction/commit/rollback)
Securitycore/Security.php输入清洗、输出转义、CSRF、频率限制、HTML 清洗、Referer 校验(详见 6.4)
Routecore/Route.php路由分发:解析 URL 参数,收集模板变量后调用 Theme::render()
Rewritecore/Rewrite.php伪静态:url()getConfig()parseRequest()generateHtaccess()generateNginx()
Themecore/Theme.php主题扫描、加载、渲染、片段、资源引用(详见 3.4)
Plugincore/Plugin.php插件扫描、启停/卸载、schema 安装、钩子系统(详见 4.6)
Loggercore/Logger.php日志写入:log()logs()isEnabled()getLogFile()(详见 6.2)
SettingsModelcore/SettingsModel.phpsettings 表键值对读写:loadAll()get()set()setMany()delete()clearCache()
SiteModelcore/SiteModel.php站点数据:查询/统计/搜索/评分/反馈/点击浏览统计等
CategoryModelcore/CategoryModel.php分类数据:getAll()getSidebarCategories()getBySlug()
FeatureModelcore/FeatureModel.php推荐位(site_features)管理
WormholeModelcore/WormholeModel.php虫洞联盟成员与统计
AutoLinkModelcore/AutoLinkModel.php友链自动收录全流程(auto-link 插件)
BlacklistModelcore/BlacklistModel.php黑名单管理(wormhole / auto-link 插件共享)
ApiKeyModelcore/ApiKeyModel.php开放 API Key 校验与限流
SitemapModelcore/SitemapModel.phpSitemap / 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_idsID 回收队列(删除站点后复用 ID)
site_daily_stats站点每日浏览/点击统计(趋势图数据源)

插件启用的表(由 Plugin::ensureSchema() 自动创建,卸载时智能清理):

表名创建插件说明
articlesarticle文章(标题、slug、内容、分类、标签、状态、浏览)
blacklistwormhole / auto-link黑名单共享表(两个插件都声明,最后一个卸载时才删表)
notify_logsnotify邮件发送记录(notify 同时向 sites 表添加 submit_email 字段)
friendlinksfriendlink友情链接
spider_visitsspider搜索引擎蜘蛛来访记录
插件管理的表

插件声明的表(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()$urlHTTP 重定向并终止
getSiteUrl()$path=''获取站点完整 URL(支持子路径拼接)
getCurrentSiteUrl()从请求推导当前站点 URL
getDisplayDomain()$url从 URL 提取域名(显示用)
parseDomain()$url同 getDisplayDomain
getCategoryUrl()$slug生成分类页 URL(等价 Rewrite::url('category'))
normalizeSiteUrl()$urlURL 无协议时补全 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.phpTheme::exists()),theme.json 提供展示信息。

templates/mytheme/
  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.phpcategory/{slug}/(分页 page-{n}$category, $sites, $slug, $page, $sort, $total, $totalPages, $perPage, $seoTitle, $seoDesc, $seoKeywords, $showWeight, $categories, $settings
site.phpsite/{id}/$site, $category, $related, $categories, $settings, $showWeight, $seoTitle, $seoDesc, $seoKeywords, $ratingStats, $trendData
search.phpsearch/?q=关键词$keyword, $sites, $page, $total, $totalPages, $perPage, $categories, $settings, $seoTitle, $seoDesc, $seoKeywords
submit.phpsubmit/$categories, $siteStats, $settings, $enable, $needReview, $seoTitle, $seoDesc, $seoKeywords
wormhole.phpwormhole/$categories, $siteStats, $wormholeStats, $members, $settings, $seoTitle, $seoDesc, $seoKeywords(成员/统计数据依赖 wormhole 插件启用)
article_list.phparticles/(article 插件启用后)$articles, $page, $total, $totalPages, $perPage, $categories, $settings, $seoTitle, $seoDesc, $seoKeywords
article_detail.phparticle/{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()$namebool 切换主题并写入 current_theme(主题不存在返回 false)
Theme::scan()array 扫描 templates/ 下所有可用主题 [name => info](按名称排序)
Theme::getInfo()$namearray 主题信息:name/title/version/author/description/preview/screenshot/files 等
Theme::exists()$namebool 目录存在且含 index.php
Theme::render()$template, $vars=[]渲染模板(当前主题缺失自动回退 default,仍缺失输出 500 提示);由 Route 调用,开发者一般不直接调用
Theme::path()$templatestring 当前主题下模板文件的绝对路径
Theme::partial()$name, $vars=[]加载布局片段(header/footer 等),自动继承页面变量、显式参数优先;当前主题缺失回退 default,再缺失静默跳过
Theme::e()$valuestring HTML 实体转义(等价 Security::e()
Theme::eAttr()$valuestring HTML 属性值转义(等价 Security::eAttr()
Theme::url()$type, $params=[]string 生成 URL(内部委托 Rewrite::url(),自动适配动态/伪静态模式)
Theme::asset()$filestring 主题资源 URL(/templates/{当前主题}/{file}
Theme::config()$key, $default=null读取当前主题配置值(settings 表,前缀 theme_{主题名}_,见 3.8)
Theme::setConfig()array $data批量保存当前主题配置(自动加 theme_{主题名}_ 前缀)
Theme::hasSettingsPage()$namebool 主题目录是否含 admin.php(决定后台「设置」入口是否显示)

模板中未注入变量时建议兜底默认值,例如默认主题 header.php 中:$seoTitle = $seoTitle ?? $settings['site_name'] ?? '懒人导航';

3.5 钩子列表

钩子分三类:前台模板钩子(主题模板中放置 Plugin::hook() 调用点,插件在此输出内容)、业务事件钩子(核心/插件在事件发生时触发,主题通常不感知)、后台钩子(后台页面提供,供插件注入菜单与设置 Tab)。

前台模板钩子(主题开发必放)

钩子名所在文件 / 位置参数用途示例
before_headerheader.php:<!DOCTYPE html> 之前head 之前注入内容/统计代码
after_headerheader.php:<body> 之后body 开头注入横幅
search_bar_afterindex.php:搜索栏之后「提交站点」按钮等
site_list_beforeindex.php:站点卡片网格之前列表上方广告/内容
sidebar_topindex.php:侧边栏分类列表前侧边栏顶部广告
sidebar_bottomindex.php:侧边栏分类列表后文章入口、虫洞联盟入口、广告等(多插件共享)
site_list_afterindex.php:站点卡片网格之后列表下方广告/内容
before_contentsite.php:详情内容前[$site](当前站点数组)详情上方广告/内容
after_contentsite.php:详情内容后[$site](当前站点数组)详情下方广告/内容
before_footerfooter.php:<footer> 之前页脚前内容
after_footerfooter.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.phpindex.phpsite.phpfooter.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_afterarticle 插件后台编辑表单(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_aftersidebar_topsidebar_bottomsite_list_beforesite_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.phpsite.phpsearch.phpsubmit.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()$namebool 目录是否含 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">输出到前台 &lt;head&gt;</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/{插件名}/ 目录下,目录名即插件名(小写字母/数字/连字符)。一个完整插件可包含以下文件:

plugins/myplugin/
  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.mdplugins/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)

  1. 创建独立表:遍历 tables,通过 information_schema 检查表是否存在,不存在则执行 SQL({prefix} 占位符替换为实际表前缀)
  2. 添加字段:遍历 columns,通过 information_schema.columns 检查字段是否存在,不存在则 ALTER TABLE ADD COLUMN
  3. 写入配置:遍历 config,检查 settings 表中是否已存在该 key,不存在则写入默认值
幂等安全

所有操作都是幂等的:表已存在则跳过,字段已存在则跳过,配置已存在则跳过。因此重复启用插件不会出错,也不会覆盖用户已修改的配置。

配置项命名规范

插件配置项建议使用 plugin_{插件名}_{配置键} 的命名格式存储在 settings 表中(schema.php 的 config 中直接写完整 key,如 'plugin_myplugin_count'),并配合 Plugin::config('myplugin', 'count', 默认值) 读取——该方法内部即拼接 plugin_{plugin}_{key} 前缀。

少数早期插件(如 auto-link 的 autolink_enableautolink_need_reviewautolink_default_categoryautolink_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.phpconfig_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_tokensection(插件名)、tab 三个隐藏字段
  • admin/settings.phpswitch ($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()$namebool 检查 plugin_{name}_enabled 是否为 1
Plugin::setEnabled()$name, $enabled启停插件(启动时自动 ensureSchema()
Plugin::ensureSchema()$name安装数据库结构:建表 / 加字段 / 写默认配置(幂等)
Plugin::loadSchema()$namearray 加载 schema.php,返回 ['tables','columns','config']
Plugin::ensureTables()$name旧接口别名(内部转发 ensureSchema,已弃用)
Plugin::uninstall()$namearray 卸载:停用 + 删自建表(共享表跳过)+ 删字段 + 清配置,返回 ['success','dropped_tables','dropped_columns','cleared_keys']
Plugin::getEnabledPlugins()array 所有已启用插件 [name => info]
Plugin::registerHook()$hook, $callback, $priority=10注册钩子回调(动作/过滤通用)
Plugin::addFilter()$hook, $callback, $priority=10registerHook 的语义别名
Plugin::hook()$hook, $args=[]执行动作钩子:按优先级依次调用回调并输出
Plugin::filter()$hook, $value, $args=[]mixed 执行过滤钩子:值链式经过所有回调后返回
Plugin::hasHook()$hookbool 钩子是否已有注册回调
Plugin::config()$plugin, $key, $default=null读取插件配置(拼接 plugin_{plugin}_{key}
Plugin::setConfig()$plugin, $key, $value写入插件配置
Plugin::getDir()$namestring 插件目录绝对路径
Plugin::asset()$plugin, $filestring 插件资源 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 表被 wormholeauto-link 共同声明),系统会智能处理:

  • 启动时ensureSchema() 使用 CREATE TABLE IF NOT EXISTS,重复执行安全
  • 卸载时Plugin::uninstall() 通过 getPluginsDeclaringTable() 检查是否还有其他插件(无论启用与否)声明该表(schema.php tables + plugin.json tables 均计入)。只要有,就跳过删表
  • 全部卸载时:当最后一个声明该表的插件被卸载时,才真正执行 DROP TABLE

Plugin::uninstall($name) 的完整流程与返回:

  1. 停用插件(plugin_{name}_enabled = 0
  2. 删除自建表(智能处理共享表,见上)
  3. 删除 schema.php columns 声明添加到已有表的字段(ALTER TABLE DROP COLUMN
  4. 清除配置:删除 plugin_{name}_% 前缀通配命中项 + schema.php config 声明的 key
  5. 清除扫描缓存,写 plugin_uninstall 频道日志
  6. 返回 ['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.phpswitch ($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 启动插件

  1. daily-quote 目录放入 plugins/
  2. 进入后台「插件管理」,找到「每日一言」
  3. 点击「启动」——系统自动创建 quotes 表、写入 2 条默认配置、加载插件代码
  4. 前台首页侧边栏底部出现每日一言
  5. 后台侧边栏出现「每日一言」管理入口,可添加/删除名言
  6. 后台「基础设置」出现「每日一言」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_enablewormhole_need_reviewwormhole_fallback_categoryplugin_wormhole_rate_limitblock_all_ip

状态说明
none未加入联盟
manual后台手动加入(不检测)
autoJS 上报自动加入(每日检测)
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_submitplugin_submit_need_reviewplugin_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.xmlrobots.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_titleplugin_friendlink_targetplugin_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 三个公开端点豁免。

端点方法说明鉴权
sitesGET分类站点列表(category=slug、page、sort=br/newest/views/clicks)
featuredGET推荐站点
siteGET站点详情
searchGET搜索站点
submitPOST提交站点(CSRF;submit 插件未启用返回 403)CSRF
clickPOST记录点击
fetch-tdkPOST获取 TDK + 权重(CSRF;支持 internal HMAC 签名)CSRF
update-metaPOST更新站点 TDK + 权重CSRF
ratePOST提交评分(id、rating=1~5,IP 防刷)
feedbackPOST提交问题反馈
wormholeGET联盟成员列表(需启用 wormhole 插件)
wormhole.jsGET联盟嵌入 JS 脚本
wormhole-teleportGET虫洞随机传送
wormhole-joinGET加入上报(返回透明 GIF)
auto-linkGET友链自动收录触发(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/sitesGET开放站点列表(category/page/limit/sort=views|clicks|br|newest|name)
open/siteGET开放站点详情(id)
open/site/checkGET网址收录/审核状态查询(url,App 查收录进度、提交前查重用)
open/site/relatedGET相关站点(id、limit)
open/featuredGET推荐位站点(limit)
open/rankGET开放排行榜(type=views|clicks|br_pc|br_mobile|newest)
open/searchGET开放搜索(q)
open/statsGET开放统计

站点发布 / 编辑 / 删除(写接口)

端点方法说明
open/submitPOST发布/提交站点(name/url/category_id 必填;默认 published,可传 status=pending)
open/site/updatePOST编辑站点(id + 部分更新:名称/网址/分类/描述/标签/权重/状态/推荐/排序)
open/site/deletePOST删除站点(id)

分类管理

端点方法说明
open/categoriesGET开放分类列表(含站点数与 SEO 信息)
open/category/createPOST新增分类(name/slug 必填,slug 唯一)
open/category/updatePOST编辑分类(id + 部分更新)
open/category/deletePOST删除分类(分类下仍有站点时返回 40901)

系统查询

端点方法说明
open/pluginsGET插件列表与启用状态(判断哪些插件接口可用)

内置插件接口(插件启用后自动注册)

内置插件在各自目录提供 api.php 接口声明:插件启用后open/插件/* 接口自动注册并在后台「API 密钥」使用说明中自动展示查询案例与说明;停用后接口失效(返回 403 / 40301)。

插件端点(节选)方法说明
文章open/article/list / open/article/detailGET文章列表/详情
文章open/article/publish / open/article/update / open/article/deletePOST文章发布/编辑/删除
虫洞联盟open/wormhole/members / open/wormhole/stats / open/wormhole/randomGET联盟成员/统计/随机成员
友情链接open/friendlinksGET友链列表
友情链接open/friendlink/create / open/friendlink/update / open/friendlink/deletePOST友链新增/编辑/删除
蜘蛛来访open/spider/stats / open/spider/trend / open/spider/visitsGET蜘蛛来访汇总/趋势/明细
开放 API 约定

成功响应 {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_joinwormhole_checkwormhole_modelwormhole_display
友链自动收录autolink
安全风控security_ratelimitsecurity_csrfsecurity_referer
跳转与 APIgo_jumpapi_5118api_tdkopen_api
后台管理审计admin_authadmin_siteadmin_categoryadmin_featureadmin_blacklistadmin_settingadmin_wormholeadmin_api_key
系统与数据库database_errorplugin_errorplugin_infoplugin_uninstallsearch_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/ 目录中内置插件的实际实现。