vue vue-router  vuex vue/cli typescript 高仿网易云音乐实现代码全方位解析

前言:  vue全家桶 + TypeScript  构建一款移动端音乐webApp 项目的笔记

项目源码地址   (https://github.com/lang1427/vue-typescript-music

涉及功能:  音乐播放器(播放进度条;播放列表;收藏歌单;删除播放列表中的歌曲;顶部歌单名过长滚动(通知信息);播放模式【列表循环,随机播放,单曲循环】;下载当前播放音乐;暂停播放;开始播放;上下首播放;左滑下一首播放;右滑上一首播放;歌词);轮播图;推荐歌单;新碟;歌单;专辑;登录;注册;播放历史;歌单管理(新建歌单,删除歌单);歌单添加歌曲;编辑歌单信息;下一首播放;评论(上拉加载更多评论;评论点赞/取消点赞;发表评论;回复评论;复制评论;删除评论;评论输入框特效);搜索(防抖搜索处理;历史搜索记录;热搜榜;热门歌手);搜索结果(综合,单曲,视频,歌手,专辑,歌单,主播电台,用户);歌手详情(热门歌曲,专辑,MV)


音乐播放器

src/views/player 组件

音乐播放,暂停

    <audio autoplay></audio>

 当点击音乐播放列表时,将当前的音乐列表存放在一个容器中(playList),并记录当前播放音乐的索引值 (播放相关功能多处都要使用,许用vuex管理)

// mixin.ts
// 播放方法play(index)  设置当前的播放索引
import { PlayList } from "@/conf/playlist";
export const playMixin = {
  methods: {
    play(index) {
      this.$store.dispatch("changeCurrentPlayIndex", index);
      // 如果是播放列表中,点击的播放,则不执行以下操作,只需改变当前播放索引即可
      if (this.songlist) {
        let playArr = [];
        this.songlist.forEach(item => {
          playArr.push(new PlayList(item));
        });
        this.$store.dispatch("changePlayList", playArr);
      }
    }
  }
}

  监听当前播放音乐的索引值,一旦发生变化,就去请求 得到音乐的url地址

// 网络请求   音乐是否可用 (可用的情况下获取音乐的url)  
async getIsCanMusic(callback) {
    if (this.$store.getters.playMusicID === -1) return false;
    try {
      let res = await isCanMusic(this.$store.getters.playMusicID);
      if (res.success) {
        this.getMusicURL(callback);
      } else {
        this.$toast(res.message);
        this.isLoading = false;
        this.stop();
        this.url = null;
      }
    } catch (e) {
      this.$toast("亲爱的,暂无版权");
      this.isLoading = false;
      this.url = null;
      this.stop();
    }
  }
  async getMusicURL(callback) {
    let res = await musicUrl(this.$store.getters.playMusicID);
    if (res.code === 200) {
      this.isPlay = true;
      if (res.data[0].url == null) {
        this.$toast("亲爱的,暂无版权");
        this.stop();
        this.isLoading = false;
      }
      this.url = res.data[0].url;
      this.$nextTick(() => {
        // 确保刷新后不会自动播放
        callback && callback();
      });
    }
  }

  监听url的改变,一旦改变就将新的url赋值给audio的src属性上 因为是autoplay 即src有新值即播放

另外需要监听当前播放列表中当前音乐的id 去获取音乐是否可用的网络请求

 
  @Watch("$store.getters.playMusicID")
  changePlauMusicID() {
    this.getIsCanMusic();
  }

  @Watch("url")
  changeURL(newVal: string) {
    this.$refs.audio.src = newVal;
  }

由于采用的是本地存储,当播放容器中数据时刷新会导致audio标签的src为null,需要手动在mounted生命中周期中手动调用获取音乐是否可用等接口 (需要注意的是在回掉函数中)

  mounted() {
    this.getIsCanMusic(() => {
      this.stop();
    });
  }

无论是播放还是暂停 都是迷你版容器子组件和全屏版子组件向父组件index.vue发送事件。暂停状态改成播放状态,反之一样

// 改变播放状态的方法
  playStatus(val: boolean) {
    this.isPlay = val;
    if (val) {
      this.play();
    } else {
      this.stop();
    }
  }

播放模式

确定播放模式:["列表循环", "单曲循环", "随机模式"]

默认模式取索引为 0 =》列表循环 ,当点击播放模式时,改变索引值(这里通过枚举)进行改变;同时改变其类样式

// 定义播放的类型
export enum EPlayMode {
  listLoop, // 0列表循环
  singleLoop, // 1单曲循环
  random  // 2随机播放
}
// 播放模式 mixin.ts
export const playModeMixin = {
  data() {
    return {
      modeName: ["列表循环", "单曲循环", "随机模式"]
    }
  },
  computed: {
// 类样式
    modeICON() {
      let mode = "listloop";
      switch (this.$store.state.playMode) {
        case 0:
          mode = "listloop";
          break;
        case 1:
          mode = "singleloop";
          break;
        case 2:
          mode = "random";
          break;
      }
      return mode;
    }
  }
  , methods: {
    changeMode() {
      switch (this.$store.state.playMode) {
        case 0:
          this.$store.commit("changePlayMode", EPlayMode.singleLoop);
          break;
        case 1:
          this.$store.commit("changePlayMode", EPlayMode.random);
          break;
        case 2:
          this.$store.commit("changePlayMode", EPlayMode.listLoop);
          break;
      }
// 播放模式改变的消息提示
      if (this.isShow != undefined) {
          this.isShow = true;
        let timer = window.setTimeout(() => {
          this.isShow = false;
          window.clearTimeout(timer);
        }, 1000);
      }
    }
  }
}
// 采用的是背景图片
      &.play-mode {
        background-repeat: no-repeat;
        background-size: 40%;
        background-position: center center;
        height: 20px;
      }
      &.listloop {
        background-image: url(../image/listloop.png);
      }
      &.singleloop {
        background-image: url(../image/singleloop.png);
      }
      &.random {
        background-image: url(../image/random.png);
      }

上下首播放,左右滑切换播放

封装next()  prev() 两个方法,当触发下一首播放时调用next方法,反之调用prev方法。

无论是上一首播放还是下一首播放,都应该考虑播放模式。

  1. 当前播放模式为列表循环:next()方法只需要将当前播放容器的当前播放索引值进行自增即可,需要注意溢出情况当大于等于播放容器数量-1时设置为0;prev()反之
  2. 当前播放模式为单曲循环同上,单曲循环模式只应该在歌曲播放完成之后进行判断是否为单曲循环模式
  3. 当前播放模式为随机播放,则需要随机生成一个0~播放容器长度-1的索引值进行设置
  prev() {
   // this.isLoading = true;  不是关键代码,加载中的提示信息
    if (this.$store.state.playMode === 2) {  
// 这里就是随机生成匹配的索引值了
      let random = Math.floor(
        Math.random() * this.$store.getters.playListLength
      );
      this.$store.dispatch("changeCurrentPlayIndex", random);
    } else {
      let index = this.$store.state.currentPlayIndex;
      if (index <= 0) {
        this.$store.dispatch(
          "changeCurrentPlayIndex",
          this.$store.getters.playListLength - 1
        );
      } else {
        this.$store.dispatch("changeCurrentPlayIndex", index - 1);
      }
    }
  }
  next() {
   // this.isLoading = true;
    if (this.$store.state.playMode === 2) {
      let random = Math.floor(
        Math.random() * this.$store.getters.playListLength
      );
      this.$store.dispatch("changeCurrentPlayIndex", random);
    } else {
      let index = this.$store.state.currentPlayIndex;
      if (index >= this.$store.getters.playListLength - 1) {
        this.$store.dispatch("changeCurrentPlayIndex", 0);
      } else {
        this.$store.dispatch("changeCurrentPlayIndex", index + 1);
      }
    }
  }

左右滑动切换上下首,只在全屏播放容器中的cd处操作实现。需要获取左右滑的位移  是否超过屏幕的1/4,超过即调用上下首播放的功能

full-player.vue

<!--  绑定触摸开始与结束时间  --->
      <div class="CD-lyrics">
        <div
          :class="[playStatu?'rotate cd':'cd']"
          @touchstart="toggleStart"
          @touchend="toggleEnd"
        >
          <img :src="$store.getters.playMusicImg" alt />
        </div>
      </div>
  private touch = {
    startX: 0
  };
  toggleStart(e) {
    this.touch.startX = e.touches[0].pageX;  // 记录触摸开始时的X坐标值
  }
  toggleEnd(e) {
// 计算位移 = 结束时x的坐标值 - 开始时x的坐标值
    let displacement = e.changedTouches[0].pageX - this.touch.startX; 
// 获取全屏播放容器的宽度
    let clientWidth = (this.$refs.fullPlayer as HTMLElement).clientWidth;
    // 滑动的位移超过1/4则进行上下首切换
    if (Math.abs(displacement) > clientWidth / 4) {
      if (displacement > 0) {
        this.$emit("prev");
      } else {
        this.$emit("next");
      }
    }
  }

index.vue

    <full-player
      ref="fullPlayer"
      @prev="prev"
      @next="next"
    ></full-player>

播放完当前音乐后自动调用下一首  需要判断模式是否为单曲循环

设置当前播放音乐的当前时间为0,而不是给audio标签添加loop属性 

  singleloop() {
    this.$refs.audio.currentTime = 0;
    this.play();
  }
  playEnd() {
    if (this.$store.state.playMode === 1) {
      this.singleloop();
    } else {
      this.next();
    }
  }

下载当前播放音乐

这个并没有提供下载音乐的接口,这里只是简单把当前音乐的src路径给放到超链接中即可简单实现

          <a  class="fa-arrow-circle-o-down download"  :href="downloadUrl" 
             download  @click="downloadMusic"
          ></a>

播放进度条

环形进度条采用SVG的形式刻画,线性进度条为单纯div设置宽度颜色即可。都需要一个共同的当前进度值Peraent转递给进度条组件。

当前播放进度通过audio标签的timeupdate事件获取,初始进度为0,总进度为音乐的时长可通过audio标签的canplaythrough

事件获取

// timeupdate当前播放时间一改变就会执行
  timeupdate(e) {
    if (!this.isMove) {  // 这里是为了阻止拖动或点击进度条时执行
      this.currentTime = e.target.currentTime;  // 设置当前时间
    }
  }
  loadComplete(e) {
    // 获取到歌曲总时长
    this.duration = e.target.duration;
  }

当前进度 =  当前播放时间 / 总时长

  get Peraent() {
    return this.duration === 0 ? 0 : this.currentTime / this.duration;
  }

点击或滑动进度条 至 指定的时间段播放

只有全屏播放容器full-player.vue中才可以产生滑动或点击进度条调整播放时间的方式,So,在该组件下需要向外发送两个事件,而这个并不是重点,重点是full-player.vue组件下的子组件progress-bar.vue组件,此组件记录了当前按下,滑动,松开一系列操作,为了得到当前的时间

点击与滑动进度条不同,点击进度条则直接计算出当前的进度想外发送当前进度值,而滑动需要通过touchstart,touchmove,touchend事件的综合,这里的进度百分比是指  线性进度条红色区域所占比的值并不是当前的进度Peraent

  progressClick(e) {
    let moveLineWidth = e.pageX - this.$refs.currentTime.offsetWidth;
    this.percent = Math.max(0, moveLineWidth / e.toElement.offsetWidth); 
    this.$emit("endPercent", this.percent);
  }
  progressStart(e) {
    this.moveStatus = true;
  }
  progressMove(e) {
    if (!this.moveStatus) return;

    // 移动过程中进度条的宽度  =  当前移动过程中PageX的值 - 展示当前时间div的宽度
    let moveLineWidth =
      e.touches[0].pageX - this.$refs.currentTime.offsetWidth;
    // 进度百分比 = 移动过程中进度条的宽度 / 进度栏
    this.percent = Math.max(0, moveLineWidth / this.totalProgress);
    // 向外告知 当前移动过程中的百分比
    this.$emit("changePercent", this.percent);
  }
  progressEnd(e) {
    this.moveStatus = false;
    this.$emit("endPercent", this.percent);
  }

所以在index.vue中需要设置他的当前播放时间为 进度百分比 * 总时长。

(设置当前播放时间即可直接改变当前进度:因为当前进度是计算属性获取的当前时间/总时长的值)

  changePercent(newVal) {
    this.isMove = true;
    this.currentTime = newVal * this.duration;
  }
  endPercent(newVal) {
    this.currentTime = newVal * this.duration;
    this.$refs.audio.currentTime = this.currentTime;
    if (this.$refs.audio.paused) {
      this.stop();
    } else {
      this.play();
    }
    this.isMove = false;
  }

顶部歌单名过长滚动(通知信息)

这个感觉没什么好说的,第三方库很多,我是根据 vant ,来写的noticeBar组件,比较简单,也比较实用。需要注意的是一个潜在的问题,我已经Pull requests这个问题。 https://github.com/youzan/vant/pull/6069

感兴趣的可以看看我写的noticeBar组件,

问题在于 :

  1. 因为顶部的noticeBar在隐藏的情况下是没有宽度的,需要监听到隐藏状态,当是全屏播放容器时则需要开启滚动
  2. 没有重置滚动

So,使用时,full-player.vue组件需要监听是否是全屏容器,进行手动调用滚动方法

  watch: {
    "$parent.isMiniShow": {
      handler(val) {
        if (val === false) {
          this.$refs.noticeBar.startScroll();
        }
      },
      immediate: true
    }
  }

播放列表;收藏歌单;删除播放列表中的歌曲

好像没什么难度,就是一些数据请求 Dom渲染。不过需要注意滑动播放列表时,上一层(非底部弹出层)的内容也随之滚动,还好解决了这个问题。 产生的问题及解决方式

解决方式的原理:给body添加overflow:hidden

<script> 
 @Watch("popupShow")
  changePopupShow(newVal: boolean) {
    if (newVal === true) {
      document.body.classList.add("hidden");
    } else {
      document.body.classList.remove("hidden");
    }
  }
}
</script>
<style lang='less'>
// 不让cssModules 添加 哈希值的方式 : 不要 scoped
.hidden {
  overflow: hidden;
}
</style>

 另外在说说删除播放容器中的歌曲的实现方式吧,如下:

