基于vue(axios)创建一个用户登录界面

396 阅读6分钟

一、简述

该登录界面使用 Vue 3 的 Composition API 构建,并使用了 Vant UI 组件库。

核心功能与原理

  1. 模式切换
    通过 state.type 控制显示登录或注册表单,点击 "还没有账号?立即注册" 或 "已有账号?立即登录" 可切换模式。

  2. 表单验证

    • 使用 Vant 的 van-form 和 van-field 组件实现表单布局。
    • 通过 rules 属性配置必填项验证(用户名、密码、验证码)。
  3. 验证码机制

    • 使用 vue-img-verify 组件生成图形验证码。
    • 用户输入后,通过 state.verify.toLowerCase() !== state.imgCode.toLowerCase() 验证,忽略大小写。
  4. 登录逻辑

    • 点击登录按钮时,触发 onSubmit 方法。

    • 验证通过后,模拟登录成功:

      • 生成临时 token(mock_token_时间戳)。
      • 使用 setLocal 存储 token 到本地。
      • 显示成功提示并跳转至首页。
  5. 注册逻辑

    • 点击注册按钮时,同样触发 onSubmit 方法。

    • 验证通过后,调用 register API(假设为真实接口):

      • 密码使用 MD5 加密。
      • 注册成功后切换到登录模式。

代码结构与交互流程

  1. 模板部分

    • 使用 v-if 和 v-else 根据 state.type 切换显示登录 / 注册表单。
    • 表单字段通过 v-model 绑定到 state 中的响应式数据。
  2. 脚本部分

    • 使用 reactive 创建响应式状态 state,存储表单数据和模式。
    • toggle 方法切换登录 / 注册模式,并清空验证码。
    • onSubmit 方法处理表单提交,验证验证码并执行登录 / 注册逻辑。
  3. 样式部分

    • 使用 Less 编写样式,包含响应式设计(@media)。
    • 自定义 Vant 组件样式(如 ::v-deep 选择器)。

核心技术点

  1. 响应式原理

    • 通过 Vue 3 的 reactive 和 ref 实现数据响应式。
    • 表单数据变化自动更新 UI。
  2. 组件通信

    • 通过 ref 引用 vue-img-verify 组件,获取验证码值。
  3. 状态管理

    • 使用 setLocal(可能是封装的 localStorage 工具)存储认证状态。
  4. UI 交互优化

    • 表单验证错误提示(如 "验证码错误")。
    • 按钮悬停效果和点击反馈。
    • 移动端适配(通过媒体查询)。

二、npm install

在终端输入指令安装相关的库

npm install vant
npm install axios qs
npm install -D less 
npm install js-md5  

三、目录结构

image.png

image.png

login.vue、TopBar.vue和VueImageVerify.vue放在components文件夹下。

四、代码

login.vue

<template>
  <div class="login">
    <!-- 头部导航栏 -->
    <topbar 
      :name="state.type === 'login' ? '登录' : '注册'" 
      :back="'/home'" 
    ></topbar>

    <!-- 登录模块 -->
    <div v-if="state.type === 'login'" class="login-body login">
      <van-form @submit="onSubmit">
        <!-- 用户名输入框 -->
        <van-field
          v-model="state.username"
          name="username"
          label="用户名"
          placeholder="请输入用户名"
          :rules="[{ required: true, message: '用户名不能为空' }]"
        />

        <!-- 密码输入框 -->
        <van-field
          v-model="state.password"
          type="password"
          name="password"
          label="密码"
          placeholder="请输入密码"
          :rules="[{ required: true, message: '密码不能为空' }]"
        />

        <!-- 验证码模块 -->
        <van-field
          center
          clearable
          label="验证码"
          placeholder="请输入验证码"
          v-model="state.verify"
        >
          <template #button>
            <vue-img-verify ref="verifyRef" />
          </template>
        </van-field>

        <!-- 操作按钮区域 -->
        <div class="btn-area">
          <div class="link" @click="toggle('register')">
            还没有账号?立即注册
          </div>
          <van-button 
            round 
            block 
            color="#1baeae" 
            native-type="submit"
          >
            登录
          </van-button>
        </div>
      </van-form>
    </div>

    <!-- 注册模块 -->
    <div v-else class="login-body register">
      <van-form @submit="onSubmit">
        <van-field
          v-model="state.username1"
          name="username1"
          label="用户名"
          placeholder="请输入用户名"
          :rules="[{ required: true, message: '用户名不能为空' }]"
        />

        <van-field
          v-model="state.password1"
          type="password"
          name="password1"
          label="密码"
          placeholder="请输入密码"
          :rules="[{ required: true, message: '密码不能为空' }]"
        />

        <van-field
          center
          clearable
          label="验证码"
          placeholder="请输入验证码"
          v-model="state.verify"
        >
          <template #button>
            <vue-img-verify ref="verifyRef" />
          </template>
        </van-field>

        <div class="btn-area">
          <div class="link" @click="toggle('login')">
            已有账号?立即登录
          </div>
          <van-button 
            round 
            block 
            color="#1baeae" 
            native-type="submit"
          >
            注册
          </van-button>
        </div>
      </van-form>
    </div>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue';
