├── src ├── store │ ├── modules │ │ ├── global │ │ │ ├── state.js │ │ │ ├── mutations.js │ │ │ └── index.js │ │ ├── user │ │ │ ├── state.js │ │ │ ├── mutations.js │ │ │ ├── index.js │ │ │ ├── actions.js │ │ │ └── getters.js │ │ └── music │ │ │ ├── index.js │ │ │ ├── state.js │ │ │ ├── mutations.js │ │ │ ├── actions.js │ │ │ └── getters.js │ ├── helper │ │ ├── global.js │ │ ├── music.js │ │ └── user.js │ └── index.js ├── style │ ├── index.scss │ ├── themes │ │ ├── variables-red.js │ │ ├── variables.js │ │ └── variables-white.js │ ├── variables.scss │ ├── app.scss │ ├── mixin.scss │ ├── reset.scss │ └── element-overwrite.scss ├── api │ ├── album.js │ ├── artist.js │ ├── search.js │ ├── index.js │ ├── user.js │ ├── mv.js │ ├── discovery.js │ ├── playlist.js │ ├── comment.js │ └── song.js ├── assets │ └── image │ │ ├── play-bar.png │ │ └── play-bar-support.png ├── utils │ ├── index.js │ ├── mixin.js │ ├── config.js │ ├── rem.js │ ├── dom.js │ ├── global.js │ ├── business.js │ ├── lrcparse.js │ ├── axios.js │ └── common.js ├── base │ ├── empty.vue │ ├── title.vue │ ├── loading.vue │ ├── pagination.vue │ ├── nbutton.vue │ ├── play-icon.vue │ ├── highlight-text.vue │ ├── scroller.vue │ ├── video-player.vue │ ├── toggle.vue │ ├── card.vue │ ├── volume.vue │ ├── confirm.vue │ ├── icon.vue │ ├── progress-bar.vue │ └── tabs.vue ├── main.js ├── page │ ├── discovery │ │ ├── index.vue │ │ ├── banner.vue │ │ ├── new-playlists.vue │ │ ├── new-mvs.vue │ │ └── new-songs.vue │ ├── songs │ │ └── index.vue │ ├── search │ │ ├── mvs.vue │ │ ├── playlists.vue │ │ ├── index.vue │ │ └── songs.vue │ ├── mvs │ │ └── index.vue │ ├── playlists │ │ └── index.vue │ ├── playlist-detail │ │ ├── detail-header.vue │ │ └── index.vue │ └── mv │ │ └── index.vue ├── App.vue ├── components │ ├── share.vue │ ├── routes-history.vue │ ├── song-card.vue │ ├── with-pagination.vue │ ├── playlist-card.vue │ ├── top-playlist-card.vue │ ├── comment.vue │ ├── mv-card.vue │ ├── theme.vue │ ├── playlist.vue │ ├── user.vue │ ├── comments.vue │ ├── search.vue │ ├── song-table.vue │ ├── mini-player.vue │ └── player.vue ├── layout │ ├── index.vue │ ├── menu.vue │ └── header.vue └── router.js ├── public ├── favicon.ico └── index.html ├── babel.config.js ├── .gitignore ├── release.sh ├── vue.config.js ├── LICENSE ├── README.md ├── CHANGELOG.md ├── package.json └── STUDY.md /src/store/modules/global/state.js: -------------------------------------------------------------------------------- 1 | export default { 2 | axiosLoading: false, 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JYbmarawcp/vue-netease-music/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/style/index.scss: -------------------------------------------------------------------------------- 1 | @import "./element-overwrite.scss"; 2 | @import "./reset.scss"; 3 | @import "./app.scss"; 4 | -------------------------------------------------------------------------------- /src/api/album.js: -------------------------------------------------------------------------------- 1 | import { request } from "@/utils" 2 | 3 | export const getAlbum = id => request.get(`/album?id=${id}`) -------------------------------------------------------------------------------- /src/assets/image/play-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JYbmarawcp/vue-netease-music/HEAD/src/assets/image/play-bar.png -------------------------------------------------------------------------------- /src/store/modules/user/state.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // 登录用户 3 | user: {}, 4 | // 登录用户歌单 5 | userPlaylist: [] 6 | } -------------------------------------------------------------------------------- /src/assets/image/play-bar-support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JYbmarawcp/vue-netease-music/HEAD/src/assets/image/play-bar-support.png -------------------------------------------------------------------------------- /src/api/artist.js: -------------------------------------------------------------------------------- 1 | import { request } from "@/utils" 2 | 3 | // 获取歌手单曲 4 | export const getArtists = id => request.get(`/artists?id=${id}`) 5 | -------------------------------------------------------------------------------- /src/store/modules/global/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setAxiosLoading(state, loading) { 3 | state.axiosLoading = loading 4 | } 5 | } -------------------------------------------------------------------------------- /src/store/helper/global.js: -------------------------------------------------------------------------------- 1 | import { createNamespacedHelpers } from 'vuex' 2 | 3 | export const { mapState, mapMutations } = createNamespacedHelpers('global') -------------------------------------------------------------------------------- /src/store/helper/music.js: -------------------------------------------------------------------------------- 1 | import { createNamespacedHelpers } from 'vuex' 2 | 3 | export const { mapState, mapMutations, mapGetters, mapActions } = createNamespacedHelpers('music') -------------------------------------------------------------------------------- /src/store/helper/user.js: -------------------------------------------------------------------------------- 1 | import { createNamespacedHelpers } from 'vuex' 2 | 3 | export const { mapState, mapMutations, mapGetters, mapActions } = createNamespacedHelpers('user') 4 | -------------------------------------------------------------------------------- /src/store/modules/global/index.js: -------------------------------------------------------------------------------- 1 | import state from './state' 2 | import mutations from './mutations' 3 | 4 | export default { 5 | namespaced: true, 6 | state, 7 | mutations 8 | } -------------------------------------------------------------------------------- /src/store/modules/user/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setUser(state, user) { 3 | state.user = user 4 | }, 5 | setUserPlaylist(state, playlist) { 6 | state.userPlaylist = playlist 7 | } 8 | } -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './axios' 2 | 3 | export * from './common' 4 | 5 | export * from './business' 6 | 7 | export * from './rem' 8 | 9 | export * from './dom' 10 | 11 | export * from './mixin' 12 | 13 | export * from './config' -------------------------------------------------------------------------------- /src/utils/mixin.js: -------------------------------------------------------------------------------- 1 | import store from "@/store" 2 | 3 | export const hideMenuMixin = { 4 | created () { 5 | store.commit('music/setMenuShow', false) 6 | }, 7 | beforeDestroy () { 8 | store.commit('music/setMenuShow', true) 9 | } 10 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ], 5 | plugins: [ 6 | [ 7 | 'component', 8 | { 9 | libraryName: 'element-ui', 10 | styleLibraryName: 'theme-chalk' 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/store/modules/user/index.js: -------------------------------------------------------------------------------- 1 | import state from "./state" 2 | import * as getters from "./getters" 3 | import mutations from "./mutations" 4 | import actions from "./actions" 5 | export default { 6 | namespaced: true, 7 | state, 8 | getters, 9 | mutations, 10 | actions, 11 | } -------------------------------------------------------------------------------- /src/store/modules/music/index.js: -------------------------------------------------------------------------------- 1 | import state from './state' 2 | import * as getters from './getters' 3 | import mutations from './mutations' 4 | import actions from './actions' 5 | 6 | export default { 7 | namespaced: true, 8 | state, 9 | getters, 10 | mutations, 11 | actions, 12 | } -------------------------------------------------------------------------------- /src/api/search.js: -------------------------------------------------------------------------------- 1 | import { request } from "@/utils" 2 | 3 | export const getSearchHot = () => request.get('/search/hot') 4 | 5 | export const getSearchSuggest = (keywords) => request.get('/search/suggest', {params: {keywords}}) 6 | 7 | export const getSearch = (params) => request.get('/search', { params }) 8 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | export * from './discovery' 2 | 3 | export * from './mv' 4 | 5 | export * from './song' 6 | 7 | export * from './playlist' 8 | 9 | export * from './artist' 10 | 11 | export * from './comment' 12 | 13 | export * from './search' 14 | 15 | export * from './user' 16 | 17 | export * from './album' -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | import { requestWithoutLoading } from "@/utils" 2 | 3 | export const getUserDetail = (uid) => requestWithoutLoading.get("/user/detail", { params: {uid}}) 4 | 5 | const PLAYLIST_LIMIT = 1000 6 | export const getUserPlaylist = (uid) => requestWithoutLoading.get("/user/playlist", {params: {uid, limit: PLAYLIST_LIMIT}}) -------------------------------------------------------------------------------- /src/api/mv.js: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils' 2 | 3 | export const getMvDetail = id => request.get(`/mv/detail?mvid=${id}`) 4 | 5 | export const getMvUrl = id => request.get(`/mv/url?id=${id}`) 6 | 7 | export const getSimiMv = id => request.get(`/simi/mv?mvid=${id}`) 8 | 9 | export const getAllMvs = (params) => request.get(`mv/all`, { params }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | # custom 25 | music -------------------------------------------------------------------------------- /src/base/empty.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /src/api/discovery.js: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils' 2 | 3 | export const getBanner = () => request.get('/banner?type=0') 4 | 5 | export const getNewSongs = () => request.get('/personalized/newsong') 6 | 7 | export const getPersonalized = params => request.get('/personalized', { params }) 8 | 9 | export const getPersonalizedMv = () => request.get('/personalized/mv') 10 | -------------------------------------------------------------------------------- /src/style/themes/variables-red.js: -------------------------------------------------------------------------------- 1 | import variablesWhite from './variables-white' 2 | 3 | export default { 4 | ...variablesWhite, 5 | ['--header-bgcolor']: '#D74D45', 6 | ['--header-font-color']: '#EFB6B2', 7 | ['--header-input-color']: '#EFB6B2', 8 | ['--header-input-bgcolor']: '#DD6861', 9 | ['--header-input-placeholder-color']: '#EFB6B2', 10 | ['--round-hover-bgcolor']: '#CA4841', 11 | } -------------------------------------------------------------------------------- /src/api/playlist.js: -------------------------------------------------------------------------------- 1 | import { request, requestWithoutLoading } from "@/utils" 2 | 3 | // 获取歌单 4 | export const getPlaylists = (params) => request.get('/top/playlist', { params }) 5 | 6 | // 精品歌单 7 | export const getTopPlaylists = (params) => request.get('/top/playlist/highquality', { params }) 8 | 9 | // 获取相似歌单 10 | export const getSimiPlaylists = (id, option) => 11 | requestWithoutLoading.get(`/simi/playlist?id=${id}`, option) 12 | -------------------------------------------------------------------------------- /src/base/title.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/base/loading.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import App from './App.vue' 4 | import router from './router' 5 | 6 | import '@/style/index.scss' 7 | import '@/utils/rem' 8 | import '@/utils/axios' 9 | import store from './store/index' 10 | import global from './utils/global' 11 | 12 | Vue.config.productionTip = false 13 | Vue.config.performance = process.env.NODE_ENV !== "production" 14 | 15 | Vue.use(global) 16 | 17 | new Vue({ 18 | router, 19 | store, 20 | render: h => h(App), 21 | }).$mount('#app') 22 | -------------------------------------------------------------------------------- /src/base/pagination.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import musicModule from './modules/music' 4 | import userModule from './modules/user' 5 | import globalModule from './modules/global' 6 | import createLogger from 'vuex/dist/logger' 7 | 8 | Vue.use(Vuex) 9 | const debug = process.env.NODE_ENV !== 'production' 10 | 11 | export default new Vuex.Store({ 12 | modules: { 13 | music: musicModule, 14 | user: userModule, 15 | global: globalModule, 16 | }, 17 | plugins: debug ? [createLogger()] : [] 18 | }) -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | export const playModeMap = { 2 | sequence: { 3 | code: 'sequence', 4 | icon: 'sequence', 5 | name: '顺序播放' 6 | }, 7 | loop: { 8 | code: 'loop', 9 | icon: 'loop', 10 | name: '单曲循环' 11 | }, 12 | random: { 13 | code: 'random', 14 | icon: 'random', 15 | name: '随机播放' 16 | }, 17 | } 18 | 19 | // 存储播放记录 20 | export const PLAY_HISTORY_KEY = '__play_history__' 21 | 22 | // 用户id 23 | export const UID_KEY = '__uid__' 24 | 25 | // 音量 26 | export const VOLUME_KEY = "__volume__" -------------------------------------------------------------------------------- /src/api/comment.js: -------------------------------------------------------------------------------- 1 | import { requestWithoutLoading } from "@/utils" 2 | 3 | // 热门评论 4 | export const getHotComment = params => 5 | requestWithoutLoading.get(`/comment/hot`, { params }) 6 | // 歌曲评论 7 | export const getSongComment = params => 8 | requestWithoutLoading.get(`/comment/music`, { params }) 9 | 10 | // 歌单评论 11 | export const getPlaylistComment = params => 12 | requestWithoutLoading.get('/comment/playlist', { params }) 13 | 14 | // Mv评论 15 | export const getMvComment = params => 16 | requestWithoutLoading.get('/comment/mv', { params }) 17 | -------------------------------------------------------------------------------- /src/api/song.js: -------------------------------------------------------------------------------- 1 | import { request, requestWithoutLoading } from "@/utils" 2 | 3 | 4 | // 获取音乐详情(支持多个id,用 , 隔开) 5 | export const getSongDetail = ids => request.get(`/song/detail?ids=${ids}`) 6 | 7 | // 新歌速递 8 | export const getTopSongs = type => request.get(`/top/song?type=${type}`) 9 | 10 | // 歌单详情 11 | export const getListDetail = params => request.get('/playlist/detail', { params }) 12 | 13 | // 相似音乐 14 | export const getSimiSongs = (id, option) => 15 | requestWithoutLoading.get(`/simi/song?id=${id}`, option) 16 | 17 | // 歌词 18 | export const getLyric = id => request.get(`/lyric?id=${id}`) -------------------------------------------------------------------------------- /src/store/modules/music/state.js: -------------------------------------------------------------------------------- 1 | import storage from "good-storage"; 2 | import { PLAY_HISTORY_KEY } from "@/utils" 3 | 4 | export default { 5 | // 当前播放歌曲 6 | currentSong: {}, 7 | // 当前播放时长 8 | currentTime: 0, 9 | // 播放状态 10 | playing: false, 11 | // 播放模式 12 | playMode: "sequence", 13 | // 播放列表显示 14 | isPlaylistShow: false, 15 | // 歌曲详情页显示 16 | isPlayerShow: false, 17 | // 播放提示显示 18 | isPlaylistPromptShow: false, 19 | // 播放列表数据 20 | playlist: [], 21 | // 播放历史数据 22 | playHistory: storage.get(PLAY_HISTORY_KEY, []), 23 | // 菜单显示 24 | isMenuShow: true, 25 | } -------------------------------------------------------------------------------- /src/page/discovery/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | 32 | 39 | -------------------------------------------------------------------------------- /src/components/share.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | 33 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echoCommon() 4 | { 5 | echo -e "\033[33m $1 \033[0m" 6 | } 7 | 8 | echoFail() 9 | { 10 | echo -e "\033[31m $1 \033[0m" 11 | } 12 | 13 | echoSuccess() 14 | { 15 | echo -e "\033[32m $1 \033[0m" 16 | } 17 | 18 | git pull 19 | echoSuccess 'git pull success' 20 | git add . 21 | echoSuccess 'git add success' 22 | echo 23 | git cz 24 | echoSuccess 'commit success' 25 | echo 26 | echoCommon "选择要发布的方式?" 27 | select var in release-major release-minor release-patch; 28 | do 29 | break 30 | done 31 | 32 | npm run $var 33 | 34 | if [ $? -eq 0 ]; then 35 | echoSuccess "release success" 36 | echoCommon "start building" 37 | git push --follow-tags origin master 38 | npm run build 39 | else 40 | echoFail "release failed" 41 | fi 42 | -------------------------------------------------------------------------------- /src/base/nbutton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/routes-history.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | 31 | -------------------------------------------------------------------------------- /src/utils/rem.js: -------------------------------------------------------------------------------- 1 | import { throttle } from './common' 2 | 3 | export const remBase = 14 4 | 5 | let htmlFontSize 6 | !(function() { 7 | const calc = function() { 8 | const maxFontSize = 18 9 | const minFontSize = 14 10 | const html = document.getElementsByTagName('html')[0] 11 | const width = html.clientWidth 12 | let size = remBase * (width / 1440) 13 | size = Math.min(maxFontSize, size) 14 | size = Math.max(minFontSize, size) 15 | html.style.fontSize = size + 'px' 16 | htmlFontSize = size 17 | } 18 | calc() 19 | window.addEventListener('resize', throttle(calc, 500)) 20 | })() 21 | 22 | // 根据基准字号计算 23 | // 用于静态样式 24 | export function toRem(px) { 25 | return `${px / remBase}rem` 26 | } 27 | 28 | // 根据当前的html根字体大小计算 29 | // 用于某些js的动态计算 30 | export function toCurrentRem(px) { 31 | return `${px / htmlFontSize}rem` 32 | } -------------------------------------------------------------------------------- /src/base/play-icon.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | 36 | -------------------------------------------------------------------------------- /src/page/discovery/banner.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | 27 | 40 | -------------------------------------------------------------------------------- /src/style/variables.scss: -------------------------------------------------------------------------------- 1 | $theme-color: #d33a31; 2 | $black: #000; 3 | $white: #fff; 4 | $gold: #e7aa5a; 5 | $blue: #517eaf; 6 | 7 | $font-size: 14px; 8 | $font-size-medium-sm: 13px; 9 | $font-size-lg: 16px; 10 | $font-size-sm: 12px; 11 | $font-size-xs: 10px; 12 | $font-size-medium: 15px; 13 | $font-size-title: 18px; 14 | $font-size-title-lg: 24px; 15 | 16 | $font-weight-bold: 700; 17 | 18 | $font-color-transparent: rgba(255, 255, 255, 0.5); 19 | 20 | $border: 1px solid #3f3f3f; 21 | 22 | $page-padding: 16px 32px; 23 | // layout 24 | $layout-content-min-width: 700px; 25 | // content 26 | $center-content-max-width: 1000px; 27 | // header 28 | $header-height: 50px; 29 | 30 | // mini-player 31 | $mini-player-height: 60px; 32 | $mini-player-z-index: 1002; 33 | 34 | //playlist 35 | $playlist-z-index: 1001; 36 | 37 | //search 38 | $search-panel-z-index: 1001; 39 | 40 | //song-detail 41 | $song-detail-z-index: 1000; 42 | -------------------------------------------------------------------------------- /src/store/modules/user/actions.js: -------------------------------------------------------------------------------- 1 | import storage from 'good-storage' 2 | import { getUserDetail, getUserPlaylist } from "@/api" 3 | import { UID_KEY } from "@/utils" 4 | import {notify, isDef } from "@/utils" 5 | 6 | export default { 7 | async login ({ commit }, uid) { 8 | const error = () => { 9 | notify.error('登陆失败,请输入正确的uid。') 10 | return false 11 | } 12 | if (!isDef(uid)) { 13 | return error() 14 | } 15 | 16 | try { 17 | const user = await getUserDetail(uid) 18 | const { profile } = user 19 | commit('setUser', profile) 20 | storage.set(UID_KEY, profile.userId) 21 | } catch (e) { 22 | return error() 23 | } 24 | 25 | const { playlist } = await getUserPlaylist(uid) 26 | commit('setUserPlaylist', playlist) 27 | return true 28 | }, 29 | logout ({ commit }) { 30 | commit('setUser', {}) 31 | commit('setUserPlaylist', []) 32 | storage.set(UID_KEY, null) 33 | } 34 | } -------------------------------------------------------------------------------- /src/page/discovery/new-playlists.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 36 | 37 | -------------------------------------------------------------------------------- /src/page/discovery/new-mvs.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 43 | 44 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const WorkboxPlugin = require("workbox-webpack-plugin") 2 | 3 | const isProd = process.env.NODE_ENV === 'production' 4 | 5 | module.exports = { 6 | outputDir: 'music', 7 | configureWebpack: { 8 | devServer: { 9 | open: true, 10 | port: 8998, 11 | }, 12 | externals: isProd ? { 13 | vue: 'Vue', 14 | 'vue-router': 'VueRouter', 15 | vuex: 'Vuex', 16 | axios: 'axios', 17 | } : {}, 18 | plugins: [ 19 | new WorkboxPlugin.GenerateSW() 20 | ] 21 | }, 22 | css: { 23 | loaderOptions: { 24 | postcss: { 25 | plugins: [ 26 | require('postcss-pxtorem')({ // 把px单位换算成rem单位 27 | rootValue: 14, // 换算的基数(设计图750的根字体为75) 28 | // selectorBlackList: ['weui', 'mu'], // 忽略转换正则匹配项 29 | propList: ['*'] 30 | }) 31 | ] 32 | }, 33 | sass: { 34 | prependData: ` 35 | @import "@/style/variables.scss"; 36 | @import "@/style/mixin.scss"; 37 | ` 38 | }, 39 | }, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/style/app.scss: -------------------------------------------------------------------------------- 1 | /** 2 | ** 应用级别的一些样式 3 | **/ 4 | body { 5 | color: var(--font-color); 6 | } 7 | 8 | ::selection { 9 | background-color: $theme-color; 10 | color: white; 11 | } 12 | 13 | // 滚动条 14 | ::-webkit-scrollbar-track { 15 | background-color: var(--menu-bgcolor); 16 | } 17 | 18 | ::-webkit-scrollbar { 19 | width: 5px; 20 | height: 5px; 21 | background-color: #f5f5f5; 22 | } 23 | 24 | ::-webkit-scrollbar-thumb { 25 | background-color: var(--scrollbar-color); 26 | } 27 | 28 | .slide-enter-active, 29 | .slide-leave-active { 30 | transition: all 0.5s; 31 | transform: none; 32 | } 33 | .slide-enter, 34 | .slide-leave-to { 35 | transform: translateY(100%); 36 | } 37 | 38 | .fade-enter-active, 39 | .fade-leave-active { 40 | transition: all 0.5s; 41 | opacity: 1; 42 | } 43 | .fade-enter, 44 | .fade-leave-to { 45 | opacity: 0; 46 | } 47 | 48 | // 转化为rem 49 | .iconfont { 50 | font-size: 16px; 51 | } 52 | 53 | a { 54 | color: $theme-color; 55 | 56 | &:hover, 57 | &:focus, 58 | &:visited { 59 | color: $theme-color; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 杭少爱吸猫 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/base/highlight-text.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | -------------------------------------------------------------------------------- /src/store/modules/music/mutations.js: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from "@/utils" 2 | 3 | export default { 4 | setCurrentSong(state, song) { 5 | state.currentSong = song 6 | }, 7 | setCurrentTime(state, time) { 8 | state.currentTime = time 9 | }, 10 | setPlayingState(state, playing) { 11 | state.playing = playing 12 | }, 13 | setPlayMode(state, mode) { 14 | state.playMode = mode 15 | }, 16 | setPlaylistShow(state, show) { 17 | state.isPlaylistShow = show 18 | }, 19 | setPlayerShow(state, show) { 20 | state.isPlayerShow = show 21 | }, 22 | setMenuShow(state, show) { 23 | state.isMenuShow = show 24 | }, 25 | setPlaylistPromptShow(state, show) { 26 | state.isPlaylistPromptShow = show 27 | }, 28 | setPlaylist(state, playlist) { 29 | const { isPlaylistShow, playlist: oldPlaylist } = state 30 | state.playlist = playlist 31 | // 播放列表未显示 并且两次播放列表的不一样 则弹出提示 32 | if (!isPlaylistShow && !shallowEqual(oldPlaylist, playlist, 'id')) { 33 | state.isPlaylistPromptShow = true 34 | setTimeout(() => { 35 | state.isPlaylistPromptShow = false 36 | }, 2000) 37 | } 38 | }, 39 | setPlayHistory(state, history) { 40 | state.playHistory = history 41 | } 42 | } -------------------------------------------------------------------------------- /src/store/modules/user/getters.js: -------------------------------------------------------------------------------- 1 | import { isDef } from "@/utils" 2 | 3 | export const isLogin = (state) => isDef(state.user.userId) 4 | 5 | // 根据用户请求的数据整合出菜单 6 | export const userMenus = (state) => { 7 | const { user, userPlaylist} = state 8 | const retMenus = [] 9 | const userCreatelist = [] 10 | const userCollectList = [] 11 | 12 | userPlaylist.forEach(playlist => { 13 | const { userId } = playlist 14 | if (user.userId === userId) { 15 | userCreatelist.push(playlist) 16 | } else { 17 | userCollectList.push(playlist) 18 | } 19 | }) 20 | 21 | const genPlaylistChildren = playlist => 22 | playlist.map(({ id, name }) => ({ 23 | path: `/playlist/${id}`, 24 | meta: { 25 | title: name, 26 | icon: "playlist-menu" 27 | }, 28 | })) 29 | 30 | if (userCreatelist.length) { 31 | retMenus.push({ 32 | type: "playlist", 33 | title: "创建的歌单", 34 | children: genPlaylistChildren(userCreatelist) 35 | }) 36 | } 37 | 38 | if (userCollectList.length) { 39 | retMenus.push({ 40 | type: "playlist", 41 | title: "收藏的歌单", 42 | children: genPlaylistChildren(userCollectList) 43 | }) 44 | } 45 | 46 | return retMenus 47 | } -------------------------------------------------------------------------------- /src/utils/dom.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | let elementStyle = document.createElement('div').style 4 | 5 | let vendor = ( () => { 6 | let transformNames = { 7 | // safari浏览器 8 | webkit: 'webkitTransform', 9 | // Firefox浏览器 10 | Moz: 'MozTransform', 11 | // Opera浏览器 12 | O: 'OTransform', 13 | // IE 14 | ms: 'msTransform', 15 | standard: 'transform' 16 | } 17 | for (let key in transformNames) { 18 | if (elementStyle[transformNames[key]] !== undefined) { 19 | return key 20 | } 21 | } 22 | return false 23 | })() 24 | 25 | export function prefixStyle(style) { 26 | if (vendor === false) { 27 | return false 28 | } 29 | 30 | if (vendor === 'standard') { 31 | return style 32 | } 33 | 34 | return vendor + style.charAt(0).toUpperCase() + style.substr(1) 35 | } 36 | 37 | export function hasParent(dom, parentDom) { 38 | parentDom = Array.isArray(parentDom) ? parentDom : [parentDom] 39 | while(dom) { 40 | if (parentDom.find(p => p === dom)) { 41 | return true 42 | } else { 43 | dom = dom.parentNode 44 | } 45 | } 46 | } 47 | 48 | export function scrollInto(dom) { 49 | dom.scrollIntoView({ behavior: "smooth" }) 50 | } 51 | 52 | export const EMPTY_IMG = "data:image/gif;base64, R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" -------------------------------------------------------------------------------- /src/base/scroller.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 55 | 56 | 63 | -------------------------------------------------------------------------------- /src/utils/global.js: -------------------------------------------------------------------------------- 1 | import { 2 | Input, 3 | Dialog, 4 | Button, 5 | Loading, 6 | Carousel, 7 | CarouselItem, 8 | Table, 9 | TableColumn, 10 | Popover, 11 | Pagination 12 | } from "element-ui" 13 | import Meta from "vue-meta" 14 | import VueLazyload from 'vue-lazyload' 15 | import * as utils from './index' 16 | import { EMPTY_IMG } from './dom' 17 | 18 | export default { 19 | install(Vue) { 20 | const requireComponent = require.context( 21 | "@/base", 22 | true, 23 | /[a-z0-9]+\.(jsx?|vue)$/i, 24 | ) 25 | //批量注册base组件 26 | requireComponent.keys().forEach(fileName => { 27 | const componentConfig = requireComponent(fileName) 28 | const componentName = componentConfig.default.name 29 | if(componentName) { 30 | Vue.component(componentName, componentConfig.default || componentConfig) 31 | } 32 | }) 33 | // 修改默认尺寸大小 34 | Vue.prototype.$ELEMENT = { size: 'small' } 35 | Vue.prototype.$utils = utils 36 | 37 | Vue.use(Input) 38 | Vue.use(Dialog) 39 | Vue.use(Button) 40 | Vue.use(Loading) 41 | Vue.use(Carousel) 42 | Vue.use(CarouselItem) 43 | Vue.use(Table) 44 | Vue.use(TableColumn) 45 | Vue.use(Popover) 46 | Vue.use(Pagination) 47 | 48 | Vue.use(Meta) 49 | 50 | Vue.use(VueLazyload, { 51 | loading: EMPTY_IMG, 52 | error: EMPTY_IMG, 53 | }) 54 | } 55 | } -------------------------------------------------------------------------------- /src/base/video-player.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 54 | 55 | -------------------------------------------------------------------------------- /src/base/toggle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 58 | 59 | -------------------------------------------------------------------------------- /src/style/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin text-ellipsis() { 2 | overflow: hidden; 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | } 6 | 7 | @mixin text-ellipsis-multi($line) { 8 | display: -webkit-box; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | -webkit-line-clamp: $line; 12 | -webkit-box-orient: vertical; 13 | } 14 | 15 | @mixin flex-center($direction: row) { 16 | display: flex; 17 | flex-direction: $direction; 18 | align-items: center; 19 | justify-content: center; 20 | } 21 | 22 | @mixin box-shadow { 23 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2); 24 | } 25 | 26 | @mixin img-wrap($width, $height: $width) { 27 | width: $width; 28 | height: $height; 29 | flex-shrink: 0; 30 | 31 | img { 32 | width: 100%; 33 | height: 100%; 34 | } 35 | } 36 | 37 | @mixin abs-stretch { 38 | position: absolute; 39 | left: 0; 40 | bottom: 0; 41 | top: 0; 42 | right: 0; 43 | } 44 | 45 | @mixin abs-center { 46 | position: absolute; 47 | left: 50%; 48 | top: 50%; 49 | transform: translate(-50%, -50%); 50 | } 51 | 52 | @mixin round($d) { 53 | width: $d; 54 | height: $d; 55 | border-radius: 50%; 56 | } 57 | 58 | @mixin list($item-width) { 59 | .list-wrap { 60 | display: flex; 61 | flex-wrap: wrap; 62 | margin: 0 -12px; 63 | 64 | .list-item { 65 | width: $item-width; 66 | margin-bottom: 36px; 67 | padding: 0 12px; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/store/modules/music/actions.js: -------------------------------------------------------------------------------- 1 | import storage from 'good-storage' 2 | import { PLAY_HISTORY_KEY, getSongImg } from "@/utils" 3 | 4 | export default { 5 | // 整合歌曲信息 并且开始播放 6 | async startSong({ commit, state }, rawSong) { 7 | // 浅拷贝一份 改变引用 8 | // 1.不污染元数据 9 | // 2.单曲循环为了触发watch 10 | const song = Object.assign({}, rawSong) 11 | if (!song.img) { 12 | if (song.albumId) { 13 | song.img = await getSongImg(song.id, song.albumId) 14 | } 15 | } 16 | commit('setCurrentSong', song) 17 | commit('setPlayingState', true) 18 | // 历史记录 19 | const { playHistory } = state 20 | const playHistoryCopy = playHistory.slice() 21 | const findedIndex = playHistoryCopy.findIndex(({id}) => song.id === id) 22 | // 删除旧那一项 插入到最前面 23 | if (findedIndex !== -1) { 24 | playHistoryCopy.splice(findedIndex, 1) 25 | } 26 | playHistoryCopy.unshift(song) 27 | commit('setPlayHistory', playHistoryCopy) 28 | storage.set(PLAY_HISTORY_KEY, playHistoryCopy) 29 | }, 30 | clearPlaylist({ commit }) { 31 | commit('setPlaylist', []) 32 | }, 33 | clearHistory({ commit }) { 34 | const history = [] 35 | commit('setPlayHistory', history) 36 | storage.set(PLAY_HISTORY_KEY, history) 37 | }, 38 | addToPlaylist({ commit, state }, song) { 39 | const { playlist } = state 40 | const copy = playlist.slice() 41 | if (!copy.find(({id}) => id === song.id)) { 42 | copy.unshift(song) 43 | commit('setPlaylist', copy) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/utils/business.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 业务工具方法 3 | */ 4 | import { getAlbum, getMvDetail } from "@/api" 5 | import router from '../router' 6 | import { isDef, notify } from "./common" 7 | 8 | export function createSong (song) { 9 | const { id, name, img, artists, duration, albumId, albumName, mvId, ...rest } = song 10 | 11 | return { 12 | id, 13 | name, 14 | img, 15 | artists, 16 | duration, 17 | albumName, 18 | url: genSongPlayUrl(song.id), 19 | artistsText: getArtistsText(artists), 20 | durationSecond: duration / 1000, 21 | // 专辑 如果需要额外请求封面的话必须加上 22 | albumId, 23 | // mv的id 如果有的话会在songTable组件中加上mv链接 24 | mvId, 25 | ...rest 26 | } 27 | } 28 | 29 | export async function getSongImg (id, albumId) { 30 | if (!isDef(albumId)) { 31 | throw new Error('need albumId') 32 | } 33 | const { songs } = await getAlbum(albumId) 34 | const { 35 | al: { picUrl } 36 | } = songs.find(({ id: songId }) => songId === id) || {} 37 | return picUrl 38 | } 39 | 40 | export function getArtistsText (artists) { 41 | return (artists || []).map(({ name }) => name).join('/') 42 | } 43 | 44 | // 有时候虽然有mvId 但是请求却404,所以跳转前先请求一把 45 | export async function goMvWithCheck (id) { 46 | try { 47 | await getMvDetail(id) 48 | goMv(id) 49 | } catch (error) { 50 | notify("mv获取失败") 51 | } 52 | } 53 | 54 | export function goMv (id) { 55 | router.push(`/mv/${id}`) 56 | } 57 | 58 | function genSongPlayUrl(id) { 59 | return `https://music.163.com/song/media/outer/url?id=${id}.mp3` 60 | } -------------------------------------------------------------------------------- /src/base/card.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎵基于Vue2、Vue-CLI3的高仿网易云mac客户端播放器(PC) Online Music Player 2 | 3 | 音乐播放器虽然烂大街了,但是作为前端没自己撸一个一直是个遗憾,而且偶然间发现 pc 端 web 版的网易云音乐做的实在是太简陋了,社区仿 pc 客户端的网易云也不多见,为了弥补这个遗憾,就用 vue 全家桶模仿 mac 客户端的 ui 实现了一个,欢迎提出意见和 star~ 4 | 5 | 💐[预览地址](https://www.musics.net.cn) 6 | 7 | 💐[源码地址](https://github.com/JYbmarawcp/vue-netease-music) 8 | 9 | ### 进度 10 | 11 | - [x] mv 页(3.0 新增) 12 | - [x] cd 页 (2.0 新增) 13 | - [x] 搜索建议 14 | - [x] 搜索详情 15 | - [x] 播放(版权歌曲无法播放) 16 | - [x] 发现页 17 | - [x] 播放列表 18 | - [x] 播放记录 19 | - [x] 全部歌单 20 | - [x] 歌单详情 21 | - [x] 最新音乐 22 | - [x] 主题换肤功能 23 | - [x] 登录(网易云 uid 登录) 24 | 25 | ### 后端接口 26 | 27 | https://binaryify.github.io/NeteaseCloudMusicApi 28 | 29 | ### 技术栈 30 | 31 | - ***Vue*** 全家桶 Vue-CLI3 create 出来的。 32 | - ***Element-Ui*** 魔改样式。 33 | - ***better-scroll*** 歌词滚动部分用了黄轶老大的 (贼爽) 34 | - ***CSS Variables*** 主题换肤。 35 | - ***ES 6 / 7*** (JavaScript 语言的下一代标准) 36 | - ***Sass***(CSS 预处理器) 37 | - ***postcss-pxtorem***(自动处理 rem,妈妈再也不用担心屏幕太大太小了) 38 | - ***workbox-webpack-plugin*** 谷歌开发的利用 Service Work 预缓存 chunks 的 webpack 插件。 39 | 40 | ### Screenshots 41 | 42 | ![首页](https://user-images.githubusercontent.com/23615778/62509203-da358580-b83c-11e9-97b3-367fb06a8347.png) 43 | 44 | ![歌单列表](https://user-images.githubusercontent.com/23615778/62509204-dace1c00-b83c-11e9-8d3f-0bcb93e3aab7.png) 45 | 46 | ![歌单详情](https://user-images.githubusercontent.com/23615778/62509201-d99cef00-b83c-11e9-8e4b-b122b8b94468.png) 47 | 48 | ![音乐播放](https://user-images.githubusercontent.com/23615778/62509202-da358580-b83c-11e9-98e1-530e5741ff56.png) 49 | 50 | ### 安装与使用 51 | 52 | ``` 53 | npm i 54 | npm run dev 55 | ``` 56 | 57 | ### 友情链接 58 | [mmPlayer](https://github.com/maomao1996/Vue-mmPlayer) 59 | -------------------------------------------------------------------------------- /src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/song-card.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /src/utils/lrcparse.js: -------------------------------------------------------------------------------- 1 | export default function lyricParser (lrc) { 2 | return { 3 | 'lyric': parseLyric(lrc.lrc.lyric || ''), 4 | 'tlyric': parseLyric(lrc.tlyric.lyric || ''), 5 | 'lyricuser': lrc.lyricUser, 6 | 'transUser': lrc.transUser, 7 | } 8 | } 9 | 10 | export function parseLyric (lrc) { 11 | const lyrics = lrc.split('\n') 12 | const lrcObj = [] 13 | for (let i = 0; i < lyrics.length; i++) { 14 | const lyric = decodeURIComponent(lyrics[i]) 15 | const timeReg = /\[\d*:\d*((\.|:)\d*)*\]/g 16 | const timeRegExpArr = lyric.match(timeReg) 17 | if (!timeRegExpArr) continue 18 | const content = lyric.replace(timeReg, '') 19 | for (let k = 0, h = timeRegExpArr.length; k < h; k++) { 20 | const t = timeRegExpArr[k] 21 | const min = Number(String(t.match(/\[\d*/i)).slice(1)) 22 | const sec = Number(String(t.match(/:\d*/i)).slice(1)) 23 | const time = min * 60 + sec 24 | if (content !== '') { 25 | lrcObj.push({ time: time, content }) 26 | } 27 | } 28 | } 29 | return lrcObj 30 | } 31 | 32 | // lyric: "[00:00.000] 作曲 : 爱星人↵[00:00.826] 作词 : 爱星人↵[00:02.478]作曲:爱星人↵[00:03.559]作词:爱星人↵[00:04.533]编曲:爱星人↵[00:05.642]吉他:爱星人,Jeff(菲律宾),庄高你很爱我↵[02:59.643]只是那爱情出错↵[03:04.540]爱如天气如火花的你↵[03:09.193]在心中与我对望OH↵[03:21.274]在心中与我对望 Woo ~↵[03:26.559]爱与你对望 Woo ~↵[03:31.411]爱与你对望↵[03:33.455]Shorty↵[03:34.939]you gotta get that↵[03:36.483]Sometimes Men will go little whack↵[03:40.124]they just wanna find a Hera(赫拉女神,宙斯妻子)↵[03:42.265]but can‘t take a moment to get to know her↵[03:45.689]Don't worry↵[03:47.238]I'm not one of their kind.Everything will be fine.↵" 33 | // tlyric: "[59]↵[03:31.411]↵[03:33.455]Shtory↵[03:34.939]你要明白↵[03:36.483]有时男人会变得失常↵[03:40.124]他们只中意追寻赫拉↵[03:42.265]但却不能费时体会她↵[03:45.689]不要担心↵[03:47.238]我不是他们中的一员 一切都会好的↵" 34 | 35 | -------------------------------------------------------------------------------- /src/style/themes/variables.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ['--body-bgcolor']: '#252525', 3 | ['--light-bgcolor']: '#2e2e2e', 4 | 5 | ['--font-color']: '#b1b1b1', 6 | ['--font-color-shallow']: '#6f6f6f', 7 | ['--font-color-white']: '#dcdde4', 8 | ['--font-color-grey']: '#5C5C5C', 9 | ['--font-color-grey2']: '#808080', 10 | ['--font-color-grey-shallow']: '#727272', 11 | ['--border']: '#3F3F3F', 12 | ['--scrollbar-color']: '#3a3a3a', 13 | ['--round-hover-bgcolor']: '#373737', 14 | ['--stripe-bg']: '#323232', 15 | ['--shallow-theme-bgcolor']: '#2D2625', 16 | ['--shallow-theme-bgcolor-hover']: '#352726', 17 | 18 | //header 19 | ['--header-bgcolor']: '#252525', 20 | ['--header-font-color']: '#b1b1b1', 21 | ['--header-input-color']: '#b1b1b1', 22 | ['--header-input-bgcolor']: '#4B4B4B', 23 | ['--header-input-placeholder-color']: '#727272', 24 | 25 | //menu 26 | ['--menu-bgcolor']: '#202020', 27 | ['--menu-item-hover-bg']: '#1d1d1d', 28 | ['--menu-item-active-bg']: '#1b1b1b', 29 | 30 | //player 31 | ['--player-bgcolor']: '#252525', 32 | 33 | //playlist 34 | ['--playlist-bgcolor']: '#363636', 35 | ['--playlist-hover-bgcolor']: '#2e2e2e', 36 | 37 | //search 38 | ['--search-bgcolor']: '#363636', 39 | 40 | //progress 41 | ['--progress-bgcolor']: '#232323', 42 | 43 | //input 44 | ['--input-color']: '#b1b1b1', 45 | ['--input-bgcolor']: '#4B4B4B', 46 | 47 | //button 48 | ['--button-border-color']: '#454545', 49 | ['--button-hover-bgcolor']: '#3E3E3E', 50 | //tab 51 | ['--tab-item-color']: '#797979', 52 | ['--tab-item-hover-color']: '#B4B4B4', 53 | ['--tab-item-active-color']: '#fff', 54 | //modal 55 | ['--modal-bg-color']: '#202020', 56 | // prompt 57 | ['--prompt-bg-color']: '#363636', 58 | //song-detail 59 | ['--song-shallow-grey-bg']: '#2A2A2A', 60 | } -------------------------------------------------------------------------------- /src/style/themes/variables-white.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ['--body-bgcolor']: '#fff', 3 | ['--light-bgcolor']: '#f5f5f5', 4 | 5 | ['--font-color']: '#4a4a4a', 6 | ['--font-color-shallow']: '#404040', 7 | ['--font-color-white']: '#333333', 8 | ['--font-color-grey']: '#5c5c5c', 9 | ['--font-color-grey2']: '#909090', 10 | ['--font-color-grey-shallow']: '#BEBEBE', 11 | ['--border']: '#f2f2f1', 12 | ['--scrollbar-color']: '#D0D0D0', 13 | ['--round-hover-bgcolor']: '#EBEBEB', 14 | ['--stripe-bg']: '#FAFAFA', 15 | ['--shallow-theme-bgcolor']: '#fdf6f5', 16 | ['--shallow-theme-bgcolor-hover']: '#FBEDEC', 17 | 18 | //header 19 | ['--header-bgcolor']: '#F9F9F9', 20 | ['--header-font-color']: '#4a4a4a', 21 | ['--header-input-color']: '#4a4a4a', 22 | ['--header-input-bgcolor']: '#EDEDED', 23 | ['--header-input-placeholder-color']: '#BEBEBE', 24 | 25 | // menu 26 | ['--menu-bgcolor']: '#ededed', 27 | ['--menu-item-hover-bg']: '#e7e7e7', 28 | ['--menu-item-active-bg']: '#e2e2e2', 29 | 30 | //player 31 | ['--player-bgcolor']: '#F9F9F9', 32 | 33 | //playlist 34 | ['--playlist-bgcolor']: '#fff', 35 | ['--playlist-hover-bgcolor']: '#EFEFEF', 36 | 37 | //search 38 | ['--search-bgcolor']: '#fff', 39 | //progress 40 | ['--progress-bgcolor']: '#F5F5F5', 41 | 42 | //input 43 | ['--input-color']: '#4a4a4a', 44 | ['--input-bgcolor']: '#EDEDED', 45 | 46 | //button 47 | ['--button-border-color']: '#D9D9D9', 48 | ['--button-hover-bgcolor']: '#F5F5F5', 49 | 50 | //tab 51 | ['--tab-item-color']: '#7F7F81', 52 | ['--tab-item-hover-color']: '#343434', 53 | ['--tab-item-active-color']: '#000', 54 | 55 | //modal 56 | ['--modal-bg-color']: '#F9F9F9', 57 | // prompt 58 | ['--prompt-bg-color']: '#fff', 59 | //song-detail 60 | ['--song-shallow-grey-bg']: '#E6E5E6', 61 | } 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Features 2 | 3 | * 新增百度统计 ([30a017a](https://github.com/JYbmarawcp/vue-netease-music/commit/30a017acd28d19fd4d8fc2efebe1ca21533839c8)) 4 | * 获取歌手单曲 ([ccfcd6a](https://github.com/JYbmarawcp/vue-netease-music/commit/ccfcd6a23f142a0a93e4718900192f94bc0d40d9)) 5 | 6 | ## [1.0.0](https://github.com/JYbmarawcp/vue-netease-music/compare/v0.1.1...v1.0.0) (2020-08-13) 7 | 8 | 9 | ### Features 10 | 11 | * 添加了预缓存和vuex打印日志 ([ea841ef](https://github.com/JYbmarawcp/vue-netease-music/commit/ea841ef493400ce8960cb3d7f370d0a30aef8d61)) 12 | * 预览地址 ([a3c48e9](https://github.com/JYbmarawcp/vue-netease-music/commit/a3c48e9d9dac9e6d0ccad25bd90e7b5aa8278b6c)) 13 | * 预览地址 ([896e214](https://github.com/JYbmarawcp/vue-netease-music/commit/896e21463337aeb0ffdd4a6a2df715dfc4dbe296)) 14 | 15 | ## [0.2.0](https://github.com/JYbmarawcp/vue-netease-music/compare/v0.1.1...v0.2.0) (2020-08-13) 16 | 17 | 18 | ### Features 19 | 20 | * 添加了预缓存和vuex打印日志 ([ea841ef](https://github.com/JYbmarawcp/vue-netease-music/commit/ea841ef493400ce8960cb3d7f370d0a30aef8d61)) 21 | * 预览地址 ([a3c48e9](https://github.com/JYbmarawcp/vue-netease-music/commit/a3c48e9d9dac9e6d0ccad25bd90e7b5aa8278b6c)) 22 | * 预览地址 ([896e214](https://github.com/JYbmarawcp/vue-netease-music/commit/896e21463337aeb0ffdd4a6a2df715dfc4dbe296)) 23 | 24 | ### [0.1.1](https://github.com/JYbmarawcp/vue-netease-music/compare/v0.2.0...v0.1.1) (2020-08-11) 25 | 26 | 27 | ### Features 28 | 29 | * workboxPlugin ([8b21134](https://github.com/JYbmarawcp/vue-netease-music/commit/8b2113480c14851e42ee535fc02febfb37dd2af3)) 30 | 31 | ## 0.2.0 (2020-08-11) 32 | 33 | 34 | ### Features 35 | 36 | * 优化自动commitChangeLog ([a5f855f](https://github.com/JYbmarawcp/vue-netease-music/commit/a5f855f1932b2833a27c015043db59b671fe10a4)) 37 | * 合并 ([d4ac624](https://github.com/JYbmarawcp/vue-netease-music/commit/d4ac624e0de902326bbfabec3365d14bc1133f73)) 38 | 39 | # 0.1.0 (2020-08-11) 40 | -------------------------------------------------------------------------------- /src/page/songs/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 72 | 73 | -------------------------------------------------------------------------------- /src/base/volume.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 65 | 66 | 82 | -------------------------------------------------------------------------------- /src/page/search/mvs.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 66 | 67 | -------------------------------------------------------------------------------- /src/page/search/playlists.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 60 | 61 | -------------------------------------------------------------------------------- /src/components/with-pagination.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 78 | 79 | -------------------------------------------------------------------------------- /src/base/confirm.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 74 | 75 | 89 | -------------------------------------------------------------------------------- /src/components/playlist-card.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-netease-music", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "commit": "git-cz", 10 | "release-f": "standard-version -f", 11 | "release-major": "standard-version -r major", 12 | "release-minor": "standard-version -r minor", 13 | "release-patch": "standard-version -r patch" 14 | }, 15 | "dependencies": { 16 | "@better-scroll/core": "^2.0.0-beta.10", 17 | "@better-scroll/mouse-wheel": "^2.0.0-beta.10", 18 | "@better-scroll/scroll-bar": "^2.0.0-beta.10", 19 | "axios": "^0.19.2", 20 | "clipboard": "^2.0.6", 21 | "core-js": "^3.6.5", 22 | "element-ui": "^2.13.2", 23 | "good-storage": "^1.1.1", 24 | "lodash-es": "^4.17.15", 25 | "node-sass": "^4.14.1", 26 | "vue": "^2.6.11", 27 | "vue-lazyload": "^1.3.3", 28 | "vue-meta": "^2.4.0", 29 | "vue-router": "^3.3.4", 30 | "vuex": "^3.5.1", 31 | "xgplayer": "^2.9.8" 32 | }, 33 | "devDependencies": { 34 | "@vue/cli-plugin-babel": "~4.4.0", 35 | "@vue/cli-plugin-eslint": "~4.4.0", 36 | "@vue/cli-service": "~4.4.0", 37 | "babel-eslint": "^10.1.0", 38 | "babel-plugin-component": "^1.1.1", 39 | "commitizen": "^4.1.2", 40 | "cz-conventional-changelog": "^3.2.0", 41 | "eslint": "^6.7.2", 42 | "eslint-plugin-vue": "^6.2.2", 43 | "postcss-pxtorem": "^5.1.1", 44 | "sass-loader": "^8.0.0", 45 | "standard-version": "^8.0.2", 46 | "vue-cli-plugin-element": "~1.0.1", 47 | "vue-template-compiler": "^2.6.11", 48 | "workbox-webpack-plugin": "^5.1.3" 49 | }, 50 | "eslintConfig": { 51 | "root": true, 52 | "env": { 53 | "node": true 54 | }, 55 | "extends": [ 56 | "plugin:vue/essential", 57 | "eslint:recommended" 58 | ], 59 | "parserOptions": { 60 | "parser": "babel-eslint" 61 | }, 62 | "rules": {} 63 | }, 64 | "browserslist": [ 65 | "> 1%", 66 | "last 2 versions", 67 | "not dead" 68 | ], 69 | "config": { 70 | "commitizen": { 71 | "path": "./node_modules/cz-conventional-changelog" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/base/icon.vue: -------------------------------------------------------------------------------- 1 | 2 | 69 | 70 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | 28 | 29 | 30 |
31 | 32 | <% if ( NODE_ENV === 'production' ) { %> 33 | 34 | 35 | 36 | 37 | <%} %> 38 | 39 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/utils/axios.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Loading } from "element-ui"; 3 | import { confirm } from "@/base/confirm"; 4 | import store from "@/store"; 5 | 6 | const BASE_URL = "https://api.mtnhao.com/"; 7 | //不带全局loading的请求实例 8 | export const requestWithoutLoading = createBaseInstance(); 9 | //带全局loading的请求实例 10 | //传入函数是因为需要在处理请求结果handleResponse之前处理loading 11 | //所以要在内部插入loading拦截器的处理逻辑 12 | export const request = createBaseInstance(); 13 | mixinLoading(request.interceptors) 14 | //通用的axios的实例 15 | function createBaseInstance() { 16 | const instance = axios.create({ 17 | baseURL: BASE_URL, 18 | }); 19 | 20 | instance.interceptors.response.use(handleResponse, handleError); 21 | return instance; 22 | } 23 | 24 | function handleError(e) { 25 | confirm(e.message, "出错啦!"); 26 | throw e; 27 | } 28 | //返回响应的结果data 29 | function handleResponse(response) { 30 | return response.data; 31 | } 32 | 33 | let loading; 34 | let loadingCount = 0 35 | const SET_AXIOS_LOADING = 'global/setAxiosLoading' 36 | function mixinLoading(interceptors) { 37 | interceptors.request.use(loadingRequestInterceptor) 38 | interceptors.response.use( 39 | loadingResponseInterceptor, 40 | loadingResponseErrorInterceptor 41 | ) 42 | 43 | function loadingRequestInterceptor(config) { 44 | if (!loading) { 45 | loading = Loading.service({ 46 | target: 'body', 47 | background: 'transparent', 48 | text: '载入中' 49 | }) 50 | store.commit(SET_AXIOS_LOADING, true) 51 | } 52 | loadingCount++ 53 | 54 | return config 55 | } 56 | 57 | function loadingResponseInterceptor(response) { 58 | handleResponseLoading() 59 | return response 60 | } 61 | 62 | function loadingResponseErrorInterceptor(e) { 63 | handleResponseLoading() 64 | throw e 65 | } 66 | 67 | function handleResponseLoading() { 68 | loadingCount-- 69 | if (loadingCount === 0) { 70 | loading.close() 71 | loading = null 72 | store.commit(SET_AXIOS_LOADING, false) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/components/top-playlist-card.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 31 | 32 | -------------------------------------------------------------------------------- /src/page/search/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 73 | 74 | -------------------------------------------------------------------------------- /src/components/comment.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/mv-card.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 34 | 35 | -------------------------------------------------------------------------------- /src/store/modules/music/getters.js: -------------------------------------------------------------------------------- 1 | import { isDef, playModeMap } from "@/utils" 2 | 3 | export const currentIndex = (state) => { 4 | const { currentSong, playlist } = state 5 | return playlist.findIndex(({ id }) => id === currentSong.id) 6 | } 7 | 8 | export const nextSong = (state, getters) => { 9 | const { playlist, playMode } = state 10 | const nextStartMap = { 11 | [playModeMap.sequence.code]: getSequenceNextIndex, 12 | [playModeMap.loop.code]: getLoopNextIndex, 13 | [playModeMap.random.code]: getRandomNextIndex 14 | } 15 | const getNextStart = nextStartMap[playMode] 16 | const index = getNextStart() 17 | 18 | return playlist[index] 19 | 20 | // 顺序 21 | function getSequenceNextIndex () { 22 | let nextIndex = getters.currentIndex + 1 23 | if (nextIndex > playlist.length - 1) { 24 | nextIndex = 0 25 | } 26 | return nextIndex 27 | } 28 | 29 | // 随机 30 | function getRandomNextIndex () { 31 | return getRandomIndex(playlist, getters.currentIndex) 32 | } 33 | 34 | // 单曲 35 | function getLoopNextIndex () { 36 | return getters.currentIndex 37 | } 38 | } 39 | 40 | 41 | // 上一首歌 42 | export const prevSong = (state, getters) => { 43 | const { playlist, playMode } = state 44 | const prevStratMap = { 45 | [playModeMap.sequence.code]: genSequencePrevIndex, 46 | [playModeMap.loop.code]: getLoopPrevIndex, 47 | [playModeMap.random.code]: getRandomPrevIndex 48 | } 49 | const getPrevStrat = prevStratMap[playMode] 50 | const index = getPrevStrat() 51 | 52 | return playlist[index] 53 | 54 | function genSequencePrevIndex () { 55 | let prevIndex = getters.currentIndex - 1 56 | if (prevIndex < 0) { 57 | prevIndex = playlist.length - 1 58 | } 59 | return prevIndex 60 | } 61 | 62 | function getLoopPrevIndex () { 63 | return getters.currentIndex 64 | } 65 | 66 | function getRandomPrevIndex () { 67 | return getRandomIndex(playlist, getters.currentIndex) 68 | } 69 | } 70 | 71 | // 当前是否有歌曲在播放 72 | export const hasCurrentSong = (state) => { 73 | return isDef(state.currentSong.id) 74 | } 75 | 76 | function getRandomIndex (playlist, currentIndex) { 77 | // 防止无限循环 78 | if (playlist.length === 1) { 79 | return currentIndex 80 | } 81 | let index = Math.round(Math.random() * (playlist.length - 1)) 82 | if (index === currentIndex) { 83 | index = getRandomIndex(playlist, currentIndex) 84 | } 85 | return index 86 | } -------------------------------------------------------------------------------- /src/page/search/songs.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 85 | 86 | -------------------------------------------------------------------------------- /src/components/theme.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 90 | 91 | -------------------------------------------------------------------------------- /src/page/discovery/new-songs.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 95 | 96 | -------------------------------------------------------------------------------- /src/style/reset.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | overflow: hidden; 5 | font-weight: 400; 6 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, 7 | sans-serif; 8 | } 9 | 10 | html, 11 | body, 12 | div, 13 | span, 14 | object, 15 | iframe, 16 | h1, 17 | h2, 18 | h3, 19 | h4, 20 | h5, 21 | h6, 22 | p, 23 | blockquote, 24 | pre, 25 | abbr, 26 | address, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | samp, 37 | small, 38 | strong, 39 | sub, 40 | sup, 41 | var, 42 | b, 43 | i, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | figcaption, 67 | figure, 68 | footer, 69 | header, 70 | hgroup, 71 | menu, 72 | nav, 73 | section, 74 | summary, 75 | time, 76 | mark, 77 | audio, 78 | video { 79 | margin: 0; 80 | padding: 0; 81 | border: 0; 82 | outline: 0; 83 | font-size: 100%; 84 | vertical-align: baseline; 85 | background: transparent; 86 | list-style: none; 87 | box-sizing: border-box; 88 | } 89 | 90 | body { 91 | line-height: 1.2; 92 | } 93 | 94 | :focus { 95 | outline: 1; 96 | } 97 | 98 | article, 99 | aside, 100 | canvas, 101 | details, 102 | figcaption, 103 | figure, 104 | footer, 105 | header, 106 | hgroup, 107 | menu, 108 | nav, 109 | section, 110 | summary { 111 | display: block; 112 | } 113 | 114 | nav ul { 115 | list-style: none; 116 | } 117 | 118 | blockquote, 119 | q { 120 | quotes: none; 121 | } 122 | 123 | blockquote:before, 124 | blockquote:after, 125 | q:before, 126 | q:after { 127 | content: ""; 128 | content: none; 129 | } 130 | 131 | a { 132 | margin: 0; 133 | padding: 0; 134 | border: 0; 135 | font-size: 100%; 136 | vertical-align: baseline; 137 | background: transparent; 138 | } 139 | 140 | ins { 141 | background-color: #ff9; 142 | color: #000; 143 | text-decoration: none; 144 | } 145 | 146 | mark { 147 | background-color: #ff9; 148 | color: #000; 149 | font-style: italic; 150 | font-weight: bold; 151 | } 152 | 153 | del { 154 | text-decoration: line-through; 155 | } 156 | 157 | abbr[title], 158 | dfn[title] { 159 | border-bottom: 1px dotted #000; 160 | cursor: help; 161 | } 162 | 163 | table { 164 | border-collapse: collapse; 165 | border-spacing: 0; 166 | } 167 | 168 | hr { 169 | display: block; 170 | height: 1px; 171 | border: 0; 172 | border-top: 1px solid #cccccc; 173 | margin: 1em 0; 174 | padding: 0; 175 | } 176 | 177 | input, 178 | select { 179 | vertical-align: middle; 180 | } 181 | 182 | img[src=""], 183 | img:not([src]) { 184 | opacity: 0; 185 | } 186 | -------------------------------------------------------------------------------- /src/layout/menu.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 62 | 63 | -------------------------------------------------------------------------------- /src/page/mvs/index.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 105 | 106 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Router from "vue-router"; 3 | 4 | const Discovery = () => import(/* webpackChunkName: "Discovery" */ '@/page/discovery') 5 | const Songs = () => import(/* webpackChunkName: "Songs" */ '@/page/songs') 6 | const Playlists = () => import(/* webpackChunkName: "Playlists" */ '@/page/playlists') 7 | const PlaylistDetail = () => import(/* webpackChunkName: "PlaylistDetail" */ '@/page/playlist-detail') 8 | const Mvs = () => import(/* webpackChunkName: "Mvs" */ '@/page/mvs') 9 | const Mv = () => import(/* webpackChunkName: "Mv" */ '@/page/mv') 10 | const Search = () => import(/* webpackChunkName: "Search" */ '@/page/search') 11 | const SearchSongs = () => import(/* webpackChunkName: "SearchSongs" */ '@/page/search/songs') 12 | const SearchPlaylists = () => import(/* webpackChunkName: "SearchPlaylists" */ '@/page/search/playlists') 13 | const SearchMvs = () => import(/* webpackChunkName: "SearchMvs" */ '@/page/search/mvs') 14 | 15 | //内容需要居中的页面 16 | export const LayoutCenterNames = ['discovery', 'songs', 'mvs'] 17 | 18 | //需要显示在侧边栏菜单的页面 19 | export const menuRoutes = [ 20 | { 21 | path: '/discovery', 22 | name: 'discovery', 23 | component: Discovery, 24 | meta: { 25 | title: '发现音乐', 26 | icon: 'music', 27 | } 28 | }, 29 | { 30 | path: '/playlists', 31 | name: 'playlists', 32 | component: Playlists, 33 | meta: { 34 | title: '推荐歌单', 35 | icon: 'playlist-menu' 36 | } 37 | }, 38 | { 39 | path: '/songs', 40 | name: 'songs', 41 | component: Songs, 42 | meta: { 43 | title: '最新音乐', 44 | icon: 'yinyue' 45 | } 46 | }, 47 | { 48 | path: '/mvs', 49 | name: 'mvs', 50 | component: Mvs, 51 | meta: { 52 | title: '最新MV', 53 | icon: 'mv' 54 | } 55 | } 56 | ] 57 | // 解决ElementUI导航栏中的vue-router在3.0版本以上重复点菜单报错问题 58 | const originalPush = Router.prototype.push 59 | Router.prototype.push = function push(location) { 60 | return originalPush.call(this, location).catch(err => err) 61 | } 62 | Vue.use(Router); 63 | 64 | export default new Router({ 65 | mode: "hash", 66 | routes: [ 67 | { 68 | path: "/", 69 | redirect: '/discovery', 70 | }, 71 | { 72 | path: "/playlist/:id", 73 | name: 'playlist', 74 | component: PlaylistDetail 75 | }, 76 | { 77 | path: '/mv/:id', 78 | name: 'mv', 79 | component: Mv, 80 | props: (route) => ({id: +route.params.id}), 81 | }, 82 | { 83 | path: '/search/:keywords', 84 | name: 'search', 85 | component: Search, 86 | props: true, 87 | children: [ 88 | { 89 | path: '/', 90 | redirect: 'songs', 91 | }, 92 | { 93 | path: 'songs', 94 | name: 'searchSongs', 95 | component: SearchSongs, 96 | }, 97 | { 98 | path: 'playlists', 99 | name: 'searchPlaylists', 100 | component: SearchPlaylists, 101 | }, 102 | { 103 | path: 'mvs', 104 | name: 'searchMvs', 105 | component: SearchMvs, 106 | } 107 | ] 108 | }, 109 | ...menuRoutes, 110 | ], 111 | }); 112 | -------------------------------------------------------------------------------- /src/page/playlists/index.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 121 | 122 | -------------------------------------------------------------------------------- /src/base/progress-bar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 86 | 87 | -------------------------------------------------------------------------------- /src/components/playlist.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 94 | 95 | -------------------------------------------------------------------------------- /src/page/playlist-detail/detail-header.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 68 | 69 | -------------------------------------------------------------------------------- /src/utils/common.js: -------------------------------------------------------------------------------- 1 | import { Notification } from 'element-ui' 2 | 3 | export { debounce, throttle } from 'lodash-es' 4 | 5 | export function pad (num, n = 2) { 6 | let len = num.toString().length 7 | while (len < n) { 8 | num = '0' + num 9 | len++ 10 | } 11 | return num 12 | } 13 | 14 | export function isDef (v) { 15 | return v !== undefined && v !== null 16 | } 17 | 18 | export function formatDate (date, fmt = 'yyyy-MM-dd hh:mm:ss') { 19 | date = date instanceof Date ? date : new Date(date) 20 | if (/(y+)/.test(fmt)) { 21 | fmt = fmt.replace( 22 | RegExp.$1, 23 | (date.getFullYear() + '').substr(4 - RegExp.$1.length) 24 | ) 25 | } 26 | let o = { 27 | 'M+': date.getMonth() + 1, 28 | 'd+': date.getDate(), 29 | 'h+': date.getHours(), 30 | 'm+': date.getMinutes(), 31 | 's+': date.getSeconds(), 32 | } 33 | for (let k in o) { 34 | if (new RegExp(`(${k})`).test(fmt)) { 35 | let str = o[k] + '' 36 | fmt = fmt.replace( 37 | RegExp.$1, 38 | RegExp.$1.length === 1 ? str : padLeftZero(str) 39 | ) 40 | } 41 | } 42 | return fmt 43 | } 44 | function padLeftZero (str) { 45 | return ('00' + str).substr(str.length) 46 | } 47 | 48 | export function formatTime (interval) { 49 | interval = interval | 0 50 | const minute = pad((interval / 60) | 0) 51 | const second = pad(interval % 60) 52 | return `${minute}:${second}` 53 | } 54 | 55 | export function formatNumber (number) { 56 | number = Number(number) || 0 57 | return number > 100000 ? `${Math.round(number / 10000)}万` : number 58 | } 59 | 60 | export function genImgUrl (url, w, h) { 61 | if (!h) { 62 | h = w 63 | } 64 | url += `?param=${w}y${h}` 65 | return url 66 | } 67 | 68 | export function isLast(index, arr) { 69 | return index === arr.length - 1 70 | } 71 | 72 | export function shallowEqual(a, b, compareKey) { 73 | if (a.length !== b.length) { 74 | return false 75 | } 76 | for (let i = 0; i < a.length; i++) { 77 | let compareA = a[i] 78 | let compareB = b[i] 79 | if (compareKey) { 80 | compareA = compareA[compareKey] 81 | compareB = compareB[compareKey] 82 | } 83 | if (!Object.is(compareA, compareB)) { 84 | return false 85 | } 86 | } 87 | return true 88 | } 89 | 90 | export function notify (message, type) { 91 | const params = { 92 | message, 93 | duration: 1500, 94 | } 95 | const fn = type ? Notification[type] : Notification 96 | return fn(params) 97 | } 98 | 99 | ['success', 'warning', 'info', 'error'].forEach((key) => { 100 | notify[key] = (message) => { 101 | return notify(message, key) 102 | } 103 | }) 104 | 105 | export function requestFullScreen(element) { 106 | const docElm = element; 107 | if (docElm.requestFullScreen) { 108 | docElm.requestFullScreen() 109 | } else if (docElm.msRequestFullscreen) { 110 | docElm.msRequestFullscreen() 111 | } else if (docElm.mozRequestFullScreen) { 112 | docElm.mozRequestFullScreen() 113 | } else if (docElm.webkitRequestFullScreen) { 114 | docElm.webkitRequestFullScreen() 115 | } 116 | } 117 | 118 | export function exitFullscreen() { 119 | const de = window.parent.document 120 | if (de.exitFullscreen) { 121 | de.exitFullscreen() 122 | } else if (de.mozCancelFullScreen) { 123 | de.mozCancelFullScreen() 124 | } else if (de.webkitCancelFullScreen) { 125 | de.webkitCancelFullScreen() 126 | } else if (de.msExitFullscreen) { 127 | de.msExitFullscreen() 128 | } 129 | } 130 | 131 | export function isFullscreen() { 132 | return document.fullScreen || 133 | document.mozFullScreen || 134 | document.webkitIsFullScreen 135 | } 136 | 137 | export function getPageOffset (page, limit) { 138 | return (page - 1) * limit 139 | } 140 | -------------------------------------------------------------------------------- /src/components/user.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 112 | 113 | 161 | -------------------------------------------------------------------------------- /src/layout/header.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 87 | 88 | -------------------------------------------------------------------------------- /src/style/element-overwrite.scss: -------------------------------------------------------------------------------- 1 | // table 2 | .el-table th, 3 | .el-table td { 4 | padding: 4px !important; 5 | font-size: $font-size-sm !important; 6 | } 7 | .el-table::before { 8 | height: 0 !important; 9 | } 10 | .el-table--enable-row-hover .el-table__body tr:hover > td { 11 | background-color: var(--playlist-hover-bgcolor) !important; 12 | } 13 | // 空数据 14 | .el-table__empty-block { 15 | background: var(--body-bgcolor); 16 | color: var(--font-color); 17 | } 18 | .el-table__header-wrapper th { 19 | color: var(--font-color); 20 | } 21 | .el-table { 22 | background-color: var(--body-bgcolor) !important; 23 | } 24 | 25 | // 表格单元格的通用样式 26 | @mixin el-td-style($color) { 27 | td, 28 | th, 29 | tr { 30 | background-color: $color !important; 31 | transition: background-color 0s !important; 32 | border-bottom: none !important; 33 | 34 | .cell { 35 | white-space: nowrap !important; 36 | } 37 | } 38 | } 39 | .el-table, 40 | .el-table { 41 | @include el-td-style(var(--body-bgcolor)); 42 | 43 | tr.el-table__row--striped { 44 | @include el-td-style(var(--stripe-bg)); 45 | } 46 | } 47 | // 允许外部在某个类下面覆写table样式 48 | @mixin el-table-theme($color, $stripe-color: var(--stripe-bg)) { 49 | /deep/.el-table { 50 | @include el-td-style($color); 51 | 52 | tr.el-table__row--striped { 53 | @include el-td-style(#{$stripe-color}); 54 | } 55 | } 56 | } 57 | // carosel 58 | .el-carousel--horizontal { 59 | overflow: hidden; 60 | } 61 | 62 | // popover 63 | @each $direction in 'bottom' 'top' 'left' 'right' { 64 | .el-popper[x-placement^='#{$direction}'] .popper__arrow, 65 | .el-popper[x-placement^='#{$direction}'] .popper__arrow::after { 66 | border-#{$direction}-color: var(--prompt-bg-color) !important; 67 | } 68 | } 69 | .el-popover { 70 | background: var(--prompt-bg-color) !important; 71 | border: none !important; 72 | text-align: left; 73 | @include box-shadow; 74 | } 75 | 76 | // input 77 | $input-height: 24px; 78 | @mixin el-input-style($color, $bg-color, $placeholder-color) { 79 | .el-input__inner { 80 | height: $input-height !important; 81 | line-height: $input-height !important; 82 | background: #{$bg-color} !important; 83 | border: none !important; 84 | color: #{$color} !important; 85 | 86 | &:hover { 87 | border: none !important; 88 | } 89 | } 90 | .el-input__prefix { 91 | i { 92 | line-height: $input-height + 1px !important; 93 | color: #{$color} !important; 94 | transition: none !important; 95 | } 96 | } 97 | 98 | input::-webkit-input-placeholder { 99 | color: #{$placeholder-color} !important; 100 | } 101 | } 102 | 103 | // 外部覆写input-theme样式 104 | @mixin el-input-theme($color, $bg-color, $placeholder-color: $color) { 105 | /deep/.el-input { 106 | @include el-input-style($color, $bg-color, $placeholder-color); 107 | } 108 | } 109 | 110 | .el-input { 111 | @include el-input-style( 112 | var(--input-color), 113 | var(--input-bgcolor), 114 | var(--font-color-grey-shallow) 115 | ); 116 | } 117 | 118 | // pagination 119 | .el-pagination, 120 | .el-pagination button, 121 | .el-pager li { 122 | background: inherit !important; 123 | color: var(--font-color) !important; 124 | 125 | .active { 126 | color: $theme-color !important; 127 | } 128 | } 129 | 130 | // dialog 131 | .el-dialog { 132 | background: var(--modal-bg-color) !important; 133 | @include box-shadow; 134 | 135 | .el-dialog__body { 136 | color: var(--font-color) !important; 137 | } 138 | 139 | // 右上角图标 140 | .el-dialog__headerbtn:focus .el-dialog__close, 141 | .el-dialog__headerbtn:hover .el-dialog__close { 142 | color: $theme-color; 143 | } 144 | } 145 | 146 | // button 147 | .el-button--primary { 148 | background: $theme-color !important; 149 | border-color: $theme-color !important; 150 | } 151 | 152 | // loading 153 | .el-loading-spinner { 154 | circle { 155 | stroke: $theme-color !important; 156 | } 157 | .el-loading-text { 158 | color: $theme-color !important; 159 | } 160 | .el-icon-loading { 161 | color: $theme-color !important; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/components/comments.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 143 | 144 | 159 | -------------------------------------------------------------------------------- /src/page/playlist-detail/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 137 | 138 | -------------------------------------------------------------------------------- /src/page/mv/index.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 131 | 132 | 211 | -------------------------------------------------------------------------------- /src/base/tabs.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 138 | 139 | -------------------------------------------------------------------------------- /src/components/search.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 219 | 220 | -------------------------------------------------------------------------------- /src/components/song-table.vue: -------------------------------------------------------------------------------- 1 | 236 | 237 | -------------------------------------------------------------------------------- /STUDY.md: -------------------------------------------------------------------------------- 1 | ### Bug Fixes 2 | 3 | - 完美解决 Cannot download "https://github.com/sass/node-sass/releases/download/binding.nod的问题 4 | 【新版解决方案】:一句命令解决 npm i node-sass --sass_binary_site=https://npm.taobao.org/mirrors/node-sass/ 5 | 6 | - vue 为编写插件提供了一个 install(Vue, Option) 方法,该方法为 vue 添加全局功能; 7 | 该方法有两个参数,第一个是 Vue 构造器,第二个是可选的参数 8 | 9 | - require.context 是 Webpack 中用来管理依赖的一个函数,此方法会生成一个上下文模块,包含目录下所有模块的引用,通过正则表达式匹配,然后 require 进来 10 | require.context('.', false, /\.vue\$/) 11 | 参数一:要查询的目录,上述代码指的是当前目录 12 | 参数二:是否要查询子孙目录,方法默认的值为 false 13 | 参数三:要匹配的文件的后缀,是一个正则表达式,上述我要查询的是.vue 文件) 14 | 正则表达匹配中是有区分大小写的,i 就是说明不区分大小写 15 | 16 | - 在 flex 容器中,当空间不够的时候,flex-shrink 不为 0 的元素会被压缩,所以解决的方法就是给图片设置:flex-shrink:0。 17 | 18 | - 为了防止组件多个实例之间共享属性,所以需要通过工厂函数来获取值 19 | 数组 20 | data: { 21 | default: () => [] 22 | } 23 | 24 | 对象 25 | data: { 26 | default: () => ({}) 27 | } 28 | 29 | - vm.$slots 30 | 用来访问被插槽分发的内容。每个具名插槽有其相应的 property (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default property 包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。 31 | 32 | - substr() 方法可在字符串中抽取从 start 下标开始的指定数目的字符。 33 | 34 | - number | 0 可以向下取整(先将数值转换成 32 位二进制整数值(如果有小数则忽略) 35 | 36 | - vue-lazyLoad 37 | preLoad (预加载高度比例) 38 | error (图片路径错误时加载图片) 39 | loading (预加载图片) 40 | attempt (尝试加载图片数量) 41 | 42 | - watch 43 | watch: { 44 | a: function (val, oldVal) { 45 | console.log('new: %s, old: %s', val, oldVal) 46 | }, 47 | // 方法名 48 | b: 'someMethod', 49 | // 该回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深 50 | c: { 51 | handler: function (val, oldVal) { /_ ... _/ }, 52 | deep: true 53 | }, 54 | // 该回调将会在侦听开始之后被立即调用 55 | d: { 56 | handler: 'someMethod', 57 | immediate: true 58 | }, 59 | // 你可以传入回调数组,它们会被逐一调用 60 | e: [ 61 | 'handle1', 62 | function handle2 (val, oldVal) { /* ... */ }, 63 | { 64 | handler: function handle3 (val, oldVal) { /* ... */ }, 65 | /* ... */ 66 | } 67 | ], 68 | // watch vm.e.f's value: {g: 5} 69 | 'e.f': function (val, oldVal) { /_ ... _/ } 70 | } 71 | 72 | - 让当前的元素滚动到浏览器窗口的可视区域内。定义动画过渡效果, 定义垂直方向,定义水平方向 73 | element.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"}); 74 | 75 | - model 76 | 类型:{ prop?: string, event?: string } 77 | 允许一个自定义组件在使用 v-model 时定制 prop 和 event。默认情况下,一个组件上的 v-model 会把 value 用作 prop 且把 input 用作 event,但是一些输入类型比如单选框和复选框按钮可能想使用 value prop 来达到不同的目的。使用 model 选项可以回避这些情况产生的冲突。 78 | // 定义 model 属性 79 | model: { 80 | // prop 即 父组件使用 v-model 绑定的属性,这个名称是自定义的 81 | prop: "value", 82 | // event 即 子组件会向父组件触发的事件,父组件的 v-model 可以监听到这个事件,并将 vlaue 赋值给 v-model 绑定的属性 83 | event: "input" 84 | }, 85 | props: { 86 | // 这里的 prop 定义必须与 model 中定义的 prop 同名 87 | value: String 88 | }, 89 | - align-self 属性定义 flex 子项单独在侧轴(纵轴)方向上的对齐方式。 90 | - :not(selector) 选择器匹配非指定元素/选择器的每个元素。 91 | - filter(滤镜) 属性 92 | blur(px)给图像设置高斯模糊。"radius"一值设定高斯函数的标准差,或者是屏幕上以多少像素融在一起, 所以值越大越模糊 93 | - v-slot:header 可以被重写为 #header 94 | 95 | * v-loading 优雅的使用 loading 96 | element-loading-text="拼命加载中"  设置 loading 文字 97 | 98 | element-loading-background="rgba(0, 0, 0, 0.8)" 设置 loading 背景 99 | 100 | element-loading-spinner="el-icon-loading" 设置 loading 图标 101 | 102 | - inject 和 provide 103 | provide 可以在祖先组件中指定我们想要提供给后代组件的数据或方法,而在任何后代组件中,我们都可以使用 inject 来接收 provide 提供的数据或方法。 104 | 105 | - 换肤 106 | document.documentElement.style.setProperty(key, value) 来设置变量 107 | 108 | - :show.sync 109 | props 的绑定默认是单向的,我们要在组件内部更新 show 值,需要在父组件上添加 .sync 修饰符,以创建『双向绑定』: 110 | 111 | 上面的代码会被 Vue 扩展为: 112 | 113 | 因为父组件有 update:show 事件监听,所以我们能在组件内部使用 $emit 来关闭消息提示: this.$emit('update:show', false) 114 | 115 | - undefined 若函数没有返回值则默认返回一个 undefined,布尔值为 fales 116 | 117 | - Element.requestFullscreen() 方法用于发出异步请求使元素进入全屏模式。 118 | toFullScreen:全屏 119 | function toFullScreen(){ 120 |    let elem = document.body; 121 |    elem.webkitRequestFullScreen 122 |   ? elem.webkitRequestFullScreen() 123 |   : elem.mozRequestFullScreen 124 |   ? elem.mozRequestFullScreen() 125 |   : elem.msRequestFullscreen 126 |   ? elem.msRequestFullscreen() 127 |   : elem.requestFullScreen 128 |   ? elem.requestFullScreen() 129 |   : alert("浏览器不支持全屏"); 130 | } 131 | 132 | exitFullscreen:退出全屏 133 | function exitFullscreen(){ 134 |    let elem = parent.document; 135 |    elem.webkitCancelFullScreen 136 |   ? elem.webkitCancelFullScreen() 137 |   : elem.mozCancelFullScreen 138 |   ? elem.mozCancelFullScreen() 139 |   : elem.cancelFullScreen 140 |   ? elem.cancelFullScreen() 141 |   : elem.msExitFullscreen 142 |   ? elem.msExitFullscreen() 143 |   : elem.exitFullscreen 144 |   ? elem.exitFullscreen() 145 |   : alert("切换失败,可尝试 Esc 退出"); 146 | } 147 | 是否全屏 148 | export function isFullscreen() { 149 | return document.fullScreen || 150 | document.mozFullScreen || 151 | document.webkitIsFullScreen 152 | } 153 | 154 | - 1turn 一圈 155 | - animation-play-state: paused; 暂停动画 156 | - border-color 这个属性的初始值(Initial value)就是 currentcolor 157 | 158 | - decodeURIComponent() 函数可对 encodeURIComponent() 函数编码的 URI 进行解码。 159 | http%3A%2F%2Fwww.w3school.com.cn%2FMy%20first%2F 160 | http://www.w3school.com.cn/My first/ 161 | 162 | * require("workbox-webpack-plugin") 163 | module.exports = { 164 | // Other webpack config... 165 | plugins: [ 166 | // Other plugins... 167 | new GenerateSW() 168 | ] 169 | }; 170 | 这将为您的所有Webpack资产生成具有预缓存设置的服务工作者。 171 | 172 | * 浏览器兼容方面 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | * 自动将http的不安全请求升级为https 181 | 182 | 183 | * 184 | width:控制 viewport 的大小,可以指定的一个值,如果 600,或者特殊的值,如 device-width 为设备的宽度(单位为缩放为 100% 时的 CSS 的像素)。 185 | height:和 width 相对应,指定高度。 186 | initial-scale:初始缩放比例,也即是当页面第一次 load 的时候缩放比例。 187 | maximum-scale:允许用户缩放到的最大比例。 188 | minimum-scale:允许用户缩放到的最小比例。 189 | user-scalable:用户是否可以手动缩放 190 | 191 | * JPG 有损压缩、体积小、加载快、不支持透明 192 | 用于轮播图 193 | 194 | * PNG-8 与 PNG-24 无损压缩、质量高、体积大、支持透明 195 | 用于 Logo 196 | 197 | * SVG 文本文件、体积小、不失真、兼容性好(渲染成本高) 198 | 阿里矢量图标库 199 | 200 | * Base64 文本文件、依赖编码、小图标解决方案 201 | 图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的) 202 | 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充) 203 | 图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低) 204 | 205 | * 内核分为渲染引擎和js引擎,但随着js引擎越来越独立,内核成了渲染引擎的代称 206 | 207 | 渲染引擎又包括了 HTML 解释器、CSS 解释器、布局、网络、存储、图形、音视频、图片解码器等等零部件。 208 | Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari) 209 | HTML 解释器:将 HTML 文档经过词法分析输出 DOM 树。 210 | 211 | CSS 解释器:解析 CSS 文档, 生成样式规则。 212 | 213 | 图层布局计算模块:布局计算每个对象的精确位置和大小。 214 | 215 | 视图绘制模块:进行具体节点的图像绘制,将像素渲染到屏幕上。 216 | 217 | JavaScript 引擎:编译执行 Javascript 代码。 218 | 219 | CSS 选择符是从右到左进行匹配的 220 | 避免使用通配符,只对需要用到的元素进行选择。 221 | 关注可以通过继承实现的属性,避免重复匹配重复定义。 222 | 少用标签选择器。如果可以,用类选择器替代 223 | 减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素。 224 | 225 | JS的三种加载方式 226 | 正常模式: 227 | 228 | 这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。 229 | 230 | async 模式: 231 | 232 | async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。 233 | 234 | defer 模式: 235 | 236 | defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。 237 | 238 | 239 | * 如何规避回流与重绘 240 | 将“导火索”缓存起来,避免频繁改动 241 | 避免逐条改变样式,使用类名去合并样式 242 | 将 DOM “离线” display:none => *操作* => display: block 243 | 244 | 当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,你就要注意了! 245 | “像这样”的属性,到底是像什么样?——这些值有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。 246 | 247 | * 动态添加多个样式 248 | :class="[songCls(song.id), { border: index === playlist.length-1}]" 249 | 250 | * window.open有什么弊端 251 | window.open打开的网页可以通过window.opener属性获取到来源网站的window对象,或者通过document.referrer获取到来源网站的地址。所以在使用window.open时,可以把第三个参数设置为noopener=yes,noreferrer=yes。 252 | 253 | * vm.$props 254 | v-bind="$props" 255 | 通过 $props 将父组件的 props 一起传给子组件 256 | * vm.$attrs 257 | v-bind="$attrs" 258 | 实现父级传来的属性透传给内部组件(不包括prop,class和style) 259 | * vm.$listeners 260 | v-on="$listeners" 261 | 实现父级传来的方法透传给内部组件 -------------------------------------------------------------------------------- /src/components/mini-player.vue: -------------------------------------------------------------------------------- 1 | 103 | 104 | 284 | 285 | 429 | -------------------------------------------------------------------------------- /src/components/player.vue: -------------------------------------------------------------------------------- 1 | 131 | 132 | 363 | 364 | 600 | --------------------------------------------------------------------------------