登录模块

698 阅读6分钟

登录模块

路由与组件

目标:登录组件在书写一级路由的时候已经准备,添加路由链接跳转到登录页即可。

  • 添加跳转链接:src/components/app-topnav.vue
<li><RouterLink to="/login">请先登录</RouterLink></li>

结构布局-CV

image.png

静态结构参考代码 - CV

src\views\Login\index.vue

<script setup lang="ts">
//
</script>

<template>
  <div class="page-login">
    <!-- 1. 头部 -->
    <header class="login-header">
      <div class="container">
        <h1 class="logo"><RouterLink to="/">小兔鲜</RouterLink></h1>
        <h3 class="sub"><slot>欢迎登录</slot></h3>
        <RouterLink class="entry" to="/">
          进入网站首页
          <i class="iconfont icon-angle-right"></i>
          <i class="iconfont icon-angle-right"></i>
        </RouterLink>
      </div>
    </header>
    <!-- 2. 主体登录区域 -->
    <section class="login-section">
      <div class="wrapper">
        <nav>
          <a href="javascript:;">账户登录</a>
        </nav>
        <LoginForm />
      </div>
    </section>
    <!-- 3. 底部 -->
    <footer class="login-footer">
      <div class="container">
        <p>
          <a href="javascript:;">关于我们</a>
          <a href="javascript:;">帮助中心</a>
          <a href="javascript:;">售后服务</a>
          <a href="javascript:;">配送与验收</a>
          <a href="javascript:;">商务合作</a>
          <a href="javascript:;">搜索推荐</a>
          <a href="javascript:;">友情链接</a>
        </p>
        <p>CopyRight &copy; 小兔鲜儿</p>
      </div>
    </footer>
  </div>
</template>

<style scoped lang="less">
// 头部样式
.login-header {
  background: #fff;
  border-bottom: 1px solid #e4e4e4;
  .container {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
  }
  .logo {
    width: 200px;
    a {
      display: block;
      height: 132px;
      width: 100%;
      text-indent: -9999px;
      background: url(@/assets/images/logo.png) no-repeat center 18px / contain;
    }
  }
  .sub {
    flex: 1;
    font-size: 24px;
    font-weight: normal;
    margin-bottom: 38px;
    margin-left: 20px;
    color: #666;
  }
  .entry {
    width: 120px;
    margin-bottom: 38px;
    font-size: 16px;
    i {
      font-size: 14px;
      color: @xtxColor;
      letter-spacing: -5px;
    }
  }
}
// 主体样式
.login-section {
  background: url(@/assets/images/login-bg.png) no-repeat center / cover;
  height: 488px;
  position: relative;
  .wrapper {
    width: 380px;
    background: #fff;
    position: absolute;
    left: 50%;
    top: 54px;
    transform: translate3d(100px, 0, 0);
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);
    nav {
      font-size: 14px;
      height: 55px;
      margin-bottom: 20px;
      border-bottom: 1px solid #f5f5f5;
      display: flex;
      padding: 0 40px;
      text-align: right;
      align-items: center;
      a {
        flex: 1;
        line-height: 1;
        display: inline-block;
        font-size: 18px;
        position: relative;
        text-align: center;
      }
    }
  }
}

// 底部样式
.login-footer {
  padding: 30px 0 50px;
  background: #fff;
  p {
    text-align: center;
    color: #999;
    padding-top: 20px;
    a {
      line-height: 1;
      padding: 0 10px;
      color: #999;
      display: inline-block;
      ~ a {
        border-left: 1px solid #ccc;
      }
    }
  }
}
</style>

表单布局-CV

目标: 实现登录页面的整体大结构布局

  • 新建表单组件

src/views/Login/components/login-form.vue

<script setup lang="ts">
//
</script>

<template>
  <div class="account-box">
    <div class="form">
      <div class="form-item">
        <div class="input">
          <i class="iconfont icon-user"></i>
          <input type="text" placeholder="请输入用户名或手机号" />
        </div>
      </div>
      <div class="form-item">
        <div class="input">
          <i class="iconfont icon-lock"></i>
          <input type="password" placeholder="请输入密码" />
        </div>
      </div>
      <div class="form-item">
        <div class="agree">
          <XtxCheckBox />
          <span>我已同意</span>
          <a href="javascript:;">《隐私条款》</a>
          <span></span>
          <a href="javascript:;">《服务条款》</a>
        </div>
      </div>
      <a href="javascript:;" class="btn">登录</a>
    </div>
    <div class="action">
      <img
        src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png"
        alt=""
      />
      <div class="url">
        <a href="javascript:;">忘记密码</a>
        <a href="javascript:;">免费注册</a>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
