diff --git a/.changeset/28369-identifier-issues-in-reader-locale.md b/.changeset/28369-identifier-issues-in-reader-locale.md new file mode 100644 index 00000000000..d4b1337a335 --- /dev/null +++ b/.changeset/28369-identifier-issues-in-reader-locale.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(console): a metadata create form states identifier errors in the reader's language, and its summary banner wraps them + +Clause-②: yes (widening) +Metadata-protocol: none + +A Chinese-language administrator who gave a new permission set (or any metadata item whose name is a snake_case identifier) an all-Chinese label was told in English, in the banner above the form, that Name "must be at least 2 characters" and "must be lowercase snake_case …", and the second sentence was cut off with an ellipsis on a narrow window. + +The banner and the inline field errors now pick the text by what the schema issue is (its code, and for a pattern its regex), never by reading the English: in Chinese they read 至少需要 2 个字符 and the snake_case rule in Chinese. In English they still show the sentences `@objectstack/spec` wrote, which do not change. The banner's lines now wrap instead of being truncated. + +`@object-ui/i18n`'s `studio` namespace gains the zh-only `engine.validation.spec.*` rows (`stringTooShort`, `snakeCaseIdentifier`, `systemIdentifier`); English has no row on purpose and falls back to the spec's message. `SchemaFormIssue` gains an optional `detail` (the issue's code and parameters). diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.identifierIssueI18n-28369.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.identifierIssueI18n-28369.test.tsx new file mode 100644 index 00000000000..1d464144b5b --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.identifierIssueI18n-28369.test.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28369 — the create form's summary banner states Name's identifier + * errors in the reader's locale, and wraps them instead of cutting them. + * + * A zh-CN administrator who types an all-Chinese label into a new permission + * set leaves Name empty, and `SnakeCaseIdentifierSchema` refuses it twice + * (`.min(2)` and `.regex`). Those messages are the spec's, in English, and stay + * so; the Console picks the reader's-locale text by the issue's code and + * pattern, never by reading the English: + * + * (a) zh-CN: both banner lines are Chinese, and neither carries the spec's + * English sentence; + * (b) CONTROL en-US: the banner shows the spec's own sentences, read from + * the installed schema (no copy of them lives in this repo); + * (c) the banner lines wrap: no line is cut with an ellipsis; + * (d) both identifier grammars of `shared/identifiers.zod.ts`, parsed live, + * answer zh rows in zh and the spec's message in en. + * + * The REAL page with the live registration (`registerBuiltinAnchors`) and the + * real served permission form from `METADATA_FORM_REGISTRY`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { z } from 'zod'; +import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '@objectstack/spec/shared'; +// The zh rows live in `@object-ui/i18n`'s `studio` namespace, whose `zh` +// catalogue is fetched on demand; resident before any case. +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +import { translateSchemaIssue, tOptional } from './i18n'; +import { validateMetadataDraft } from './clientValidation'; + +await loadStudioCatalogue('zh'); + +let localeFixture: 'en-US' | 'zh-CN' = 'zh-CN'; +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => localeFixture, +})); + +/** The spec's own messages for an empty identifier, from the installed schema. */ +function specIssues(schema: { safeParse: (v: unknown) => { error?: { issues: unknown[] } } }) { + return (schema.safeParse('').error?.issues ?? []) as Array<{ + code: string; + message: string; + origin?: string; + minimum?: number; + inclusive?: boolean; + format?: string; + pattern?: string; + }>; +} +const SNAKE = specIssues(SnakeCaseIdentifierSchema); +const SNAKE_EN = SNAKE.map((i) => i.message); + +const ZH_TOO_SHORT = (tOptional('engine.validation.spec.stringTooShort', 'zh-CN') ?? '').replace('{minimum}', '2'); +const ZH_SNAKE = tOptional('engine.validation.spec.snakeCaseIdentifier', 'zh-CN') ?? ''; +const ZH_SYSTEM = tOptional('engine.validation.spec.systemIdentifier', 'zh-CN') ?? ''; + +const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, { + io: 'input', + unrepresentable: 'any', +}) as Record; + +const CLIENT = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => null), + save: vi.fn(async (_type: string, _name: string, payload: Record) => payload), + history: vi.fn(async () => []), +}; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { + type: 'permission', + label: 'Permission Set', + domain: 'security', + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, + schema: PERMISSION_JSON_SCHEMA, + form: METADATA_FORM_REGISTRY.permission, + createSeed: getMetadataCreateSeed('permission') as Record, + }, + ], + }), +})); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { registerBuiltinAnchors } from './anchors'; + +registerDefaultMetadataSchemas(); +registerBuiltinAnchors(); + +beforeEach(() => { + localeFixture = 'zh-CN'; + window.localStorage.setItem('metadata-admin:autosave', '0'); +}); +afterEach(() => { + cleanup(); +}); + +/** Opens a new permission set and types an all-Chinese label; Name stays empty. */ +async function bannerLinesForChineseLabel(): Promise { + render( + + + , + ); + const labelBox = (await screen.findByRole('textbox', { name: /^(Label|标签)/ }, { timeout: 8000 })) as HTMLInputElement; + fireEvent.change(labelBox, { target: { value: '销售运营' } }); + const banner = await screen.findByTestId('metadata-validation-banner', {}, { timeout: 8000 }); + await waitFor(() => expect(banner.querySelectorAll('[data-testid="metadata-diagnostics-item"]').length).toBeGreaterThanOrEqual(2)); + return Array.from(banner.querySelectorAll('[data-testid="metadata-diagnostics-item"]')); +} + +describe('the create banner states identifier issues in the reader’s locale (objectstack#28369)', () => { + it('the fixtures are real: the spec refuses an empty identifier twice, and the zh rows exist', () => { + expect(SNAKE.map((i) => i.code)).toEqual(['too_small', 'invalid_format']); + expect(ZH_TOO_SHORT).toBe('至少需要 2 个字符'); + expect(ZH_SNAKE).toMatch(/snake_case/); + expect(ZH_SYSTEM).not.toBe(''); + }); + + it('(a) zh-CN: both Name lines are Chinese, and no line carries the spec’s English', async () => { + const lines = (await bannerLinesForChineseLabel()).map((li) => li.textContent ?? ''); + expect(lines.some((l) => l.includes(ZH_TOO_SHORT))).toBe(true); + expect(lines.some((l) => l.includes(ZH_SNAKE))).toBe(true); + for (const en of SNAKE_EN) expect(lines.some((l) => l.includes(en))).toBe(false); + }); + + it('(b) CONTROL en-US: the banner shows the spec’s own sentences', async () => { + localeFixture = 'en-US'; + const lines = (await bannerLinesForChineseLabel()).map((li) => li.textContent ?? ''); + for (const en of SNAKE_EN) expect(lines.some((l) => l.includes(en))).toBe(true); + expect(lines.some((l) => l.includes(ZH_SNAKE))).toBe(false); + }); + + it('(c) the banner lines wrap instead of cutting the message', async () => { + const lines = await bannerLinesForChineseLabel(); + for (const li of lines) { + expect(li.className.split(/\s+/)).not.toContain('truncate'); + expect(li.className.split(/\s+/)).toContain('whitespace-normal'); + } + }); +}); + +describe('the spec’s identifier grammars are rendered by code and pattern (objectstack#28369)', () => { + it('(d) the client door carries each issue’s code and pattern beside the spec’s message', async () => { + const { issues } = await validateMetadataDraft('permission', { name: '', label: '销售运营' }); + const nameIssues = issues.filter((i) => i.path === 'name'); + expect(nameIssues.map((i) => i.message)).toEqual(SNAKE_EN); + expect(nameIssues.map((i) => translateSchemaIssue(i, 'zh-CN'))).toEqual([ZH_TOO_SHORT, ZH_SNAKE]); + expect(nameIssues.map((i) => translateSchemaIssue(i, 'en-US'))).toEqual(SNAKE_EN); + }); + + it.each([ + ['SnakeCaseIdentifierSchema', SNAKE, ZH_SNAKE], + ['SystemIdentifierSchema', specIssues(SystemIdentifierSchema), ZH_SYSTEM], + ] as const)('(d) %s, parsed live: zh rows in zh, the spec’s message in en', (_name, parsed, zhFormat) => { + const asIssue = ({ message, ...detail }: (typeof parsed)[number]) => ({ message, detail }); + expect(parsed.map((i) => translateSchemaIssue(asIssue(i), 'zh-CN'))).toEqual([ZH_TOO_SHORT, zhFormat]); + expect(parsed.map((i) => translateSchemaIssue(asIssue(i), 'en-US'))).toEqual(parsed.map((i) => i.message)); + }); + + it('an issue without a code keeps the existing message path', () => { + expect(translateSchemaIssue({ message: SNAKE_EN[1] }, 'zh-CN')).toBe(SNAKE_EN[1]); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 5d16344f6a9..5f0dd044ffd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -143,7 +143,7 @@ import { readFields } from './previews/object-fields-io.js'; import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../assistant/assistantBus.js'; import { useRegisteredMetadataInspector } from './inspector-registry.js'; import { useRegisteredMetadataDefaultInspector } from './default-inspector-registry.js'; -import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidationMessage } from './i18n.js'; +import { useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js'; import { JsonSourceEditor } from '../item-tools/JsonSourceEditor.js'; import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js'; import { @@ -867,7 +867,7 @@ function MetadataResourceEditPageImpl({ const errs = (hasClientValidator(type, draftMode) ? withServerOnlyErrors(displayIssues, diag?.errors) : (diag?.errors ?? []) - ).map((i) => ({ path: i.path, message: translateValidationMessage(i.message, locale) })); + ).map((i) => ({ path: i.path, message: translateSchemaIssue(i, locale) })); const warns = (diag?.warnings ?? []).map((i) => ({ path: i.path, message: translateValidationMessage(i.message, locale), @@ -3044,7 +3044,7 @@ function MetadataResourceEditPageImpl({ : (diag?.errors ?? []) ).map((i) => ({ path: i.path, - message: translateValidationMessage(i.message, locale), + message: translateSchemaIssue(i, locale), })); const liveValid = hasClientValidator(type, draftMode) ? liveErrors.length === 0 @@ -3102,7 +3102,9 @@ function MetadataResourceEditPageImpl({
    {head.map((e, i) => ( -
  • + // Wraps: a message is the fix the author needs, so + // it is never cut to the banner's width. +
  • {e.path ? labelForIssuePath(e.path) : '(root)'}: {e.message}
  • ))} diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 8480c2b14e0..c62ac980ba0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -68,7 +68,8 @@ import { type WidgetLabelling, type WidgetRenderer, } from './widgets.js'; -import { useMetadataLocale, t, tFormat, translateValidationMessage, translateEnumOption, translateSchemaFieldLabel, translateSchemaFieldHelp } from './i18n.js'; +import { useMetadataLocale, t, tFormat, translateSchemaIssue, translateEnumOption, translateSchemaFieldLabel, translateSchemaFieldHelp } from './i18n.js'; +import type { SchemaIssueDetail } from './i18n.js'; import { prettifyFieldName as prettify, untranslatedFieldLabel } from './field-source-label.js'; /** @@ -1078,6 +1079,12 @@ function readVisibility( export interface SchemaFormIssue { path: string; message: string; + /** + * The Zod issue's code and parameters when the issue came from a client + * schema parse; what `translateSchemaIssue` picks the reader's-locale text + * by. Absent on server diagnostics and lint findings. + */ + detail?: SchemaIssueDetail; } export interface SchemaFormProps { @@ -1189,7 +1196,7 @@ function SchemaFormBody({ const issuesByPath = React.useMemo(() => { const map: Record = {}; for (const i of issues) { - (map[i.path] ??= []).push(translateValidationMessage(i.message, locale)); + (map[i.path] ??= []).push(translateSchemaIssue(i, locale)); } return map; }, [issues, locale]); diff --git a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts index e5eca2a6acd..bfeb59f0ee3 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts @@ -21,6 +21,7 @@ */ import type { SchemaFormIssue } from './SchemaForm.js'; +import type { SchemaIssueDetail } from './i18n.js'; import { lintCelPredicate } from './celAuthoring.js'; import { readFields } from './previews/object-fields-io.js'; @@ -48,6 +49,12 @@ type ZodLikeIssue = { * can list several retired aliases in a single `keys` array. */ keys?: string[]; + /** `too_small` / `invalid_format` parameters, carried to the render as `detail`. */ + origin?: string; + minimum?: number | bigint; + inclusive?: boolean; + format?: string; + pattern?: string; }; type ZodLikeSchema = { @@ -1027,6 +1034,32 @@ function annotateRetiredFormulaKeyIssues(issues: ZodLikeIssue[], draft: unknown) }); } +/** + * The issue codes `translateSchemaIssue` picks a reader's-locale text by. Only + * these carry a `detail`: every other issue keeps the plain `{ path, message }` + * it had, so a code that no rendering reads adds nothing to the door's answer. + */ +const LOCALIZED_ISSUE_CODES: ReadonlySet = new Set(['too_small', 'invalid_format']); + +/** + * The issue's code and parameters, so the page renders it in the reader's + * locale by WHAT it is (`translateSchemaIssue`) instead of by its English. + * PRESENTATION ONLY, like the two passes above: the message stays the spec's, + * and the verdict is already decided. + */ +function issueDetail(issue: ZodLikeIssue): SchemaIssueDetail | undefined { + if (typeof issue.code !== 'string' || !LOCALIZED_ISSUE_CODES.has(issue.code)) return undefined; + const minimum = typeof issue.minimum === 'bigint' ? Number(issue.minimum) : issue.minimum; + return { + code: issue.code, + ...(typeof issue.origin === 'string' ? { origin: issue.origin } : {}), + ...(typeof minimum === 'number' ? { minimum } : {}), + ...(typeof issue.inclusive === 'boolean' ? { inclusive: issue.inclusive } : {}), + ...(typeof issue.format === 'string' ? { format: issue.format } : {}), + ...(typeof issue.pattern === 'string' ? { pattern: issue.pattern } : {}), + }; +} + // Keyed by mode AND type — `view` resolves to a different schema per mode, so // caching by type alone would hand the create gate whichever mode asked first. const SCHEMA_CACHE = new Map(); @@ -1167,10 +1200,14 @@ export async function validateMetadataDraft( ? annotateRetiredFormulaKeyIssues(rawIssues, draft) : rawIssues; const issues: SchemaFormIssue[] = [ - ...renderable.map((issue) => ({ - path: (issue.path ?? []).map((seg) => String(seg)).join('.'), - message: issue.message, - })), + ...renderable.map((issue) => { + const detail = issueDetail(issue); + return { + path: (issue.path ?? []).map((seg) => String(seg)).join('.'), + message: issue.message, + ...(detail ? { detail } : {}), + }; + }), ...celIssues, ]; return { ok: false, issues }; diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index 41adfa441fc..fe4f9f6b13f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -892,6 +892,71 @@ export function translateValidationMessage( return raw; } +/** + * The structural slice of a Zod issue that a rendered message may be chosen + * by: its `code` and the parameters Zod reports beside it. Carried next to the + * spec's own `message` from `validateMetadataDraft`, so a translation is picked + * by WHAT the issue is, never by reading its English. + */ +export interface SchemaIssueDetail { + /** Zod 4 issue discriminator (`too_small`, `invalid_format`, …). */ + code: string; + /** `too_small`: what was measured (`string`, `array`, `number`, …). */ + origin?: string; + /** `too_small`: the bound, and whether the bound itself is allowed. */ + minimum?: number; + inclusive?: boolean; + /** `invalid_format`: the format family (`regex`, `email`, …). */ + format?: string; + /** `invalid_format` with `format: 'regex'`: the pattern, as Zod reports it. */ + pattern?: string; +} + +/** + * The spec's identifier grammars, keyed by the pattern source its schemas + * declare (`@objectstack/spec`, `shared/identifiers.zod.ts`), each to the zh row + * that says what the grammar asks for. A pattern not listed keeps the spec's + * own message. `ResourceEditPage.identifierIssueI18n-28369.test.tsx` parses the + * live schemas and reds when a pattern here stops matching what they report. + */ +const SPEC_PATTERN_ROWS: Readonly> = Object.freeze({ + '^[a-z][a-z0-9_]*$': 'engine.validation.spec.snakeCaseIdentifier', + '^[a-z][a-z0-9_.]*$': 'engine.validation.spec.systemIdentifier', +}); + +/** `/source/flags` or a bare `source` → `source`. */ +function patternSource(pattern: string): string { + const m = pattern.match(/^\/(.*)\/[a-z]*$/s); + return m ? m[1] : pattern; +} + +/** + * The text to render for one schema issue in the reader's locale. + * + * The spec's message is the contract's voice, and English is its language. + * The `engine.validation.spec.*` family has zh rows only, read through + * {@link tOptional}: an en console finds nothing and shows the spec's own + * sentence, never a copy of it kept in this repo. An issue with no `detail` + * (a server diagnostic, a CEL lint finding) takes + * {@link translateValidationMessage} unchanged. + */ +export function translateSchemaIssue( + issue: { message: string; detail?: SchemaIssueDetail }, + locale?: SupportedLocale | string, +): string { + const d = issue.detail; + if (d?.code === 'too_small' && d.origin === 'string' && typeof d.minimum === 'number' && d.inclusive !== false) { + const row = tOptional('engine.validation.spec.stringTooShort', locale); + if (row) return row.replace('{minimum}', String(d.minimum)); + } + if (d?.code === 'invalid_format' && d.format === 'regex' && typeof d.pattern === 'string') { + const key = SPEC_PATTERN_ROWS[patternSource(d.pattern)]; + const row = key ? tOptional(key, locale) : undefined; + if (row) return row; + } + return translateValidationMessage(issue.message, locale); +} + /** * Returns the locale string most browsers report (matches navigator.language). * diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts index 138a6f73a35..b1554a037a9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts @@ -147,6 +147,7 @@ const ZH_ONLY_FLOOR: Readonly> = Object.freeze({ 'engine.packages.form.help.': 9, 'engine.flowNode.': 40, 'engine.enum.': 4, + 'engine.validation.spec.': 3, }); /** @@ -183,6 +184,15 @@ const ZH_ONLY_REASONS = Object.freeze([ file: '../package-schema.ts', anchor: 'en-US deliberately has NO entry', }, + { + prefix: 'engine.validation.spec.', + reason: + 'zh-only on purpose: with no `en` row tOptional returns undefined and translateSchemaIssue shows the ' + + 'issue message @objectstack/spec wrote — an `en` row would copy that text into a second producer, ' + + 'against AGENTS.md #0.1', + file: '../i18n.ts', + anchor: 'family has zh rows only, read through', + }, ]); const familyKeys = (prefix: string): string[] => Object.keys(EN).filter((key) => key.startsWith(prefix)); diff --git a/ui/packages/i18n/src/__tests__/studio-zh-only-families.ts b/ui/packages/i18n/src/__tests__/studio-zh-only-families.ts index dfe1a88f141..3ca060be0e6 100644 --- a/ui/packages/i18n/src/__tests__/studio-zh-only-families.ts +++ b/ui/packages/i18n/src/__tests__/studio-zh-only-families.ts @@ -12,4 +12,5 @@ export const STUDIO_ZH_ONLY_FAMILIES: readonly string[] = Object.freeze([ 'engine.flowNode.', 'engine.enum.type.', 'engine.packages.form.help.', + 'engine.validation.spec.', ]); diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 1ffa22d5ad2..7ce37cfc5d3 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -3647,6 +3647,14 @@ const STUDIO_ZH: Record = { 'engine.validation.seedPlaceholderUnset': '请先设置 {fields} 再保存:新建项不会带着模板的占位值保存。', // objectstack#28179 — 见英文表同名条目。 'engine.validation.fieldRequired': '此项为必填项。', + // The `engine.validation.spec.*` rows are zh-only on purpose: they render a + // schema issue from `@objectstack/spec` by its code, and the en text is the + // spec's own message (`translateSchemaIssue`, read through `tOptional`). + 'engine.validation.spec.stringTooShort': '至少需要 {minimum} 个字符', + 'engine.validation.spec.snakeCaseIdentifier': + '须为小写 snake_case:以字母开头,只能包含字母、数字和下划线(例如 “user_profile”)', + 'engine.validation.spec.systemIdentifier': + '须为小写:以字母开头,可包含字母、数字、下划线或点(例如 “user_profile” 或 “order.created”)', 'engine.embedded.saved': '已保存。', 'engine.embedded.readOnlyParent': '父类型为只读;仍会尝试 PUT 保存,但服务器可能拒绝。', 'engine.embedded.saveIntoParent': '保存到 {parentType}',