Life BS 插件开发文档

适用产品:lifesystem · lifesoso · lifegame(三端内核一致,一次开发三端通用)
文档版本 v3.5 · 最后更新 2026-09-26 · 零基础可读:按顺序读一遍约 20 分钟,照着做即可交第一个插件
v3.5 更新(2026-09-26):新增 5.12 壁纸类插件(wallpaper)——插件只要在 plugin.json 声明 wallpaper,或提供 wallpapers/ 目录,宿主自动识别并在后台「界面背景」设置里列出可选壁纸;勾选「每日自动跟随当天壁纸」后,宿主每天自动取插件当天那张图作站点背景(lifesystem / lifegame 主界面与导航页、lifesoso 首页),取不到静默沿用已选背景。
v3.4 更新(2026-09-14):新增 5.10 组件(components,lifesoso 组件中心)——插件只要在 plugin.json 里声明 components 数组,宿主(lifesoso)自动扫描注册进后台「组件管理」菜单组,用户启用后组件卡片自动出现在 Tab 页;卸载插件时组件自动清理。
v3.3 更新(2026-09-12):5.6 新增 menuGroups 菜单组——多个插件声明同一个组 id 即自动合并(适合「多个小游戏共用一个娱乐组」),含内置图标名、保留字黑名单与合并规则。
📎 本文档是通用完整规范;另有一份「专属开发文档」建议一起用
在开发者中心创建插件后,可在线打开 / 下载该插件的专属开发文档(含你登记的 code、价格与试用状态、按登记填好的 plugin.json 模板、上传与审核流程)。 两份互补:本文档讲「怎么写」,专属文档讲「你这个插件登记了什么」;让 AI 帮忙写插件时请两份一起提供。
前往开发者中心下载专属文档 打印 / 另存为 PDF
v3.0 最重要的两条变化
1. 流程变了:先提交插件资料审核(不需要先做版本),资料审核通过后,你才能上传并安装版本。详见第 9 节。
2. 驳回会写明原因:无论插件资料还是版本被驳回,平台都会填写原因,原因会原样出现在发给你的邮件里。详见第 10 节。
3. 介绍页可以直接上传 HTML 了:插件开发完成后,让 AI(或自己)写一个完整版 HTML 产品介绍页,在「编辑资料」里上传即可,详情页原样内嵌展示——不用再在富文本编辑器里一点点排版。详见第 9 节。
4. v3.3:多个插件可以共用一个左菜单组了:plugin.json 新增 menuGroups,多个插件声明同一个组 id(如 fun)就自动并进同一个「小游戏」组。详见 5.6。 5. v3.4:组件(components)—— lifesoso 组件中心:plugin.json 新增 components 数组,声明后宿主自动扫描进后台「组件管理」菜单,用户启用后卡片出现在 Tab 页;卸载插件时组件自动清理。详见 5.10。
6. v3.5:提交插件必须选择「插件分类」:导航主题 / 小游戏 / 小组件 / 辅助工具(后台可配置),它决定插件在客户端的落位形态,插件库也按分类筛选。LifeSOSO 前台没有左菜单,小游戏 / 小组件在 lifesoso 一律走「组件」呈现。详见 5.11。
7. v3.5:可以写壁纸类插件了:在 plugin.json 声明 wallpaper(或用约定目录 wallpapers/),宿主自动识别并在后台「界面背景」列出可选壁纸,支持「每日自动跟随当天壁纸」。三端宿主最低版本:lifesystem v4.0.8 / lifegame v1.9.2 / lifesoso 3.0.5。详见 5.12。

0. 这份文档在讲什么

你能给 lifesystem / lifesoso / lifegame 这三款产品写插件:一个 .zip 包,用户在自己的站点「插件市场」里点一下就装上。你可以:

你不需要改宿主(那三款产品)的任何源码,也不需要懂它们的框架结构。你只要按约定放好文件、填一份 plugin.json,剩下的由宿主和云端完成。

一句话理解插件:插件 = 一个以 plugin.json 为入口的 PHP 文件夹。宿主读这份清单,决定什么时候加载你的哪个文件。

1. 五个必懂名词

名词是什么你要记住的
宿主被装进去的那套系统(lifesystem / lifesoso / lifegame)不要改它的文件;它给你提供函数(见 5.4)
插件 code插件的唯一标识,如 nav_collect只能小写字母、数字、下划线,2~40 位,不能用连字符;文件夹名、包内 plugin.json、云端登记三处必须一致
plugin.json插件的「说明书」,必须放在 zip 包根级宿主靠它认识你的插件;字段见第 5 节
挂载点(hook)宿主预留的「插槽」,如 login.form.bottom你在 plugin.json 里声明「我要插到这个位置 + 用哪个文件」,宿主负责渲染
版本三段式版本号,如 1.0.0插件资料先过审,之后才能上传版本;版本号只能递增
最容易搞混的一对概念:插件 vs 版本。
「插件」是你在云端登记的商品(名称、简介、价格、适用产品);「版本」是这个商品下的一次代码交付(zip 包 + 版本号)。
现在是两道审核:先审插件资料,再审版本包。资料没过审,版本传不上去。

2. 三端架构(只有一处不同)

三端的内核约定完全一致,差别只有一件事:网站的根目录在哪。理解这一条,你的插件就能三端通用。

产品Web 根目录插件装在哪插件目录能否被浏览器直接访问
lifesoso站点根(/)<root>/plugins/<code>/可以:/plugins/<code>/xxx.php 直接可达
lifesystempublic/<root>/plugins/<code>/不可以:在 public/ 之外,必须走「公开端点」(5.3)
lifegamepublic/<root>/plugins/<code>/不可以:同上

所以写插件时只要遵守两条,就自动三端通用:

3. 五分钟做出第一个插件先跑通这一节

目标:做一个插件,在登录页的表单下方显示一行自定义内容。三端通用,不改任何宿主文件。

  1. 建文件夹 新建 hello_life/(文件夹名就是插件 code,只能小写字母、数字、下划线)。
  2. 写清单 在里面新建 plugin.json:
    {
      "code": "hello_life",
      "name": "Hello Life",
      "version": "1.0.0",
      "summary": "最小示例:在登录页显示一行问候",
      "author": "你的名字",
      "targets": ["lifesystem", "lifesoso", "lifegame"],
      "hooks": {
        "login.form.bottom": "hook_login.php"
      }
    }
  3. 写挂载文件 新建 hook_login.php(宿主会注入 $__hook_name、$__hook_vars 两个变量):
    <?php
    ?>
    <div style="padding:10px;text-align:center;color:#0d9488">
      👋 Hello Life 已挂载(挂载点:<?= htmlspecialchars($__hook_name) ?>)
    </div>
  4. 打包 把这两个文件压成 hello_life.zip,plugin.json 必须在包的根级。
  5. 云端登记 用户门户 → 开发者中心 → 新增插件,code 填 hello_life,填好名称/简介/价格 → 提交资料审核(这步不需要上传 zip)。
  6. 上传版本 资料审核通过后,插件详情 → 发布新版本 → 传 hello_life.zip → 等管理员发布。
  7. 安装验证 版本发布后,在任意一端的「插件市场」安装,打开登录页就能看到那行字。
就这样:没有建表、没有改核心文件、没有判断宿主类型。下面开始讲细节,遇到不懂的再回来查。

4. 插件目录结构

一个插件 = 一个文件夹,最终打包成 .zip(允许包内统一带一层顶层目录,客户端会自动剥离):

hello_life/                # 文件夹名 = 插件 code,必须与 plugin.json 的 code 完全一致
├── plugin.json            # ★必须:插件清单(必须在 zip 根级)
├── hook_login.php         # 挂载文件(在 hooks 里声明)
├── hook_head.php          # 挂载文件
├── ping.php               # 公开端点(在 endpoints 里声明)
├── admin.php              # 后台页面(在 admin 字段声明)
├── install.php            # 安装/升级脚本(建表)
├── uninstall.php          # 卸载脚本(清表)
├── assets/                # 自带 css/js/图片
│   ├── style.css
│   └── logo.png
└── lib/                   # 插件自用 PHP 类
    └── Helper.php
⚠️ 打包红线:包内必须有根级 plugin.json;任何含 ../、.、绝对路径、盘符(:)的条目都会被整包拒绝且零写入(一个文件都不会落盘)。

5. plugin.json 字段全表

字段必填说明与校验规则
code★插件标识。正则 ^[a-z0-9_]{2,40}$:小写字母 / 数字 / 下划线,2~40 位,不允许连字符 -。必须与文件夹名、云端登记的 code 完全一致,否则客户端拒绝安装。
name★展示名称(≤80 字)。
version★版本号。云端要求三段式 1.0.0;客户端校验 \d+(\.\d+){0,3}。
summary一句话简介(≤255 字),市场卡片展示,也是审核重点。
author作者署名(≤80 字)。
icon图标。推荐直接粘贴 SVG 代码(<svg viewBox="0 0 24 24">…</svg>),内联渲染不受外链失效影响;也可填图片 URL。留空则用名称首字生成。
targets适用产品数组,如 ["lifesystem","lifesoso","lifegame"]。填了就必须包含当前产品,否则该客户端拒绝安装;不填 = 不限制。
min_app最低客户端版本(如 3.18.4),低于此版本在市场提示不兼容(软提示,不阻断)。三端版本号互不相干,通用插件通常不填。
admin后台入口文件(插件目录内的 .php,如 admin.php)。声明后市场出现「打开」按钮。见 5.2。
install安装/升级脚本,文件落盘后执行,适合建表。注意:它抛出的异常会被捕获,界面仍显示安装成功(见 8.1)。
uninstall卸载脚本,删目录前执行,适合清理自己建的表。
endpoints公开端点白名单数组,如 ["ping.php","callback.php"]。只有声明过的文件可被免登录访问。见 5.3。
hooks声明式挂载点(对象):键 = 挂载点名,值 = 插件内 php 文件(字符串或数组)。新插件一律用这个。见 5.1。
pages插件多页面白名单数组,如 ["game.php","rank.php"]。声明后可通过页面载入器打开(框架端 ?route=plugin-page&code=xx&file=game.php/lifesoso admin/plugin_page.php?code=xx&file=game.php)。见 5.6。
menu侧栏 / 后台菜单声明数组,插件自己的页面可以直接出现在左菜单里。见 5.6。
menuGroups菜单组声明数组(配合 menu 用)。多个插件声明同一个组 id 就会自动并进同一个菜单组,适合「多个小游戏共用一个『娱乐』组」。见 5.6。
theme主题类插件声明(导航首页主题)。声明后插件会出现在客户端「主题管理」里,用户无需进插件即可一键切换。见 5.7。
components组件声明(lifesoso 组件中心)。数组形式,每项声明一个卡片:code / name / icon / description / render / sort。宿主会自动扫描并注册进后台「组件管理」菜单组,用户启用后卡片自动出现在 Tab 页;卸载插件时组件行自动清理。lifesystem / lifegame 忽略此字段。见 5.10。
用不到的字段直接删掉那一行,不要留空字符串或 null。字段越少,审核越快。

5.1 hooks 挂载点推荐·三端通用

想在前台页面输出内容时,不要改宿主任何文件:在 plugin.json 里声明 hooks,宿主负责渲染。
好处是安装即生效、卸载即消失、客户端升级也不会冲掉你的挂载。

已启用的挂载点

挂载点渲染位置典型用途
head.meta公共布局 </head> 之前统计埋点脚本、插件全局样式
login.form.bottom登录表单内、提交按钮之后第三方登录入口
profile.card个人中心 / 系统设置·个人信息 卡片区末尾账号绑定管理、会员信息卡
footer.script—暂未启用宿主还没预埋,写了不会被渲染