// 账号容器
.account-box {
  .toggle {
    padding: 15px 40px;
    text-align: right;
    a {
      color: @xtxColor;
      i {
        font-size: 14px;
      }
    }
  }
  .form {
    padding: 0 20px;
    &-item {
      margin-bottom: 28px;
      .input {
        position: relative;
        height: 36px;
        > i {
          width: 34px;
          height: 34px;
          background: #cfcdcd;
          color: #fff;
          position: absolute;
          left: 1px;
          top: 1px;
          text-align: center;
          line-height: 34px;
          font-size: 18px;
        }
        input {
          padding-left: 44px;
          border: 1px solid #cfcdcd;
          height: 36px;
          line-height: 36px;
          width: 100%;
          &.error {
            border-color: @priceColor;
          }
          &.active,
          &:focus {
            border-color: @xtxColor;
          }
        }
        .code {
          position: absolute;
          right: 1px;
          top: 1px;
          text-align: center;
          line-height: 34px;
          font-size: 14px;
          background: #f5f5f5;
          color: #666;
          width: 90px;
          height: 34px;
          cursor: pointer;
        }
      }
      > .error {
        position: absolute;
        font-size: 12px;
        line-height: 28px;
        color: @priceColor;
        i {
          font-size: 14px;
          margin-right: 2px;
        }
      }
    }
    .agree {
      a {
        color: #069;
      }
    }
    .btn {
      display: block;
      width: 100%;
      height: 40px;
      color: #fff;
      text-align: center;
      line-height: 40px;
      background: @xtxColor;
      &.disabled {
        background: #cfcdcd;
      }
    }
  }
  .action {
    padding: 20px 40px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    .url {
      a {
        color: #999;
        margin-left: 10px;
      }
    }
  }
}
</style>

登录模块入口组件 src/views/Login/index.vue

在主体登录区域引入并使用表单组件。

<script setup lang="ts">
import LoginForm from "./components/login-form.vue";
</script>

<template>
  <div class="page-login">
    <!-- 1. 头部 -->
    <header class="login-header">
      ....
    </header>
    <!-- 2. 主体登录区域 -->
    <section class="login-section">
      <div class="wrapper">
        <nav>
          <a href="javascript:;">账户登录</a>
        </nav>
        <LoginForm />
      </div>
    </section>
    <!-- 3. 底部 -->
    <footer class="login-footer">
      ....
    </footer>
  </div>
</template>

消息提示组件 和 复选框组件

XtxCheckBox 组件使用

<script setup lang="ts">
import { ref } from "vue";

const isAgree = ref(false);
</script>


<XtxCheckBox v-model="isAgree">我已同意</XtxCheckBox>

Message 组件使用

image.png

<script setup lang="ts">
import { message } from "@/components/XtxUI";

message({ type: "success", text: "登录成功", time: 2000});
</script>

登录前表单校验

目标:校验之前我们已经实现了很多次,这里弱化验证写法,简单做一个非空检验即可。

参考代码

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message } from '@/components/XtxUI';

const isAgree = ref(false);
const form = reactive({
  account: '',
  password: '',
});

const loginBtn = () => {
  if (!form.account) {
    message({ type: 'error', text: '用户名或手机号不能为空' });
    return;
  }
  if (!form.password) {
    message({ type: 'error', text: '密码不能为空' });
    return;
  }
  if (!isAgree.value) {
    message({ type: 'error', text: '请同意许可' });
    return;
  }
  console.log('通过校验,可以发送请求');
};
</script>


// 📌 绑定 form 响应式数据到表单元素中
<input v-model.trim="form.account" type="text" placeholder="请输入用户名或手机号" />
<input v-model.trim="form.password" type="password" placeholder="请输入密码" />
<XtxCheckBox v-model="isAgree">我已同意</XtxCheckBox>

// 📌 登录按钮
<a href="javascript:;" class="btn" @click="loginBtn">登录</a>

会员 Store 和 类型声明

定义 Store

定义新 Store: src\store\modules\member.ts

import { defineStore } from 'pinia';

export const useMemberStore = defineStore('member', () => {
  // 记得 return
  return {};
});

合并新 Store: src\store\index.ts

export * from './modules/member';

定义类型声明

新建类型声明文件:src\types\modules\member.d.ts

// 类型声明文件

合并类型声明:src\types\index.d.ts

// 统一导出所有类型文件
export * from "./modules/member";

账户登录实现

本节目标: 实现账户名密码登录功能

用户名和密码登录接口

基本信息

Path: /login

Method: POST

接口描述:

登录成功后,后台返回的 token,请在本地保存,并在每次请求接口时在 Header 中携带上。

请求参数

Body

名称类型是否必须默认值备注其他信息
accountstring必须用户名或手机号undefined: ceshi
passwordstring必须密码undefined: 123456

发送登录请求

修改文件:src/store/modules/member.ts

import { http } from '@/utils/request';
import { defineStore } from 'pinia';

export const useMemberStore = defineStore('member', () => {
  // 用户名和密码登录
  const loginAccount = async (data: object) => {
    const res = await http('POST', '/login', data);
    console.log('/login', res.data.result);
  };

  // 记得 return
  return {
    loginAccount,
  };
});

3)登录表单调用

login-form.vue

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message } from '@/components/XtxUI';
+ import { useMemberStore } from '@/store';

const isAgree = ref(false);
const form = reactive({
  account: '',
  password: '',
});

