# 分屏登录页

> 左侧品牌面板 + 右侧表单的分屏登录页：登录（密码 / 验证码）、注册（带强度条）、找回密码，zod 校验。

A shadcn/ui block from the [@yiui](https://yiui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/yiui/auth-01).

- Page, with a live preview: https://shadcn.exchange/r/yiui/auth-01
- Screenshot: https://shadcn.exchange/shot/yiui/auth-01/light.png
- Registry item: https://yiui.dev/r/auth-01.json
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/yiui/auth-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`, `zod`
- Registry items: `button`, `label`, `checkbox`, `https://yiui.dev/r/ripple.json`, `https://yiui.dev/r/glyph-field.json`, `https://yiui.dev/r/text-animate.json`, `https://yiui.dev/r/segmented-control.json`, `https://yiui.dev/r/toggle-switch.json`, `https://yiui.dev/r/phone-input.json`, `https://yiui.dev/r/bar-field.json`

## Files

### `src/registry/blocks/auth-01/page.tsx`

```tsx
"use client"

import { Auth01 } from "@/components/auth-01/auth-01"

/**
 * 登录页入口：把下面几个回调换成你自己的认证实现。
 * 回调返回 Promise；失败时 throw new Error("中文原因")，组件会显示在表单上方。
 * 登录成功后的跳转请在 onSignIn / onSocialSignIn 里自行处理，例如 router.push("/")。
 */
export default function LoginPage() {
  return (
    <Auth01
      brand={{ name: "Acme" }}
      onSignIn={async (values) => {
        // TODO: 调用你的认证服务，例如 authClient.signIn.email(...)
        console.info("sign in", values)
        await new Promise((resolve) => setTimeout(resolve, 600))
      }}
      onSignUp={async (values) => {
        console.info("sign up", values)
        await new Promise((resolve) => setTimeout(resolve, 600))
      }}
      onForgotPassword={async (email) => {
        console.info("forgot password", email)
        await new Promise((resolve) => setTimeout(resolve, 600))
      }}
      onSendCode={async (phone) => {
        console.info("send code", phone)
        await new Promise((resolve) => setTimeout(resolve, 600))
      }}
      onSocialSignIn={async (provider) => {
        console.info("social sign in", provider)
        await new Promise((resolve) => setTimeout(resolve, 600))
      }}
    />
  )
}
```

### `src/registry/blocks/auth-01/auth-01.tsx`

```tsx
"use client"

import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { useState } from "react"

import { cn } from "@/lib/utils"

import { defaultBrand, type BrandConfig, type BrandFeature } from "./brand"
import { BrandPanel, MobileBrand } from "./brand-panel"
import type { Auth01Labels } from "./config"
import { ForgotPasswordForm } from "./forgot-password-form"
import { SignInForm, type SignInValues } from "./sign-in-form"
import { SignUpForm, type SignUpValues } from "./sign-up-form"
import type { SocialProvider } from "./social-buttons"

type AuthView = "sign-in" | "sign-up" | "forgot-password"

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 未传回调时的示例实现：只在控制台打印，并模拟 0.8 秒的网络请求
const demo =
  <Args extends unknown[]>(name: string) =>
  async (...args: Args) => {
    console.info(`[auth-01] ${name}`, ...args)
    await wait(800)
  }

/**
 * 分屏式登录 / 注册页：左侧品牌面板 + 右侧表单，窄屏只显示表单。
 * 回调返回 Promise；组件管理 loading，回调 throw 的 Error.message 会显示为表单错误。
 */
function Auth01({
  brand,
  defaultView = "sign-in",
  socialProviders = ["github", "google"],
  allowSignUp = true,
  termsHref = "#",
  privacyHref = "#",
  onSignIn = demo<[SignInValues]>("onSignIn"),
  onSignUp = demo<[SignUpValues]>("onSignUp"),
  onForgotPassword = demo<[string]>("onForgotPassword"),
  onSendCode = demo<[string]>("onSendCode"),
  onSocialSignIn = demo<[SocialProvider]>("onSocialSignIn"),
  labels,
  className,
}: {
  /** 品牌信息，逐项覆盖默认的示例品牌 */
  brand?: Partial<BrandConfig>
  defaultView?: AuthView
  /** 启用的第三方登录，传空数组隐藏该区域 */
  socialProviders?: readonly SocialProvider[]
  /** 设为 false 隐藏「注册」入口（仅邀请制的产品） */
  allowSignUp?: boolean
  termsHref?: string
  privacyHref?: string
  onSignIn?: (values: SignInValues) => Promise<void>
  onSignUp?: (values: SignUpValues) => Promise<void>
  onForgotPassword?: (email: string) => Promise<void>
  onSendCode?: (phone: string) => Promise<void>
  onSocialSignIn?: (provider: SocialProvider) => Promise<void>
  /** 覆盖默认文案，见 config.ts 的 DEFAULT_LABELS */
  labels?: Partial<Auth01Labels>
  className?: string
}) {
  const [view, setView] = useState<AuthView>(defaultView)
  const reducedMotion = useReducedMotion()
  const resolved: BrandConfig = { ...defaultBrand, ...brand }
  return (
    <div data-slot="auth-01" className={cn("relative flex min-h-svh", className)}>
      <BrandPanel brand={resolved} labels={labels} />
      <main className="relative flex flex-1 items-center justify-center bg-background p-6 pb-16 sm:p-12 sm:pb-16">
        <div className="w-full max-w-md">
          <MobileBrand brand={resolved} />
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={view}
              initial={{ opacity: 0, y: reducedMotion ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reducedMotion ? 0 : -8 }}
              transition={{ duration: 0.18 }}
            >
              {view === "sign-in" && (
                <SignInForm
                  socialProviders={socialProviders}
                  allowSignUp={allowSignUp}
                  onSignIn={onSignIn}
                  onSendCode={onSendCode}
                  onSocialSignIn={onSocialSignIn}
                  onForgotPassword={() => setView("forgot-password")}
                  onSwitchToSignUp={() => setView("sign-up")}
                  labels={labels}
                />
              )}
              {view === "sign-up" && (
                <SignUpForm
                  termsHref={termsHref}
                  privacyHref={privacyHref}
                  onSignUp={onSignUp}
                  onSwitchToSignIn={() => setView("sign-in")}
                  labels={labels}
                />
              )}
              {view === "forgot-password" && (
                <ForgotPasswordForm
                  onForgotPassword={onForgotPassword}
                  onSwitchToSignIn={() => setView("sign-in")}
                  labels={labels}
                />
              )}
            </motion.div>
          </AnimatePresence>
        </div>
        <p className="absolute right-6 bottom-4 text-xs text-muted-foreground">
          {resolved.copyright}
        </p>
      </main>
    </div>
  )
}

export { Auth01 }
export type {
  Auth01Labels,
  AuthView,
  BrandConfig,
  BrandFeature,
  SignInValues,
  SignUpValues,
  SocialProvider,
}
```

### `src/registry/blocks/auth-01/config.ts`

```ts
/**
 * 登录页所有用户可见的文案，包含表单校验提示（validation* 键）。
 * 校验提示里的 {min}、{length} 由 schemas.ts 的规则常量填入；只改规则（密码策略、手机号格式）
 * 不用动文案，想换提示语则覆盖对应的 validation* 键。
 * 品牌名、标题、特性列表属于品牌数据，见 brand.ts。
 */
const DEFAULT_LABELS = {
  // 品牌面板
  brandPanelLabel: "{name} 品牌介绍",
  // 登录
  signInTitle: "欢迎回来",
  signInDescription: "输入账号信息即可登录",
  signInMethodLabel: "登录方式",
  signInPasswordTab: "密码登录",
  signInCodeTab: "验证码登录",
  accountLabel: "用户名或邮箱",
  accountPlaceholder: "请输入用户名或邮箱",
  passwordLabel: "密码",
  passwordPlaceholder: "请输入密码",
  forgotPassword: "忘记密码？",
  remember: "记住登录状态",
  signInSubmit: "登录",
  signInPending: "登录中…",
  phoneLabel: "手机号",
  phonePlaceholder: "请输入 11 位手机号",
  codeLabel: "验证码",
  codePlaceholder: "6 位数字验证码",
  sendCode: "获取验证码",
  sendCodePending: "发送中…",
  resendCode: "{seconds}s 后重发",
  socialDivider: "或",
  noAccount: "还没有账号？",
  signUpLink: "注册",
  // 注册
  signUpTitle: "创建账号",
  signUpDescription: "填写以下信息即可完成注册",
  nameLabel: "姓名",
  namePlaceholder: "请输入姓名",
  emailLabel: "邮箱",
  emailPlaceholder: "name@example.com",
  newPasswordPlaceholder: "至少 8 位，包含字母和数字",
  confirmPasswordLabel: "确认密码",
  confirmPasswordPlaceholder: "再次输入密码",
  termsPrefix: "我已阅读并同意",
  termsOfService: "服务条款",
  termsConjunction: "和",
  privacyPolicy: "隐私政策",
  signUpSubmit: "注册",
  signUpPending: "注册中…",
  haveAccount: "已有账号？",
  signInLink: "登录",
  // 找回密码
  forgotPasswordTitle: "找回密码",
  forgotPasswordDescription: "输入注册时使用的邮箱，我们会发送重置链接",
  sendResetLink: "发送重置链接",
  sendResetLinkPending: "发送中…",
  backToSignIn: "返回登录",
  emailSentTitle: "邮件已发送",
  emailSentDescription:
    "我们已向 {email} 发送了重置密码的链接，请查收邮件。没收到？请检查垃圾邮件箱。",
  // 输入框与密码强度
  showPassword: "显示密码",
  hidePassword: "隐藏密码",
  passwordStrength: "强度：{level}",
  passwordStrengthEmpty: "强度：—",
  strengthWeak: "弱",
  strengthFair: "一般",
  strengthGood: "较强",
  strengthStrong: "强",
  // 校验提示（schemas.ts 使用，{min}、{length} 由规则常量填入）
  validationAccountRequired: "请输入用户名或邮箱",
  validationPasswordRequired: "请输入密码",
  validationEmailRequired: "请输入邮箱",
  validationEmailInvalid: "请输入有效的邮箱地址",
  validationPhoneInvalid: "请输入有效的 {length} 位手机号",
  validationCodeInvalid: "请输入 {length} 位数字验证码",
  validationPasswordMin: "密码至少 {min} 位",
  validationPasswordLetter: "密码需要包含字母",
  validationPasswordDigit: "密码需要包含数字",
  validationNameMin: "姓名至少 {min} 个字符",
  validationConfirmRequired: "请再次输入密码",
  validationTermsRequired: "请先阅读并同意服务条款",
  validationPasswordMismatch: "两次输入的密码不一致",
  // 通用
  requestFailed: "操作失败，请稍后重试",
} as const

type Auth01Labels = { [Key in keyof typeof DEFAULT_LABELS]: string }

/** 合并用户传入的文案与默认文案。 */
function resolveLabels(labels?: Partial<Auth01Labels>): Auth01Labels {
  return { ...DEFAULT_LABELS, ...labels }
}

/** 把文案里的 {key} 占位符替换成实际值，例如「强度：{level}」。 */
function formatLabel(
  template: string,
  values: Record<string, string | number>,
): string {
  return Object.entries(values).reduce(
    (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
    template,
  )
}

/** 以 {key} 为界把文案拆成前后两段，便于把值渲染成强调元素。 */
function splitLabel(template: string, key: string): [string, string] {
  const [before = template, after = ""] = template.split(`{${key}}`)
  return [before, after]
}

export { DEFAULT_LABELS, formatLabel, resolveLabels, splitLabel }
export type { Auth01Labels }
```

### `src/registry/blocks/auth-01/brand.tsx`

```tsx
import { Gauge, Hexagon, ShieldCheck, Zap } from "lucide-react"
import type { ReactNode } from "react"

type BrandFeature = {
  icon: ReactNode
  title: string
  description: string
}

type BrandConfig = {
  /** 产品名，显示在 Logo 旁 */
  name: string
  /** Logo 节点：建议用 currentColor 的 SVG，这样在品牌面板（反色）和表单上方（正常色）都能看清 */
  logo: ReactNode
  /** 品牌面板的大标题 */
  title: string
  description: string
  /** 品牌面板上的特性列表，建议 2–3 条 */
  features: BrandFeature[]
  /** 右下角的版权文字 */
  copyright: string
}

/** 示例品牌：换成自己的产品信息，或通过 Auth01 的 brand 属性逐项覆盖。 */
const defaultBrand: BrandConfig = {
  name: "Acme",
  logo: <Hexagon strokeWidth={1.5} />,
  title: "把想法变成可用的产品",
  description: "一个账号，打开全部工具。登录后继续上次的工作，随时随地保持同步。",
  features: [
    {
      icon: <Zap />,
      title: "快速上手",
      description: "几分钟完成配置，立刻开始使用",
    },
    {
      icon: <Gauge />,
      title: "稳定可靠",
      description: "全天候运行，关键数据实时同步",
    },
    {
      icon: <ShieldCheck />,
      title: "安全优先",
      description: "加密传输，权限精细可控",
    },
  ],
  copyright: "© 2026 Acme. 保留所有权利。",
}

export { defaultBrand }
export type { BrandConfig, BrandFeature }
```

### `src/registry/blocks/auth-01/brand-panel.tsx`

```tsx
import { cn } from "@/lib/utils"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
import { Ripple } from "@/components/ui/ripple/ripple"
import { TextReveal } from "@/components/ui/text-animate/text-animate"

import type { BrandConfig } from "./brand"
import { formatLabel, resolveLabels, type Auth01Labels } from "./config"

function Logo({
  logo,
  className,
}: {
  logo: BrandConfig["logo"]
  className: string
}) {
  // 外层限定尺寸，内部的 svg / img 自动缩放到框内
  return (
    <span
      data-slot="auth-01-brand-logo"
      aria-hidden="true"
      className={cn(
        "flex shrink-0 items-center justify-center [&>*]:max-h-full [&>*]:max-w-full [&>svg]:size-full",
        className,
      )}
    >
      {logo}
    </span>
  )
}

/**
 * 大屏左半边的品牌面板：主色渐变 + 模糊光斑 + 向外扩散的波纹 + 四周的字符矩阵。
 * 亮色下是主色底配 primary-foreground 文字；暗色下 primary 会变成浅色，整块面板会变亮，
 * 所以暗色改为 card 深底，只用 primary 的低透明度渐变染上主题色，文字回到 foreground。
 * 光斑、波纹、字符矩阵、图标底共用 --panel-glow，随亮暗一起切换。
 */
function BrandPanel({
  brand,
  labels,
}: {
  brand: BrandConfig
  labels?: Partial<Auth01Labels>
}) {
  const text = resolveLabels(labels)
  return (
    <aside
      data-slot="auth-01-brand-panel"
      aria-label={formatLabel(text.brandPanelLabel, { name: brand.name })}
      className="relative hidden overflow-hidden bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground [--panel-glow:var(--primary-foreground)] lg:flex lg:w-1/2 dark:bg-card dark:from-primary/16 dark:via-primary/7 dark:to-primary/3 dark:text-foreground dark:[--panel-glow:var(--primary)]"
    >
      <div aria-hidden="true" className="absolute inset-0">
        <div className="absolute top-20 left-10 size-72 rounded-full bg-(--panel-glow)/10 blur-3xl" />
        <div className="absolute right-10 bottom-20 size-96 rounded-full bg-(--panel-glow)/5 blur-3xl" />
        {/* 字符矩阵只铺在四周，中间留白给文字；GlyphField 自带底色，这里改成透明 */}
        <GlyphField
          fade="center"
          glyphs="01"
          tone="var(--panel-glow)"
          density={0.4}
          fontSize={13}
          speed={0.02}
          className="absolute inset-0 min-h-0 bg-transparent opacity-25"
        />
        <Ripple
          size={720}
          rings={4}
          ringGap={140}
          baseSize={200}
          duration={8}
          background="none"
          tone="var(--panel-glow)"
          className="absolute inset-0 h-full"
        />
      </div>
      <div className="relative z-10 flex flex-col justify-center px-12 xl:px-20">
        <div className="mb-8 flex items-center gap-3">
          <Logo logo={brand.logo} className="size-14" />
          <span className="text-2xl font-bold" translate="no">
            {brand.name}
          </span>
        </div>
        <p className="mb-6 text-2xl leading-tight font-bold text-balance xl:text-3xl">
          <TextReveal by="word" trigger="mount" stagger={70}>
            {brand.title}
          </TextReveal>
        </p>
        <p className="mb-10 max-w-md text-lg leading-8 text-current/80">
          {brand.description}
        </p>
        <ul className="flex flex-col gap-4">
          {brand.features.map((feature) => (
            <li key={feature.title} className="flex items-center gap-4">
              <span
                aria-hidden="true"
                className="rounded-lg bg-(--panel-glow)/20 p-2 [&_svg]:size-5"
              >
                {feature.icon}
              </span>
              <div>
                <div className="font-medium">{feature.title}</div>
                <div className="text-sm text-current/70">
                  {feature.description}
                </div>
              </div>
            </li>
          ))}
        </ul>
      </div>
    </aside>
  )
}

/** 窄屏下表单上方的 Logo + 名称（品牌面板此时被隐藏）。 */
function MobileBrand({ brand }: { brand: BrandConfig }) {
  return (
    <div
      data-slot="auth-01-mobile-brand"
      className="mb-8 flex items-center justify-center gap-2 lg:hidden"
    >
      <Logo logo={brand.logo} className="size-10" />
      <span className="text-xl font-bold" translate="no">
        {brand.name}
      </span>
    </div>
  )
}

export { BrandPanel, MobileBrand }
```

### `src/registry/blocks/auth-01/sign-in-form.tsx`

```tsx
"use client"

import { useMemo, useState } from "react"

import {
  PhoneInput,
  PhoneInputSendButton,
} from "@/components/ui/phone-input/phone-input"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"

import { formatLabel, resolveLabels, type Auth01Labels } from "./config"
import {
  FormError,
  LinkButton,
  PasswordField,
  SubmitButton,
  TextField,
  ViewHeading,
} from "./form-parts"
import {
  PHONE_LENGTH,
  createCodeSignInSchema,
  createSignInSchema,
} from "./schemas"
import { SocialButtons, type SocialProvider } from "./social-buttons"
import { useAsyncAction, useAuthForm } from "./use-auth-form"

type SignInValues =
  | { method: "password"; account: string; password: string; remember: boolean }
  | { method: "code"; phone: string; code: string }

type SignInMethod = SignInValues["method"]

const CODE_COOLDOWN = 60

function PasswordSignIn({
  onSignIn,
  onForgotPassword,
  labels,
}: {
  onSignIn: (values: SignInValues) => Promise<void>
  onForgotPassword: () => void
  labels?: Partial<Auth01Labels>
}) {
  // labels 引用不变时 text 与 schema 都不重建，避免每次输入都重新编译 zod schema
  const text = useMemo(() => resolveLabels(labels), [labels])
  const schema = useMemo(() => createSignInSchema(text), [text])
  const form = useAuthForm(
    schema,
    { account: "", password: "", remember: false },
    { fallbackError: text.requestFailed },
  )
  return (
    <form
      data-slot="auth-01-sign-in-password"
      noValidate
      onSubmit={form.handleSubmit((values) =>
        onSignIn({ method: "password", ...values }),
      )}
      className="flex flex-col gap-5"
    >
      <FormError message={form.formError} />
      <TextField
        id="sign-in-account"
        label={text.accountLabel}
        autoComplete="username"
        placeholder={text.accountPlaceholder}
        value={form.values.account}
        error={form.errors.account}
        onChange={(event) => form.setValue("account", event.target.value)}
        onBlur={() => form.blur("account")}
      />
      <PasswordField
        id="sign-in-password"
        label={text.passwordLabel}
        autoComplete="current-password"
        placeholder={text.passwordPlaceholder}
        value={form.values.password}
        error={form.errors.password}
        onChange={(event) => form.setValue("password", event.target.value)}
        onBlur={() => form.blur("password")}
        labels={labels}
        footerAction={
          <LinkButton className="text-xs" onClick={onForgotPassword}>
            {text.forgotPassword}
          </LinkButton>
        }
      />
      {/* 记住登录状态是即时生效的开关，不是需要同意的声明，所以用开关而不是复选框 */}
      <ToggleSwitch
        label={text.remember}
        tone="primary"
        checked={form.values.remember}
        onCheckedChange={(checked) => form.setValue("remember", checked)}
      />
      <SubmitButton pending={form.pending} pendingText={text.signInPending}>
        {text.signInSubmit}
      </SubmitButton>
    </form>
  )
}

function CodeSignIn({
  onSignIn,
  onSendCode,
  labels,
}: {
  onSignIn: (values: SignInValues) => Promise<void>
  onSendCode: (phone: string) => Promise<void>
  labels?: Partial<Auth01Labels>
}) {
  const text = useMemo(() => resolveLabels(labels), [labels])
  const schema = useMemo(() => createCodeSignInSchema(text), [text])
  const form = useAuthForm(
    schema,
    { phone: "", code: "" },
    { fallbackError: text.requestFailed },
  )
  const sender = useAsyncAction(text.requestFailed)

  // 失败时必须 throw：PhoneInputSendButton 遇到抛错就不进入倒计时，用户可以马上重试
  const sendCode = async () => {
    if (!form.validateField("phone")) throw new Error(text.validationPhoneInvalid)
    const sent = await sender.run(() => onSendCode(form.values.phone))
    if (!sent) throw new Error(text.requestFailed)
  }
  const phoneInvalid = formatLabel(text.validationPhoneInvalid, {
    length: PHONE_LENGTH,
  })
  return (
    <form
      data-slot="auth-01-sign-in-code"
      noValidate
      onSubmit={form.handleSubmit((values) =>
        onSignIn({ method: "code", ...values }),
      )}
      className="flex flex-col gap-5"
    >
      <FormError message={form.formError ?? sender.error} />
      <PhoneInput
        id="sign-in-phone"
        size="lg"
        label={text.phoneLabel}
        placeholder={text.phonePlaceholder}
        value={form.values.phone}
        error={form.errors.phone}
        labels={{ invalid: phoneInvalid, required: phoneInvalid }}
        onValueChange={(phone) => form.setValue("phone", phone)}
        onBlur={() => form.blur("phone")}
        action={
          <PhoneInputSendButton
            cooldown={CODE_COOLDOWN}
            onSend={sendCode}
            labels={{
              send: text.sendCode,
              sending: text.sendCodePending,
              countdown: text.resendCode,
              resend: text.sendCode,
            }}
          />
        }
      />
      <TextField
        id="sign-in-code"
        label={text.codeLabel}
        inputMode="numeric"
        maxLength={6}
        autoComplete="one-time-code"
        placeholder={text.codePlaceholder}
        value={form.values.code}
        error={form.errors.code}
        onChange={(event) => form.setValue("code", event.target.value)}
        onBlur={() => form.blur("code")}
      />
      <SubmitButton pending={form.pending} pendingText={text.signInPending}>
        {text.signInSubmit}
      </SubmitButton>
    </form>
  )
}

function SignInForm({
  socialProviders,
  allowSignUp,
  onSignIn,
  onSendCode,
  onSocialSignIn,
  onForgotPassword,
  onSwitchToSignUp,
  labels,
}: {
  socialProviders: readonly SocialProvider[]
  allowSignUp: boolean
  onSignIn: (values: SignInValues) => Promise<void>
  onSendCode: (phone: string) => Promise<void>
  onSocialSignIn: (provider: SocialProvider) => Promise<void>
  onForgotPassword: () => void
  onSwitchToSignUp: () => void
  labels?: Partial<Auth01Labels>
}) {
  const text = resolveLabels(labels)
  const [method, setMethod] = useState<SignInMethod>("password")
  const methodOptions = [
    { value: "password", label: text.signInPasswordTab },
    { value: "code", label: text.signInCodeTab },
  ] as const
  const social = useAsyncAction(text.requestFailed)
  return (
    <div data-slot="auth-01-sign-in" className="flex flex-col gap-5">
      <ViewHeading
        title={text.signInTitle}
        description={text.signInDescription}
      />
      <div className="flex flex-col gap-3">
        <SegmentedControl
          aria-label={text.signInMethodLabel}
          size="lg"
          options={methodOptions}
          value={method}
          onValueChange={(value) => setMethod(value as SignInMethod)}
        />
        {method === "password" ? (
          <PasswordSignIn
            onSignIn={onSignIn}
            onForgotPassword={onForgotPassword}
            labels={labels}
          />
        ) : (
          <CodeSignIn
            onSignIn={onSignIn}
            onSendCode={onSendCode}
            labels={labels}
          />
        )}
      </div>
      {socialProviders.length > 0 && (
        <>
          <div
            role="separator"
            className="flex items-center gap-3 text-xs text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border"
          >
            {text.socialDivider}
          </div>
          <FormError message={social.error} />
          <SocialButtons
            providers={socialProviders}
            pending={social.pending}
            onSelect={(provider) => social.run(() => onSocialSignIn(provider))}
          />
        </>
      )}
      {allowSignUp && (
        <p className="text-center text-sm text-muted-foreground">
          {text.noAccount}{" "}
          <LinkButton onClick={onSwitchToSignUp}>{text.signUpLink}</LinkButton>
        </p>
      )}
    </div>
  )
}

export { SignInForm }
export type { SignInValues }
```

### `src/registry/blocks/auth-01/sign-up-form.tsx`

```tsx
"use client"

import { useMemo } from "react"

import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"

import { resolveLabels, type Auth01Labels } from "./config"
import {
  FormError,
  LinkButton,
  PasswordField,
  SubmitButton,
  TextField,
  ViewHeading,
} from "./form-parts"
import { PasswordStrength } from "./password-strength"
import { createSignUpSchema } from "./schemas"
import { useAuthForm } from "./use-auth-form"

type SignUpValues = { name: string; email: string; password: string }

function SignUpForm({
  termsHref,
  privacyHref,
  onSignUp,
  onSwitchToSignIn,
  labels,
}: {
  termsHref: string
  privacyHref: string
  onSignUp: (values: SignUpValues) => Promise<void>
  onSwitchToSignIn: () => void
  labels?: Partial<Auth01Labels>
}) {
  // labels 引用不变时 text 与 schema 都不重建，避免每次输入都重新编译 zod schema
  const text = useMemo(() => resolveLabels(labels), [labels])
  const schema = useMemo(() => createSignUpSchema(text), [text])
  const form = useAuthForm(
    schema,
    {
      name: "",
      email: "",
      password: "",
      confirm: "",
      terms: false,
    },
    { fallbackError: text.requestFailed },
  )
  return (
    <div data-slot="auth-01-sign-up" className="flex flex-col gap-5">
      <ViewHeading title={text.signUpTitle} description={text.signUpDescription} />
      <form
        noValidate
        onSubmit={form.handleSubmit(({ name, email, password }) =>
          onSignUp({ name: name.trim(), email: email.trim(), password }),
        )}
        className="flex flex-col gap-4"
      >
        <FormError message={form.formError} />
        <TextField
          id="sign-up-name"
          label={text.nameLabel}
          autoComplete="name"
          placeholder={text.namePlaceholder}
          value={form.values.name}
          error={form.errors.name}
          onChange={(event) => form.setValue("name", event.target.value)}
          onBlur={() => form.blur("name")}
        />
        <TextField
          id="sign-up-email"
          label={text.emailLabel}
          type="email"
          autoComplete="email"
          placeholder={text.emailPlaceholder}
          value={form.values.email}
          error={form.errors.email}
          onChange={(event) => form.setValue("email", event.target.value)}
          onBlur={() => form.blur("email")}
        />
        <div className="flex flex-col gap-2">
          <PasswordField
            id="sign-up-password"
            label={text.passwordLabel}
            autoComplete="new-password"
            placeholder={text.newPasswordPlaceholder}
            value={form.values.password}
            error={form.errors.password}
            onChange={(event) => form.setValue("password", event.target.value)}
            onBlur={() => form.blur("password")}
            labels={labels}
          />
          <PasswordStrength password={form.values.password} labels={labels} />
        </div>
        <PasswordField
          id="sign-up-confirm"
          label={text.confirmPasswordLabel}
          autoComplete="new-password"
          placeholder={text.confirmPasswordPlaceholder}
          value={form.values.confirm}
          error={form.errors.confirm}
          onChange={(event) => form.setValue("confirm", event.target.value)}
          onBlur={() => form.blur("confirm")}
          labels={labels}
        />
        <div className="flex flex-col gap-2">
          <div className="flex items-center gap-2">
            <Checkbox
              id="sign-up-terms"
              checked={form.values.terms}
              aria-invalid={form.errors.terms ? true : undefined}
              aria-describedby={form.errors.terms ? "sign-up-terms-error" : undefined}
              onCheckedChange={(checked) =>
                form.setValue("terms", checked === true)
              }
            />
            <Label
              htmlFor="sign-up-terms"
              className="cursor-pointer leading-5 font-normal"
            >
              {text.termsPrefix}
              <a
                href={termsHref}
                className="text-primary underline-offset-4 hover:underline"
              >
                {text.termsOfService}
              </a>
              {text.termsConjunction}
              <a
                href={privacyHref}
                className="text-primary underline-offset-4 hover:underline"
              >
                {text.privacyPolicy}
              </a>
            </Label>
          </div>
          {form.errors.terms ? (
            <p id="sign-up-terms-error" className="text-xs text-destructive">
              {form.errors.terms}
            </p>
          ) : null}
        </div>
        <SubmitButton pending={form.pending} pendingText={text.signUpPending}>
          {text.signUpSubmit}
        </SubmitButton>
      </form>
      <p className="text-center text-sm text-muted-foreground">
        {text.haveAccount}{" "}
        <LinkButton onClick={onSwitchToSignIn}>{text.signInLink}</LinkButton>
      </p>
    </div>
  )
}

export { SignUpForm }
export type { SignUpValues }
```

### `src/registry/blocks/auth-01/forgot-password-form.tsx`

```tsx
"use client"

import { MailCheck } from "lucide-react"
import { useMemo, useState } from "react"

import { Button } from "@/components/ui/button"
import { Ripple } from "@/components/ui/ripple/ripple"

import { resolveLabels, splitLabel, type Auth01Labels } from "./config"
import { FormError, SubmitButton, TextField, ViewHeading } from "./form-parts"
import { createForgotPasswordSchema } from "./schemas"
import { useAuthForm } from "./use-auth-form"

function ForgotPasswordForm({
  onForgotPassword,
  onSwitchToSignIn,
  labels,
}: {
  onForgotPassword: (email: string) => Promise<void>
  onSwitchToSignIn: () => void
  labels?: Partial<Auth01Labels>
}) {
  // labels 引用不变时 text 与 schema 都不重建，避免每次输入都重新编译 zod schema
  const text = useMemo(() => resolveLabels(labels), [labels])
  const schema = useMemo(() => createForgotPasswordSchema(text), [text])
  const form = useAuthForm(
    schema,
    { email: "" },
    { fallbackError: text.requestFailed },
  )
  const [sentTo, setSentTo] = useState<string | null>(null)

  if (sentTo) {
    const [before, after] = splitLabel(text.emailSentDescription, "email")
    return (
      <div
        data-slot="auth-01-email-sent"
        className="flex flex-col items-center gap-4 text-center"
        role="status"
      >
        {/* 以邮件图标为圆心向外扩散的波纹，表达「已发出」；纯装饰，状态由下方文字播报 */}
        <Ripple
          size={192}
          rings={3}
          ringGap={44}
          baseSize={56}
          duration={3.5}
          background="none"
          tone="primary"
          className="h-48 w-48 max-w-full"
        >
          <span className="absolute top-1/2 left-1/2 flex size-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-edge bg-background text-primary [&_svg]:size-7">
            <MailCheck />
          </span>
        </Ripple>
        <div className="flex flex-col gap-1">
          <h1 className="text-2xl font-bold tracking-tight">
            {text.emailSentTitle}
          </h1>
          <p className="text-sm leading-6 text-muted-foreground">
            {before}
            <span className="font-medium text-foreground">{sentTo}</span>
            {after}
          </p>
        </div>
        <Button size="lg" className="w-full" onClick={onSwitchToSignIn}>
          {text.backToSignIn}
        </Button>
      </div>
    )
  }
  return (
    <div data-slot="auth-01-forgot-password" className="flex flex-col gap-5">
      <ViewHeading
        title={text.forgotPasswordTitle}
        description={text.forgotPasswordDescription}
      />
      <form
        noValidate
        onSubmit={form.handleSubmit(async ({ email }) => {
          await onForgotPassword(email.trim())
          setSentTo(email.trim())
        })}
        className="flex flex-col gap-5"
      >
        <FormError message={form.formError} />
        <TextField
          id="forgot-email"
          label={text.emailLabel}
          type="email"
          autoComplete="email"
          placeholder={text.emailPlaceholder}
          value={form.values.email}
          error={form.errors.email}
          onChange={(event) => form.setValue("email", event.target.value)}
          onBlur={() => form.blur("email")}
        />
        <SubmitButton
          pending={form.pending}
          pendingText={text.sendResetLinkPending}
        >
          {text.sendResetLink}
        </SubmitButton>
      </form>
      <Button variant="ghost" size="lg" onClick={onSwitchToSignIn}>
        {text.backToSignIn}
      </Button>
    </div>
  )
}

export { ForgotPasswordForm }
```

### `src/registry/blocks/auth-01/form-parts.tsx`

```tsx
"use client"

import { Eye, EyeOff, Loader2 } from "lucide-react"
import { useState, type ComponentProps, type ReactNode } from "react"

import { Button } from "@/components/ui/button"
import { BarField } from "@/components/ui/bar-field/bar-field"
import { cn } from "@/lib/utils"

import { resolveLabels, type Auth01Labels } from "./config"

/**
 * 方条输入框：标签在条内左侧，值靠右；错误文字在条下方，通过 aria-describedby 关联，读屏会一并读出。
 * 高度取 lg 档（36px，触屏 44px），与区块里 size="lg" 的按钮等高。
 */
function TextField({
  id,
  label,
  error,
  footerAction,
  trailing,
  className,
  value,
  defaultValue,
  ...props
}: Omit<ComponentProps<"input">, "id" | "size"> & {
  id: string
  label: string
  error?: string
  /** 条下方右侧的操作，例如「忘记密码？」；有错误文字时错误在左、操作在右 */
  footerAction?: ReactNode
  /** 条内最右侧的元素，例如显示/隐藏密码按钮 */
  trailing?: ReactNode
}) {
  return (
    <BarField
      {...props}
      value={value === undefined ? undefined : String(value)}
      defaultValue={
        defaultValue === undefined ? undefined : String(defaultValue)
      }
      id={id}
      label={label}
      error={error}
      footerAction={footerAction}
      suffix={trailing}
      size="lg"
      className={cn(
        "[&_[data-slot=bar-field-label-above]]:hidden! [&_[data-slot=bar-field-label]]:not-sr-only! [&_input]:text-right!",
        className,
      )}
    />
  )
}

/** 带显示/隐藏切换的密码框。 */
function PasswordField({
  labels,
  ...props
}: Omit<ComponentProps<typeof TextField>, "type" | "trailing"> & {
  labels?: Partial<Auth01Labels>
}) {
  const text = resolveLabels(labels)
  const [visible, setVisible] = useState(false)
  return (
    <TextField
      {...props}
      type={visible ? "text" : "password"}
      trailing={
        <Button
          type="button"
          variant="ghost"
          size="icon-lg"
          onClick={() => setVisible((value) => !value)}
          aria-label={visible ? text.hidePassword : text.showPassword}
          aria-pressed={visible}
          // 按钮比条内边距大，向右让出 8px，图标视觉上仍与条的右缘留足间距
          className="shrink-0 text-muted-foreground hover:text-foreground"
        >
          {visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
        </Button>
      }
    />
  )
}

/** 表单级错误（接口返回的失败原因等），出现时读屏会立即朗读。 */
function FormError({ message }: { message: string | null }) {
  if (!message) return null
  return (
    <p
      data-slot="auth-01-form-error"
      role="alert"
      className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
    >
      {message}
    </p>
  )
}

/** 提交按钮：pending 时显示转圈并禁用。 */
function SubmitButton({
  pending,
  pendingText,
  children,
}: {
  pending: boolean
  pendingText: string
  children: ReactNode
}) {
  return (
    <Button
      data-slot="auth-01-submit-button"
      type="submit"
      size="lg"
      className="w-full"
      disabled={pending}
    >
      {pending ? (
        <>
          <Loader2 aria-hidden="true" className="animate-spin" />
          {pendingText}
        </>
      ) : (
        children
      )}
    </Button>
  )
}

/** 视图头部：标题 + 说明。每个视图只有一个 h1。 */
function ViewHeading({
  title,
  description,
}: {
  title: string
  description: string
}) {
  return (
    <div data-slot="auth-01-view-heading" className="flex flex-col gap-1">
      <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
      <p className="text-sm text-muted-foreground">{description}</p>
    </div>
  )
}

/** 文字链接样式的按钮，用于视图切换。 */
function LinkButton({ className, ...props }: ComponentProps<"button">) {
  return (
    <button
      data-slot="auth-01-link-button"
      type="button"
      className={cn(
        "rounded-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
        className,
      )}
      {...props}
    />
  )
}

export {
  FormError,
  LinkButton,
  PasswordField,
  SubmitButton,
  TextField,
  ViewHeading,
}
```

### `src/registry/blocks/auth-01/password-strength.tsx`

```tsx
import { cn } from "@/lib/utils"

import { formatLabel, resolveLabels, type Auth01Labels } from "./config"

// 四段强度条：颜色从 destructive 过渡到主色，跟随主题；文字在 DEFAULT_LABELS 里
const levels = [
  { label: "strengthWeak", bar: "bg-destructive" },
  { label: "strengthFair", bar: "bg-destructive/50" },
  { label: "strengthGood", bar: "bg-primary/60" },
  { label: "strengthStrong", bar: "bg-primary" },
] as const satisfies readonly { label: keyof Auth01Labels; bar: string }[]

/** 按长度与字符种类打分：0 表示尚未输入，1–4 对应 弱 / 一般 / 较强 / 强。 */
function getPasswordStrength(password: string) {
  if (!password) return 0
  const score =
    Number(password.length >= 8) +
    Number(password.length >= 12) +
    Number(/[a-z]/.test(password) && /[A-Z]/.test(password)) +
    Number(/\d/.test(password)) +
    Number(/[^A-Za-z0-9]/.test(password))
  return Math.min(4, Math.max(1, score <= 1 ? 1 : score >= 4 ? 4 : score))
}

/** 四段强度条 + 文字说明。 */
function PasswordStrength({
  password,
  labels,
}: {
  password: string
  labels?: Partial<Auth01Labels>
}) {
  const text = resolveLabels(labels)
  const strength = getPasswordStrength(password)
  const level = strength ? levels[strength - 1] : null
  return (
    <div
      data-slot="auth-01-password-strength"
      data-strength={strength}
      className="flex items-center gap-3"
    >
      <div aria-hidden="true" className="grid flex-1 grid-cols-4 gap-1">
        {levels.map((item, index) => (
          <span
            key={item.label}
            className={cn(
              "h-1 rounded-full bg-muted transition-colors motion-reduce:transition-none",
              index < strength && item.bar,
            )}
          />
        ))}
      </div>
      <span
        aria-live="polite"
        className="w-16 text-xs whitespace-nowrap text-muted-foreground"
      >
        {level
          ? formatLabel(text.passwordStrength, { level: text[level.label] })
          : text.passwordStrengthEmpty}
      </span>
    </div>
  )
}

export { PasswordStrength, getPasswordStrength }
```

### `src/registry/blocks/auth-01/schemas.ts`

```ts
import { z } from "zod"

import { formatLabel, type Auth01Labels } from "./config"

/**
 * 校验规则常量：改策略只改这里，提示文案里的 {min}、{length} 会自动带入新值。
 * 提示文案本身全部来自 labels（config.ts 的 validation* 键），可逐实例覆盖。
 */

/** 密码最少位数 */
const PASSWORD_MIN_LENGTH = 8

/** 姓名最少字符数 */
const NAME_MIN_LENGTH = 2

/** 短信验证码位数 */
const CODE_LENGTH = 6

/** 中国大陆手机号位数，需与 PHONE_PATTERN 的位数一致 */
const PHONE_LENGTH = 11

/** 中国大陆 11 位手机号；面向海外用户时替换这条规则 */
const PHONE_PATTERN = /^1[3-9]\d{9}$/

/** 验证码只接受数字，位数由 CODE_LENGTH 决定 */
const CODE_PATTERN = new RegExp(`^\\d{${CODE_LENGTH}}$`)

function emailField(labels: Auth01Labels) {
  return z
    .string()
    .trim()
    .min(1, labels.validationEmailRequired)
    .email(labels.validationEmailInvalid)
}

function passwordField(labels: Auth01Labels) {
  return z
    .string()
    .min(
      PASSWORD_MIN_LENGTH,
      formatLabel(labels.validationPasswordMin, { min: PASSWORD_MIN_LENGTH }),
    )
    .regex(/[A-Za-z]/, labels.validationPasswordLetter)
    .regex(/\d/, labels.validationPasswordDigit)
}

function phoneField(labels: Auth01Labels) {
  return z
    .string()
    .trim()
    .regex(
      PHONE_PATTERN,
      formatLabel(labels.validationPhoneInvalid, { length: PHONE_LENGTH }),
    )
}

/** 密码登录。labels 需是合并过默认值的完整文案。 */
function createSignInSchema(labels: Auth01Labels) {
  return z.object({
    account: z.string().trim().min(1, labels.validationAccountRequired),
    password: z.string().min(1, labels.validationPasswordRequired),
    remember: z.boolean(),
  })
}

/** 验证码登录。 */
function createCodeSignInSchema(labels: Auth01Labels) {
  return z.object({
    phone: phoneField(labels),
    code: z
      .string()
      .trim()
      .regex(
        CODE_PATTERN,
        formatLabel(labels.validationCodeInvalid, { length: CODE_LENGTH }),
      ),
  })
}

/** 注册：确认密码与条款勾选也在前端校验。 */
function createSignUpSchema(labels: Auth01Labels) {
  return z
    .object({
      name: z
        .string()
        .trim()
        .min(
          NAME_MIN_LENGTH,
          formatLabel(labels.validationNameMin, { min: NAME_MIN_LENGTH }),
        ),
      email: emailField(labels),
      password: passwordField(labels),
      confirm: z.string().min(1, labels.validationConfirmRequired),
      terms: z.boolean().refine((value) => value, labels.validationTermsRequired),
    })
    .refine((values) => values.password === values.confirm, {
      message: labels.validationPasswordMismatch,
      path: ["confirm"],
    })
}

/** 找回密码。 */
function createForgotPasswordSchema(labels: Auth01Labels) {
  return z.object({ email: emailField(labels) })
}

export {
  CODE_LENGTH,
  NAME_MIN_LENGTH,
  PASSWORD_MIN_LENGTH,
  PHONE_LENGTH,
  PHONE_PATTERN,
  createCodeSignInSchema,
  createForgotPasswordSchema,
  createSignInSchema,
  createSignUpSchema,
}
```

### `src/registry/blocks/auth-01/social-buttons.tsx`

```tsx
import type { SVGProps } from "react"

import { Button } from "@/components/ui/button"

type SocialProvider = "github" | "google"

function GitHubIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      data-slot="auth-01-github-icon"
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      {...props}
    >
      <path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.64-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.6 9.6 0 0 1 12 6.81c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" />
    </svg>
  )
}

// 单色 G 标识：用 currentColor，不引入品牌多色，跟随主题
function GoogleIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      data-slot="auth-01-google-icon"
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      {...props}
    >
      <path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
    </svg>
  )
}

// 品牌名是专有名词，不放进 labels
const providers = {
  github: { label: "GitHub", Icon: GitHubIcon },
  google: { label: "Google", Icon: GoogleIcon },
} as const

/** 第三方登录按钮行：点击后交给 onSelect，pending 时全部禁用。 */
function SocialButtons({
  providers: enabled,
  pending,
  onSelect,
}: {
  providers: readonly SocialProvider[]
  pending: boolean
  onSelect: (provider: SocialProvider) => void
}) {
  if (!enabled.length) return null
  return (
    <div
      data-slot="auth-01-social-buttons"
      className="grid gap-2"
      style={{
        gridTemplateColumns: `repeat(${enabled.length}, minmax(0, 1fr))`,
      }}
    >
      {enabled.map((provider) => {
        const { label, Icon } = providers[provider]
        return (
          <Button
            key={provider}
            type="button"
            variant="outline"
            size="lg"
            disabled={pending}
            onClick={() => onSelect(provider)}
          >
            <Icon data-icon="inline-start" />
            {label}
          </Button>
        )
      })}
    </div>
  )
}

export { SocialButtons }
export type { SocialProvider }
```

### `src/registry/blocks/auth-01/use-auth-form.ts`

```ts
"use client"

import { useCallback, useState, type FormEvent } from "react"
import type { z } from "zod"

import { DEFAULT_LABELS } from "./config"

type FieldErrors = Record<string, string>
// 字面量初始值（false、""）会被推断成字面量类型，这里放宽为 boolean / string
type Widen<T> = {
  [K in keyof T]: T[K] extends boolean ? boolean : T[K] extends string ? string : T[K]
}

/** 每个字段只保留第一条错误，避免一次堆出一长串提示 */
function toErrors(error: z.ZodError): FieldErrors {
  const errors: FieldErrors = {}
  for (const issue of error.issues) {
    const key = String(issue.path[0] ?? "")
    if (key && !(key in errors)) errors[key] = issue.message
  }
  return errors
}

/** 把未知错误转成可展示的中文文案；没有 message 时用传入的兜底文案。 */
function errorMessage(
  error: unknown,
  fallback: string = DEFAULT_LABELS.requestFailed,
) {
  return error instanceof Error && error.message ? error.message : fallback
}

/** 包装一个异步动作：管理 loading，并把抛出的错误转成可展示的中文文案。 */
function useAsyncAction(fallbackError: string = DEFAULT_LABELS.requestFailed) {
  const [pending, setPending] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const run = useCallback(
    async (action: () => Promise<void>) => {
      setPending(true)
      setError(null)
      try {
        await action()
        return true
      } catch (caught) {
        setError(errorMessage(caught, fallbackError))
        return false
      } finally {
        setPending(false)
      }
    },
    [fallbackError],
  )
  return { pending, error, run, clearError: () => setError(null) }
}

/**
 * 极简受控表单：zod 校验 + 字段错误 + 提交状态。
 * 首次提交前，字段失焦时校验该字段；提交失败后，输入时实时重新校验。
 */
function useAuthForm<I extends Record<string, string | boolean>>(
  schema: z.ZodType,
  initial: I,
  options: { fallbackError?: string } = {},
) {
  type V = Widen<I>
  const [values, setValues] = useState<V>(initial as V)
  const [errors, setErrors] = useState<FieldErrors>({})
  const [submitted, setSubmitted] = useState(false)
  const action = useAsyncAction(options.fallbackError)

  const check = useCallback(
    (next: V) => {
      const result = schema.safeParse(next)
      return result.success ? {} : toErrors(result.error)
    },
    [schema],
  )

  const setValue = <K extends keyof V & string>(key: K, value: V[K]) => {
    const next = { ...values, [key]: value }
    setValues(next)
    if (submitted) setErrors(check(next))
    else if (errors[key]) setErrors((prev) => ({ ...prev, [key]: "" }))
  }
  const blur = (key: keyof V & string) => {
    if (values[key] === "" && !submitted) return
    const found = check(values)[key] ?? ""
    setErrors((prev) => ({ ...prev, [key]: found }))
  }
  const handleSubmit =
    (onValid: (values: V) => Promise<void>) =>
    async (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault()
      setSubmitted(true)
      const found = check(values)
      setErrors(found)
      if (Object.keys(found).length) return
      await action.run(() => onValid(values))
    }

  return {
    values,
    errors,
    setValue,
    blur,
    handleSubmit,
    pending: action.pending,
    formError: action.error,
    run: action.run,
    /** 校验单个字段并返回是否通过（用于「发送验证码」前校验手机号） */
    validateField: (key: keyof V & string) => {
      const found = check(values)[key] ?? ""
      setSubmitted(true)
      setErrors((prev) => ({ ...prev, [key]: found }))
      return !found
    },
  }
}

export { errorMessage, useAsyncAction, useAuthForm }
export type { FieldErrors }
```
