【Vue3网易云音乐项目实战教程】四、首页功能的实现(1)

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

前言

前几节课,相当于项目来说,仅仅是做了个热身,本节课开始,就开始进入到开发状态了。本节课我们主要实现以下几个功能。后期的课程仅会贴出部分代码,详细代码我会贴到Gitee地址里,目前项目是未完成状态,持续开发中,请注意查阅。

本节实现功能

  1. 左侧导航
  2. 顶部导航
  3. 登录退出
  4. banner

引入iconfont

字体图标一种可缩放的矢量图标,它可以被定制大小、颜色、阴影以及任何可以用CSS的样式。可以将多个图标整合到一个字体文件中,从而减少网页的请求次数。相当于使用图片图标,体积也较小很多。

字体图标的使用只要四步即可:

  • 在字体图标库添加icon到我的项目

20220424144248.jpg

  • 下载并将字体添加到项目中

20220424143726.jpg

  • 复制iconfont.css到项目中

20220424143859.jpg

  • 通过 class 在 html 中添加字体图标
// 示例:
<i class="iconfont icon-home"></i>
<i class="iconfont icon-rank"></i>
复制代码

左侧导航实现路由

效果图如下,切换导航,渲染不同的页面。

QQ20220424-141651-HD_.gif

// template
<el-aside class="side-main">
    // Logo部分
    <router-link to="/" class="logo">
        <img src="@/assets/img/logo.jpg">
    </router-link>
    // 菜单部分
    <ul class="nav">
        <li :class="{'is-active': curNav.indexOf(item.path) >= 0}" 
            v-for="item in navList" 
            :key="item.path" 
            @click="selectNav(item)">
            <i :class="[`iconfont`, `icon-${item.path}`]"></i> <span>{{item.name}}</span>
        </li>
    </ul>
</el-aside>