const loginBtn = () => {
  if (!form.account) {
    return message({ type: 'error', text: '用户名或手机号不能为空' });
  }
  if (!form.password) {
    return message({ type: 'error', text: '密码不能为空' });
  }
  if (!isAgree.value) {
    return message({ type: 'error', text: '请同意许可' });
  }
+  // 发送登录请求
+  const member = useMemberStore();
+  member.loginAccount(form);
};
</script>

定义TS类型

类型文件:src\types\modules\member.d.ts

export interface Profile {
  id: string;
  account: string;
  mobile: string;
  token: string;
  avatar: string;
  nickname: string;
  gender: string;
  birthday: string;
  cityCode: string;
  provinceCode: string;
  profession: string;
}

应用类型:

import { message } from '@/components/XtxUI';
import type { Profile } from '@/types';
import { http } from '@/utils/request';
import { defineStore } from 'pinia';
import { ref } from 'vue';
// 🎯非 vue 组件,需导入路由实例,不能使用 useRouter()
import router from '@/router';

export const useMemberStore = defineStore('member', () => {
  // 用户信息
  const profile = ref<Profile>();
  // 用户名和密码登录
  const loginAccount = async (data: object) => {
    // 0. 发送登录请求
    const res = await http<Profile>('POST', '/login', data);
    // 1. 保存用户信息
    profile.value = res.data.result;
    // 2. 请求成功给用户提示
    message({ type: 'success', text: '登录成功' });
    // 3. 跳转页面
    router.push('/');
  };

  // 记得 return
  return {
    loginAccount,
    profile,
  };
});

持久化存储

登录成功后,把用户信息保存到本地。

  {
    persist: {
      // 持久化存储 profile
      paths: ['profile'],
    },
  }

用户信息渲染

用户信息渲染: Layout/components/app-topnav.vue

<script setup lang="ts">
import { useMemberStore } from '@/store';
import { storeToRefs } from 'pinia';

const member = useMemberStore();
// storeToRefs 解构出来的数据还能保持响应式
const { profile } = storeToRefs(member);
</script>

<template>
  <nav class="app-topnav">
    <div class="container">
      <ul>
        <template v-if="profile">
          <li>
            <a href="javascript:;">
              <i class="iconfont icon-user"></i>
              {{ profile.nickname || profile.account || profile.mobile }}
            </a>
          </li>
          <li><a href="javascript:;">退出登录</a></li>
        </template>
        <template v-else>
          <li><RouterLink to="/login">请先登录</RouterLink></li>
          <li><a href="javascript:;">免费注册</a></li>
        </template>
        ...
      </ul>
    </div>
  </nav>
</template>

退出登录实现

本节目标: 实现账户名密码登录功能

  1. 提供 actions, 清空用户数据
// 退出登录
const logout = async () => {
  // 1. 清理 Pinia 用户信息
  profile.value = undefined;
  // 2. 提示用户
  message({ type: 'success', text: '退出成功' });
  // 3. 跳转页面
  router.push('/login');
};

// 记得 return
  1. 页面中调用 src\views\Layout\components\app-topnav.vue
<a @click="member.logout()" href="javascript:;">退出登录</a>

优化:登录成功页面回跳

携带路由完整路径:src\views\Layout\components\app-topnav.vue

- <RouterLink to="/login">请先登录</RouterLink>

+ <RouterLink :to="`/login?target=${$route.fullPath}`">请先登录</RouterLink>

登录成功后回跳:src\store\modules\member.ts

    // 用户名和密码登录
    const loginAccount = async (data: object) => {
      // 0. 发送登录请求
      const res = await http<Profile>('POST', '/login', data);
      // 1. 保存用户信息
      profile.value = res.data.result;
      // 2. 请求成功给用户提示
      message({ type: 'success', text: '登录成功' });
+      // 🐛 在非 .vue 组件中 useRoute() 返回 undefined,没法获取当前路由信息
+      // 📌 解决方案,通过 router 路由实例 currentRoute 获取
+      const { target = '/' } = router.currentRoute.value.query;
      // 3. 跳转页面
-      router.push('/');
+      router.push(target as string);
    };

请求拦截器和响应拦截器

请求拦截器 - 请求成功携带 token

完善请求拦截器 utils/request.ts

// 官方说明:https://pinia.vuejs.org/core-concepts/outside-component-usage.html
// 中文翻译:https://fedocs.gitee.io/docs-pinia-zh/zh/core-concepts/outside-component-usage.html
// ❌ 非组件中,Pinia 常见错误写法
// const { member } = useStore();

// 添加请求拦截器
instance.interceptors.request.use(
  function (config) {
    // 在发送请求之前做些什么
    // ✅ 在组件外,哪里使用,写哪里前面
    const member = useMemberStore();
    // 1. 获取 token
    const token = member.profile?.token;
    // 2. 如果有 token
    if (token) {
      // 3. 请求头携带 token 信息
      config.headers = {
        ...config.headers,  // 保留原来的 headers 信息
        Authorization: `Bearer ${token}`,
      };
    }
    return config;
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  }
);

