VUE移动端音乐APP学习【六】:歌手详情页面开发

子路由配置

创建singer-detail.vue组件

<template>
  <div class="singer-detail"></div>
</template>

<script>
export default {
  name: 'singer-detail',
};
</script>

<style lang="scss" scoped>
  .singer-detail {
    position: fixed;
    z-index: 100;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: $color-background;
  }
</style>

在route --> index.js中:引入并配置Singer子路由SingerDetail

import SingerDetail from '../components/singer-detail/singer-detail';

{
    path: '/singer',
    name: 'Singer',
    component: Singer,
    children: [{
      path: ':id',
      component: SingerDetail,
    }],
  }

在singer.vue中添加<router-view></router-view>

<template>
  <div class="singer">
    <list-view :data="singers"></list-view>
    <router-view></router-view>
  </div>
</template>

在listview.vue中给<li class="list-group-item">添加点击事件,并在methods中定义selectItem方法

<ul>
      <li v-for="(group,index) in data" class="list-group" ref="listGroup" :key="index">
        <h2 class="list-group-title">{{group.title}}</h2>
        <ul>
          <li class="list-group-item" @click="selectItem(item)" v-for="item in group.items" :key="item.id">
            <img v-lazy="item.avatar" class="avatar">
            <span class="name">{{item.name}}</span>
          </li>
        </ul>
      </li>
</ul> methods: { selectItem(item) { this.$emit('select', item); }, }

在singer.vue中监听select事件,触发selectSinger

<list-view @select="selectSinger" :data="singers"></list-view>


 methods: {
    selectSinger(singer) {
      this.$router.push({
        path: `/singer/${singer.id}`,
      });
    },
}

转场动画实现

点击歌手跳转到歌手详情页可以加个过渡动画效果实现转场

vue中的transition标签可以方便得进行动画过渡

<template>
  <transition name="slide">
    <div v-if="show" class="singer-detail"></div>
  </transition>
</template>


<script>
export default {
  name: 'singer-detail',
  data() {
    return {
      show: false,
    };
  },
  created() {
    setTimeout(() => {
      this.show = true;
    }, 20);
  },
};
</script>

.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s;
}

.slide-enter,
.slide-leave-to {
  transform: translate3d(100%, 0, 0);//100% 完全移动到屏幕右侧 动画开始后向左滑入
}

 

 

 

效果图

 使用Vuex实现路由之间参数数据的获取

子路由SingerDetail需要从父路由页面Singer获取很多数据,都用参数获取内容太多,所以需要使用Vuex来进行管理。

Vuex是一个用来管理组件之间通信的插件,它是一个专为【vue.js】应用程序开发的状态管理模式,它解决了组件之间同一状态的共享问题,它能够更好地在组件外部管理状态。

安装 : npm install vuex --save

在src -->store目录下创建以下js文件:

  1. index.js:入口文件
  2. state.js:管理所有状态 state
  3. mutations.js:管理所有mutation —— 更改 Vuex 的 store 中状态state的唯一方法
  4. mutation-types.js:管理所有mutation 事件类型(type)--字符串常量
  5. actions.js:处理异步操作和修改、以及对mutation的封装
  6. getters.js:对获取的state 做一些映射

在state.js中定义singer数据

const state = {
  singer: {},
};
export default state;

在mutation-types中定义字符串常量

// 定义一些字符串常量
export const SET_SINGER = 'SET_SINGER';

在mutations.js中引入mutation-types作关联,并可对state进行修改

import * as types from './mutation-types';

const mutations = {
  [types.SET_SINGER](state, singer) {
    state.singer = singer;
  },
};
export default mutations;

在getters.js中对state进行包装和输出

// 从state里取数据
export const singer = (state) => state.singer;

初始化 index.js入口文件

import Vue from 'vue';
import Vuex from 'vuex';
// Vuex 内置日志插件用于一般的调试
import createLogger from 'vuex/dist/logger';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';

Vue.use(Vuex);
// 只在开发环境时启动严格模式
const debug = process.env.NODE_ENV !== 'production';
export default new Vuex.Store({
  actions,
  getters,
  state,
  mutations,
  strict: debug,
  plugins: debug ? [createLogger()] : [],
});

注意:在严格模式下,无论何时发生了状态变更且不是由 mutation 函数引起的,将会抛出错误。这能保证所有的状态变更都能被调试工具跟踪到。不要在发布环境下启用严格模式!严格模式会深度监测状态树来检测不合规的状态变更——请确保在发布环境下关闭严格模式,以避免性能损失。 