当点击了x或则那个垃圾桶都会调用remove方法,remove方法接受一个参数,参数为当前播放列表点击的索引值或则-1,-1代表删除所有播放列表中的歌曲即清空操作。当然这里删除所有歌曲的话会有一个确认清空的弹框。

  confirmRemove() {
    this.$store.dispatch("removePlayList", -1);
  }
  remove(val: number) {
    if (val === -1) {
      this.confirmShow = true;
      return false;
    }
    this.$store.dispatch("removePlayList", val);
  }

接下来就是异步的vuex处理了


  removePlayList(context, newVal) {
    return new Promise((resolve, reject) => {
      if (newVal === -1) {
        context.commit('removeAll')
        resolve('清空播放列表')
      } else {
        context.commit('removeCurrent', newVal)
        resolve('删除当前选中歌曲')
      }
    })

  },

以上都比较简单,至于为啥使用了Promise,完全是个误会,可以省略。当初是为了想要接着做一些其他的操作,然后发现不需要。

接下来才是真正的删除播放列表功能的代码

// 当删除的索引值 小于 当前播放的索引值时 需要对当前的索引值 -1

  // 当删除的索引值 等于 当前播放的索引值 并且 是最后一个时, 需要对当前的索引值设为 0
  // 这里的length 不用减1  是因为 上面splice时已经去掉了一个元素

  removeAll(state) {
    state.playList = []
    window.localStorage.setItem('playlist', JSON.stringify([]))
    mutations.changePlayIndex(state, -1)
  },
  removeCurrent(state, newVal) {
    state.playList.splice(newVal, 1)
    window.localStorage.setItem('playlist', JSON.stringify(state.playList))
    // 当删除的索引值 小于 当前播放的索引值时 需要对当前的索引值 -1
    if (newVal < state.currentPlayIndex) {
      mutations.changePlayIndex(state, state.currentPlayIndex - 1)
    }
    // 当删除的索引值 等于 当前播放的索引值 并且 是最后一个时, 需要对当前的索引值设为 0
    // 这里的length 不用减1  是因为 上面splice时已经去掉了一个元素
    if (newVal === state.currentPlayIndex && newVal === state.playList.length) {
      mutations.changePlayIndex(state, 0)
    }
  },

 歌词