响应拦截器 - 请求失败提示用户

响应拦截器,添加错误提示

import { message } from '@/components/Message'

// 添加响应拦截器
instance.interceptors.response.use(
 function (response) {
    // 如果请求成功成功 2xx 就直接返回 data 中的数据
   return response
 },
 function (error) {
    // 对响应错误做点什么
    if (error.code === 'ERR_NETWORK') {
      // 提示无网络错误
      message({ type: 'error', text: '亲,换个网络试试~' });
    } else {
      // 提示后端响应的错误
      message({ type: 'error', text: error.response.data.message });
    }
    // 控制台显示错误
    return Promise.reject(error);
})

QQ三方登录 - 前置环境和交互

image.png

登录简要流程梳理

本节目标: 了解第三方登录的实现流程

  1. 在登录页面,QQ登录按钮处,添加个超链接,赋予其打开QQ登录页面功能
  2. 回跳的页面得到QQ给的唯一标识 openId,根据openId去后台查询是否已经绑定过账户
    • 如果绑定过,完成登录
    • 没有绑定过
      • 有账号的,绑定手机号,即为登录
      • 没账号的,完善账户信息,注册后登录
  3. 登录成功后,跳转首页或者来源页面

申请流程(运维)

1)参考文档

  1. 准备工作(opens new window)
  2. QQ互联JS_SDK(opens new window)

2)大概步骤

  1. 准备一个已经备案的网站需要有 QQ 登录的逻辑(登录页面,回跳页面)
  2. 然后在 QQ 互联上进行认证,并且审核通过
  3. QQ 互联上创建应用,应用需要域名,备案号,回调地址
  4. 等待人工审核,审核通过会得到 应用ID 回调地址 , 应用key 后端使用。
  5. 帮大家申请的结果如下:
# 测试用 appid 
# 100556005

# 测试用 redirect_uri
# http://www.corho.com:8080/#/login/callback

常见疑问❓

  1. 这个申请工作一般由谁去做?
    • 公司的运维 (负责管理公司账号的人)
  2. 申请下来的 id,应用 key,回调地址 uri ,前端在用的时候能改吗?
    1. 前端不能修改,否则无效,运维人员修改也要腾讯审核通过后才能使用。
    2. 回调地址 uri 的包含四部分: 1. 域名,2. 端口号 3. 哈希路由模式 4. 路由地址 都必须完全一致,否则不能展示。
  3. 🚨 访问 www.corho.com:8080/ 看不到内容?
    1. 修改脚手架 vite.config.ts 配置。
    2. 修改电脑的 host 文件,访问本地服务器。

QQ三方登录-电脑环境设置🚨

目标:浏览器访问 www.corho.com:8080/#/ 地址,看到开发的本地 Vue 项目。

核心步骤

  1. 修改脚手架 vite.config.ts 配置。
  2. 修改电脑的 host 文件。

第一步:修改 vite 配置

修改 vite.config.ts 文件:

export default defineConfig({
  // 配置开发服务器
  server: {
    // QQ三方登录的回调uri为:http://www.corho.com:8080/#/login/callback
    // vite 中配置: www.corho.com:8080
    host: "www.corho.com",
    port: 8080,
    // 其他有价值的配置项
    open: true, // 帮我们打开浏览器
  },
  ...
});

第二步:修改 host 文件

windows 系统

🔔其他情况:

  • 如果没有 hosts 文件,请新建 hosts 文件(无后缀名)
  • 修改电脑配置,需要先退出 360 或 各种管家 各种 杀毒软件
  • 如果修改 hosts 文件有弹窗警告,点击信任(因为这是我们自己进行的安全操作)
1. 找到 C:\Windows\System32\drivers\etc 下 hosts 文件
2. 在文件中加入  127.0.0.1    www.corho.com
3. 保存即可

# 如果提示没有权限
1. 将 hosts 文件移到其他位置,然后进行修改,确认保存。
2. 将修改后的 hosts 文件替换 c 盘文件

mac OS 系统

1. 打开命令行窗口
2. 输入:sudo vim /etc/hosts
3. 按下:i 键
4. 输入:127.0.0.1       www.corho.com
5. 按下:esc
6. 按下:shift + :
7. 输入:wq 回车即可

关键步骤验证

📌步骤验证:浏览器访问 www.corho.com:8080/#/ 能看到自己开发的 Vue3 项目表示成功。

QQ授权登录实现

image.png

按钮跳转实现

1)在index.html 开发需要的添加 sdk.js 文件导入。

<script src="http://connect.qq.com/qc_jssdk.js" data-appid="100556005" data-redirecturi="http://www.corho.com:8080/#/login/callback"></script>

2)在 src/views/login/components/login-form.vue 给图片套上跳转链接。

      <a
        href="https://graph.qq.com/oauth2.0/authorize?response_type=token&scope=all&client_id=100556005&redirect_uri=http%3A%2F%2Fwww.corho.com%3A8080%2F%23%2Flogin%2Fcallback"
      >
        <img
          src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png"
          alt=""
        />
      </a>

