- 后端
- 企业应用
【免费下载链接】snipe-it
A free open source IT asset/license management system
本篇技术指南以 snipe-it 仓库中 .ai/rules/presenters.md 这一项目规则为骨架,系统讲解该开源 IT 资产管理系统(Laravel 应用)中 Presenter 层的职责边界与实现方式。读完本文,你将掌握$model->present()的调用链、dataTableLayout()的 Bootstrap-table 列配置规范,以及为什么控制器、Transformer 与 Blade 模板不应再承载展示格式化逻辑,并能在新增实体时按仓库既有约定正确落地自己的 Presenter。
Presenters 职责定位:一条贯穿全仓的架构规则
.ai/rules/presenters.md虽短,却定义了 snipe-it 前端展示逻辑的"宪法"。其核心表述如下:
Presenters own display and datatable configDisplay formatting and Bootstrap-table column config belong in
app/Presenters/<Entity>Presenter.php, reached from the model via$model->present(). Keep this logic out of controllers, transformers, and Blade.
翻译并拆解为三条可执行的规则:
- 展示格式化(display formatting)归 Presenter 管:日期如何显示、状态文案如何拼接、图片 URL 如何生成、名称如何渲染成带权限校验的链接,这些都属于"展示逻辑"。
- 数据表列配置(Bootstrap-table column config)归 Presenter 管:每个实体列表页的列字段、可搜索性、可排序性、可见性、formatter 等,统一由
app/Presenters/<Entity>Presenter.php中的静态dataTableLayout()方法产出。 - Controller、Transformer、Blade 一律不写这类逻辑:它们只负责调用与装配,不负责决定"某一列叫什么、某个值怎么渲染"。
仓库app/Presenters/目录下共有 31 个 Presenter 类(如AssetPresenter、UserPresenter、LocationPresenter、LicensePresenter、SupplierPresenter等),覆盖了资产、用户、位置、许可、配件、组件、耗材、厂商、状态标签等全部核心实体,可见这是一套被全仓严格执行的约定。
$model->present()的入口:Presentable trait 与模型绑定
规则的落地点是"通过$model->present()到达 Presenter"。这一能力由 app/Presenters/Presentable.php 中的Presentabletrait 提供:
trait Presentable { protected $presenterInterface; public function present() { if (! $this->presenter || ! class_exists($this->presenter)) { throw new \Exception('Presenter class does not exist'); } if (! isset($this->presenterInterface)) { $this->presenterInterface = new $this->presenter($this); } return $this->presenterInterface; } }这里包含三个关键设计:
- 惰性单例:首次调用时实例化 Presenter 并缓存到
$presenterInterface,同一模型多次调用present()不会重复 new。 - 防御式校验:若模型未声明
$presenter属性或声明的类不存在,直接抛异常,避免"静默失败"导致视图拿到不可预期结果。 - 模型与 Presenter 的一对一绑定:由各模型上的
protected $presenter属性声明,例如 app/Models/Asset.php 中protected $presenter = AssetPresenter::class;并use Presentable;。全仓 21 个模型都遵循同样写法,从Accessory、Component到Statuslabel、Department一应俱全。
抽象基类 Presenter:所有子类共享的通用能力
所有实体 Presenter 都继承自 app/Presenters/Presenter.php 这个抽象基类。构造函数接受一个SnipeModel(仓库所有模型的基础父类,见 app/Models/SnipeModel.php),并将其保存在受保护的$model属性中。
基类提供了一批开箱即用的通用展示方法:
| 方法 | 职责 |
|---|---|
displayAddress() | 将address、address2、city、state、zip、country拼接为规范地址文本,逐段使用e()转义防 XSS |
categoryUrl()/locationUrl()/companyUrl()/manufacturerUrl() | 返回关联对象的nameUrl()链接;资产类模型会自动穿透到其model再取分类/厂商 |
dynamicUrl() | 支持{LOCALE}、{SERIAL}、{MODEL_NAME}、{MODEL_NUMBER}占位符的动态 URL 生成,用于用户自定义的查询链接,序列号与型号名会经urlencode处理 |
最值得注意的是基类的两个魔法方法:
public function __get($property) { if (method_exists($this, $property)) { return $this->{$property}(); } return $this->model->{$property}; } public function __call($method, $args) { return $this->model->$method($args); }这意味着对 Presenter 的任何属性/方法访问都会先查 Presenter 自身,再回退到模型——$asset->present()->fullName会命中AssetPresenter::fullName()方法,而$asset->present()->serial则会透明地转发到Asset模型上的serial字段。这解释了为什么控制器里可以写出$asset->present()->warranty_expires()(如 app/Http/Controllers/ReportsController.php)这种"模型数据 + Presenter 格式化"的混合调用。
dataTableLayout():Bootstrap-table 列配置的中心化产出
规则中"datatable config"的具体实现,是各 Presenter 中名为dataTableLayout()的静态方法。以最复杂的 app/Presenters/AssetPresenter.php 为例,该方法返回json_encode后的列配置数组,每个元素描述一列的完整行为。常见的配置属性及其含义如下:
| 属性 | 含义 |
|---|---|
field | 数据字段名,对应 API 返回的行数据键名 |
searchable/sortable | 是否参与搜索框检索、是否可点击列头排序 |
switchable | 是否出现在"列选择器"中,允许用户动态显隐 |
visible | 默认是否可见 |
title | 列标题,一律使用trans('...')翻译键,保证多语言 |
formatter | 前端 JS 格式化函数名(如hardwareLinkFormatter、dateDisplayFormatter、imageFormatter、trueFalseFormatter) |
footerFormatter | 页脚聚合函数(如sumFormatter汇总金额列) |
checkbox | 是否为批量操作复选框列 |
printIgnore/class | 打印忽略与 CSS 类控制(如hidden-print) |
titleTooltip | 列头悬浮提示 |
AssetPresenter::dataTableLayout()还接收一个$hide_fields参数,用于按页面场景增删列。例如 resources/views/blade/table/assets.blade.php 中:
:presenter="\App\Presenters\AssetPresenter::dataTableLayout($status_type !== 'Deleted' ? ['deleted_at'] : [])"即在非"已删除"视图下隐藏deleted_at列,删除视图则显示。Blade 模板在这里只做调用和传参,不内联任何列定义,这正是规则要求的"Keep ... out of ... Blade"。
此外,dataTableLayout()具备三个值得注意的动态扩展点,展示了"配置中心化"如何与业务动态性共存:
- 自定义字段动态追加:通过
CustomField::whereHas('fieldset', fn ($q) => $q->whereHas('models'))查询挂在有模型字段集上的自定义字段,把每个字段按db_column追加为列表列,并依据field_encrypted、show_in_listview决定加密锁图标与默认可见性。 - 同步适配器侧表列:为对接 MDM/RMM 的
primary_mac、primary_ip、external_os、external_os_version、last_seen追加隐藏列,供管理员通过列选择器按需暴露。 - 专用布局变体:
dataTableLayoutRequestable()为/account/requestable页面输出独立的列配置,并额外追加show_in_requestable_list=1的自定义字段,配合assetRequestActionsFormatter渲染请求/取消按钮。
实例方法:面向视图的格式化门面
除静态的列配置外,各 Presenter 还通过实例方法向视图与控制器暴露格式化结果。以AssetPresenter为例:
- 名称与标签链接:
nameUrl()、formattedNameLink()、formattedTagLink()均先执行auth()->user()->can('view', ...)权限校验,有权限才渲染可点击链接,否则输出纯文本;已软删除对象会附加deletedCSS 类。 - 图片:
imageUrl()/imageSrc()优先取资产自身图片,缺失时回退到资产模型图片,并通过Storage::disk('public')生成完整 URL 与<img>标签。 - 生命周期日期:
eol_date()依据purchase_date+ 模型eol(月数)计算 EOL 日期;months_until_eol()返回距 EOL 的月差;warranty_expires()由购买日期叠加warranty_months得到保修到期日。 - 状态语义:
statusMeta()/statusText()/fullStatusText()把"已分配"与状态标签叠加成人类可读的复合状态,例如已分配且状态为"Ready to Deploy"时仅显示(Deployed),否则显示Deployed (Other Label),并处理状态标签缺失时的Invalid status兜底。 - 统一导航:
viewUrl()、glyph()、calendarUrl()、calendarColor()分别产出详情页路由、图标、日历事件链接与颜色(颜色沿tag_color→ 模型分类 → 供应商逐级回退)。
UserPresenter则提供了emailLink()、gravatar()(依次回退:用户上传头像 → 系统默认头像 → Gravatar)以及用户维度的小型表格布局(consumablesDataTableLayout()、accessoriesDataTableLayout()、licensesDataTableLayout()),用于在用户详情页展示其领用清单。
为什么必须把逻辑移出 Controller / Transformer / Blade
规则的约束是有明确工程动机的,仓库源码可以印证每一条:
- 控制器只负责装配:观察 app/Http/Controllers/Api/AssetsController.php 中的
$asset->use_text = $asset->present()->fullName;与 app/Http/Controllers/Api/UsersController.php 中的$user->use_image = ($user->present()->gravatar) ? ...,控制器只是把 Presenter 的结果"取过来用",并不自己拼字符串。 - Transformer 保持瘦身:API 响应转换层只负责字段投影与结构映射,展示级格式化(地址拼接、日期语义、权限化链接)全部下沉到 Presenter,避免同一格式化逻辑在多个 Transformer 里重复。
- Blade 只做声明式渲染:模板中出现的是
\App\Presenters\AssetPresenter::dataTableLayout(...)这样的调用点,而不是一长串列配置硬编码。这样当需要为某实体新增/调整列时,只需改动 Presenter 一处,所有使用该布局的页面同步生效。
这套分工带来的直接收益是单一职责与一处修改、全局生效:格式化规则与列定义有且仅有一个权威出处,控制器、Transformer、Blade 三个层都不会出现"自己实现一份展示逻辑"的漂移。
测试如何守护这份约定
仓库的测试同样锚定了 Presenter 的输出契约。例如 tests/Feature/Importer/AssetNameColumnRoundTripTest.php 直接对AssetPresenter::dataTableLayout()的 JSON 结果做断言:
The AssetPresenter datatable layout drives the bs-table CSV export, so it must define a name column.
这类测试把"列配置存在于 Presenter"从约定层面固化成了 CI 可执行的事实:一旦有人把列定义挪去别处或改名,导出与导入映射的往返链路就会在测试中显形。类似地,tests/Feature/CheckoutAcceptances/Ui/AccessoryAcceptanceTest.php 等验收测试通过$acceptance->assignedTo->present()->fullName校验邮件/页面中的展示名,印证了 Presenter 是展示数据的可信来源。
为新增实体实现一个 Presenter 的实践清单
若要在 snipe-it 中为一个新实体落地展示层,按仓库既有约定应完成以下四步:
- 编写 Presenter 类:在
app/Presenters/下新建<Entity>Presenter.php,继承抽象基类Presenter。用静态方法dataTableLayout()输出列配置(返回json_encode的数组),用实例方法提供名称链接、日期、状态、图片等格式化能力。 - 绑定到模型:在对应模型上
use App\Presenters\Presentable;声明 trait,并设置protected $presenter = <Entity>Presenter::class;。 - 列标题统一走
trans():所有title使用翻译键而非硬编码文案,确保与全仓多语言体系一致;格式化函数优先复用既有 JS formatter。 - 保持职责纯净:控制器与 Transformer 只调用
$model->present()获取结果,Blade 模板只声明dataTableLayout()调用点;任何新的展示格式化逻辑都必须回到 Presenter 内实现。
遵循这一规则,新实体从"列表页列定义"到"详情页链接与日期展示"都能与既有 31 个实体保持完全一致的架构风格,也让后续维护者可以凭$presenter属性与目录约定快速定位每一处展示逻辑的权威来源。
- 后端
- 企业应用
【免费下载链接】snipe-it
A free open source IT asset/license management system
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考