歌词的解析通过lyric-parser.js 

网易云音乐Api 歌词数据 与  lyric-parser源代码 这里的有所冲突,不晓得别的api数据有没有,推荐你使用我utils目录下的lyric-parser.js   另外需要使用 require的形式导入 而非import,当然你也可以自己改成自己想要导入导出模块的形式

我改变其导出的方式是因为在ts中比较严格,会有一些错误信息提示,但并非错误

Could not find a declaration file for module '@/utils/lyric-parser'. 'F:/music/vue-typescript-music/src/utils/lyric-parser.js' implicitly has an 'any' type.

好像没什么太大的注意事项,无非就是调用提供的Api,所以自己看吧。

1.获取到歌曲url后就获取歌词

2.点击暂停或则播放触发提供的togglePlay() 切换播放模式的方法

3.单曲循环是调用seek()方法

4.滑动进度条时同样调用seek方法,传入正确的时间

        <div class="lyrics" v-show="currentShow==='lyrics'" @click="currentShow='cd'">
          <lyric-scroll class="lyric-scroll-wrapper" ref="lyricScroll">
            <div class="lyric-content">
              <div class="current-lyric" v-if="lyricData">
                <p
                 ref="lyricLine"
                  v-for="(line,index) of lyricData.lines"
                  :key="line.key"
                  :class="{'current':$parent.currnetLineNum===index}"
                   class="text"
                >{{ line.txt }}</p>
              </div>
              <p v-if="lyricData === null" class="lyric-state">{{ lyricState }}</p>
            </div>
          </lyric-scroll>
        </div>