3)点击QQ登录按钮,点击后新窗口打开登录页面

image.png

小知识补充:

// 这是什么东西,怎么看起来像乱码?
http%3A%2F%2Fwww.corho.com%3A8080%2F%23%2Flogin%2Fcallback
http://www.corho.com:8080/#/login/callback

// 解码
decodeURIComponent('http%3A%2F%2Fwww.corho.com%3A8080%2F%23%2Flogin%2Fcallback')
// 解码结果:'http://www.corho.com:8080/#/login/callback'

// 转码
encodeURIComponent('http://www.corho.com:8080/#/login/callback')
// 转码结果:'http%3A%2F%2Fwww.corho.com%3A8080%2F%23%2Flogin%2Fcallback'

为了更方便维护

<script>
const client_id = '100556005';
const redirect_uri = encodeURIComponent(
  'http://www.corho.com:8080/#/login/callback'
);
const qqLoginUrl = `https://graph.qq.com/oauth2.0/authorize?response_type=token&scope=all&client_id=${client_id}&redirect_uri=${redirect_uri}`;
</script>


      <a :href="qqLoginUrl">
        <img
          src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png"
          alt=""
        />
      </a>

回跳页面路由准备

前面的配置完成了:www.corho.com:8080/#/

接下来在完善:www.corho.com:8080/#/login/cal…

配置路由和组件

新建组件:views/Login/callback.vue

<script setup lang="ts">
//
</script>

<template>
  <h1>三方登录的回跳页面</h1>
</template>

配置路由:

2)绑定路由 (一级路由)

{
  path: '/login/callback',
  component: () => import('@/views/Login/callback.vue')
},

📌步骤验证:www.corho.com:8080/#/login/cal… 看到回调页面组件。

🚨常见错误:没有使用 hash 哈希路由模式,无法识别 # 哈希部分路径,请检查并修正。

QQ互联核心 API

官方文档

项目中需要用到的 3 个 API

  • QC.Login.check
    • 检查用户是否登录
  • QC.api("get_user_info").success
    • 获取信息
  • QC.Login.getMe
    • 获取 QQ 用户唯一标识 openId

ESlint 添全局变量

  • 注意:由于项目开启了 eslint 检查,需要在 .eslintrc.cjs 添加QC 全局变量。
// eslintrc.cjs

module.exports = {
  ...
  // 全局变量
  globals: {
    QC: true,
  },
}

TS 类型声明文件

  • 注意:由于是 TS 开发,需要在 env.d.ts 添加 QC 类型声明。
// env.d.ts

// QC 类型声明 - QQ 登录模块
declare namespace QC {
  const Login: {
    // QC.Login.check()
    check: () => boolean;
    // QC.Login.getMe((openId) => {
    //   console.log("获取QQ用户openId", openId);
    // });
    getMe: (callback: (openId: string) => void) => void;
  };
  // QC.api("get_user_info").success((res: unknown) => {
  //   console.log("获取QQ用户资料", res);
  // });
  function api(s: string): {
    success: (res: unknown) => void;
  };
}

测试 3 个 API

<script setup lang="ts">
// 1. 检查用户是否已登录
if (QC.Login.check()) {
  // 2. 获取用户资料
  QC.api("get_user_info").success((res: any) => {
    console.log("😀获取用户资料", res);
  });
  // 3. 获取 QQ 用户唯一标识 openId
  QC.Login.getMe((openId) => {
    console.log("🗝️openId", openId);
  });
}
</script>

<template>
  <h1>callback-QQ登录回跳页面测试</h1>
</template>

回跳组件静态结构

目标:准备静态结果,渲染切换效果。

image.png

静态结构

  • 复制组件静态结构:views/Login/callback.vue

  • 温馨提示:头部和底部和登录页相同,自己完成 LoginHeader 头部组件和 LoginFooter 底部组件的抽离。

...

<template>
  <LoginHeader>联合登录</LoginHeader>
  <section class="container">
    <nav class="tab">
      <a
        href="javascript:;"
        class="active" 
      >
        <i class="iconfont icon-bind" />
        <span>已有小兔鲜账号,请绑定手机</span>
      </a>
      <a
        href="javascript:;"
      >
        <i class="iconfont icon-edit" />
        <span>没有小兔鲜账号,请完善资料</span>
      </a>
    </nav>
    <div class="tab-content">
      <!-- 内容 -->  
    </div>
  </section>
  <LoginFooter />
</template>

<style scoped lang='less'>
.container {
  padding: 25px 0;
}
.tab {
  background: #fff;
  height: 80px;
  padding-top: 40px;
  font-size: 18px;
  text-align: center;
  a {
    color: #666;
    display: inline-block;
    width: 350px;
    line-height: 40px;
    border-bottom: 2px solid #e4e4e4;
    i {
      font-size: 22px;
      vertical-align: middle;
    }
    span {
      vertical-align: middle;
      margin-left: 4px;
    }
    &.active {
      color: @xtxColor;
      border-color: @xtxColor;
    }
  }
}
.tab-content {
  min-height: 600px;
  background: #fff;
}
</style>

