关于 migrate-frontend-forms
为 Sentry 开发者提供将 JsonForm、FormModel 等旧表单迁移到基于 TanStack 的新系统的指南,涵盖功能映射、错误适配和保存行为调整。
主要功能
- 01映射旧表单配置到新 API
- 02迁移自动保存与提交行为
- 03转换服务端字段错误
- 04调整成功与失败回调
- 05处理仅展示内容和表单重置
适用场景
- 01将 JsonForm 迁移到新表单系统
- 02替换旧的按焦点保存逻辑
- 03迁移后端错误映射机制
- 04保留保存提示及重置行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ getsentry/ sentry --skill migrate-frontend-forms安装后在 Claude Code 中输入/migrate-frontend-forms确认已加载。
详细介绍
表单迁移指南
此技能可帮助将表单从 Sentry 的旧版表单系统(JsonForm、FormModel)迁移到基于 TanStack 的新系统。
功能映射
| 旧系统 | 新系统 | 备注 |
|---|---|---|
saveOnBlur: true | AutoSaveForm | 默认行为 |
confirm | confirm prop | string | ((value) => string | undefined) |
showHelpInTooltip | variant="compact" | 用于布局组件 |
disabledReason | disabled="reason" | 字符串会显示工具提示 |
extraHelp | 布局中的 JSX | 在字段下方渲染 <Text> |
getData | mutationFn | 在 mutation 函数中转换数据 |
mapFormErrors | 请求错误适配器 | 普通表单需显式提供;自动保存表单会提供 |
saveMessage | onSuccess | 在 mutation 的 onSuccess 回调中显示 toast |
formatMessageValue | onSuccess | 在 onSuccess 回调中控制 toast 内容 |
resetOnError | onError | 在 mutation 的 onError 中调用 form.reset() |
saveOnBlur: false | useScrapsForm | 使用带有显式保存按钮的普通表单 |
| (自动) | form.reset() | 若表单在成功 mutation 后仍留在页面上,则调用 |
help | hintText | 用于布局组件 |
label | label | 用于布局组件 |
required | required | 用于布局组件和 Zod schema |
| 仅用于显示的内容 | DisplayField.Row / DisplayField.Stack | 不注册输入项,也不包含在表单状态中 |
功能详情
confirm → confirm prop
旧版:
{
name: 'require2FA',
type: 'boolean',
confirm: {
true: 'Enable 2FA for all members?',
false: 'Allow members without 2FA?',
},
isDangerous: true,
}
新版:
<AutoSaveForm
name="require2FA"
confirm={value =>
value
? 'Enable 2FA for all members?'
: 'Allow members without 2FA?'
}
{...}
>
showHelpInTooltip → variant="compact"
旧版:
{
name: 'field',
help: 'This is help text',
showHelpInTooltip: true,
}
新版:
<field.Layout.Row
label="Field"
hintText="This is help text"
variant="compact"
>
disabledReason → disabled="reason"
旧版:
{
name: 'field',
disabled: true,
disabledReason: 'Requires Business plan',
}
新版:
<field.Input
disabled="Requires Business plan"
{...}
/>
extraHelp → JSX
旧版:
{
name: 'sensitiveFields',
help: 'Main help text',
extraHelp: 'Note: These fields apply org-wide',
}
新版:
<field.Layout.Stack label="Sensitive Fields" hintText="Main help text">
<field.TextArea {...} />
<Text size="sm" variant="muted">
Note: These fields apply org-wide
</Text>
</field.Layout.Stack>
getData → mutationFn
getData 函数会在将字段数据发送到 API 前对其进行转换。在新系统中,请在 mutationFn 中处理此操作。
旧版:
// Wrap field value in 'options' key
{
name: 'sentry:csp_ignored_sources_defaults',
type: 'boolean',
getData: data => ({options: data}),
}
// Or extract/transform specific fields
{
name: 'slug',
getData: (data: {slug?: string}) => ({slug: data.slug}),
}
新版:
<AutoSaveForm
name="sentry:csp_ignored_sources_defaults"
schema={schema}
initialValue={project.options['sentry:csp_ignored_sources_defaults']}
mutationOptions={{
mutationFn: data => {
// Transform data before API call (equivalent to getData)
const transformed = {options: data};
return fetchMutation({
url: `/projects/${organization.slug}/${project.slug}/`,
method: 'PUT',
data: transformed,
});
},
}}
>
{field => (
<field.Layout.Row label="Use default ignored sources">
<field.Switch checked={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
更简单的写法 - 如果你只需要包装值:
mutationOptions={{
mutationFn: fieldData => {
return fetchMutation({
url: `/projects/${org}/${project}/`,
method: 'PUT',
data: {options: fieldData}, // getData equivalent
});
},
}}
重要:正确为 mutation 添加类型
mutationFn 应使用 API 的数据类型(例如 Partial<Organization>、Partial<Project>)声明类型,而不是使用 schema 推断出的类型。schema 仅用于客户端字段验证——mutation 接收的是 API 端点接受的任何数据。让 mutation 依赖 schema 会将两个无关的关注点耦合起来,并可能在 schema 类型与 API 类型不完全匹配时导致类型错误。
// ❌ Don't use generic types - breaks field type narrowing
mutationOptions={{
mutationFn: (data: Record<string, unknown>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ❌ Don't tie mutation type to the zod schema
mutationOptions={{
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ✅ Use the API's data type
mutationOptions={{
mutationFn: (data: Partial<UserDetails>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
请确保 Zod schema 的类型与 API 类型兼容(即可以赋值给 API 类型)。例如,如果 API 需要 'off' | 'low' | 'high' 这样的字符串联合类型,请使用 z.enum(['off', 'low', 'high']),而不是 z.string()。
绝不要向 useMutation、mutationOptions 或任何 TanStack Query 函数传入调用处的泛型。 这适用于所有泛型——数据、错误、变量和上下文。必须推断类型,而不是断言类型。完整规则请参阅 static/AGENTS.md 中的“TanStack Query Type Inference”。
// ❌ Generics on useMutation — NEVER do this
const mutation = useMutation<CodeOwner, RequestError, [Payload]>({
mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}),
});
// ❌ Generics on mutationOptions — NEVER do this either
mutationOptions<unknown, RequestError, Variables, MyContext>({...})
// ❌ Explicit context type — inferred from onMutate return
type MyContext = {changeId: string};
// ❌ RequestError as error generic — it's a type assertion in disguise
// Other things can go wrong that would NOT yield a RequestError
// ✅ Type the mutationFn payload; fetchMutation<T> carries the return type
const mutation = useMutation({
mutationFn: (payload: {codeMappingId: string; raw: string}) =>
fetchMutation<CodeOwner>({
url: `/projects/${org}/${project}/codeowners/`,
method: 'POST',
data: payload,
}),
});
// ✅ Context is inferred from onMutate, error is Error by default
mutationOptions({
mutationFn: (variables: MyVars) => fetchMutation<MyResponse>({...}),
onMutate: async () => {
return {changeId: uniqueId()}; // context type inferred from this
},
onError: (_error, _vars, context) => {
// context?.changeId is typed automatically
// _error is Error — use runtime narrowing for RequestError
},
})
mapFormErrors → requestErrorToFieldErrors + setFieldErrors
mapFormErrors 函数用于将 API 错误响应转换为字段级错误。在新系统中,使用 requestErrorToFieldErrors 转换 Sentry API 错误,然后将 Scraps 的 FieldErrors 结果传递给 setFieldErrors。
不要将 RequestError 直接传递给 setFieldErrors。Scraps 不依赖于 Sentry 的 API 客户端类型。
旧:
// Form-level error transformer
function mapMonitorFormErrors(responseJson?: any) {
if (responseJson.config === undefined) {
return responseJson;
}
// Flatten nested config errors to dot notation
const {config, ...rest} = responseJson;
const configErrors = Object.fromEntries(
Object.entries(config).map(([key, value]) => [`config.${key}`, value])
);
return {...rest, ...configErrors};
}
<Form mapFormErrors={mapMonitorFormErrors} {...}>
新:
import {setFieldErrors} from '@sentry/scraps/form';
import {RequestError} from 'sentry/utils/requestError/requestError';
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {...},
validators: {onDynamic: schema},
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
if (!(error instanceof RequestError)) {
return;
}
// Keep custom mapping only when the legacy form reshaped the response.
const responseJson = error.responseJSON;
if (responseJson?.config) {
// Flatten nested errors to dot notation
const {config, ...rest} = responseJson;
const errors: Record<string, {message: string}> = {};
for (const [key, value] of Object.entries(rest)) {
errors[key] = {message: Array.isArray(value) ? value[0] : String(value)};
}
for (const [key, value] of Object.entries(config)) {
errors[`config.${key}`] = {message: Array.isArray(value) ? value[0] : String(value)};
}
setFieldErrors(formApi, errors);
}
}
},
});
更简单的模式 - 适用于扁平错误响应:
import {setFieldErrors} from '@sentry/scraps/form';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
if (!(error instanceof RequestError)) {
addErrorMessage(t('Unable to save changes.'));
return;
}
const handled = setFieldErrors(
formApi,
requestErrorToFieldErrors(error, formApi.state.values)
);
if (!handled) {
addErrorMessage(t('Unable to save changes.'));
}
}
},
requestErrorToFieldErrors 接受 RequestError。在 Sentry 调用点处对未知错误进行收窄后再转换。适配器会根据 formApi.state.values 过滤响应键,并返回 Scraps 的字段错误结构。仅当迁移需要自定义响应重构时(例如上面的嵌套 config 示例),才使用直接的 FieldErrors 对象。
对于 AutoSaveForm,标准请求错误处理是自动的。Sentry 表单错误提供程序使用 requestErrorToFieldErrors 处理字段错误,使用 getRequestErrorUserMessage 处理请求详情或状态消息。不要为每个自动保存字段添加常规表单的 catch 块。
注意:
setFieldErrors支持使用点号表示法的嵌套路径:'config.schedule': {message: 'Invalid schedule'}
saveMessage → onSuccess
saveMessage 在保存成功后显示自定义 toast/alert。在新系统中,请在 mutation 的 onSuccess 回调中处理此逻辑。
旧:
{
name: 'fingerprintingRules',
saveOnBlur: false,
saveMessageAlertVariant: 'info',
saveMessage: t('Changing fingerprint rules will apply to future events only.'),
}
新:
import {addSuccessMessage} from 'sentry/actionCreators/indicator';
<AutoSaveForm
name="fingerprintingRules"
schema={schema}
initialValue={project.fingerprintingRules}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onSuccess: () => {
// Custom success message (equivalent to saveMessage)
addSuccessMessage(t('Changing fingerprint rules will apply to future events only.'));
},
}}
>
formatMessageValue → onSuccess
formatMessageValue 控制更改后的值在成功 toast 中的显示方式。将其设置为 false 会完全禁止显示该值(适用于大型文本字段)。在新系统中,你可以在 onSuccess 中直接控制这一点。
旧:
{
name: 'fingerprintingRules',
saveMessage: t('Rules updated'),
formatMessageValue: false, // Don't show the (potentially huge) value in toast
}
新:
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onSuccess: () => {
// Just show the message, no value (equivalent to formatMessageValue: false)
addSuccessMessage(t('Rules updated'));
},
}}
// Or if you want to show a formatted value:
onSuccess: (data) => {
addSuccessMessage(t('Slug changed to %s', data.slug));
},
resetOnError → onError
resetOnError 选项在保存失败时将字段恢复为之前的值。在新系统中,请在 mutation 的 onError 回调中调用 form.reset()。
旧:
// Form-level reset on error
<Form resetOnError apiEndpoint="/auth/" {...}>
// Or field-level (BooleanField always resets on error)
<FormField resetOnError name="enabled" {...}>
新(使用 useScrapsForm):
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {password: ''},
validators: {onDynamic: schema},
onSubmit: async ({value}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// Reset form to previous values on error (equivalent to resetOnError)
form.reset();
throw error; // Re-throw if you want error handling to continue
}
},
});
新(使用 AutoSaveForm):
<AutoSaveForm
name="enabled"
schema={schema}
initialValue={settings.enabled}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
onError: () => {
// The field automatically shows error state via TanStack Query
// If you need to reset the value, you can pass a reset callback
},
}}
>
注意:配合 TanStack Query 的 AutoSaveForm 已经优雅地处理了错误状态 - mutation 的
isError状态会反映在 UI 中。手动重置通常仅在特定 UX 需求(如密码字段)时才需要。
保存后重置
当使用 useScrapsForm 处理保存后仍停留在页面上的表单时,请在 mutation 成功后调用 form.reset()。这会将表单与更新后的 defaultValues 重新同步,使其再次变为 pristine 状态 — 任何依赖于表单 dirty 状态的 UI(如条件显示的 Save/Cancel 按钮)将正确更新。
onSubmit: ({value}) =>
mutation
.mutateAsync(value)
.then(() => form.reset())
.catch(() => {}),
注意:
AutoSaveForm会自动处理此逻辑。只有在使用useScrapsForm时才需要添加此代码。
saveOnBlur: false → useScrapsForm
设置了 saveOnBlur: false 的字段会显示带有 Save/Cancel 按钮的内联 alert,而不是自动保存。这用于危险操作(slug 变更)或大型文本编辑(fingerprint rules)。
在新系统中,使用带有 useScrapsForm 的常规表单和显式的 Save 按钮。这保留了在提交之前显示警告的 UX。
旧:
{
name: 'slug',
type: 'string',
saveOnBlur: false,
saveMessageAlertVariant: 'warning',
saveMessage: t("Changing a project's slug can break your build scripts!"),
}
新:
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
const slugSchema = z.object({
slug: z.string().min(1, 'Slug is required'),
});
function SlugForm({project}: {project: Project}) {
const mutation = useMutation({
mutationFn: (data: {slug: string}) =>
fetchMutation({url: `/projects/${org}/${project.slug}/`, method: 'PUT', data}),
});
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {slug: project.slug},
validators: {onDynamic: slugSchema},
onSubmit: ({value}) => mutation.mutateAsync(value).catch(() => {}),
});
return (
<form.AppForm form={form}>
<form.AppField name="slug">
{field => (
<field.Layout.Stack label="Project Slug">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
{/* Warning shown before saving (equivalent to saveMessage) */}
<Alert variant="warning">
{t("Changing a project's slug can break your build scripts!")}
</Alert>
<Flex gap="sm" justify="end">
<form.ResetButton>Reset</form.ResetButton>
<form.SubmitButton>Save</form.SubmitButton>
</Flex>
</form.AppForm>
);
}
何时使用此模式:
- 用户应在提交前看到警告的危险操作(slug 变更、安全令牌)
- 希望在保存前完成编辑的大型多行文本字段(fingerprint rules、filters)
- 任何自动保存不合理的字段
通过表单提交,而不是绕过它。 遵循上面的 SlugForm 模式 — mutation 在 onSubmit 中运行,Save 按钮为 <form.SubmitButton>。不要在没有 onSubmit 的情况下渲染 <form.AppForm> 并通过独立的 <Button onClick> 触发 mutation:
// ❌ Form is never submitted; mutation fires from a separate button
const form = useScrapsForm({
...defaultFormOptions,
defaultValues,
validators: {onDynamic: schema},
// no onSubmit
});
return (
<form.AppForm form={form}>
<form.AppField name="codeMappingId">{...}</form.AppField>
<Button onClick={() => mutation.mutate(...)}>Save</Button>
</form.AppForm>
);
一个从未真正提交的表单会绕过验证、pending/disabled 状态和字段错误接线。
仅用于展示的信息
对于日期、状态文本或其他不含输入项的内容,请使用 @sentry/scraps/form 中的 DisplayField.Row 或 DisplayField.Stack。不要仅仅为了实现布局而创建 AppField 或 AutoSaveForm:
<DisplayField.Row label={t('Created')}>
<Text>
<DateTime date={data.dateCreated} />
</Text>
</DisplayField.Row>
移除仅用于展示这些内容的 schema 条目和默认值。必要时使用 <Text> 保留文本样式。使用现有的 form.Subscribe 在渲染期间计算派生的展示值;不要使用 form.setFieldValue 将这些值复制到表单状态中。
Row 与响应式字段布局一致;Stack 则始终采用堆叠布局。这些组件使用描述列表语义,不包含 <label>、字段上下文或错误聚焦注册。将实际的只读输入保留在 AppField 内的 field.Input readOnly 和 field.Layout.* 中,以便保留输入关联。处理表单状态的自定义控件仍需要 field.Base。
有关布局属性和订阅示例,请参阅表单生成指南。
保留表单搜索功能
Sentry 的 SettingsSearch 允许用户搜索单个设置字段。迁移表单时,必须通过使用 FormSearch 包裹迁移后的表单来保留此搜索功能。
FormSearch 组件
FormSearch 是一个构建时标记组件——它没有任何运行时行为,只会原样渲染其子项。静态提取脚本会读取它的 route 属性,将表单字段与其导航路由关联起来,使这些字段能够出现在 SettingsSearch 搜索结果中。
import {FormSearch} from 'sentry/components/core/form';
<FormSearch route="/settings/account/details/">
<FieldGroup title={t('Account Details')}>
<AutoSaveForm name="name" schema={schema} initialValue={user.name} mutationOptions={...}>
{field => (
<field.Layout.Row label={t('Name')} hintText={t('Your full name')} required>
<field.Input />
</field.Layout.Row>
)}
</AutoSaveForm>
</FieldGroup>
</FormSearch>
属性:
| 属性 | 类型 | 描述 |
|---|---|---|
route | string | 此表单对应的设置路由(例如 '/settings/account/details/')。用于搜索导航。 |
children | ReactNode | 表单内容——运行时原样渲染。 |
规则:
route必须与设置页面 URL 完全一致(包括末尾的斜杠)。- 使用单个
FormSearch包裹整个表单区块,而不是单个字段。 FormSearch内的每个<AutoSaveForm>或<form.AppField>都会被索引。请确保label和hintText是纯字符串字面量或t()调用——提取器会跳过计算得出或动态生成的字符串。
表单字段注册表
添加或更新 FormSearch 包装器后,请重新生成字段注册表,以便搜索结果保持最新:
pnpm run extract-form-fields
此脚本(./scripts/extractFormFields.ts)会扫描所有 TSX 文件,查找 <FormSearch> 组件,提取字段元数据(name、label、hintText、route),并将生成的注册表写入 static/app/views/settings/fieldRegistry.generated.ts。请将此生成文件与迁移 PR 一并提交——它是源码树的一部分。
在对
FormSearch包装器内的表单进行任何更改后(添加、移除、更改标签),都请运行此命令。生成的文件会纳入版本控制,不应手动编辑。
迁移:已可搜索的旧表单
如果要迁移的旧版 JsonForm 已被 SettingsSearch 索引(即它在 sentry/data/forms 中有条目),则必须为新表单添加 FormSearch 包装器,以保留搜索功能。新旧来源会并存——对于相同的路由与字段组合,新的注册表条目优先于旧条目;但移除旧表单后,旧条目也会随之消失。
处理可空的初始值
旧版选择字段通常以空值/未定义值开头,并要求用户进行选择。字段始终为必填项时,请在 schema 中使用 .nullable().refine(),使用 z.input<typeof schema> 为 defaultValues 指定类型,并在 onSubmit 中调用 schema.parse(value)。
旧版:
{
name: 'provider',
type: 'select',
required: true,
choices: [['github', 'GitHub'], ['launchdarkly', 'LaunchDarkly']],
}
新版:
const schema = z.object({
provider: z
.enum(['github', 'launchdarkly'])
.nullable()
.refine(v => v !== null, 'Provider is required'),
});
// z.input accepts null; z.output (after refine) does not
const defaultValues: z.input<typeof schema> = {
provider: null,
};
const form = useScrapsForm({
...defaultFormOptions,
defaultValues,
validators: {onDynamic: schema},
onSubmit: ({value}) => {
// schema.parse narrows null away — mutation receives z.output
return mutation.mutateAsync(schema.parse(value)).catch(() => {});
},
});
只要必填字段没有有意义的初始值,就需要采用此模式。区分 z.input 和 z.output,可确保表单接受 null 作为默认值,同时 mutation 接收经过验证的非空类型。
条件必填字段
迁移仅在其他值使字段可见时才必填的旧字段时,请保持表单级 schema 的宽松性,并在渲染出来的 AppField 上进行验证。**请勿使用表单级 .superRefine() 来处理此类条件必填要求:**即使字段处于隐藏状态,表单级验证仍会运行,因此即使用户看不到或无法修正该字段,必填错误也可能阻止提交。
const schema = z.object({
plan: z.enum(['standard', 'enterprise']),
billingEmail: z.string(),
});
<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
{showBilling =>
showBilling ? (
<form.AppField
name="billingEmail"
validators={{onDynamic: z.string().trim().email('Enter a valid billing email')}}
>
{field => (
<field.Layout.Stack label="Billing Email" required>
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
)}
</form.AppField>
) : null
}
</form.Subscribe>;
对于可空数值字段,请在表单 schema 中使用 z.number().nullable(),并使用如下字段验证器:z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required')。当可见输入为空时,字段验证器会拒绝其产生的 null。
有意不迁移的功能
| 功能 | 用法 | 原因 |
|---|---|---|
allowUndo | 3 个表单 | Toast 中的撤销功能会增加复杂性,收益却很有限。请改用简单的错误 Toast。 |
迁移检查清单
- 将 JsonForm/FormModel 替换为 useScrapsForm 或 AutoSaveForm
-
useMutation不使用泛型——为mutationFn的 payload 指定类型,并使用fetchMutation<T>指定返回类型 - 使用带 Save 按钮的
useScrapsForm时:mutation 在onSubmit中运行,由<form.SubmitButton>触发(不要使用从未提交的表单) - 将输入字段配置对象转换为 JSX AppField 组件
- 将仅用于展示的内容转换为
DisplayField.Row或DisplayField.Stack,不添加 schema 或表单状态条目 - 使用宽松的表单 schema,并在渲染出来的
AppField上验证条件必填字段 - 将布局中的
help替换为hintText - 将
showHelpInTooltip替换为variant="compact" - 将
disabledReason替换为disabled="reason string" - 将
extraHelp替换为布局中的额外 JSX - 将
confirm对象转换为函数:(value) => message | undefined - 在 mutationFn 中处理
getData - 对于常规表单,在调用
requestErrorToFieldErrors之前,将未知错误收窄为RequestError,然后调用setFieldErrors - 对于
AutoSaveForm,使用应用提供的请求错误处理 - 仅在
mapFormErrors重塑了 API 响应时保留自定义错误映射 - 在 onSuccess 回调中处理
saveMessage - 将
saveOnBlur: false字段转换为带 Save 按钮的常规表单 - mutation 成功后调用
form.reset()(适用于留在当前页面的表单) - 确认
onSuccess的缓存更新会与现有数据合并(使用 updater 函数)——某些 API 端点可能只返回部分对象 - 如果旧表单可在 SettingsSearch 中搜索,请使用
<FormSearch route="...">包装迁移后的表单 - 运行
pnpm run extract-form-fields并提交更新后的fieldRegistry.generated.ts