bbs1.org 插件开发 AI 规则
本文件是 AI 新建、修改和审查 bbs1.org 插件时的完整规范。开始工作前先读完本文件,再检查核心函数和功能最接近的现有插件;实现时以当前代码为准,不臆造接口。
执行顺序
- 明确插件 ID、功能边界、配置项、数据归属、页面入口、权限要求、外部请求和计划任务。
- 优先复用核心函数、Hook、路由、后台标签和相邻插件的成熟模式;插件机制能够完成时,不修改
index.php或核心资源。 - 只在
app/plugins/插件ID/plugin.php内实现插件逻辑,固定 CSS 和 JavaScript 由 manifest 的assets提供;只在少数页面使用的资源按「插件资源按需加载」声明assets_mode并登记。 - 新建插件时验证默认配置、安装、启用、停用和卸载;修改插件时兼容旧配置与旧数据,并至少递增补丁版本;涉及用户可感知能力时同步更新描述。
- 完成后执行 PHP 语法检查、差异检查,并按本文件末尾的清单复核。
基础约束
- 兼容 PHP 8.1、SQLite、MySQL 和 PostgreSQL,不引入框架、Composer 包或构建依赖。
- 插件目录和 manifest
id使用相同 ID,只包含小写字母、数字、下划线或短横线。插件自有的 PHP、CSS、JavaScript、浏览器存储和文件名称必须以该 ID 开头,禁止使用无前缀的通用名称,防止与核心或其他插件冲突。 - 命名空间前缀固定:PHP 函数使用
foo_bar_,常量使用FOO_BAR_,类、接口、Trait、Enum 使用FooBar前缀或包含FooBar的命名空间;JavaScript 函数、顶层变量和全局变量使用foo_bar_;私有 Hook 使用foo_bar.。CSS 类、CSS ID、CSS 变量、data-*属性和自定义事件使用插件 ID 的连字符形式,例如foo-bar-*、--foo-bar-*、data-foo-bar-*。 - manifest 中注册已有核心 Hook 时,必须使用核心定义的原始名称,例如
topic.after_save;只有插件自行定义并通过hook()或fire()调用的私有 Hook 才必须以插件 ID 开头,例如foo_bar.after_import。不得以兼容为由定义或保留无前缀的插件私有别名。 - 禁止以
function_exists()、class_exists()或类似兼容分支定义无前缀的插件函数、类或常量。插件之间不得直接调用对方的函数、类或常量,也不得依赖对方的 CSS/JavaScript;确需共享代码能力时,由提供方插件注册以自身 ID 为前缀的私有 Hook(如foo_bar.render_card),消费方插件通过hook()调用并传入缺省值:提供方未安装或未启用时 Hook 无人注册、原值返回,消费方据此优雅降级,不得出现依赖错误。仅全站通用的能力才移入核心,并使用正式的核心函数或 Hook。 plugin.php开头必须包含if (!defined('APP_ROOT')) exit;。- manifest 必须准确声明
id、name、version、description、author;按需声明assets、assets_mode、hooks、links、routes、admin_tabs、cron、install、uninstall。 description面向普通用户,只说明用户可感知的功能和收益,语言简短易懂;不得写技术实现、协议或依赖、数据库与任务调度等技术名词,也不得写版本更新点或开发说明。- 所有插件源码修改,无论是功能、修复、样式、脚本还是重构,都必须同步提升该插件 manifest 的
version,至少递增补丁版本;不得沿用原版本号。涉及用户可感知能力时,description也必须与当前能力一致。 - 插件读写路径使用
DATA_DIR、PLUGIN_DIR、UPLOAD_DIR等核心常量,禁止硬编码部署目录;缓存和运行数据统一放入DATA_DIR,公开附件地址由附件所属插件生成,共享上传目录分片使用upload_hash_dir()。
最小插件结构:
<?php
if (!defined('APP_ROOT')) exit;
function hello_install(array $plugin): void
{
$t = app_db_types();
app_db_create_table('plugin_hello_items', "id {$t['id']},item_key {$t['key']} NOT NULL UNIQUE,title {$t['string']} NOT NULL,created_at {$t['uint']} NOT NULL");
}
function hello_css(): string
{
return '.hello-message{color:var(--brand);font-weight:600}';
}
function hello_footer($html, array $ctx): string
{
return (string)$html . '<span class="hello-message">Hello</span>';
}
return [
'id' => 'hello',
'name' => 'Hello',
'version' => '1.0.0',
'description' => '在页脚显示问候信息。',
'author' => 'your-name',
'assets' => ['css' => 'hello_css'],
'hooks' => ['page.footer' => 'hello_footer'],
'install' => 'hello_install',
];核心约束:禁止循环内数据库查询(N+1)
这是插件开发的最上位性能约束。它限制的不是“渲染钩子里查一次库”,而是“查库次数随页内条目数线性放大”。判据是量级:查询数若为 O(页内行数)(列表逐行、回帖逐条、同一页重复触发)则为违规;若恒为 O(1)(整页单次触发、批量预取、内存映射)则合规。
平台对帖子列表、回帖等以“逐条触发渲染钩子”的方式渲染(如 topic.after_render 在列数 / 首页每行、reply.after_render 在每个回帖)。若这些反复触发的路径里每渲染一条就查一次库,整页产生与行数相等的 N+1 查询,量级从 O(1) 退化为 O(行数)。
判红标准(触犯即不合格):
- 在
for/foreach/while/ 列表循环 / 回帖逐条循环内,每渲染一条就查一次库(即使用了缓存,只要每个不同 key 的首次查询仍发生在循环里,仍属结构性 N+1)。 - 在帖子、回帖、勋章、列表、用户或统计的循环体中逐条查询关联数据。
不属违规的情形:某个钩子整页只触发一次且不随行数放大(如 topic.after_render 在主题查看页只对主楼执行一次、受 list/marker 限定、只落在详情页单点),允许单次查询;这类“单点渲染”虽可用,仍建议批量预取。不要把「详情页单次查询」误当成违规主体。
任何合规场景一律遵循“先收集、再批查、后映射”三步:
- 先收集整批对象 ID(内存);
- 用分块
IN (...)一次批量读取(SQLite/MySQL/PostgreSQL 通用); - 在内存中按 ID 建立映射,渲染阶段只读内存映射,不再查库。
当“逐条渲染钩子拿不到整页对象集合”(无法在一条查询里覆盖整页)时,使用「唯一占位符 + 页面级批量回填」(见下),而不是退回在循环内逐条查库。
常用方案速查:
| 场景 | 首选方案 |
|---|---|
| 列表 / 批处理 | 收集 ID → 分块 IN (...) → 内存映射 |
| 同一请求重复读同批数据 | 请求级缓存($GLOBALS) |
| 跨请求持久缓存 | save_settings_values() + settings_rows_cache() |
| 逐条渲染钩子、整页对象不可预知 | 唯一占位符 + 页面级钩子整页一次性回填 |
| 绕开整页流程的片段(AJAX 返回局部 HTML) | 就地少量查询直接渲染,不得遗留占位符 |
占位符标准规范
用于“逐条渲染钩子拿不到整页对象集合,却必须禁止循环内查库”的场景。
格式:
<!--{插件ID}-{token}-{主键ID}-->插件ID:manifest 的id,默认小写字母/数字/下划线/短横线,例如medal。token:请求内随机串;同一请求内所有占位符共用一个。用random_bytes()生成 6~12 字节再 hex 化,防止把用户输入反馈中伪造的同形注释误当占位符,也避免碰撞。主键ID:待回填对象的无符号整数主键(uid、帖子 ID 等)。- 占位符整体只能由插件按白名单生成,绝不允许直接用用户输入拼接。
回填流程(必须在“拿到完整整页 HTML”的钩子里完成,例如核心 page.before_render):
- 提点:逐条渲染钩子内只插入唯一占位符,并把对象主键记入请求作用域内存;页内无已启用对象时整页不埋占位符。
- 合并取回:在整页钩子里收集对象 ID 去重,用一条
IN (...)批量取回,构建“主键 → 渲染结果”映射。 - 一次替换:用
preg_replace_callback()以<!--插件ID-token-(\d+)-->为模式(preg_quote()转义 token)整页替换一次;对象不复存在或不应显示的替换为空字符串(让占位符就地消失)。 - 收敛:返回的最终 HTML 必须不含有未替换的占位符。
约束与安全:
- 禁止按“用户名 / 作者名 / 任意字符串”做回填匹配;占位符自带的唯一主键是唯一可靠的定位手段,避免回填错位或破坏结构。
- 对绕过整页模板的响应(AJAX /
json_response直接返回局部 HTML),整页回填钩子不会触发,此时必须就地做少量查询的直接渲染(允许单次小查询),绝不能留下未回填占位符。 - 回填内容与普通内容一样在输出前经
h()转义;占位符由前缀 + token + 数字组成,不含可注入文本。 - 优先级顺序:能整段
IN预取 + 内存映射、请求级/持久缓存时优先;占位符只用于逐条钩子无法整批的例外,且必须配套整页回填钩子。
生命周期与配置
- 新插件放入目录后,需要在后台“插件”页执行“同步插件”。插件注册信息保存在
app_plugins,普通请求不会扫描插件目录。 - 新插件默认停用;只有启用后才执行。市场安装、更新或重新安装后插件会自动停用,再次启用时执行当前版本的
install。 install负责建表、补列和创建索引,且必须可重复执行;Schema 函数只能由install调用,不能出现在普通请求、页面渲染或业务函数中。uninstall只能删除插件明确拥有的表、缓存和文件。无法确认归属的用户内容或附件不得连带删除。- 修改现有插件时,旧配置缺少新字段不能报错;读取配置时集中补默认值,并归一化布尔值、枚举、字符串长度和数值上下限。
- 保存配置前验证
$_POST、$_GET和 JSON 输入,不能把原始请求数据直接交给数据库、文件系统或外部接口。
推荐把配置入口集中为一个函数:
function hello_config(): array
{
$raw = plugin_config('hello', []);
return [
'enabled' => (int)($raw['enabled'] ?? 0) === 1,
'interval_minutes' => min(1440, max(1, (int)($raw['interval_minutes'] ?? 60))),
];
}数据库与性能
- 表结构和跨数据库写入使用
app_db_*;普通参数化查询使用q()、one()、val(),多步关联写入使用tx()保证原子性。 - 插件表使用
plugin_插件ID_前缀,系统表保持app_前缀。字段类型来自app_db_types():ID、外键、计数和时间使用uint,状态位使用普通INTEGER。 - 插件原则上不得修改
app_*核心表结构。确有必要扩展核心表时,新增字段必须使用plugin_插件ID_字段名前缀,禁止使用tags、status等无插件归属的通用字段名;插件自有plugin_*表内部字段不需要重复插件前缀。 - 核心表扩展字段由
install使用app_db_ensure_columns()创建,并在“不保留数据”的uninstall中使用app_db_drop_column()删除;创建和删除都必须可重复执行,禁止直接拼接ALTER TABLE ... ADD/DROP COLUMN。 - 建表、补列、删列、索引和卸载分别使用
app_db_create_table()、app_db_ensure_columns()、app_db_drop_column()、app_db_create_index()、app_db_drop_index()、app_db_drop_table()。 - Upsert 键必须有主键或唯一索引。新增或更新使用
app_db_upsert(),只防重复使用app_db_insert_ignore();新增后使用app_db_last_insert_id('表名'),不要直接调用 PDO 的lastInsertId()。 - 业务去重条件必须与唯一约束完全一致。按规则、频道或周期隔离的数据,唯一键应包含
group_key、channel、period_key等范围字段,不能查询按复合范围判断而表结构只约束单列。 - 多个时间表达式取最大值使用
app_db_greatest(),不要写只适配某一种数据库的 SQL。 - 列表和批处理禁止循环逐条查询。先收集 ID,使用分块
IN (...)一次读取,再在内存中建立映射。 - Hook 优先复用
$ctx和$value。同一请求重复读取的数据使用请求级缓存;允许短暂延迟的数据可使用短期 Cookie 缓存。写入后主动失效相关缓存,不为验证缓存额外查询数据库。 - 需要跨请求持久保存的缓存使用
save_settings_values()写入并通过setting()读取,不生成 PHP 缓存文件。 - 新增或更新主题后调用
topic_fts_sync(),新增或更新回帖后调用reply_fts_sync();禁止直接读写app_topics_fts和app_replies_fts。 - 需要保存可搜索的结构化正文时使用标准 Markdown 表格。单元格换行转为空格,
|写成\|;不要用 Base64 或私有编码隐藏可搜索内容。
典型写入:
app_db_upsert('plugin_hello_items', [
'item_key' => $key,
'title' => $title,
'created_at' => now(),
], ['item_key']);Hook、路由与界面
- Hook 函数通常接收
($value, array $ctx)并返回修改后的值,返回null表示不修改。识别插件自有主题或回帖时,先检查专属内容标识,命中后才查询插件表。 - 顶部栏入口使用
top.bar.actionsHook(每页执行一次),在$value的left、right_before_search或right_after_search数组中以插件 ID 为 key 写入 HTML;不要引入slot属性,不使用 CSSorder、:has()或根据其他插件存在性调整位置。left位于版块导航之后,right_before_search位于搜索框左侧,right_after_search位于搜索框右侧。入口 HTML 应由服务端直接输出,插件 JavaScript 只绑定交互和状态;如需后台控制显示,manifest 使用entries.top_actions。 - 前台页面通过 manifest
routes注册,链接使用route_url();后台页面通过admin_tabs注册。不要硬编码index.php查询串。 - 涉及发帖、回帖、管理或用户数据的路由必须显式检查登录和权限,后台入口调用
need_admin()。修改状态的操作只接受 POST,并调用require_post();表单包含form_token()。 - 所有外部数据和用户数据输出到 HTML 前使用
h()。URL 先由核心 URL 函数生成,再转义。 - 固定 CSS 和 JavaScript 只能通过 manifest
assets声明。资源函数无参数并返回源码,不包含<style>、<script>标签,也不能依赖当前用户、页面、CSRF 或实时请求数据;动态值通过插件 HTML 的data-*属性传递。 - 插件 JavaScript 需要定位核心 Hook 的页面承载元素时,使用
[data-slot~="hook.name"];同一元素可用空格声明多个 Hook 插槽。重复插槽先通过帖子 ID、data-floor、data-plugin-id或插件自有根容器缩小范围,不依赖核心内部层级选择器。 - 不直接修改自动生成的
app/assets/plugins.css和app/assets/plugins.js。启用、停用、卸载、市场安装、更新和后台同步插件时,系统会重建这些资源。只有少数页面才用到的重型资源可改为按需加载,见「插件资源按需加载」。 - CSS 类名、ID、变量、
data-*属性、@keyframes、@property和container-name必须使用插件 ID 前缀;JavaScript 函数、顶层变量、全局变量、自定义事件名、HTMLid和锚点也必须使用对应前缀。生成后的插件 JavaScript 分块由核心自动包进匿名 IIFE 并附带 try/catch(形如(function(){try{ ... }catch(_bbs1Err){console.error('[插件ID]',_bbs1Err)}})();),插件代码因此运行在自身函数作用域内、异常不会外溢,顶层const/let与其他插件互不冲突,插件无需再自行包裹 IIFE;CSS 分块仅加/* 插件ID */注释分隔,不做包裹。即便如此,函数、顶层变量、全局变量、自定义事件仍需加前缀,导出到window的能力必须挂在window.<插件ID>上。Cookie、localStorage、sessionStorage、BroadcastChannel的插件键名同样必须前缀化。选择器限制在插件自己的根容器内;不要覆盖body、通用标签、核心通用类或其他插件类,也不能依赖其他插件的样式。 - 颜色优先使用系统变量:背景和边框使用
--bg、--panel、--line、--line-soft;文字使用--text、--text-muted、--text-subtle、--text-disabled;品牌和交互使用--brand、--brand-hover、--brand-soft、--focus-ring、--focus-halo;状态使用--success、--danger、--warning、--info及对应*-soft;反色、遮罩和阴影使用--inverse、--inverse-border、--inverse-text、--color-dark-rgb(遮罩 RGB 分量源)、--backdrop、--shadow-base、--shadow-medium。 - 界面字号统一使用 CSS 变量:
--font-size-xxs为 10px、--font-size-xs为 11px、--font-size-sm为 12px、--font-size-md为 14px、--font-size-lg为 16px、--font-size-xl为 18px。font-size与font中的字号禁止直接写数字;需要例外字号时先定义语义化变量,再使用该变量。 - 只有还原第三方品牌或表达数据类别时才能在插件作用域内使用额外颜色;禁止无理由使用
!important。 - 前后台界面都要处理窄屏、长文本、空数据、失败、权限不足和交互状态,避免固定宽度导致溢出。
前端 data-slot 接口
核心页面会在稳定的承载元素上输出 data-slot,供插件 JavaScript 查找和绑定交互。属性值以空格分隔;选择单个插槽必须使用 [data-slot~="..."],不能使用模糊的 [data-slot*="..."]。下表是当前核心提供的插槽,名称以源码为准:
data-slot 值 | 页面位置 / 用途 | 相关 PHP Hook |
|---|---|---|
sidebar.feature_links | 首页侧栏“快捷功能”链接列表 | sidebar.feature_links |
top.menu_links | 桌面端顶部版块导航;移动端菜单也复用 | top.menu_links |
user.menu_links | 用户侧栏菜单;移动端菜单也复用 | user.menu_links |
user.card.after_info | 侧栏用户卡片:用户信息与链接区之间 | user.sidebar_card.after_info |
sidebar.stack | 整个侧栏容器 | sidebar.stack |
mainpanel_extra | 主内容面板,扩展内容追加在主内容之后 | mainpanel_extra |
topic.actions | 主题主楼操作条(主楼正文底部,引用、管理等) | topic.actions |
topic.after_render | 主题列表项或主题 | topic.after_render |
reply.after_render | 回帖帖子项 | reply.after_render |
topic.content_after | 主题主楼内容之后的扩展区域 | topic.content_after |
reply.content_after | 回帖楼层内容之后的扩展区域 | reply.content_after |
topic.title_suffix | 主题列表标题链接之后 | topic.title_suffix |
top.actions | 顶部操作栏整体 | top.bar.actions |
top.actions.left | 顶部版块导航右侧的操作区 | top.bar.actions |
top.actions.right.before-search | 顶部搜索框左侧操作区 | top.bar.actions |
top.actions.right.after-search | 顶部搜索框右侧操作区 | top.bar.actions |
page.before_render | 页面主内容 <main> 容器 | page.before_render |
page.footer | 页面页脚容器 | page.footer |
login.after_form | 登录面板(登录表单之后可追加内容) | login.after_form |
login.form_extra | 登录表单内部扩展字段 | login.form_extra |
register.form_extra | 注册表单内部扩展字段 | register.form_extra |
profile.after_form | 个人资料面板(资料表单之后可追加内容) | profile.after_form |
profile.settings_tabs | 个人设置页标签栏 | profile.settings_tabs |
profile.settings_tab_content | 当前个人设置标签的内容区 | profile.settings_tab_content |
user.profile_tabs | 用户资料页标签栏 | user.profile_tabs |
topic.index_tabs | 首页 / 版块主题列表标签栏 | topic.index_tabs |
topic.toolbar_actions | 首页 / 版块主题列表工具栏操作区 | topic.toolbar_actions |
reply.form_extra | 回帖表单内部扩展字段 | reply.form_extra |
attachment.uploader | 发帖或回帖表单的附件上传区域 | attachment.uploader |
admin.plugin.actions | 后台每个插件条目的操作区 | admin.plugin.actions |
admin.tabs | 后台顶栏标签栏 | admin.tabs |
admin.plugins.tabs | 后台“插件”页顶部标签栏 | admin.plugins.tabs |
user.notifications.tabs | 用户通知页「全部 / 收件箱 / 发件箱」标签栏 | user.notifications.tabs |
上表只列核心默认 HTML 中真实输出的 data-slot 值(以源码为准)。以下服务端拼接 Hook 没有 data-slot 承载元素,前端无法用 [data-slot~="..."] 定位,只能在 PHP 侧接管或替换,故不列入上表:page.template / page.template.before、topic.index_template / topic.index_template.before、topic.template / topic.template.before、topic.content_html / reply.content_html(正文管道,承载元素 .post-content 上并无 data-slot)。它们的注册与语义见下方「Hooks 与展示位置」「整体模板 Hook」「帖子操作条与更多弹层」。
同一元素可能声明多个值,例如发帖表单的 data-slot="attachment.uploader topic.form_extra"。JavaScript 示例:
(function () {
const form = document.querySelector('[data-slot~="topic.form_extra"]');
if (!form) return;
form.addEventListener('change', function (event) {
// 只处理插件自己的控件。
if (!event.target.matches('[data-my-plugin-field]')) return;
});
}());data-slot 只保证核心扩展位置和语义,不保证内部子元素层级或每页出现次数。主题列表、主题详情和回帖中的 topic.after_render 可能出现多次,必须结合 id="post-..."、data-floor 或插件自己的根容器缩小范围;页面级插槽通常每页只有一个。通过 AJAX 返回的局部 HTML 也可能重新生成插槽,插件应使用事件委托或在替换后重新初始化。
manifest 注册形式:
'assets' => ['css' => 'hello_css', 'js' => 'hello_js'],
'routes' => ['hello' => 'hello_page'],
'admin_tabs' => ['hello' => 'hello_admin_page'],用 links.admin_tabs 声明后台入口时,这里的 admin_tabs 映射仍必须保留:它才是 Tab 页面的内容回调,links 只负责顶栏入口。
前端公共 API
核心在 index.js 中通过 window.bbs1 暴露一组公共函数,供插件 JavaScript 调用。核心脚本先于插件脚本执行,因此这些函数在插件代码运行时一定可用。调用前请判断存在性,便于核心版本回退时优雅降级:
window.bbs1?.showToast('已保存');| 函数 | 签名 | 说明 |
|---|---|---|
showToast | showToast(message) | 右下角轻提示,约 1.8 秒后自动消失。页面缺少 #toast 时静默忽略。 |
openModal | openModal(title, html) | 打开通用弹层,html 作为内容插入。页面缺少弹层节点时静默忽略。 |
closeModal | closeModal() | 关闭弹层并清空内容;若弹层正等待 openConfirm / openPrompt,该 Promise 以取消值结束。 |
openConfirm | openConfirm(message, title = '确认操作') | 返回 Promise:确定得到 true,取消或关闭得到 false。 |
openPrompt | openPrompt(message, title = '请输入', value = '1') | 返回 Promise:确定得到去掉首尾空白的输入字符串,取消得到 null。 |
openNotify | openNotify(url) | 按核心的私信弹层流程请求 url 并展示。 |
formActionUrl | formActionUrl(form) | 取表单提交地址,缺失时回退到当前页面地址。 |
markButtonPending | markButtonPending(button) | 给按钮加提交中状态;页面恢复(pageshow)时由核心统一清除。 |
注意事项:
- 只用上表列出的函数。核心脚本的顶层
const声明(如modalBody、finishConfirm)属于内部实现,不是契约,可能在版本升级中改名或移除。 - 不要覆盖
window.bbs1,也不要在其中添加插件自己的字段;插件自己的全局导出使用window.<插件ID>形式的前缀命名。 - 需要页面就绪时再初始化:核心脚本以
defer加载,DOM 已可用,但若依赖其他插件渲染的节点,应使用事件委托或MutationObserver。
插件资源按需加载
默认情况下,启用插件的 assets 会被合并进公共合集 app/assets/plugins.css 和 app/assets/plugins.js,全站每个页面都会加载。功能只在少数页面出现的插件(编辑器、播放器、图表、大尺寸样式)会拖慢所有页面,这类插件应改为按需加载。
声明方式:manifest 增加 'assets_mode' => 'ondemand'。值不是 ondemand 时按默认(公共合集)处理,无需为旧插件补字段。
按类型声明:CSS 和 JavaScript 可以分开决定,assets_mode 支持数组:'assets_mode' => ['css' => 'common', 'js' => 'ondemand']。只写需要的类型即可,未写的类型按 common 处理;两类都声明 ondemand 时与字符串 'ondemand' 等价。典型场景是「样式每个内容页都要用、脚本只在编辑页或弹层里用」,让 CSS 留在公共合集、只把 JS 按需化。数组形式只有新内核识别,老内核按默认模式处理(两类都并入公共合集),功能不受影响,只是失去按需效果。
登记方式:在负责渲染该页面的 Hook 里调用 plugin_asset_require('插件ID')。只需要其中一类资源时传第二个参数:plugin_asset_require('插件ID', 'css') 只输出样式、plugin_asset_require('插件ID', 'js') 只输出脚本;不传(或传其它值)表示该插件所有按需类型都要(声明为字符串 'ondemand' 时就是两类)。核心按页面收集并去重,公共合集照常输出,被登记的插件再追加自己的 <link> / <script>;CSS 落在 <head>,JavaScript 在页脚以 defer 加载。未登记的页面完全不加载该资源。
function hello_editor_css(): string
{
return '.hello-editor{min-height:160px}';
}
function hello_editor_js(): string
{
return "document.querySelectorAll('[data-hello-editor]').forEach(function (root) { root.dataset.ready = '1'; });";
}
function hello_editor_form_extra($html, array $ctx): string
{
plugin_asset_require('hello');
return (string)$html . '<div class="hello-editor" data-hello-editor></div>';
}
return [
'id' => 'hello',
'name' => 'Hello 编辑器',
'version' => '1.0.0',
'description' => '在发帖表单里提供一个可复用的输入区。',
'author' => 'your-name',
'assets' => ['css' => 'hello_editor_css', 'js' => 'hello_editor_js'],
'assets_mode' => 'ondemand',
'hooks' => ['topic.form_extra' => 'hello_editor_form_extra'],
];按类型登记:页面只用到样式或只用到脚本时,传第二个参数,避免另一类资源白下载。
function hello_list_badge($html, array $ctx): string
{
if (function_exists('plugin_asset_require')) plugin_asset_require('hello', 'css');
return (string)$html . '<span class="hello-editor-badge">Hello</span>';
}规则:
- 登记必须发生在页面输出之前,也就是插件自己的 Hook 里。核心在整页模板渲染完成后统一收集,因此
page.footer这类页脚 Hook 里的登记同样有效。 - 同一个插件多次登记无副作用,核心自动去重;不同插件之间互不影响,输出顺序按登记顺序。
assets函数仍必须无参数、返回源码、不依赖当前用户或页面数据,与默认模式一致;动态值通过data-*传递。- 按需资源由核心生成到
app/assets/plugins/<插件ID>.css|js,插件不要手写或提交这些文件,升级器也不会覆盖该目录。生成与重建由启用、停用、卸载、市场安装、更新和后台同步自动完成;文件缺失时页面首次访问会补生成。 - 参数是插件 ID,只有已启用且声明为按需模式的插件会被接受;未启用、未声明按需或 ID 非法时静默忽略。插件之间的直接调用仍受「不得依赖其他插件」约束,通常只登记自己。
- 类型过滤只决定输出哪类标签,不拆分按需文件:按需类型的资源仍是
app/assets/plugins/<插件ID>.css和/或.js。plugin_asset_require('插件ID', 'css')只是不再同时输出<script>;同一请求内先后调用不同类型(或后调不带参数)时取并集,只增不减。插件没有声明该类型时不会输出任何标签。 - 混合模式下只有声明为
ondemand的类型会生成独立文件、需要在页面里登记;common类型直接进公共合集,对common类型调用plugin_asset_require()是空操作。plugin_asset_require('插件ID')不带类型时也只登记该插件按需的那几类。 - 兼容老内核时写成
if (function_exists('plugin_asset_require')) plugin_asset_require('插件ID');:老内核根本没有这个函数,任何形式的调用(带不带第二个参数都一样)都会因未定义函数抛错并被自动停用插件;老内核同时会忽略assets_mode、把资源照旧并入公共合集,所以跳过登记后界面依然正常,只是不按需。兜底只包住调用,不要用它定义无前缀函数。 - 页面内的 AJAX 局部 HTML 不会触发资源输出:片段渲染不走整页模板,
plugin-assets标记不会被回填,所以局部 HTML 里不要放资源标签,也不要指望plugin_asset_require()在片段里自动生效。 - 需要给 AJAX 片段补资源时,用
plugin_asset_require('插件ID', 'js')的返回值:它返回本次登记命中的句柄数组(形如['js' => [['id' => '插件ID', 'type' => 'js', 'url' => '/app/assets/plugins/插件ID.js?v=...']]]),可以直接并入自己的响应,由前端按url补<link>/<script>并按id去重。 json_response()也会自动附带本次请求中plugin_asset_require()收集到的句柄:响应顶层增加assets键(结构同上,只含css/js下实际有资源的类型)。调用方自己写了assets时不覆盖;ok为假(如err()的 AJAX 分支)时不附带。核心前端目前只在核心回帖 AJAX 里自动注入assets_css/assets_js(由内核自己写入的响应字段),并不会读取assets:插件请求走自己的前端逻辑时,需要自行按data.assets里的url补<link>/<script>并按id去重。- 从默认模式改成按需模式(或反向)后,系统会在对应插件更新或下次后台同步时迁移资源位置:移入独立文件并删除合集里的旧分块,反向则写回公共合集并删除独立文件。混合模式逐类型迁移:只改
css的类型时只移动 CSS 分块,JavaScript 保持在合集里,反之亦然。迁移不影响其他插件。
何时不要用按需加载:几乎每页都需要的资源(导航、主题列表样式、全局脚本)留在公共合集更划算,否则每页都要多一次请求。只在少数路由出现的功能才适合按需。
资源加载约束(必须遵守):
- 插件的 CSS / JavaScript 只在实际使用该插件功能的页面加载;要按类型分别登记,列表页只用样式就不要顺带输出脚本,详情页才需要的脚本不要在前一页登记。
- 禁止在每页都会触发的全局渲染 Hook 里无条件调用
plugin_asset_require()。属于这一类的是page.before_render、page.head、page.header、page.footer、page.seo、page.template、page.template.before、top.menu_links、top.bar.actions、user.menu_links、sidebar.feature_links、sidebar.stack。 - 功能确实要挂在全局 Hook 上时,必须先判断当前路由、页面类型或 HTML 上下文(
$ctx['route']、$ctx['page_type']、$ctx['plugin_route']、页面里是否存在对应data-*锚点等),命中才登记,不命中直接返回原 HTML。不要自己解析$_GET['a']:美化 URL、后台 Tab、AJAX 与插件路由都已在$ctx中归一化,详见「页面 Hook 通用 ctx」。 - 判断页面范围只能依据实际路由和 Hook 调用关系,不能凭插件名、目录名或文件名猜测;无法安全确定范围的保留现状并在交付说明里写明原因,不要为了「减少请求」删掉真实的全局依赖。
- 主题、皮肤、全局控件这类确实每页都会用到的资源,继续留在公共合集或在全局 Hook 里有明确理由地登记;按需化改造只调整资源登记位置与加载条件,不改变现有功能、页面文案、业务逻辑和后台配置。
安全、文件与外部请求
- 文件名和路径必须经过白名单验证,防止路径穿越。上传和远程文件限制协议、主机、类型和大小,并拒绝内网地址、凭据 URL 和脚本文件。
- 插件自有的目录、文件、锁、临时文件、日志文件和公开附件命名必须包含插件 ID;不得在共享目录创建
cache、lock、log等无前缀的通用名称。 - 外部 HTTP 请求设置连接超时、总超时、重定向上限、响应大小和明确的 User-Agent;跟随重定向时逐跳重新验证目标。
- Cookie、Token、密码和密钥不得出现在页面、日志、错误信息、队列键或公开文件中。
- 错误信息保持简短,不暴露凭据、敏感请求头或完整 SQL。远程失败应可重试,但不能无限同步阻塞用户请求。
- 插件权限等同站点代码,只实现任务需要的访问范围,不读取或修改无关数据。
计划任务、采集与队列
- 计划任务通过 manifest
cron注册。任务名在插件内唯一,callback是已定义的函数名;回调可以不接收参数,也可以接收插件 manifest 和当前任务配置。 interval可以是 60 至 31536000 的秒数,也可以是返回秒数的插件函数名。后台可配置间隔时使用间隔函数。- 只有启用的插件进入统一调度。不要依赖普通页面请求触发任务,也不要添加心跳或 cron 部署探测。
- 回调必须可重复运行,并使用互斥锁、唯一来源键和幂等写入。多请求或可续跑任务使用可重试队列,
queue_key必须唯一,消费索引使用(failure_count,id)。 - 失败任务达到重试上限后默认暂停 30 分钟;暂停到期后清零失败次数并恢复,不能让失败任务永久阻塞队列。
- 任务租约 30 分钟:核心每轮调度会把「运行中但租约已过期 5 分钟」的任务标记为失败并立即允许重跑,卡住的运行日志一并收尾。核心在任务入口会先续租一次,但任务预计超过 5 分钟时仍必须在批处理循环里周期性调用
\app\optional\Cron::cron_lease_touch(),否则租约到期后同一任务可能被并发认领、重复处理。 - 同一批远端记录先收集来源 ID,再批量查询已入库记录和待处理队列;禁止在远端列表循环中逐条查询。
- 去重策略必须匹配数据性质:不可变历史数据按来源键存在即跳过;可刷新数据比较内容指纹,仅在变化时更新内容、搜索索引和
updated_at;周期归档把周期加入唯一键。 - 外部记录保存来源 ID、来源 URL、首次创建时间、最后看到时间和最后更新时间。图片先按规范化来源 URL 的 SHA-256 键复用下载结果,再按文件内容哈希复用附件;不要把长度不可控的完整 URL 作为跨数据库主键。
- 采集正文、图片或回帖必须遵循配置。功能关闭时不应先请求远端再丢弃结果。
计划任务示例:
function hello_cron_interval(): int
{
return hello_config()['interval_minutes'] * 60;
}
function hello_collect(array $plugin, array $task): string
{
// 加锁后执行可重复运行的采集或清理任务。
return 'done';
}
// manifest
'cron' => [
'collect' => [
'callback' => 'hello_collect',
'interval' => 'hello_cron_interval',
],
],跨库 SQL 速查(SQLite / MySQL / PostgreSQL 三驱动)
db_driver() 有三个取值:sqlite、mysql、pgsql。只写 db_driver() === 'mysql' ? A : B 两分支是不合格的:else 一旦写成 SQLite 专有语法,PostgreSQL 站点会直接报错。真实故障例(2026-09-22):$expr = db_driver() === 'mysql' ? "FROM_UNIXTIME(...)" : "date(created_at,'unixepoch')" → PG 报 function date(integer, unknown) does not exist,整个后台「活跃趋势」打不开。
三驱动一律用 match (db_driver()) 显式列出,不要省略 pgsql 分支:
| 需求 | SQLite | MySQL | PostgreSQL |
|---|---|---|---|
| 自增主键 | INTEGER PRIMARY KEY AUTOINCREMENT | INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT | INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY |
| 整数时间戳 → 日期 | date(created_at,'unixepoch')(UTC) | FROM_UNIXTIME(created_at,'%Y-%m-%d')(会话时区) | to_char(to_timestamp(created_at),'YYYY-MM-DD')(会话时区) |
| 分页 | LIMIT n OFFSET m | 同左 | 同左(LIMIT m,n 只有 MySQL/SQLite 认,PG 报 LIMIT #,# syntax is not supported) |
| upsert | ON CONFLICT(键) DO NOTHING / DO UPDATE SET x=excluded.x | INSERT IGNORE / ON DUPLICATE KEY UPDATE x=VALUES(x) | 同 SQLite |
| 大小写不敏感 LIKE | LIKE | LIKE | ILIKE |
优先用核心助手,别自己写驱动分支(自己写就是这次出错的根因):
- 建表类型:
app_db_types()→$t['id']/['uint']/['key']/['string']/['text']; - 标识符引用
app_db_identifier()、占位符sql_marks()、upsertapp_db_upsert()、取刚插入的主键app_db_last_insert_id()(PG 下走pg_get_serial_sequence,不能用lastInsertId())、MAX/GREATEST用app_db_greatest()。
交付检查
- 使用当前项目已有函数和相邻插件模式,没有复制功能重复的基础设施。
- 保持原生 PHP 风格、参数类型明确、分支可读、错误信息简短,不为减少行数牺牲可维护性。
- 普通页面没有新增不必要的数据库查询、外部请求或同步耗时操作。
- 已按 N+1 判据核对:列表循环 / 回帖逐条 / 同一页重复触发的路径内无逐条投库查询;详情页单次渲染与批量预取不算违规;使用占位符的路径已确认最终 HTML 无残留占位符。
- 配置默认值、非法输入、边界值、旧配置和旧数据均可正常处理。
- 数据库占位符、唯一约束、索引、事务和 SQLite/MySQL/PostgreSQL 兼容性已检查:所有
db_driver()分支都显式覆盖pgsql(见「跨库 SQL 速查」),未出现LIMIT m,n、AUTOINCREMENT、date(x,'unixepoch')这类只在 MySQL 或 SQLite 成立的写法。 - 只要动过
plugin.php一行(含注释、空白、行尾),manifest 的version就必须递增;只改核心文件时不要去动插件版本号。漏升的后果是实打实的:后台「可更新」提示由Plugin::plugin_market_update_available()的version_compare($remote, $local, '>')判定,版本号不变就永远不提示更新;插件导出文件名是插件ID_版本.php1,同版本号会互相覆盖。manifest 在文件末尾,核对时以最后的return [...]为准,不要取文件里第一处'version'。 - 插件描述已同步更新,未修改插件职责之外的核心文件或生成资源。
- 按需模式已核对:声明了
assets_mode的插件,每个会用到其界面的页面都在对应 Hook 里调用了plugin_asset_require();只用样式或只用脚本的页面已传'css'/'js'缩小加载范围;未登记该资源的页面确认不加载它,已登记的页面确认标签只出现一次。全局渲染 Hook 中没有任何无条件的plugin_asset_require()调用,确需全局的已在交付说明里写明理由。 - 已运行
php -l app/plugins/插件ID/plugin.php和git diff --check。 - 交付说明列出行为变化、迁移影响、验证结果,以及未执行的外部副作用操作。
界面与图标速查
优先使用核心 svg_icon(),图标继承 currentColor 并自动适配主题。当前常用图标包括:user(用户)、id(身份)、reply(回复)、notify(通知)、forum(版块)、topic(主题)、view(浏览)、settings(设置)、admin(管理)、pages(文档)和 upload(上传)。
插件自带 SVG 应使用 viewBox="0 0 24 24"、fill="none"、stroke="currentColor",主轮廓使用 stroke-width="2",尺寸交由 CSS 控制。需要新图标时优先反馈给核心加入 svg_icon(),避免重复实现。
官方资源
核心 API 速查
| 分组 | 函数 |
|---|---|
| 数据库 | q() one() val() rows_by_ids() row() del() tx(callable) app_db_upsert() app_db_insert_ignore() |
| 表结构 | app_db_create_table() app_db_drop_table() app_db_create_index() app_db_drop_index() app_db_table_exists() app_db_columns() app_db_ensure_columns() app_db_index_exists() |
| 身份权限 | uid() me() need_login() need_admin() need_manage() can_manage() can_manage_topic() can_manage_reply() can_speak() is_super_user() forum_group_allowed() |
| 积分 | user_points_change($user_id, $delta, $reason = '系统调整', $notify = false, $context = [])(自带事务,勿在 tx() 内调用) |
| 页面渲染 | page() shell_html() sidebar_stack_html() sidebar_user_card_html() form_shell() paginate() page_seo() page_head_html() page_nav_html() page_footer_html() admin_list_head() |
| 表单 | form_token() hidden_inputs() input() textarea() checkbox() number_input() select_input() post_action_form() render_form_fields() |
| 跳转/提示 | route_url() admin_url() base_url() go() set_flash() err() json_response() |
| 工具 | h() cut() now() human_time() length_limit() app_cookie() svg_icon() avatar_tag() avatar_link_tag() avatar_remote_url() |
| 论坛数据 | forum_by_id() forums_cache() select_forum() refresh_topic_stats() pinned_topic_ids() create_notification() notification_box() notifications_total() notifications_list() notifications_unread_total() mark_notifications_read() |
| 列表渲染 | topic_list_row($row, $sort) topic_list_select_columns() rows_by_ids() attach_topic_list_users()(列表行会自动整页预载,见「列表行批量预载」) topic_list_preload($rows)(仅行不经 attach_topic_list_users() 等特殊场景需手动调用) |
| 全文检索 | topic_fts_sync() reply_fts_sync() topic_fts_delete() reply_fts_delete() content_search_condition() search_index_available() search_index_rebuild() search_like_pattern() |
| 插件 | plugins() plugin_load() plugin_config() plugin_save_config() plugin_id_valid() plugin_registry_row() plugin_enabled() plugin_uses_entry() plugin_entry_enabled() plugin_call() plugin_asset_require() |
Hooks 与展示位置
| Hook | 触发时机 | 备注 |
|---|---|---|
app.boot | 全站每个请求启动一次 | 预加载数据的最佳位置 |
topic.before_render / reply.before_render | 主题/回帖渲染前 | 红区,调用链零 DB 读 |
topic.after_render / reply.after_render | 主题/回帖渲染后 | 循环内零 DB 读;用于修改楼层 HTML 本身(徽章、样式等) |
topic.content_html / reply.content_html | 主题主楼/回帖楼层的正文 HTML(替换式管道) | 只替换正文 HTML($value 为渲染后的正文),同楼层 .post-content 内的操作条 .post-ops(post.ops_actions 输出)与楼层锚点由核心在该 Hook 之后单独拼装,不在替换范围内;替换/包装正文的唯一可靠位置,插件勿自行 strrpos/正则定位正文;无输出必须原样返回 $value;ctx 带 row/body/topic_id,循环内零 DB 读。 |
topic.content_after / reply.content_after | 主题主楼/回帖楼层正文末尾追加内容 | 追加式管道:返回 $value . 自身输出,无输出必须原样返回 $value(返回空串会覆盖他人输出);插入位置与锚点由核心维护,插件勿自行 strrpos 定位 |
post.ops_actions | 主题主楼与每个回帖的操作条(正文底部) | 逐楼钩子,循环内零 DB 读;条目可选左侧或“更多”弹层,详见下方“帖子操作条与更多弹层” |
topic.before_save / topic.after_save | 主题保存前后 | 处理主题数据 |
reply.before_save / reply.after_save | 回帖保存前后 | 处理回帖数据 |
topic.replies | 主题页回帖集合 | 每页一次,可整体预加载 |
page.before_render | 整页输出前 | 占位符批量回填的唯一可靠位置 |
sidebar.stack | 侧栏组件栈 | value 和返回值均为数组 |
sidebar.feature_links | 侧栏快捷功能 | 非循环展示位置 |
top.menu_links | 顶部版块导航/移动端版块列表 | 链接数组,同一请求一次 |
top.bar.actions | 顶部栏插件入口 | left、right_before_search、right_after_search 三个区域 |
user.profile_tabs | 用户资料页标签栏 | 展示位置:个人主页 Tab |
user.profile_tab_allowed | 用户资料页标签页可见性判定 | 渲染该标签页数据与内容前调用,详见下方“个人主页标签页可见性” |
user.menu_links | 个人卡片与移动端我的菜单 | 展示位置:个人卡片 |
user.sidebar_card.after_info | 侧栏用户卡片:用户信息与链接区之间 | value 为 HTML,ctx 含 user(卡片上的用户行)、self(是否本人);仅登录用户的卡片触发,返回非空时输出在 data-slot="user.card.after_info" 容器内 |
register.form_extra / login.form_extra | 注册/登录表单附加区 | 仅渲染表单扩展 |
profile.after_form | 个人资料页附加区 | ctx 含 user |
profile.settings_tabs | 个人设置页标签栏 | value 为 Tab 数组,ctx 含 user、tab;展示位置:个人设置 Tab |
profile.settings_tab_content | 当前个人设置标签内容 | value 为 HTML,ctx 含 user、tab、tabs;仅在非默认标签触发 |
admin.tabs | 后台顶栏标签 | value 为 items 数组 |
admin.plugins.tabs | 后台“插件”页顶部标签 | value 为 items 数组,后台插件页标题栏(Plugin.php) |
user.notifications.tabs | 用户通知页「全部 / 收件箱 / 发件箱」标签栏 | value 为 items 数组;分箱取值由核心 notification_box() 提供(all/inbox/outbox,收件箱按 recipient_id、发件箱按 sender_id AND kind='direct'),详见下方“通知收件箱/发件箱过滤” |
admin.plugin.actions | 后台每个插件条目的操作区 | 逐插件行追加操作按钮(Plugin.php) |
admin.plugins.view | 后台“插件”页 view 参数的非内置分支整页视图接管 | 返回值即整页 HTML(ctx 带 view、with_tabs);返回非字符串时核心直接 err('你访问的页面不存在', 404),不是“追加 HTML”(index.php) |
notification.after_create | 通知写入后 | 仅入队,勿同步请求外部服务 |
markdown.render / markdown.after | Markdown 渲染前后 | after 可能逐行调用,禁止查库 |
page.seo / page.footer | SEO 元信息/页脚 | 返回值覆盖或追加 |
user.before_save / user.after_save | 用户保存前后 | before 可返回过滤数组 |
逐楼 Hook 的 ctx
topic.before_render / topic.after_render(主楼)与 reply.before_render / reply.after_render(回帖)除 row、body 外统一带以下键,插件不必再读 $GLOBALS 或自己推算:
| 键 | 含义 |
|---|---|
topic_id | 主题 ID;主楼即自身,回帖取其 topic_id |
reply_id | 回帖 ID;主楼为 0 |
floor | 楼层号;主楼为 0,回帖与 reply_position 相同 |
reply_position | 仅回帖存在,等于楼层号;主楼不提供该键,因此可继续用它区分主楼与回帖 |
is_reply | 是否回帖(bool) |
is_first_page | 是否第 1 页(bool);AJAX 新增回帖等不知道页码时为 false |
page / page_size | 当前页码与每页条数;未知时为 0 |
forum_id | 所属版块 ID |
list | 是否列表行;详情页逐楼渲染为 false |
row 仍是原始行数组,body 是原始正文,调用方传入的其他 ctx 键原样保留。topic.content_html / reply.content_html / topic.content_after / reply.content_after 也带同一组键。列表页的 topic.title_suffix 带 topic_id、forum_id、page、page_size、is_first_page、list(true)、sort;topic.before_render / topic.after_render 在列表行触发时同样带这组键。
帖子操作条与更多弹层
内核在主楼和每个回帖的 .post-content 末尾渲染操作条 .post-ops:左侧为动作条目,右侧为楼层锚点(#楼层号 / 主楼“主楼”标签)、更多按钮与弹层 .post-ops-menu,弹层的开关、定位与内置“编辑”“复制链接”由内核处理。
新增动作用 post.ops_actions 钩子(逐楼触发,$ctx 含 row、is_reply、topic_id、floor,主楼 floor 为 0;返回 null 表示不修改):
function demo_ops_actions(array $items, array $ctx): array
{
$items[] = ['html' => '<a class="icon-action icon-pages" href="..."><span>动作</span></a>', 'placement' => 'menu'];
return $items;
}placement:'menu'(默认,右侧弹层)或'left'(操作条左侧)。- 条目用
icon-action+<span>文字</span>即获统一基线;经topic.actions/reply.after_render注入的条目仍在左侧。 - 逐楼钩子,遵守 N+1 红区。
整体模板 Hook
page.template.before、topic.index_template.before 和 topic.template.before 在核心拼接默认 HTML 前执行,初始 $value 为 null;回调返回字符串即可完全接管 HTML 拼接,返回 null 则继续使用核心默认拼接。对应的 page.template、topic.index_template 和 topic.template 在拼接后执行,可继续修改或完全替换最终 HTML。所有模板 Hook 均接收 ($value, array $ctx)。
page.template的$ctx包含title、body、seo、settings、site_name、page_title、meta、head_extra、header_html、flash,以及下方「页面 Hook 通用 ctx」中的四个通用键。topic.index_template的$ctx包含rows、total、page、page_size、offset、forum、user、forum_id、profile_tab、profile_tabs、sort、query、search_field、simple_pagination、has_next_page等列表页数据。topic.template的$ctx包含topic、forum、replies、page、page_size、offset、reply_order、replyid、floor等主题详情数据。
页面 Hook 通用 ctx
所有页面级 Hook 的 $ctx 都带同一组路由信息,用于决定是否注入内容或登记按需资源。不要自己解析 $_GET['a'] 或正则匹配 URL。
| 键 | 类型 | 含义 |
|---|---|---|
route | string | 归一化后的当前路由:parse_path_route() 处理过美化 URL 之后的 $_GET['a'],缺省 home。后台固定为 admin,插件页面为插件注册的路由 ID。 |
page_type | string | 页面类别:home、forum(版块列表)、topic(主题详情)、user(用户主页)、search、admin(含后台各 Tab)、form(login、register、profile、topic_edit、reply_edit)、plugin(插件 routes 注册的页面)、other。 |
is_ajax | bool | 当前请求是否为 AJAX(X-Requested-With: XMLHttpRequest)。注意它只反映请求头,不代表响应是完整页面;AJAX 局部 HTML 不输出资源,见上文「按需加载」。 |
plugin_route | string | 当前路由所属插件 ID;核心路由为空字符串。插件自己的页面判断用 $ctx['plugin_route'] === '插件ID',比匹配 route 更稳。 |
提供这组键的 Hook:page.before_render、page.head、page.header、page.footer、page.seo、page.template、page.template.before(后两者同时保留原有的模板数据键)。routes 注册的页面如果自行拼接 HTML、不调用 page(),这些 Hook 不会触发,也就没有 $ctx。
page_type 只做粗分类,不能区分后台的具体 Tab;后台内需要细分时读 $_GET['tab'](route 仍是 admin)。
topic.index_data.load / topic.replies_data.load 可以提供完整数据;核心仍会继续触发对应的 *.data.loaded Hook。*.data.loaded 回调返回数组时,返回值会作为后续模板的数据;返回 null 表示保留原数据。模板 Hook 适合整体换肤或完全自定义布局,局部扩展优先使用已有的细粒度 Hook。
主题列表的默认查询另有三个细粒度 Hook,只加列、加条件或加排序时不必整段接管 topic.index_data.load:
topic.list_columns:返回附加列名数组,核心与默认列合并后用于列表SELECT。topic.index_data.where:返回附加 WHERE 条件(不含WHERE关键字),核心以AND (...)拼到默认条件之后;COUNT与列表查询共用同一份 WHERE,因此总数和分页保持一致。返回非空时总数改用实时COUNT,不再走缓存的站点统计。只作用于默认主题列表(含用户主页的主题 Tab),不作用于通知、回复列表和回帖搜索分支。topic.index_data.order:返回 ORDER BY 片段(不含ORDER BY关键字),核心把它放在默认排序之前,默认排序保留为并列时的次序;返回空串或null表示不改。
这三个 Hook 的 $ctx 为 forum_id、user、profile_tab、query、search_field、sort、page、page_size、offset。返回的 SQL 片段直接拼接执行,属于受信任的插件代码;不要拼入未校验的用户输入,需要按用户输入过滤或排序时在插件内完成校验与参数化,复杂查询仍用整段接管。
行尾锚点(页面级精准插入用)
核心在 topic_post_row() 输出的每条帖子行(主贴与楼层)的 </li> 之前追加唯一 HTML 注释锚点,供插件在 page.before_render 做整页级定位。行尾结构固定为:
...[content_after 各插件输出]</div><!--ab:post:<主题id>--></li>
...[content_after 各插件输出]</div><!--ab:reply:<楼层id>--></li>- 产出函数:
html_anchor('post', <主题id>)/html_anchor('reply', <楼层id>),两类 kind 永不冲突。 topic/reply.content_after的插入点在「</div>+ 锚点」之前,仍在.post-content内部,与引入锚点前的位置一致。
规则:
- 需要在「主贴正文之后、本行之内」精准落位时,用
preg_quote($anchor, '/')拼进正则匹配到锚点为止(如'/占位符(.*?)<\/div><!--ab:post:<id>--><\/li>/'),禁止用</div></li>之类的通用结构序列定位——任何插件的content_after输出都可能含有该序列,会造成误插。 - 锚点是核心专属契约,插件输出里不得伪造或移除
<!--ab:*-->注释。 - 核心过旧无锚点时,消费方应保留回退逻辑;只在锚点匹配失败时使用。
列表行批量预载:自动登记 + topic_list_preload()
核心的列表预载钩子 topic.index_data.loaded 用于按 IN (集合ID) 一次取回整页主题级数据(众筹、积分商城、微信红包、悬赏、回帖红包、标签、等级、投票、抽奖、猜谜、插件市场…)并写入插件的 $GLOBALS 缓存;逐行渲染钩子(topic.after_render / topic.title_suffix)只读这些缓存。
自行拼装 rows 再逐行 topic_list_row() 的页面无需为此写任何代码,预载已做在核心内部;前提是这份行集合经过 attach_topic_list_users() —— 它既是补用户名/头像的最后一道工序,也是预载的登记点:
attach_topic_list_users($rows)(列表行的最后一道准备工序)顺手登记该行集合;- 第一次
topic_list_row()渲染前,核心合并去重、一次派发topic.index_data.loaded; - 已派发过的行绝不重复派发(批次闸门):页面自己已经显式派发过(例如先按可见性过滤再派发)时,核心不再补发。
实测(like_coin「我的点赞」单页 20 / 50 行):修复前 83 / 198 条 SQL,修复后恒为 13 条(其中 5 条是整页一次的插件预载,与行数无关)。
因此只有两种例外需要手动调用助手 topic_list_preload($rows):行不经过 attach_topic_list_users()(手工拼字段、数据来自外部接口 / 缓存 / JSON 等),或需要在 topic_list_row() 之前就完成预载(例如整段 HTML 先缓存)。
登记点为什么落在 attach_topic_list_users():登记必须同时满足「早于第一次渲染」和「完整行集合已知」两个条件。渲染循环内部(topic.before_render / topic.after_render)只看得到当前行,不满足后者;更上游又没有「查主题行的统一出口」(插件各自写 SQL,核心无从知晓)。attach_topic_list_users() 是核心列表页与插件自建列表页的唯一公共必经点,因此是这两个条件同时逼出来的位置,而不是随意选的。
$rows = ...; // 自己拼装的行(字段同 topic_list_select_columns)
$rows = topic_list_preload($rows); // 整页一次批量预载,行内插件数据在渲染前就绪
foreach ($rows as $row) $html .= topic_list_row($row, 'post'); // 循环内零 DB- 幂等:插件预载只补未缓存的 id,重复调用不会重复查库;空数组直接返回。
- 插件需要兼容老内核时用
function_exists('topic_list_preload')兜底:老内核没有该助手,退回逐行惰性查询而不报错。 - 写行内数据插件时:把数据挂在
topic.index_data.loaded上做批量预取,after_render/title_suffix里只读缓存。这样首页、版块页与任何自建列表页都会自动获得批量预载;反过来,若在行渲染钩子里直接查库(one('… WHERE topic_id=?')),每个列表页都会退化成一页上百条 SQL。
个人主页标签页可见性
插件用 user.profile_tabs 注册标签页后,如需按访问者限制某个标签页是否可见,使用 user.profile_tab_allowed:
- ctx:
user(被访问的用户)、self(访问者是否本人)、tab(当前标签页 key)。 - 返回值:
true或null放行;false拒绝并显示核心默认提示;返回非空字符串则拒绝,并以该字符串作为提示文案。 - 核心在渲染该标签页的数据(
user.profile_tab_data)、头部(user.profile_tab_header)与尾部(user.profile_tab_footer)之前判定一次,拒绝时全部跳过。因此插件无需覆盖他人输出,也不受插件 ID 顺序影响。 - 标签栏本身不会被移除,仍由
user.profile_tabs决定;被拒绝时主区域显示提示文案。
function example_profile_tab_allowed($allowed, array $ctx): mixed
{
$tab = (string)($ctx['tab'] ?? '');
$user = is_array($ctx['user'] ?? null) ? $ctx['user'] : [];
if ($tab !== 'example' || empty($user['id']) || !empty($ctx['self'])) return $allowed;
return '因个人隐私设置,不对外开放访问';
}entries 展示位置
声明对应 Hook 后,后台“插件 → 本地插件 → 展示位置”会出现开关。未声明 entries 时默认勾选;首次同步只补齐缺失值,已有后台选择会保留。
| entries 键 | Hook | 实际显示位置 |
|---|---|---|
feature_links | sidebar.feature_links | 首页侧栏和移动端快捷功能区 |
sidebar_cards | sidebar.stack | 侧栏卡片区域 |
home_tabs | topic.index_tabs | 首页/版块列表顶部 Tab |
profile_tabs | user.profile_tabs | 用户主页顶部 Tab |
profile_settings_tabs | profile.settings_tabs | 个人设置页顶部 Tab |
profile_card | user.menu_links | 侧栏个人卡片和移动端我的菜单 |
topic_actions | topic.actions | 主题首帖操作条(正文底部)左侧 |
admin_tabs | admin.tabs | 后台顶部 Tab |
top_menu | top.menu_links | PC 顶部版块区和移动端版块列表 |
top_actions | top.bar.actions | 版块导航、搜索框前后 |
links 入口数据化
入口只是在导航或 Tab 栏加一条静态链接时,不必注册 Hook,直接在 manifest 的 links 里声明,核心会按原有位置和顺序渲染,并且不会 include 插件文件。links 与旧内核兼容:老内核不认识该字段会忽略它,所以同一插件应同时保留原有 hooks 回调;新内核检测到 links 声明后自动优先使用数据、跳过该入口的回调。
可声明的入口只有 7 个:top_menu、feature_links、profile_card、profile_tabs、profile_settings_tabs、home_tabs、admin_tabs。sidebar_cards、topic_actions、top_actions 返回 HTML 或依赖页面数据,继续用函数 Hook。
'links' => [
'top_menu' => [
['text' => '积分商城', 'route' => 'points_mall', 'when' => ['config' => ['key' => 'enabled', 'default' => true]]],
],
'profile_card' => [
['text' => '我的订单', 'route' => 'points_mall_orders', 'icon' => 'pages', 'when' => ['login' => true, 'self' => true]],
],
'admin_tabs' => [
['key' => 'batch_management', 'label' => '内容', 'route' => 'admin', 'params' => ['tab' => 'batch_management'], 'before' => 'plugins'],
],
],text(Tab 类入口可写label):纯文本,核心输出时统一转义,最多 60 字;和text_config至少写一个。text_config:文案取自插件配置的键名(plugin_config()里保存的键)。配置值非空时用它覆盖text,此时text可写配置缺省值,也可以留空;纯文本、会话前经过截断与去标签,最多 60 字。url或route+params:二选一(同时写时只用url)。url里含控制字符、或显式写了http/https以外的协议(如javascript:、mailto:)时整条丢弃;相对地址和//host/path这类协议相对地址会被放行,后者会解析到站外,使用前确认目标可信。route由route_url()生成,参数键只用小写字母、数字、下划线(最多 10 个)。参数值可以写{ctx}占位符,从当前入口上下文取值:{uid}(当前登录用户)、{self}、{active}、{forum_id}、{query}、{page}、{user.id}、{user.username};取不到值或为空时整条链接不渲染。各入口实际提供哪些键见下方「links上下文(ctx)契约」,写了该入口没有的键会静默不渲染。key:Tab 类入口(admin_tabs/home_tabs/profile_tabs/profile_settings_tabs)用它与$active比较决定高亮。写route时可以省略,核心会拿route当 key;只写url时必须显式给出key(否则整条丢弃)。核心不检查 key 是否与已有 Tab 冲突:同名时这条会覆盖已有 Tab,出现在原位置或被before/after插入的位置上,所以不要占用核心已用的 key。before/after可把该 Tab 插到指定核心 Tab 之前/之后,锚点不存在时追加在末尾。icon:核心图标名(user/id/reply/notify/forum/topic/view/settings/admin/pages/upload)。icon_html可传以<svg开头、</svg>结尾的单行片段(≤1600 字节),标签白名单为svg、g、path、circle、ellipse、rect、line、polyline、polygon、defs、linearGradient、radialGradient、stop、clipPath、mask、title、desc、text、tspan;use、a、image、style、script、foreignObject等其他标签一律拒绝,含on*事件属性、href、xlink、style、javascript:的片段同样拒绝。when:渲染条件,全部满足才显示。login(已登录)、self(当前查看的是本人主页)、admin(true要求可访问后台,false要求不是管理员)、config(插件配置条件)、ctx(入口上下文条件)。config接受单个键名字符串、单个条件数组或条件数组列表;ctx只接受单个条件数组或条件数组列表,直接写键名字符串会被忽略。列表内全部满足才显示,两段各最多 8 条。单个条件形如['key' => 'enabled', 'default' => true],default是缺省值(配置缺省值,ctx 取不到值时的替代值);不写equals时要求该值为真;'equals' => false这类布尔按真假比较(0、'0'、空字符串、null都算假,不只是「空」),其他标量按字符串比较。ctx按当前入口上下文判断,键支持user.id这类点号路径。- 文字可以来自配置(用
text_config);依赖$ctx的显示条件和 URL 参数用when.ctx与{ctx}占位符表达;带未读角标、需要拼装动态文案或调用plugin_asset_require()的入口仍无法数据化,继续用函数 Hook。 - 每个入口最多 20 条,同步时逐字段校验:缺少文案(
text/label/text_config都为空)、缺少或非法的url/route、非法key、非法icon的条目整条丢弃;params与when.config/when.ctx里的非法项只跳过该项,条目保留——某段条件全部被跳过时等于没写该条件,入口会变成无条件显示,写错条件不会报错,只能靠页面上看不出来排查。 - 数据条目只输出核心链接结构,不能再调用
plugin_asset_require();需要插件样式或脚本的入口继续用函数 Hook。 - 同一入口不要同时期待数据和回调都生效:声明
links后该入口的回调会被跳过,回调里的其他逻辑也会一起失效。
links.admin_tabs 必须与 admin_tabs 内容回调同时保留。 links.admin_tabs 只登记后台顶栏 Tab 的文案、链接、高亮 key 和顺序,本身不提供页面内容;点进 Tab 后渲染的内容仍由 manifest 的 admin_tabs 映射指向的函数返回。只写 links.admin_tabs 而删掉 admin_tabs,Tab 能点进去但页面为空;只写 admin_tabs 映射(既没有 links 也没有 hooks['admin.tabs']),顶栏不会出现该 Tab。因此三项同时保留:
'admin_tabs' => ['points_mall' => 'points_mall_admin_page'],
'links' => [
'admin_tabs' => [
['key' => 'points_mall', 'label' => '兑换商城', 'route' => 'admin', 'params' => ['tab' => 'points_mall']],
],
],
'hooks' => ['admin.tabs' => 'points_mall_admin_tabs'],其中 admin_tabs 映射负责内容,links 负责入口,hooks['admin.tabs'] 仅供旧内核注册入口。声明 links 后新内核跳过的只是 admin.tabs 入口回调,admin_tabs 内容回调照常执行;entries.admin_tabs 开关仍决定入口是否展示。
links 上下文(ctx)契约
{ctx} 占位符和 when.ctx 只能写当前入口真实提供的键。键不存在时按空值处理,不会报错:{ctx} 取不到值会让整条链接静默不渲染,when.ctx 按 default / equals 判定后可能静默隐藏,作者很难从页面现象定位。uid 是全局键(当前登录用户),所有入口都可用。
| 入口 | 可用键(除全局 uid) | 说明 |
|---|---|---|
top_menu | user.id、user.username | user 是当前登录用户;游客没有该键 |
feature_links | 无 | 侧栏快捷功能不提供 user / self / forum_id |
profile_card | user.id、user.username、self | user 是个人卡片上的用户,self 表示查看者是本人 |
profile_tabs | user.id、user.username、self、query | user 是被访问的用户,self 表示看的是本人主页 |
profile_settings_tabs | user.id、user.username | user 是当前用户 |
home_tabs | forum_id、query、page | 首页/版块列表的版块、搜索词、页码 |
admin_tabs | active | 当前后台 Tab 的 key |
when.self只在profile_card、profile_tabs生效;写在其他入口会永远不满足。- 上表以外的键(例如在
top_menu写{forum_id}、在feature_links写{self})不会报错,只会让链接不出现,排查时先核对此表。 - 需要“仅登录可见”时优先用
when.login,不要依赖{user.id}在游客下取不到值的副作用。
内容长度自定义配置
核心把长度上限做成站点设置项,由 length_limit(string $type, string $bound): int 统一读取,$bound 取 min / max / value:
- 设置键名:
{type}_min_length、{type}_max_length,value对应{type}_length;未配置时默认0,再由length_limit()夹回硬上限(短字段DB_STRING_MAX_LENGTH,长文本DB_TEXT_MAX_LENGTH),且max自动不小于min。 - 可用
$type:username、email、search、title、bio、topic_body、reply_body、excerpt。后台“设置”页可配置这些项(Admin.php);excerpt_length()是length_limit('excerpt','value')的别名。 - 插件校验用户输入长度时必须复用
length_limit(),不得写死数字或另建一套设置项;按站点配置截断用cut($text, length_limit('...', 'max'))。
通知收件箱/发件箱过滤
用户主页通知页的「全部 / 收件箱 / 发件箱」分箱由核心维护,插件一般只需读取结果:
notification_box()归一化box参数并返回all/inbox/outbox(非法值回退all)。notifications_total($uid, $box)/notifications_list($uid, $size, $offset, $box)按分箱取数:inbox按recipient_id、outbox按sender_id AND kind='direct'、all含非私信通知。- 标签栏本身是
user.notifications.tabsHook(承载元素data-slot="user.notifications.tabs"),插件可在其上追加分类;仅当box === 'all'时核心才标记全部已读。
发布与 AI 协作
- 完成本地验证后,在后台“插件 → 本地插件列表”找到目标插件并点击“分享”。
- 在官方发布页设置售价(
0为免费)、更新日志和协作者权限,然后提交审核。 - 后续修改必须提升版本号并重新测试,再重复分享流程。
给 AI 的最小提示:
请先完整阅读插件开发规范:PLUGIN.md
并检查最接近的现有插件。
请在 app/plugins/<插件ID>/plugin.php 开发插件。
需求:<清楚描述功能、入口、设置项和权限>
遇到不熟悉的功能,不要立即从零实现。先检索 GitHub 上维护活跃的同类开源项目,比较实现思路、依赖和已知问题。优先借鉴经过验证的设计。
完成后请提升 version,执行 PHP 语法检查与差异检查。