从零理解 Spree Dashboard 插件体系:以 `@spree/dashboard-plugin-example` 为模板构建可分发后台功能

从零理解 Spree Dashboard 插件体系:以 `@spree/dashboard-plugin-example` 为模板构建可分发后台功能 从零理解 Spree Dashboard 插件体系以spree/dashboard-plugin-example为模板构建可分发后台功能【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree本篇文章以 Spree 仓库内自带的参考实现spree/dashboard-plugin-example一个为后台管理端添加 Brands 品牌功能的插件包为主线完整拆解 Spree React Dashboard 的插件扩展机制。读完本文你将掌握defineDashboardPlugin的六个核心扩展点导航、路由、表格、插槽、列扩展、翻译的注册方式、插件自动发现与激活的底层原理以及如何把应用内定制提升为可pnpm add一键安装的分发式 npm 插件。插件在 Spree Dashboard 架构中的位置Spree 的 React Dashboard 是一个单页应用SPA由三个 npm 包分层构成见 docs/developer/dashboard/overview.mdx包定位插件作者主要接触什么spree/dashboard-ui设计系统shadcn 原语 复合组件PageHeader、ResourceTable渲染层等 设计令牌复用现成 UI 组件spree/dashboard-core框架层导航/路由/插槽/表格/设置导航注册表、auth/permission/store Provider、admin SDK client、defineDashboardPluginAPI所有定制 API 都在这层spree/dashboard可部署的 SPA路由、资源 hooks、schema、locale 文案、应用外壳很少直接改spree/dashboard-plugin-example演示的正是把扩展打包成可分发插件这条路径。官方文档在 docs/developer/dashboard/plugins/overview.mdx 中给出了明确的选型建议单项目团队直接使用应用内定制src/plugins.ts而需要跨项目分发开源集成、内部共享包、商业化 SaaS 插件时才走插件路线。插件与应用内定制使用完全相同的注册表 API差异只在于打包与分发方式而非能力边界。包结构与插件标记Marker该包的整体布局如下详见 packages/dashboard-plugin-example/README.mdpackages/dashboard-plugin-example/ ├── package.json ← spree.dashboard.plugin 标记 文件路由声明 └── src/ ├── index.tsx ← 插件入口 —— defineDashboardPlugin 调用 ├── types.ts ← Brand 接口真实插件应由 Typelizer 生成 ├── client.ts ← adminClient.request 的类型化封装 ├── routes/ │ ├── brands.index.tsx ← /brands 列表文件路由 │ └── brands.$brandId.tsx ← /brands/$brandId 详情文件路由 ├── pages/ │ ├── brands-list.tsx ← 基于 ResourceTable 的列表页 │ └── brand-detail.tsx ← 品牌详情页 ├── slots/ │ └── product-brand-card.tsx ← product.form_sidebar 插槽组件 └── locales/ └── en.json ← 插件翻译资源插件身份的关键在 package.json 中的spree字段{ name: spree/dashboard-plugin-example, main: ./src/index.tsx, spree: { dashboard: { plugin: true, routes: ./src/routes } }, peerDependencies: { spree/dashboard-core: workspace:^, spree/dashboard-ui: workspace:^, tanstack/react-query: ^5.100.9, tanstack/react-router: ^1.169.2, i18next: ^26.2.0, react: ^19.2.6 } }两个关键点plugin: true是宿主仪表盘的自动发现标记——安装方只需pnpm add无需改动宿主代码routes: ./src/routes声明文件路由目录宿主构建时会将插件路由编译进自身的类型化路由树使Link指向插件页面时获得完整类型检查。peerDependencies里把spree/dashboard-core等声明为 peer 依赖确保插件的注册表引用与宿主解析到同一个模块单例——这是注册表registry机制能工作的前提。自动发现与激活一条链路的三个环节安装插件后它如何被唤醒链路分为三段均有源码可查1. 发现读取宿主依赖清单查找带标记的包。packages/dashboard-core/src/vite/discover.ts 中的discoverDashboardPlugins()遍历宿主package.json的dependencies与devDependencies逐个解析包清单凡spree.dashboard.plugin true即纳入插件列表同时从标记的routes字段解析出文件路由目录discoverDashboardPluginManifests返回DashboardPluginManifest[]。2. 合成虚拟模块。Vite 集成把发现结果合成为一个名为virtual:spree-dashboard-plugins的虚拟模块。宿主入口 packages/dashboard/src/main.tsx 在创建路由前执行import virtual:spree-dashboard-plugins——注释明确说明它必须在 shell 的 i18n/nav/search 引导之后执行以保证插件在模块加载期调用i18n.t时翻译已就绪。3. 激活入口模块副作用。插件的src/index.tsx在 import 时同步执行翻译注册与defineDashboardPlugin(...)调用在首次渲染前完成全部注册。宿主侧显式白名单是逃生舱spreeDashboardPlugin({ plugins: [...] })传入数组即可关闭自动发现。本仓库的 dashboard 应用为此提供了一个可端到端验证的真实路径packages/dashboard/vite.config.tscd packages/dashboard VITE_EXAMPLE_PLUGINtrue pnpm dev配置中默认plugins: []显式空白名单仅当VITE_EXAMPLE_PLUGIN true时才走真实发现逻辑——注释说明这是因为该示例插件是唯一带标记的依赖作为devDependency引入。这个开关完整复刻了真实宿主发现 → 激活 → 挂载的路径导航项注册、/brands路由挂载、商品页插槽组件出现、商品表新增 Brand 列。defineDashboardPlugin六大扩展点一次性注册插件入口 src/index.tsx 按固定顺序执行两件事先i18n.addResourceBundle合并翻译再defineTableBrand(brands, ...)声明品牌表最后defineDashboardPlugin({...})统一注册其余扩展点。defineDashboardPlugin的实现位于 packages/dashboard-core/src/plugin.ts它接收DashboardPluginConfig内部用safely包装器逐一调用各注册表nav.add/addChild/remove/update、registerSlot、tables[key].addColumn/removeColumn/updateColumn、pluginRoutes.add、formFields.register等收集全部错误后一次性抛出AggregateError避免打地鼠式调试。配置中的locales字段会在注册其他扩展之前先行合并进 i18next——因为后续的导航标签、列标题都可能引用插件自带的 key。示例插件演示的六个扩展点1. 导航项nav.addChildren嵌套进 Products 菜单README 将其描述为 adds Brands to the main sidebar实际源码采用的是嵌套而非顶层新增nav: { addChildren: { products: [ { key: products.brands, label: i18n.t(admin.brands_plugin.nav), path: /brands, position: 600, subject: Spree::Brand, }, ], }, },addChildren以父菜单 keyproducts为键追加子项保留父菜单原有子项价格表、分类、选项、调拨position: 600将其排在内置子项最后一个约 500之后。子项需自行声明subject——nav-registry.ts 中subject是 CanCanCan 主体名addChild会在父 key 缺失或子 key 重复时抛错。2. 自定义路由TanStack 文件路由 类型化路径参数该插件不使用routes:注册表那是给宿主应用内定制用的动态路由而是走文件路由src/routes/下两个文件由宿主构建编译进路由树。src/routes/brands.index.tsxexport const Route createFileRoute(/_authenticated/$storeId/brands/)({ validateSearch: resourceSearchSchema, component: BrandsRoute, })文件头注释强调路径字面量是最终合成路径挂在_authenticated/$storeId布局之下路由生成器会校验它并可能在不符合时改写文件。validateSearch: resourceSearchSchema让列表页的 URL 查询参数page、sort、search、filters与内置资源页使用同一套校验。src/routes/brands.$brandId.tsx 演示路径参数export const Route createFileRoute(/_authenticated/$storeId/brands/$brandId)({ component: BrandDetailRoute, }) function BrandDetailRoute() { const { brandId } Route.useParams() return BrandDetailPage brandId{brandId} / }$brandId是受类型检查的一等公民——Link to/$storeId/brands/$brandId params{{ brandId: b.id }}无需任何类型断言。3. 表格定义defineTable在模块顶层执行src/index.tsx 顶部以模块作用域调用defineTableBrand(brands, { title: i18n.t(admin.brands_plugin.table.title), searchParam: search, searchPlaceholder: i18n.t(admin.brands_plugin.table.search_placeholder), defaultSort: { field: name, direction: asc }, emptyMessage: i18n.t(admin.brands_plugin.table.empty), columns: [ { key: name, label: ..., sortable: true, filterable: true, default: true, render: (b) Link ...{b.name}/Link }, { key: slug, label: ..., sortable: true, filterable: true, default: true, render: (b) b.slug }, { key: products_count, label: ..., default: true, className: text-right tabular-nums, render: (b) b.products_count }, { key: created_at, label: ..., sortable: true, default: false, render: (b) RelativeTime iso{b.created_at} / }, ], })table-registry.ts 的defineTable将定义存入Mapstring, TableDef并flushPending。设计上值得注意的两点模块顶层注册是硬性要求ResourceTable tableKeybrands挂载时按 key 读取注册表未注册会直接抛错。README 强调 Always register at module top-level——虽然 catch-all 路由每次导航都读取路由注册表延迟注册不至于崩溃但先挂山再注册会造成脆弱的 UX挂起队列pending queue内置表由路由文件的副作用 import 惰性注册插件在启动期调用tables.products.addColumn(...)会与这种惰性注册竞争。因此对未注册表的变更会进入pending队列待defineTable出现时重放table-registry.ts对永远不注册的表比如插件扩展了未安装的可选功能变更静默不执行——这正是期望语义。4. 插槽组件product.form_sidebar商品卡插槽slot是宿主页面不知道插件存在、插件以纯增量方式贡献 UI的机制slots: { product.form_sidebar: [ { id: brand-card, component: ProductBrandCard as never, position: 250, }, ], },src/slots/product-brand-card.tsx 中ProductBrandCard只类型化它实际读取的字段interface ProductBrandCardProps { product: { id: string; brand_id?: string | null } }position: 250使其渲染在内置一方卡片100、200…之后。README 特别提醒插槽上下文的类型由插槽定义而非插件定义——product.form_sidebar的完整上下文是{ product, permissions, store, user }但插槽作者绝不应假设超过官方插槽目录slots-catalog文档化的内容。as never的强制转换是因为defineDashboardPlugin的slots被类型化为泛型擦除的Recordstring, SlotEntry[]宿主页面知道其上下文形状但插件门面不知道。5. 表格列扩展给核心 Products 表加 Brand 列tables: { products: { add: [ { key: brand, label: i18n.t(admin.brands_plugin.products_column.label), default: false, render: (product: { brand_id?: string | null }) product.brand_id ? BrandNameCell brandId{product.brand_id} / : —, }, ], }, },BrandNameCell通过 TanStack Query 按brandId拉取品牌名queryKey[plugin-brands, brand, brandId]staleTime: 5 * 60_000Dashboard 的查询缓存会在多行间去重请求。addColumn对已存在的 key 会抛错提示改用updateColumn这是注册表保证 key 唯一性的体现。6. 翻译admin.brands_plugin.*命名空间src/locales/en.json 将全部文案收拢在admin.brands_plugin.*下入口处执行i18n.addResourceBundle(en, translation, en, true, true)deep: trueoverwrite: true将插件 key 深合并进框架已有的admin.*命名空间而不丢失既有内容。README 对此有明确警告addResourceBundle是深合并两个插件若都写admin.actions.share会竞争后者覆盖前者——插件专属文案必须放在自己的命名空间下与框架和其他插件隔离。defineDashboardPlugin也提供了locales配置项会在注册其他扩展前自动合并。前端之外后端假设与配对 Rails 引擎README 强调一个关键事实本包不携带后端。插件的brandsClientsrc/client.ts调用的是假想的 Brands Admin APIexport const brandsClient { list: (params?: BrandsListParams) adminClient.requestPaginatedResponseBrand(GET, /brands, { params }), get: (id: string) adminClient.requestBrand(GET, /brands/${id}), create: (body: BrandCreateParams) adminClient.requestBrand(POST, /brands, { body }), update: (id: string, body: BrandUpdateParams) adminClient.requestBrand(PATCH, /brands/${id}, { body }), delete: (id: string) adminClient.requestvoid(DELETE, /brands/${id}), }注释说明了封装动机adminClient.requestT只认识运行时形状字符串键ListParams是闭合接口因此这里自定义了更宽的BrandsListParams将请求收敛在 client 模块内便于插件日后在一个地方重命名、换版本或替换后端实现。真实的 Brands 插件应同步发布一个 Rails 引擎spree_brands注册Spree::Brand模型、Spree::Api::V3::Admin::BrandsControllerResourceController子类、Alba 序列化器、/api/v3/admin/brands路由与 CanCanCan ability。Dashboard 插件是前端gem 是后端两者一同发布但分属不同包。adminClient.request的完整用法见 docs/developer/sdk/admin/extending.mdx。权限门控与 403 兜底subject: Spree::Brand承担两层职责导航可见性侧边栏按 CanCanCanread权限过滤条目——无权限管理员的侧边栏不显示 Brands直达 URL 兜底catch-all 路由 packages/dashboard/src/routes/_authenticated/$storeId/$.tsx 在matchPluginRoute命中后检查match.entry.subject !permissions.can(read, match.entry.subject)渲染 Not authorized 的ErrorState403 语义无匹配则渲染 Page not found。该路由还设置了errorComponent: PluginRouteError把第三方页面的渲染错误隔离在自身路由内不让坏插件拖垮整个 Dashboard 外壳。README 对此的定性非常清晰这是 UX 而非授权——服务端仍然强制执行权限前端门控只是体验层。开发工作流与运行验证该包虽位于 monorepo 内但其形状镜像了一个真实的外部插件pnpm install pnpm -F spree/dashboard-plugin-example typecheckdashboard 通过 workspace 符号链接一旦加入packages/dashboard/package.json的依赖引用它。运行VITE_EXAMPLE_PLUGINtrue pnpm dev在packages/dashboard下后导航项、插槽组件、表格列与/brands路由全部点亮。在自己的仪表盘应用里pnpm add spree/dashboard-plugin-example并重启 dev server 即走同一机制无需宿主代码改动若宿主入口没有导入虚拟模块直接基于spree/dashboard-core自建的 admin 应用一个纯副作用导入即可完成同样的激活import spree/dashboard-plugin-example插件作者的四条实践准则综合 README 的 Per-extension-point notes 与源码可提炼出四条可复用的准则defineTable必须在模块顶层执行先于任何ResourceTable挂载表格的初始定义是列/筛选/排序的唯一事实来源后续扩展用tables[key].addColumn。subject只控制 UX 可见性服务端授权独立执行直达 URL 由 catch-all 路由以 403 兜底。插槽组件只类型化自己读取的字段上下文契约以spree/dashboard的插槽目录为准不要臆测更多。翻译放独立命名空间admin.plugin.*避免addResourceBundle深合并下的 key 竞争需要给 Ruby 侧注册的规则/计算器/需求类型提供商户可读名称时也通过locales字段提供。结语spree/dashboard-plugin-example用约 200 行前端代码完整示范了一个生产级 Spree 插件的最小形态前端是defineDashboardPlugin驱动的注册表消费者后端是可独立发布的 Rails 引擎打包层靠spree.dashboard.plugin标记与文件路由声明接入宿主的自动发现管线。无论你是要交付一个开源集成、跨客户分发的内部包还是商业化 SaaS 插件都可以直接复制本包的结构作为起点——正如其 README 所说Copy this package shape for your own plugin; its intended as a template.【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考