写法

"hooks": {
  "head.meta":         "hook_head.php",
  "login.form.bottom": "inject_login.php",
  "profile.card":      ["inject_profile.php"]
}

值可以是字符串,也可以是数组(一个挂载点挂多个文件,按顺序渲染)。路径只能是插件目录内的 .php 文件,不能带 ..、不能以 / 开头。

挂载文件里能用什么

$__hook_name 当前挂载点名(字符串),可用来判断渲染来源、避免重复输出。
$__hook_vars 调用方传入的附加变量(数组)。
输出方式 直接 echo 或写 HTML,宿主会捕获输出并插入页面。
宿主函数 5.4 里的 db() / h() / currentUser() 等全部可用。
容错机制(也是排错关键):单个插件的挂载文件报错不会影响页面和其它插件——异常只记进站点错误日志,那一处输出变空。
所以「装了不显示」的第一件事永远是:去看站点的 PHP 错误日志。
挂载点需要的最低客户端版本:lifesystem ≥ v3.18.4、lifegame ≥ v1.0.3、lifesoso ≥ 2.8.7。低于此版本 hooks 不生效(插件能装,但不显示)。

5.2 admin 后台页

在 plugin.json 声明 admin 后,用户在市场点「打开」会进入你的 admin.php。宿主载入前会注入三个变量:

变量类型含义
$PLUGINarray插件注册表行:code / name / version / status / mf(mf 就是 plugin.json 解析后的数组)。
$PLUGIN_DIRstring插件绝对目录,如 /var/www/lifesystem/plugins/hello_life。写文件一律落在它里面。
$PLUGIN_URLstring插件目录的相对 URL,用来引用自带资源:<link rel="stylesheet" href="<?= $PLUGIN_URL ?>/assets/style.css">。
两个实操提醒:
① 后台入口必须真实位于插件目录内、且是单个 .php 文件,否则拒绝载入;插件页是过程式 PHP,不要依赖 $this 之类的控制器上下文。
② $PLUGIN_URL 在 lifesoso 是 ../plugins/<code>、在框架端是 /plugins/<code>;框架端只有站点把插件目录映射(软链/别名)到 public/plugins 时资源才可访问。不确定的话,把关键 CSS 直接内联在挂载文件里输出,最稳。

5.3 endpoints 公开端点

框架端(lifesystem / lifegame)的 web 根是 public/,插件目录在公网不可达。插件要对外提供免登录页面(OAuth 回调、打点接收、动态 JS)时,必须走端点路由:

/public/index.php?route=plugin-endpoint&code=插件code&file=ping.php

该文件必须写在 endpoints 白名单里,否则一律 403;插件必须已安装且启用。lifesoso 根即 web 根,插件目录直接可达,同一份代码两端都能工作。

⚠️ OAuth 回调的坑(血泪):第三方平台(QQ / 微信 / 支付宝)固定把授权码参数 code 拼在回调 URL 上,会和本路由的插件标识 code 重名。
宿主的取法是「取 query string 里第一个 code 作为插件标识」,所以你必须保证插件 code 在前、授权码在后,形如:
…?route=plugin-endpoint&code=hello_life&file=callback.php&code=xxxxx
拼 URL 时把插件参数放前面即可。拼接工具见 5.4 的 plugin_url() 思路:不要手写死链。

5.4 宿主给你的现成函数三端同名同语义

三端提供同名同语义的全局函数,插件可直接调用,无需判断宿主类型、无需自带兼容层:

函数说明
db()返回 PDO 连接(已设异常模式)。查询示例见第 6 节。
h($s)HTML 转义。输出任何用户输入时用它。
csrfToken()返回当前会话 CSRF 令牌,放进表单隐藏域。
checkCsrf()校验令牌,失败直接中断。处理 POST 前先调一次。
currentUser()当前登录用户数组(users 表整行),未登录返回 null。
plugin_hook($name, $vars = [])返回该挂载点渲染结果的字符串。
plugin_hook_run($name, $vars = [])直接输出该挂载点内容。
plugin_hook_has($name)判断该挂载点是否有插件监听(布尔)。
\App\Service\PluginHook::render() / run() / has()框架端的面向对象写法,与上面三个函数等价。

宿主业务数据查询(目前仅 lifesystem 提供)

读取宿主模块数据请用下面的惰性查询函数,不要自己写 SQL 查宿主表(表结构属于宿主内部实现,可能随版本调整);调用前用 function_exists() 判断,老版本宿主没有时优雅降级。查询失败一律返回空数组,不会抛异常。