import topbar from '@/components/TopBar.vue';
import vueImgVerify from '@/components/VueImageVerify.vue';
import { setLocal } from '@/common/js/utils';
import { login, register } from '@/api/user';
import md5 from 'js-md5';
import { showSuccessToast, showFailToast } from 'vant';

const verifyRef = ref(null);
const state = reactive({
  username: '',
  password: '',
  username1: '',
  password1: '',
  type: 'login', // 初始为登录模式
  imgCode: '',
  verify: ''
});

// 切换登录/注册模式
const toggle = (mode) => {
  state.type = mode;
  state.verify = ''; // 切换时清空验证码
};

// 表单提交处理
const onSubmit = async (values) => {
  // 获取验证码
  state.imgCode = verifyRef.value?.state?.imgCode || '';
  
  // 验证验证码
  if (state.verify.toLowerCase() !== state.imgCode.toLowerCase()) {
    showFailToast('验证码错误');
    return;
  }

  try {
    if (state.type === 'login') {
      // 登录逻辑
      const { data } = await login({
        loginName: values.username,
        passwordMd5: md5(values.password)
      });
      
            // 检查响应结构
      console.log('登录响应:', response);
      
      // 根据实际响应结构获取 token
      const token = response.data || response; // 假设直接返回 token 或 { data: token }
      setLocal('token', token);
      showSuccessToast('登录成功');
      window.location.href = '/'; // 跳转首页
    } else {
      // 注册逻辑
      await register({
        loginName: values.username1,
        password: md5(values.password1)
      });
      showSuccessToast('注册成功,请登录');
      toggle('login'); // 注册成功后切换到登录模式
    }
  } catch (error) {
    console.error('请求失败:', error);
    showFailToast('操作失败,请重试');
  }
};
</script>

<style lang="less" scoped>
.login {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  background-color: #f5f7fa;
  padding: 20px 15px;
  font-family: 'PingFang SC', sans-serif;
}

.topbar {
  width: 100%;
  max-width: 400px;
  margin-bottom: 30px;
}

.login-body {
  width: 100%;
  max-width: 400px;
  background-color: #fff;
  border-radius: 12px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  padding: 32px 24px;
  margin-top: 20px;
}

.van-form {
  width: 100%;
}

.van-field {
  margin-bottom: 20px;

  ::v-deep .van-field__label {
    font-size: 14px;
    color: #333;
    font-weight: 500;
    margin-bottom: 8px;
  }

  ::v-deep .van-field__control {
    height: 48px;
    padding: 0 16px;
    font-size: 14px;
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    background-color: #fff;

    &:focus {
      border-color: #1baeae;
      box-shadow: 0 0 0 2px rgba(27, 174, 174, 0.2);
    }
  }
}

.van-field--center {
  display: flex;
  align-items: center;
  gap: 12px;

  .vue-img-verify {
    width: 180px;
    height: 48px;
  }
}

.btn-area {
  margin-top: 32px;
  text-align: center;
}

.link {
  font-size: 14px;
  color: #1989fa;
  margin-bottom: 16px;
  cursor: pointer;

  &:hover {
    text-decoration: underline;
  }
}

.van-button {
  height: 52px;
  font-size: 16px;
  background-color: #1baeae;
  border: none;
  border-radius: 26px;
  transition: background-color 0.2s ease;

  &:hover {
    background-color: #169c9c;
  }
}

// 移动端适配
@media (max-width: 480px) {
  .login-body {
    padding: 24px 16px;
  }

  .van-field--center {
    .vue-img-verify {
      width: 140px;
    }
  }
}
</style>

TopBar.vue