js脚本就不往上添了,看源码吧,比较简单。

歌单 专辑

歌单和专辑都是通过同一个组件 musicList

如路由以下配置:

const album = () => import(/*webpackChunkName:'album'*/'views/musicList/index.vue')
const songsheet = () => import(/*webpackChunkName:'songsheet'*/'views/musicList/index.vue')

export default [
    {
        path: '/album/:id',
        name: 'album',
        component: album
    },
    {
        path: '/songsheet/:id',
        name: 'songsheet',
        component: songsheet
    }
]

请求数据时 只需要对路由做一个判断,请求对应得路由即可

  created() {
    if (this.$route.path.match(/\/album\//)) {
      this.getAlbumContent();
    } else if (this.$route.path.match(/\/songsheet\//)) {
      this.getSongsDetail();
      this.getUserSongsheet();
    }
  }

顶部标题 等一系列内容都是通过路由进行区分

 登录 注册

邮箱登陆就算了,已经屏蔽了,屏蔽原因:获取的很多信息都不是正确的。

  1. 获取用户的手机号,输入过程中+86的颜色变深,根据输入框的值是否长度大于1并且是数字,效验手机号等操作
  2. 输入完手机号 就会发送短信 至你的手机号 ,输入正确的验证码(验证码也做了数字或字母等限制,以封装完成)
  3. 验证验证码是否正确,正确则判断用户是否为注册,未注册则去注册,注册了则输入密码进行登陆
  4. 注册需要输入密码与昵称

1.

    <div class="phone-box">
      <span class="ico" :class="isActive ? 'active':''">+86</span>
      <input class="phone-input" type="tel"  maxlength="11"  v-model.trim="phoneNumber"  placeholder="请输入手机号" 
          @keyup="isNumber"/>
    </div>
  private phoneNumber:string = ''
  get isActive(){
    if(this.phoneNumber.length>=1 && this.phoneNumber.match(/^\d/)){
      return true
    }
  }
  isNumber(){
   this.phoneNumber =  this.phoneNumber.replace(/[^\d]/g,'')
  }

2,3  一旦进入输入验证码界面,计时器就开始,注意初始值为59而不是60,因为判断条件是!=60才显示倒计时,而且进入看到60并不是这个需求。另外,登录时需要用到验证码,这里没有对路由保存验证码或手机号等信息,所以需要在离开这个组件之前beforeDestroy通过事件总线$bus将验证码发射出去,登陆时接受即可。而手机号是通过vuex进行了保存。

    <div class="send-out">
      <div class="info">
        <p>验证码已发送至</p>
        <p class="num">
          <span class="ico">+86</span>
          {{ $store.getters.encodeLoginAccount }}
        </p>
      </div>
      <div class="timer">
        <p class="time" v-if="timer!=60">{{ timer }} s</p>
        <p class="regain" v-else @click="Regain">重新获取</p>
      </div>
    </div>
    <v-code :count="4" @inputComplete="getTestVerifyCode" />
  timer= 59;
  verifycodeVal= "";
  pawd= "";
  pawdShow= false;
  created() {
    this.startTimer();
    this.getSendVerifyCode();  // 发送请求 获取验证码至手机号
  }
  beforeDestroy() {
    window.clearInterval(this.flagTimer);
    this.$bus.$emit("verifycodeVal", this.verifycodeVal);
  }
  startTimer() {
    this.flagTimer = window.setInterval(() => {
      this.timer--;
      if (this.timer === 0) {
        this.timer = 60;
        window.clearInterval(this.flagTimer);
      }
    }, 1000);
  }
  Regain() {
    this.startTimer();
    this.getSendVerifyCode();
  }

  async getSendVerifyCode() {
    let res = await sendVerifyCode(this.$store.state.loginAccount);
    if (res.code === 200) this.$toast("已发送验证码");
  }
  async getTestVerifyCode(inputVal: string) {
    this.verifycodeVal = inputVal;
    let res = await testVerifyCode(this.$store.state.loginAccount, inputVal);
    if (res.code === 200) this.getTestIsRegister();
    else this.$toast(res.message);
  }
  async getTestIsRegister() {
    let res = await testIsRegister(this.$store.state.loginAccount);
    if (res.code === 200) {
      if (res.exist === -1) {
        this.$router.push("/register");
      } else {
        // 手机登陆
        this.pawdShow = true;
      }
    }
  }

4. 注册之后即为登录。这里只要在created时将验证码值接收,完了发生请求即可

  created() {
    this.$bus.$on('verifycodeVal',(code)=>{
      this.verifyCode = code
    })
  }

重点分享下自己封装的验证码组件  (代码就不贴了,请看verify-code组件)

1.首先是input框的数量,通过count可自定义数量,必须传入的绑定值,因为通过count进行遍历得到input框的数量

2.通过各种事件对input的框进行效验,不能输入中文等

3.通过mode自定义input框的模式为只能输入数字,或者只能输入字母,或者能输入数字与字母。默认为只输入数字

4.当前输入框完成输入后跳转到下一个输入框,删除当前输入框内容跳转到上一个输入框

5.对布局采用特定的计算方式,input框的宽度,无需设置样式,当然如果你觉得不合自己胃口,可以自行更改

6.当确保了所有输入框的内容都有且有一个值时,想外发送一个事件inputComplete,并将当前所输入完成的值传递出去

播放历史

限制:最近播放200条数据

没有提供serviceApi那就自己存在本地,与播放相关,所以需要在audio标签事件中进行,这里以当歌曲加载完毕既添加播放历史为例。

    <audio ref="audio" autoplay @canplaythrough="loadComplete"></audio>
  loadComplete(e) {
    // 200条历史播放功能
    const { playMusicID, playMusicName, playMusicImg } = this.$store.getters;
    this.$store.dispatch("operationPlayHistory", {
      id: playMusicID,
      name: playMusicName,
      imgURL: playMusicImg
    });
  }

 逻辑有点绕,but 仔细捋一下就会很清晰

首先 判断 当前播放列表是否是最近播放中的数据,如果是则直接返回

如果操作播放历史的方法传入-1,即代表清空最近播放记录(当然目前界面上没有去写播放记录中的此项操作)

如果最近播放列表中的数据不等于0需要查找一下当前需要添加的最近播放的数据是否包含在最近播放列表中,

如果包含即findIndex的结果!=-1,则需要删除掉原最近播放中需要添加的这项。

另外直接判断最近播放列表数据是否已超过200条。

不管有没有超过,在上一个判断中如果删除了原最近播放中需要添加的这项数据,就都需要插入回去,而没有超过200条可直接在数组中往前插入unshift,

当然,如果上一个判断已经删除掉了一个,则肯定没有200条数据了。

如果超过了,则需要将原有的数据中的最后一个删除,将新的数据插入在最前面。

  operationPlayHistory(context, newVal) {

    if (lodash.isEqual(context.state.playList, context.state.playHistory)) return false

    if (newVal === -1) {
      context.commit('clearPlayHistory')
      return false
    }

    if (context.state.playHistory.length != 0) {
      let res = context.state.playHistory.findIndex((item) => {
        return item.id === newVal.id
      })
      if (res !== -1) {
        context.commit('removeCurrentPlayHistory', res)
      }
    }

    if (context.state.playHistory.length < 200) {
      context.commit('unshiftPlayHistory', newVal)
    } else {
      context.commit('splicePlayHistory', newVal)
    }
  }
// mutationins
  clearPlayHistory(state) {
    state.playHistory = []
    window.localStorage.setItem('playHistory', JSON.stringify([]))
  },
  removeCurrentPlayHistory(state, newVal) {
    state.playHistory.splice(newVal, 1)
    window.localStorage.setItem('playHistory', JSON.stringify(state.playHistory))
  },
  unshiftPlayHistory(state, newVal) {
    state.playHistory.unshift(newVal)
    window.localStorage.setItem('playHistory', JSON.stringify(state.playHistory))
  },
  splicePlayHistory(state, newVal) {
    state.playHistory.splice(state.playHistory.length - 1, 1)
    mutations.unshiftPlayHistory(state, newVal)
  }

歌单管理 (需登录)

新建歌单

很单纯的一个弹框加网络请求,没什么难度

删除歌单

涉及复选框的全选和反全选,单选等操作,还是说一下吧

1.首先复选框v-module双向绑定选择的值,当然值会是一个数组,动态绑定value值为每项的id

<input type="checkbox" class="checkbox-items" v-model="isChecks" :value="item.id" />
  private isChecks: number[] = [];

 2.全选和反选的文字替换:根据计算属性,计算选中的数量的个数是否等于原数量的个数,等于则需要显示为取消全选否则全选

  get isAllCheckText() {
    if (this.isChecks.length === (this as any).mySongsList.length) {
      return "取消全选";
    }
    return "全选";
  }

3.点击全选或取消全选都触发allCheck方法,方法中获取到每项复选框,如果显示的是全选则将每项的value值push进双向绑定的数组中即可;如果显示的是取消全选,则将双向绑定的数组设为空数组

  allCheck() {
    let checkBox = this.$refs.songlistREF;
    let checkItems = (checkBox as HTMLElement).querySelectorAll(
      ".checkbox-items"
    );
    if (this.isAllCheckText === "全选") {
      this.isChecks = [];
      for (let item of checkItems) {
        this.isChecks.push((item as any).value);
      }
    } else {
      this.isChecks = [];
    }
  }

歌单添加歌曲(自身歌单才有的操作)

1. 默认显示最近播放中的歌曲进行添加,

    <div class="recently-played" v-if="searchRes.length===0">
      <h6 class="title">最近播放</h6>
      <ul class="list">
        <li
          class="list-items"
          v-for="item of playHistoryList"
          :key="item.id"
          @click="add(item.id)"
        >{{ item.name }}</li>
      </ul>
    </div>
  get playHistoryList() {
    return this.$store.state.playHistory;
  }

2.搜索后(只搜单曲),显示搜索后的歌曲信息

3.搜索有个防抖处理  和搜索界面一样,这里先说防抖,后面搜索就不说了

debounce防抖函数,通过监听搜索内容的变化,调用防抖函数,将网络请求传递进去,当500ms后,输入框的值还没有发生变化,就向服务器发送请求,请求搜索的歌曲

  debounce(fn: any, delay: number = 500) {
    if (this.timer) clearTimeout(this.timer);
    this.timer = window.setTimeout(() => {
      fn.call(this, this.searchContent);
    }, delay);
  }
  @Watch("searchContent")
  changeSearchContent(newVal: string) {
    if (newVal.trim().length !== 0) {
      this.debounce(this.getSearchSuggest);
    }
  }

4.添加歌曲到歌单中:点击当前项,拿到当前项的id发送请求

另外说一下,这里的缝隙是怎么写的,整体采用flex布局,左边一个固定宽度,输入框盒子为1,输入框宽度为calc(100% - 25px),至于右侧的缝隙是另写了一个没有内容的div设置一个小小的宽度即可实现。

编辑歌单信息(自身歌单才有的操作)

无论是编辑啥,都是通过路由传参的形式将需要的数据传递进去

  goEditName() {
    this.$router.push({
      path: "/songmanage/update/editname",
      query: {
        songid: this.id + "",
        songname: this.songName
      }
    });
  }
  goEditTags() {
    this.$router.push({
      path: "/songmanage/update/edittags",
      query: {
        songid: this.id + "",
        tags: this.songTags
      }
    });
  }
  goEditDesc() {
    this.$router.push({
      path: "/songmanage/update/editdesc",
      query: {
        songid: this.id + "",
        desc: this.songDesc
      }
    });
  }

编辑歌单名称

编辑保存后,为了防止页面刷新,内容还是之前为被编辑的名称,所以需要更改songname参数的值

  async setUpdateSongName() {
    let res = await updateSongName(this.id, <string>this.songName);
    if (res.code === 200) {
      this.$toast("修改成功");
      // this.$route.query.songname = this.songName 
        // query.songname改变了 but 地址栏中的songname并没有发生变化   *错误方式*
      this.$router.replace({
        query: { ...this.$route.query, songname: this.songName }
      });
    }
  }

编辑歌单tags

 为了实现 那种布局 模式,所以采用了很多个table,实在没有想到可遍历的形式将那种布局给填充出来,如果在看的各位,有实现思路,欢迎提出!

所以为了实现编辑歌单tags的需求,不得已手动操作Dom。废话不多说了,说实现思路:

1.页面进来需要获取到选中了的数量,为了展示在提示消息中

  private checkCount: number = 0;
  get tipMes() {
    return `请选择合适的标签,最多选择3个,已选${this.checkCount}个`;
  }
  mounted() {
    this.setCheckCount();
  }
  setCheckCount() {
    let tagsList = (<HTMLElement>this.$refs.tagsBody).querySelectorAll(
      ".tags-active"
    );
    this.checkCount = tagsList.length;
  }

 2.点击选中时,判断是否选中,如果选中,则需要移除类样式与相关文本内容

3.如果没有被选中,需要判断是否超过了3个,没有则被选中,否则给出提示消息,不让选中

  isCheck(e: any) {
    if (e.target.classList.contains("tags-active")) {
      this.songTags = (this.songTags as string).replace(
        htmlDecode(e.target.innerHTML),
        ""
      );
      e.target.classList.remove("tags-active");
    } else {
      if (this.checkCount >= 3) {
        this.$toast("最多选择3个");
        return false;
      }
      this.songTags = (this.songTags as string).replace(
        "",
        htmlDecode(e.target.innerHTML)
      );
      e.target.classList.add("tags-active");
    }

    this.setCheckCount();
  }

4.保存时,在将所有被选中的内容保存起来向后台发送请求

  async setUpdateSongTags() {
    let tagsList = this.$refs.tagsBody.querySelectorAll(
      ".tags-active"
    );
    let tags = [];
    tagsList.forEach(item => {
      tags.push(htmlDecode(item.innerHTML));
    });
    tags = tags.join(";");
    let res = await updateSongTags(this.id, tags);
    if (res.code === 200) {
      this.$toast("修改成功");
      this.$router.replace({
        query: { ...this.$route.query, tags }
      });
    }
  }

特别要注意的是 可能 因为 & 等转义符而改变了原有内容,需要反转义一下

编辑歌单描述

需求:唯一一个保存之后回到上一个页面。没什么别的难度

下一首播放

弹框是songlist-operation组件

逻辑:将当前点击项相关信息插入到播放列表中当前播放的下一个中即可。

  nextPlay(playInfo) {
    let obj = {
      id: playInfo.songsId,
      imgURL: playInfo.imgUrl,
      name: playInfo.songsName
    };
    this.$store.commit("insertPlaylist", obj);
    this.operationShow = false;
  }
  insertPlaylist(state, newVal) {
    (state.playList as []).splice(state.currentPlayIndex + 1, 0, (<never>newVal))
    window.localStorage.setItem('playlist', JSON.stringify(state.playList))
  },

评论

先说一下评论的数据获取,获取的数据格式回复他人评论的数据居然不是放在该评论里,而是单独在来一个评论,看了一下真实的网易云音乐App的评论,无法理解网易云的评论为啥要搞成这样,感觉颠覆了我的思想,于是我改成了我想要的样子,这效果类似于QQ空间的说说,主要我是对评论和回复的数据进行了过滤,在将回复评论给对应的评论中

你也可以根据你自己的想法来,but 这不是一个bug,请不要在使用过程中对我提这是bug

回复中的parentCommentId等于评论中的commentId,即为此评论中的回复

为了防止循环遍历而创建多个元素,所以这里采用template的形式进行循环,当然:key是不能在template中进行绑定的

        <div class="list-items" v-for="comment of commentData" :key="comment.commentId">
          <div
            class="comment-content">{{ comment.commentContent }}</div>
          <div class="reply">
            <template v-for="reply of replyData">
              <div :key="reply.commentId" v-if="reply.parentCommentId === comment.commentId">
                <span class="name">{{ reply.userName }} :&nbsp;</span>
                <span>{{ reply.commentContent }}</span>
              </div>
            </template>
          </div>
        </div>
  get commentData() {     // 评论
    return this.commentList.filter(item => {
      return item.parentCommentId === 0;
    });
  }
  get replyData() {      // 回复
    return this.commentList.filter(item => {
      return item.parentCommentId !== 0;
    });
  }

评论点赞、取消点赞(需登录)

            <div class="liked">
              <span
                @click="setLikeComment(comment.commentId)"
                :class="comment.commentLiked ? 'fa-thumbs-o-up liked-active' : 'fa-thumbs-o-up' "
              >{{ comment.commentLikedCount !== 0 ? comment.commentLikedCount : '' }} &nbsp;&nbsp;</span>
            </div>
  async setLikeComment(cid: number) {
    this.$parent.testLogin();

    let domEvent = event;
    let res = await likeComment(
      parseInt(this.$route.query.id),
      cid,
      domEvent.target.classList.contains("liked-active") ? 0 : 1,
      this.$parent.commentType
    );
    if (res.code === 200) {
      if (!domEvent.target.classList.contains("liked-active")) {
        domEvent.target.classList.add("liked-active");
        if (domEvent.target.innerHTML == "") {
          domEvent.target.innerHTML = 1;
        } else {
          domEvent.target.innerHTML =
            domEvent.target.innerHTML * 1 + 1;
        }
      } else {
        domEvent.target.classList.remove("liked-active");
        domEvent.target.innerHTML -= 1;
        if (domEvent.target.innerHTML == 0) {
          domEvent.target.innerHTML = "";
        }
      }
    }
  }
// 将font-awesome 的 fa-thumbs-o-u(竖大拇指)字体图标 改成 after 显示
.fa-thumbs-o-up:before {
  content: "";
}
.fa-thumbs-o-up:after {
  content: "  \f087";
}

1.原本Dom节点为两个span,第一个span是点赞数量,第二个span是大拇指的小图标,至于为什么改成一个span了,是因为两个span会导致很多不必要的麻烦;当然,当你改成一个span时,你的小图标显示在你文字的前面,很明显不服合界面美观。于是将font-awesome的大拇指图标从before改成after即可

2.点赞或取消点赞都需要登录状态下,所以一开始就要调用testLogin()

3.第三个参数为是否点赞,1为点赞,0为取消点赞,原本是根据commentLiked来判断1或者0,但是呢,考虑到对数据的处理,这里并不好在直接对原有的commentNewData数据进行$set()的操作了,所以采用了操作dom的形式

如果感兴趣我之前的想法的话,请看原有操作错误的想法 

4.操作dom需要注意的是,需要在axios请求之前将dom的event保存起来,因为在axios里面的event是网络请求相关event事件对象了,并不是dom的event事件对象

5.需要对innerHTML中的值进行判断处理

发表,回复,删除等评论操作都是通过同一个接口operationComment 

 发表评论、回复评论(需要登录)

1.通过输入框值进行发送send()操作,发送时向外传递是发表评论还是回复评论

2.如果是回复评论,则在回复过程中需要点击当前回复的评论对象执行reply()方法,该方法向事件总线$bus.$emit("replyComment")传递对应的评论id及用户名称,那么在发送输入框内容之前,就会将当前传递的id和name通过接受$bus.$on("replyComment"),将接受到的用户名称用于inputplaceholder属性中,当发送时进行判断placeholder是否还是默认值时,如果不是则为回复评论。否则为发表评论

3.send()之后需要对input以及回复的id进行重置

  reply(replyID, userName) {
    this.$bus.$emit("replyComment", replyID, userName);
  }
  mounted() {
    this.$bus.$on("replyComment", (rid, name) => {
      (this.$refs.commentInput as HTMLInputElement) &&
        (this.$refs.commentInput as HTMLInputElement).focus();
      this.$refs.commentInput
        ? ((this.$refs.commentInput as HTMLInputElement).placeholder =
            "回复" + name)
        : null;
      this.replyID = rid;
    });
  }
  send() {
    if (this.commentVal.trim() === "") {
      return false;
    }
    let operationType = 1;
    if (
      (this.$refs.commentInput as HTMLInputElement).placeholder !==
      "随乐而起,有感而发"
    ) {
      operationType = 2;
    }
    this.$emit(
      "sendComment",
      operationType,
      this.commentVal,
      this.replyID !== -1 ? this.replyID : null
    );
    this.commentVal = "";
    this.replyID = -1;
    this.$refs.commentInput
      ? ((this.$refs.commentInput as HTMLInputElement).placeholder =
          "随乐而起,有感而发")
      : null;
  }

这里并不是通过点击发送按钮进行验证是否登录,而是通过当input获取到焦点时

      <input type="text" placeholder="随乐而起,有感而发" ref="commentInput"  v-model="commentVal"
        @focus="$parent.testLogin()"
      />

复制评论和删除评论都需要对当前评论长按才会出现此功能,当然删除评论只能是自身发表的评论

先说一下长按事件,移动端经常会有的操作,但是却没有原生的长按事件,So,需要自己手写一个。

长按事件的构成:touchstart、touchmove、touchend事件的综合;

start时启动一个300ms的一次性定时器,一次性定时器内部执行你的内部代码,一旦move或者end则关闭一次性定时器。

我已经封装成自定义指令了,请看源码中longpress.ts文件

          <div
            class="comment-content"
            v-longpress="{'methods':longpressDialog,'params':{content:comment.commentContent,userId:comment.userId,commentId:comment.commentId}}"
            @click="!longpress && reply(comment.commentId,comment.userName)"
          >{{ comment.commentContent }}</div>
   private longpress: boolean = false; // 用于阻止长按事件与点击事件冲突 
  longpressDialog(obj) {
    this.longpress = true;
    this.isDialogShow = true;
    this.content = obj.content;
    this.commentUserID = obj.userId;
    this.commentContentId = obj.commentId;
  }

以上代码是长按时出现对话框(复制评论、删除评论)

因为长按事件执行时同时还绑定了点击事件,很明显两者事件有冲突,需要解决这个问题;

通过定义一个longpress的boolean变量来决定点击事件是否可执行即可解决此问题,完美!!!

复制评论

    <!--  type不能是hidden-->
    <input type="text" style="position: absolute;top: 0;left: 0;opacity: 0;z-index: -10;" id="copyinput" />

浏览器自带的复制内容的事件,当然需要是text 的类型

  copyComment() {
    let input = document.getElementById("copyinput");
    input.value = this.content;
    input.select();
    document.execCommand("copy");
    this.resetDialog();
  }

删除评论

没什么好说的,就是直接调删除的接口

都说完了评论,回复,删除等操作,当然这些操作之后都需要对原有的数据进行处理,刷新肯定是不可取的。所以,只能在这些操作请求成功之后,对数据进行处理

  async setOperationComment(t, content, commentId) {
    let res = await operationComment(
      t,
      this.commentType,
      parseInt(this.$route.query.id),
      content,
      commentId
    );
    if (res.code === 200) {
      switch (t) {
        case 0:
          let index = this.commentNewData.findIndex(item => {
            return item.commentId === commentId;
          });
          this.commentNewData.splice(index, 1);
          this.commentTotal -= 1;
          break;
        case 1:
          this.commentNewData.unshift(new CommentClass(res.comment));
          this.commentTotal += 1;
          break;
        case 2:
          this.commentNewData.unshift(new CommentClass(res.comment));
          this.commentNewData[0].parentCommentId = commentId;
          break;
      }
    } else {
      this.$toast(res.msg);
    }
  }

评论输入框特效

通过四个span,进行动画,需要注意的是外层盒子为relative,input的宽度不能和外层盒子同宽度,同宽度的话会导致输入框内容显示不下时动画异常,同时需要将外层盒子和input的背景色弄成一致,以达到无缝效果

    <div class="input-box">
      <input
        type="text"
        placeholder="随乐而起,有感而发"
        ref="commentInput"
        v-model="commentVal"
        @focus="$parent.testLogin()"
      />
      <span></span>
      <span></span>
      <span></span>
      <span></span>
    </div>
  .input-box {
    margin: 0 8px;
    flex: 1;
    position: relative;
    overflow: hidden;
    background: white;
    input {
      width: 95%;
      height: 30px;
      padding-left: 6px;
      border: none;
      background: white;
      outline: none;
    }
    span {
      position: absolute;
      &:nth-of-type(1) {
        width: 100%;
        height: 2px;
        background: -webkit-linear-gradient(left, transparent, #03e9f4);
        left: -100%;
        top: 0;
        animation: line1 1s linear infinite;
      }
      &:nth-of-type(2) {
        height: 100%;
        width: 2px;
        background: -webkit-linear-gradient(top, transparent, #03e9f4);
        top: -100%;
        right: 0;
        animation: line2 1s 0.35s linear infinite;
      }
      &:nth-of-type(3) {
        width: 100%;
        height: 2px;
        background: -webkit-linear-gradient(left, transparent, #03e9f4);
        left: 100%;
        bottom: 0;
        animation: line3 1s 0.45s linear infinite;
      }
      &:nth-of-type(4) {
        height: 100%;
        width: 2px;
        background: -webkit-linear-gradient(top, transparent, #03e9f4);
        top: 100%;
        left: 0px;
        animation: line4 1s 0.8s linear infinite;
      }
    }
  }
@keyframes line1 {
  50%,
  100% {
    left: 100%;
  }
}
@keyframes line2 {
  50%,
  100% {
    top: 100%;
  }
}
@keyframes line3 {
  50%,
  100% {
    left: -100%;
  }
}
@keyframes line4 {
  50%,
  100% {
    top: -100%;
  }
}

搜索

防抖搜索处理

上面已经说过了,这里就不说了,代码是一样的

历史搜索记录

就是将搜索过的值保存在本地而已,当然也是要做数据处理判断搜索名是否已存在

热搜榜

单纯的网络请求+界面排序,前三个的排名加个红色字体的类样式,没了

都没多大难度,另外搜索输入框有值时,会显示搜索推荐的内容,当不对其做操作,而是滑动热搜榜时,需要将搜索推荐的内容给隐藏,

做法:定义一个isActive的变量用于控制其显示与隐藏,当再次点击到输入框时设置回显示

热门歌手

歌手区域的滑动是通过better-scroll,首字母拼音通过touch事件自己联动效果  拼音插件

关键组件:scroll-list-view(联动效果)    singer(数据处理 )

看源码把,注释我已经写得很清晰了。

就到这里吧,其他的真没什么难度,可能在写的时候会遇到问题,但是,写下来之后发现还是挺简单的一个项目

补充:

  1. 当有播放容器时需要对App.vue中router-view内部的元素加上一个margin-bottom;视情况而定,当然也有部分才有better-scroll中的元素,需要设置bottom
  2. 子路由带来的问题:如从歌手详情返回到热门歌手中没有数据、无法滚动等bug 。。。

当然以上问题都被解决了,看源码吧,解析结束

结语:文章内容有点长,感谢您所看的废话,最后希望您喜欢

Logo

前往低代码交流专区

更多推荐