插件开发指南
概述
InnoShop 的插件机制是一个功能强大的工具,它允许开发者以模块化的方式扩展商城系统的功能。利用 Laravel 框架的 ServiceProvider,插件可以轻松地注册到系统中,涵盖 MVC、数据库迁移(migrations)、路由(routes)、视图(views)、语言包(lang)、中间件(middleware)以及命令行脚本(console command)等。
插件原理
InnoShop 插件基于 Laravel 的自动发现和引导机制,确保插件的各个组成部分能够被系统识别和加载。插件的 PHP 代码遵循 PSR-4 自动加载规范,即目录名和类名应采用 StudlyCaps(大驼峰命名法)。
插件目录结构
一个典型的插件目录结构如下:
PluginName/
├── Boot.php # 插件启动类(必需)
├── config.json # 插件配置信息(必需)
├── fields.php # 配置字段定义(可选)
├── composer.json # Composer依赖(可选)
├── Controllers/ # 控制器目录(可选)
│ ├── Front/ # 前台控制器
│ └── Panel/ # 后台控制器
├── Lang/ # 语言包目录(必需,至少中英文)
│ ├── en/ # 英文语言包
│ │ ├── common.php # 通用翻译
│ │ ├── front.php # 前台翻译
│ │ └── panel.php # 后台翻译
│ └── zh-cn/ # 简体中文语言包
│ ├── common.php
│ ├── front.php
│ └── panel.php
├── Models/ # 数据模型(可选)
├── Migrations/ # 数据库迁移(可选)
├── Routes/ # 路由定义(可选)
│ ├── front.php # 前台路由
│ └── panel.php # 后台路由
├── Services/ # 服务类(可选)
├── Libraries/ # 工具类(可选)
├── Views/ # 视图文件(可选)
│ ├── front/ # 前台视图
│ └── panel/ # 后台视图
├── Public/ # 公共资源(可选)
│ ├── css/
│ ├── js/
│ └── images/
├── Repositories/ # 数据仓库(可选)
└── Middleware/ # 中间件(可选)注意:在 InnoShop 插件的目录结构中,Routes 目录需要包含特定命名的文件:panel.php 用于管理后台路由配置,以及 front.php 用于前端路由配置。
插件类型对比示例
- 支付插件(Stripe、Paypal):结构简单,主要包含 Boot.php、fields.php、config.json
- 功能插件(Coupon):结构完整,包含 MVC 全栈结构
- 基础插件(BankTransfer):最小化结构,仅必需文件
config.json 规范
基本格式
{
"code": "plugin_code",
"name": {
"zh-cn": "插件中文名称",
"en": "Plugin English Name"
},
"description": {
"zh-cn": "插件详细中文描述",
"en": "Detailed English description"
},
"type": "plugin_type",
"version": "v1.0.0",
"icon": "/image/logo.png",
"author": {
"name": "Author Name",
"email": "author@example.com"
},
"panel_route": "pluginname.panel.index"
}插件类型 (type)
type 必须是以下合法值之一,否则插件安装时会报错:
| type | 说明 | 示例 |
|---|---|---|
payment | 支付方式 | Stripe, Paypal, Alipay |
shipping | 配送方式 | FlexShipping |
price | 商品价格 | 价格策略 |
orderfee | 订单费用 | 手续费计算 |
marketing | 营销推广 | Coupon, Gifts |
service | 外部服务 | 外部服务集成 |
feature | 功能扩展 | 基础功能扩展 |
类型已变更
旧版使用 billing 表示支付方式,现已统一为 payment。如果你的插件还在使用 billing,请改为 payment。
语言 locale 格式
config.json 和 Lang/ 目录中的语言代码使用短横线格式(如 zh-cn),不是下划线格式(如 )。zh_cn
# 正确
"name": { "zh-cn": "支付宝", "en": "Alipay" }
Lang/zh-cn/common.php
# 错误
"name": { "zh_cn": "支付宝", "en": "Alipay" }
Lang/zh_cn/common.phppanel_route 字段
panel_route 是插件列表中点击插件时跳转的路由名。没有它,后台无法进入插件管理页面。
// 缺少 panel_route — 后台插件列表点击无响应
{
"code": "service_bot",
"name": {"en": "ServiceBot"}
}
// 正确 — 添加 panel_route
{
"code": "service_bot",
"name": {"en": "ServiceBot"},
"panel_route": "servicebot.conversations"
}插件配置字段说明
一个简单的 fields.php 如下所示:
return [
[
'name' => 'type', // 字段名称
'label' => '类型' // 与 label_key 二选一, label 表示字段显示名称
'label_key' => 'common.type', // 与 label 二选一, 'common.type' 将调用插件语言包定义的 key 来展示字段名称
'type' => 'select', // 字段类型, 具体可选类型将在下面章节讲解
'options' => [
['value' => 'fixed', 'label_key' => 'common.fixed'],
['value' => 'percent', 'label_key' => 'common.percent'],
],
'required' => true, // 是否必填项
'rules' => 'required', // 字段验证规则, 参考 https://laravel.com/docs/11.x/validation#available-validation-rules
],
[
'name' => 'value',
'label_key' => 'common.value',
'type' => 'string',
'required' => true,
'rules' => 'required',
],
];fields.php 必须是扁平数组
fields.php 不支持嵌套分组格式,必须是扁平的一维数组:
// 错误:InnoShop 不支持嵌套分组
return [
'general' => [
['name' => 'enabled', ...],
],
];
// 正确:扁平数组
return [
['name' => 'enabled', ...],
];配置字段类型说明
- bool - 布尔类型,用于表示开关状态。
- checkbox - 复选框,适用于多选项场景(必须带
options数组)。 - image - 图片上传,保存上传图片的路径。
- multi-rich-text - 多语言富文本框,适用于多语言环境下的富文本编辑。
- multi-string - 多语言字符串,适用于多语言环境下的简单文本编辑。
- multi-textarea - 多语言多行文本框,适用于多语言环境下的多行文本输入。
- rich-text - 富文本框,用于编辑格式化文本内容。
- select - 下拉选项,提供选择单一选项的功能。
- string - 字符串,用于输入和存储简短的文本信息。
- textarea - 多行文本框,适用于长文本输入。
配置字段通用参数
所有字段类型都支持以下通用参数:
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| name | string | 是 | 字段名称,用于表单提交和数据库存储 |
| label | string | 是 | 字段显示名称 |
| type | string | 是 | 字段类型 |
| required | boolean | 否 | 是否必填,默认 false |
| description | string | 否 | 字段说明文本 |
| rules | string | 否 | Laravel 验证规则 |
多语言字段
支持多语言的字段类型:multi-string、multi-textarea、multi-rich-text。这些字段类型需要设置:
'is-locales' => true,
'multiple' => trueoptions 字段格式规范
对于 select、checkbox 等需要选项的字段类型,options 必须使用数组格式,而不是键值对格式。
// 错误格式(旧版本格式,已废弃)
'options' => [
'value1' => 'Label 1',
'value2' => 'Label 2',
]
// 正确格式(标准格式)
'options' => [
['value' => 'value1', 'label' => 'Label 1'],
['value' => 'value2', 'label' => 'Label 2'],
]各类型字段示例
string(单行文本)
[
'name' => 'api_key',
'label' => 'API密钥',
'type' => 'string',
'required' => true,
'rules' => 'required|min:32',
]textarea(多行文本)
[
'name' => 'field_name',
'label' => '字段标签',
'type' => 'textarea',
'required' => true,
'description' => '字段说明'
]select(下拉选择)
[
'name' => 'environment',
'label' => '运行环境',
'type' => 'select',
'options' => [
['value' => 'sandbox', 'label' => '沙盒环境'],
['value' => 'production', 'label' => '生产环境'],
],
'required' => true,
'emptyOption' => false,
]bool(布尔开关)
[
'name' => 'enabled',
'label' => '启用状态',
'type' => 'bool',
]开关类字段用 bool 类型
开关类字段使用 bool 类型(不需要 options)。checkbox 类型必须带 options 数组,否则会报错。
checkbox(复选框)
[
'name' => 'features',
'label' => '功能选项',
'type' => 'checkbox',
'options' => [
['value' => 'feature1', 'label' => '功能1'],
['value' => 'feature2', 'label' => '功能2'],
],
]image(图片上传)
[
'name' => 'logo',
'label' => 'Logo图片',
'type' => 'image',
'recommend_size' => '200x100',
]配置字段使用说明
无论是在模型(Model)、视图(View)还是控制器(Controller)中,您都可以使用 plugin_setting('pluginName', 'configKey') 函数来获取插件的配置值。
配置字段最佳实践
- 字段名称使用小写字母和下划线
- 必填字段设置
required和rules - 图片上传字段建议设置
recommend_size - 下拉选择框建议设置
emptyOption - 多语言字段必须设置
is-locales和multiple - 所有字段都应该提供清晰的
description - 验证规则使用 Laravel 的验证规则语法
- 使用
label_key引用语言文件,而非硬编码label
// 不推荐:直接写 label,不支持多语言
['name' => 'enabled', 'label' => '启用插件', 'type' => 'bool']
// 推荐:用 label_key 引用语言文件
['name' => 'enabled', 'label_key' => 'panel.enabled', 'type' => 'bool']label_key 会自动从 Lang/{locale}/panel.php 中查找对应翻译。
插件启动类(Boot.php)
基本结构
Boot.php 是插件的启动类,包含 init 方法用于注册钩子。入口文件代码如下所示:
namespace Plugin\PluginName;
use Plugin\PluginName\Services\PluginService;
class Boot
{
/**
* 初始化插件时执行的方法。
*/
public function init(): void
{
// 1. 注册后台菜单
listen_hook_filter('panel.component.sidebar.plugin.routes', function ($data) {
$data[] = [
'route' => 'plugin.panel.index',
'title' => trans('PluginName::common.menu_title'),
];
return $data;
});
// 2. 注册支付处理(支付插件专用)
listen_hook_filter('service.payment.mobile_pay.data', function ($data) {
$order = $data['order'];
if ($order->payment_method_code != 'plugin_code') {
return $data;
}
$data['params'] = (new PluginService($order))->getPaymentData();
return $data;
});
// 3. 注册模板插入点
listen_blade_insert('checkout.confirm.before', function ($data) {
return view('PluginName::front.widget', $data);
});
// 4. 注册费用计算(营销插件专用)
listen_hook_filter('service.checkout.fee.methods', function ($classes) {
$classes[] = PluginFee::class;
return $classes;
});
}
}常用 Hook 点列表
| Hook名称 | 触发位置 | 参数类型 |
|---|---|---|
panel.component.sidebar.plugin.routes | 后台菜单注册 | array |
service.payment.mobile_pay.data | 移动端支付数据 | array |
service.checkout.fee.methods | 结账费用计算 | array |
checkout.confirm.before | 结账确认前 | array |
service.state_machine.machines | 状态机处理 | array |
模板 Hook 点
- 首页:blade
@hookinsert('home.content.top') @hookinsert('home.swiper.after') @hookinsert('home.content.bottom') - 产品页:blade
@hookinsert('product.show.top') @hookinsert('product.detail.after') @hookinsert('product.show.bottom')
Hook 部分可以参考 Hook 开发文档。
路由定义标准
路由文件自动加载约定
InnoShop 插件路由是自动加载的,不需要在 Boot.php 中手动注册。把路由文件放到 Routes/ 目录,使用正确的文件名,系统会自动加载。
不要在 Boot.php 中手动注册路由
BaseBoot 没有 loadRoutesFrom 方法(这是 ServiceProvider 的方法),在 Boot.php 中调用会导致 500 错误。
| 文件名 | 路径前缀 | 中间件 |
|---|---|---|
Routes/panel.php | {panel}/ | panel, admin_auth |
Routes/panel-api.php | api/panel/ | panel_api |
Routes/front.php | /{locale}/ | front |
Routes/front-api.php | api/ | api, auth:sanctum |
Routes/root.php | / | front |
Routes/main.php | / | front |
前台路由(Routes/front.php)
use Plugin\PluginName\Controllers\Front\PluginController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'plugin'], function () {
Route::get('/', [PluginController::class, 'index'])->name('plugin.front.index');
Route::post('/submit', [PluginController::class, 'submit'])->name('plugin.front.submit');
});后台路由(Routes/panel.php)
use Plugin\PluginName\Controllers\Panel\PluginController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'plugin'], function () {
Route::get('/', [PluginController::class, 'index'])->name('plugin.panel.index');
Route::get('/create', [PluginController::class, 'create'])->name('plugin.panel.create');
Route::post('/', [PluginController::class, 'store'])->name('plugin.panel.store');
Route::get('/{id}/edit', [PluginController::class, 'edit'])->name('plugin.panel.edit');
Route::put('/{id}', [PluginController::class, 'update'])->name('plugin.panel.update');
Route::delete('/{id}', [PluginController::class, 'destroy'])->name('plugin.panel.destroy');
});控制器规范
前台控制器(Controllers/Front/PluginController.php)
namespace Plugin\PluginName\Controllers\Front;
use App\Http\Controllers\Controller;
use Plugin\PluginName\Services\PluginService;
use Illuminate\Http\Request;
class PluginController extends Controller
{
protected PluginService $pluginService;
public function __construct(PluginService $pluginService)
{
$this->pluginService = $pluginService;
}
public function index()
{
$data = $this->pluginService->getFrontData();
return view('PluginName::front.index', $data);
}
public function submit(Request $request)
{
$validated = $request->validate([
'field' => 'required|string',
]);
$result = $this->pluginService->processSubmission($validated);
if ($request->ajax()) {
return json_success('提交成功', $result);
}
return redirect()->back()->with('success', '提交成功');
}
}后台控制器(Controllers/Panel/PluginController.php)
namespace Plugin\PluginName\Controllers\Panel;
use App\Http\Controllers\Controller;
use Plugin\PluginName\Repositories\PluginRepo;
use Plugin\PluginName\Services\PluginService;
use Illuminate\Http\Request;
class PluginController extends Controller
{
protected PluginService $pluginService;
protected PluginRepo $pluginRepo;
public function __construct(PluginService $pluginService, PluginRepo $pluginRepo)
{
$this->pluginService = $pluginService;
$this->pluginRepo = $pluginRepo;
}
public function index()
{
$plugins = $this->pluginRepo->all();
return view('PluginName::panel.index', compact('plugins'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$plugin = $this->pluginService->create($validated);
return redirect()->panel_route('plugin.panel.index')
->with('success', '创建成功');
}
}视图规范
布局继承
{{-- 正确 --}}
@extends('panel::layouts.app')
{{-- 错误 --}}
@extends('admin::layouts.app')后台视图必须放在 Views/panel/ 目录,前台视图放在 Views/front/ 目录。
缩进规范
Blade 文件必须使用两个空格缩进。项目根目录的 .editorconfig 文件已配置 [*.blade.php] 使用两个空格缩进,请确保编辑器遵循此配置。
{{-- 正确 --}}
@section('content')
<div class="container">
<div class="row">
@if($condition)
<p>内容</p>
@endif
</div>
</div>
@endsection
{{-- 错误:四个空格缩进 --}}
@section('content')
<div class="container">
<p>内容</p>
</div>
@endsection空数据展示
<x-common-no-data />视图调用
// 错误:视图路径格式错误或缺少命名空间
return view('addtoany::share');
return view('share');
// 正确:使用插件名::路径格式
return view('AddToAny::share');语言包规范
语言包目录结构
PluginName/
├── Lang/
│ ├── en/
│ │ ├── common.php # 插件通用翻译
│ │ ├── front.php # 插件前台翻译
│ │ └── panel.php # 插件后台翻译
│ └── zh-cn/
│ ├── common.php
│ ├── front.php
│ └── panel.php通用语言包(Lang/en/common.php)
return [
'plugin_name' => 'Plugin Name',
'plugin_description' => 'Plugin Description',
'menu_title' => 'Plugin Menu',
'button_submit' => 'Submit',
'message_save_success' => 'Saved successfully',
'message_save_failed' => 'Save failed',
];后台语言包(Lang/en/panel.php)
return [
'menu_list' => 'List',
'menu_create' => 'Create',
'table_id' => 'ID',
'table_title' => 'Title',
'table_actions' => 'Actions',
'button_edit' => 'Edit',
'button_delete' => 'Delete',
];语言包调用
// 插件内调用
trans('PluginName::common.field_title'); // 调用插件通用翻译
trans('PluginName::panel.menu_title'); // 调用插件后台翻译
// 调用系统公共语言包
trans('common/status_active'); // 系统通用翻译
trans('panel/button_submit'); // 系统后台翻译{{-- 视图中使用 --}}
{{ trans('PluginName::common/plugin_name') }}
{{ trans('common/button_submit') }}
@lang('PluginName::common/message_save_success')语言包最佳实践
- 使用一维数组结构,便于自动化翻译
- 语言键名使用下划线连接,例如:
field_title、button_submit - 按功能模块划分前缀,例如:
menu_、button_、field_、message_ - 配置字段的标签键名使用相对路径,无需包含插件名
- 优先使用系统公共语言包中已有的翻译
- 系统语言包使用斜杠(
/),插件语言包使用双冒号加斜杠(::/)
静态资源
引用静态资源
使用 plugin_asset 助手函数引用静态资源。
// 错误:直接用 asset() 引用 plugins 目录(会 404)
asset('plugins/ServiceBot/Public/js/widget.js')
// 正确:用 plugin_asset() 自动复制到 public/static/plugins/
plugin_asset('service_bot', 'js/widget.js')
// 输出:http://domain.com/static/plugins/service_bot/js/widget.jsplugin_asset() 会自动将 Public/ 下的文件复制到 public/static/plugins/{code}/,然后返回 URL。
插件开发流程
以开发 友情链接(PartnerLink) 插件为例:
1. 创建插件目录
在 /plugins 目录下创建名为 PartnerLink 的目录,并创建必须的文件 config.json。
{
"code": "partner_link",
"name": {
"zh-cn": "友情链接",
"en": "Partner Links"
},
"description": {
"zh-cn": "友情链接",
"en": "Partner Links"
},
"type": "feature",
"version": "v1.0.0",
"icon": "/image/logo.png",
"author": {
"name": "InnoShop",
"email": "edward@innoshop.com"
}
}2. 编写代码
在插件根目录创建入口文件 Boot.php,并根据需要开发插件的控制器、模型、视图等。
namespace Plugin\PartnerLink;
use Plugin\PartnerLink\Models\PartnerLink;
class Boot
{
public function init(): void
{
// 修改后台管理界面左侧菜单,添加友情链接的菜单项
listen_hook_filter('component.sidebar.plugin.routes', function ($data) {
$data[] = [
'route' => 'partner_links.index',
'title' => '友情链接',
];
return $data;
});
// 在 Blade 模板的 footer 顶部插入友情链接的 HTML
listen_blade_insert('layouts.footer.top', function ($data) {
$data['links'] = PartnerLink::query()->where('active', 1)->limit(10)->get();
return view('PartnerLink::front.partner_links', $data);
});
}
}3. 配置文件
如果需要配置,则定义 fields.php。
4. 启用插件
插件的启用和禁用通过 InnoShop 的后台管理系统进行。
JavaScript 与前端开发
JavaScript 代码位置
必须使用 @push('footer') 而不是 @push('scripts')。
{{-- 正确 --}}
@push('footer')
<script>
$(document).ready(function() {
// JavaScript 代码会正常执行
console.log('jQuery 正常工作');
});
</script>
@endpush
{{-- 错误 — 这样不会生效 --}}
@push('scripts')
<script>
// InnoShop 的布局系统使用 @stack('footer') 渲染 JS,不是 @stack('scripts')
</script>
@endpush原因: InnoShop 的布局系统使用 @stack('footer') 来渲染 JavaScript 代码,而不是 @stack('scripts')。
前台资源注入方式
如果需要在所有前台页面注入 JS/CSS,可以使用 View::creator:
// Boot.php 中注册
private function registerFrontendWidget(): void
{
View::creator('layouts.app', function ($view) {
static $pushed = false;
if ($pushed) return;
$pushed = true;
$js = plugin_asset('service_bot', 'js/widget.js');
$css = plugin_asset('service_bot', 'css/widget.css');
$html = <<<HTML
<link rel="stylesheet" href="{$css}">
<script src="{$js}"></script>
<script>MyWidget.init({...});</script>
HTML;
$view->getFactory()->startPush('footer', $html);
});
}注意 static $pushed
需要使用 static $pushed 防止重复注入。前台布局视图名是 layouts.app,不是 front::layouts.app。
Panel Axios 注意事项
后台(Panel)的 axios 经过 panel-http.js 拦截器处理,响应已被自动解包一层。在 then 回调中,res 直接就是后端返回的 JSON 对象(如 {success: true, message: ...}),不需要 再通过 res.data 访问。
// 错误 — 多了一层 .data
axios.post(url, params).then(function (res) {
if (res.data && res.data.success) { ... }
});
// 正确 — res 已经是 response.data
axios.post(url, params).then(function (res) {
if (res && res.success) { ... }
});catch 中的 error 没有被解包
catch 中的 error 仍然是原始的 axios error 对象,需要通过 error.response.data 获取错误信息。
JavaScript 多语言
// 在视图中定义翻译
<script>
window.translations = {
system: {
submit: "{{ trans('common/button_submit') }}",
cancel: "{{ trans('common/button_cancel') }}"
},
plugin: {
save: "{{ trans('PluginName::common/message_save_success') }}",
error: "{{ trans('PluginName::common/message_save_failed') }}"
}
};
</script>错误处理规范
控制器错误处理
- 在 action 方法中使用 try-catch 包装服务实例化
- 使用
session()->flash('error', $e->getMessage())显示错误 - 始终返回视图,不要重定向到错误页面
public function index()
{
try {
$service = new PluginService;
$data = $service->getData();
return view('PluginName::index', compact('data'));
} catch (Exception $e) {
session()->flash('error', $e->getMessage());
return view('PluginName::index', ['data' => collect()]);
}
}服务层错误处理
- 服务类可以直接抛出异常
- 使用具体的异常类型
- 提供有意义的错误消息
性能优化建议
条件加载资源
public function init(): void
{
if (plugin_setting('enabled')) {
// 只在启用时注册 Hook
listen_blade_insert('hook.name', function() {
return view('Plugin::widget');
});
}
}缓存使用
use Illuminate\Support\Facades\Cache;
public function getPluginData()
{
return Cache::remember('plugin_data', 3600, function() {
return $this->generatePluginData();
});
}调试技巧
// 1. 添加日志调试
\Log::info('Plugin debug:', ['data' => $data]);
// 2. 使用 dd 调试
// dd($pluginData);
// 3. 检查 Hook 注册
listen_hook_filter('test.hook', function($data) {
\Log::info('Hook triggered:', ['data' => $data]);
return $data;
});踩坑实录
以下错误均来自实际开发过程,记录以便后续开发避免重复踩坑。
1. PSR-4 命名空间大小写不匹配
问题:目录名 MambaSms,但命名空间写 Plugin\MambaSMS,导致整个插件被 autoloader 跳过。
Class Plugin\MambaSMS\Boot located in ./plugins/MambaSms/Boot.php does not comply with psr-4 autoloading standard. Skipping.原因:PSR-4 要求命名空间大小写与目录名完全一致。
// 错误:命名空间与目录名大小写不一致
// 目录:plugins/MambaSms/
namespace Plugin\MambaSMS;
// 正确:与目录名一致
namespace Plugin\MambaSms;规则:插件目录名是什么,命名空间就是什么。MambaSms 目录 → Plugin\MambaSms 命名空间。
2. 在 Boot.php 中手动注册路由
问题:在 Boot.php 中调用 $this->loadRoutesFrom(),但 BaseBoot 没有这个方法(这是 ServiceProvider 的方法),导致 500 错误。
// 错误:BaseBoot 没有 loadRoutesFrom 方法
public function init(): void
{
$this->loadRoutesFrom(__DIR__ . '/Routes/admin.php');
}正确做法:把路由文件放到 Routes/ 目录,使用正确的文件名,系统会自动加载。
// 正确:Boot.php 不需要任何路由注册代码
public function init(): void
{
// 路由由 PluginServiceProvider 自动扫描 Routes/ 目录加载
// 这里只做事件监听、hook 注册等
}3. 路由文件命名不符合约定
// 错误命名 — 不被识别
Routes/admin.php
Routes/api.php
// 正确命名
Routes/panel.php → 后台面板路由
Routes/front-api.php → 前台 API 路由必须严格使用约定的文件名,参见上方「路由文件自动加载约定」表格。
4. fields.php 格式错误
使用嵌套分组
// 错误:InnoShop 不支持嵌套分组的 fields 格式
return [
'general' => [
['name' => 'enabled', ...],
],
];
// 正确:扁平数组
return [
['name' => 'enabled', ...],
];checkbox 类型缺少 options
// 错误:checkbox 必须有 options 数组,否则报 Undefined array key "options"
['name' => 'enabled', 'type' => 'checkbox', 'value' => true]
// 正确方案 1:用 bool 类型做开关(不需要 options)
['name' => 'enabled', 'type' => 'bool']
// 正确方案 2:checkbox 带 options
['name' => 'features', 'type' => 'checkbox', 'options' => [
['value' => 'a', 'label' => 'A'],
]]规则:开关类字段用 bool 类型,多选用 checkbox(必须带 options)。
5. 后台视图目录和布局错误
-- 错误:视图放在 Views/admin/ 而不是 Views/panel/,使用了错误的布局
@extends('admin::layouts.master')
-- 正确:视图放在 Views/panel/,使用正确的布局
@extends('panel::layouts.app')6. config.json 缺少 panel_route
panel_route 是插件列表中点击插件时跳转的路由名。没有它,后台无法进入插件管理页面。
// 缺少 panel_route
{
"code": "service_bot",
"name": {"en": "ServiceBot"}
}
// 正确:添加 panel_route
{
"code": "service_bot",
"name": {"en": "ServiceBot"},
"panel_route": "servicebot.conversations"
}7. 前台插件 JS/CSS 注入方式错误
// 错误:前台布局没有 'front::' 前缀
View::creator('front::layouts.app', ...);
// 错误:也没有 'admin::' 前缀
View::creator('admin::layouts.app', ...);
// 正确:前台布局视图名就是 'layouts.app'
View::creator('layouts.app', function ($view) {
static $pushed = false;
if ($pushed) return;
$pushed = true;
$view->getFactory()->startPush('footer', $html);
});8. 插件静态资源路径错误
// 错误:直接用 asset() 引用 plugins 目录
asset('plugins/ServiceBot/Public/js/widget.js') // 404
// 正确:用 plugin_asset() 自动复制到 public/static/plugins/
plugin_asset('service_bot', 'js/widget.js')9. 本地开发 Queue 不需要 Redis
本地开发如果没有 Redis 扩展,.env 改为:
# 本地开发用 sync,不需要 Redis
QUEUE_CONNECTION=sync
# 线上生产用 redis
# QUEUE_CONNECTION=redis同时注意杀掉后台残留的 php artisan queue:work redis 进程,否则会持续报 Class "Redis" not found 错误填满日志。
# 查找并杀掉
ps aux | grep queue:work
kill <PID>部署和发布检查清单
发布前检查
- [ ] 所有必需文件完整(Boot.php、config.json)
- [ ] 语言包完整(至少中英文)
- [ ] config.json 配置正确(type 合法、包含 panel_route)
- [ ] 版权信息完整
- [ ] 命名空间与目录名大小写一致
- [ ] fields.php 使用扁平数组格式
- [ ] 路由文件命名符合约定
- [ ] 测试通过
结语
InnoShop 的插件机制为开发者提供了一个灵活、扩展性强的平台。通过本指南,开发者可以快速掌握插件开发的基本流程和规范,为 InnoShop 贡献丰富的功能扩展。