plugin.json 声明 wallpaper,或提供 wallpapers/ 目录,宿主自动识别并在后台「界面背景」设置里列出可选壁纸;勾选「每日自动跟随当天壁纸」后,宿主每天自动取插件当天那张图作站点背景(lifesystem / lifegame 主界面与导航页、lifesoso 首页),取不到静默沿用已选背景。plugin.json 里声明 components 数组,宿主(lifesoso)自动扫描注册进后台「组件管理」菜单组,用户启用后组件卡片自动出现在 Tab 页;卸载插件时组件自动清理。menuGroups 菜单组——多个插件声明同一个组 id 即自动合并(适合「多个小游戏共用一个娱乐组」),含内置图标名、保留字黑名单与合并规则。
code、价格与试用状态、按登记填好的 plugin.json 模板、上传与审核流程)。
两份互补:本文档讲「怎么写」,专属文档讲「你这个插件登记了什么」;让 AI 帮忙写插件时请两份一起提供。plugin.json 新增 menuGroups,多个插件声明同一个组 id(如 fun)就自动并进同一个「小游戏」组。详见 5.6。
5. v3.4:组件(components)—— lifesoso 组件中心:plugin.json 新增 components 数组,声明后宿主自动扫描进后台「组件管理」菜单,用户启用后卡片出现在 Tab 页;卸载插件时组件自动清理。详见 5.10。plugin.json 声明 wallpaper(或用约定目录 wallpapers/),宿主自动识别并在后台「界面背景」列出可选壁纸,支持「每日自动跟随当天壁纸」。三端宿主最低版本:lifesystem v4.0.8 / lifegame v1.9.2 / lifesoso 3.0.5。详见 5.12。
你能给 lifesystem / lifesoso / lifegame 这三款产品写插件:一个 .zip 包,用户在自己的站点「插件市场」里点一下就装上。你可以:
你不需要改宿主(那三款产品)的任何源码,也不需要懂它们的框架结构。你只要按约定放好文件、填一份 plugin.json,剩下的由宿主和云端完成。
plugin.json 为入口的 PHP 文件夹。宿主读这份清单,决定什么时候加载你的哪个文件。| 名词 | 是什么 | 你要记住的 |
|---|---|---|
| 宿主 | 被装进去的那套系统(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 | 插件资料先过审,之后才能上传版本;版本号只能递增 |
三端的内核约定完全一致,差别只有一件事:网站的根目录在哪。理解这一条,你的插件就能三端通用。
| 产品 | Web 根目录 | 插件装在哪 | 插件目录能否被浏览器直接访问 |
|---|---|---|---|
| lifesoso | 站点根(/) | <root>/plugins/<code>/ | 可以:/plugins/<code>/xxx.php 直接可达 |
| lifesystem | public/ | <root>/plugins/<code>/ | 不可以:在 public/ 之外,必须走「公开端点」(5.3) |
| lifegame | public/ | <root>/plugins/<code>/ | 不可以:同上 |
所以写插件时只要遵守两条,就自动三端通用:
hooks(5.1),宿主负责渲染,你不用管目录在哪。endpoints 里声明(5.3),框架端走 ?route=plugin-endpoint 路由,lifesoso 直连。别自己拼 URL。目标:做一个插件,在登录页的表单下方显示一行自定义内容。三端通用,不改任何宿主文件。
hello_life/(文件夹名就是插件 code,只能小写字母、数字、下划线)。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"
}
}
hook_login.php(宿主会注入 $__hook_name、$__hook_vars 两个变量):
<?php
?>
<div style="padding:10px;text-align:center;color:#0d9488">
👋 Hello Life 已挂载(挂载点:<?= htmlspecialchars($__hook_name) ?>)
</div>
hello_life.zip,plugin.json 必须在包的根级。hello_life,填好名称/简介/价格 → 提交资料审核(这步不需要上传 zip)。hello_life.zip → 等管理员发布。一个插件 = 一个文件夹,最终打包成 .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;任何含 ../、.、绝对路径、盘符(:)的条目都会被整包拒绝且零写入(一个文件都不会落盘)。| 字段 | 必填 | 说明与校验规则 |
|---|---|---|
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。字段越少,审核越快。想在前台页面输出内容时,不要改宿主任何文件:在 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,宿主会捕获输出并插入页面。db() / h() / currentUser() 等全部可用。在 plugin.json 声明 admin 后,用户在市场点「打开」会进入你的 admin.php。宿主载入前会注入三个变量:
| 变量 | 类型 | 含义 |
|---|---|---|
$PLUGIN | array | 插件注册表行:code / name / version / status / mf(mf 就是 plugin.json 解析后的数组)。 |
$PLUGIN_DIR | string | 插件绝对目录,如 /var/www/lifesystem/plugins/hello_life。写文件一律落在它里面。 |
$PLUGIN_URL | string | 插件目录的相对 URL,用来引用自带资源:<link rel="stylesheet" href="<?= $PLUGIN_URL ?>/assets/style.css">。 |
.php 文件,否则拒绝载入;插件页是过程式 PHP,不要依赖 $this 之类的控制器上下文。$PLUGIN_URL 在 lifesoso 是 ../plugins/<code>、在框架端是 /plugins/<code>;框架端只有站点把插件目录映射(软链/别名)到 public/plugins 时资源才可访问。不确定的话,把关键 CSS 直接内联在挂载文件里输出,最稳。
框架端(lifesystem / lifegame)的 web 根是 public/,插件目录在公网不可达。插件要对外提供免登录页面(OAuth 回调、打点接收、动态 JS)时,必须走端点路由:
/public/index.php?route=plugin-endpoint&code=插件code&file=ping.php
该文件必须写在 endpoints 白名单里,否则一律 403;插件必须已安装且启用。lifesoso 根即 web 根,插件目录直接可达,同一份代码两端都能工作。
code 拼在回调 URL 上,会和本路由的插件标识 code 重名。code 作为插件标识」,所以你必须保证插件 code 在前、授权码在后,形如:…?route=plugin-endpoint&code=hello_life&file=callback.php&code=xxxxxplugin_url() 思路:不要手写死链。
三端提供同名同语义的全局函数,插件可直接调用,无需判断宿主类型、无需自带兼容层:
| 函数 | 说明 |
|---|---|
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() | 框架端的面向对象写法,与上面三个函数等价。 |
读取宿主模块数据请用下面的惰性查询函数,不要自己写 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 不在其中,对非管理组恒隐藏。插件入口在「插件市场」。 |
以 lifegame 加一个小游戏为例,lifesystem 完全同构。一共 5 步:
| 步骤 | 改哪个文件 | 做什么 |
|---|---|---|
| 1 | src/Controller/XxxController.php | 新建控制器,方法名对应动作(如 index() / api())。渲染用 $this->render('xxx/index', [...]),管理页务必先做管理员校验。 |
| 2 | templates/xxx/index.php | 页面模板;后台页由 layout_main.php 包裹,前台页由 layout_public.php 包裹。 |
| 3 | public/index.php | 加一条 case 'xxx': 分发;同时把 'xxx' => '页面标题' 加进 $pageTitles。免登录页(给游客看的)还要把 route 加进「未登录也可访问」白名单数组,否则会被重定向到登录页。 小技巧:未匹配到的 route 会走动态分发( base-action → BaseController::action()),命名规范的话可以不用写 case。 |
| 4 | templates/layout_main.php | 在对应分组的 sidebar-flyout 容器里加一条 <a class="flyout-item" href="?route=xxx">;要新建分组就加一对 flyout-xxx / flyout-xxx-menu(id 必须成对)。 |
| 5 | 手机端入口(如有) | lifesystem / lifegame 的手机抽屉菜单是另一份模板,别漏加,否则手机上看不到入口。 |
plugin.json 里声明 menu(配合 pages 白名单)即可,不需要宿主改任何文件,宿主自动渲染,卸载自动消失。详见 5.6 插件菜单。改 admin/inc.php 的 $nav:加一行 'key' => ['xxx.php', '菜单名', '<svg …>'],,然后在 admin/ 下放 xxx.php 页面(顶部 require __DIR__ . '/inc.php'; 走统一登录与权限门禁)。
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\* / 各游戏 Model | lifegame 的游戏数据都走 Model 层(如房间、积分、战绩)。插件不直接读游戏表,需要宿主数据时请让宿主补一个惰性查询函数,规则同 5.4:function_exists() 守卫 + 失败返回空数组。 |
导航主题类插件(接管控航首页外观)必须遵守,否则生态里已有插件会静默失效:
head.meta 挂载点渲染在公共布局里,站点统计(zdtongji)靠它注入埋点脚本;换成整页 HTML 会让统计全部失效。layout_public.php 的 <head> 里已自动注入:浏览器标题(页面标题 + 后台「连接符」+ 站名)、<meta name="keywords">、<meta name="description">(数据来自后台「导航设置 → SEO 设置」,为空则不输出)。主题不要再写 <title> 或这两类 meta——重复输出会让搜索引擎取错、且覆盖后台设置。需要额外 meta(如 og: 分享卡)仍走 head.meta 挂载点。lifesystem_nav_nofollow_attr($b['url'])(白名单里的网址会自动豁免),不要自己判断 rel。lifesystem_nav_jump_url() 只负责跳地址与计数,不含 nofollow 属性。.search-box(搜索框容器)与 #searchInput(输入框)。热点热榜(redianbang)的 JS 靠它们在「搜索框下方」定位插入位,找不到就 return false 静默不显示,不报错、不打日志,极难排查。lifesystem_nav_jump_url($id),不要直接写外链,否则点击量不计数,热门排序与站点统计失真。lifesystem_home_settings() 的开关:后台关掉的模块(域名/米友/联系方式/文章),主题也不要显示。if (!function_exists('db')) { /* 自带 PDO 兜底 */ }。这样即使装在未升级的站点上也不会白屏。admin/plugin_run.php?code=,框架端是 ?route=plugin-run&code=。硬编码会在另一端全部 404;请从当前请求推导,或直接用宿主函数。做小游戏 / 娱乐页的插件,最想要的就是「我的页面直接出现在左侧菜单里」。做法:页面文件放进 pages 白名单,再在 menu 里声明入口,宿主负责渲染。
核心内置了一个「小游戏」菜单组:你只要把 menu[].group 写成 "小游戏"(兼容 "娱乐" / "entertainment"),插件就会自动归类到这个组——不需要自己写 menuGroups。多个小游戏插件都写 "小游戏",就自动并成一个「小游戏」组,各占一行。
只有当你想要一个自定义名称 / 图标 / 排序的专属组时,才需要用到 menuGroups(见 5.6.1)。
{
"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。 |
系统 / 记账 / 规划 / 导航 / 工具箱 / 主题管理 / 文章管理 / 域名管理 / 图书 / 负债 / 报销 / 简历 / 正念 / 学习 / 线上娱乐 / 线下娱乐 / 插件市场 / 设置 / 首页 / 关于 等宿主自带菜单名,组名会被自动改成「XXX·插件」,避免伪装成系统菜单(lifesoso 另有自己的一套保留字)。「小游戏 / 娱乐」本身不是保留字——多个游戏插件用「小游戏」当组名是完全正常、也是推荐的用法。menu[].group 写组 id 就走 menuGroups 定义;写一个普通中文串(旧写法)就当组名用、自动按名字合并——两种写法可以并存。menuGroups 也不写 group 的项,进默认组「插件应用」(lifesoso 为「插件」)。group,或写 group:"插件应用",都会自动归入「插件应用」组(两者等价,宿主已做去重,不会出现两个「插件应用」)。推荐普通插件直接不写 group,让入口统一收在「插件应用」下,避免左菜单组被一个个插件撑多。plugin.json 里声明了 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。 | |
admin | true = 仅管理员可见(框架端生效)。默认所有登录用户可见。 | |
target | 填 _blank 则新窗口打开(自动带 rel="noopener")。 | |
public_landing | lifesystem 公开导航首页入口开关。填 true 时,该插件的入口会出现在 lifesystem 的公开导航首页「小游戏」区块(需后台「域名管理 → 导航首页显示设置」开启小游戏显示)。条件:① 插件已启用;② menu 里有 group 为 "小游戏" / "娱乐" / "entertainment" 的项(优先取它),否则取第一个 menu 项;③ 后台总开关开启。默认不显示。 |
| 端 | 渲染位置 | 访问门槛 |
|---|---|---|
| lifesystem | PC:左侧栏「工具箱」之后、「系统」之前,每个菜单组 = 一个可展开分组(悬停展开,样式与宿主自带分组一致)。 移动端 / 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 字面名分组),不会报错。route 里的 file 必须同时出现在 pages 里,否则打开时提示「未在 pages 中声明」。?(写 plugin-page&code=…);lifesoso 后台必须带(写 plugin_page.php?code=…)。plugin-page 页面对所有登录用户开放——涉及管理/敏感数据的页面请放 admin 入口(走 plugin-run),并在 menu 里标 "admin": true。flyout-* 之类的 id):会导致菜单重复、悬浮层套悬浮层,且客户端一升级就残留。菜单一律用 menu / menuGroups 声明,宿主负责渲染与去重。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>
menu 里把 group 写成 "小游戏",让它自动归入内置的小游戏菜单组(不要写 menuGroups);页面文件放进 pages 白名单,只输出页面主体,宿主会包页头页脚。」AI 照本段改即可,无需理解菜单合并细节。主题类插件用来自定义公开导航首页的页面主体(卡片布局、配色、模块排列)。和菜单一样是声明式的:插件声明 theme,宿主负责渲染,插件不改宿主任何文件。
{
"code": "glow_nav",
"name": "光影导航主题",
"version": "1.0.0",
"theme": { "scope": "landing", "entry": "theme.php", "name": "光影导航" },
"admin": "admin.php",
"pages": ["theme.php"]
}
scope:作用范围,目前只支持 landing(公开导航首页)。写其它值视为非主题插件。entry:主题模板文件(插件目录内的单层 .php),默认 theme.php。name:主题管理页显示的名称,默认用插件 name。"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 单值)。主题插件之间的样式请用自己独有的类名前缀,避免互相污染。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 必须保留(热点热榜等插件靠它定位插入位)。样式用纯色、明/暗都避开渐变,符合规范。在 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 变量上,就完成「主题设置 → 前台即时生效」的闭环。该文件只输出页面主体,页头页脚由宿主后台布局统一包裹。后台「文章管理」维护的文章可展示在导航首页;分类用于给文章分组,前台文章列表已支持按分类筛选。
| 函数 | 说明 |
|---|---|
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);详情页显示所属分类,并列出「同分类的其他文章」。cat-ID)、排序(越大越靠前)。删除分类不会删文章,该分类下的文章自动变为「未分类」。lifesystem 后台「搜索引擎」菜单组(管理员)让站长配置公开导航首页搜索框上展示哪些外部搜索引擎:
route=search-engine-config):增删改条目 + 拖拽/上下移排序;字段:图标(emoji/文字或图片地址)、名称、搜索跳转地址({q} 表示关键词)、排序号、是否在首页展示。route=search-engine-display):勾选哪些条目出现在首页搜索框标签页上;未勾选的仅后台存在、不在前台展示。默认已预置百度 / 必应 / 谷歌 / 必应国际 / 哔哩哔哩,站长可自由增删。「站内」搜索固定始终展示(搜的是站内公共标签库),不在此配置之列。首次安装(表为空)自动预置常用引擎;升级存量库不覆盖站长已有的配置。
| 函数 | 说明 |
|---|---|
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 新窗口)。宿主内置默认主题已实现,主题可照做。lifesoso 的 Tab 页(/tab.php)是一个「组件中心」:顶部搜索 + 中部组件卡片网格 + 底部 Dock 导航。你只要把你的插件做成组件,就自动进入后台「组件管理」菜单——用户在你的插件详情页点「启用组件」后,组件卡片立刻出现在登录用户的 Tab 页;卸载插件时组件行自动清理。不需要宿主改任何文件,也不需要建表。
menu 声明的插件入口只渲染在后台侧栏(见 5.6),普通用户在前台看不到。所以在 lifesoso 上要让用户用得到你的功能,就必须是组件:云端分类选「小组件」的插件走这里;「小游戏」要进 lifesoso 也得写成组件(同一份包在 lifesystem / lifegame 仍可走 menu 左菜单,互不影响)。详见 5.11。plugin.json 里加一个 components 数组 + 提供一份渲染 .php,就算完成。lifesystem / lifegame 装你的插件时会自动忽略 components 字段(不影响其他功能),所以是三端安全的写法。{
"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。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 节)。 |
<html> 外层——你已经处在一个 <section class="comp-card"> 里面,只输出内部内容。mytodo_items),避免和其他插件冲突。die / exit——会让宿主后续组件不渲染;用 return 或输出空串兜底。component.php 里做全表扫描。plugin.json 的 endpoints 白名单里声明 API 文件(见 5.3),JS 里用相对路径调用。component_bootstrap() 会造成无谓 IO;tab.php 已经跑过了。targets 只写 ["lifesoso"];如果你想让插件在三端都能装(其他功能三端通用,但只有 lifesoso 会启用组件),targets 写全 ["lifesystem","lifesoso","lifegame"] 也行——lifesystem/lifegame 会装但忽略 components 字段。「组件管理」是 lifesoso 后台菜单,列出所有声明了 components 的插件 / 官方内置组件,站长可启用 / 停用 / 改排序 / 改单卡片尺寸。新插件只要按下述做,就能自动出现在这里,用户在前台 Tab 页直接配置、调用,无需改宿主任何代码、无需建表。
| 字段 | 必填 | 说明 |
|---|---|---|
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 递增。后台可覆盖。 |
<section> 外层(外层由宿主 component_render() 生成,含标题栏与「拖动 / ⋯ / ✕ / 打开」控件)。$user(当前登录用户,一定非空)、$comp(当前组件元数据行)。db() / h() / csrfToken() / userTabPrefs() / tabPrefsSave() 等(见 5.4)。user_id 隔离;组件内联 <script> 即可(宿主已注入 window.SITE.csrf 供提交用),组件是独立 IIFE,不要依赖 tab.js 的私有函数。die/exit(会让后续组件不渲染);重查询加索引或缓存,别在 component.php 里全表扫描。组件若需持久化用户自己的配置或数据(如文件夹、日历日程),统一走 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 |
common.php 的 userTabPrefs()(默认空值 + 读取 + 返回)与 tabPrefsSave()(写入 JSON)里各加一处,无需建表、无需迁移。同一账号多端自然同步。plugin.json(声明 components)+ component.php,部署到 plugins/<code>/(用户/市场)或 components/<code>/(官方内置)。component_bootstrap() 扫描后即把组件写入 component_states 表,后台「组件管理」立刻出现一条,默认启用。/tab.php)即可看到卡片,点卡片头「打开」弹窗或直接在网格内嵌使用。component_bootstrap() 发现目录不存在会自动清理对应组件行;你自己的业务表请在 uninstall.php 里清理。随内核分发包下发的官方组件放 components/<code>/(刻意不走 plugins/,以便随更新通道送达、不被站点保护规则跳过);用户 / 插件市场安装的组件放 plugins/<code>/。两处同名时官方内置优先。已内置:mytodo(待办)/ calendar(日历 + 我的日程)/ devtools(开发者工具箱)/ navboard / countdown / notepad / eatwhat / muyu / pomodoro / poetry。
redianbang(热点热榜)这类插件,按本节的 components 声明接入,自动出现在后台「组件管理」与登录用户 Tab 页。不要再往宿主里加内置热榜。redianbang 现状:已是一个完整插件,靠 hooks.head.meta 把热榜插到搜索框下方(LifeSOSO 搜索结果页右侧栏 / Life System 导航首页),数据源是百度 / 微博 / 知乎 / 头条 / 抖音 / B站 / 豆瓣等公开接口,按源缓存(默认 10 分钟)。下面只补一步:加一个 components 声明 + 一份 component.php,让热榜也能进「组件中心」。
{
"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 互不冲突,可并存(搜索框下方的热榜 + 组件中心的卡片是同一份数据两个入口)。<?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; ?>
{ok, name, code, id, list:[{title, url, hot}], updated}(见 api.php)。组件渲染是服务端 include,直接复用 rd_fetch_cached() 即可,不要再走一次 HTTP 自调用。要做「多榜单切换 / 总览」,把 display 改成 "modal"、在 component.php 里渲染全部勾选榜单并点开弹窗即可(可复用已有的 bang.php 总览逻辑)。在云端创建 / 编辑插件时,除了名称与简介,还必须选择插件分类。分类由平台后台配置(「插件库 → 分类管理」),默认四项:导航主题 / 小游戏 / 小组件 / 辅助工具。它不写进 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。宿主(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 里建目录,再用定时器或访问触发去更新),宿主侧什么都不用再改。
// 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 分钟内不再重试,所以你的下载逻辑偶发失败也不会白屏。后台有「立即同步今日壁纸」按钮可强制触发。file 指向插件目录之外:宿主会做 realpath 越界检查,含 ..、绝对路径、空字节的条目直接丢弃;data:/伪协议当图片地址,url 只允许 http(s);plugin.json 是否被正确解析(见 5);③壁纸目录名是否是约定的那几个。<code>_wallpaper_list)。用 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。宿主卸载时只删插件目录,不会替你删表——留下垃圾表是常见的驳回原因。try/catch 并 error_log();装完不要只看界面的成功提示,要去看日志和功能是否真的在。$PLUGIN_DIR 内;卸载时该目录会被整体删除。hello_xxx),并在 uninstall.php 里 DROP。eval / system / exec / shell_exec / passthru / proc_open / assert / 反引号 / 变量 include 落马 / file_put_contents 写 .php。云端会自动扫描并在后台标红;确需使用(如采集类插件调 curl)请在提交说明里写清用途。SHA256 必须与云端登记一致,不一致安装中止。h() / htmlspecialchars();处理 POST 前先 checkCsrf()。| 坑 | 现象 | 正确写法 |
|---|---|---|
| 条件函数定义 + 提前调用 | 安装后功能缺失、界面却显示「安装成功」 | 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,静默跳过 |
宿主模板的空格随时会变。定位一律用正则,不要拼精确字符串。 |
| 坑 | 说明 |
|---|---|
硬编码 admin/plugin_run.php | lifesoso 用这个,框架端是 ?route=plugin-run&code=。硬编码会导致另一端链接全 404。 |
| 把插件目录当公网可达 | 框架端 web 根是 public/,插件目录在它之外。要对外提供 URL 必须声明 endpoints。 |
| 假设宿主一定有某个全局函数 | 老版本客户端可能没有。加 function_exists() 守卫。 |
| 表名不加前缀 | 与其它插件或核心表冲突,排查极难。 |
plugin.json 的 code 与文件夹名、云端登记三处一致,且 targets 包含当前产品。head.meta / login.form.bottom / profile.card)。file_put_contents(__DIR__.'/_debug.log', 'ran');,能生成文件说明宿主确实渲染了它。Ctrl/⌘+V 粘贴);② 直接上传一个完整版 HTML 介绍页。它是审核必看项,也是用户决定装不装的主要依据。Win+Shift+S、macOS 用 ⌘+Shift+4 截屏后,回到编辑器按 Ctrl/⌘+V,图片会自动上传并插入,不需要先存成文件;也可以直接把图片拖进编辑区,或点工具栏 🖼 选图。.html 文件,样式(CSS)和交互(JS)全部内联在里面,图片用外链或 base64 内嵌。建议包含:这个插件解决什么问题、核心功能清单、适用哪些人、怎么安装使用、效果截图、常见问题、版本更新记录;.html / .htm 文件,或含 index.html 的 .zip 包(包内只允许 html/css/js/图片/字体/音视频/pdf,最多 300 个文件、40MB),整体 ≤ 20MB;不允许 .php 等可执行后缀;草稿 draft → 待审核 submitted → 已上架 online / 已驳回 rejected;已上架后可被下架 offline(会注明下架理由)。待审核 pending → 已发布 published / 已驳回 rejected。只有 published 的版本会下发到客户端。平台审核看两件事:资料是否真实准确、代码是否安全守规矩。下面是被驳回的高频原因,提交前照着自查一遍能省一轮往返。
| 驳回原因 | 典型情况 | 怎么改 |
|---|---|---|
| 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 |
| 货不对板 | 简介/截图与实际功能不符 | 如实描述;功能未完成的先别提交 |
| 资料不规范 | 名称堆砌关键词、简介含联系方式/外部链接 | 名称简洁、简介只写功能 |
| 类型 | 客户端表现 | 说明 |
|---|---|---|
| 免费 | 「安装」按钮,直接装 | price_type=free,无需授权。 |
| 付费 | 「前往购买」 | price_type=paid + price(积分)。跳门户用积分购买,产生授权后客户端才拿到下载地址。 |
| 试用 | 「免费试用 N 天」 | 设了 trial_days>0 时出现,一步领取并安装。 |
客户端内核调用 /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 再安装;没有安装权时该字段为空。一个「后台页 + 挂载点 + 建表」的完整骨架,改改名就能用。
{
"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"]
}
{
"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"),它的菜单就会并进已有的「小游戏」组,左菜单不会被一个个插件撑长。
<?php
?>
<div style="padding:10px;text-align:center;color:#0d9488">
👋 Hello Life 已挂载(挂载点:<?= h($__hook_name) ?>)
</div>
<?php
?>
<style>.hello-badge{color:#0d9488;font-weight:600}</style>
<?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(); // ← 调用必须放在定义之后
<?php
try { db()->exec("DROP TABLE IF EXISTS hello_items"); } catch (Throwable $e) {}
<?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(根含上述文件)→ 云端上传 → 发布 → 客户端市场安装并「打开」。
plugin.json 在 zip 的根级;code 与文件夹名、云端登记三处一致。../、无绝对路径、无隐藏段(. / ..)。version 是三段式(如 1.0.0),且比已发布版本大。targets 覆盖了你要支持的所有端。hooks / endpoints 里声明的文件都真实存在,名字拼写正确。install.php 的函数调用都在定义之后(见 8.1)。php -l 文件名 语法检查。uninstall.php 里有对应 DROP。h(),写操作前用了 checkCsrf()。theme;小游戏→menu,LifeSOSO 端用 components;小组件→components;辅助工具→hooks/后台页)。见 5.11。wallpaper 声明(或约定的 wallpapers/ 目录)与列表里的图片真实存在,扩展名 / 体积合规;「每日跟随」已在宿主支持的最低版本上实测。见 5.12。php -l 文件名。这一步能挡掉绝大多数低级错误。components + 渲染 .php),它就会以卡片形式出现在登录用户的 Tab 组件中心;同一份包在 lifesystem / lifegame 仍走 menu 左菜单,互不影响(见 5.10)。hooks;要存数据就自己建带前缀的表。plugin.json 都写一份相同 id 的 menuGroups(如 {"id":"fun","name":"小游戏"}),并把 menu[].group 填成这个 id,宿主会按 id 自动合并成一个组(详见 5.6)。menuGroups,会退回按 group 字面名分组,不会报错。插件使用中遇到问题、想投诉或建议某个插件、或对本文有疑问,请发邮件至 9041708@qq.com,我们会尽快处理并持续完善本文档。
开发者在自己服务器(或本机)部署好宿主并激活授权后,把开发中的插件整个文件夹丢进宿主的 plugins/<code>/ 目录,后台「插件市场」会自动识别出「本地未上架插件」,点「安装并测试」即可本机加载运行。全程不联网、不依赖 lifebs 授权、不触发云端审核。
| 端 | 状态 | 说明 |
|---|---|---|
| lifesystem | 已实现 | 当前版本起支持本地未上架插件 |
| lifegame | 待同步 | 架构同构,后续版本支持 |
| lifesoso | 待同步 | 过程式实现,后续版本支持 |
plugins/<code>/,目录名必须等于插件 code,且内含合法 plugin.json。install.php 建表 → 直接启用。跳过云端 code 校验、跳过下载、跳过 targets 限制。plugins/<code>/ 即可。| 维度 | 本地插件(source=local) | 市场插件(source=cloud) |
|---|---|---|
| 来源 | 开发者自己放目录 + 后台安装 | 云端市场下载安装 |
| 前台标记 | 已安装列表显示「本地插件」徽章 | 无特殊徽章 |
| 市场升级 | 不显示升级,仅在有云端版时显示「用市场版覆盖」(需确认) | 正常显示升级 |
| 点击统计 | 不计入云端统计 | 正常上报 |
| 程序更新 | 两者都在 plugins/ 下,均受部署更新保护规则保护,不会被程序自动更新删除 | |
不会。宿主更新保护规则已包含 plugins/ 目录,程序自动更新只清理「服务端包移除且不在保护名单」的文件,插件目录会被保留,开发者更新宿主版本后本地插件仍能继续测试、不报错。
plugin.json 补全 targets(声明适用端),否则正式市场安装会被拒。-dev / -local 后缀),上架版请用正式语义化版本。install.php 的异常(不吞错误),按红字提示修。php.ini 设 display_errors = On 看报错(生产环境务必关回)。plugin.json、且目录确实在宿主 plugins/ 下。