3)准备绑定手机组件 (有老账号) 和 完善信息组件(新账号)

src/views/Login/components/callback-bind.vue 绑定手机

<script setup lang="ts">
//
</script>

<template>
  <div class="xtx-form">
    <div class="user-info">
      <img src="@/assets/images/200.png" alt="" />
      <p>Hi,Vue3 欢迎来小兔鲜,完成绑定后可以QQ账号一键登录哦~</p>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-phone"></i>
        <input class="input" type="text" placeholder="绑定的手机号" />
      </div>
      <div class="error"></div>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-code"></i>
        <input class="input" type="text" placeholder="短信验证码" />
        <span class="code">发送验证码</span>
      </div>
      <div class="error"></div>
    </div>
    <a href="javascript:;" class="submit">立即绑定</a>
  </div>
</template>

<style scoped lang="less">
.user-info {
  width: 320px;
  height: 70px;
  margin: 0 auto;
  display: flex;
  background: #f2f2f2;
  align-items: center;
  padding: 0 10px;
  margin-bottom: 25px;
  img {
    background: #f2f2f2;
    width: 50px;
    height: 50px;
  }
  p {
    padding-left: 10px;
  }
}
.code {
  position: absolute;
  right: 0;
  top: 0;
  line-height: 50px;
  width: 80px;
  color: #999;
  &:hover {
    cursor: pointer;
  }
}
</style>

src/views/Login/components/callback-register.vue 注册信息

<script setup lang="ts">
//
</script>

<template>
  <div class="xtx-form">
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-user"></i>
        <input class="input" type="text" placeholder="请输入用户名" />
      </div>
      <div class="error"></div>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-phone"></i>
        <input class="input" type="text" placeholder="请输入手机号" />
      </div>
      <div class="error"></div>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-code"></i>
        <input class="input" type="text" placeholder="请输入验证码" />
        <span class="code">发送验证码</span>
      </div>
      <div class="error"></div>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-lock"></i>
        <input class="input" type="password" placeholder="请输入密码" />
      </div>
      <div class="error"></div>
    </div>
    <div class="xtx-form-item">
      <div class="field">
        <i class="icon iconfont icon-lock"></i>
        <input class="input" type="password" placeholder="请确认密码" />
      </div>
      <div class="error"></div>
    </div>
    <a href="javascript:;" class="submit">立即提交</a>
  </div>
</template>

<style scoped lang="less">
.code {
  position: absolute;
  right: 0;
  top: 0;
  line-height: 50px;
  width: 80px;
  color: #999;
  &:hover {
    cursor: pointer;
  }
}
</style>

4)使用组件

image.png

完成切换交互

image.png

实现思路 : 典型的tab类效果实现,升级成 <KeepAlive> 动态组件实现。

代码落地

<script setup lang="ts">
...
const isBind = ref(true);
</script>

<template>
  <!-- 1. 头部 -->
  <LoginHeader>联合登录</LoginHeader>
  <!-- 2. 主体 -->
  <section class="container">
    <!-- 2.1 tab 头部 -->
    <nav class="tab">
      <a
        href="javascript:;"
        @click="isBind = true"
        :class="{ active: isBind === true }"
      >
        <i class="iconfont icon-bind"></i>
        <span>已有小兔鲜账号,请绑定手机</span>
      </a>
      <a
        href="javascript:;"
        @click="isBind = false"
        :class="{ active: isBind === false }"
      >
        <i class="iconfont icon-edit"></i>
        <span>没有小兔鲜账号,请完善资料</span>
      </a>
    </nav>
    <!-- 2.2 tab 内容 -->
    <div class="tab-content">
    <div class="tab-content">
      <!-- 👶普通写法,能互斥切换,但是组件反复挂载和卸载状态会丢失 -->
      <!--
        <CallbackBind v-if="isBind" />
        <CallbackRegister v-else />
      -->
      <!-- 💪升级写法,KeepAlive + component 缓存组件,保存组件的状态 -->
      <KeepAlive>
        <component :is="isBind ? CallbackBind : CallbackRegister" />
      </KeepAlive>
    </div>
    </div>
  </section>
  <!-- 3. 底部 -->
  <LoginFooter />
</template>

QQ头像和昵称渲染

image.png

TS类型声明文件

新建文件:src\types\api\qq.d.ts

export interface Data {
  ret: number;
  msg: string;
  is_lost: number;
  nickname: string;
  gender: string;
  gender_type: number;
  province: string;
  city: string;
  year: string;
  constellation: string;
  figureurl: string;
  figureurl_1: string;
  figureurl_2: string;
  figureurl_qq_1: string;
  figureurl_qq_2: string;
  figureurl_qq: string;
  figureurl_type: string;
  is_yellow_vip: string;
  vip: string;
  yellow_vip_level: string;
  level: string;
  is_yellow_year_vip: string;
}

