# backend-kit **Repository Path**: mouc/backend-kit ## Basic Information - **Project Name**: backend-kit - **Description**: 通用后台骨架:插件运行时、后台布局与组件、几个跨页面复用的机制。 - **Primary Language**: PHP - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-24 - **Last Updated**: 2026-09-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # backend-kit 通用后台骨架:插件运行时、后台布局与组件、几个跨页面复用的机制。 **它不认识业务。** 后台里凡是"这个项目特有"的东西——有哪些角色、菜单上有什么、 数据按什么维度收敛、日志落哪张表——都不写在包里,而是从配置和契约进来。 代价是接入时得多填几处,换来的是这个包能装到别的项目上。 包里提供的: | | | |---|---| | 插件运行时 | 目录扫描、启停、依赖检查、能力合并、路由与视图挂载 | | 布局与组件 | `layouts.app` 骨架、KPI 卡、图表、批量条、表单说明、快捷键层 | | 自带的挂件 | 顶栏全局搜索、顶栏改密码、侧栏插件分组(三个 Livewire 组件) | | 复用机制 | 批量勾选、筛选记忆、模型级操作留痕 | | 导出 | 导出按钮组件 + 通用落盘器(XLSX / CSV) | | 入口与菜单 | 后台前缀的唯一真源、菜单与快捷键同源 | 包不提供的:角色与能力的清单、数据范围那一维是什么、日志表长什么样、**后台里能搜到什么**。 这几样由宿主填配置或实现契约(见下)。 > 包依赖 `livewire/livewire ^4.4` 与 `maatwebsite/excel ^4.0`(导出器用)。 > 除了这两个,都是 `illuminate/*` 组件级依赖——不整依赖于 `laravel/framework`。 --- ## 接入 ### 1. 装进来 本包是独立仓库,按 VCS 引入(三种装法里当前用的这种): ```jsonc // 当前用法:包在独立仓库里,宿主按 VCS 引入 "repositories": [{ "type": "vcs", "url": "git@gitee.com:mouc/backend-kit.git" }], "require": { "tianlu/backend-kit": "^0.1.0" } // 本地开发包本体时,也可以临时用 path 指向你 clone 下来的目录 "repositories": [{ "type": "path", "url": "../backend-kit" }], // 公开发到 Packagist 之后——什么都不用配,直接:composer require tianlu/backend-kit ``` 装好之后 `vendor/tianlu/backend-kit` 就位,Laravel 的包自动发现会读到 `extra.laravel.providers` 并注册 `BackendKitServiceProvider`——不用手动加 provider。 可以用 `php artisan package:discover` 确认列表里有 `tianlu/backend-kit`。 **不要退回"把 src/ 直接映射进宿主 autoload"那条老路:** ```jsonc "autoload": { "psr-4": { "Tianlu\\BackendKit\\": "packages/backend-kit/src/" } } ``` 类照样能用,但包自己的 composer.json 完全不生效——服务提供者不会自动发现(得手动注册), 包的依赖也没人管,更关键的是"这个包能不能装到别的项目上"这件事从此没人验证: 哪天包里混进一句 `use App\...`,在直挂模式下跑得好好地,一发布就炸。 **provider 顺序有讲究**:包的 provider 要排在宿主 `AppServiceProvider` 之前。宿主通常会在自己的 provider 里重新绑定 `PluginManager`(换成带业务能力矩阵的子类),而 Laravel 里后注册的绑定 覆盖先注册的——顺序反了,包内代码(中间件、路由)拿到的还是基类那一个。 (走自动发现时顺序天然正确;手工注册才需要留意。) ### 2. 发布配置 ```bash php artisan vendor:publish --tag=backend-kit-config ``` 不发布也能跑:包里的配置默认值都是中性的,只是菜单是空的、品牌是 "Backend"。 要改菜单、品牌、布局挂件就必须发布。 ### 3. 建插件状态表 迁移不在包里(表名可配,包不该替宿主决定迁移文件放哪): ```php Schema::create('plugins', function (Blueprint $table) { $table->id(); $table->string('key')->unique(); // 与 plugins/ 下的目录一一对应 $table->boolean('enabled')->default(false); $table->string('version')->nullable(); // 启用时的版本,用于提示升级 $table->json('settings')->nullable(); // 插件自定义配置项 $table->timestamp('enabled_at')->nullable(); $table->timestamps(); }); ``` 只存"状态",不存"定义":插件叫什么、有什么能力全在代码里。代码删了插件、 库里留一行记录没有副作用;反过来若把定义存进库,两边迟早对不上。 ### 4. 按需绑定契约 ```php // AppServiceProvider::register() $this->app->singleton(ScopeResolver::class, fn () => new HostScopeResolver); $this->app->singleton(ActivityLogWriter::class, fn () => new HostActivityLogger); $this->app->singleton(QrCodeRenderer::class, fn () => new HostQrCodeRenderer); $this->app->singleton(SearchProvider::class, fn () => new HostSearchProvider); ``` 要不要绑取决于用不用得到,不必一上来就全接——但有一条例外: - **`SearchProvider`:默认就得绑。** 布局顶栏默认挂着 `backend-kit::global-search`, 它靠这个契约才知道后台里有什么可搜;不绑的话,第一个渲染顶栏的请求就会解析失败。 暂时不想接搜索,就把 `layout.components.search` 设成 `null` 把它摘下来(见下)。 **摘掉之后键位表里的 `Ctrl / ⌘ + K` 会一起消失**,这个键也不再被接管—— 面板与实际按键读的是同一份配置(`Shortcuts::globalKeys()`),不会教人按一个按不响的键。 - `ScopeResolver`:布局用它显示"当前范围"(如"全部"/"某个仓库")。那里套了 `app()->bound()`, 没绑只是不显示这一块,页面照常;但你自己的列表页一旦调它,就会解析失败。 - `ActivityLogWriter`:只有挂了 `LogsActivity` trait 的模型才会解析,不绑 = 第一次写日志时抛异常。 - `QrCodeRenderer`:只有用了 `qr-code` 组件才会解析,不绑 = 第一次渲染二维码时抛异常。 四个都没有默认实现,**漏绑会在第一次用到时解析失败**,而不是静悄悄降级。这是刻意的,理由见文末。 ### 5. 注册插件开关中间件 插件路由是恒定注册的,"开没开"由 `EnsurePluginEnabled` 在请求期判定。把它挂上 (要定制未启用时的行为就继承它,再注册子类): ```php $middleware->prependToPriorityList( \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, EnsurePluginEnabled::class, ); ``` **必须排在 auth 前面。** 顺序反了的话,未启用的插件会先被 auth 跳去登录页—— 那等于告诉外人这个 URL 是存在的。锚点要用契约接口 `AuthenticatesRequests` (优先级表里登记的是它,写具体类 `Authenticate` 匹配不上,会被当成"排最后")。 ### 6. 两条不报错、但会缺东西的前提 漏了它们都不会抛异常,只是页面上少点东西或样式缺几档——所以放最后,但别跳过。 **Livewire 的版本对得上。** 它是包的正式依赖(`^4.4`,composer 会替你看着), 但组件的注册方式(命名空间)从 v4 起才有——接进一个更早版本的 Livewire 项目会报错, 那时该升级的是那个项目。 **宿主得提供几个 CSS 基础类。** 包只给结构与组件,不带样式原子。组件实际引用的是这十一个: `.card`、`.tabular`、`.form-guide`、`.nav-link`、`.nav-active`、`.nav-new`、`.nav-new-badge`、 `.btn-primary`、`.btn-ghost`、`.input`、`.no-print`。 缺了它们组件照样渲染,只是看着"没样式"——而且**一条都不报错**,所以这份清单要跟着 `resources/views/` 一起维护。`KitAssetsTest::test_the_host_css_classes_the_kit_relies_on_exist` 盯着它:清单里写了、README 漏了或宿主没定义,测试就红。 > `form-tip` 不在这份清单里:那个组件的样式全靠自身工具类,宿主只需要一条 > `.form-tip code { … }`(把说明文字里的行内代码点出来),缺了也不影响排版。 **Tailwind 要扫到包的视图。** 在宿主的 CSS 入口里加一行: ```css @source '../../vendor/tianlu/backend-kit/resources/views/**/*.blade.php'; ``` 漏了这行的症状最难查:不是整页没样式,而是**包里独有的那几个类**(比如 KPI 卡的 `bg-sky-50`)没被生成。编译后的 Blade 缓存(`storage/framework/views/`)会偶然把包的视图 也扫进去——渲染过一次就"看着正常",清洁检出(CI、新项目首次 build)时才露出来。 建议加一条守护:断言这行在,且它指向的路径真能匹配到 `.blade.php` 文件。 --- ## 发布一个新版本 包是独立仓库(`git@gitee.com:mouc/backend-kit.git`),发版的全部动作就是在包仓库里打 tag 推上去: ```bash cd backend-kit git tag -a v0.2.0 -m "backend-kit 0.2.0" && git push --tags ``` 宿主那边 `composer update tianlu/backend-kit` 就装到新版(约束 `^0.1.0` 之内自动升)。 三条规矩: - **`composer.json` 的 `version` 与 tag 保持一致。** path 仓库认前者(它没有 tag 可看), VCS 仓库与 Packagist 认后者——两边不一致就会出现"本地装的是 0.2.0、别人装到 0.1.0"。 - **别在宿主仓库里顺手改这个包。** 改的是 `vendor/` 里那份?`composer install` 会把它盖掉。 要改就改包仓库,然后回宿主 `composer update`。 - **发版前跑一遍宿主的全量测试。** `KitPackageTest` 盯着包纯不纯、宿主那层壳还在不在转发、 发布必需字段齐不齐;`KitPurityTest` 盯着包里不出现宿主业务词。包仓库自己不带测试, 这是刻意的——那几条要有一个真实宿主才跑得起来。 发布后的归档受 `.gitattributes` 的 `export-ignore` 约束:装进 vendor 的人只会拿到 `src/`、`config/`、`resources/`、`LICENSE` 和这份 README。 要上 Packagist,把包仓库提交到 ,之后别人就不用写 `repositories` 那段了;宿主要跟着换成装法 C,见上面「1. 装进来」。 ## 配置速查 发布后的 `config/backend.php`: | 段 | 干什么 | |---|---| | `prefix` | 后台入口前缀(默认 `master`,环境变量 `BACKEND_PREFIX`)。路由、侧边栏、插件菜单、登录落点都从这里取值 | | `plugins` | `base_dir` / `namespace` / `table` / `host_api`(插件可依赖的宿主类白名单) | | `brand` | 侧边栏与标题上的品牌文字、图标 | | `nav` | 菜单,**同时也是快捷键的唯一来源** | | `access` | `role_labels` / `abilities` / `matrix` / `scoped_roles` | | `layout` | `css`、`home_url`、三个挂件(`plugin_nav` / `search` / `password`),挂件按组件**注册名**填,`null` 表示不挂 | | `search` | `placeholder`:顶栏搜索框的占位提示(包给中性默认值,宿主换成自己后台的说法) | | `chart` | `currency`,`format=money` 时摆在数字前的符号 | 菜单(键位由它派生,不另维护一张表): ```php 'nav' => [ ['label' => '经营分析', 'items' => [ ['url' => '/dashboard', 'label' => '看板', 'icon' => '📊', 'ability' => 'view.dashboard', 'key' => 'd'], ]], ], ``` `url` 写后台内路径,前缀由 `Backend::path()` 统一拼。`ability` 绑定能力,当前角色 无权时整项消失(不显示,也不给键位)。`key` 给了就同时是 `g+字母` 的跳转键位。 --- ## 布局与组件 ```blade @extends('backend-kit::layouts.app') @section('content') @endsection @push('scripts') {{-- 可选 --}} @endpush ``` 骨架(侧边栏、顶栏、快捷键层)随包提供,不强制 publish:要改的地方都在配置里, publish 出来反而多一份"包升级了但宿主这份还是旧的"的分叉。 ### 侧边栏可收起 左上角的「‹」把 240px 的侧栏收成 80px 的图标栏,内容区跟着让位;再点一下展开。 窄屏(`< lg`)不受影响,仍是覆盖式抽屉——收成图标栏在手机上反而不好点。 状态存在 `localStorage` 的 `backend_kit_nav_tight`(`'1'` = 收起), 并且**由 `` 里的一小段脚本在首屏绘制之前读出来**:这是个整页导航的应用, 放到 Alpine 起来之后再读就晚了,每翻一页都会先渲染成展开、再"啪"地收起一次。 收起态由 `` 上的一个类(`nav-tight`)驱动,布局里的切换只是加/去这个类。 这么做还有两个好处:插件贡献的侧栏入口(`PluginNav`)会跟着一起收,不用各自实现一遍; 以及 JS 没跑起来时侧栏就是普通展开态,不会卡在中间形态。 组件: - `kpi-card`(`label` / `value` / `sub` / `icon` / `tone`) - `bulk-bar`(`count` / `unit`,动作按钮塞默认插槽;由 `HasBulkSelection` 驱动) - `form-tip`(`tone` / `icon`)字段级填写说明,`form-guide`(`title` / `icon` / `tone`)表单顶部说明卡 - `export-buttons`(`routeName` / `report` / `params` / `label` / `formats`)导出下拉。 走**命名路由**而不是拼地址——后台入口挂了前缀之后,拼出来的地址会直接 404。 路由名与报表名都是宿主的事,包不知道它们叫什么,由调用方(或宿主那层壳)传进来 - `segment`(`options` / `value` / `property` / `variant`)分段按钮组,也就是"近30天 / 近90天 / 近一年"那一排。值取自 Livewire 属性(`value`),点一下把该属性设成对应值(`property`); `variant` 只有 `amber`(默认,区间与筛选)和 `dark`(深色页签)两种。 **它存在的唯一理由是那条比较**:`options` 的键写成 `['30' => …]` 时 PHP 会把它转成 int, 而属性多是 string,`'30' === 30` 恒为 false——高亮永远不出现、也不报错, 整组按钮看着都没选中。这段知识以前在七个页面里各抄一遍,只有两处抄对了 - `qr-code`(`value` / `size` / `margin`)内联 SVG 二维码。画码的实现由宿主绑 `QrCodeRenderer` 提供(见下) - `keyboard-shortcuts` 布局已自动挂上,无需手动引 - `chart.bars` / `chart.line` / `chart.donut` / `chart.radar`:数据以 `$series` / `$labels` 传入,`$height`、`$format`(`money` 时取 `config('backend.chart.currency')`)按需; 各图另有自己的参数,见组件头部注释 图表是手绘 SVG:后台这几张图不值得为一个图表库多几百 KB 前端资源, 代价是交互只有 tooltip 级别。 ### 自带的三个 Livewire 挂件 这三个东西每个后台都要,写起来又没什么花样,所以包直接给实现。它们注册在 `backend-kit::` 命名空间下,写在 `config('backend.layout.components')` 里决定挂不挂、挂哪儿: ```php 'layout' => [ 'components' => [ 'plugin_nav' => 'backend-kit::plugin-nav', // 侧栏底部:插件贡献的入口 'search' => 'backend-kit::global-search', // 顶栏中间:全局搜索 'password' => 'backend-kit::change-password', // 顶栏:改自己的密码 ], ], ``` 默认值就是上面这三行(装完就有)。**任一项填 `null` 就摘掉那个挂件**, 填自己的组件注册名就换掉它——三个类都可以继承后重写方法,视图也在 `backend-kit::livewire.*`, 要改版就 publish 或换掉包里的视图。带命名空间前缀是为了不抢名字:宿主自己也写一个 `change-password` 时,两边不会互相盖掉。 | 组件 | 干什么 | 需要宿主提供 | |---|---|---| | `backend-kit::change-password` | 弹层改当前登录者的密码:验旧密码、只改自己、改完不踢下线 | 无(`auth()->user()` 与 `Hash`) | | `backend-kit::global-search` | 顶栏搜索下拉;回车命中唯一直达、多条进命中最多的列表页 | `SearchProvider` 契约 | | `backend-kit::plugin-nav` | 侧栏插件分组 + 刚启用的入口高亮 | 无(读容器里的 `PluginManager`) | `plugin-nav` 为什么要单独做成一个组件:布局只在整页请求里渲染一次(Livewire 把布局套在 首屏 HTML 上,后续 update 只回传组件自己的 DOM),写死在布局里的插件菜单,在插件中心 开关插件后会停在旧状态,得手动刷新才看得到新入口。做成组件后,`plugins-updated` 事件 能让它就地重渲染。 > 搜索占位符在 `config('backend.search.placeholder')`——包不知道你的后台里能搜到什么, > 默认值写成"搜索…",这句得由宿主填。 ### 导出:按钮和接住它的那半都在包里 `export-buttons` 只负责生成链接,接住它的是 `Tianlu\BackendKit\Exports\ReportExport`: 给一份「表头 + 二维数组」就落 XLSX / CSV,表头样式、CSV 的 UTF-8 BOM、文档属性、 工作表名清洗都在里面——**宿主不用为每张报表写一个 Export 类**。 ```php return Excel::download(new ReportExport($data), $filename, $writerType, [ 'Content-Type' => $csv ? 'text/csv; charset=UTF-8' : 'application/...sheet', ]); ``` `$data` 的形状就是这套导出的全部契约,包不知道表里装的是什么: | 键 | 干什么 | |---|---| | `key` / `label` / `sheet` | 报表标识、人类可读的报表名、工作表名(前两个写进文件元信息) | | `headings` | 表头,一维数组 | | `rows` | 数据行,`Collection` 或二维数组 | | `filters` | 筛选条件,写进元信息(`"这份数据是哪个范围、哪段时间的"`),可为空 | | `generated_at` | 生成时间,同样进元信息 | 报表**有哪些、按什么条件取数**,仍然是宿主的事(白名单、参数收敛都在宿主那边); 包只负责"已经取好的数据怎么落成一个文件"。 > 除了 `illuminate/*`,就这两个依赖:`maatwebsite/excel`(导出器是它实现的, > 而导出按钮本来就指望有个服务端接住——只给按钮不给落盘器,接上去的人还得自己写一遍) > 与 `livewire/livewire`(自带的那三个挂件)。代价分别是带进 PhpSpreadsheet, > 以及认了"这是个 Livewire 后台"。 --- ## 契约与扩展点 ### 宿主路由名 —— 布局与导出按钮会用到(用到才需要) 包自己不带路由,它有两处要宿主的**路由名**: | 用在哪儿 | 路由名 | 没提供会怎样 | |---|---|---| | 布局顶栏的「退出」 | `logout` | 这一格不渲染,页面照常 | | `export-buttons` 的 `routeName`(调用方传,宿主那层壳里定的) | 宿主定,本项目的壳传 `export` | 按钮禁用,tooltip 里写明缺哪个路由名 | 两处都**故意不抛异常**:一个退出按钮、一个导出菜单,不该有能力把整页带崩。 这条是踩过坑的——宿主里曾有 `route('marketing')` 指向插件路由,插件一关就整页 500, 偏偏那页是库存预警,最需要打开的时候打不开。 `KitComponentsTest::test_every_route_call_in_the_kit_is_guarded` 扫包内所有视图: 调了 `route()` 的文件必须带存在性兜底,新加一处没兜底的会被这条挡住。 ### 插件开关事件 —— 包的侧栏在监听(做插件中心才需要) 插件开关成功后要派发 `PluginManager::EVENT_UPDATED`(值就是 `plugins-updated`, 带 `keys: [...]` 标出这次变的是哪几个),侧栏的 `PluginNav` 靠它就地重渲染: 布局只在整页请求里渲染一次,不广播这一声,刚启用的入口要手动刷新才出现。 常量挂在 `Tianlu\BackendKit\Plugins\PluginManager` 上,宿主的插件中心直接用 (它继承的就是这个类,不必再写一遍字符串)。名字两边各写一遍的症状是**静默的**: 菜单不再跟着变、不报错,只是每次都得刷新——所以收成一个来源。 ### ScopeResolver —— 数据范围(用到才绑) "能不能进这个页面"归能力管,"进了页面能看到哪些数据"归范围管。这份契约只管后者, 且不知道那个维度叫什么(仓库、区域、租户在它眼里只是一个模型 + 一组受限角色)。 **返回值里 `null` 一律表示"不限",`[]` 才是"什么都看不到"。** 受限账号若没绑定归属, 必须返回 `[]`——返回 `null` 就成了"看到全部",越权由此发生。 多数情况不用从零实现,继承 `ModelScopeResolver` 回答三个问题即可: ```php class StoreScopeResolver extends ModelScopeResolver { protected function modelClass(): string { return Store::class; } protected function ownedId(Authenticatable $user): ?int { return $user->store_id; } protected function scopedRoles(): array { return ['manager']; } } ``` ### ActivityLogWriter —— 操作日志(用到才绑) 审计落到哪张表由宿主定。实现有一条硬约束:**写不进去不能拖垮业务**。日志表不可写、 字段超长、序列化失败,都不该让"保存这张单"失败,实现里要自己吞掉异常。 配套 trait `LogsActivity` 挂在模型上(created/updated/deleted 自动留痕), 需要脱敏的字段覆盖 `activityHidden()`。 ### QrCodeRenderer —— 二维码怎么画(用到才绑) 包只提供"页面上摆一个二维码"的壳,画码的库由宿主选: ```php // AppServiceProvider::register() $this->app->singleton(QrCodeRenderer::class, fn () => new QrCodeService); ``` 不绑的原因和另两个契约不同,但结论一样:画二维码要引专门的库,而**引哪个库是宿主的 决定**——包不该替所有装它的项目硬塞一个依赖,也不能因为自己想画个码就替宿主选定实现。 漏绑会在第一次渲染时解析失败,而不是渲染出一个空盒子(空盒子的页面照常 200, 看的人只会以为码坏了)。 ### SearchProvider —— 后台里能搜到什么(顶栏挂了就得绑) 搜索框的 UI 与"回车去哪儿"是包的事,检索范围是宿主的: ```php // AppServiceProvider::register() $this->app->singleton(SearchProvider::class, fn () => new SearchService); // 实现 public function search(string $term, mixed $user = null, int $perGroup = 8): array { return [ 'orders' => [ 'label' => '单据', 'icon' => '📄', 'total' => 128, 'items' => [['title' => '单号 A10086', 'subtitle' => '今天 10:24', 'url' => '/master/orders/3']], 'allUrl' => '/master/orders?q=A10086', ], ]; } ``` 返回**分组**:只返回有命中的组,键是组标识。组件据此决定回车去哪儿——只命中一条就直达, 多条就进 `total` 最大的那组的列表页。权限过滤放在这里做(宿主才知道谁能看什么): 给一个没权限的人看结果,只是让他多点一次 403。 参数里的 `$user` 是 `mixed`:包不认识宿主的用户模型,类型由实现自己解释。 这一条不像别的契约那样"用到才绑"——顶栏默认就挂着搜索,**不绑会炸**。 不想接就把 `layout.components.search` 设成 `null`。 ### PluginManager 子类 —— 接进宿主的授权体系(可选) 包不知道宿主用 Gate、Policy 还是别的什么,所以给了三个可覆盖方法: `coreAbilities()`(宿主核心能力)、`coreMatrix()`(角色 → 能力矩阵)、 `syncAbilities()`(注册进宿主授权系统)。不覆盖也能跑,只是插件能力不进授权体系。 绑定时**key 必须是包里那个类**——包内代码解析的是它。绑成宿主子类自己的类名, 同一个请求里会冒出两个互不相认的管理器: ```php $this->app->singleton(Tianlu\BackendKit\Plugins\PluginManager::class, fn () => new HostPluginManager); $this->app->alias(Tianlu\BackendKit\Plugins\PluginManager::class, HostPluginManager::class); ``` 常用 API:`discover()` / `find($key)` / `enabled()` / `isEnabled($key)` / `enable($key)` / `disable($key)` / `toggle($key)` / `setting($key, $name)` / `missingDependencies($plugin)` / `enabledDependents($plugin)` / `abilities()` / `matrix()` / `navItems()` / `registerEnabled()`。 运行期改完开关要调一次 `registerEnabled()`,让当前请求立刻生效。 --- ## trait | trait | 宿主要提供 | |---|---| | `HasBulkSelection` | `bulkRowIds()`(当前页行 ID);`bulkFilterProperties()` 可覆盖,声明哪些筛选一变就清空勾选 | | `RemembersFilters` | `rememberedFilters()`(属性 => 出厂默认值) | | `LogsActivity` | 可选 `activityHidden()` | 筛选记忆存在 session 里,按页面类名分桶;**URL 上带了的参数优先**——分享出去的 链接必须如实还原,不能因为对方上次看的是 90 天就把 7 天的链接变成 90 天。 --- ## 写插件 一个插件 = 自包含的一目录: ``` plugins/barcode/ ├── BarcodePlugin.php # 必需,类名 = 目录名的 Studly + "Plugin" ├── migrations/ # 有则启用时自动跑 ├── routes/web.php # 有则自动注册 ├── resources/views/ # 视图命名空间 = key,即 barcode::xxx ├── Livewire/ └── Services/ ``` 扫描规则:`{namespace}\{Studly(目录名)}\{Studly(目录名)}Plugin`。 ```php class BarcodePlugin extends Plugin { public function key(): string { return 'barcode'; } public function name(): string { return '条码扫描'; } public function description(): string { return '出入库时扫码代替手工选品'; } public function abilities(): array { return ['view.barcode' => '使用条码扫描']; } public function grants(): array { return ['manager' => ['view.barcode']]; } public function navItems(): array { return [['url' => '/barcode', 'label' => '条码扫描', 'icon' => '📷', 'ability' => 'view.barcode']]; } } ``` 还可覆盖:`version()` / `icon()` / `category()` / `dependencies()` / `isCore()` / `defaultEnabled()` / `settings()` / `install()` / `uninstall()`。 依赖插件没启用就开不了本插件(避免出现"装了但点进去报错");核心插件不可关闭; **停用不回滚迁移**——数据留在库里,重新启用原样恢复,避免手滑关一下就丢三个月数据。 宿主通常会在自己和包之间垫一层薄壳(如 `App\Plugins\Plugin extends 包的 Plugin`), 用来给所有插件注入公共能力,那时插件继承宿主的那一层。 --- ## 几处刻意的选择 读代码时容易以为"这里少做了什么"的地方,其实都是决定: - **契约都不给默认实现。** 一个"默认全量可见"的 `ScopeResolver` 会让漏绑变成 静默越权;一个"什么都不写"的 `ActivityLogWriter` 会让漏绑变成审计黑洞—— 系统照常跑,只是从此不留痕迹;一个"什么都搜不到"的 `SearchProvider` 会让搜索框 永远是空的,看起来像没做索引。所以漏绑当场炸。 - **路由表与插件开关解耦。** 路由恒定注册(含未启用的),开关由中间件在请求期判。 否则 `route:cache` 之后,开关就非得靠清缓存才生效:新开的插件在旧缓存里没路由, 已关的插件在旧缓存里还留着路由。 - **关掉的插件返回 404,不是 403。** "没这功能"和"没权限"是两件事,403 等于告诉 对方"系统里其实装了这个"。 - **菜单与快捷键同源。** 分两张表时,删页面忘了删键位就会留一个"按下去 404"的坑, 而这坑只有用户会踩到。 - **自带挂件比写在布局里多一步。** 搜索、改密码、插件菜单做成组件而不是写死在布局里, 是因为布局只在整页请求渲染一次;挂件挂在配置里,是为了不想用的时候能摘掉, 而不是靠注释掉一段 Blade。 - **`plugins.host_api` 白名单。** 插件必然要碰宿主的数据与服务,但"躲不掉"不等于 "随便碰"。填了就该由宿主那条边界测试逐条比对,多依赖一个名单外的类就红。 ## 改这个包之前要知道的规矩 - 包内(含注释)不许出现 `App\`,也不许出现宿主的业务词。有测试守(包要能脱离 这套业务单独用;注释里写着业务词,说明写代码的人心里装的还是这一个项目)。 - 包里已有的类,宿主不留同名副本——两边各修一次 bug、各加一个方法,就是合并难度的源头。 要留同名"薄壳"只为省下调用点的全量改动(如 `\App\Backend`),必须显式 `extends` 包里 那个类:不继承的同名副本(双胞胎)会被宿主那条纯洁性测试拦下。