"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))
}}
/>
)
}
"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,
}
/**
* 登录页所有用户可见的文案,包含表单校验提示(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 }
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 }
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 }
"use client"
import { KeyRound, Lock, Smartphone, User } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
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 { 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}
icon={User}
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}
icon={Lock}
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}
labelAction={
<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)
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (seconds <= 0) return
const timer = window.setTimeout(
() => setSeconds((value) => value - 1),
1000,
)
return () => window.clearTimeout(timer)
}, [seconds])
const sendCode = async () => {
if (!form.validateField("phone")) return
const sent = await sender.run(() => onSendCode(form.values.phone.trim()))
if (sent) setSeconds(CODE_COOLDOWN)
}
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} />
<TextField
id="sign-in-phone"
label={text.phoneLabel}
icon={Smartphone}
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder={text.phonePlaceholder}
value={form.values.phone}
error={form.errors.phone}
onChange={(event) => form.setValue("phone", event.target.value)}
onBlur={() => form.blur("phone")}
/>
<TextField
id="sign-in-code"
label={text.codeLabel}
icon={KeyRound}
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")}
adjacent={
<Button
type="button"
variant="outline"
size="lg"
className="w-28 shrink-0 tabular-nums"
disabled={sender.pending || seconds > 0}
onClick={sendCode}
>
{sender.pending
? text.sendCodePending
: seconds > 0
? formatLabel(text.resendCode, { seconds })
: text.sendCode}
</Button>
}
/>
<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 }
"use client"
import { Lock, Mail, User } from "lucide-react"
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}
icon={User}
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}
icon={Mail}
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}
icon={Lock}
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}
icon={Lock}
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 }
"use client"
import { Mail, 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}
icon={Mail}
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 }
"use client"
import { Eye, EyeOff, Loader2, type LucideIcon } from "lucide-react"
import { useState, type ComponentProps, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { resolveLabels, type Auth01Labels } from "./config"
/** 带图标、错误提示的输入框;错误通过 aria-describedby 关联,读屏会一并读出。 */
function TextField({
id,
label,
icon: Icon,
error,
labelAction,
trailing,
adjacent,
className,
...props
}: Omit<ComponentProps<typeof Input>, "id"> & {
id: string
label: string
icon: LucideIcon
error?: string
/** 标签行右侧的操作,例如「忘记密码?」 */
labelAction?: ReactNode
/** 输入框内右侧的元素,例如显示/隐藏密码按钮 */
trailing?: ReactNode
/** 输入框外右侧的元素,例如「获取验证码」按钮 */
adjacent?: ReactNode
}) {
const errorId = `${id}-error`
return (
<div data-slot="auth-01-text-field" className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>{label}</Label>
{labelAction}
</div>
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<Icon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={cn("h-9 pl-9", trailing && "pr-10", className)}
{...props}
/>
{trailing}
</div>
{adjacent}
</div>
{error ? (
<p id={errorId} className="text-xs text-destructive">
{error}
</p>
) : null}
</div>
)
}
/** 带显示/隐藏切换的密码框。 */
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-sm"
onClick={() => setVisible((value) => !value)}
aria-label={visible ? text.hidePassword : text.showPassword}
aria-pressed={visible}
className="absolute inset-y-0 right-1.5 my-auto 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 }
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 }
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,
}
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 }
"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 }
Auth01 提炼自我的一个企业监控平台项目的登录页,在原有「左侧品牌面板 + 右侧表单」结构上,补上了完整的账号流程:
- 登录:密码登录 / 验证码登录用方条分段控件切换;账号(用户名或邮箱)、密码显示切换、记住登录状态开关、忘记密码、第三方登录(GitHub、Google)、跳转注册。
- 注册:姓名、邮箱、密码(实时强度条)、确认密码、同意服务条款。
- 找回密码:邮箱提交后切换为「邮件已发送」的成功状态,邮件图标向外扩散波纹。
- 三个视图在同一个组件内切换,带 180ms 的淡入位移过渡,系统开启「减少动态效果」时只保留淡入。
- 大屏左半屏是品牌面板,窄屏隐藏面板并在表单上方显示 Logo 与名称。
- 品牌面板的颜色都来自主题变量,切换颜色主题和亮暗模式后自动跟随:亮色下是
primary底配primary-foreground文字;暗色下primary会变成浅色,面板改用card深底,只叠一层低透明度的primary渐变染上主题色,文字回到foreground。光斑、波纹、字符矩阵共用一个--panel-glow变量。预览里可以直接试。 - 输入框、提交按钮、第三方登录按钮、「获取验证码」、「返回登录」统一为 36px(按钮用
size="lg",输入框h-9),与方条分段控件的lg档对齐。
用到的本站组件
这个区块本身就是自研组件的一组真实用例,除表单输入、按钮、复选框(注册页的条款勾选)仍用 shadcn 原语外,其余都换成了本站组件:
| 位置 | 组件 | 说明 |
|---|---|---|
| 品牌面板背景 | ripple | 以面板中心向外扩散的同心波纹,替代原来的静态圆环 |
| 品牌面板四周 | glyph-field | 淡淡的 0 / 1 字符矩阵,fade="center" 让中间留白给文字 |
| 品牌面板标题 | text-animate 的 TextReveal | 挂载时按词逐个显现,读屏仍读完整标题 |
| 登录方式切换 | segmented-control | 密码登录 / 验证码登录,替代原来的 Tabs |
| 记住登录状态 | toggle-switch | 即时生效的设置项,替代原来的复选框 |
| 「邮件已发送」 | ripple | 邮件图标为圆心,表达「已发出」 |
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/auth-01.json会安装 shadcn 的 button、input、label、checkbox,本站的 ripple、glyph-field、text-animate、segmented-control、toggle-switch(及它们依赖的 flow-diagram 色调与背景),以及 npm 包 motion、zod、lucide-react。如果项目里已有同名文件,CLI 会询问是否覆盖。
用法
最简单的用法是直接渲染,不传任何属性。此时所有回调都退回「控制台打印 + 模拟 0.8 秒请求」的示例实现,方便先看效果:
import { Auth01 } from "@/components/auth-01/auth-01"
export default function LoginPage() {
return <Auth01 />
}接入你的认证服务
回调都返回 Promise。组件负责按钮的 loading 状态;失败时 throw new Error("中文原因"),错误信息会显示在表单上方。 下面以 better-auth 为例,NextAuth / Auth.js、Supabase 等同理:
"use client"
import { useRouter } from "next/navigation"
import { Auth01 } from "@/components/auth-01/auth-01"
import { authClient } from "@/lib/auth-client"
export default function LoginPage() {
const router = useRouter()
return (
<Auth01
brand={{ name: "你的产品", title: "一句话说清价值" }}
onSignIn={async (values) => {
if (values.method === "code") {
// 验证码登录:values.phone / values.code
throw new Error("暂未开通验证码登录")
}
const isEmail = values.account.includes("@")
const { error } = isEmail
? await authClient.signIn.email({
email: values.account,
password: values.password,
rememberMe: values.remember,
})
: await authClient.signIn.username({
username: values.account,
password: values.password,
rememberMe: values.remember,
})
if (error) throw new Error(error.message ?? "登录失败")
router.push("/")
}}
onSignUp={async ({ name, email, password }) => {
const { error } = await authClient.signUp.email({ name, email, password })
if (error) throw new Error(error.message ?? "注册失败")
router.push("/")
}}
onForgotPassword={async (email) => {
const { error } = await authClient.requestPasswordReset({ email })
if (error) throw new Error(error.message ?? "发送失败")
}}
onSendCode={async (phone) => {
await fetch("/api/sms/send", { method: "POST", body: JSON.stringify({ phone }) })
}}
onSocialSignIn={async (provider) => {
await authClient.signIn.social({ provider })
}}
/>
)
}使用 NextAuth 时,把 onSignIn 换成 signIn("credentials", { redirect: false, ... }) 并在返回 error 时抛出即可;第三方登录对应 signIn(provider)。
换成英文
labels 也能覆盖校验提示(validation* 键)。提示里的 {min}、{length} 由 schemas.ts 的规则常量填入,保留占位符就会显示成对应的数字:
import { Auth01, type Auth01Labels } from "@/components/auth-01/auth-01"
// 只覆盖需要改的键,没写的键仍是默认中文
const english: Partial<Auth01Labels> = {
signInTitle: "Welcome back",
signInDescription: "Sign in to continue",
accountLabel: "Username or email",
accountPlaceholder: "you@example.com",
passwordLabel: "Password",
passwordPlaceholder: "Enter your password",
signInSubmit: "Sign in",
signUpTitle: "Create an account",
nameLabel: "Name",
emailLabel: "Email",
newPasswordPlaceholder: "At least 8 characters, letters and numbers",
confirmPasswordLabel: "Confirm password",
signUpSubmit: "Sign up",
// 校验提示:{min}、{length} 一定要保留,否则数字不会被填入
validationAccountRequired: "Enter your username or email",
validationPasswordRequired: "Enter your password",
validationEmailRequired: "Enter your email",
validationEmailInvalid: "Enter a valid email address",
validationPhoneInvalid: "Enter a valid {length}-digit phone number",
validationCodeInvalid: "Enter the {length}-digit code",
validationPasswordMin: "Use at least {min} characters",
validationPasswordLetter: "Include a letter",
validationPasswordDigit: "Include a number",
validationNameMin: "Name needs at least {min} characters",
validationConfirmRequired: "Re-enter your password",
validationPasswordMismatch: "Passwords do not match",
validationTermsRequired: "Please accept the terms to continue",
}
export default function LoginPage() {
return <Auth01 labels={english} />
}完整键与默认值见下方 labels。
API
Auth01
品牌信息,逐项覆盖默认示例品牌,见下方 BrandConfig
类型Partial<BrandConfig>初始视图
类型"sign-in" | "sign-up" | "forgot-password""sign-in"启用的第三方登录,传 [] 隐藏该区域;默认两个都开
("github" | "google")[]["github", "google"]设为 false 隐藏「注册」入口
booleantrue注册页中「服务条款」的链接
类型string注册页中「隐私政策」的链接
类型stringvalues 是联合类型:{ method: "password", account, password, remember } 或 { method: "code", phone, code }
(values: SignInValues) => Promise<void>校验通过后才会调用;确认密码与条款勾选已在组件内校验
类型(values: { name; email; password }) => Promise<void>成功返回后显示「邮件已发送」
类型(email: string) => Promise<void>成功返回后「获取验证码」按钮进入 60 秒倒计时
类型(phone: string) => Promise<void>点击第三方登录按钮时调用
类型(provider) => Promise<void>追加到最外层容器
类型stringBrandConfig
产品名
类型stringLogo。建议用 currentColor 的 SVG,在品牌面板(反色)和表单上方(正常色)都能看清;也可以传 <img>
ReactNode品牌面板的大标题
类型string品牌面板的说明
类型string特性列表,建议 2–3 条,图标用 lucide-react
{ icon: ReactNode; title; description }[]右下角版权文字
类型stringlabels
所有键都在 config.ts 的 DEFAULT_LABELS 里,传 labels={{ signInTitle: "Sign in" }} 这样覆盖其中任意几项。含 {name}、{email}、{seconds}、{level} 的文案会在渲染时填入实际值;表格末尾的 validation* 键是表单校验提示,其中的 {min}、{length} 由 schemas.ts 的规则常量填入(例如改小密码最小长度后,提示里的数字会自动变)。
| 键 | 默认值 | 说明 |
|---|---|---|
brandPanelLabel | {name} 品牌介绍 | 品牌面板的 aria-label |
signInTitle | 欢迎回来 | 登录视图标题(h1) |
signInDescription | 输入账号信息即可登录 | 登录视图说明 |
signInMethodLabel | 登录方式 | 登录方式分段控件的 aria-label |
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 | 创建账号 | 注册视图标题(h1) |
signUpDescription | 填写以下信息即可完成注册 | 注册视图说明 |
nameLabel | 姓名 | 姓名输入框的标签 |
namePlaceholder | 请输入姓名 | 姓名输入框的占位文字 |
emailLabel | 邮箱 | 邮箱输入框(注册与找回密码共用) |
emailPlaceholder | name@example.com | 邮箱输入框(注册与找回密码共用) |
newPasswordPlaceholder | 至少 8 位,包含字母和数字 | 注册密码框占位文字 |
confirmPasswordLabel | 确认密码 | 确认密码输入框的标签 |
confirmPasswordPlaceholder | 再次输入密码 | 确认密码输入框的占位文字 |
termsPrefix | 我已阅读并同意 | 条款勾选行的四段文字 |
termsOfService | 服务条款 | 条款勾选行的四段文字 |
termsConjunction | 和 | 条款勾选行的四段文字 |
privacyPolicy | 隐私政策 | 条款勾选行的四段文字 |
signUpSubmit | 注册 | 注册按钮与其加载文案 |
signUpPending | 注册中… | 注册按钮与其加载文案 |
haveAccount | 已有账号? | 跳转登录 |
signInLink | 登录 | 跳转登录 |
forgotPasswordTitle | 找回密码 | 找回密码视图标题(h1) |
forgotPasswordDescription | 输入注册时使用的邮箱,我们会发送重置链接 | 找回密码视图说明 |
sendResetLink | 发送重置链接 | 提交按钮与其加载文案 |
sendResetLinkPending | 发送中… | 提交按钮与其加载文案 |
backToSignIn | 返回登录 | 返回登录按钮 |
emailSentTitle | 邮件已发送 | 发送成功视图标题(h1) |
emailSentDescription | 我们已向 {email} 发送了重置密码的链接,请查收邮件。没收到?请检查垃圾邮件箱。 | 发送成功视图说明,{email} 会加粗显示 |
showPassword | 显示密码 | 密码显示切换按钮的 aria-label |
hidePassword | 隐藏密码 | 密码显示切换按钮的 aria-label |
passwordStrength | 强度:{level} | 强度条右侧文字 |
passwordStrengthEmpty | 强度:— | 强度条右侧文字 |
strengthWeak | 弱 | 四个强度等级 |
strengthFair | 一般 | 四个强度等级 |
strengthGood | 较强 | 四个强度等级 |
strengthStrong | 强 | 四个强度等级 |
validationAccountRequired | 请输入用户名或邮箱 | 密码登录:账号为空 |
validationPasswordRequired | 请输入密码 | 密码登录:密码为空 |
validationEmailRequired | 请输入邮箱 | 邮箱为空(注册、找回密码) |
validationEmailInvalid | 请输入有效的邮箱地址 | 邮箱格式错误 |
validationPhoneInvalid | 请输入有效的 {length} 位手机号 | 验证码登录:手机号格式错误,{length} 默认 11 |
validationCodeInvalid | 请输入 {length} 位数字验证码 | 验证码格式错误,{length} 默认 6 |
validationPasswordMin | 密码至少 {min} 位 | 密码太短,{min} 默认 8 |
validationPasswordLetter | 密码需要包含字母 | 密码缺少字母 |
validationPasswordDigit | 密码需要包含数字 | 密码缺少数字 |
validationNameMin | 姓名至少 {min} 个字符 | 姓名太短,{min} 默认 2 |
validationConfirmRequired | 请再次输入密码 | 确认密码为空 |
validationPasswordMismatch | 两次输入的密码不一致 | 两次输入的密码不同 |
validationTermsRequired | 请先阅读并同意服务条款 | 未勾选条款 |
requestFailed | 操作失败,请稍后重试 | 回调抛出的错误没有 message 时的兜底提示 |
可访问性
- 每个视图只有一个
h1(由ViewHeading渲染),品牌面板是带aria-label的aside,光斑、波纹与字符矩阵等装饰全部aria-hidden;品牌标题的逐词动画只作用在隐藏层,读屏读到的是完整原文。 - 输入框与
Label通过htmlFor/id关联;字段错误用aria-describedby关联到同一个输入框,出错时设置aria-invalid;表单级错误是role="alert",出现时立即朗读。 - 密码显示切换按钮带
aria-pressed和随状态变化的中文名称(显示密码 / 隐藏密码)。 - 登录方式是带
aria-label的单选组(segmented-control),方向键切换;「记住登录状态」是role="switch"的开关;「或」分隔线是role="separator"。 - 密码强度条是装饰(
aria-hidden),文字结果用aria-live="polite"播报;强度不只靠颜色区分,同时有文字。 - 「邮件已发送」视图是
role="status",读屏会读出提交结果。 - 提交中按钮禁用并显示「登录中…」,避免重复提交;所有图标按钮都有中文
aria-label。 - 视图切换动画遵循
prefers-reduced-motion,开启后只保留淡入;波纹、字符矩阵与标题显现在开启「减少动态效果」时显示静态画面,标题直接显示全文。
注意事项
文件结构
app/login/page.tsx # 页面入口:把回调接到你的认证服务
components/auth-01/
├── auth-01.tsx # 主组件:分屏布局 + 视图切换
├── config.ts # ★ 全部用户可见文案 DEFAULT_LABELS
├── brand.tsx # 品牌配置类型与默认示例品牌
├── brand-panel.tsx # 左侧品牌面板(波纹 + 字符矩阵 + 标题显现)、窄屏顶部品牌
├── sign-in-form.tsx # 登录(分段控件切换密码 / 验证码)+ 第三方登录
├── sign-up-form.tsx # 注册
├── forgot-password-form.tsx # 找回密码(成功态用波纹)
├── password-strength.tsx # 密码强度条与评分
├── social-buttons.tsx # GitHub / Google 按钮
├── form-parts.tsx # 输入框、密码框、错误提示等共用件
├── schemas.ts # zod 校验规则工厂与规则常量,提示语取自 labels
└── use-auth-form.ts # 受控表单状态 + 校验 + 提交状态- 校验规则与校验提示分开:规则常量(
PASSWORD_MIN_LENGTH、NAME_MIN_LENGTH、CODE_LENGTH、PHONE_PATTERN/PHONE_LENGTH)在schemas.ts,提示文案是labels的validation*键;提示里的{min}、{length}由常量填入,改规则后文案里的数字自动更新。schema 由createSignInSchema(labels)这类工厂函数生成,组件里用useMemo缓存,labels引用不变就不会重建。 - 前端校验不是安全边界:手机号、密码策略只是体验层,服务端必须再校验一次。
- 静态导出:组件是纯客户端组件,不使用 Server Actions 或
cookies(),可用于output: "export"。
如何自定义
- 校验规则:规则常量都在
schemas.ts。手机号默认是中国大陆 11 位规则,面向其他地区时替换PHONE_PATTERN并同步PHONE_LENGTH;密码策略是PASSWORD_MIN_LENGTH与两条正则(必须含字母、必须含数字);姓名最短长度是NAME_MIN_LENGTH,验证码位数是CODE_LENGTH。提示文案不用改,数字会跟着常量走;newPasswordPlaceholder(「至少 8 位,包含字母和数字」)是静态示例文案,改了最小长度记得一并改。 - 强度条算法:
password-strength.tsx的getPasswordStrength,按长度(8、12)与大小写、数字、符号计分,颜色从destructive过渡到primary。 - 只要登录:传
allowSignUp={false},再传socialProviders={[]}隐藏第三方登录(省略时默认会显示 GitHub 和 Google)。 - 增减第三方平台:在
social-buttons.tsx的providers里补一项,并扩展SocialProvider类型。 - 改文案:优先用
labels,校验提示也一样,见上方换成英文;整站换语言时也可以直接改config.ts的DEFAULT_LABELS。 - 表单状态:刻意只用受控状态 + zod,没有引入 react-hook-form。字段首次失焦时校验该字段,提交失败后改为输入时实时校验。