结算流程
在一个页面中分步骤进行:收货地址 → 付款 → 确认 → 完成。前面步骤中填写的内容,会在最后汇总显示。
结账
要点
- 1
用一个数字记住当前在哪一步
1 = 收货地址,2 = 付款,3 = 确认,4 = 完成。用这个数字决定显示哪个页面。“下一步”就 +1,“返回”就 −1。
- 2
进入下一步前,只校验当前步骤
所有步骤的校验规则集中在一个 schema 里,点击“下一步”时只校验当前步骤的输入框(trigger)。有错误就不往下走。
- 3
在顶部显示进度
已完成的步骤打 ✓,当前步骤为黑色,后面的步骤为灰色。知道还剩几步,用户就不容易中途放弃。
使用的工具
点击查看第 2 部分的比较
修改代码
修改代码,右边的预览会跟着变化。
import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" // 输入校验规则(Zod):所有步骤的字段都写在同一个 schema 里 const schema = z.object({ name: z.string().min(1, "请输入姓名"), zip: z.string().regex(/^\d{6}$/, "请输入6位数字(例:200120)"), // 中国的邮政编码是6位数字 address: z.string().min(1, "请输入详细地址"), cardNumber: z.string().regex(/^\d{4} ?\d{4} ?\d{4} ?\d{4}$/, "请输入16位卡号"), expiry: z.string().regex(/^\d{2} ?\/ ?\d{2}$/, "请按 月 / 年 的格式输入(例:12 / 28)"), cvc: z.string().regex(/^\d{3,4}$/, "请输入3~4位数字"), }) // 把全角数字和符号转成半角(这样在中文输入法下输入也能通过) function toHalfWidth(value) { return value .replace(/[\uff10-\uff19]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0)) .replace(/[\uff0d\u30fc\u2010]/g, "-") .replace(/\uff0f/g, "/") .replace(/\u3000/g, " ") } // 卡号:只保留数字,每4位加一个空格(4242424242424242 → 4242 4242 4242 4242) function formatCardNumber(value) { const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 16) return digits.match(/.{1,4}/g)?.join(" ") ?? "" } // 有效期:只保留数字,在前2位后加上 / (1228 → 12 / 28) function formatExpiry(value) { const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 4) return digits.length > 2 ? digits.slice(0, 2) + " / " + digits.slice(2) : digits } // 给输入框加上“边输入边格式化”的功能(包在 register 的结果外面使用) // 输入法正在选字时改写内容会导致文字错乱,所以等选字结束后再格式化 function withFormat(field, format) { function update(event) { event.target.value = format(event.target.value) field.onChange(event) } return { ...field, onChange: (event) => (event.nativeEvent.isComposing ? field.onChange(event) : update(event)), onCompositionEnd: update, } } // 每个步骤要校验的字段 const stepFields = { 1: ["name", "zip", "address"], 2: ["cardNumber", "expiry", "cvc"], } const stepLabels = ["收货信息", "支付", "确认"] // 把标签、输入框和错误提示组合在一起 function Field({ id, label, error, children }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } // 顶部的步骤条(① 收货信息 — ② 支付 — ③ 确认) function StepBar({ step }) { return ( <div className="mb-6 flex items-center gap-2 text-xs"> {stepLabels.map((label, index) => { const number = index + 1 return ( <div key={label} className="flex flex-1 items-center gap-2 last:flex-none"> <span // 已完成和当前步骤(number <= step)为深色,未到的步骤为灰色 className={number <= step ? "bg-primary text-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full" : "flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"} > {/* 已完成的步骤(number < step)显示 ✓,其他显示序号 */} {number < step ? "✓" : number} </span> <span className="whitespace-nowrap">{label}</span> {number < stepLabels.length && <span className="h-px flex-1 bg-border" />} </div> ) })} </div> ) } export default function App() { // ① 当前步骤(1 = 收货信息,2 = 支付,3 = 确认,4 = 完成) const [step, setStep] = useState(1) const { register, trigger, getValues, setValue, reset, formState: { errors } } = useForm({ resolver: zodResolver(schema), mode: "onChange", }) // “下一步”:先校验当前步骤的输入,再前进 async function goNext() { const ok = await trigger(stepFields[step]) // 只校验当前步骤的字段(有错误时显示红色提示) if (!ok) return setStep(step + 1) } // “上一步” function goBack() { // 回到上一个步骤 setStep(step - 1) } // “提交订单” function placeOrder() { // 进入完成步骤(4) setStep(4) } // “重新开始” function restart() { reset() // 清空所有输入 // 回到第1个步骤 setStep(1) } // 中国的邮政编码没有免费的地址查询服务,所以这里不自动填写地址(由用户自己输入) // 在确认步骤中显示的输入内容 const values = getValues() //console.log(step) return ( <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground"> {/* step 为 4 时不显示 StepBar */} {step === 4 ? null : <StepBar step={step} />} {/* ① 收货信息:仅在 step 为 1 时显示 */} {step === 1 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">收货信息</p> <Field id="name" label="收货人" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <Field id="zip" label="邮政编码" error={errors.zip?.message}> <Input id="zip" placeholder="200120" inputMode="numeric" {...register("zip", { setValueAs: toHalfWidth })} /> </Field> <Field id="address" label="详细地址" error={errors.address?.message}> <Input id="address" {...register("address")} /> </Field> <div className="flex justify-end"> <Button type="button" onClick={goNext}>下一步</Button> </div> </div> )} {/* ② 支付:仅在 step 为 2 时显示 */} {step === 2 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">支付</p> <Field id="cardNumber" label="卡号" error={errors.cardNumber?.message}> <Input id="cardNumber" placeholder="4242 4242 4242 4242" inputMode="numeric" autoComplete="cc-number" {...withFormat(register("cardNumber"), formatCardNumber)} /> </Field> <div className="grid grid-cols-2 gap-3"> <Field id="expiry" label="有效期" error={errors.expiry?.message}> <Input id="expiry" placeholder="12 / 28" inputMode="numeric" autoComplete="cc-exp" {...withFormat(register("expiry"), formatExpiry)} /> </Field> <Field id="cvc" label="安全码" error={errors.cvc?.message}> <Input id="cvc" placeholder="123" inputMode="numeric" autoComplete="cc-csc" {...withFormat(register("cvc"), (value) => toHalfWidth(value).replace(/\D/g, "").slice(0, 4))} /> </Field> </div> <p className="text-xs text-muted-foreground">这是演示,请勿输入真实卡号</p> <div className="flex justify-between"> <Button type="button" variant="outline" onClick={goBack}>上一步</Button> <Button type="button" onClick={goNext}>下一步</Button> </div> </div> )} {/* ③ 确认:仅在 step 为 3 时显示 */} {step === 3 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">确认订单信息</p> <dl className="space-y-3 text-sm"> <div className="flex justify-between gap-4"> <dt className="text-muted-foreground">收货信息</dt> <dd className="text-right"> {values.name} <br />{values.address} {values.zip} </dd> </div> <div className="flex justify-between gap-4"> <dt className="text-muted-foreground">支付</dt> {/* 卡号只显示后4位 */} <dd>银行卡 ****{values.cardNumber?.slice(-4)}</dd> </div> <div className="flex justify-between border-t pt-3 font-semibold"> <dt>合计</dt> <dd>¥1,680</dd> </div> </dl> <div className="flex justify-between"> <Button type="button" variant="outline" onClick={goBack}>上一步</Button> <Button type="button" onClick={placeOrder}>提交订单</Button> </div> </div> )} {/* 完成:仅在 step 为 4 时显示 */} {step === 4 && ( <div className="py-6 text-center"> <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-xl text-emerald-600">✓</div> <p className="mt-4 text-lg font-semibold">感谢您的订购</p> <p className="mt-1 text-sm text-muted-foreground">订单号:A-20261003-0001</p> <Button type="button" variant="outline" className="mt-6" onClick={restart}> 重新开始(演示用) </Button> </div> )} </div> ) }