登录页布局
登录页css
- src/views/login/login.css.tsx
tsx
import styled from "@emotion/styled";
export const LoginPageWrapper = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(135deg, #e6f4ff 0%, #f6ffed 100%);
box-sizing: border-box;
/* 响应式适配:小屏幕压缩留白 */
@media (max-width: 480px) {
padding: 16px 12px;
}
`;
export const LoginCard = styled.div`
width: 100%;
max-width: 400px;
padding: 40px 36px 32px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
box-sizing: border-box;
@media (max-width: 480px) {
padding: 28px 20px 24px;
}
`;
export const LoginHeader = styled.div`
text-align: center;
margin-bottom: 32px;
@media (max-width: 480px) {
margin-bottom: 24px;
}
`;
export const LoginTitle = styled.h1`
margin: 0;
font-size: 28px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
@media (max-width: 480px) {
font-size: 24px;
}
`;
export const LoginSubtitle = styled.p`
margin: 8px 0 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
`;
export const FieldIcon = styled.span`
color: rgba(0, 0, 0, 0.25);
display: inline-flex;
`;
export const CaptchaRow = styled.div`
display: flex;
gap: 12px;
.ant-input-affix-wrapper {
flex: 1;
min-width: 0;
}
`;
export const CaptchaImage = styled.button`
flex-shrink: 0;
width: 120px;
height: 40px;
padding: 0;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
&:hover {
border-color: #1677ff;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
@media (max-width: 480px) {
width: 100px;
}
`;
export const StrengthBar = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-top: -12px;
margin-bottom: 20px;
.ant-progress {
flex: 1;
margin: 0;
}
`;
export const StrengthText = styled.span<{ color: string }>`
font-size: 12px;
flex-shrink: 0;
color: ${(props) => props.color};
`;登录页布局
- src/views/login/login.tsx
tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { App as AntdApp, Button, Form, Input, Progress } from "antd";
import {
LockOutlined,
ReloadOutlined,
SafetyOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import {
CaptchaImage,
CaptchaRow,
FieldIcon,
LoginCard,
LoginHeader,
LoginPageWrapper,
LoginSubtitle,
LoginTitle,
StrengthBar,
StrengthText,
} from "./login.css";
/** 登录表单字段类型 */
interface LoginFormValues {
username: string;
password: string;
captcha: string;
}
/** 生成随机验证码文本(去除易混淆字符) */
function generateCaptchaText(length = 4): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
let text = "";
for (let i = 0; i < length; i += 1) {
text += chars.charAt(Math.floor(Math.random() * chars.length));
}
return text;
}
/** 模拟请求服务器生成验证码图片,实际项目中替换为真实接口 */
function fetchCaptchaImage(text: string): Promise<string> {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = 120;
canvas.height = 44;
const ctx = canvas.getContext("2d")!;
// 背景
ctx.fillStyle = `hsl(${Math.random() * 360}, 60%, 94%)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 干扰线
for (let i = 0; i < 4; i += 1) {
ctx.strokeStyle = `hsl(${Math.random() * 360}, 50%, 60%)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.stroke();
}
// 干扰点
for (let i = 0; i < 30; i += 1) {
ctx.fillStyle = `hsl(${Math.random() * 360}, 50%, 50%)`;
ctx.beginPath();
ctx.arc(
Math.random() * canvas.width,
Math.random() * canvas.height,
1,
0,
Math.PI * 2,
);
ctx.fill();
}
// 验证码字符(随机旋转/偏移)
for (let i = 0; i < text.length; i += 1) {
ctx.save();
ctx.font = `bold ${22 + Math.random() * 6}px Arial`;
ctx.fillStyle = `hsl(${Math.random() * 360}, 70%, 40%)`;
ctx.translate(18 + i * 26, 30);
ctx.rotate((Math.random() - 0.5) * 0.5);
ctx.fillText(text[i], -8, 0);
ctx.restore();
}
// 模拟网络延迟
setTimeout(() => resolve(canvas.toDataURL("image/png")), 200);
});
}
/** 用户名格式校验:手机号 / 邮箱 / 普通账号(字母开头的字母数字下划线) */
const USERNAME_PATTERNS = [
/^1[3-9]\d{9}$/, // 手机号
/^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/, // 邮箱
/^[A-Za-z][A-Za-z0-9_]{2,19}$/, // 账号
];
/** 计算密码强度:0 弱 / 1 中 / 2 强 */
function getPasswordStrength(pwd: string): number {
if (!pwd) return -1;
let score = 0;
if (pwd.length >= 8) score += 1;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score += 1;
if (/\d/.test(pwd)) score += 1;
if (/[^A-Za-z0-9]/.test(pwd)) score += 1;
if (score <= 2) return 0;
if (score === 3) return 1;
return 2;
}
const STRENGTH_MAP = [
{ text: "弱", color: "#ff4d4f", percent: 33 },
{ text: "中", color: "#faad14", percent: 66 },
{ text: "强", color: "#52c41a", percent: 100 },
];
function LoginPage() {
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const [form] = Form.useForm<LoginFormValues>();
const [loading, setLoading] = useState(false);
const [captchaImage, setCaptchaImage] = useState("");
const [captchaLoading, setCaptchaLoading] = useState(false);
const captchaTextRef = useRef("");
const passwordValue = Form.useWatch("password", form) ?? "";
const strength = getPasswordStrength(passwordValue);
/** 刷新验证码:重新请求生成新的验证码图片 */
const refreshCaptcha = useCallback(async () => {
setCaptchaLoading(true);
try {
const text = generateCaptchaText();
const image = await fetchCaptchaImage(text);
captchaTextRef.current = text;
setCaptchaImage(image);
// 刷新后清空已输入的验证码
form.setFieldValue("captcha", "");
} finally {
setCaptchaLoading(false);
}
}, [form]);
// 首次进入页面生成验证码(延迟到下一帧,避免 effect 中同步 setState 引发级联渲染)
useEffect(() => {
const timer = setTimeout(refreshCaptcha, 0);
return () => clearTimeout(timer);
}, [refreshCaptcha]);
/** 提交登录:先完成全部字段前端校验,再发起登录请求 */
const handleSubmit = async (values: LoginFormValues) => {
// 验证码正确性校验(忽略大小写)
if (
values.captcha.trim().toLowerCase() !==
captchaTextRef.current.toLowerCase()
) {
form.setFields([
{ name: "captcha", errors: ["验证码不正确,请重新输入"] },
]);
refreshCaptcha();
return;
}
setLoading(true);
try {
// 模拟登录请求,实际项目中替换为真实接口
await new Promise((resolve, reject) => {
setTimeout(() => {
if (values.password === "123456") {
reject(new Error("用户名或密码错误"));
} else {
resolve(true);
}
}, 1200);
});
message.success("登录成功,正在跳转...");
navigate("/");
} catch (error) {
message.error(
error instanceof Error ? error.message : "登录失败,请稍后重试",
);
refreshCaptcha();
} finally {
setLoading(false);
}
};
return (
<LoginPageWrapper>
<LoginCard>
<LoginHeader>
<LoginTitle>React CMS</LoginTitle>
<LoginSubtitle>欢迎登录管理系统</LoginSubtitle>
</LoginHeader>
<Form<LoginFormValues>
form={form}
layout="vertical"
size="large"
requiredMark={false}
onFinish={handleSubmit}
autoComplete="off"
>
{/* 用户名:支持手机号/邮箱/账号 */}
<Form.Item
name="username"
label="用户名"
validateFirst
rules={[
{ required: true, whitespace: true, message: "请输入用户名" },
{ min: 3, max: 50, message: "长度需在 3-50 个字符之间" },
{
validator: (_, value: string) => {
if (!value) return Promise.resolve();
const valid = USERNAME_PATTERNS.some((reg) =>
reg.test(value.trim()),
);
return valid
? Promise.resolve()
: Promise.reject(
new Error("请输入正确的手机号、邮箱或账号"),
);
},
},
]}
>
<Input
prefix={
<FieldIcon>
<UserOutlined />
</FieldIcon>
}
placeholder="手机号 / 邮箱 / 账号"
maxLength={50}
allowClear
/>
</Form.Item>
{/* 密码:遮蔽模式 + 明文切换 + 强度提示 */}
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: "请输入密码" },
{ min: 6, max: 20, message: "密码长度需在 6-20 个字符之间" },
]}
>
<Input.Password
prefix={
<FieldIcon>
<LockOutlined />
</FieldIcon>
}
placeholder="请输入密码"
maxLength={20}
visibilityToggle={{ visible: undefined }}
/>
</Form.Item>
{/* 密码强度提示 */}
{strength >= 0 && (
<StrengthBar>
<Progress
percent={STRENGTH_MAP[strength].percent}
showInfo={false}
strokeColor={STRENGTH_MAP[strength].color}
size="small"
/>
<StrengthText color={STRENGTH_MAP[strength].color}>
强度:{STRENGTH_MAP[strength].text}
</StrengthText>
</StrengthBar>
)}
{/* 图形验证码:点击刷新 */}
<Form.Item
name="captcha"
label="验证码"
rules={[
{ required: true, whitespace: true, message: "请输入验证码" },
{ len: 4, message: "验证码为 4 位字符" },
]}
>
<CaptchaRow>
<Input
prefix={
<FieldIcon>
<SafetyOutlined />
</FieldIcon>
}
placeholder="请输入验证码"
maxLength={4}
allowClear
/>
<CaptchaImage
type="button"
title="看不清?点击刷新"
onClick={refreshCaptcha}
disabled={captchaLoading}
>
{captchaImage ? (
<img src={captchaImage} alt="验证码" />
) : (
<ReloadOutlined spin={captchaLoading} />
)}
</CaptchaImage>
</CaptchaRow>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={loading}>
{loading ? "登录中..." : "登 录"}
</Button>
</Form.Item>
</Form>
</LoginCard>
</LoginPageWrapper>
);
}
/** 包装 AntdApp 以支持 message 上下文 */
export default function Login() {
return (
<AntdApp>
<LoginPage />
</AntdApp>
);
}