<template>
  <header class="simple-header van-hairline--bottom">
    <i v-if="!isback" class="nbicon nbfanhui" @click="goBack"></i>
    <i v-else>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</i>
    <div class="simple-header-name">{{ name }}</div>
    <i class="nbicon nbmore"></i>
  </header>
  <div class="block" />
</template>
  
<script setup>
  import { ref } from 'vue'
  import { useRouter } from 'vue-router'

  const props = defineProps({
    name: String,
    back: String,
    noback: Boolean
  });
  const isback = ref(props.noback)
  const router = useRouter()
  const goBack = () => {
    if (!props.back) {
      router.go(-1)
    } else {
      router.push({ path: props.back })
    }
  }
</script>
  
<style lang="less" scoped>
  @import '../common/style/mixin';
  .simple-header {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 10000;
    .fj();
    .wh(100%, 44px);
    line-height: 44px;
    padding: 0 10px;
    .boxSizing();
    color: #252525;
    background: #fff;
    .simple-header-name {
      font-size: 14px;
    }
  }
  .block {
    height: 44px;
  }
</style>

VueImageVerify.vue

<template>
    <div class="img-verify">
      <canvas ref="verify" :width="state.width" :height="state.height" @click="handleDraw"></canvas>
    </div>
  </template>
  
  <script setup>
  import { reactive, onMounted, ref } from 'vue'
  const verify = ref(null)
  const state = reactive({
    pool: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', // 字符串
    width: 120,
    height: 40,
    imgCode: ''
  })
  defineExpose({ state })
  onMounted(() => {
    // 初始化绘制图片验证码
    state.imgCode = draw()
  })
  
  // 点击图片重新绘制
  const handleDraw = () => {
    state.imgCode = draw()
  }
  
  // 随机数
  const randomNum = (min, max) => {
    return parseInt(Math.random() * (max - min) + min)
  }
  // 随机颜色
  const randomColor = (min, max) => {
    const r = randomNum(min, max)
    const g = randomNum(min, max)
    const b = randomNum(min, max)
    return `rgb(${r},${g},${b})`
  }
  
  // 绘制图片
  const draw = () => {
    // 3.填充背景颜色,背景颜色要浅一点
    const ctx = verify.value.getContext('2d')
    // 填充颜色
    ctx.fillStyle = randomColor(180, 230)
    // 填充的位置
    ctx.fillRect(0, 0, state.width, state.height)
    // 定义paramText
    let imgCode = ''
    // 4.随机产生字符串,并且随机旋转
    for (let i = 0; i < 4; i++) {
      // 随机的四个字
      const text = state.pool[randomNum(0, state.pool.length)]
      imgCode += text
      // 随机的字体大小
      const fontSize = randomNum(18, 40)
      // 字体随机的旋转角度
      const deg = randomNum(-30, 30)
      /*
        * 绘制文字并让四个文字在不同的位置显示的思路 :
        * 1、定义字体
        * 2、定义对齐方式
        * 3、填充不同的颜色
        * 4、保存当前的状态(以防止以上的状态受影响)
        * 5、平移translate()
        * 6、旋转 rotate()
        * 7、填充文字
        * 8、restore出栈
        * */
      ctx.font = fontSize + 'px Simhei'
      ctx.textBaseline = 'top'
      ctx.fillStyle = randomColor(80, 150)
      /*
        * save() 方法把当前状态的一份拷贝压入到一个保存图像状态的栈中。
        * 这就允许您临时地改变图像状态,
        * 然后,通过调用 restore() 来恢复以前的值。
        * save是入栈,restore是出栈。
        * 用来保存Canvas的状态。save之后,可以调用Canvas的平移、放缩、旋转、错切、裁剪等操作。 restore:用来恢复Canvas之前保存的状态。防止save后对Canvas执行的操作对后续的绘制有影响。
        *
        * */
      ctx.save()
      ctx.translate(30 * i + 15, 15)
      ctx.rotate((deg * Math.PI) / 180)
      // fillText() 方法在画布上绘制填色的文本。文本的默认颜色是黑色。
      // 请使用 font 属性来定义字体和字号,并使用 fillStyle 属性以另一种颜色/渐变来渲染文本。
      // context.fillText(text,x,y,maxWidth);
      ctx.fillText(text, -15 + 5, -15)
      ctx.restore()
    }
    // 5.随机产生5条干扰线,干扰线的颜色要浅一点
    for (let i = 0; i < 5; i++) {
      ctx.beginPath()
      ctx.moveTo(randomNum(0, state.width), randomNum(0, state.height))
      ctx.lineTo(randomNum(0, state.width), randomNum(0, state.height))
      ctx.strokeStyle = randomColor(180, 230)
      ctx.closePath()
      ctx.stroke()
    }
    // 6.随机产生40个干扰的小点
    for (let i = 0; i < 40; i++) {
      ctx.beginPath()
      ctx.arc(randomNum(0, state.width), randomNum(0, state.height), 1, 0, 2 * Math.PI)
      ctx.closePath()
      ctx.fillStyle = randomColor(150, 200)
      ctx.fill()
    }
    return imgCode
  }
  </script>
  <style>
  .img-verify canvas {
    cursor: pointer;
  }
  </style>