在main.js中引入Store

import store from './store';

new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount('#app');

在singer.vue中调用mapMutations作对象映射,传递参数singer

import { mapMutations } from 'vuex';

 ...mapMutations({
      setSinger: 'SET_SINGER',
    }),

selectSinger(singer) {
      this.$router.push({
        path: `/singer/${singer.id}`,
      });
      this.setSinger(singer);
    },

在singer-detail.vue中通过引入mapGetters,取到vuex中存储的singer数据

import { mapGetters } from 'vuex';

computed: {
    ...mapGetters([
      'singer',
    ]),
  },

created() {
    ...
    console.log(this.singer);
  },

在singer.js获取歌手歌曲接口数据:

export function getSingerDetail(singerId) {
  return axios.get(`/api/artists?id=${singerId}`);
}

在singer-detail中引入getSingerDetail方法和ERR_OK常量,并在methods中调用该方法获取singer相关歌曲数据

import { getSingerDetail } from '../../api/singer';
import { ERR_OK } from '../../api/config';


methods: {
    _getDetail() {
      // 用户刷新时,mapGetters获取到的singer为空 需要回退歌手列表页
      if (!this.singer.id) {
        this.$router.push('/singer');
        return;
      }
      getSingerDetail(this.singer.id).then((res) => {
        if (res.status === ERR_OK) {
          this.songs = this._normalizeSongs(res.data.hotSongs);
          console.log(this.songs);
        }

      });
    },

注意:只有从singer页面选择歌手跳转到对应singer-detail路由中,才能得到singer数据。当用户刷新时,歌手详情页面将会自动返回到歌手列表页面

同样需要对获取到的歌手相关歌曲数据进行封装处理,创建song.js,构造一个Song类

export default class Song {
  // song的id,mid,歌手,歌曲名name,专辑名album,歌曲长度duration,歌曲图片img,歌曲的真实路径url
  constructor({
    id, singer, name, album, duration, image, url,
  }) {
    this.id = id;
    this.singer = singer;
    this.name = name;
    this.album = album;
    this.duration = duration;
    this.image = image;
    this.url = url;
  }
}

歌手详情处理

在song.js中处理musicData数据:

export function createSong(musicData) {
  return new Song({
    id: musicData.id,
    singer: filterSinger(musicData.ar),
    name: musicData.name,
    album: musicData.al.name,
    duration: (musicData.dt / 1000) | 0,
    image: musicData.al.picUrl,
    url: `https://music.163.com/song/media/outer/url?id=${musicData.id}.mp3`,
  });
}

function filterSinger(singer) {
  let ret = [];
  if (!singer) {
    return '';
  }
  singer.forEach((s) => {
    ret.push(s.name);
  });
  return ret.join('/');
}

在singer-detail.vue中调用createSong方法,将处理好的数据赋值给this.songs

_normalizeSongs(list) {
      let ret = [];
      list.forEach((item) => {
        if (item.id && item.al.id) {
          ret.push(createSong(item));
        }
      });
      return ret;
    },

创建music-list.vue

<div class="music-list">
        <div class="back">
                <i class="icon-back"></i>
        </div>
        <h1 class="title" v-html="title"></h1>
        <div class="bg-image" :style="bgStyle">
                <div class="filter"></div>
        </div>
</div>

export default {
  name: 'music-list',
  props: {
    bgImage: {
      type: String,
      default: '',
    },
    songs: {
      type: Array,
      // eslint-disable-next-line vue/require-valid-default-prop
      default: [],
    },
    title: {
      type: String,
      default: '',
    },
  },
}

在singer-detail中引入该组件,向该组件传递singer数据中的songs,name,avatar

<template>
  <transition name="slide">
    <music-list v-if="show" :title="title" :bgImage="bgImage" :songs="songs"></music-list>
  </transition>
</template>


computed: {
    title() {
      return this.singer.name;
    },
    bgImage() {
      return this.singer.avatar;
    },
    ...mapGetters([
      'singer',
    ]),
  },

song-list组件开发

由于歌曲列表这一部分在后续开发页面(例如歌曲排行榜)中都会使用到,所以在这里将它当作一个基础组件进行开发

<div class="song-list">
       <ul>
             <li v-for="(song, index) in songs" :key="index" class="item">
                 <div class="content">
                    <h2 class="name">{{song.name}}</h2>
                    <p class="desc">{{getDesc(song)}}</p>
                 </div>
             </li>
       </ul>
</div>


props: {
    songs: {
       type: Array,
       default: []
    }
}

methods: {
    getDesc(song){
         return `${song.singer} 。${song.album}`
    }
}

在music-list.vue中引用该组件和scroll组件

<scroll :data="songs" class="list" ref="list">
     <div class="song-list-wrapper">
            <song-list :songs="songs"></song-list>
     </div>
</scroll>

import Scroll from '../../base/scroll/scroll';
import SongList from '../../base/song-list/song-list';

 

music-list组件实现了列表可以往上滚动,也可以往下滚动;图片随着列表滚动实现缩小放大的效果。

music-list.vue完整代码:

  1 <template>
  2   <div class="music-list">
  3     <div class="back" @click="back">
  4       <i class="iconfont icon-back">&#xe600;</i>
  5     </div>
  6     <h1 class="title" v-html="title"></h1>
  7     <div class="bg-image" :style="bgStyle" ref="bgImage">
  8       <div class="play-wrapper">
  9         <div class="play" v-show="songs.length > 0" ref="playBtn">
 10           <i class="icon-play"></i>
 11           <span class="text">随机播放全部</span>
 12         </div>
 13       </div>
 14       <div class="filter" ref="filter"></div>
 15     </div>
 16     <div class="bg-layer" ref="layer"></div>
 17     <scroll @scroll="scroll" :probe-type="probeType"  :listen-scroll="listenScroll" :data="songs" class="list" ref="list">
 18       <div class="song-list-wrapper">
 19         <song-list :songs="songs"></song-list>
 20       </div>
 21       <div class="loading-container" v-show="!songs.length">
 22         <loading></loading>
 23       </div>
 24     </scroll>
 25   </div>
 26 </template>
 27 
 28 <script>
 29 import Scroll from '../../base/scroll/scroll';
 30 import SongList from '../../base/song-list/song-list';
 31 import { prefixStyle } from '../../common/js/dom';
 32 import Loading from '../../base/loading/loading';
 33 
 34 const RESERVED_HEIGHT = 40;
 35 const transform = prefixStyle('transform');
 36 export default {
 37   name: 'music-list',
 38   components: {
 39     Scroll,
 40     SongList,
 41     Loading,
 42   },
 43   props: {
 44     bgImage: {
 45       type: String,
 46       default: '',
 47     },
 48     songs: {
 49       type: Array,
 50       // eslint-disable-next-line vue/require-valid-default-prop
 51       default: [],
 52     },
 53     title: {
 54       type: String,
 55       default: '',
 56     },
 57   },
 58   computed: {
 59     bgStyle() {
 60       return `background-image:url(${this.bgImage})`;
 61     },
 62   },
 63   data() {
 64     return {
 65       scrollY: 0,
 66     };
 67   },
 68   created() {
 69     this.probeType = 3;
 70     this.listenScroll = true;
 71   },
 72   mounted() {
 73     this.imageHeight = this.$refs.bgImage.clientHeight;
 74     this.minTranslateY = -this.imageHeight + RESERVED_HEIGHT;
 75     this.$refs.list.$el.style.top = `${this.imageHeight}px`;
 76   },
 77   methods: {
 78     scroll(pos) {
 79       this.scrollY = pos.y;
 80     },
 81     back() {
 82       this.$router.back();
 83     },
 84   },
 85   watch: {
 86     scrollY(newY) {
 87       let translateY = Math.max(this.minTranslateY, newY);
 88       let zIndex = 0;
 89       // 图片放大
 90       let scale = 1;
 91       // 图片模糊
 92       let blur = 0;
 93       this.$refs.layer.style[transform] = `translate3d(0,${translateY}px,0)`;
 94       const percent = Math.abs(newY / this.imageHeight);
 95       // 图片往下拉时
 96       if (newY > 0) {
 97         scale = 1 + percent;
 98         zIndex = 10;
 99       } else {
100         blur = Math.min(20 * percent, 20);
101       }
102       // CSS高斯模糊属性 只有iphone看得到效果
103       this.$refs.filter.style['backdrop-filter'] = `blur(${blur}px)`;
104       this.$refs.filter.style['webkiBackdrop-filter'] = `blur(${blur}px)`;
105       // 滚到顶部时
106       if (newY < this.minTranslateY) {
107         zIndex = 10;
108         // 由于bgImage是宽高比,所以要先把paddingTop设为0
109         this.$refs.bgImage.style.paddingTop = 0;
110         this.$refs.bgImage.style.height = `${RESERVED_HEIGHT}px`;
111         this.$refs.playBtn.style.display = 'none';
112       } else { // 还没滚动到顶部时
113         this.$refs.bgImage.style.paddingTop = '70%';
114         this.$refs.bgImage.style.height = 0;
115         this.$refs.playBtn.style.display = '';
116       }
117       this.$refs.bgImage.style.zIndex = zIndex;
118       this.$refs.bgImage.style[transform] = `scale(${scale})`;
119     },
120   },
121 };
122 </script>
123 
124 <style lang="scss" scoped>
125   .music-list {
126     position: fixed;
127     z-index: 100;
128     top: 0;
129     left: 0;
130     bottom: 0;
131     right: 0;
132     background: $color-background;
133 
134     .back {
135       position: absolute;
136       top: 0;
137       left: 6px;
138       z-index: 50;
139 
140       .icon-back {
141         display: block;
142         padding: 10px;
143         font-size: $font-size-large-x;
144         color: $color-theme;
145       }
146     }
147 
148     .title {
149       position: absolute;
150       top: 0;
151       left: 10%;
152       z-index: 40;
153       width: 80%;
154 
155       @include no-wrap();
156 
157       text-align: center;
158       line-height: 40px;
159       font-size: $font-size-large;
160       color: $color-text;
161     }
162 
163     .bg-image {
164       position: relative;
165       width: 100%;
166       height: 0;
167       padding-top: 70%;
168       //设置旋转元素的基点位置
169       transform-origin: top;
170       background-size: cover;
171 
172       .play-wrapper {
173         position: absolute;
174         bottom: 20px;
175         z-index: 50;
176         width: 100%;
177 
178         .play {
179           box-sizing: border-box;
180           width: 135px;
181           padding: 7px 0;
182           margin: 0 auto;
183           text-align: center;
184           border: 1px solid $color-theme;
185           color: $color-theme;
186           border-radius: 100px;
187           font-size: 0;
188 
189           .icon-play {
190             display: inline-block;
191             vertical-align: middle;
192             margin-right: 6px;
193             font-size: $font-size-medium-x;
194           }
195 
196           .text {
197             display: inline-block;
198             vertical-align: middle;
199             font-size: $font-size-small;
200           }
201         }
202       }
203 
204       .filter {
205         position: absolute;
206         top: 0;
207         left: 0;
208         width: 100%;
209         height: 100%;
210         background: rgba(7, 17, 27, 0.4);
211       }
212     }
213 
214     .bg-layer {
215       position: relative;
216       height: 100%;
217       background: $color-background;
218     }
219 
220     .list {
221       position: fixed;
222       top: 0;
223       bottom: 0;
224       width: 100%;
225       background: $color-background;
226 
227       .song-list-wrapper {
228         padding: 20px 30px;
229       }
230 
231       .loading-container {
232         position: absolute;
233         width: 100%;
234         top: 50%;
235         transform: translateY(-50%);
236       }
237     }
238   }
239 </style>
music-list.vue

整体效果

接口更新

由于之前使用的QQ音乐API ko2部分接口失效,改换成网易云接口

推荐页面接口(轮播图和热门歌单推荐):

import axios from './axios';
// import jsonp from '../common/js/jsonp';

export function getRecommend() {
  // const url = '/api/getDigitalAlbumLists';
  // return jsonp(url);
  return axios.get('/api/banner');
}
export function getDiscList() {
  return axios.get('/api/personalized');
}

歌手列表接口:

import axios from 'axios';

export function getSingerList() {
  return axios.get('/api/top/artists');
}

歌手歌曲接口:

export function getSingerDetail(singerId) {
  return axios.get(`/api/artists?id=${singerId}`);
}

需要优化的地方:

①采用网易云接口后发现获取到的歌手头像变形

 解决办法:使用object-fit保持图片尺寸

在listview.vue中添加object-fit: cover;

.avatar {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    // 保持原有尺寸比例
    object-fit: cover;
}

②获取到的头像过大,导致加载过慢

解决办法:在singer.vue中每个url后加 ?param=300x300实现压缩图片大小

③修改获取歌手姓名首字母方法,当getCamelChars()中传入的参数不是汉字时,不会进行转换,仍然输出源字符串

修改代码:

export function Getinitial(string) {
  let pinyin = require('js-pinyin');
  pinyin.setOptions({ checkPolyphone: false, charCase: 0 });
  // getCamelChars()中传入的参数不是汉字时,不会进行转换,仍然输出源字符串。
  return pinyin.getCamelChars(string).substring(0, 1).toUpperCase();
}
posted @ 2021-04-04 22:25  小风车吱呀转  阅读(293)  评论(0编辑  收藏  举报