Skip to content

登录页布局 ​

登录页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>
  );
}