函数说明
lifesystem_nav_groups()公共标签库的分组列表(含一级/二级,parent_id=0 为一级)。字段:id / name / parent_id / icon_type / icon_value / sort_order。
lifesystem_nav_bookmarks()公共标签库的公开书签列表(与宿主导航页同源同序)。字段:id / name / url / description / icon_* / screenshot / hit_count / is_friend_link / group_id / group_name / urls(多地址数组)。
lifesystem_nav_friend_links()友情链接 = 公开书签里 is_friend_link=1 的子集(宿主导航首页底部「友情链接」区块同源)。不要从域名管理模块取友链。
lifesystem_domain_records()域名管理 - 域名列表(含 registrar_name):domain / meaning / reg_date / exp_date / status / price / price_type(fixed一口价|negotiable议价) / sort_order。
lifesystem_domain_alliances()域名管理 - 米友/联盟列表:name / url / icon_path / description / sort_order。
lifesystem_domain_contacts()域名管理 - 联系方式列表(字段以实际表结构为准)。
lifesystem_home_settings()导航首页模块开关与条数:show_domains / show_alliances / show_contacts / show_articles / show_books(bool)、domain_count / alliance_count / article_count / book_count(int)、settings(站点设置原始数组)。主题类插件必须遵守这些开关,否则与后台设置不一致。
lifesystem_books()导航首页应展示的系统书籍(已应用后台开关与条数)。字段:id / title / author / description / cover / file_type(pdf|txt|epub) / file_size / status(complete完结|serial连载中) / created_at / url。后台关闭「首页显示书籍」或没有已勾选的系统书籍时返回空数组。
宿主首页的卡片样式:大图卡片——封面在上(3:4 纵向裁切 object-fit:cover,左上角连载/完结角标、底部格式角标),书名在下(2 行截断)+ 作者一行;无封面时显示灰底占位。
lifesystem_book_url($id)书籍详情页地址($id<=0 时返回列表页地址)。阅读需登录,游客访问会自动落到登录页,主题无需自己判断。
lifesystem_book_list_url()公开书籍列表页地址(首页放不下的系统书籍都在这里,游客可读)。
lifesystem_articles()导航首页应展示的文章(已应用后台开关与条数)。字段:id / title / summary / cover / author / view_count / created_at / updated_at / url(详情页地址),以及两个展示专用字段:thumb(缩略图:优先 cover,其次正文第一张图,都没有则为 ''——主题应显示浅绿占位块)、excerpt(简介:优先 summary,其次正文开头纯文本,已截断 80 字),以及 v3.18.7 新增的分类字段 category_id / category / category_slug / category_url(见 5.8)。后台关闭「首页显示文章」时返回空数组。
宿主首页的卡片样式:横向长方卡——左侧纵向缩略图(96×124,无图时浅绿底 #e7f6ec),右侧标题 + 简介(2/3 行截断)+ 底部一行分类标签 / 阅读量 / 日期 / 作者。
lifesystem_article_url($id)文章详情页地址($id<=0 时返回列表页地址)。
lifesystem_article_list_url()文章列表页地址。
lifesystem_article_categories()文章分类清单(默认只返回有已发布文章的分类),字段见 5.8。
lifesystem_article_category_url($id)某分类的文章列表页地址。
lifesystem_theme_code()当前生效的导航首页主题插件 code(空 = 内置默认主题),见 5.7。
lifesystem_nav_jump_url($id)导航卡片跳转地址(经宿主 nav-jump 路由,宿主会累加点击量)。主题渲染卡片时必须用它,不要直接写外链。
lifesystem_nav_nofollow_attr($url)出站链接的 rel="nofollow" 属性字符串(开启「出站链接加 nofollow」且目标不在白名单时返回 rel="nofollow",否则返回空串)。主题渲染卡片出站 <a> 时应把返回值拼到标签上,以同步宿主「导航设置 → GO 链跳转」的 nofollow 设置,不要自己硬编码 rel。$url 传最终外部网址(如 $b['url']),不是站内中转链接。
lifesystem_search_engines()导航首页搜索框应展示的搜索引擎列表(已应用后台「搜索引擎 → 显示设置」开关)。字段:id / name / icon / url_template(含 {q} 占位)/ sort_order。主题渲染搜索框标签页时必须用它,不要硬编码百度/谷歌——否则与后台配置不一致。
lifesystem_search_engine_url($id, $keyword)拼接某搜索引擎的最终跳转地址(关键词自动 urlencode)。url_template 里用 {q} 表示关键词,没写 {q} 时自动拼到末尾。引擎不存在 / 模板为空时返回空串。

本节点清三端的菜单是怎么来的、「我新写的功能/小游戏怎么出现在菜单上」、以及各端能给你的数据接口。三端机制不同,分开说。

① 三端菜单机制总览

端菜单在哪定义菜单形态与插件的关系
lifesystem templates/layout_main.php(左侧栏分组 + 悬浮子菜单)
templates/layout_public.php(游客导航条)
分组式悬浮菜单:一个 sidebar-flyout-trigger(id="flyout-xxx")对应一个 sidebar-flyout(id="flyout-xxx-menu"),按 id 自动配对,JS 通用绑定,新增组不用写 JS。菜单项 = 一个 route。
插件不会自动出现在侧栏:插件入口在「插件市场 → 我的插件 → 打开」。
lifegame templates/layout_main.php(list-group 分组:模拟经营 / 休闲棋牌 / 线下娱乐 / 系统) 与 lifesystem 同一套悬浮菜单机制,只是分组内容不同。新增一个小游戏=新增一个 route + 控制器 + 模板 + 一条菜单项。
lifesoso admin/inc.php 里的 $nav 数组(扁平,顶部横排) 一维数组:'key' => ['页面文件', '菜单名', 'SVG图标']。key 同时是权限位——客服组/运营组能否看到由 adminMenus() 决定;license / updater / plugins / logs 不在其中,对非管理组恒隐藏。插件入口在「插件市场」。

② 在 lifesystem / lifegame 新增一个菜单项(含新小游戏)

以 lifegame 加一个小游戏为例,lifesystem 完全同构。一共 5 步:

步骤改哪个文件做什么
1src/Controller/XxxController.php新建控制器,方法名对应动作(如 index() / api())。渲染用 $this->render('xxx/index', [...]),管理页务必先做管理员校验。
2templates/xxx/index.php页面模板;后台页由 layout_main.php 包裹,前台页由 layout_public.php 包裹。
3public/index.php加一条 case 'xxx': 分发;同时把 'xxx' => '页面标题' 加进 $pageTitles。
免登录页(给游客看的)还要把 route 加进「未登录也可访问」白名单数组,否则会被重定向到登录页。
小技巧:未匹配到的 route 会走动态分发(base-action → BaseController::action()),命名规范的话可以不用写 case。
4templates/layout_main.php在对应分组的 sidebar-flyout 容器里加一条 <a class="flyout-item" href="?route=xxx">;要新建分组就加一对 flyout-xxx / flyout-xxx-menu(id 必须成对)。
5手机端入口(如有)lifesystem / lifegame 的手机抽屉菜单是另一份模板,别漏加,否则手机上看不到入口。
插件能不能自己往侧栏加菜单?能(v3.18.7 / v1.0.4 / lifesoso 2.8.8+)。 在 plugin.json 里声明 menu(配合 pages 白名单)即可,不需要宿主改任何文件,宿主自动渲染,卸载自动消失。详见 5.6 插件菜单。
上面 5 步讲的是宿主自己加内置功能菜单的流程——那是另一回事:插件请用 5.6,不要改宿主文件。

③ 在 lifesoso 新增一个后台菜单项

改 admin/inc.php 的 $nav:加一行 'key' => ['xxx.php', '菜单名', '<svg …>'],,然后在 admin/ 下放 xxx.php 页面(顶部 require __DIR__ . '/inc.php'; 走统一登录与权限门禁)。

如果希望客服组/运营组也能看到,还要把该 key 加进 adminMenus() 的授权清单;不加则只有管理组可见(license / updater / plugins / logs 就是这么处理的)。

④ 各端能给插件的数据接口速查

端可用接口说明
三端通用 db() / h() / csrfToken() / checkCsrf() / currentUser() / plugin_hook() 见 5.4;由 src/plugin_compat.php(框架端)与 common.php(lifesoso)提供,三端同名同语义。
lifesystem lifesystem_nav_groups()公共标签库分组(一级/二级)。
lifesystem_nav_bookmarks()公共标签库书签(卡片数据)。
lifesystem_nav_friend_links()友情链接(= 书签里 is_friend_link 的子集)。
lifesystem_domain_*() / lifesystem_articles() / lifesystem_books() / lifesystem_home_settings()域名、米友、联系方式、文章、系统书籍、首页模块开关。
lifesoso plugin_cloud_url() / plg_*(plugin_core.php)云端地址与插件市场交互(安装/升级/卸载/反馈)。
cacheGet() / cachePut() / setting() / fetchUrl() / fetchMulti()缓存、站点设置读、HTTP 抓取(fetchMulti 为并发版)。搜索类插件优先复用这些,不要自己造轮子。
lifegame \App\Service\* / 各游戏 Modellifegame 的游戏数据都走 Model 层(如房间、积分、战绩)。插件不直接读游戏表,需要宿主数据时请让宿主补一个惰性查询函数,规则同 5.4:function_exists() 守卫 + 失败返回空数组。

⑤ 主题类插件的对接契约(重要)

导航主题类插件(接管控航首页外观)必须遵守,否则生态里已有插件会静默失效:

为兼容更老的客户端,保险写法是加一层守卫:if (!function_exists('db')) { /* 自带 PDO 兜底 */ }。这样即使装在未升级的站点上也不会白屏。
不要硬编码后台链接。三端后台入口不同:lifesoso 是 admin/plugin_run.php?code=,框架端是 ?route=plugin-run&code=。硬编码会在另一端全部 404;请从当前请求推导,或直接用宿主函数。

5.6 插件菜单(menu / menuGroups / pages)把小游戏挂到左菜单

做小游戏 / 娱乐页的插件,最想要的就是「我的页面直接出现在左侧菜单里」。做法:页面文件放进 pages 白名单,再在 menu 里声明入口,宿主负责渲染。
核心内置了一个「小游戏」菜单组:你只要把 menu[].group 写成 "小游戏"(兼容 "娱乐" / "entertainment"),插件就会自动归类到这个组——不需要自己写 menuGroups。多个小游戏插件都写 "小游戏",就自动并成一个「小游戏」组,各占一行。
只有当你想要一个自定义名称 / 图标 / 排序的专属组时,才需要用到 menuGroups(见 5.6.1)。

plugin.json 写法(以 lifegame 小游戏为例)

{
  "code": "doudizhu",
  "name": "斗地主计分",
  "version": "1.0.0",
  "pages": ["game.php", "rank.php"],
  "menu": [
    { "title": "斗地主",     "route": "plugin-page&code=doudizhu&file=game.php", "icon": "🃏", "group": "小游戏", "sort": 10 },
    { "title": "斗地主战绩", "route": "plugin-page&code=doudizhu&file=rank.php", "icon": "🏆", "group": "小游戏", "sort": 20 }
  ]
}

上面的 "group": "小游戏" 直接命中核心内置的「小游戏」组(图标 game、排序 10),无需声明 menuGroups。另一个小游戏插件(如「麻将计分」)照写 "group": "小游戏",它的菜单就会自动并进同一个「小游戏」组——不需要任何协商。

字段必填说明
id★组 id,只允许小写字母 / 数字 / 下划线 / 连字符([a-z0-9_-],≤24 位),大小写不敏感。这是多个插件合并成同一组的唯一依据;非法 id 的组定义会被丢弃。
name组显示名,最长 8 字(超长自动截断)。留空则用 id 当名字。
icon组图标:内置图标名或 emoji 二选一。内置名(线框风格,与宿主菜单一致):game puzzle tool star music book chart coin heart bolt camera gift globe play chat shield tag flag clock users。填别的(或 emoji)就按纯文本渲染。
order组排序,越小越靠前,默认 100。
合并规则(跨插件):组 id 相同的会并成一组;合并后的显示名 / 图标取 order 最小的那个声明(order 相同取先安装的)。所以同一组 id 请各插件写一致的 name / icon,避免显示名来回变。
四条硬规矩:
「插件应用」默认组 + 按需显示(懒渲染):

menu 单项字段

字段必填说明
title★菜单名,≤20 字,会被剥标签。
route★站内路由。框架端写 plugin-page&code=xx&file=yy.php(index.php 的 query,不带 ?);lifesoso 后台写 plugin_page.php?code=xx&file=yy.php。
安全红线:禁止协议(javascript:、http://、//evil.com),只允许站内相对路径 + 安全 query 字符,不合规的项会被静默丢弃。
icon只接受纯文字 / emoji / 内置图标名(≤4 字符),不支持 HTML/SVG——防 XSS。留空显示 🧩。内置名同 menuGroups(game、star……)。
group填 menuGroups 里声明过的组 id(推荐);也可直接填组名(≤8 字,同名合并)。留空进默认组「插件应用」(lifesoso 默认「插件」)。
sort排序,越小越靠前,默认 100。
admintrue = 仅管理员可见(框架端生效)。默认所有登录用户可见。
target填 _blank 则新窗口打开(自动带 rel="noopener")。
public_landinglifesystem 公开导航首页入口开关。填 true 时,该插件的入口会出现在 lifesystem 的公开导航首页「小游戏」区块(需后台「域名管理 → 导航首页显示设置」开启小游戏显示)。
条件:① 插件已启用;② menu 里有 group 为 "小游戏" / "娱乐" / "entertainment" 的项(优先取它),否则取第一个 menu 项;③ 后台总开关开启。默认不显示。

各端表现

端渲染位置访问门槛
lifesystemPC:左侧栏「工具箱」之后、「系统」之前,每个菜单组 = 一个可展开分组(悬停展开,样式与宿主自带分组一致)。
移动端 / APP:每个菜单组 = 一个一级菜单,组内页面自动变成顶部 tabs——多个插件共组后只占一个入口
plugin-page 路由:已登录即可(不要求管理员)。未登录跳登录页;插件被停用则提示联系管理员。
只有 pages 里声明过的文件能打开,admin 入口不会因此对外开放。
lifegame同上(PC 在「麻将计分」之后、「系统」之前;移动端 / APP 同 lifesystem)
lifesoso后台侧栏末尾($nav 之后)按组分块:组标题 + 组内菜单项,平铺展示admin/plugin_page.php:仅管理组(与插件市场一致,客服/运营组不展示插件菜单)。
最低客户端版本:
 · menu:lifesystem ≥ v3.18.7、lifegame ≥ v1.0.4、lifesoso ≥ 2.8.8。
 · menuGroups:lifesystem ≥ v3.18.8、lifegame ≥ v1.0.6、lifesoso ≥ 2.8.9。
 · 内置「小游戏」组(直接写 group:"小游戏" / "娱乐" / "entertainment" 均可归入,无需 menuGroups):lifesystem ≥ v4.0.7、lifegame ≥ v1.9.1;lifesoso 不支持(其菜单机制独立,小游戏在 lifesoso 需自行用 menuGroups 声明组)。
老版本不认识这些字段,插件照常能装、能从市场「打开」——menu 只是不出现,menuGroups 会被忽略(菜单项退回按 group 字面名分组),不会报错。
三条容易踩的坑:
页面文件里可用的上下文与 admin 入口完全一致:$PLUGIN / $PLUGIN_DIR / $PLUGIN_URL / db() / h() / currentUser() 等,页头页脚由宿主统一包裹,你只输出页面主体。

基础框架(复制即可起步,小游戏插件)

下面就是最小可跑的小游戏插件骨架:plugin.json(只写 group:"小游戏" 即归入内置小游戏组,不用写 menuGroups)+ game.php(页面主体)。直接抄进 plugins/你的code/,到插件市场启用,左侧菜单就会出现「小游戏 → 你的游戏」。它已遵守 5.6 全部约定(pages 白名单、route 安全、页面只输出主体)。

{
  "code": "clicker",
  "name": "点击计分",
  "version": "1.0.0",
  "author": "你的名字",
  "pages": ["game.php"],
  "public_landing": true,
  "menu": [
    { "title": "点击计分", "route": "plugin-page&code=clicker&file=game.php", "icon": "🎮", "group": "小游戏", "sort": 10 }
  ]
}
<?php
/**
 * 小游戏页面骨架(示例:点击计分)
 * 宿主已包裹页头页脚,这里只输出页面主体
 */
$title = $PLUGIN['name'] ?? '小游戏';
?>
<div class="game-wrap">
  <h2><?= htmlspecialchars($title, ENT_QUOTES) ?></h2>
  <p class="mut">点一下按钮,看看手速:</p>
  <div class="score" id="score">0</div>
  <button class="btn" id="hit">点我 +1</button>
</div>
<style>
  .game-wrap{max-width:420px;margin:1.5rem auto;text-align:center}
  .score{font-size:3rem;font-weight:700;margin:.6rem 0;color:#2f6df6}
  .btn{padding:.6rem 1.4rem;border:1px solid #2f6df6;background:#2f6df6;color:#fff;border-radius:8px;cursor:pointer;font-size:1rem}
  .btn:active{transform:scale(.96)}
  @media (prefers-color-scheme:dark){.score{color:#7aa2ff}.btn{background:#3b5bdb;border-color:#3b5bdb}}
</style>
<script>
  let n=0;const el=document.getElementById('score');
  document.getElementById('hit').addEventListener('click',function(){n++;el.textContent=n;});
</script>
给 AI 写插件的提示词可以这样写:「帮我写一个 lifesystem 小游戏插件,plugin.json 的 menu 里把 group 写成 "小游戏",让它自动归入内置的小游戏菜单组(不要写 menuGroups);页面文件放进 pages 白名单,只输出页面主体,宿主会包页头页脚。」AI 照本段改即可,无需理解菜单合并细节。

5.7 主题插件(theme,接管导航首页)lifesystem ≥ v3.18.7

主题类插件用来自定义公开导航首页的页面主体(卡片布局、配色、模块排列)。和菜单一样是声明式的:插件声明 theme,宿主负责渲染,插件不改宿主任何文件。

plugin.json 写法(三种都兼容,推荐对象写法)

{
  "code": "glow_nav",
  "name": "光影导航主题",
  "version": "1.0.0",
  "theme": { "scope": "landing", "entry": "theme.php", "name": "光影导航" },
  "admin":  "admin.php",
  "pages":  ["theme.php"]
}
简写也支持:"theme": "landing" 或 "theme": true(等价于 scope=landing + entry=theme.php)。

用户在哪儿切换

lifesystem 后台左侧栏「主题管理」(管理员可见)会列出所有已启用且声明了 theme 的插件,一张卡片一个主题,点「启用此主题」即生效,也可一键「恢复默认主题」。手机上在抽屉菜单的「主题管理」组里。
当前生效的主题存在站点设置 landing_theme(存插件 code,空 = 内置默认主题)。用户不用进插件内部设置——插件自己的设置页仍可通过卡片的「主题设置」按钮进入(要求插件声明了 admin)。

主题文件怎么写(关键约定)

项说明
只输出页面主体顶部导航条与页脚由宿主 layout_public.php 提供。不要输出 <html>/<head>/<body> 标签——输出会被包进宿主的页面容器里。
可用变量$themePlg(当前主题信息:code / name / version / author / dir / url / entry)、$systemSetting、$appVersion、$publicBookmarks、$groups、$friendLinks、$domains、$alliances、$contacts、$articles、$books、$articleCategories、$searchEngines(导航首页搜索框应展示的搜索引擎,已应用后台「显示设置」开关;元素字段:id / name / icon / url_template(含 {q} 占位)/ sort_order)。
推荐取数方式用宿主惰性函数(5.4 / 5.8 / 5.9),已经自动应用后台开关与条数:lifesystem_home_settings()、lifesystem_nav_bookmarks()、lifesystem_nav_groups()、lifesystem_articles()、lifesystem_article_categories()、lifesystem_books()、lifesystem_domain_*()、lifesystem_search_engines()。
必须保留① 搜索框 .search-box / #searchInput(热点热榜插件靠它定位插入位);② head.meta 挂载点在公共布局里,不要替换整个 layout(站点统计靠它注入埋点)。
必须遵守lifesystem_home_settings() 的模块开关(show_domains / show_alliances / show_contacts / show_articles / show_books):后台关掉的模块主题也不要显示。卡片跳转用 lifesystem_nav_jump_url($id) 以正确计数点击量;同时把 lifesystem_nav_nofollow_attr($b['url']) 拼到卡片出站 <a> 上,以同步后台「GO 链跳转 → 出站链接加 nofollow」设置。禁止在主题里自写 <title> 或 <meta name="keywords/description">(宿主已在 layout_public.php 注入,重复输出会冲突)。
兼容旧客户端主题是不可用的老版本上不会报错(不认识 theme 字段,只是不生效)。建议文件开头加守卫:if (!function_exists('db')) { /* 自带 PDO 兜底 */ }。
容错主题渲染抛异常时宿主自动回退内置默认主题并写错误日志,不会白屏。插件被停用 / 卸载后自动回退,不留死链。
多主题并存:一个站点可同时安装多个主题插件,同时在后台切换;同一时刻只有一个生效(landing_theme 单值)。主题插件之间的样式请用自己独有的类名前缀,避免互相污染。
配套:文章分类(5.8)——主题常用「分类」做首页分类导航;分类由后台「文章管理 → 分类管理」维护,主题直接调 lifesystem_article_categories() 即可。

基础框架(复制即可起步)

下面两份文件就是最小可跑的主题插件骨架:theme.php 渲染导航首页主体,admin.php 是可选的主题设置页。直接抄进你的插件目录、改样式即可上线;它已遵守 5.7 全部契约(只输出主体、保留搜索框 #searchInput、尊重模块开关、卡片跳转计数)。本地自测时把这两份 + 上面的 plugin.json 放进 plugins/你的code/,到「主题管理」启用即可。

<?php
/**
 * 主题入口:公开导航首页主体(示例骨架)
 * 只输出页面主体;顶栏/页脚由宿主 layout_public.php 提供
 * 已遵守 5.7 契约:保留搜索框 #searchInput、尊重模块开关、卡片跳转计数
 */
// 旧客户端兜底:主题不可用的老版本不会报错,只是不生效
if (!function_exists('lifesystem_nav_jump_url')) {
    function lifesystem_nav_jump_url($id) { return '/public/index.php?route=nav-jump&id=' . (int)$id; }
}

$bookmarks   = $publicBookmarks ?? [];
$groups      = $groups ?? [];
$articles    = $articles ?? [];
$books       = $books ?? [];
$domains     = $domains ?? [];
$alliances   = $alliances ?? [];
$contacts    = $contacts ?? [];
$friendLinks = $friendLinks ?? [];

// 模块开关(宿主已按后台过滤传入数组;这里再显式判断双保险)
$hs = function_exists('lifesystem_home_settings') ? lifesystem_home_settings() : [];
$on = function($k) use ($hs) { return empty($hs) || !empty($hs[$k]); };
$showDomains   = $on('show_domains');
$showAlliances = $on('show_alliances');
$showContacts  = $on('show_contacts');
$showArticles  = $on('show_articles');
$showBooks     = $on('show_books');
?>
<style>
.tw-wrap{max-width:1100px;margin:0 auto}
.tw-sec{font-weight:600;font-size:1.05rem;margin:1.4rem 0 .7rem;color:#0f172a}
body.theme-dark .tw-sec{color:#e2e8f0}
.tw-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.tw-card{display:block;padding:14px;border:1px solid #e2e8f0;border-radius:14px;background:#fff;color:#0f172a;text-decoration:none;transition:.18s}
.tw-card:hover{border-color:#0d9488;box-shadow:0 8px 22px rgba(13,148,136,.12)}
body.theme-dark .tw-card{background:#1e293b;border-color:#334155;color:#e2e8f0}
body.theme-dark .tw-card:hover{border-color:#2dd4bf}
.tw-search{max-width:640px;margin:0 auto 28px;position:relative}
.tw-search .row{display:flex;gap:8px;background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:6px}
body.theme-dark .tw-search .row{background:#1e293b;border-color:#334155}
.tw-search input{flex:1;border:none;outline:none;background:transparent;padding:10px;font-size:1rem;color:#0f172a}
body.theme-dark .tw-search input{color:#e2e8f0}
.tw-search button{background:#0d9488;color:#fff;border:none;border-radius:10px;padding:0 18px;cursor:pointer}
#twResult{position:absolute;left:0;right:0;top:100%;margin-top:8px;background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;display:none;z-index:50}
body.theme-dark #twResult{background:#1e293b;border-color:#334155}
#twResult a{display:block;padding:10px 14px;color:#0f172a;text-decoration:none;font-size:.9rem}
body.theme-dark #twResult a{color:#e2e8f0}
</style>

<div class="tw-wrap">
  <div class="tw-search">
    <div class="row">
      <input id="searchInput" placeholder="搜索导航站点..." autocomplete="off" oninput="twSearch(this.value)">
      <button onclick="twGo()">搜索</button>
    </div>
    <div id="twResult"></div>
  </div>

  <?php if (!empty($inviteMsg)): ?><div class="alert alert-success small py-2"><?= htmlspecialchars($inviteMsg, ENT_QUOTES) ?></div><?php endif; ?>

  <?php if (!empty($groups)): foreach ($groups as $g):
      $items = [];
      foreach ($bookmarks as $b) { if ((int)($b['group_id'] ?? 0) === (int)($g['id'] ?? 0)) $items[] = $b; }
      if (empty($items)) continue;
  ?>
    <div class="tw-sec"><?= htmlspecialchars($g['name'], ENT_QUOTES) ?></div>
    <div class="tw-grid">
      <?php foreach ($items as $b): ?>
      <a class="tw-card" href="<?= htmlspecialchars(lifesystem_nav_jump_url($b['id']), ENT_QUOTES) ?>">
        <?= htmlspecialchars($b['name'], ENT_QUOTES) ?>
      </a>
      <?php endforeach; ?>
    </div>
  <?php endforeach; endif; ?>

  <?php if ($showArticles && !empty($articles)): ?>
    <div class="tw-sec">最新文章</div>
    <div class="tw-grid">
      <?php foreach ($articles as $a): ?>
      <a class="tw-card" href="/public/index.php?route=article-detail&id=<?= (int)$a['id'] ?>"><?= htmlspecialchars($a['title'], ENT_QUOTES) ?></a>
      <?php endforeach; ?>
    </div>
  <?php endif; ?>

  <?php if ($showBooks && !empty($books)): ?>
    <div class="tw-sec">在线书库</div>
    <div class="tw-grid">
      <?php foreach ($books as $bk): ?>
      <a class="tw-card" href="/public/index.php?route=books-detail&id=<?= (int)$bk['id'] ?>"><?= htmlspecialchars($bk['title'], ENT_QUOTES) ?></a>
      <?php endforeach; ?>
    </div>
  <?php endif; ?>

  <?php if ($showDomains && !empty($domains)): ?>
    <div class="tw-sec">域名清单</div>
    <div class="tw-grid">
      <?php foreach ($domains as $d): ?>
      <a class="tw-card" href="<?= htmlspecialchars($d['url'] ?? '#', ENT_QUOTES) ?>"><?= htmlspecialchars($d['domain'], ENT_QUOTES) ?></a>
      <?php endforeach; ?>
    </div>
  <?php endif; ?>
</div>

<script>
var twData = <?= json_encode(array_map(function($b){return ['id'=>(int)($b['id']??0),'name'=>(string)($b['name']??''),'url'=>(string)($b['url']??'')];}, $bookmarks), JSON_UNESCAPED_UNICODE) ?>;
function twSearch(kw){
  kw=(kw||'').trim().toLowerCase();
  var box=document.getElementById('twResult');
  if(!kw){box.style.display='none';return;}
  var m=twData.filter(function(s){return s.name.toLowerCase().indexOf(kw)>=0;}).slice(0,8);
  box.innerHTML=m.length?m.map(function(s){return '<a href="'+s.url+'">'+s.name+'</a>';}).join(''):'<div style="padding:10px 14px;color:#94a3b8">无匹配</div>';
  box.style.display='block';
}
function twGo(){var v=document.getElementById('searchInput').value.trim();if(v)window.open('https://www.baidu.com/s?wd='+encodeURIComponent(v),'_blank');}
document.addEventListener('click',function(e){var b=document.getElementById('twResult');if(b&&e.target.id!=='searchInput')b.style.display='none';});
</script>
上面的 theme.php 已经是「整页主体」:宿主在主题激活时直接 include 它(layout_public.php 第 111 行),顶栏/页脚/统计埋点由宿主注入。搜索框 #searchInput 必须保留(热点热榜等插件靠它定位插入位)。样式用纯色、明/暗都避开渐变,符合规范。

主题设置页 admin.php(可选)

在 plugin.json 声明 "admin":"admin.php" 后,「主题管理」卡片会出现「主题设置」按钮直达本页。宿主注入 $PLUGIN / $PLUGIN_DIR / $PLUGIN_URL;设置存插件目录内的 theme_options.json,不碰宿主任何表。

<?php
/**
 * 主题设置页(可选):plugin.json 声明 "admin":"admin.php" 后出现
 * 宿主注入变量:$PLUGIN / $PLUGIN_DIR / $PLUGIN_URL
 * 设置存在插件目录内 theme_options.json,不碰宿主任何表
 */
$optFile = $PLUGIN_DIR . '/theme_options.json';
$opts = is_file($optFile) ? json_decode((string)file_get_contents($optFile), true) : [];
$opts = is_array($opts) ? $opts : [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 真实环境请加 CSRF 校验(插件-run 路由已要求管理员)
    $opts['accent']     = trim((string)($_POST['accent'] ?? '#0d9488'));
    $opts['show_clock'] = !empty($_POST['show_clock']) ? 1 : 0;
    @file_put_contents($optFile, json_encode($opts, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    echo '<div class="alert alert-success">已保存</div>';
}
$accent = htmlspecialchars($opts['accent'] ?? '#0d9488', ENT_QUOTES);
$clock  = !empty($opts['show_clock']);
?>
<form method="post" style="max-width:520px">
  <label>主题强调色 <input name="accent" value="<?= $accent ?>" style="width:120px"></label>
  <label><input type="checkbox" name="show_clock" <?= $clock ? 'checked' : '' ?>> 显示时钟</label>
  <button type="submit">保存</button>
</form>
把 theme.php 里用到的 $opts['accent'] 等配置读出来套到 CSS 变量上,就完成「主题设置 → 前台即时生效」的闭环。该文件只输出页面主体,页头页脚由宿主后台布局统一包裹。

5.8 文章与分类接口(lifesystem v3.18.7)

后台「文章管理」维护的文章可展示在导航首页;分类用于给文章分组,前台文章列表已支持按分类筛选。

函数说明
lifesystem_articles()导航首页应展示的文章(已应用开关与条数)。字段:id / title / summary / cover / thumb / excerpt / author / view_count / created_at / updated_at / url,以及分类字段 category_id / category(分类名)/ category_slug / category_url(未分类时 category 为空串、category_id 为 0)。
lifesystem_article_categories()文章分类清单。默认只返回至少有一篇已发布文章的分类;字段 id / name / slug / sort_order / count(已发布文章数)/ url(该分类的文章列表页)。传 false 则返回全部分类(管理类插件用)。空数组 = 无需渲染分类条。
lifesystem_article_url($id)文章详情页地址($id ≤ 0 时返回列表页地址)。
lifesystem_article_list_url()文章列表页地址。
lifesystem_article_category_url($id)某分类的文章列表页地址($id ≤ 0 时返回全部文章列表页)。
lifesystem_theme_code()当前生效的导航首页主题插件 code;使用内置默认主题时为空串(主题插件可据此判断"是不是自己在生效")。
前台表现(宿主默认主题已实现,主题可照做):文章卡片显示分类标签;文章列表页顶部有分类筛选条(?route=article-list&cat=ID);详情页显示所属分类,并列出「同分类的其他文章」。
后台侧栏「文章管理 → 分类管理」可增删改分类,字段:分类名(唯一)、英文标识 slug(可选,留空自动 cat-ID)、排序(越大越靠前)。删除分类不会删文章,该分类下的文章自动变为「未分类」。

5.9 搜索引擎接口(lifesystem)v4.0.8

lifesystem 后台「搜索引擎」菜单组(管理员)让站长配置公开导航首页搜索框上展示哪些外部搜索引擎:

「站内」搜索固定始终展示(搜的是站内公共标签库),不在此配置之列。首次安装(表为空)自动预置常用引擎;升级存量库不覆盖站长已有的配置。

函数说明
lifesystem_search_engines()首页搜索框应展示的搜索引擎(已应用后台「显示设置」开关)。字段:id / name / icon / url_template(含 {q} 占位)/ sort_order。主题渲染搜索框标签页时必须用它,不要硬编码百度/谷歌——否则与后台配置不一致。
lifesystem_search_engine_url($id, $keyword)拼接最终跳转地址(关键词自动 urlencode)。url_template 没写 {q} 时自动拼到末尾;引擎不存在 / 模板为空返回空串。
主题怎么还原搜索框:固定保留一个「站内」标签页(搜 $publicBookmarks),再遍历 lifesystem_search_engines() 生成外部引擎标签页;点击时用 lifesystem_search_engine_url($id, $kw) 得到跳转地址(window.open 新窗口)。宿主内置默认主题已实现,主题可照做。

5.10 组件(components,lifesoso 组件中心)lifesoso ≥ v2.9.0

lifesoso 的 Tab 页(/tab.php)是一个「组件中心」:顶部搜索 + 中部组件卡片网格 + 底部 Dock 导航。你只要把你的插件做成组件,就自动进入后台「组件管理」菜单——用户在你的插件详情页点「启用组件」后,组件卡片立刻出现在登录用户的 Tab 页;卸载插件时组件行自动清理。不需要宿主改任何文件,也不需要建表。

这一点很关键:lifesoso 前台没有左菜单——menu 声明的插件入口只渲染在后台侧栏(见 5.6),普通用户在前台看不到。所以在 lifesoso 上要让用户用得到你的功能,就必须是组件:云端分类选「小组件」的插件走这里;「小游戏」要进 lifesoso 也得写成组件(同一份包在 lifesystem / lifegame 仍可走 menu 左菜单,互不影响)。详见 5.11。
核心结论:plugin.json 里加一个 components 数组 + 提供一份渲染 .php,就算完成。lifesystem / lifegame 装你的插件时会自动忽略 components 字段(不影响其他功能),所以是三端安全的写法。

plugin.json 里怎么写

{
  "code": "mytodo",
  "name": "我的待办",
  "version": "1.0.0",
  "summary": "在 Tab 页显示一个个人待办提醒组件",
  "author": "你的名字",
  "targets": ["lifesoso"],
  "components": [
    {
      "code": "mytodo",
      "name": "我的待办",
      "icon": "✅",
      "description": "个人待办卡片,Tab 页顶部展示",
      "render": "component.php",
      "sort": 10
    }
  ],
  "endpoints": ["todo_api.php"]
}
字段必填说明
code★组件标识,正则同插件 code:^[a-z0-9_]{2,40}$。建议与插件 code 同名(如 mytodo),避免多组件混淆。
name★卡片标题,显示在组件卡片头部(≤120 字)。
icon图标:emoji 或短文本,空则用 🧩。
description一句话说明,用于后台「组件管理」列表展示。
render★渲染文件(插件目录内的 .php,必须以 .php 结尾、不能带 ..)。宿主 include 这个文件,直接 echo 出卡片内部 HTML。
sort排序号(int),不填按数组顺序 ×10 递增。后台可覆盖。

渲染文件里能用什么

宿主 component_render() 会用局部变量 include 你的渲染文件;文件里可直接读到:

$user 当前登录用户行(数组)。Tab 页只在登录用户下渲染,$user 一定不为空。
$comp 当前组件的元数据行:component_code / plugin_code / name / icon / description / render / enabled / sort / config / created_at / updated_at。
宿主函数 5.4 里 db() / h() / csrfToken() / plugin_url() 等全部可用。
输出方式 直接 echo 卡片正文 HTML,不要写 <section> 外层——外层由宿主生成,你只写卡片内容。

完整示例:一个「待办提醒」组件

两个文件就够:plugin.json(上面那个)+ component.php。装好进 plugins/mytodo/,登录用户 Tab 页会多一张卡片;后台「组件管理」列表里出现一条 我的待办,站长可停用、可改排序、可改单卡片尺寸(s/m/l)。

<?php
// component.php —— 组件卡片正文(宿主负责外框与标题)
$uid = (int)($user['id'] ?? 0);
$items = [];
if ($uid > 0) {
  try {
    $pdo = db();
    $pdo->exec("CREATE TABLE IF NOT EXISTS mytodo_items (
      id INT AUTO_INCREMENT PRIMARY KEY,
      user_id INT UNSIGNED NOT NULL,
      title VARCHAR(200) NOT NULL,
      done TINYINT(1) NOT NULL DEFAULT 0,
      sort INT NOT NULL DEFAULT 0,
      created_at DATETIME NOT NULL,
      KEY uq_user (user_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    $st = $pdo->prepare('SELECT id,title,done FROM mytodo_items WHERE user_id=? ORDER BY done ASC, sort ASC, id DESC LIMIT 10');
    $st->execute([$uid]);
    $items = $st->fetchAll();
  } catch (PDOException $e) { /* 忽略 */ }
}
?>
<form class="todo-form" id="todoForm">
  <input type="text" name="title" maxlength="60" placeholder="添加待办…回车提交" autocomplete="off">
</form>
<ul class="todo-list">
  <?php foreach ($items as $t): ?>
    <li data-id="<?= h($t['id']) ?>" data-done="<?= h($t['done']) ?>">
      <input type="checkbox" data-act="toggle" <?= $t['done'] ? 'checked' : '' ?>>
      <span class="ttl"><?= h($t['title']) ?></span>
      <button type="button" data-act="del" aria-label="删除">✕</button>
    </li>
  <?php endforeach; ?>
</ul>

生命周期:安装 → 启用 → 停用 → 卸载

动作组件状态
用户在市场安装你的插件宿主 component_bootstrap() 扫描 plugin.json,写入 component_states 表,默认 enabled=1。
后台「组件管理」停用仅把 enabled 置 0,用户数据不删;重新启用后数据还在。
后台改单卡片尺寸写入 component_states 表 config 字段,前台卡片按 s/m/l 显示。
用户卸载插件下次 component_bootstrap() 发现插件目录不存在,自动 DELETE 对应 component_states 行。你自己的业务表请写在 uninstall.php 里清理(见第 6 节)。

常见坑

兼容提示:如果你只给 lifesoso 做组件,targets 只写 ["lifesoso"];如果你想让插件在三端都能装(其他功能三端通用,但只有 lifesoso 会启用组件),targets 写全 ["lifesystem","lifesoso","lifegame"] 也行——lifesystem/lifegame 会装但忽略 components 字段。

组件管理:开发要求与基础框架(给后续开发者的落地清单)v3.x

「组件管理」是 lifesoso 后台菜单,列出所有声明了 components 的插件 / 官方内置组件,站长可启用 / 停用 / 改排序 / 改单卡片尺寸。新插件只要按下述做,就能自动出现在这里,用户在前台 Tab 页直接配置、调用,无需改宿主任何代码、无需建表。

1. 后端:plugin.json 的 components[] 字段(全量)
字段必填说明
code★组件标识,正则同插件 code:^[a-z0-9_]{2,40}$。建议与插件 code 同名(如 mytodo),避免多组件混淆。
name★卡片标题,显示在组件卡片头部(≤120 字)。
icon图标:emoji / 短文本 / 内联 SVG。空则用 🧩。
description一句话说明,后台「组件管理」列表展示。
render★渲染文件(插件目录内的 .php,必须以 .php 结尾、不含 ..)。宿主 include 它,直接 echo 卡片内部 HTML。
size默认跨格数 "WxH"(单位:格)。如 "2x3"=宽 2 格高 3 格;不填则取后台默认值。宽屏自动收敛时,单卡高度决定卡片内能显示几条内容。
display"inline"(默认,内容直接嵌在卡片里)或 "modal"(卡片只占 1 格,点开弹窗看完整内容,适合工具类组件)。
sort排序号(int),不填按数组顺序 ×10 递增。后台可覆盖。
2. 渲染文件:component.php 约定
3. 用户偏好 / 跨设备数据:userTabPrefs

组件若需持久化用户自己的配置或数据(如文件夹、日历日程),统一走 userTabPrefs($uid) / tabPrefsSave(...):数据存在 user_tab_prefs.layout 的 JSON 里,按 user_id 一行,换设备登录同一账号自动同步。当前内置 key:

layout key含义读写方式
folders组件中心文件夹:[{id, name, members:[组件code...]}]随布局整体保存(act=save)
cal_events日历「我的日程」:[{date,time,text,color}]独立接口 api/tab_prefs.php?act=cal_events(只更新本 key,不动布局)
items / hidden / cols / dock组件布局(顺序 / 占格 / 显示方式 / Dock 大小)整体保存 act=save
扩展自己的用户级 key:只需在 common.php 的 userTabPrefs()(默认空值 + 读取 + 返回)与 tabPrefsSave()(写入 JSON)里各加一处,无需建表、无需迁移。同一账号多端自然同步。
4. 前端配置与调用流程
  1. 开发者写好 plugin.json(声明 components)+ component.php,部署到 plugins/<code>/(用户/市场)或 components/<code>/(官方内置)。
  2. 宿主 component_bootstrap() 扫描后即把组件写入 component_states 表,后台「组件管理」立刻出现一条,默认启用。
  3. 站长在「组件管理」启用 / 停用 / 改排序 / 改单卡片尺寸;用户在 Tab 页(/tab.php)即可看到卡片,点卡片头「打开」弹窗或直接在网格内嵌使用。
  4. 卸载插件后,component_bootstrap() 发现目录不存在会自动清理对应组件行;你自己的业务表请在 uninstall.php 里清理。
5. 官方内置组件目录约定

随内核分发包下发的官方组件放 components/<code>/(刻意不走 plugins/,以便随更新通道送达、不被站点保护规则跳过);用户 / 插件市场安装的组件放 plugins/<code>/。两处同名时官方内置优先。已内置:mytodo(待办)/ calendar(日历 + 我的日程)/ devtools(开发者工具箱)/ navboard / countdown / notepad / eatwhat / muyu / pomodoro / poetry。

6. 实战:把「热点热榜(redianbang)」做成 lifesoso 组件当前落点
重要变更(lifesoso v3.x):lifesoso 后台「组件管理」里已不再内置「热搜榜」组件——程序内不再携带热榜。所有热榜需求一律走插件:用 redianbang(热点热榜)这类插件,按本节的 components 声明接入,自动出现在后台「组件管理」与登录用户 Tab 页。不要再往宿主里加内置热榜。

redianbang 现状:已是一个完整插件,靠 hooks.head.meta 把热榜插到搜索框下方(LifeSOSO 搜索结果页右侧栏 / Life System 导航首页),数据源是百度 / 微博 / 知乎 / 头条 / 抖音 / B站 / 豆瓣等公开接口,按源缓存(默认 10 分钟)。下面只补一步:加一个 components 声明 + 一份 component.php,让热榜也能进「组件中心」。

plugin.json 改动(在原有 hooks / endpoints 基础上追加 components)
{
  "code": "redianbang",
  "name": "热点热榜",
  "targets": ["lifesoso", "lifesystem"],
  "hooks":     { "head.meta": "hook_head.php" },
  "endpoints": ["api.php", "bang.php", "cfg.php", "js.php", "css.php"],
  "components": [
    {
      "code": "redianbang",
      "name": "热点热榜",
      "icon": "🔥",
      "description": "百度/微博/知乎/头条等公开热榜,组件中心直接看",
      "render": "component.php",
      "size": "2x3",
      "display": "inline",
      "sort": 20
    }
  ]
}
加了 components 后:lifegame 忽略它;lifesystem 仍走 hooks(组件不生效);只有 lifesoso 会在后台「组件管理」多出一条「热点热榜」,用户启用后 Tab 页出现热榜卡片。hooks 与 components 互不冲突,可并存(搜索框下方的热榜 + 组件中心的卡片是同一份数据两个入口)。
component.php(热榜卡片正文,复用 redianbang 已有的抓取 / 缓存层)
<?php
// component.php —— 由 lifesoso component_render() 在 <section class="comp-card"> 内 include
// 复用 redianbang 自带数据源与缓存(默认 10 分钟,对齐宿主 CACHE_TTL_HOT=600)
require_once __DIR__ . '/lib/Sources.php';

$srcCode = 'baidu';                 // 组件默认展示的榜单;实际应从 redianbang_config.selected 取站点/用户选择
$rows = [];
try {
  $s = rd_source_by_key($srcCode);
  if ($s !== null) {
    $d = rd_fetch_cached($s['code'], null, false);
    $rows = array_slice((array)($d['list'] ?? []), 0, 10);   // list:[{title, url, hot}]
  }
} catch (Throwable $e) { $rows = []; }
?>
<ol class="rd-comp-list">
  <?php foreach ($rows as $i => $it): ?>
    <li>
      <span class="rd-rank"><?= $i + 1 ?></span>
      <a href="<?= h($it['url'] ?? '#') ?>" target="_blank" rel="noopener"><?= h($it['title'] ?? '') ?></a>
      <?php if (!empty($it['hot'])): ?><span class="rd-hot"><?= h($it['hot']) ?></span><?php endif; ?>
    </li>
  <?php endforeach; ?>
</ol>
<?php if (!$rows): ?>
  <p class="rd-empty">热榜加载中,或暂未选择榜单(去插件后台勾选)。</p>
<?php endif; ?>
数据形状以 redianbang 现有 API 为准:{ok, name, code, id, list:[{title, url, hot}], updated}(见 api.php)。组件渲染是服务端 include,直接复用 rd_fetch_cached() 即可,不要再走一次 HTTP 自调用。要做「多榜单切换 / 总览」,把 display 改成 "modal"、在 component.php 里渲染全部勾选榜单并点开弹窗即可(可复用已有的 bang.php 总览逻辑)。

5.11 插件分类(提交插件时必选)决定插件在三端怎么落位

在云端创建 / 编辑插件时,除了名称与简介,还必须选择插件分类。分类由平台后台配置(「插件库 → 分类管理」),默认四项:导航主题 / 小游戏 / 小组件 / 辅助工具。它不写进 plugin.json,只登记在云端,但它决定了:

分类适用端落地形态与开发要点
导航主题 LifeSystem 仅用于 LifeSystem 的导航页主题插件,也可扩展为全站主题。需要在 plugin.json 里声明 theme 接管导航首页(见 5.7),并用 lifesystem_search_engines() 还原搜索框(见 5.9)。
小游戏 LifeSystem / LifeGame
(LifeSOSO 走组件)
仅用于 LifeSystem 与 LifeGame 的小游戏插件:用 menu(group:"小游戏")把游戏页挂到左菜单,见 5.6。
LifeSOSO 前台没有左菜单(插件菜单只渲染在后台侧栏),所以小游戏要在 LifeSOSO 上给用户玩,必须写成「组件」(components)注册进组件中心展示,见 5.10。
小组件 LifeSOSO 仅用于 LifeSOSO「组件管理」菜单:在程序的组件模式(Tab 组件中心 /tab.php)下,以卡片形式展示在登录用户的 Tab 页。做法 = 声明 components + 提供一个渲染 .php,见 5.10。
辅助工具 三端通用 用于三端都能用的能力插件,例如聚合登录(hooks: login.form.bottom)、站点访问统计(hooks: head.meta)等。
采集类插件建议投向 LifeSystem 与 LifeSOSO(这两端有内容与站点数据可采)。
一个插件只能选一个分类:如果它同时具备多种能力(比如既是小游戏又带后台工具),请按用户最常使用的那个入口来选,并在简介里写清楚其它能力。
分类选错会怎样:提交资料审核时平台会按分类核对落位——例如选了「小组件」却没写 components、选了「导航主题」却没声明 theme,会被要求改正后重新提交。分类可在「编辑插件信息」里改(已上架插件改分类需要平台审核后生效)。
壁纸类插件选哪个分类?目前平台分类里没有单独的「壁纸」项,请一律选「辅助工具」(后台后续若新增「壁纸美化」分类,会以公告通知)。能不能被识别为壁纸来源,不看分类、只看 plugin.json 的 wallpaper 字段 / 约定目录,见 5.12。

5.12 壁纸类插件(wallpaper)给宿主提供界面背景图

宿主(LifeSystem / LifeGame / LifeSOSO)已经内置了界面背景能力:管理员在后台「背景设置 / 外观背景」里可以手动上传图片,也可以从你的插件里挑一张。
分工很明确:宿主不联网抓图、也不认识任何第三方壁纸源;「图从哪来」完全由插件决定——插件每天把图放到自己的目录里(或提供一个清单脚本),宿主负责发现、复制与缓存。

宿主生效位置最低宿主版本
LifeSystem 后台界面 + 公开导航页 + 手机网页版 / APP v4.0.8 / v1.9.2 / 3.0.5
(本契约随这三个版本起提供;低于此版本没有对应菜单项,宿主会忽略 wallpaper 字段,插件不会报错)
LifeGame 后台界面 + 公开页 + 手机网页版 / APP
LifeSOSO 首页(index.php)

宿主的三级发现(命中任意一级即可被识别为「壁纸类插件」)

优先级方式插件要做什么
① provider(推荐) plugin.json 里写 "wallpaper": {"provider": "lib/wallpaper.php"},该文件实现 <code>_wallpaper_list() 与 <code>_wallpaper_today()(函数名里的 <code> 换成你的插件 code,例如 bing_wall_wallpaper_list();只写 wallpaper_list() 也认,但加前缀可避免和别的插件重名)。
② endpoint plugin.json 里写 "wallpaper": {"endpoint": "wallpaper.php"},或把 wallpaper.php 列进 endpoints。该文件输出(或 return)JSON 列表。
③ 目录直读(零改造) 什么都不用声明,只要插件目录下存在 wallpapers/(或 wallpaper/、images/wallpapers/、images/)并放了图片。也可以显式指定:"wallpaper": {"dir": "wallpapers"},简写 "wallpaper": "wallpapers" 同样支持。

条目格式(三种方式通用)

列表里每一条可以是字符串,也可以是对象;对象支持这些字段:

字段必填说明
file二选一插件目录内的相对路径,例如 wallpapers/2026-09-26.jpg。必须真实存在且扩展名为 jpg / jpeg / png / webp,单张 ≤ 8MB。
url二选一外链直链(http(s)://)。宿主会下载到本地再引用,不会把外链写进页面。
date否YYYY-MM-DD。「每日跟随」靠它找当天那张;不填时宿主会尝试从文件名里提取。
title否展示名;不填用文件名。

列表容器可以是裸数组,也可以是 {items:[...]} / {data:[...]} / {list:[...]} / {wallpapers:[...]} / {images:[...]};endpoint 方式下还可以额外返回 {today:{...}} 或 {daily:"2026-09-26.jpg"} 直接指明今天这张。

最小可用示例(③ 零改造,推荐先这样做)

mywallpaper/                 <-- 插件目录(code = mywallpaper)
├── plugin.json
└── wallpapers/
    ├── 2026-09-26.jpg
    ├── 2026-09-25.jpg
    └── 2026-09-24.jpg
// plugin.json(可选:加上 wallpaper 字段可跳过目录探测,加载更快更确定)
{
  "code": "mywallpaper",
  "name": "每日壁纸",
  "version": "1.0.0",
  "wallpaper": { "dir": "wallpapers" }
}

每天由你的插件负责产出 / 下载当天的图并存成 wallpapers/YYYY-MM-DD.jpg(建议在 install.php 里建目录,再用定时器或访问触发去更新),宿主侧什么都不用再改。

① provider 方式的函数签名

// lib/wallpaper.php —— 宿主会 require_once 你这个文件
<?php
/** 返回列表:[['file'=>'wallpapers/2026-09-26.jpg','title'=>'秋日山谷','date'=>'2026-09-26'], ...] */
function mywallpaper_wallpaper_list(): array {
  return [ /* ... */ ];
}

/** 返回今天这张(返回 null 时宿主会退回「按 date 找 → 取最新一张」) */
function mywallpaper_wallpaper_today(): ?array {
  return ['file' => 'wallpapers/' . date('Y-m-d') . '.jpg', 'date' => date('Y-m-d')];
}
每日跟随是怎么跑的:管理员勾选「每日自动跟随插件当天壁纸」后,宿主在当天首次访问发现日期变了才会问你要一次图,成功后复制到 uploads/bg/plugin/<code>/daily/YYYY-MM-DD.jpg 并缓存;取不到时静默沿用当前背景图,10 分钟内不再重试,所以你的下载逻辑偶发失败也不会白屏。后台有「立即同步今日壁纸」按钮可强制触发。
安全红线(违反必驳回):
排错速查:后台下拉里看不到你的插件 → ①插件是否已启用且目录存在;②plugin.json 是否被正确解析(见 5);③壁纸目录名是否是约定的那几个。
能看到插件但列表为空 → 图片扩展名 / 体积是否越界;provider 函数名是否拼错(<code>_wallpaper_list)。

6. 数据读写(建表 / 查询 / 清理)

用 db() 拿到的 PDO 直接操作,表名务必带插件 code 前缀,避免和别的插件或核心表冲突。

// install.php —— 建表(幂等写法:IF NOT EXISTS)
$pdo = db();
$pdo->exec("CREATE TABLE IF NOT EXISTS hello_items (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

// 查询(务必用预处理,不要拼接 SQL)
$st = db()->prepare("SELECT * FROM hello_items WHERE id = ?");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
卸载要自己清理:在 uninstall.php 里 DROP TABLE IF EXISTS hello_items。宿主卸载时只删插件目录,不会替你删表——留下垃圾表是常见的驳回原因。
install.php 的异常会被吞掉:建表失败、函数未定义等 Fatal,插件核心捕获后仍然返回「安装成功」,表现为「装上了却什么功能都没有」。
所以:install.php 里自己 try/catch 并 error_log();装完不要只看界面的成功提示,要去看日志和功能是否真的在。

7. 安全红线(违反必驳回)

  1. 只写自己的目录。任何写文件操作都落在 $PLUGIN_DIR 内;卸载时该目录会被整体删除。
  2. 不碰站点其它文件。严禁修改核心代码、配置或其它插件目录。不要用「改写宿主文件」的方式做挂载——旧做法在客户端升级时会被冲掉,而且不安全。
  3. 建表用独立前缀(如 hello_xxx),并在 uninstall.php 里 DROP。
  4. 禁止危险函数用于恶意目的:eval / system / exec / shell_exec / passthru / proc_open / assert / 反引号 / 变量 include 落马 / file_put_contents 写 .php。云端会自动扫描并在后台标红;确需使用(如采集类插件调 curl)请在提交说明里写清用途。
  5. 包体与校验:客户端安装的硬上限是 20MB(云端上传允许 30MB,但 20~30MB 的包能过审却所有客户端都装不上)。大资源走外链。包的 SHA256 必须与云端登记一致,不一致安装中止。
  6. 联网请求必须设超时与容错(建议 ≤10 秒),避免拖垮宿主页面。
  7. 用户输入:入库用预处理,输出用 h() / htmlspecialchars();处理 POST 前先 checkCsrf()。
  8. install/uninstall 脚本防直访:加「只允许被插件核心调用」的守卫,否则可能被 URL 直接访问触发删表。
  9. 不得采集/外传用户隐私数据,不得包含未经授权的第三方代码或侵权素材。

8. 常见坑与排错

8.1 PHP 写法类(装完没反应,多半是这些)

坑现象正确写法
条件函数定义 + 提前调用安装后功能缺失、界面却显示「安装成功」 if (!function_exists('foo')) { function foo(){} } 这种条件包裹的定义,PHP 在编译期不注册,只有执行流走到 if 才存在;在它之前调用 → Fatal Error。把定义写成顶层无条件,调用放文件末尾。
注释或字符串里裸写 ?>文件后半段变成纯文本输出、语法报错 闭合标签会终止 PHP 模式。必须写就拼接:'<' . '?php ' / ' ?' . '>'。
dirname(__DIR__, N) 级数算错file_exists() 恒为假,逻辑静默跳过 N = 向上几级。在 templates/auth/login.php 里求站点根是 dirname(__DIR__, 2)。用前先打印一次确认。
锚点靠精确空格匹配strpos() 恒 false,静默跳过 宿主模板的空格随时会变。定位一律用正则,不要拼精确字符串。

8.2 架构类(一端正常、另一端异常)

坑说明
硬编码 admin/plugin_run.phplifesoso 用这个,框架端是 ?route=plugin-run&code=。硬编码会导致另一端链接全 404。
把插件目录当公网可达框架端 web 根是 public/,插件目录在它之外。要对外提供 URL 必须声明 endpoints。
假设宿主一定有某个全局函数老版本客户端可能没有。加 function_exists() 守卫。
表名不加前缀与其它插件或核心表冲突,排查极难。

8.3 排错顺序:插件装了但不显示

  1. 看站点错误日志——挂载文件报错不打断页面,只进日志,这是第一线索。
  2. 确认 plugin.json 的 code 与文件夹名、云端登记三处一致,且 targets 包含当前产品。
  3. 确认挂载点名拼写正确(只有三个已启用:head.meta / login.form.bottom / profile.card)。
  4. 确认客户端版本达标(lifesystem ≥ v3.18.4、lifegame ≥ v1.0.3、lifesoso ≥ 2.8.7)。
  5. 在挂载文件第一行写 file_put_contents(__DIR__.'/_debug.log', 'ran');,能生成文件说明宿主确实渲染了它。

9. 提交流程(先审插件,再发版本)

1
开发者认证 门户账号 → 开发者中心 → 提交认证 → 管理员通过。未认证不能创建插件。
2
创建插件(草稿) 填 code / 名称 / 简介 / 插件分类 / 价格 / 适用产品。code 一旦创建不可修改;分类必选(5.11),决定插件在三端的落位与审核口径。
3
做插件介绍 二选一,推荐后者:① 用富文本编辑器写「详细介绍」(截图可直接 Ctrl/⌘+V 粘贴);② 直接上传一个完整版 HTML 介绍页。它是审核必看项,也是用户决定装不装的主要依据。
4
提交资料审核 此时不需要任何版本包。状态 草稿 → 待审核;介绍为空(<10 字且无图)会被拦下,未选分类也会被拦下。
5
平台审核资料 通过 → 已上架;驳回 → 已驳回并写明原因(原因会发到你邮箱)。
6
上传版本 资料通过后才开放:填版本号(semver)+ 传 zip + 更新日志(可选传预览 HTML)。
7
平台发布版本 通过后版本状态 → 已发布,用户此刻才在客户端市场看到并安装;驳回同样会写明原因。
「详细介绍」编辑器怎么用:
① 粘贴截图:Windows 用 Win+Shift+S、macOS 用 ⌘+Shift+4 截屏后,回到编辑器按 Ctrl/⌘+V,图片会自动上传并插入,不需要先存成文件;也可以直接把图片拖进编辑区,或点工具栏 🖼 选图。
② 支持标题、加粗/斜体、列表、引用、代码块、分割线、链接;单张图 ≤ 5MB。
③ 内容会经过安全过滤(只保留排版标签,脚本/事件/外链脚本一律清除)后展示在插件详情页;详情页底部还会自动列出历史版本(版本号 + 发布时间 + 包大小 + 更新日志)。
④ 已上架的插件改介绍:提交后进入待审,审核通过才对外生效,期间售卖与下载不受影响。
⭐ 更省事的做法:上传「完整版 HTML 介绍页」(推荐,不会排版也能做出好看的介绍)
富文本编辑器要一点点调格式,很多开发者懒得弄、介绍页做得很简陋,直接拉低转化率。所以更推荐这样走:
① 插件开发完成后,直接让 AI 帮你生成一个完整版 HTML 产品介绍页——就一个 .html 文件,样式(CSS)和交互(JS)全部内联在里面,图片用外链或 base64 内嵌。建议包含:这个插件解决什么问题、核心功能清单、适用哪些人、怎么安装使用、效果截图、常见问题、版本更新记录;
② 开发者中心 → 本插件详情 → 「编辑插件信息」→ 产品介绍页(HTML) → 选文件上传即可;
③ 上传后,插件详情页的「介绍」页签会原样内嵌这个页面,效果就像你单独做了一个官网;
④ 规格:单个 .html / .htm 文件,或含 index.html 的 .zip 包(包内只允许 html/css/js/图片/字体/音视频/pdf,最多 300 个文件、40MB),整体 ≤ 20MB;不允许 .php 等可执行后缀;
⑤ 功能更新后记得同步更新介绍页:插件加了新功能、改了界面、调了价格,都要重新生成并上传新版介绍页,否则用户点进来看到的还是旧说明。
说明:介绍页与富文本「详细介绍」可以同时存在,有介绍页时详情页优先展示介绍页;两者都为空时,详情页只显示一句话简介。
状态速查:
插件:草稿 draft → 待审核 submitted → 已上架 online / 已驳回 rejected;已上架后可被下架 offline(会注明下架理由)。
版本:待审核 pending → 已发布 published / 已驳回 rejected。只有 published 的版本会下发到客户端。
关键约束:草稿 / 待审核 / 已驳回状态下,开发者中心不开放「发布新版本」(服务端也会拦截),必须先让资料过审。
两条容易踩的流程限制:
① 插件正在参加平台活动(促销期)时,禁止发布新版本,请等活动结束。
② 同版本号被驳回后,可以用同一个版本号重新上传(会覆盖原记录、清空原驳回原因),不需要改号;但已发布过的版本号不能重复提交。

10. 审核规则与常见驳回原因

平台审核看两件事:资料是否真实准确、代码是否安全守规矩。下面是被驳回的高频原因,提交前照着自查一遍能省一轮往返。

驳回原因典型情况怎么改
code 不一致包内 plugin.json 的 code 与云端登记/文件夹名不同三处统一;云端 code 不可改,只能改包
包结构不合规zip 根级没有 plugin.json;含 ../、绝对路径重新打包,确保 plugin.json 在根级
包体超限>20MB(客户端硬上限)移除大资源,改外链
高危代码无说明扫描命中 exec/eval/动态 include 等去掉,或在更新日志里说明用途与参数来源
越界写入改宿主文件、写插件目录之外的路径全部改回 $PLUGIN_DIR 内;用 hooks 替代文件注入
卸载不干净建了表但 uninstall.php 没 DROP补 DROP,表名加插件前缀
安装静默失败install.php 报错被吞,功能缺失自己 try/catch 记日志;本地先跑 php -l
货不对板简介/截图与实际功能不符如实描述;功能未完成的先别提交
资料不规范名称堆砌关键词、简介含联系方式/外部链接名称简洁、简介只写功能
驳回后:邮件里会写明具体原因。开发者中心 → 该插件详情也能看到;修改后重新提交即可,不影响已有版本与已购买用户。

11. 计费、试用与授权

类型客户端表现说明
免费「安装」按钮,直接装price_type=free,无需授权。
付费「前往购买」price_type=paid + price(积分)。跳门户用积分购买,产生授权后客户端才拿到下载地址。
试用「免费试用 N 天」设了 trial_days>0 时出现,一步领取并安装。
给内容/采集/导出类插件的建议:如果你的插件价值在「一次性产出成果」,开试用等于把成果白送——建议把试用天数设为 0。

12. 云端接口(客户端怎么拿包)

客户端内核调用 /api/plugins.php 拉市场列表,鉴权复用更新协议的 p / d / k。

参数说明
p / d / k产品代码 / 域名 / 授权 deploy_key(必填)
app_ver当前客户端版本,用于 min_app 兼容性提示
installed已装清单 JSON:[{"code":"nav_collect","version":"1.0.0"}],用于算升级态
act=trial + code领取试用(可选动作)
{
  "ok": 1,
  "support_email": "9041708@qq.com",
  "plugins": [
    {
      "code": "nav_collect",
      "name": "导航采集",
      "summary": "…",
      "icon_url": "https://…/icon.png",
      "author": "SYS宇晖",
      "price_type": "paid",      // free / paid
      "price": 200,              // 积分价(free 时为 0)
      "trial_days": 7,           // 0=不支持试用
      "latest": "1.0.0",         // 最新已发布版本
      "installed": "1.0.0",      // 本地已装版本(未装为空)
      "state": "upgrade",        // install / installed / upgrade / need_buy / trial
      "size": 12345,
      "sha256": "…64位十六进制…",
      "min_app": "3.18.4",
      "released": "2026-09-01 12:00:00",
      "changelog": "…",
      "url": "https://…/api/plugin_pkg.php?t=签名令牌"  // 仅在有安装权时下发
    }
  ]
}
url 是带签名的临时下载地址(有效期 1 小时)。客户端下载后先校验 sha256 再安装;没有安装权时该字段为空。
注意:没有「已发布版本」的插件不会出现在市场里——所以资料审核通过后,别忘了再传一个版本。

13. 完整示例(可直接抄)

一个「后台页 + 挂载点 + 建表」的完整骨架,改改名就能用。

plugin.json

{
  "code": "hello_life",
  "name": "Hello Life",
  "version": "1.0.0",
  "summary": "最小可运行示例:登录页问候 + 后台管理",
  "author": "你的名字",
  "targets": ["lifesystem", "lifesoso", "lifegame"],
  "admin": "admin.php",
  "install": "install.php",
  "uninstall": "uninstall.php",
  "hooks": {
    "login.form.bottom": "hook_login.php",
    "head.meta": "hook_head.php"
  },
  "endpoints": ["ping.php"]
}

小游戏插件:多插件共用一个菜单组(plugin.json 片段)

{
  "code": "mahjong",
  "name": "麻将计分",
  "version": "1.0.0",
  "pages": ["game.php", "rank.php"],
  "menuGroups": [
    { "id": "fun", "name": "小游戏", "icon": "game", "order": 10 }
  ],
  "menu": [
    { "title": "麻将计分", "route": "plugin-page&code=mahjong&file=game.php", "icon": "🀄", "group": "fun", "sort": 10 },
    { "title": "麻将战绩", "route": "plugin-page&code=mahjong&file=rank.php", "icon": "🏆", "group": "fun", "sort": 20 }
  ]
}

你的下一个游戏插件只要原样抄这段 menuGroups(同一个 "id":"fun"),它的菜单就会并进已有的「小游戏」组,左菜单不会被一个个插件撑长。

hook_login.php(挂到登录页)

<?php
?>
<div style="padding:10px;text-align:center;color:#0d9488">
  👋 Hello Life 已挂载(挂载点:<?= h($__hook_name) ?>)
</div>

hook_head.php(挂到页面 head,样式直接内联最稳)

<?php
?>
<style>.hello-badge{color:#0d9488;font-weight:600}</style>

install.php(建表 · 注意调用顺序)

<?php
/**
 * 关键点:函数用顶层无条件定义,调用放在文件最后。
 * 若写成 if (!function_exists('x')) { function x(){} } 再在前面调用 → Fatal Error。
 */
function hello_install(): void
{
    try {
        db()->exec("CREATE TABLE IF NOT EXISTS hello_items (
            id INT AUTO_INCREMENT PRIMARY KEY,
            title VARCHAR(255) NOT NULL,
            created_at DATETIME NOT NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {
        error_log('[hello_life] 建表失败:' . $e->getMessage());
    }
}

hello_install();   // ← 调用必须放在定义之后

uninstall.php

<?php
try { db()->exec("DROP TABLE IF EXISTS hello_items"); } catch (Throwable $e) {}

admin.php(后台页)

<?php
// $PLUGIN / $PLUGIN_DIR / $PLUGIN_URL 由宿主注入
?>
<div class="card">
  <h3>Hello Life</h3>
  <p>插件:<?= h($PLUGIN['name']) ?> v<?= h($PLUGIN['version']) ?></p>
</div>

打包为 hello_life.zip(根含上述文件)→ 云端上传 → 发布 → 客户端市场安装并「打开」。

14. 提交前自查清单

本机没装 PHP 也能查语法:用任意在线 PHP 语法检查,或装 PHP 后对每个文件跑 php -l 文件名。这一步能挡掉绝大多数低级错误。

15. 常见问题 FAQ

插件分类必须选吗?选错了能改吗?
必选。分类决定插件在插件库里的筛选归属与在三端的落位形态(见 5.11)。选错了可以在「编辑插件信息」里改:未上架的直接生效,已上架的需平台审核后生效。
小游戏插件想在 LifeSOSO 上也能玩,要怎么做?
LifeSOSO 前台没有左菜单,插件菜单只渲染在后台侧栏,普通用户看不到。所以请把游戏页再包一层「组件」(components + 渲染 .php),它就会以卡片形式出现在登录用户的 Tab 组件中心;同一份包在 lifesystem / lifegame 仍走 menu 左菜单,互不影响(见 5.10)。
为什么提示「还不能发布版本」?
因为插件资料还没审核通过。现在的顺序是:先提交资料审核 → 通过(状态变「已上架」)→ 才能上传版本。草稿 / 审核中 / 已驳回都不能发版本。
提交资料审核需要先把插件做好吗?
不需要。资料审核只看名称、简介、图标、价格、适用产品这些信息,不要求先有版本包。你可以先把商品登记好,再慢慢开发。
版本被驳回后,还能用同一个版本号吗?
可以,而且推荐这样做:修复后用同一个版本号重新上传,会覆盖原记录(旧的驳回原因也会清空)。已发布过的版本号则不能重复提交,必须递增。
驳回原因在哪里看?
两处:① 平台会发邮件到你账号邮箱,正文写明「驳回原因:……」;② 开发者中心 → 该插件详情 → 顶部/版本历史里也会显示。
插件标识 code 填错了怎么办?
创建后不可修改。如果还没人装过,联系平台下架后重建;如果已经有人在用,就不能改了(改了客户端会判定 code 不一致而拒绝安装)。所以第一次创建时请仔细核对。
用户装完说「没变化 / 不显示」,怎么排查?
按 8.3 的顺序来:先看站点错误日志(挂载报错只进日志、不打断页面),再核对 code 三处一致、挂载点名拼写、客户端版本是否达标。最后可在挂载文件第一行写个调试文件确认是否被执行。
安装时提示「code 不一致」是什么意思?
包内 plugin.json 的 code 与云端登记的不一样。客户端会拒绝安装且零写入。请把包内 code 改成云端登记的那个(或反过来,若还没人装)。
为什么包能上传、能过审,用户却装不上?
几乎都是体积问题:云端允许 30MB,但客户端硬上限是 20MB。20~30MB 的包所有客户端都会拒绝。把大资源改成外链。
能不能直接改宿主文件来实现功能?
不能。客户端升级会把你的改动冲掉,而且审核一定驳回。要在页面上输出内容就用 hooks;要存数据就自己建带前缀的表。
几个游戏插件怎么并进同一个菜单组?组名为什么被改成了「XX·插件」?
① 并组:每个插件的 plugin.json 都写一份相同 id 的 menuGroups(如 {"id":"fun","name":"小游戏"}),并把 menu[].group 填成这个 id,宿主会按 id 自动合并成一个组(详见 5.6)。
② 被改名:说明你的组名命中了保留字(系统、工具箱、导航、线上娱乐、线下娱乐……宿主自带的菜单名),宿主自动加「·插件」后缀以防混淆。换个名字即可(「小游戏 / 娱乐」不在保留字里,可以放心用)。
③ 老客户端不认识 menuGroups,会退回按 group 字面名分组,不会报错。
付费插件一定要开试用吗?
不。试用天数默认 0,此时市场只显示「前往购买」。采集 / 导出 / 内容类插件建议不开试用,避免用户试用期内拿走成果后不购买。

16. 支持与反馈

插件使用中遇到问题、想投诉或建议某个插件、或对本文有疑问,请发邮件至 9041708@qq.com,我们会尽快处理并持续完善本文档。

Life BS 插件库 · 三端内核(lifesystem / lifesoso / lifegame)· 文档版本 v3.5 · 最后更新 2026-09-26 · 反馈邮箱 9041708@qq.com

17. 本地开发与测试(lifesystem 已支持)

本节解决什么问题:插件没上架前,开发者想在自己部署的宿主上直接跑起来调试,不用来回发版本等审核。参照 Discuz 模式——本地开发测试与上架发布彻底解耦。

开发者在自己服务器(或本机)部署好宿主并激活授权后,把开发中的插件整个文件夹丢进宿主的 plugins/<code>/ 目录,后台「插件市场」会自动识别出「本地未上架插件」,点「安装并测试」即可本机加载运行。全程不联网、不依赖 lifebs 授权、不触发云端审核。

17.1 适用端

端状态说明
lifesystem已实现当前版本起支持本地未上架插件
lifegame待同步架构同构,后续版本支持
lifesoso待同步过程式实现,后续版本支持
本节当前仅适用于 lifesystem。lifegame / lifesoso 开发者请等待对应版本发布后再用本地测试能力。

17.2 本地测试流程

  1. 部署宿主:在你自己的环境部署 lifesystem 并激活程序授权(能跑程序就能本地测,无需 lifebs 开发者认证)。
  2. 放插件文件夹:把开发中的插件整目录上传到 plugins/<code>/,目录名必须等于插件 code,且内含合法 plugin.json。
  3. 后台安装:进「插件市场」→ 顶部「本地未上架插件」卡片会出现该插件(含名称 / 版本 / 适用端提示)→ 点「安装并测试」。
    宿主会:校验 code 与 plugin.json → 写注册表(标记「本地插件」)→ 执行 install.php 建表 → 直接启用。跳过云端 code 校验、跳过下载、跳过 targets 限制。
  4. 前台验证:插件已在宿主内加载,按你的 hooks / admin / endpoints 验证功能。
  5. 改代码热重载:修改插件 PHP 后,点「清除 OPcache」按钮(或本地直接关 OPcache),刷新即生效。
  6. 卸载:点「卸载」会保留目录(方便改完重装继续测);如需彻底删除目录,手动移除 plugins/<code>/ 即可。

17.3 本地插件与市场插件的区别

维度本地插件(source=local)市场插件(source=cloud)
来源开发者自己放目录 + 后台安装云端市场下载安装
前台标记已安装列表显示「本地插件」徽章无特殊徽章
市场升级不显示升级,仅在有云端版时显示「用市场版覆盖」(需确认)正常显示升级
点击统计不计入云端统计正常上报
程序更新两者都在 plugins/ 下,均受部署更新保护规则保护,不会被程序自动更新删除

17.4 程序更新时本地插件会丢吗?

不会。宿主更新保护规则已包含 plugins/ 目录,程序自动更新只清理「服务端包移除且不在保护名单」的文件,插件目录会被保留,开发者更新宿主版本后本地插件仍能继续测试、不报错。

17.5 本地测完,怎么上架?

  1. 去 lifebs 开发者中心登记插件资料(填 code、分类等),等资料审核通过。
  2. 在「编辑资料」上传产品介绍页(可直接传完整 HTML)。
  3. 上传版本 zip(版本审核通过后,用户才能在市场看到并安装)。
  4. 上架前建议在 plugin.json 补全 targets(声明适用端),否则正式市场安装会被拒。
本地测试 ≠ 上架。本地随便写版本号(建议带 -dev / -local 后缀),上架版请用正式语义化版本。

17.6 排错