AI 从一条 prompt 生成完整表单管线:SQL 迁移、RLS 策略、类型定义、受控状态、校验逻辑和 Supabase 写入;指出 Alert.alert on web、unchecked error、RLS 无策略等五大高频踩坑点。
每个移动应用底层都是表单:注册、结账、入职、KYC。UI 部分一下午就能做完;而那些看不见的东西(键盘几何、验证、迁移、RLS、类型化写入)才是吞噬时间的黑洞。
大多数 AI 表单生成器只给你一个漂亮的 <TextInput> 就停了。真正有用的模式是用一条 prompt 生成整条链路:SQL 迁移、RLS 策略、重生成的类型、受控状态、可见的错误提示,以及一次真正的 Supabase 插入。
有五种静默失败模式会持续让表单带病上线:web 上用 Alert.alert、{ error } 未检查、RLS 但无策略、生成的类型已过期、以及吞掉崩溃的保护子句。
用增量迭代(点选编辑、跟进 prompt)而非重新生成。全量重新生成会丢失每个字段的精细打磨。
问任何 React Native 开发者,移动开发哪里最慢,表单几乎总是排在前列。但不是因为 UI 表面看起来的原因。看得见的部分(标签、输入框、提交按钮)一下午能做出来。看不见的部分才是把日程表吃掉的那个:
键盘几何。 iOS 把内容往上推;Android 调整大小;提交按钮在某个平台上跑到键盘下面,在另一个平台上又飘浮得不对。每个有 TextInput 的页面都需要一个 KeyboardAvoidingView 带上正确的 behavior prop,再加上一个 keyboardShouldPersistTaps="handled" 的 ScrollView,否则上线就是坏的。
受控状态。 每个字段都需要一个 useState 切片、一个 onChangeText 处理函数、一个 value prop,以及一个干净的充值方式。Formik 和 react-hook-form 封装了这些,但它们增加了依赖图,而且两者都没有处理移动端特有的交互细节。
带可见错误提示的验证。 一个静默失败的验证器比没有更糟糕。错误必须在正确的字段、正确的时间渲染出来。
数据库那一半。 一个不持久化的表单只是个 demo。持久化意味着要有一张表、正确类型的列、RLS 策略(否则每个查询都返回零行但不报错)、一个类型化的客户端,以及对 mutation 的错误处理。
web 上的失败模式。 React Native for Web 不是 React Native。Alert.alert 在 web 上是空操作。一个未处理的 promise 拒绝在 native 上会浮出水面,在 web 上则消失得无影无踪。AI 生成的表单经常同时踩中这两个坑。
下面是一个仅生成 UI 的表单工具和一个数据录入生成器的区别。假设 prompt 是:
Add a customer intake form to my services app. Fields: full name, phone (US format), email, service type (single-select from three options), notes. Save to the database, show it in an admin list, and only let each user see their own submissions.
(给我的服务应用添加一个客户录入表单。字段:全名、电话(美国格式)、邮箱、服务类型(三个选项的单选)、备注。保存到数据库,在管理员列表中展示,且只让每个用户看到自己的提交。)
仅 UI 的工具会给你一个包含五个样式化输入框和一个提交按钮的页面,提交按钮把东西 log 到 console。很漂亮,没用。
全栈 AI 应用构建器把这个 prompt 当作端到端的契约。在 RapidNative 的 fullstack-supabase 模板中,它一次生成:
一条 SQL 迁移,创建 intake_submissions 表,包含正确类型的列、一个 updated_at 触发器、开启 row level security,以及两个策略(select 和 insert)作用域到 auth.uid() = user_id。
在 src/db/types.ts 中重新生成的 TypeScript 类型,这样 client.from('intake_submissions') 就能自动补全你刚创建的精确列。
一个包裹在 KeyboardAvoidingView 中的表单页面,包含受控的 TextInput 字段、每个字段设置好的 keyboard type(email-address、phone-pad)、自动填充提示、on-blur 验证带内联错误文字、由 spinner 管理的提交,以及一个 Supabase insert() 调用,其 { error } 被检查并暴露出来。
一个管理员列表页面,通过 TanStack Query 的 useQuery 读取,以 ['intake_submissions', userId] 为 key,这样写入时可以干净地失效。
用 fullstack-supabase 模板打开一个新项目,把这个 prompt 丢进聊天:
Build a "Customer Feedback" screen. Fields: full_name (required), email (required, valid email), rating (integer 1–5, required), message (optional, up to 500 chars). Submit inserts into a feedback table scoped to the current user via RLS. After submit, clear the form and show a green success toast for 2 seconds. Also add an admin list screen that shows the current user's own feedback rows, newest first.
(构建一个"客户反馈"页面。字段:full_name(必填)、email(必填,合法邮箱)、rating(整数 1-5,必填)、message(可选,最多 500 字符)。提交时通过 RLS 插入到一个作用域为当前用户的 feedback 表。提交后清空表单并显示一个绿色成功 toast 2 秒。再加一个管理员列表页面,展示当前用户自己的反馈行,按最新排序。)
在幕后,生成器运行一个四步 LLM 流水线:规划 schema、写迁移、应用到一个浏览器内 PGlite 实例(真正的 Postgres 在 WASM 里,不是 mock)、重新生成类型,然后写页面。PGlite 之所以重要:以前 pg-mem 的 Postgres 子集会接受 uuid = text 这种比较,而真正的 Postgres 在 create policy 时会拒绝。这种迁移看起来是绿的,而整个 RLS 链悄悄失败,每个页面都返回空数据。切换到 PGlite 消灭了整整一类"本地正常、上线就坏"的 bug。
实际落到你项目里的东西:
迁移(supabase/migrations/20260904_add_feedback.sql):
create table if not exists feedback (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid()
references auth.users(id) on delete cascade,
full_name text not null,
email text not null,
rating int not null check (rating between 1 and 5),
message text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists feedback_user_id_idx on feedback(user_id);
alter table feedback enable row level security;
drop policy if exists feedback_select_own on feedback;
create policy feedback_select_own on feedback
for select using (auth.uid() = user_id);
drop policy if exists feedback_insert_own on feedback;
create policy feedback_insert_own on feedback
for insert with check (auth.uid() = user_id);
每一部分都是深思熟虑的。if not exists 加在表和索引上,这样重新构建不会抛出 42P07。每个 create policy 前面有 drop policy if exists,因为 Postgres 没有 create policy if not exists。RLS 开启且有两个策略:不创建策略就开启 RLS 会让每个查询都返回零行,而应用看起来坏了却没有任何错误。外键上建索引是因为 Postgres 不会自动创建,没有索引的话查找会走 seq-scan。
页面(app/(app)/feedback.tsx),压缩到实际输出的形态:
export default function FeedbackScreen() {
const { client } = useApp();
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [rating, setRating] = useState<number | null>(null);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const validate = () => {
const e: Record<string, string> = {};
if (!fullName.trim()) e.fullName = 'Required';
if (!/^\S+@\S+\.\S+$/.test(email)) e.email = 'Enter a valid email';
if (!rating) e.rating = 'Pick 1–5';
if (message.length > 500) e.message = 'Max 500 characters';
setErrors(e);
return Object.keys(e).length === 0;
};
const onSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
try {
const { error } = await client
.from('feedback')
.insert({ full_name: fullName, email, rating, message: message || null });
if (error) {
setErrors({ form: error.message });
return;
}
setFullName(''); setEmail(''); setRating(null); setMessage('');
setSuccess(true);
qc.invalidateQueries({ queryKey: ['feedback'] });
setTimeout(() => setSuccess(false), 2000);
} finally {
setSubmitting(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 128 }}
className="bg-background"
>
{/* Field JSX with keyboardType, autoComplete, and inline <Text> errors */}
</ScrollView>
</KeyboardAvoidingView>
);
}
注意里面有什么:每个字段一个受控状态切片、一个在提交时运行并填充 per-field 错误 map 的验证器、带正确 per-platform behavior 的 KeyboardAvoidingView、带 keyboardShouldPersistTaps="handled" 的 ScrollView,以及(大多数生成器遗漏的那块)Supabase insert 返回的 { error } 被检查并渲染到屏幕状态中。不是 Alert.alert。不是 console.error。是用户真正能看见的可见文字。
如果一个生成的表单不工作,而你又看不出原因,几乎总是以下五种之一。它们能编译、能上线,但按钮看起来像没反应,console 里什么都没有。
1. Alert.alert 作为唯一的反馈路径。 react-native 的 Alert 在 web 上什么都不做,而大多数编辑器预览都是 Expo Web。提交处理函数的错误分支如果是 Alert.alert('Error', msg); return;,在预览中完全看不见。按钮就是什么都不做。把错误渲染到屏幕状态中。如果真的想要 web 上的 modal,分支判断 Platform.OS === 'web' 然后用 window.alert 或自定义应用内对话框。
2. Supabase 调用返回的 { error } 未检查。 客户端返回 { data, error },它不会抛出。如果你写了 await client.from('feedback').insert(...) 但从不解构 error,PostgREST 失败(列不存在、RLS 拒绝、约束冲突)会静默消失,UI 继续好像写入成功了一样。
3. RLS 开启但无策略。 "表单提交了但列表是空的"最常见的原因。alter table ... enable row level security 但没有匹配的 create policy 会让每个 select 返回零行,每个 insert 用一个模糊的权限错误失败。永远要把 RLS 和至少一个 select 策略放在同一个迁移里。
4. 生成的类型已过期。 src/db/types.ts 是从已应用的迁移生成的。如果它漂移了(有人编辑了迁移但没有重新生成类型),client.from('feedback') 开始把所有列类型变成 never,修复方法是重新生成类型。不要用 client as any 强行绕过,这会把代码和数据库之间的真实漂移埋起来。
5. 对必然存在的东西加保护子句。 if (!client) return; 把应该大声崩溃的东西变成了空操作。只有对真正可选的值(未认证的用户、空输入)才加保护,而且保护时要 setError(...) 让用户看到为什么什么都没发生。
这些在 AI 生成的代码中比人类手写的代码更重要的原因是:模型在优化"能编译且看起来合理"。从模型的角度看,静默失败和成功无法区分。生成器必须被训练过(或在 system prompt 中指定)来写这些模式的可感知失败形式,并拒绝静默形式。
生成只是起点。真正重要的是迭代速度,因为第二个 prompt 总是"让它更好看",第三个总是"加一个字段"。
两种不需要重新生成整个页面就能迭代的方式:
点选编辑。 在预览中点击任何元素(标签、输入框、提交按钮)并用自然语言描述变更。AI 只编辑那个节点的 props 或样式类,所以文件的其余部分不受影响。比"重新生成整个文件但改一下 X"快得多,而且不会丢失你之前做的编辑。
跟进 prompt。 "在 email 和 rating 之间加一个 company 字段,可选,autocomplete=organization。"生成器读取当前文件,添加状态、添加 JSX、更新验证器、写一条 add column if not exists company text 迁移,然后重新生成类型。你不会得到其他所有东西的重写。
全量重新生成会丢失每个字段的精细打磨。增量编辑保留它。学会用增量的语言来 prompt,迭代成本就能接近零。
多步向导。 对于入职、KYC 或结账,把一个长表单拆到多个页面并带进度条。模式:一个路由对应一步,app/(auth)/onboarding/[step].tsx,状态提升到 React context,最后一个 insert 完成。Prompt:"把这个注册拆成三步(账号、个人资料、偏好),顶部加进度条,除了第一步每步都加返回按钮。"
文件上传(带 web 的坑)。 ImagePicker 在 web 上返回 blob: 或 data: URI,而 expo-file-system 无法读取两者。任何上传文件的生成表单都必须分支判断 Platform.OS === 'web':在 web 上用 await (await fetch(uri)).blob() 并从 blob.type 取扩展名;在 native 上保留 FileSystem 的 base64 路径。
乐观写入。 对于聊天、点赞、评论,任何延迟会显现的地方:用 TanStack Query 的 useMutation 包装写入,onMutate 立即更新缓存,onError 回滚。Prompt:"让提交变成乐观的。立即在列表中显示新行,写入失败则回滚。"
如果你手写少量表单,专用表单库(如 Formik)是不错的选择。但只要表单数量超过几个,或者"后端"是定义的一部分,这个权衡就变了。
AI React Native 表单构建器如何处理验证? 验证存在于生成的组件内部,作为 validate() 函数,用字段名作 key 填充 errors map,渲染为每个输入下面的内联 <Text>。字段默认在提交时验证;在 prompt 中加"validate on blur",生成器就会接上每个字段的 onBlur 处理函数。对于基于 schema 的验证,在 prompt 中加 Zod,生成器就会加上 schema 并在 validate() 中加 safeParse 调用。
AI 生成的表单能写入真实数据库吗? 能。在全栈模板中,生成器为目标表写一条 SQL 迁移、开启 RLS、创建作用域到 auth.uid() 的策略、重生成 TypeScript schema,并在提交处理函数中插入一个带错误处理的 client.from('table').insert(...) 调用。写入在预览中命中真正的 Postgres(在 WASM 中的 PGlite),所以你在编辑器里看到的就是会上线的。
生成表单页面的无障碍性如何? 生成的表单包含输入框上的 accessibilityLabel、每个字段设置好的 keyboard type(email-address、phone-pad、numeric)、自动补全提示(autoComplete="email", "tel", "name"),以及屏幕阅读器会读出的内联错误文字。
要点不是"AI 现在会写表单了"。AI 写表单已经两年了。要点是有效的表面已经转移了:从生成可见的好看层,到从一个自然语言描述秒级生成整个数据录入管线(迁移、RLS、类型化 schema、受控状态、键盘行为、可见错误、以及一个真正能持久化的 mutation),生成的是你拥有的代码。全部在你已经熟悉的 Expo + React Native 技术栈上。
哪个表单模式坑你坑得最深:键盘几何、静默 RLS 失败、还是更糟糕的?在评论区说出来。