export interface QQUserInfo {
  status: string;
  fmt: string;
  ret: number;
  code: number;
  data: Data;
  seq: string;
  dataText: string;
}

统一出口导出 src\types\index.d.ts

// 统一导出所有自定义的类型文件
export * from "./api/home";
export * from "./api/category";
export * from "./api/goods";
export * from "./api/member";
+export * from "./api/qq";

渲染用户信息

  • 父组件传递属性
<script setup lang="ts">
// ...
+import { ref } from "vue";
+import type { QQUserInfo } from "@/types";

+const userInfo = ref<QQUserInfo>();
// 1. 检查用户是否已登录
if (QC.Login.check()) {
  // 2. 获取 QQ 用户唯一标识 openId
  QC.Login.getMe((openId) => {
    console.log("openId", openId);
  });
  // 3. 获取用户资料
+  QC.api("get_user_info").success((res: QQUserInfo) => {
    // console.log("获取用户资料", res);
+    userInfo.value = res;
  });
}

const isBind = ref(true);
</script>

<template>
    <!-- 2.2 tab 内容 -->
    <div class="tab-content">
      <KeepAlive>
        <component
+          :userInfo="userInfo"
          :is="isBind ? CallbackBind : CallbackRegister"
        />
      </KeepAlive>
    </div>
  </section>
</template>
  • 子组件渲染
<script setup lang="ts">
import type { QQUserInfo } from "@/types";

defineProps<{
  userInfo: QQUserInfo | undefined;
}>();
</script>

<template>
  <!-- 🚨防止控制台报错,QQ用户信息没回来不渲染 -->
  <div class="xtx-form" v-if="userInfo">
    <div class="user-info">
      <img :src="userInfo.data.figureurl_2" alt="" />
      <p>
        Hi,{{ userInfo.data.nickname }}
        欢迎来小兔鲜,完成绑定后可以QQ账号一键登录哦~
      </p>
    </div>
    ...
</template>

QQ三方登录 - 多状态介绍

回跳地址这样一个组件内做判断态,分以下 3 种状态。

状态1:已经有账号并且已经绑定 qq 直接获取用户信息,并直接跳转。

状态2:QQ 绑定某个已注册账号,绑定成功后,获取用户信息,再跳转。

状态3:注册全新账号并绑定 QQ,注册成功后,获取用户信息,再跳转。

image.png

有账号未绑定 (绑定测试账号)🚨🚨

目标:把自己的 QQ 信息 绑定到某个测试账号中。

image.png

🔔温馨提示:如果测试账号已被绑定的,手动调用一下解绑被占用的测试手机号。

pcapi-xiaotuxian-front-devtest.itheima.net/login/socia…

实现思路

  1. 收集接口所需的 三个 参数
  2. 进行短信验证码发送 (🚨必须调用接口,验证码接口比较多,看清楚后再调用)
  3. 进行绑定,完成后把当前用户数据存入Pinia,跳转到首页

unionId 父传子

目标:把 unionId 参数传递给子组件,作为 QQ 登录接口其中一个参数。

父组件:src\views\Login\callback.vue

<script setup lang="ts">
...
const userInfo = ref<QQUserInfo>();
+const unionId = ref("");
// 1. 检查用户是否已登录
if (QC.Login.check()) {
  // 2. 获取 QQ 用户唯一标识 openId
  QC.Login.getMe((openId) => {
+    unionId.value = openId;
  });
  // 3. 获取用户资料
  QC.api("get_user_info").success((res: QQUserInfo) => {
    userInfo.value = res;
  });
}
</script>

<template>
  ...
  <section class="container">
    <!-- 2.2 tab 内容 -->
    <div class="tab-content">
      <KeepAlive>
        <component
          :userInfo="userInfo"
+          :unionId="unionId"
          :is="isBind ? CallbackBind : CallbackRegister"
        />
      </KeepAlive>
    </div>
  </section>
  ...
</template>

子组件:src\views\Login\components\callback-bind.vue

<script setup lang="ts">
defineProps<{
  userInfo: QQUserInfo | undefined;
+  unionId: string;
}>();
</script>

表单数据收集

  • 准备响应式数据
// 表单信息收集
const form = reactive({
  mobile: '13012345729',
  code: '123456',
});
  • 模板绑定
<template>
...
        <input
+          v-model="form.mobile"
          class="input"
          type="text"
          placeholder="绑定的手机号"
        />
...
        <input
+          v-model="form.code"
          class="input"
          type="text"
          placeholder="短信验证码"
        />
  ...  
</template>
  • 点击绑定按钮,检查数据
<script>
// ...省略其他代码
// 获取会员 Store
const member = useMemberStore();
// 点击绑定按钮(绑定登录)
const loginBtn = () => {
  // ...省略表单校验
  // 🔔检查三个参数是否收集成功
  console.log(unionId, form);
};

</script>


<a @click="loginBtn()" href="javascript:;" class="submit">立即绑定</a>

接口1:三方登录_账号绑定

Path: /login/social/bind

Method: POST

请求参数

Body