index.js

import { http } from './http';
import { config } from './config';

const { default_headers } = config;

const request = (option) => {
  const { url, method, params, data, headersType, responseType } = option;
  return http({
    url: url,
    method,
    params,
    data,
    responseType: responseType,
    headers: {
      'Content-Type': headersType || default_headers
    }
  });
};

export default {
  get: (option) => {
    return request({ method: 'get', ...option });
  },
  post: (option) => {
    return request({ method: 'post', ...option });
  },
  delete: (option) => {
    return request({ method: 'delete', ...option });
  },
  put: (option) => {
    return request({ method: 'put', ...option });
  }
};

http.js

import axios from 'axios';
import qs from 'qs';
import { config } from './config';
import { showFailToast  } from 'vant';
const { result_code, base_url } = config;
export const PATH_URL = base_url[import.meta.env.VITE_API_BASEPATH];

// 创建axios实例
const http = axios.create({
  baseURL: PATH_URL, // 设置基本URL,用于所有请求的前缀
  timeout: config.request_timeout // 请求超时时间
});


http.interceptors.request.use(
  (config) => {
    if (
      config.method === 'post' &&
      config.headers['Content-Type'] === 'application/x-www-form-urlencoded'
    ) {
      config.data = qs.stringify(config.data);
    }
      config.headers['Token'] = localStorage.getItem('token') || '';
    // Encode query parameters
    if (config.method === 'get' && config.params) {
      let url = config.url;
      url += '?';
      const keys = Object.keys(config.params);
      for (const key of keys) {
        if (config.params[key] !== undefined && config.params[key] !== null) {
          url += `${key}=${encodeURIComponent(config.params[key])}&`;
        }
      }
      url = url.substring(0, url.length - 1);
      config.params = {};
      config.url = url;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

http.interceptors.response.use(
  (response) => {
    if (response.config.responseType === 'blob') {
      return response;
    } else if (response.data.resultCode === result_code) {
      return response.data;
    } else {
      showFailToast(response.data.message);
      if(response.data.resultCode === 416) {
        localStorage.clear()
        window.location.href = '/login'
      }
    }
  },
  (error) => {
    showFailToast(error.message)
    return Promise.reject(error);
  }
);

export { http };

config.js

const config = {
    /**
     * api请求基础路径
     */
    base_url: {
      // 开发环境接口前缀
      base: 'http://vue3shopapi.liangdaye.cn/api/v1',
  
      // 打包开发环境接口前缀
      dev: 'http://vue3shopapi.liangdaye.cn/api/v1',
  
      // 打包生产环境接口前缀
      pro: 'http://vue3shopapi.liangdaye.cn/api/v1',
  
      // 打包测试环境接口前缀
      test: 'http://vue3shopapi.liangdaye.cn/api/v1'
    },
  
    /**
     * 接口成功返回状态码
     */
    result_code: 200,
  
    /**
     * 接口请求超时时间
     */
    request_timeout: 60000,
  
    /**
     * 默认接口请求类型
     * 可选值:application/x-www-form-urlencoded multipart/form-data
     */
    default_headers: 'application/json'
  };
  
  export { config };
  

user.js

import axios from '../config/axios/index'

export function login(params) {
    console.log("parmas...:", params)
  return axios.post({ url: '/user/login', data: params});
}
export function register(params) {
  return axios.post({ url: '/user/register', data: params});
}
export function getUserInfo() {
  return axios.get({ url: '/user/info'});
}

在main.js中加入以下代码,用于注册相关组件。

   .use(Field)  // 注册 Field 组件
   .use(Form)   // 注册 Form 组件
   .use(Button); // 注册 Button 组件

五、效果展示

image.png

image.png