Skip to content

插件开发指南

概述

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 规范

基本格式

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.jsonLang/ 目录中的语言代码使用短横线格式(如 zh-cn),不是下划线格式(如 zh_cn)。

# 正确
"name": { "zh-cn": "支付宝", "en": "Alipay" }
Lang/zh-cn/common.php

# 错误
"name": { "zh_cn": "支付宝", "en": "Alipay" }
Lang/zh_cn/common.php

panel_route 字段

panel_route 是插件列表中点击插件时跳转的路由名。没有它,后台无法进入插件管理页面。

json
// 缺少 panel_route — 后台插件列表点击无响应
{
    "code": "service_bot",
    "name": {"en": "ServiceBot"}
}

// 正确 — 添加 panel_route
{
    "code": "service_bot",
    "name": {"en": "ServiceBot"},
    "panel_route": "servicebot.conversations"
}

插件配置字段说明

一个简单的 fields.php 如下所示:

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 不支持嵌套分组格式,必须是扁平的一维数组:

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 - 多行文本框,适用于长文本输入。

配置字段通用参数

所有字段类型都支持以下通用参数:

参数名类型必填说明
namestring字段名称,用于表单提交和数据库存储
labelstring字段显示名称
typestring字段类型
requiredboolean是否必填,默认 false
descriptionstring字段说明文本
rulesstringLaravel 验证规则

多语言字段

支持多语言的字段类型:multi-stringmulti-textareamulti-rich-text。这些字段类型需要设置:

php
'is-locales' => true,
'multiple' => true

options 字段格式规范

对于 selectcheckbox 等需要选项的字段类型,options 必须使用数组格式,而不是键值对格式。

php
// 错误格式(旧版本格式,已废弃)
'options' => [
    'value1' => 'Label 1',
    'value2' => 'Label 2',
]

// 正确格式(标准格式)
'options' => [
    ['value' => 'value1', 'label' => 'Label 1'],
    ['value' => 'value2', 'label' => 'Label 2'],
]

各类型字段示例

string(单行文本)

php
[
    'name'     => 'api_key',
    'label'    => 'API密钥',
    'type'     => 'string',
    'required' => true,
    'rules'    => 'required|min:32',
]

textarea(多行文本)

php
[
    'name' => 'field_name',
    'label' => '字段标签',
    'type' => 'textarea',
    'required' => true,
    'description' => '字段说明'
]

select(下拉选择)

php
[
    'name'    => 'environment',
    'label'   => '运行环境',
    'type'    => 'select',
    'options' => [
        ['value' => 'sandbox', 'label' => '沙盒环境'],
        ['value' => 'production', 'label' => '生产环境'],
    ],
    'required'    => true,
    'emptyOption' => false,
]

bool(布尔开关)

php
[
    'name'  => 'enabled',
    'label' => '启用状态',
    'type'  => 'bool',
]

开关类字段用 bool 类型

开关类字段使用 bool 类型(不需要 options)。checkbox 类型必须带 options 数组,否则会报错。

checkbox(复选框)

php
[
    'name'  => 'features',
    'label' => '功能选项',
    'type'  => 'checkbox',
    'options' => [
        ['value' => 'feature1', 'label' => '功能1'],
        ['value' => 'feature2', 'label' => '功能2'],
    ],
]

image(图片上传)

php
[
    'name'         => 'logo',
    'label'        => 'Logo图片',
    'type'         => 'image',
    'recommend_size' => '200x100',
]

配置字段使用说明

无论是在模型(Model)、视图(View)还是控制器(Controller)中,您都可以使用 plugin_setting('pluginName', 'configKey') 函数来获取插件的配置值。

配置字段最佳实践

  1. 字段名称使用小写字母和下划线
  2. 必填字段设置 requiredrules
  3. 图片上传字段建议设置 recommend_size
  4. 下拉选择框建议设置 emptyOption
  5. 多语言字段必须设置 is-localesmultiple
  6. 所有字段都应该提供清晰的 description
  7. 验证规则使用 Laravel 的验证规则语法
  8. 使用 label_key 引用语言文件,而非硬编码 label
php
// 不推荐:直接写 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 方法用于注册钩子。入口文件代码如下所示:

php
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.phpapi/panel/panel_api
Routes/front.php/{locale}/front
Routes/front-api.phpapi/api, auth:sanctum
Routes/root.php/front
Routes/main.php/front

前台路由(Routes/front.php)

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)

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)

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)

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', '创建成功');
    }
}

视图规范

布局继承

blade
{{-- 正确 --}}
@extends('panel::layouts.app')

{{-- 错误 --}}
@extends('admin::layouts.app')

后台视图必须放在 Views/panel/ 目录,前台视图放在 Views/front/ 目录。

缩进规范

Blade 文件必须使用两个空格缩进。项目根目录的 .editorconfig 文件已配置 [*.blade.php] 使用两个空格缩进,请确保编辑器遵循此配置。

blade
{{-- 正确 --}}
@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

空数据展示

blade
<x-common-no-data />

视图调用

php
// 错误:视图路径格式错误或缺少命名空间
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)

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)

php
return [
    'menu_list' => 'List',
    'menu_create' => 'Create',
    'table_id' => 'ID',
    'table_title' => 'Title',
    'table_actions' => 'Actions',
    'button_edit' => 'Edit',
    'button_delete' => 'Delete',
];