名称类型是否必须默认值备注其他信息
unionIdstring必须三方标识QQ登录后的 openId
mobilestring必须手机号
codestring必须验证码

接口2:三方登录-发送验证码

🚨注意:这个发送的动作必须要有!也就是接口必须要调用才可以绑定。

接口文档:三方登录_发送已有账号短信

Path:/login/social/code

Method: GET

请求参数

Query

参数名称是否必须示例备注
mobile13211112222手机号

QQ 绑定登录实现

  • store 中添加:src\store\modules\member.ts
// 第三方绑定登录
const loginBind = async (data: object) => {
  // 发送登录请求
  const res = await http('POST', '/login/social/bind', data);
  console.log('POST', '/login/social/bind', res);
  // 登录成功提示
  message({ type: 'success', text: '绑定登录成功' });
};


// 第三方绑定登录-发送验证码
const sendCodeBind = async (mobile: string) => {
  // 无需接收返回值,验证码是发送给用户手机的
  await http('GET', '/login/social/code', { mobile: mobile });
  // 成功提示即可
  message({ type: 'success', text: '发送验证码成功' });
};
  • 页面中调用:src\views\Login\components\callback-bind.vue
<script>
// 点击绑定按钮(绑定登录)
const loginBtn = () => {
  // 调用绑定登录接口
  member.loginBind({
    ...form, // 表单数据
    unionId, // 三方标识
  });
};

// 发送验证码
const sendCode = () => {
  member.sendCodeBind(form.mobile)
}
</script>


<a @click="loginBtn()" href="javascript:;" class="submit">立即绑定</a>
<span @click="sendCode()" class="code">发送验证码</span>

QQ 三方登录回跳处理

🔔遇到问题:QQ 三方登录无法实现回跳,如何解决?

目标:把路由的 target 目标页传递给 QQ 登录页,实现 QQ 登录后回跳。

<script>
// 获取路由信息
const route = useRoute();
// QQ授权登录 id
const client_id = '100556005';
// QQ授权登录回跳地址,绑定成功后跳转到 target 目标页面
const redirect_uri = encodeURIComponent(
  `http://www.corho.com:8080/#/login/callback?target=${
    route.query.target || '/'
  }`
);
// QQ授权登录完整 url 拼接
const qqLoginUrl = `https://graph.qq.com/oauth2.0/authorize?response_type=token&scope=all&client_id=${client_id}&redirect_uri=${redirect_uri}`;
</script>


<a :href="qqLoginUrl">
  <img src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png" />
</a>

有账号已绑定 (QQ登录直接跳转)🚨

目标:QQ登录成功后,直接跳转。

实现思路

  1. 回跳组件初始化的时候,获取openId (openId => 对应用户身份 - QQ返回的唯一id身份标识)
  2. 根据 openId 去自己后台尝试进行 直接登录
  3. 如果成功,就代表已注册已绑定,记录返回的用户信息,跳转到首页或者来源页面

接口描述:三方直接登录

Path: /login/social

Method: POST

请求参数

Body

名称类型是否必须备注
unionIdstring必须三方标识
sourceinteger必须注册来源 注册来源,1为pc,2为webapp,3为微信小程序,4为Android,5为ios,6为qq,7为微信

代码落地

1)准备接口:src\store\modules\member.ts

// 三方登录-直接unionId登录
const loginSocial = async (data: object) => {
  const res = await http<Profile>('POST', '/login/social', data);
  // console.log('POST', '/login/social', res);
  loginSuccess(res.data.result);
};

// 记得 return

2)页面调用:

<script setup lang="ts">
  QC.Login.getMe((openId) => {
    unionId.value = openId;
    // 🚨 获取 openId 后,尝试直接登录
    //      如果登录成功,页面会跳转
    //      如果不成功,停留在当前绑定账号页面
+    const member = useMemberStore();
+    member.loginSocial({ unionId: openId, source: 6 });
  });
</script>

可选操作:升级成 TS 常量枚举类型,提高代码可读性。

// 1为pc,2为webapp,3为微信小程序,4为Android,5为ios,6为qq,7为微信
const enum LoginSource {
  PC = 1,
  WebApp,
  MiniProgram,
  Android,
  IOS,
  QQ,
  WeChat,
}

无账号未绑定 (注册登录-课后练习)

说明:业务流程和绑定测试账号流程几乎一致,表单校验,发送验证码,绑定 openId 实现注册登录。

🚨温馨提示:一个手机号和一个 QQ 号只能 注册并绑定一次。

  • 如果QQ号已绑定某个手机号,手动调用一下解绑接口。

pcapi-xiaotuxian-front-devtest.itheima.net/login/socia…

  • 如果手机号已被使用,更换新的手机号,建议记到小本本上,否则无法找回。

image.png

接口三方登录-注册登录

Path: /login/social/:unionId/complement

Method: POST

路径参数

参数名称示例备注
unionIdmegasuiscoolunionId

Body

名称类型是否必须默认值备注其他信息
accountstring必须
mobilestring必须
codestring必须
passwordstring必须