用户名/密码登录
用户名/密码登录是传统的身份验证方式,为需要明确密码管理的用户提供完整的登录体验,支持邮箱/手机号混输识别、浏览器凭据管理以及无障碍设计。
适用群体与场景
适用用户群体
- 习惯密码管理:习惯使用浏览器或第三方密码管理器的用户
- 企业与组织:有统一密码策略和合规要求的企业环境
- 传统偏好:更信任或偏好传统密码登录方式的群体
特定应用场景
- 多账户切换:需要频繁切换和管理多个不同账户的环境
- 离线与兼容:希望在本地保存凭据,或需要兼容不支持新式免密登录的老旧系统
推荐使用优先级
交互流程
代码实现
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Eye, EyeOff } from 'lucide-react';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
const loginSchema = z.object({
username: z.string()
.min(1, { message: '请输入邮箱或手机号' })
.refine((value) => {
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const isPhone = /^1[3-9]\d{9}$/.test(value);
return isEmail || isPhone;
}, { message: '请输入有效的邮箱或手机号' }),
password: z.string()
.min(6, { message: '密码至少 6 位' })
.max(32, { message: '密码最多 32 位' }),
remember: z.boolean().default(false),
});
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const [showPassword, setShowPassword] = useState(false);
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
remember: false,
},
mode: 'onTouched',
});
const {
handleSubmit,
control,
setValue,
formState: { isSubmitting },
} = form;
useEffect(() => {
if ('credentials' in navigator) {
navigator.credentials.get({ password: true, mediation: 'optional' })
.then((cred) => {
if (cred && 'password' in cred) {
setValue('username', cred.id, { shouldValidate: true });
setValue('password', cred.password, { shouldValidate: true });
setValue('remember', true);
}
})
.catch((err) => {
console.error('自动读取凭据失败:', err);
});
}
}, [setValue]);
const onSubmit = async (values: LoginFormData) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error('登录失败');
}
if (values.remember && 'credentials' in navigator) {
const cred = new PasswordCredential({
id: values.username,
password: values.password,
});
await navigator.credentials.store(cred);
}
} catch (error) {
console.error('登录失败:', error);
}
};
const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev);
};
return (
<div className="w-full max-w-md p-6 bg-white rounded-lg shadow-md">
<h2 className="mb-6 text-2xl font-bold text-center">用户登录</h2>
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱/手机号</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入邮箱或手机号"
inputMode="email"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>密码</FormLabel>
<a href="/forgot-password" className="text-sm text-blue-600 hover:underline">
忘记密码?
</a>
</div>
<FormControl>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
autoComplete="current-password"
required
{...field}
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700"
onClick={togglePasswordVisibility}
aria-label={showPassword ? '隐藏密码' : '显示密码'}
aria-pressed={showPassword}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="remember"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal cursor-pointer">
自动登录
</FormLabel>
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? '登录中...' : '登录'}
</Button>
<div className="text-center text-sm text-gray-500 mt-4">
还没有账号?{' '}
<a href="/register" className="text-blue-600 hover:underline">
立即注册
</a>
</div>
</form>
</Form>
</div>
);
}
export default LoginForm;
体验与安全特性
该模块在交互、安全和无障碍设计上的处理要点如下:
交互与表单校验
- 移动端输入防干扰:设置
autoCapitalize="none"、autoCorrect="off"和spellCheck={false},避免移动端输入邮箱时首字母自动大写或被自动纠正 - 账号混输识别:单个输入框支持邮箱与手机号混输,通过正则判断格式并给出对应的错误提示
- 找回路径:表单内嵌“忘记密码”和“立即注册”入口,密码验证失败时提供找回路径
- 校验时机:采用
onTouched模式,字段首次失焦后触发校验,避免打断输入 - 密码可见性切换:支持明文/密文切换,通过
aria-pressed反映切换状态
凭据管理与安全 (Credential API)
- 密码管理器兼容:设置
autoComplete="username"与autoComplete="current-password",并添加required属性,便于浏览器识别并提示保存/自动填充 - 凭据存储:基于 Credential Management API(需 HTTPS),勾选自动登录后将凭据交由浏览器管理
- 防重复提交:登录按钮在请求期间处于
disabled状态,避免重复提交
无障碍与移动端优化 (a11y)
- 移动端键盘:用户名输入框通过
inputMode="email"唤起对应的虚拟键盘 - 屏幕阅读器:密码可见性切换按钮通过
aria-label、aria-pressed标记当前状态 - 键盘操作:交互元素(包括密码可见性切换按钮)均可通过 Tab 聚焦、回车触发