
Spree 6.0 管理后台文案自治让 Dashboard 用自己的语言文件渲染类型名与 422 校验错误【免费下载链接】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 6.0 中React 管理后台Dashboard会渲染两类它自己并未撰写的自然语言文本可插拔类型目录价格规则、促销规则与动作、计算器、集合规则、订单路由规则、配送方式规则、佣金规则、卖家要求、集成、权限目录的名称与描述以及 422 响应体中的校验错误信息。这些文本原先全部由 Ruby 侧通过Spree.t按“请求解析出的商店 locale”生成而 Dashboard 无法控制这个 locale——结果就是波兰语界面配英语规则名、英语界面配波兰语错误提示的错位体验。本文基于仓库中的实施计划文档 6.0-admin-ui-owns-its-copy.md 与对应源码完整讲解 Spree 6.0 如何将“所有用户可见文案的所有权”从 API 移交给 DashboardAdmin API 只下发稳定的机器编码与纯文本兜底Dashboard 用自己的语言文件渲染最终词语。问题本质文案的“生产 locale”与“消费 locale”错位计划文档指出的根因是两类人读文本都由 Ruby 生产。类型目录的label/description由各类型的 Ruby 类方法Spree::Calculator.description字面量、规则基类的human_description/description对、spree/core/config/locales/en.yml 中price_rules.*、promotion_rule_types.*式键值生成422 中的句子则由 Rails 按请求 locale 从spree.errors.messages命名空间解析。而 Dashboard 的界面语言是前端自己选的它从不也不应该为界面语言发送x-spree-locale头——这个头选择的是商户查看/编辑哪一份 Mobility 内容翻译例如商品名把它绑死到界面语言会改变波兰语管理员在英语商店上实际编辑的数据。计划文档特别提到逐个类型修补如为VolumeRule补en.yml键只能维持分裂状态无法根治。正确的方向是把所有权划清API 发编码UI 发词语。核心设计决策计划文档列出的关键决策原文要求“未经讨论不得偏离”可以归纳为七条Admin API 发编码Dashboard 渲染词语。类型目录与记录序列化器保留稳定的 snake_casetype校验响应在每条错误上携带code及其插值参数。packages/dashboard*下只要存在该 code 的键就不打印 Ruby 生成的字符串。Ruby 保留人类可读兜底但不再是事实来源。label、description、message继续随响应下发服务于集成、curl调试以及“随扩展 gem 提供 Ruby 类型但不带 Dashboard 翻译”的场景文档中将其明确标注为“请求 locale 下的兜底文本”而非 UI 文案。Dashboard 仅在i18n.exists()为 false 时才使用它。Store API 完全不在范围内。422 错误处理器由 Admin 与 Store 两半共享因此更丰富的校验结构加在 Admin 基控制器中而不是共享的 ErrorHandler 里Store 响应保持逐字节不变。一个助手、一套键方案、两个面板共享。typeLabel/typeDescription从 Dashboard 私有文件移入spree/dashboard-core键方案为admin.types.family.code.{name,description}Seller Panel 直接导入同一助手不再复制语言文件。UI 语言与内容 locale 是两回事Dashboard 不把自己的界面语言作为x-spree-locale发给 Admin API。reject!以 code 作为第一个参数reject!(:not_awaiting_review)、reject!(:below_minimum, count: 5)。这直接复用errors.add(:base, symbol, **params)Rails 从en.yml解析消息并把 code 记入errors.details字符串调用继续可用转换可以渐进进行。code 与 message 并存而不是互相替代实施中发现的关键点Spree 的错误文案位于en.spree.errors.messages而裸errors.add(:base, :some_code)会在 Rails 顶层en.errors.messages及其activerecord.errors.*回退链中查找——这些命名空间 Spree 都没有填充。如果只发 code 丢掉 message所有句子都会变成 Translation missing。因此标准写法是errors.add(:attribute, :code, message: Spree.t(errors.messages.code))errors.details里多了 Dashboard 要翻译的 codeerrors.messages里保留集成方一直在读的文本带插值的消息保留其值。把文案整体搬进 Rails 命名空间被否决——那会把数百个键移出spree:命名空间破坏所有扩展的Spree.t调用。此外计划文档强调校验 code 直接来自 Rails不发明新词表errors.add(:attribute, :symbol, **params)本来就会产生errors.details工作流workflow也已经把reject!路由到ActiveModel::Errors。工作量是“把字符串与Spree.t调用点改成符号”而不是建立一套平行的错误注册表。类型目录稳定的type码与有据可查的兜底服务端目录发现入口是 spree/core/app/models/concerns/spree/preference_schema.rb 中的subclasses_with_preference_schema以及各家族的发现端点价格规则类型、规则类型、计算器列表、卖家要求种类等。各 Admin 控制器如 collection_rules_controller.rb、promotion_rules_controller.rb、price_lists_controller.rb都直接渲染model_class.subclasses_with_preference_schema。改造后的契约是目录条目继续返回{ type, label, description, preference_schema }label与description不改名、不删除但 OpenAPI 描述改为“给没有自己type翻译的客户端准备的兜底”。记录序列化器PriceRuleSerializer、PromotionRuleSerializer、PromotionActionSerializer及其嵌套calculator、CommissionRuleSerializer、DeliveryMethodRuleSerializer、OrderRoutingRuleSerializer、SellerRequirementSerializer、PermissionSerializer删除逐行的label/description/name拷贝——页面本来就已经拿到了类型目录用行上的type 目录即可渲染促销编辑器一直是这么做的DeliveryMethodRuleSerializer#name一并移除因为它复用了一个商户本以为是自有文本的属性名。Ruby 侧删除仅为下发而存在的类级文案方法Spree::Calculator.description字面量、规则基类上的human_description/description对以及只为喂线而存在的en.yml键。human_name保留作为兜底解析源也用于控制台与日志。支付方式的display_name与集成的name是品牌名而非文案位置不变。保留目录label/description的理由在计划文档中写得很直白如果删除每个携带 Ruby 类型的扩展 gem 就必须额外发一个带 locale 的 Dashboard 插件否则商户看到的是裸编码保留它们只花每条目几个字节还给整个迁移本身提供了安全网。Dashboard 侧typeLabel/typeDescription助手与六语言键方案助手实现位于 packages/dashboard-core/src/lib/type-labels.ts。其核心逻辑非常简洁function i18nKey(family: TypeFamily, code: string, facet: Facet): string { return admin.types.${family}.${code}.${facet} } export function typeLabel(family: TypeFamily, code: string, fallback?: string | null): string { const key i18nKey(family, code, name) if (i18n.exists(key)) return i18n.t(key) return fallback?.trim() || code }typeDescription同理只是没有翻译且没有兜底时返回空字符串描述是可选辅助文案宁可不渲染也不渲染裸编码。此外还有一个permissionGroupLabel(code, fallback)键为admin.types.permission_group.code.name用于权限目录的分组标签。TypeFamily联合类型枚举了全部十一个家族promotion_rule、promotion_action、calculator、order_routing_rule、collection_rule、price_rule、commission_rule、delivery_method_rule、seller_requirement、integration、permission。计划文档给出的迁移清单如下家族迁移前状态动作promotion_ruleadmin.promotions.rule_types下 11 个键迁移promotion_actionadmin.promotions.action_types下 4 个键迁移calculatoradmin.calculators下 11 个仅名称键迁移并补描述order_routing_rule3 个键迁移collection_rule3 个键迁移price_rule无新增 6 个commission_rule无新增 4 个delivery_method_rule无新增 4 个seller_requirementadmin.seller_requirements.kinds下仅名称迁移并补描述integration无按已安装的 provider gem 提供permission无资源标签、描述与分组标签所有键落地到两个包的六个语言文件packages/dashboard-core/src/locales/ 下的ar.json、de.json、en.json、fr.json、pl.json、zh-CN.jsonDashboard 包同样持有这六个文件。原先直接打印 API 字符串的消费方price-list-form.tsx、settings/commission-rates.tsx、settings/seller-requirements.tsx、delivery-method-form.tsx、卖家面板的delivery-methods.tsx、configure-integration-sheet.tsx、settings/integrations.tsx、permission-picker.tsx以及无头组件库dashboard-ui中的calculator-summary.tsx——后者因dashboard-ui是无头的改为接收已解析好的 label 作为 prop全部切换到助手。扩展类型如何接入packages/dashboard-core/src/plugin.ts 中的defineDashboardPlugin新增了locales贡献项约 L133–L144插件的 locale 数据在启动时并入 i18next不提供它的插件回落到 API 的label体验与迁移前一致。Admin 422富校验结构与服务端specific判定共享错误处理器 error_handler.rb 中render_validation_error调用format_validation_details生成details基础实现L265–L269只是把消息数组平铺返回——这正是 Store API 至今保持的形状{ attribute: [message, ...] }。Admin 分支通过 concern spree/api/app/controllers/concerns/spree/api/v3/admin/validation_details.rb 覆盖了format_validation_details它被混入 Admin 的 base_controller.rb 与 resource_controller.rb 两处。改造后一条 Admin 422 长这样{ error: { code: validation_error, message: Name cant be blank, Quantity must be greater than 0, details: { name: [{ code: blank, message: cant be blank }], quantity: [{ code: greater_than, message: must be greater than 0, count: 0 }] } } }逐条对照实现validation_details.rb L21–L44errors.details与errors.messages由 ActiveModel 同步填充第 n 条消息对应第 n 条 detailcode取 detail 中的:error符号以纯字符串添加的消息没有符号序列化为code: nullDashboard 见到 null 就渲染message插值键count、value等先展开再写入code/message/specific注释明确解释了顺序的原因校验本身可能携带code:选项作为插值数据它不能挤掉真正的 Rails 错误符号。specific字段只有服务端能做出的判断每条 detail 还携带specific布尔值回答“模型是否给这个 code 配了专属措辞”。计划文档举的例子是 webhook URL 校验它上报通用的invalid但消息是 must be a valid http or https URL——如果客户端把这个 code 翻译成通用的“无效”就丢掉了告诉商户该做什么的关键部分。这个判断只能由服务端做出因为比较的基准是“该 code 在消息解析所用 locale 下的默认值”——而客户端要在每种可能交易的语言里都认得这个默认措辞做不了。实现见specific_message?L69–L86def specific_message?(errors, attribute, code, message, options {}) message ! errors.generate_message(attribute, code, **options.symbolize_keys, raise: true) rescue StandardError false end两个实现细节值得注意raise: true必不可少。没有它Rails 对没有默认值的 code即所有 Spree 自有 code其文案在spree.errors.messages下会返回 Translation missing 字符串导致每个 Spree code 都被误判为“覆盖”。rescue StandardError false覆盖了三种正常失败I18n::MissingTranslationDataRails 无默认、I18n::MissingInterpolationArgument默认需要count但调用点只给了message:、NoMethodErroroption_values.name这类嵌套属性没有可读方法。任何一种都不应把 422 变成 500。validation_details_spec.rb 用一对匿名探针控制器Admin 侧与 Store 侧各渲染一个故意非法的记录把两种形状的契约锁死Admin 侧断言code与message并存、greater_than携带count: 0、纯字符串得到code: nil、模型自定义措辞得到specific: true、Spree 自有 code 不被误标、缺插值值时仍返回 422 而非 500Store 侧则断言details[name]依然是扁平的[cant be blank]字符串数组——“前端把details[attr][0]当字符串读的习惯不受影响”被 spec 直接保护。这也呼应了计划文档的原则specs 断言errors.details的 code而不是英语句子。客户端映射mapSpreeErrorsToForm的三级解析packages/dashboard-core/src/lib/form-errors.ts 中的mapSpreeErrorsToForm把 Admin API 抛出的SpreeError映射到 react-hook-form 实例做两件事把顶层error.message汇总挂到formState.errors.root保证FormError总能显示完整失败摘要即使个别字段未渲染再把可渲染的顶层扁平键设为逐字段错误嵌套键promotion_rules.base、line_items.0.quantity只进 root 摘要——“静默丢弃”是最坏的失败模式宁可与 root 冗余。单条 detail 的解析顺序resolveDetailMessage是admin.validation.codes.attribute.code如admin.validation.codes.url.invalid是专为该字段写的优先级最高specific为 true 时直接使用服务端messageadmin.validation.codes.code通用键用插值键填充都没有则回落到message。translatedSummary会尝试用翻译后的字段消息重建根摘要“Name cant be blank, Quantity must be greater than 0”其中属性名取自admin.fields.field.label与表单输入框标签同源只要有任何一条无法翻译就整体放弃重建、保留服务端原句——摘要要么全本地化要么原样绝不半翻译。codes这个命名空间是刻意为之admin.validation.*下已经住着表单自身 Zod 模式读取的客户端键required、min_length、invalid_email……而 Rails 的required缺失的belongs_to与客户端的required空输入语义完全不同。共享一个命名空间曾让 Dashboard 里每个必填字段底下都挂着“must exist”。为什么 code 必须与 message 并存前文决策 7 对应的是实施期发现的陷阱。标准转换写法errors.add(:quantity, :greater_than, message: Spree.t(errors.messages.greater_than)) reject!(:not_awaiting_review) reject!(:below_minimum, count: 5)errors.details由此获得 Dashboard 要翻译的 codeerrors.messages保留集成方在读的文本带插值的消息保留其值。原先读取的en.yml条目保留不删——兜底message还要从那里解析只是 Dashboard 不再依赖它们。迁移路径四步全部随 6.0 落地计划文档状态为 “Implemented — all four migration steps shipped for 6.0”Dashboard 类型标签完成。助手移入spree/dashboard-core统一键方案admin.types.family.code.{name,description}覆盖九个家族的 60 个类型码、全部六种语言所有打印 API 字符串的消费方改为按 code 解析同时补上了此前悄然回落到英语的两个促销规则channel、market与八个权限目录资源。Admin 校验形状完成。Spree::Api::V3::Admin::ValidationDetails在两个 Admin 分支上覆盖format_validation_details每条 detail 上报{ code, message, …interpolation }mapSpreeErrorsToForm翻译 code、回落到 messageStore 响应不变且由 spec 直接断言。核心错误 code完成。core 与 Stripe provider 中全部 94 个预格式化errors.add/reject!调用点获得符号 code对应原文的口径约 84 个字符串/Spree.t消息的errors.add、45 个传Spree.t/I18n.t文本的工作流reject!、6 个英文字面量拒绝并为原本没有消息的 7 个 code 新增en.yml键文案与先前版本逐字节一致。序列化器瘦身完成。七个规则/动作序列化器含嵌套 calculator 的 label上的逐行label/description/name拷贝全部移除类型目录保留其兜底defineDashboardPlugin新增locales贡献项为注册 Ruby 类型的 gem 提供文案落点。约束接手这项工作时的红线计划文档的 “Constraints on Current Work” 一节是给后续开发者的硬性规则实践中值得逐条对照新可插拔类型规则、动作、计算器、要求种类落地时必须在同一提交里为每个 Dashboard 语言文件加上admin.types.family.code.{name,description}键不要为它在en.yml里加Spree.t标签——APIlabel可以继续读human_name作为兜底。渲染类型选择器或规则行的 Dashboard 页面必须调用typeLabel/typeDescription永远不要直接打印entry.label、type.name或rule.description。唯一例外是集成name——那是品牌名Stripe、EasyPost不翻译其description是散文仍走助手。core 中新校验错误使用符号与参数errors.add(:quantity, :greater_than, count: 0)、reject!(:not_awaiting_review)绝不预格式化字符串且该 code 的 Dashboard 键在同一 PR 中添加。不要把 Dashboard 的界面语言作为x-spree-locale发给 Admin API该头只表示内容 locale。不要为这项工作触碰Spree::Api::V3::ErrorHandler或任何 Store 序列化器。共享管理端文案类型家族、校验 code放在 packages/dashboard-core/src/locales/ 的全部六种语言中绝不放入packages/seller-dashboard/src/locales/卖家面板导入助手而不是维护自己的键拷贝。小结Spree 6.0 的这次重构没有引入新的 i18n 框架也没有发明平行的错误词表它复用ActiveModel::Errors现成的details机制在 Admin 分支用一个可被 spec 锁死的覆盖层下发{ code, message, specific, …插值 }在 Dashboard 侧用一个统一的键方案admin.types.*与admin.validation.codes.*加 i18next 完成渲染用i18n.exists() 服务端message兜底保住扩展生态与curl场景。理解这套机制后你在 Spree 中新增一个规则类型、一条校验或一个扩展错误时就有了明确且唯一正确的文案落点。相关文档可继续阅读 6.0-admin-spa.md其中 “Forms i18n” 一节了解 i18n 约定与mapSpreeErrorsToForm的来历。【免费下载链接】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),仅供参考