// script
import { computed, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();   // 当前路由地址
const router = useRouter(); // 路由实例
const navList = reactive([{ // 一般是后端返回的整个网站的导航菜单
    name: '首页',
    path: 'index'
}, {
    name: '排行榜',
    path: 'rank'
}, {
    name: '歌单',
    path: 'playlist'
}, {
    name: 'MV',
    path: 'mvlist'
}, {
    name: '歌手',
    path: 'artist'
}, {
    name: '我的音乐',
    path: 'my'
}]);

//获取当前的路由地址
const curNav = computed(() => route.path);

// 当前当前菜单,导航到该菜单的URL
// 这个方法会向 history 栈添加一个新的记录,所以,当用户点击浏览器后退按钮时,会回到之前的 URL。
const selectNav = (item) => {
    router.push({
        path: `/${item.path}`
    })
};

// style
...
// 样式我就不粘贴出来了,可自行Git下来
...

复制代码

顶部导航登录功能的实现

根据提供的API接口,打算实现扫码登录、账号密码登录以及手机号快捷登录。为了避免侵权及安全的问题,注册功能就暂不准备开发。功能实现没难度,登录功能实现了,注册还不是小菜一碟嘛~

扫码登录

因为我们要实现三种登录的方式,所以要根据切换的状态,展示当前登录类型。默认显示二维码登录。

QQ20220425-180651-HD_.gif

二维码登录流程如下:

  1. 显示登录弹窗,请求二维码登录需要的key
  2. 根据获取的key,生成二维码图片并展示出来
  3. 轮询检测扫码状态接口。 800 为二维码过期,801 为等待扫码,802 为待确认,803 为授权登录成功(803 状态码下会返回 cookies)
  4. 获取登录状态及用户信息,并将登录信息存在本地
<!-- 扫码登录Vue模板 -->
......
<div class="login-qr" v-show="loginType == 'qr'">
    <div class="login-title">扫码登录</div>
    <div class="qr-tips">使用 网易云音乐APP 扫码登录</div>
    <div :class="['qr-main', {'qr-expired' : isShowExpired && !loading }]">
        <div class="qr-img">
            <img v-show="oSrc" :src="oSrc" alt="扫码登录">
        </div>
        <span class="qr-expired-btn" 
        v-show="isShowExpired && !loading" 
        @click="refreshQR">二维码过期,点击刷新</span>
        <div class="loadQR" v-loading="loading" v-show="loading"></div>
    </div>
</div>
......
复制代码
const info = reactive({
    unikey: '',       // 二维码登录生成的key
    oSrc: '',         // 生成的二维码图片
    isShowExpired: true, // 二维码是否过期
    timer: null       // 定时器,轮询扫码状态
})

// 1、获取二维码登录需要 key
const getQRkey = async() => {
    const { data: res } = await proxy.$http.getQRkey();

    if (res.code !== 200) {
        proxy.$msg.error(res.msg)
    } else {
        info.unikey = res.data.unikey;
    }
};
// 2、根据获取的key,生成二维码图片
const createQR = async() => {
    const { data: res } = await proxy.$http.createQR({ key: info.unikey});

    if (res.code !== 200) {
        proxy.$msg.error(res.msg)
    } else {
        info.isShowExpired = false;
        info.oSrc = res.data.qrimg;
    }
};
// 3、轮询检测扫码状态接口
const checkQR = async() => {
    const { data: res } = await proxy.$http.checkQR({ key: info.unikey });

    return res;
};
// 4、获取登录状态及用户信息
const getQRLogin = async(cookie) => {
    const { data: res } = await proxy.$http.getQRLogin({ cookie });

    if (res.data.code !== 200) {
        proxy.$msg.error(res.msg)
    } else {
        // TODO 保存用户信息
    }
}

// ... 定时轮询一下二维码的状态
info.timer = setInterval(async () => {
    const statusRes = await checkQR();
                
    // 二维码过期
    if (statusRes.code === 800) {
        clearInterval(info.timer);
        info.isShowExpired = true;
    }
    
    // 扫码授权成功,这一步会返回cookie
    if (statusRes.code === 803) {
        clearInterval(info.timer);
        getQRLogin(statusRes.cookie);
    }
}, 3000);
// ...
复制代码

Tips:

  • 当前切换到其他方式登录的时候,我们可以清除一下定时查询请求,减少请求次数;
  • 当二维码状态过期,要及时反馈给用户,同时也要清除定时器;
  • 当我们切换登录状态的时候,如果当前登录类型是二维码,首先应判断当前二维码是否过期,若过期则需要重新获取key,渲染新的二维码,否则就可以继续使用当前二维码,并检测二维码状态;
邮箱账号登录

传统的账号密码登录方式在用户准确记住用户名密码的前提下较为方便,输入账号密码,提交验证。

...
<el-form ref="loginFormRef" :model="loginForm" :rules="loginFormRules" class="login-form">
    <el-form-item prop="email">
        <el-input class="login-ipt" v-model="loginForm.email" clearable type="text" autocomplete="off" placeholder="请输入登录邮箱" />
    </el-form-item>
    <el-form-item prop="password">
        <el-input class="login-ipt" v-model="loginForm.password" clearable type="password" autocomplete="off" placeholder="请输入密码" />
    </el-form-item>
    <p class="forgetPwd">忘记密码?</p>
    <el-form-item>
        <el-button class="login-submit" type="primary" @click="submitForm">登录</el-button>
    </el-form-item>
</el-form>
...
复制代码
// 表单输入的邮箱与密码
const loginForm = reactive({
    email: '',
    password: ''
});
// 邮箱的正则验证
const emailVerify = (rule, value, callback) => {
    const reg = /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/;

    if (value === '') {
        callback(new Error('请输入登录邮箱'));
    } else if (!reg.test(value)) {
        callback(new Error('登录邮箱格式错误,请重新输入'));
    } else {
        callback();
    }
};
// 表单校验规则的验证
const loginFormRules = {
    email: [{ required: true, validator:emailVerify, trigger: 'blur' }],
    password: [{ required: true, message: '请输入网易密码', trigger: 'blur' }]
};
// 获取表单ref
const loginFormRef = ref();

// 提交表单
const submitForm = () => {
    if (!loginFormRef.value) return;
    loginFormRef.value.validate(async (valid) => {
        if (valid) {
            // TODO  code 406,操作频繁,请稍后再试
            const { data: res } = await proxy.$http.loginPwd(loginForm);

            if (res.code !== 200) {
                proxy.$msg.error(res.msg)
            } else {
                // TODO 获取用户信息,并保存
            }
        }
    })
}
复制代码
手机号快捷登录

手机号登录流程:

  1. 获取验证码,启动倒计时,倒计时结束后才能继续获取验证码
  2. 验证验证码是否正确
  3. 使用验证码登录
...
<el-form
    ref="loginFormPhoneRef"
    :model="loginFormPhone"
    :rules="loginFormPhoneRules"
    class="login-form"
>
    <el-form-item prop="phone">
        <el-select v-model="loginFormPhone.ctcode" class="select-code">
            <el-option
            v-for="item in countrycode"
            :key="item.value"
            :label="item.value"
            :value="item.value"
            >
                <span>{{ item.label }}</span>
                <span>{{ item.value }}</span>
            </el-option>
        </el-select>
        <el-input class="login-ipt" v-model="loginFormPhone.phone" clearable type="text" placeholder="请输入手机号" />
    </el-form-item>
    <el-form-item prop="captcha">
        <el-input class="login-ipt" v-model="loginFormPhone.captcha" clearable type="text" placeholder="请输入验证码" />
        <span class="code-btn" @click="getCaptcha">
            <template v-if="countdown >= 0">
                {{countdown}}s
            </template>
            <template v-else>
                获取验证码
            </template>
        </span>
    </el-form-item>
    <p class="forgetPwd">接收语音验证码</p>
    <el-button class="login-submit" type="primary" @click="submitPhoneForm">登录</el-button>
</el-form>
...
复制代码
 // 表单输入的手机号与验证码
const loginFormPhone = reactive({
    phone: '',       // 登录的手机号
    ctcode: '+86',   // 国家码,用于手机号登录,例如中国:86
    captcha: '',     // 验证码
});
const phoneVerify = (rule, value, callback) => {
    const reg = /^1[3456789]\d{9}$/;

    if (value === '') {
        callback(new Error('请输入手机号'));
    } else if (!reg.test(value)) {
        callback(new Error('手机号格式错误,请重新输入'));
    } else {
        callback();
    }
};
const loginFormPhoneRules = {
    phone: [{ required: true, validator: phoneVerify, trigger: 'blur' }],
    captcha: [{ required: true, message: '验证码不能为空', trigger: 'blur' }]
};
const loginFormPhoneRef = ref();
let countdown = ref(-1); // 验证码倒计时
let timer = ref(); // 定时器
let errorMsg = ref(); // 返回的错误信息

// 获取验证码
const getCaptcha = () => {
    if (countdown.value >=0) return;
    // 获取验证码
    loginFormPhoneRef.value.validateField('phone', async (valid) => {
        if (valid) {
            countHandler();
            const { data: res } = await proxy.$http.sentCode(loginFormPhone);

            if (res.code !== 200) {
                proxy.$msg.error(res.message)
            }
        }
    });
};

// 倒计时
const countHandler = () => {
    countdown.value = 10;
    clearInterval(timer.value);

    timer.value = setInterval(() => {
        if (countdown.value) {
            --countdown.value;
        } else {
            clearInterval(timer.value);
            countdown.value = -1;
        }

    }, 1000);
};

// 登录
const submitPhoneForm = () => {
    loginFormPhoneRef.value.validate(async (valid) => {
        if (valid) {
            const { data: res } = await proxy.$http.verifyCode(loginFormPhone);

            if (res.code !== 200) {
                nextTick(() => {
                    errorMsg.value = res.message;
                });
            } else {
                // 手机号与验证码验收成功,请求登录
                if (res.data) {
                    const { data: r } = await proxy.$http.loginPhone(loginFormPhone);

                    // TODO  code 406,操作频繁,请稍后再试
                    if (r.code !== 200) {
                        proxy.$msg.error(r.message)
                    } else {

                    }
                }
            }
        }
    })
}
复制代码

Tips:

el-select下拉数据量过大,导致第一次加载会有几秒的延迟.

用户信息

登录成功后,我们可以将返回的用户信息保存在本地(为了确保用户信息安全,仅限本地开发),此时为了可以轻松地跨组件/页面共享用户相关的状态信息,我们可以将值存在我们vue的Pinia里。

// account_info.js
import { defineStore } from 'pinia';

export const accountInfoStore = defineStore('account_info', {
    state: () => {
        return {
            isLogin: false, // 用户登录状态
            accountInfo: null, // 登录用户信息
        }
    },
    getters: {
        getLogin: (state) => {
            return state.isLogin || JSON.parse(window.localStorage.getItem('isLogin'));
        },
        getAccountInfo: (state) => {
            return state.accountInfo || JSON.parse(window.localStorage.getItem('accountInfo') || '{}');
        }
    },
    actions: {
        setLogin(val) {
            this.isLogin = val;
        },
        setAccountInfo(val) {
            this.accountInfo = val;
        }
    }
});
复制代码
import { accountInfoStore } from '@/store/account_info';

const accountInfo = accountInfoStore();

// 用户是否登录的状态信息
window.localStorage.setItem('isLogin', true);
// 登录用户的相关信息
window.localStorage.setItem('accountInfo', JSON.stringify(info));
// 修改State里的值
accountInfo.$patch({
    isLogin: true,
    accountInfo: info
});
复制代码

退出登录

const logout = async() => {
    const { data: res } = await proxy.$http.logout()

    if (res.code !== 200) {
        return proxy.$msg.error('数据请求失败')
    }

    proxy.$msg.success('退出成功');
    
    // 退出登录成功,清除本地localStorage缓存的信息
    window.localStorage.removeItem('accountInfo');
    window.localStorage.removeItem('isLogin');
    
    // 退出登录成功,修改Pinia里相应的值
    accountInfo.setLogin(false);
    accountInfo.setAccountInfo();

    if (route.path.indexOf('/my') >= 0) {
        router.push({ path: '/' })
    }
};
复制代码

这样的话,顶部功能暂时告一段落,我们实现了用户登录与退出(3种登录方式)。搜索功能及个人用户其他相关内容,我们后期会陆续添加,继续完善首页。先挖个坑,待填。

轮播图与骨架屏

轮播图的实现,可以使用element的插件,也可以使用swiper,因为element的插件实现的效果不符合我的预期,打算用swiper。

$ npm i swiper
复制代码

在需要等待加载内容的位置设置一个骨架屏,某些场景下比 Loading 的视觉效果更好。

<el-skeleton :loading="loading" animated>
    <template #template>
        <el-skeleton-item class="skeleton-img" variant="image" />
        <el-skeleton-item class="skeleton-img" variant="image" />
        <el-skeleton-item class="skeleton-img" variant="image" />
        <el-skeleton-item class="skeleton-img" variant="image" />
    </template>
    <template #default>
        <swiper
            :slides-per-view="4"
            :space-between="30"
            :modules="modules"
            :autoplay="{ delay: 3000 }"
            :pagination="{ clickable: true }"
            v-if="lists" 
            ref="mySwiper"
            class="banner_wrap"
        >
            <swiper-slide v-for="item of lists" :key="item.imageUrl">
                <el-image :src="item.imageUrl" :alt="item.typeTitle" class="banner_img">
                    <template #placeholder>
                        <div class="image-slot">
                            <i class="iconfont icon-placeholder"></i>
                        </div>
                    </template>
                </el-image>
            </swiper-slide>
        </swiper>
    </template>
</el-skeleton>
复制代码
... 
const lists = ref([]);      // 轮播图列表
const loading = ref(true);  // 控制是否显示加载后的 DOM

const getBanner = async() => {
    const { data: res } = await proxy.$http.getBanner()

    if (res.code !== 200) {
        return proxy.$msg.error('数据请求失败')
    }

    lists.value = res.banners;
    loading.value = false;
}

onMounted(() => {
    getBanner();
})
...
复制代码

课后总结

目前已经完成了三种类型的登录方式,在调用登录接口的时候务必带上时间戳,防止缓存,不要频繁调登录接口,不然可能会被风控,导致接口返回信息失败,登录状态还存在就不要重复调登录接口。

一键三连,努力变得更强!!!

猜你喜欢

转载自juejin.im/post/7104975375410659341