语言包调用

php
// 插件内调用
trans('PluginName::common.field_title');     // 调用插件通用翻译
trans('PluginName::panel.menu_title');       // 调用插件后台翻译

// 调用系统公共语言包
trans('common/status_active');              // 系统通用翻译
trans('panel/button_submit');               // 系统后台翻译
blade
{{-- 视图中使用 --}}
{{ trans('PluginName::common/plugin_name') }}
{{ trans('common/button_submit') }}
@lang('PluginName::common/message_save_success')

语言包最佳实践

  1. 使用一维数组结构,便于自动化翻译
  2. 语言键名使用下划线连接,例如:field_titlebutton_submit
  3. 按功能模块划分前缀,例如:menu_button_field_message_
  4. 配置字段的标签键名使用相对路径,无需包含插件名
  5. 优先使用系统公共语言包中已有的翻译
  6. 系统语言包使用斜杠(/),插件语言包使用双冒号加斜杠(::/

静态资源

引用静态资源

使用 plugin_asset 助手函数引用静态资源。

php
// 错误:直接用 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.js

plugin_asset() 会自动将 Public/ 下的文件复制到 public/static/plugins/{code}/,然后返回 URL。

插件开发流程

以开发 友情链接(PartnerLink) 插件为例:

1. 创建插件目录

/plugins 目录下创建名为 PartnerLink 的目录,并创建必须的文件 config.json

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,并根据需要开发插件的控制器、模型、视图等。

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')

blade
{{-- 正确 --}}
@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

php
// 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 访问。

javascript
// 错误 — 多了一层 .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 多语言

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()) 显示错误
  • 始终返回视图,不要重定向到错误页面
php
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()]);
    }
}

服务层错误处理

  • 服务类可以直接抛出异常
  • 使用具体的异常类型
  • 提供有意义的错误消息

性能优化建议

条件加载资源

php
public function init(): void
{
    if (plugin_setting('enabled')) {
        // 只在启用时注册 Hook
        listen_blade_insert('hook.name', function() {
            return view('Plugin::widget');
        });
    }
}

缓存使用

php
use Illuminate\Support\Facades\Cache;

public function getPluginData()
{
    return Cache::remember('plugin_data', 3600, function() {
        return $this->generatePluginData();
    });
}

调试技巧

php
// 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 要求命名空间大小写与目录名完全一致。

php
// 错误:命名空间与目录名大小写不一致
// 目录:plugins/MambaSms/
namespace Plugin\MambaSMS;

// 正确:与目录名一致
namespace Plugin\MambaSms;

规则:插件目录名是什么,命名空间就是什么。MambaSms 目录 → Plugin\MambaSms 命名空间。

2. 在 Boot.php 中手动注册路由

问题:在 Boot.php 中调用 $this->loadRoutesFrom(),但 BaseBoot 没有这个方法(这是 ServiceProvider 的方法),导致 500 错误。

php
// 错误:BaseBoot 没有 loadRoutesFrom 方法
public function init(): void
{
    $this->loadRoutesFrom(__DIR__ . '/Routes/admin.php');
}

正确做法:把路由文件放到 Routes/ 目录,使用正确的文件名,系统会自动加载。

php
// 正确: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 格式错误

使用嵌套分组

php
// 错误:InnoShop 不支持嵌套分组的 fields 格式
return [
    'general' => [
        ['name' => 'enabled', ...],
    ],
];

// 正确:扁平数组
return [
    ['name' => 'enabled', ...],
];

checkbox 类型缺少 options

php
// 错误: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. 后台视图目录和布局错误

blade
-- 错误:视图放在 Views/admin/ 而不是 Views/panel/,使用了错误的布局
@extends('admin::layouts.master')

-- 正确:视图放在 Views/panel/,使用正确的布局
@extends('panel::layouts.app')

6. config.json 缺少 panel_route

panel_route 是插件列表中点击插件时跳转的路由名。没有它,后台无法进入插件管理页面。

json
// 缺少 panel_route
{
    "code": "service_bot",
    "name": {"en": "ServiceBot"}
}

// 正确:添加 panel_route
{
    "code": "service_bot",
    "name": {"en": "ServiceBot"},
    "panel_route": "servicebot.conversations"
}

7. 前台插件 JS/CSS 注入方式错误

php
// 错误:前台布局没有 '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. 插件静态资源路径错误

php
// 错误:直接用 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 错误填满日志。

bash
# 查找并杀掉
ps aux | grep queue:work
kill <PID>

部署和发布检查清单

发布前检查

  • [ ] 所有必需文件完整(Boot.php、config.json)
  • [ ] 语言包完整(至少中英文)
  • [ ] config.json 配置正确(type 合法、包含 panel_route)
  • [ ] 版权信息完整
  • [ ] 命名空间与目录名大小写一致
  • [ ] fields.php 使用扁平数组格式
  • [ ] 路由文件命名符合约定
  • [ ] 测试通过

结语

InnoShop 的插件机制为开发者提供了一个灵活、扩展性强的平台。通过本指南,开发者可以快速掌握插件开发的基本流程和规范,为 InnoShop 贡献丰富的功能扩展。

帆连科技 · 基于 OSL 3.0 许可发布