├── .babelrc ├── .editorconfig ├── .gitignore ├── .postcssrc.js ├── README.md ├── build ├── build.js ├── check-versions.js ├── logo.png ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.vue ├── api │ ├── config.js │ ├── rank.js │ ├── recommend.js │ ├── search.js │ ├── singer.js │ └── song.js ├── assets │ └── logo.png ├── base │ ├── confirm │ │ └── confirm.vue │ ├── listview │ │ └── listview.vue │ ├── loading │ │ ├── loading.gif │ │ └── loading.vue │ ├── no-result │ │ ├── no-result.vue │ │ ├── no-result@2x.png │ │ └── no-result@3x.png │ ├── progress-bar │ │ └── progress-bar.vue │ ├── progress-circle │ │ └── progress-circle.vue │ ├── scroll │ │ └── scroll.vue │ ├── search-box │ │ └── search-box.vue │ ├── search-list │ │ └── search-list.vue │ ├── slider │ │ └── slider.vue │ ├── song-list │ │ ├── first@2x.png │ │ ├── first@3x.png │ │ ├── second@2x.png │ │ ├── second@3x.png │ │ ├── song-list.vue │ │ ├── third@2x.png │ │ └── third@3x.png │ ├── switches │ │ └── switches.vue │ └── top-tip │ │ └── top-tip.vue ├── common │ ├── fonts │ │ ├── music-icon.eot │ │ ├── music-icon.svg │ │ ├── music-icon.ttf │ │ └── music-icon.woff │ ├── image │ │ └── default.png │ ├── js │ │ ├── catch.js │ │ ├── config.js │ │ ├── dom.js │ │ ├── jsonp.js │ │ ├── mixin.js │ │ ├── singer.js │ │ ├── song.js │ │ └── util.js │ └── stylus │ │ ├── base.styl │ │ ├── icon.styl │ │ ├── index.styl │ │ ├── mixin.styl │ │ ├── reset.styl │ │ └── variable.styl ├── components │ ├── add-song │ │ └── add-song.vue │ ├── disc │ │ └── disc.vue │ ├── m-header │ │ ├── logo@2x.png │ │ ├── logo@3x.png │ │ └── m-header.vue │ ├── music-list │ │ └── music-list.vue │ ├── player │ │ └── player.vue │ ├── playlist │ │ └── playlist.vue │ ├── rank │ │ └── rank.vue │ ├── recommend │ │ └── recommend.vue │ ├── search │ │ └── search.vue │ ├── singer-detail │ │ └── singer-detail.vue │ ├── singer │ │ └── singer.vue │ ├── suggest │ │ └── suggest.vue │ ├── tab │ │ └── tab.vue │ ├── top-list │ │ └── top-list.vue │ └── user-center │ │ └── user-center.vue ├── main.js ├── router │ └── index.js └── store │ ├── actions.js │ ├── getters.js │ ├── index.js │ ├── mutation-types.js │ ├── mutations.js │ └── state.js ├── static ├── .gitkeep ├── 1.png ├── 2.png ├── 3.png ├── 4.png └── 5.png └── test ├── e2e ├── custom-assertions │ └── elementCount.js ├── nightwatch.conf.js ├── runner.js └── specs │ └── test.js └── unit ├── .eslintrc ├── jest.conf.js ├── setup.js └── specs └── HelloWorld.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-vue-jsx", "transform-runtime"], 12 | "env": { 13 | "test": { 14 | "presets": ["env", "stage-2"], 15 | "plugins": ["transform-vue-jsx", "transform-es2015-modules-commonjs", "dynamic-import-node"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | /test/unit/coverage/ 8 | /test/e2e/reports/ 9 | selenium-debug.log 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-music 2 | 3 | > vue2 + vue-router2 +vuex + jsonp + es6 +webpack 抓取QQ音乐真实数据的移动端音乐WebApp 4 | 5 | # 技术栈 6 | 7 | 【前端】 8 | 9 | - Vue:用于构建用户界面的 MVVM 框架。它的核心是响应的数据绑定和组系统件 10 | - vue-router:为单页面应用提供的路由系统,项目上线前使用了 Lazy Loading Routes 技术来实现异步加载优化性能 11 | - vuex:Vue 集中状态管理,在多个组件共享某些状态时非常便捷 12 | - vue-lazyload:第三方图片懒加载库,优化页面加载速度 13 | - better-scroll:iscroll 的优化版,使移动端滑动体验更加流畅 14 | - stylus:css 预编译处理器 15 | - ES6:ECMAScript 新一代语法,模块化、解构赋值、Promise、Class 等方法非常好用 16 | 17 | 【后端】 18 | 19 | - Node.js:利用 Express 起一个本地测试服务器 20 | - jsonp:服务端通讯。抓取 QQ音乐(移动端)数据 21 | - axios:服务端通讯。结合 Node.js 代理后端请求,抓取 QQ音乐(PC端)数据 22 | 23 | 【自动化构建及其他工具】 24 | 25 | - webpack:项目的编译打包 26 | - vue-cli:Vue 脚手架工具,快速搭建项目 27 | - eslint:代码风格检查工具,规范代码格式 28 | - vConsole:移动端调试工具,在移动端输出日志 29 | 30 | # 收获 31 | 32 | 1. 总结出一套适用于 Vue项目的通用组件 33 | 2. 总结了一套常用的 SCSS mixin 库 34 | 3. 总结了一套常用的 JS 工具函数库 35 | 4. 学会了如何在Vue中优雅的操作Dom来完成优秀的用户交互体验 36 | 37 | # 实现页面 38 | 39 | 主要页面:播放器内核页、推荐页、歌单详情页、歌手页、歌手详情页、排行页、搜索页、添加歌曲页、个人中心页等。 40 | 41 | 核心页面:播放器内核页 42 | 43 | ## 推荐页 44 | 45 | 上部分是一个轮播图组件,使用第三方库 `better-scroll` 辅助实现,使用 `jsonp` 抓取 QQ音乐(移动端)数据 46 | 47 | 下部分是一个歌单推荐列表,使用 `axios` +` Node.js` 代理后端请求,绕过主机限制 (伪造 headers),抓取 QQ音乐(PC端)数据 48 | 49 | 歌单推荐列表图片,使用图片懒加载技术 `vue-lazyload`,优化页面加载速度 50 | 51 | 为了更好的用户体验,当数据未请求到时,显示 `loading` 组件 52 | 53 | ## 推荐页 -> 歌单详情页 54 | 55 | 由于歌手的状态多且杂,这里使用 `vuex` 集中管理歌手状态 56 | 57 | 这个组件更加注重 UX,做了很多类原生 APP 动画,如下拉图片放大、跟随推动、ios 渐进增强的高斯模糊效果 `backdrop-filter` 等 58 | 59 | ## 歌手页 60 | 61 | 左右联动是这个组件的难点 62 | 63 | 左侧是一个歌手列表,使用 `jsonp` 抓取 QQ音乐(PC端)歌手数据并重组 JSON 数据结构 64 | 65 | 列表图片使用懒加载技术 `vue-lazyload`,优化页面加载速度 66 | 67 | 右侧是一个字母列表,与左侧歌手列表联动,滚动固定标题实现 68 | 69 | ## 歌手页 -> 歌手详情页 70 | 71 | 复用歌单详情页,只改变传入的参数,数据同样爬取自 QQ音乐 72 | 73 | 播放器内核页 74 | 75 | 核心组件。用 `vuex` 管理各种播放时状态,播放、暂停等功能调用 audio API 76 | 77 | 播放器可以最大化和最小化 78 | 79 | 中部唱片动画使用第三方 JS 动画库 `create-keyframe-animation` 实现 80 | 81 | 底部操作区图标使用 `iconfonts`。 82 | 83 | 抽象了一个横向进度条组件和一个圆形进度条组件,横向进度条可以拖动小球和点击进度条来改变播放进度,圆形进度条组件使用 `SVG ` 元素 84 | 85 | 播放模式有:顺序播放、单曲循环、随机播放,原理是调整歌单列表数组 86 | 87 | 歌词的爬取利用 `axios` 代理后端请求,伪造 `headers` 来实现,先将歌词 `jsonp` 格式转换为 `json` 格式,再使用第三方库 `js-base64` 进行 `Base64` 解码操作,最后再使用第三方库 `lyric-parser` 对歌词进行格式化 88 | 89 | 实现了侧滑显示歌词、歌词跟随进度条高亮等交互效果 90 | 91 | 增加了当前播放列表组件,可在其中加入/删除歌曲 92 | 93 | ## 排行页 94 | 95 | 普通组件,没什么好说的 96 | 97 | ## 排行页 -> 歌单详情页 98 | 99 | 复用歌单详情页,没什么好说的 100 | 101 | ## 搜索页 102 | 103 | 抓数据,写组件,另外,根据抓取的数据特征,做了上拉刷新的功能 104 | 105 | 考虑到数据量大且频繁的问题,对请求做了节流处理 106 | 107 | 考虑到移动端键盘占屏的问题,对滚动前的 `input` 做了 `blur()` 操作 108 | 109 | 对搜索历史进行了 `localstorage` 缓存,清空搜索历史时使用了改装过的 `confirm` 组件 110 | 111 | 支持将搜索的歌曲添加到播放列表 112 | 113 | ## 个人中心 114 | 115 | 将 `localstorage` 中 “我的收藏” 和 “最近播放” 反映到界面上 116 | 117 | ## 其他 118 | 119 | 此应用的全部数据来自 QQ音乐,推荐页的歌单列表及歌词是利用 `axios` 结合 `node.js` 代理后端请求抓取的。 120 | 121 | 全局通用的应用级状态使用 `vuex` 集中管理 122 | 123 | 全局引入 `fastclick` 库,消除 `click` 移动浏览器300ms延迟 124 | 125 | 页面是响应式的,适配常见的移动端屏幕,采用 `flex` 布局 126 | 127 | # 注意 128 | 此音乐播放器数据全部来自 QQ 音乐,接口改变了就需要修改 jsonp 和 axios 代码 129 | 130 | # 安装与运行 131 | ``` 132 | git clone https://github.com/66Web/ljq_vue_music.git 133 | 134 | cd vue-music 135 | 136 | npm install //安装依赖 137 | 138 | npm run dev //服务端运行 访问 http://localhost:8080 139 | 140 | npm run build //项目打包 141 | ``` 142 | 143 | ## 觉得有用的,可以来个star哦! 144 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | process.env.NODE_ENV = 'production' 5 | 6 | const ora = require('ora') 7 | const rm = require('rimraf') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const webpack = require('webpack') 11 | const config = require('../config') 12 | const webpackConfig = require('./webpack.prod.conf') 13 | 14 | const spinner = ora('building for production...') 15 | spinner.start() 16 | 17 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 18 | if (err) throw err 19 | webpack(webpackConfig, (err, stats) => { 20 | spinner.stop() 21 | if (err) throw err 22 | process.stdout.write(stats.toString({ 23 | colors: true, 24 | modules: false, 25 | children: false, // If you are using ts-loader, setting this to true will make TypeScript errors show up during build. 26 | chunks: false, 27 | chunkModules: false 28 | }) + '\n\n') 29 | 30 | if (stats.hasErrors()) { 31 | console.log(chalk.red(' Build failed with errors.\n')) 32 | process.exit(1) 33 | } 34 | 35 | console.log(chalk.cyan(' Build complete.\n')) 36 | console.log(chalk.yellow( 37 | ' Tip: built files are meant to be served over an HTTP server.\n' + 38 | ' Opening index.html over file:// won\'t work.\n' 39 | )) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const chalk = require('chalk') 3 | const semver = require('semver') 4 | const packageConfig = require('../package.json') 5 | const shell = require('shelljs') 6 | 7 | function exec (cmd) { 8 | return require('child_process').execSync(cmd).toString().trim() 9 | } 10 | 11 | const versionRequirements = [ 12 | { 13 | name: 'node', 14 | currentVersion: semver.clean(process.version), 15 | versionRequirement: packageConfig.engines.node 16 | } 17 | ] 18 | 19 | if (shell.which('npm')) { 20 | versionRequirements.push({ 21 | name: 'npm', 22 | currentVersion: exec('npm --version'), 23 | versionRequirement: packageConfig.engines.npm 24 | }) 25 | } 26 | 27 | module.exports = function () { 28 | const warnings = [] 29 | 30 | for (let i = 0; i < versionRequirements.length; i++) { 31 | const mod = versionRequirements[i] 32 | 33 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 34 | warnings.push(mod.name + ': ' + 35 | chalk.red(mod.currentVersion) + ' should be ' + 36 | chalk.green(mod.versionRequirement) 37 | ) 38 | } 39 | } 40 | 41 | if (warnings.length) { 42 | console.log('') 43 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 44 | console.log() 45 | 46 | for (let i = 0; i < warnings.length; i++) { 47 | const warning = warnings[i] 48 | console.log(' ' + warning) 49 | } 50 | 51 | console.log() 52 | process.exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /build/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/build/logo.png -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const config = require('../config') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const packageConfig = require('../package.json') 6 | 7 | exports.assetsPath = function (_path) { 8 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 9 | ? config.build.assetsSubDirectory 10 | : config.dev.assetsSubDirectory 11 | 12 | return path.posix.join(assetsSubDirectory, _path) 13 | } 14 | 15 | exports.cssLoaders = function (options) { 16 | options = options || {} 17 | 18 | const cssLoader = { 19 | loader: 'css-loader', 20 | options: { 21 | sourceMap: options.sourceMap 22 | } 23 | } 24 | 25 | const postcssLoader = { 26 | loader: 'postcss-loader', 27 | options: { 28 | sourceMap: options.sourceMap 29 | } 30 | } 31 | 32 | // generate loader string to be used with extract text plugin 33 | function generateLoaders (loader, loaderOptions) { 34 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader] 35 | 36 | if (loader) { 37 | loaders.push({ 38 | loader: loader + '-loader', 39 | options: Object.assign({}, loaderOptions, { 40 | sourceMap: options.sourceMap 41 | }) 42 | }) 43 | } 44 | 45 | // Extract CSS when that option is specified 46 | // (which is the case during production build) 47 | if (options.extract) { 48 | return ExtractTextPlugin.extract({ 49 | use: loaders, 50 | fallback: 'vue-style-loader' 51 | }) 52 | } else { 53 | return ['vue-style-loader'].concat(loaders) 54 | } 55 | } 56 | 57 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 58 | return { 59 | css: generateLoaders(), 60 | postcss: generateLoaders(), 61 | less: generateLoaders('less'), 62 | sass: generateLoaders('sass', { indentedSyntax: true }), 63 | scss: generateLoaders('sass'), 64 | stylus: generateLoaders('stylus'), 65 | styl: generateLoaders('stylus') 66 | } 67 | } 68 | 69 | // Generate loaders for standalone style files (outside of .vue) 70 | exports.styleLoaders = function (options) { 71 | const output = [] 72 | const loaders = exports.cssLoaders(options) 73 | 74 | for (const extension in loaders) { 75 | const loader = loaders[extension] 76 | output.push({ 77 | test: new RegExp('\\.' + extension + '$'), 78 | use: loader 79 | }) 80 | } 81 | 82 | return output 83 | } 84 | 85 | exports.createNotifierCallback = () => { 86 | const notifier = require('node-notifier') 87 | 88 | return (severity, errors) => { 89 | if (severity !== 'error') return 90 | 91 | const error = errors[0] 92 | const filename = error.file && error.file.split('!').pop() 93 | 94 | notifier.notify({ 95 | title: packageConfig.name, 96 | message: severity + ': ' + error.name, 97 | subtitle: filename || '', 98 | icon: path.join(__dirname, 'logo.png') 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | const isProduction = process.env.NODE_ENV === 'production' 5 | const sourceMapEnabled = isProduction 6 | ? config.build.productionSourceMap 7 | : config.dev.cssSourceMap 8 | 9 | module.exports = { 10 | loaders: utils.cssLoaders({ 11 | sourceMap: sourceMapEnabled, 12 | extract: isProduction 13 | }), 14 | cssSourceMap: sourceMapEnabled, 15 | cacheBusting: config.dev.cacheBusting, 16 | transformToRequire: { 17 | video: ['src', 'poster'], 18 | source: 'src', 19 | img: 'src', 20 | image: 'xlink:href' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const config = require('../config') 5 | const vueLoaderConfig = require('./vue-loader.conf') 6 | 7 | function resolve (dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | 12 | 13 | module.exports = { 14 | context: path.resolve(__dirname, '../'), 15 | entry: { 16 | app: './src/main.js' 17 | }, 18 | output: { 19 | path: config.build.assetsRoot, 20 | filename: '[name].js', 21 | publicPath: process.env.NODE_ENV === 'production' 22 | ? config.build.assetsPublicPath 23 | : config.dev.assetsPublicPath 24 | }, 25 | resolve: { 26 | extensions: ['.js', '.vue', '.json'], 27 | alias: { 28 | 'vue$': 'vue/dist/vue.esm.js', 29 | '@': resolve('src'), 30 | } 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.vue$/, 36 | loader: 'vue-loader', 37 | options: vueLoaderConfig 38 | }, 39 | { 40 | test: /\.js$/, 41 | loader: 'babel-loader', 42 | include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')] 43 | }, 44 | { 45 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 46 | loader: 'url-loader', 47 | options: { 48 | limit: 10000, 49 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 50 | } 51 | }, 52 | { 53 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 54 | loader: 'url-loader', 55 | options: { 56 | limit: 10000, 57 | name: utils.assetsPath('media/[name].[hash:7].[ext]') 58 | } 59 | }, 60 | { 61 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 62 | loader: 'url-loader', 63 | options: { 64 | limit: 10000, 65 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 66 | } 67 | } 68 | ] 69 | }, 70 | node: { 71 | // prevent webpack from injecting useless setImmediate polyfill because Vue 72 | // source contains it (although only uses it if it's native). 73 | setImmediate: false, 74 | // prevent webpack from injecting mocks to Node native modules 75 | // that does not make sense for the client 76 | dgram: 'empty', 77 | fs: 'empty', 78 | net: 'empty', 79 | tls: 'empty', 80 | child_process: 'empty' 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const path = require('path') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 11 | const portfinder = require('portfinder') 12 | 13 | //定义路由 14 | const express = require('express') 15 | const axios = require('axios') 16 | const app = express() 17 | var apiRoutes = express.Router() 18 | app.use('/api', apiRoutes) 19 | 20 | const HOST = process.env.HOST 21 | const PORT = process.env.PORT && Number(process.env.PORT) 22 | 23 | const devWebpackConfig = merge(baseWebpackConfig, { 24 | module: { 25 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true }) 26 | }, 27 | // cheap-module-eval-source-map is faster for development 28 | devtool: config.dev.devtool, 29 | 30 | // these devServer options should be customized in /config/index.js 31 | devServer: { 32 | clientLogLevel: 'warning', 33 | historyApiFallback: { 34 | rewrites: [ 35 | { from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') }, 36 | ], 37 | }, 38 | hot: true, 39 | contentBase: false, // since we use CopyWebpackPlugin. 40 | compress: true, 41 | host: HOST || config.dev.host, 42 | port: PORT || config.dev.port, 43 | open: config.dev.autoOpenBrowser, 44 | overlay: config.dev.errorOverlay 45 | ? { warnings: false, errors: true } 46 | : false, 47 | publicPath: config.dev.assetsPublicPath, 48 | proxy: config.dev.proxyTable, 49 | quiet: true, // necessary for FriendlyErrorsPlugin 50 | watchOptions: { 51 | poll: config.dev.poll, 52 | }, 53 | before(app) { 54 | //定义getDiscList接口,回调传入两个参数,前端请求这个接口 55 | app.get('/api/getDiscList', function(req, res){ 56 | var url = "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg" 57 | axios.get(url, { 58 | headers: { //通过node请求QQ接口,发送http请求时,修改referer和host 59 | referer: 'https://y.qq.com/', 60 | host: 'c.y.qq.com' 61 | }, 62 | params: req.query //把前端传过来的params,全部给QQ的url 63 | }).then((response) => { //成功回调 64 | res.json(response.data)  //response是QQ接口返回的,res是我们自己的。所以要把数据输出给浏览器前端 65 | }).catch((e) => { 66 | console.log(e) 67 | }) 68 | }) 69 | 70 | app.get('/api/music', function(req, res){//获取vkey 71 | var url="https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg" 72 | 73 | axios.get(url, { 74 | headers: { //通过node请求QQ接口,发送http请求时,修改referer和host 75 | referer: 'https://y.qq.com/', 76 | host: 'c.y.qq.com' 77 | }, 78 | params: req.query //把前端传过来的params,全部给QQ的url 79 | }).then((response) => { 80 | res.json(response.data) 81 | }).catch((e) => { 82 | console.log(e) 83 | }) 84 | }) 85 | 86 | app.get('/api/lyric', function(req, res){ 87 | var url="https://szc.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" 88 | 89 | axios.get(url, { 90 | headers: { //通过node请求QQ接口,发送http请求时,修改referer和host 91 | referer: 'https://y.qq.com/', 92 | host: 'c.y.qq.com' 93 | }, 94 | params: req.query //把前端传过来的params,全部给QQ的url 95 | }).then((response) => { 96 | // res.json(response.data) 97 | //将QQ返回的jsonp文件转换为json格式 98 | var ret = response.data 99 | if (typeof ret === 'string') { 100 | var reg = /^\w+\(({[^()]+})\)$/ 101 | // 以单词a-z,A-Z开头,一个或多个 102 | // \(\)转义括号以()开头结尾 103 | // ()是用来分组 104 | // 【^()】不以左括号/右括号的字符+多个 105 | // {}大括号也要匹配到 106 | var matches = ret.match(reg) 107 | if (matches) { 108 | ret = JSON.parse(matches[1]) 109 | // 对匹配到的分组的内容进行转换 110 | } 111 | } 112 | res.json(ret) 113 | }).catch((e) => { 114 | console.log(e) 115 | }) 116 | }) 117 | 118 | app.get('/api/getSongList', function (req, res) { 119 | var url = 'https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg' 120 | axios.get(url, { 121 | headers: { 122 | referer: 'https://y.qq.com/', 123 | host: 'c.y.qq.com' 124 | }, 125 | params: req.query 126 | }).then((response) => { 127 | res.json(response.data) 128 | }).catch((e) => { 129 | console.log(e) 130 | }) 131 | }) 132 | 133 | app.get('/api/getSearch', function (req, res) { 134 | var url = 'https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp' 135 | axios.get(url, { 136 | headers: { 137 | referer: 'https://c.y.qq.com/', 138 | host: 'c.y.qq.com' 139 | }, 140 | params: req.query 141 | }).then((response) => { 142 | res.json(response.data) 143 | }).catch((e) => { 144 | console.log(e) 145 | }) 146 | }) 147 | 148 | } 149 | }, 150 | plugins: [ 151 | new webpack.DefinePlugin({ 152 | 'process.env': require('../config/dev.env') 153 | }), 154 | new webpack.HotModuleReplacementPlugin(), 155 | new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update. 156 | new webpack.NoEmitOnErrorsPlugin(), 157 | // https://github.com/ampedandwired/html-webpack-plugin 158 | new HtmlWebpackPlugin({ 159 | filename: 'index.html', 160 | template: 'index.html', 161 | inject: true 162 | }), 163 | // copy custom static assets 164 | new CopyWebpackPlugin([ 165 | { 166 | from: path.resolve(__dirname, '../static'), 167 | to: config.dev.assetsSubDirectory, 168 | ignore: ['.*'] 169 | } 170 | ]) 171 | ] 172 | }) 173 | 174 | module.exports = new Promise((resolve, reject) => { 175 | portfinder.basePort = process.env.PORT || config.dev.port 176 | portfinder.getPort((err, port) => { 177 | if (err) { 178 | reject(err) 179 | } else { 180 | // publish the new Port, necessary for e2e tests 181 | process.env.PORT = port 182 | // add port to devServer config 183 | devWebpackConfig.devServer.port = port 184 | 185 | // Add FriendlyErrorsPlugin 186 | devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({ 187 | compilationSuccessInfo: { 188 | messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`], 189 | }, 190 | onErrors: config.dev.notifyOnErrors 191 | ? utils.createNotifierCallback() 192 | : undefined 193 | })) 194 | 195 | resolve(devWebpackConfig) 196 | } 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const webpack = require('webpack') 5 | const config = require('../config') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 12 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 13 | 14 | const env = process.env.NODE_ENV === 'testing' 15 | ? require('../config/test.env') 16 | : require('../config/prod.env') 17 | 18 | const webpackConfig = merge(baseWebpackConfig, { 19 | module: { 20 | rules: utils.styleLoaders({ 21 | sourceMap: config.build.productionSourceMap, 22 | extract: true, 23 | usePostCSS: true 24 | }) 25 | }, 26 | devtool: config.build.productionSourceMap ? config.build.devtool : false, 27 | output: { 28 | path: config.build.assetsRoot, 29 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 30 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 31 | }, 32 | plugins: [ 33 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 34 | new webpack.DefinePlugin({ 35 | 'process.env': env 36 | }), 37 | new UglifyJsPlugin({ 38 | uglifyOptions: { 39 | compress: { 40 | warnings: false 41 | } 42 | }, 43 | sourceMap: config.build.productionSourceMap, 44 | parallel: true 45 | }), 46 | // extract css into its own file 47 | new ExtractTextPlugin({ 48 | filename: utils.assetsPath('css/[name].[contenthash].css'), 49 | // Setting the following option to `false` will not extract CSS from codesplit chunks. 50 | // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack. 51 | // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`, 52 | // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110 53 | allChunks: true, 54 | }), 55 | // Compress extracted CSS. We are using this plugin so that possible 56 | // duplicated CSS from different components can be deduped. 57 | new OptimizeCSSPlugin({ 58 | cssProcessorOptions: config.build.productionSourceMap 59 | ? { safe: true, map: { inline: false } } 60 | : { safe: true } 61 | }), 62 | // generate dist index.html with correct asset hash for caching. 63 | // you can customize output by editing /index.html 64 | // see https://github.com/ampedandwired/html-webpack-plugin 65 | new HtmlWebpackPlugin({ 66 | filename: process.env.NODE_ENV === 'testing' 67 | ? 'index.html' 68 | : config.build.index, 69 | template: 'index.html', 70 | inject: true, 71 | minify: { 72 | removeComments: true, 73 | collapseWhitespace: true, 74 | removeAttributeQuotes: true 75 | // more options: 76 | // https://github.com/kangax/html-minifier#options-quick-reference 77 | }, 78 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 79 | chunksSortMode: 'dependency' 80 | }), 81 | // keep module.id stable when vendor modules does not change 82 | new webpack.HashedModuleIdsPlugin(), 83 | // enable scope hoisting 84 | new webpack.optimize.ModuleConcatenationPlugin(), 85 | // split vendor js into its own file 86 | new webpack.optimize.CommonsChunkPlugin({ 87 | name: 'vendor', 88 | minChunks (module) { 89 | // any required modules inside node_modules are extracted to vendor 90 | return ( 91 | module.resource && 92 | /\.js$/.test(module.resource) && 93 | module.resource.indexOf( 94 | path.join(__dirname, '../node_modules') 95 | ) === 0 96 | ) 97 | } 98 | }), 99 | // extract webpack runtime and module manifest to its own file in order to 100 | // prevent vendor hash from being updated whenever app bundle is updated 101 | new webpack.optimize.CommonsChunkPlugin({ 102 | name: 'manifest', 103 | minChunks: Infinity 104 | }), 105 | // This instance extracts shared chunks from code splitted chunks and bundles them 106 | // in a separate chunk, similar to the vendor chunk 107 | // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk 108 | new webpack.optimize.CommonsChunkPlugin({ 109 | name: 'app', 110 | async: 'vendor-async', 111 | children: true, 112 | minChunks: 3 113 | }), 114 | 115 | // copy custom static assets 116 | new CopyWebpackPlugin([ 117 | { 118 | from: path.resolve(__dirname, '../static'), 119 | to: config.build.assetsSubDirectory, 120 | ignore: ['.*'] 121 | } 122 | ]) 123 | ] 124 | }) 125 | 126 | if (config.build.productionGzip) { 127 | const CompressionWebpackPlugin = require('compression-webpack-plugin') 128 | 129 | webpackConfig.plugins.push( 130 | new CompressionWebpackPlugin({ 131 | asset: '[path].gz[query]', 132 | algorithm: 'gzip', 133 | test: new RegExp( 134 | '\\.(' + 135 | config.build.productionGzipExtensions.join('|') + 136 | ')$' 137 | ), 138 | threshold: 10240, 139 | minRatio: 0.8 140 | }) 141 | ) 142 | } 143 | 144 | if (config.build.bundleAnalyzerReport) { 145 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 146 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 147 | } 148 | 149 | module.exports = webpackConfig 150 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: {}, 14 | 15 | // Various Dev Server settings 16 | host: 'localhost', // can be overwritten by process.env.HOST 17 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 18 | autoOpenBrowser: false, 19 | errorOverlay: true, 20 | notifyOnErrors: true, 21 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 22 | 23 | 24 | /** 25 | * Source Maps 26 | */ 27 | 28 | // https://webpack.js.org/configuration/devtool/#development 29 | devtool: 'cheap-module-eval-source-map', 30 | 31 | // If you have problems debugging vue-files in devtools, 32 | // set this to false - it *may* help 33 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 34 | cacheBusting: true, 35 | 36 | cssSourceMap: true 37 | }, 38 | 39 | build: { 40 | // Template for index.html 41 | index: path.resolve(__dirname, '../dist/index.html'), 42 | 43 | // Paths 44 | assetsRoot: path.resolve(__dirname, '../dist'), 45 | assetsSubDirectory: 'static', 46 | assetsPublicPath: '/', 47 | 48 | /** 49 | * Source Maps 50 | */ 51 | 52 | productionSourceMap: true, 53 | // https://webpack.js.org/configuration/devtool/#production 54 | devtool: '#source-map', 55 | 56 | // Gzip off by default as many popular static hosts such as 57 | // Surge or Netlify already gzip all static assets for you. 58 | // Before setting to `true`, make sure to: 59 | // npm install --save-dev compression-webpack-plugin 60 | productionGzip: false, 61 | productionGzipExtensions: ['js', 'css'], 62 | 63 | // Run the build command with an extra argument to 64 | // View the bundle analyzer report after build finishes: 65 | // `npm run build --report` 66 | // Set to `true` or `false` to always turn it on or off 67 | bundleAnalyzerReport: process.env.npm_config_report 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const devEnv = require('./dev.env') 4 | 5 | module.exports = merge(devEnv, { 6 | NODE_ENV: '"testing"' 7 | }) 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | vue-music 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-music", 3 | "version": "1.0.0", 4 | "description": "A Vue.js project", 5 | "author": "66ljq <962375193@qq.com>", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 9 | "start": "npm run dev", 10 | "unit": "jest --config test/unit/jest.conf.js --coverage", 11 | "e2e": "node test/e2e/runner.js", 12 | "test": "npm run unit && npm run e2e", 13 | "build": "node build/build.js" 14 | }, 15 | "dependencies": { 16 | "axios": "^0.18.0", 17 | "babel-polyfill": "^6.26.0", 18 | "babel-runtime": "^6.26.0", 19 | "better-scroll": "^0.1.15", 20 | "create-keyframe-animation": "^0.1.0", 21 | "fastclick": "^1.0.6", 22 | "good-storage": "^1.1.0", 23 | "js-base64": "^2.4.9", 24 | "jsonp": "^0.2.1", 25 | "lyric-parser": "^1.0.1", 26 | "stylus": "^0.54.5", 27 | "stylus-loader": "^3.0.2", 28 | "vue": "^2.5.2", 29 | "vue-lazyload": "^1.2.6", 30 | "vue-router": "^3.0.1", 31 | "vuex": "^3.0.1" 32 | }, 33 | "devDependencies": { 34 | "autoprefixer": "^7.1.2", 35 | "babel-core": "^6.22.1", 36 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 37 | "babel-jest": "^21.0.2", 38 | "babel-loader": "^7.1.1", 39 | "babel-plugin-dynamic-import-node": "^1.2.0", 40 | "babel-plugin-syntax-jsx": "^6.18.0", 41 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 42 | "babel-plugin-transform-runtime": "^6.22.0", 43 | "babel-plugin-transform-vue-jsx": "^3.5.0", 44 | "babel-preset-env": "^1.3.2", 45 | "babel-preset-stage-2": "^6.22.0", 46 | "babel-register": "^6.22.0", 47 | "chalk": "^2.0.1", 48 | "chromedriver": "^2.27.2", 49 | "copy-webpack-plugin": "^4.0.1", 50 | "cross-spawn": "^5.0.1", 51 | "css-loader": "^0.28.0", 52 | "extract-text-webpack-plugin": "^3.0.0", 53 | "file-loader": "^1.1.4", 54 | "friendly-errors-webpack-plugin": "^1.6.1", 55 | "html-webpack-plugin": "^2.30.1", 56 | "jest": "^22.0.4", 57 | "jest-serializer-vue": "^0.3.0", 58 | "nightwatch": "^0.9.12", 59 | "node-notifier": "^5.1.2", 60 | "optimize-css-assets-webpack-plugin": "^3.2.0", 61 | "ora": "^1.2.0", 62 | "portfinder": "^1.0.13", 63 | "postcss-import": "^11.0.0", 64 | "postcss-loader": "^2.0.8", 65 | "postcss-url": "^7.2.1", 66 | "rimraf": "^2.6.0", 67 | "selenium-server": "^3.0.1", 68 | "semver": "^5.3.0", 69 | "shelljs": "^0.7.6", 70 | "uglifyjs-webpack-plugin": "^1.1.1", 71 | "url-loader": "^0.5.8", 72 | "vue-jest": "^1.0.2", 73 | "vue-loader": "^13.3.0", 74 | "vue-style-loader": "^3.0.1", 75 | "vue-template-compiler": "^2.5.2", 76 | "webpack": "^3.6.0", 77 | "webpack-bundle-analyzer": "^2.9.0", 78 | "webpack-dev-server": "^2.9.1", 79 | "webpack-merge": "^4.1.0" 80 | }, 81 | "engines": { 82 | "node": ">= 6.0.0", 83 | "npm": ">= 3.0.0" 84 | }, 85 | "browserslist": [ 86 | "> 1%", 87 | "last 2 versions", 88 | "not ie <= 8" 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | 27 | 33 | 34 | -------------------------------------------------------------------------------- /src/api/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 为了和QQ音乐接口一致,配置一些公用的参数、options和err_num码 3 | */ 4 | export const commonParams = { 5 | g_tk: 5381, 6 | inCharset: 'utf-8', 7 | outCharset: 'utf-8', 8 | notice: 0, 9 | format: 'jsonp' 10 | } 11 | 12 | export const options = { 13 | param: 'jsonpCallback' 14 | } 15 | 16 | export const ERR_OK = 0 -------------------------------------------------------------------------------- /src/api/rank.js: -------------------------------------------------------------------------------- 1 | import jsonp from '@/common/js/jsonp' 2 | import {commonParams, options} from './config' 3 | 4 | export function getTopList() { 5 | const url = "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg" 6 | 7 | const data = Object.assign({}, commonParams, { 8 | platform: 'h5', 9 | needNewcode: 1 10 | }) 11 | 12 | return jsonp(url, data, options) 13 | } 14 | 15 | export function getMusicList(topid) { 16 | const url = "https://c.y.qq.com/v8/fcg-bin/fcg_v8_toplist_cp.fcg" 17 | 18 | const data = Object.assign({}, commonParams, { 19 | topid, 20 | page: 'detail', 21 | type: 'top', 22 | tpl: 3, 23 | platform: 'h5', 24 | needNewcode: 1 25 | }) 26 | 27 | return jsonp(url, data, options) 28 | } -------------------------------------------------------------------------------- /src/api/recommend.js: -------------------------------------------------------------------------------- 1 | import jsonp from '@/common/js/jsonp' 2 | import {commonParams, options} from '@/api/config' 3 | import axios from 'axios'; 4 | 5 | /** 6 | * data: 固定参数,不知道含义,但一定要与接口保持一致 7 | */ 8 | 9 | export function getRecommend() { 10 | const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg' 11 | 12 | const data = Object.assign({}, commonParams, { 13 | platfrom: 'h5', 14 | uin: 0, 15 | needNewCode: 1 16 | }) 17 | 18 | return jsonp(url, data, options) 19 | } 20 | 21 | export function getDiscList() { 22 | // const url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg' 23 | const url = '/api/getDiscList' //调用自定义的接口 24 | 25 | const data = Object.assign({}, commonParams, { 26 | platform: 'yqq', 27 | hostUin: 0, 28 | sin: 0, 29 | ein: 29, 30 | sortId: 5, 31 | needNewCode: 0, 32 | categoryId: 10000000, 33 | rnd: Math.random(), 34 | format: 'json' //使用的时axios,所以format使用的是json,不是jsonp 35 | }) 36 | 37 | // return jsonp(url, data, options) 38 | return axios.get(url, { 39 | params: data 40 | }).then((res) => { 41 | return Promise.resolve(res.data) //es6新语法,返回一个以给定值解析后的Promise对象 42 | }) 43 | } 44 | 45 | export function getSongList (disstid) { 46 | const url = '/api/getSongList' 47 | 48 | const data = Object.assign({}, commonParams, { 49 | uin: 0, 50 | format: 'json', 51 | notice: 0, 52 | needNewCode: 1, 53 | new_format: 1, 54 | pic: 500, 55 | disstid, //关键数据 56 | type: 1, 57 | json: 1, 58 | utf8: 1, 59 | onlysong: 0, 60 | picmid: 1, 61 | nosign: 1, 62 | song_begin: 0, 63 | platform: 'h5', 64 | song_num: 100, 65 | _: +new Date() 66 | }) 67 | 68 | return axios.get(url, { 69 | params: data 70 | }).then((res) => { 71 | return Promise.resolve(res.data) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/api/search.js: -------------------------------------------------------------------------------- 1 | import jsonp from '@/common/js/jsonp' 2 | import {commonParams, options} from './config' 3 | import axios from 'axios'; 4 | 5 | export function getHotKey() { 6 | const url = "https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg" 7 | 8 | const data = Object.assign({}, commonParams, { 9 | platform: 'h5', 10 | needNewcode: 1 11 | }) 12 | 13 | return jsonp(url, data, options) 14 | } 15 | 16 | //搜索结果数据 17 | export function getSearch(query, page, zhida, perpage) { 18 | const url = "/api/getSearch" //通过自身模拟服务器访问有host权限的数据 19 | 20 | const data = Object.assign({}, commonParams, { 21 | w: query, 22 | p: page, 23 | catZhida: zhida ? 1 : 0, 24 | uin: 0, 25 | platform: 'h5', 26 | needNewCode: 1, 27 | zhidaqu: 1, 28 | t: 0, 29 | flag: 1, 30 | ie: 'utf-8', 31 | sem: 1, 32 | aggr: 0, 33 | perpage: perpage, 34 | n: perpage, 35 | remoteplace: 'txt.mqq.all', 36 | format: 'json', 37 | _: +new Date() 38 | }) 39 | 40 | return axios.get(url, { 41 | params: data 42 | }).then((res) => { 43 | return Promise.resolve(res.data) 44 | }) 45 | } -------------------------------------------------------------------------------- /src/api/singer.js: -------------------------------------------------------------------------------- 1 | import jsonp from '@/common/js/jsonp' 2 | import {commonParams, options} from '@/api/config' 3 | import axios from 'axios'; 4 | 5 | export function getSingerList() { 6 | const url = 'https://c.y.qq.com/v8/fcg-bin/v8.fcg' 7 | 8 | const data = Object.assign({}, commonParams, { 9 | channel: 'singer', 10 | page: 'list', 11 | key: 'all_all_all', 12 | pagesize: 100, 13 | pagenum: 1, 14 | hostUin: 0, 15 | needNewCode: 0, 16 | platform: 'yqq', 17 | g_tk: 1664029744, 18 | }) 19 | 20 | return jsonp(url, data, options) 21 | } 22 | 23 | export function getSingerDetail(singerId) { 24 | const url = 'https://c.y.qq.com/v8/fcg-bin/fcg_v8_singer_track_cp.fcg' 25 | 26 | const data = Object.assign({}, commonParams, { 27 | hostUin: 0, 28 | needNewCode: 0, 29 | platform: 'yqq', 30 | order: 'listen', 31 | begin: 0, 32 | num: 100, //抓取歌手数据100条 33 | songstatus: 1, 34 | g_tk: 1664029744, 35 | singermid: singerId //传入的不同歌手的Id 36 | }) 37 | 38 | return jsonp(url, data, options) 39 | } 40 | 41 | export function getMusic(songmid) { 42 | const url = '/api/music' 43 | const data = Object.assign({}, commonParams, { 44 | songmid: songmid, 45 | filename: 'C400' + songmid + '.m4a', 46 | guid: 6319873028, //会变,以实时抓取的数据为准 47 | platform: 'yqq', 48 | loginUin: 0, 49 | hostUin: 0, 50 | needNewCode: 0, 51 | cid:205361747, 52 | uin: 0, 53 | format: 'json' 54 | }) 55 | return axios.get(url, { 56 | params: data 57 | }).then((res) => { 58 | return Promise.resolve(res.data) 59 | }) 60 | } -------------------------------------------------------------------------------- /src/api/song.js: -------------------------------------------------------------------------------- 1 | import {commonParams} from './config' 2 | import axios from 'axios' 3 | 4 | export function getLyric(mid){ 5 | const url = '/api/lyric' 6 | 7 | const data = Object.assign({}, commonParams, { 8 | songmid: mid, 9 | pcachetime: +new Date(), 10 | platform: 'yqq', 11 | hostUin: 0, 12 | needNewCode: 0, 13 | g_tk: 5381, //会变化,以实时数据为准 14 | format: 'json' //规定为json请求 15 | }) 16 | 17 | return axios.get(url, { 18 | params: data 19 | }).then((res) => { 20 | return Promise.resolve(res.data) 21 | }) 22 | } -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/assets/logo.png -------------------------------------------------------------------------------- /src/base/confirm/confirm.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 56 | 57 | 58 | 115 | -------------------------------------------------------------------------------- /src/base/listview/listview.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 178 | 179 | 246 | -------------------------------------------------------------------------------- /src/base/loading/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/loading/loading.gif -------------------------------------------------------------------------------- /src/base/loading/loading.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 30 | -------------------------------------------------------------------------------- /src/base/no-result/no-result.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 36 | 37 | -------------------------------------------------------------------------------- /src/base/no-result/no-result@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/no-result/no-result@2x.png -------------------------------------------------------------------------------- /src/base/no-result/no-result@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/no-result/no-result@3x.png -------------------------------------------------------------------------------- /src/base/progress-bar/progress-bar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 79 | 80 | 111 | 112 | -------------------------------------------------------------------------------- /src/base/progress-circle/progress-circle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 38 | 39 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/base/scroll/scroll.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 111 | 112 | 115 | -------------------------------------------------------------------------------- /src/base/search-box/search-box.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 42 | 43 | 71 | 72 | -------------------------------------------------------------------------------- /src/base/search-list/search-list.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | 55 | -------------------------------------------------------------------------------- /src/base/slider/slider.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 180 | 181 | 224 | -------------------------------------------------------------------------------- /src/base/song-list/first@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/first@2x.png -------------------------------------------------------------------------------- /src/base/song-list/first@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/first@3x.png -------------------------------------------------------------------------------- /src/base/song-list/second@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/second@2x.png -------------------------------------------------------------------------------- /src/base/song-list/second@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/second@3x.png -------------------------------------------------------------------------------- /src/base/song-list/song-list.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 51 | 52 | 94 | 95 | -------------------------------------------------------------------------------- /src/base/song-list/third@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/third@2x.png -------------------------------------------------------------------------------- /src/base/song-list/third@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/base/song-list/third@3x.png -------------------------------------------------------------------------------- /src/base/switches/switches.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | 30 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/base/top-tip/top-tip.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 36 | 37 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/common/fonts/music-icon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/common/fonts/music-icon.eot -------------------------------------------------------------------------------- /src/common/fonts/music-icon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/common/fonts/music-icon.ttf -------------------------------------------------------------------------------- /src/common/fonts/music-icon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/common/fonts/music-icon.woff -------------------------------------------------------------------------------- /src/common/image/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/common/image/default.png -------------------------------------------------------------------------------- /src/common/js/catch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage相关 3 | */ 4 | 5 | import storage from 'good-storage' 6 | 7 | const SEARCH_KEY = '_search_' //双下划线标识内部key, 避免与外部key冲突 8 | const SEARCH_MAX_LENGTH = 15 //搜索历史最多存入数组15个 9 | 10 | const PLAY_KEY = '_play_' 11 | const PLAY_MAX_LENGTH = 200 12 | 13 | const FAVORITE_KEY = '_favorite_' 14 | const FAVORITE_MAX_LENGTH = 200 15 | 16 | //操作搜索历史数组的方法 17 | //参数:搜索记录数组,添加的项,筛选方法,最大数量 18 | function insertArray(arr, val, compare, maxLen){ 19 | const index = arr.findIndex(compare) //判断是否以前有搜索过,compare在外部编写 20 | if (index === 0) { //上一条搜索历史就是这个,就不需要添加历史 21 | return 22 | } 23 | if (index > 0) { //历史记录中有这条,把历史记录删了,重新添加 24 | arr.splice(index, 1) 25 | } 26 | arr.unshift(val) //没有历史记录,添加项目到第一项 27 | if (maxLen && arr.length > maxLen) { //大于最大数量的时候,删除最后一项 28 | arr.pop() 29 | } 30 | } 31 | 32 | function deleteFromArray(arr, compare){ 33 | const index = arr.findIndex(compare) 34 | if(index > -1) { 35 | arr.splice(index, 1) 36 | } 37 | } 38 | 39 | //插入最新搜索历史到本地缓存,同时返回新的搜索历史数组 40 | export function saveSearch(query) { 41 | let searches = storage.get(SEARCH_KEY, []) //如果已有历史就get缓存中的数组,没有就空数组 42 | 43 | insertArray(searches, query, (item) => { //对传入的项与已有数组进行操作 44 | return item === query 45 | }, SEARCH_MAX_LENGTH) 46 | 47 | storage.set(SEARCH_KEY, searches) //把操作过后的数组set进缓存,直接替换掉原历史 48 | return searches 49 | } 50 | 51 | export function deleteSearch(query) { 52 | let searches = storage.get(SEARCH_KEY, []) 53 | 54 | deleteFromArray(searches, (item) => { 55 | return item === query 56 | }) 57 | 58 | storage.set(SEARCH_KEY, searches) 59 | return searches 60 | } 61 | 62 | export function clearSearch() { 63 | storage.remove(SEARCH_KEY) 64 | return [] 65 | } 66 | 67 | //states获取本地缓存中的数据 68 | export function loadSearch() { 69 | return storage.get(SEARCH_KEY, []) 70 | } 71 | 72 | export function savePlay(song) { 73 | let songs = storage.get(PLAY_KEY, []) 74 | insertArray(songs, song, (item) => { 75 | return song.id === item.id 76 | }, PLAY_MAX_LENGTH) 77 | storage.set(PLAY_KEY, songs) 78 | return songs 79 | } 80 | 81 | export function loadPlay() { 82 | return storage.get(PLAY_KEY, []) 83 | } 84 | 85 | export function saveFavorite(song) { 86 | let songs = storage.get(FAVORITE_KEY, []) 87 | insertArray(songs, song, (item) => { 88 | return song.id === item.id 89 | }, FAVORITE_MAX_LENGTH) 90 | storage.set(FAVORITE_KEY, songs) 91 | return songs 92 | } 93 | 94 | export function deleteFavorite(song) { 95 | let songs = storage.get(FAVORITE_KEY, []) 96 | deleteFromArray(songs, (item) => { 97 | return item.id === song.id 98 | }) 99 | storage.set(FAVORITE_KEY, songs) 100 | return songs 101 | } 102 | 103 | export function loadFavorite() { 104 | return storage.get(FAVORITE_KEY, []) 105 | } -------------------------------------------------------------------------------- /src/common/js/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 配置项目相关信息 3 | */ 4 | 5 | //播放器播放模式: 顺序、循环、随机 6 | export const playMode = { 7 | sequence: 0, 8 | loop: 1, 9 | random: 2 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/common/js/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 封装一些DOM操作相关的代码 3 | */ 4 | 5 | export function addClass(el, className){ 6 | if(hasClass(el, className)){ 7 | return 8 | } 9 | // console.log(hasClass(el, className)) 10 | let newClass = el.className.split(' ') 11 | newClass.push(className) 12 | el.className = newClass.join(' ') 13 | } 14 | 15 | export function hasClass(el, className){ 16 | let reg = new RegExp('(^|\\s)' + className + '(\\s|$)') 17 | return reg.test(el.className) 18 | } 19 | 20 | export function getData(el, name, val){ 21 | const prefix = 'data-' 22 | name = prefix + name 23 | if(val){ 24 | return el.setAttribute(name, val) 25 | }else{ 26 | return el.getAttribute(name) 27 | } 28 | } 29 | 30 | //能力检测: 查看elementStyle支持哪些特性 31 | let elementStyle = document.createElement('div').style 32 | 33 | //供应商: 遍历查找浏览器的前缀名称,返回对应的当前浏览器 34 | let vendor = (() => { 35 | let transformNames = { 36 | webkit: 'webkitTransform', 37 | Moz: 'MozTransform', 38 | O: 'OTransform', 39 | ms: 'msTransform', 40 | standard: 'transform' 41 | } 42 | 43 | for (let key in transformNames) { 44 | if(elementStyle[transformNames[key]] !== undefined) { 45 | return key 46 | } 47 | } 48 | 49 | return false 50 | })() 51 | 52 | export function prefixStyle(style) { 53 | if(vendor === false){ 54 | return false 55 | } 56 | 57 | if(vendor === 'standard'){ 58 | return style 59 | } 60 | 61 | return vendor + style.charAt(0).toUpperCase() + style.substr(1) 62 | } -------------------------------------------------------------------------------- /src/common/js/jsonp.js: -------------------------------------------------------------------------------- 1 | import originJSONP from 'jsonp' 2 | 3 | export default function jsonp(url, data, option) { 4 | url += (url.indexOf('?') < 0 ? '?' : '&') + param(data); 5 | // console.log(url) 6 | 7 | return new Promise((resolve, reject) => { 8 | originJSONP(url, option, (err, data) => { 9 | if(!err){ 10 | resolve(data) 11 | }else{ 12 | reject(err) 13 | } 14 | }) 15 | }) 16 | } 17 | 18 | function param(data) { 19 | let url = "" 20 | for(var k in data){ 21 | let value = data[k] !== undefined ? data[k] : '' 22 | url += `&${k}=${encodeURIComponent(value)}` 23 | } 24 | return url ? url.substring(1) : '' 25 | } -------------------------------------------------------------------------------- /src/common/js/mixin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * mixin: 一个对象,Vue的混合机制,提高组件内容的复用性 3 | */ 4 | import {mapGetters, mapMutations, mapActions} from 'vuex' 5 | import {playMode} from '@/common/js/config' 6 | import {shuffle} from '@/common/js/util' 7 | 8 | export const playlistMixin = { 9 | computed:{ 10 | ...mapGetters([ 11 | 'playlist' 12 | ]) 13 | }, 14 | mounted() { 15 | this.handlePlaylist(this.playlist) 16 | }, 17 | activated() { //组件切换过来时会触发activated 18 | this.handlePlaylist(this.playlist) 19 | }, 20 | watch:{ 21 | playlist(newVal){ 22 | this.handlePlaylist(newVal) 23 | } 24 | }, 25 | methods: { //组件中定义handlePlaylist,就会覆盖这个,否则就会抛出异常 26 | handlePlaylist(){ 27 | throw new Error('component must implement handlePlaylist method') 28 | } 29 | } 30 | } 31 | 32 | export const playerMixin = { 33 | computed: { 34 | iconMode(){ 35 | return this.mode === playMode.sequence ? 'icon-sequence' : this.mode === playMode.loop ? 'icon-loop' : 'icon-random' 36 | }, 37 | ...mapGetters([ 38 | 'sequenceList', 39 | 'currentSong', 40 | 'playlist', 41 | 'mode', 42 | 'favoriteList' 43 | ]) 44 | }, 45 | methods: { 46 | changeMode(){ 47 | const mode = (this.mode + 1) % 3 48 | this.setPlayMode(mode) 49 | let list = null 50 | if(mode === playMode.random){ 51 | list = shuffle(this.sequenceList) 52 | }else{ 53 | list = this.sequenceList 54 | } 55 | this.resetCurrentIndex(list) 56 | this.setPlayList(list) 57 | }, 58 | resetCurrentIndex(list){ 59 | let index = list.findIndex((item) => { //es6语法 60 | return item.id === this.currentSong.id 61 | }) 62 | this.setCurrentIndex(index) 63 | }, 64 | toggleFavorite(song) { 65 | if (this.isFavorite(song)) { 66 | this.deleteFavoriteList(song) 67 | } else { 68 | this.saveFavoriteList(song) 69 | } 70 | }, 71 | getFavoriteIcon(song) { 72 | if (this.isFavorite(song)) { 73 | return 'icon-favorite' 74 | } 75 | return 'icon-not-favorite' 76 | }, 77 | isFavorite(song) { 78 | const index = this.favoriteList.findIndex((item) => { 79 | return item.id === song.id 80 | }) 81 | return index > -1 82 | }, 83 | ...mapMutations({ 84 | setPlayingState: 'SET_PLAYING_STATE', 85 | setCurrentIndex: 'SET_CURRENT_INDEX', 86 | setPlayMode: 'SET_PLAY_MODE', 87 | setPlayList: 'SET_PLAYLIST' 88 | }), 89 | ...mapActions([ 90 | 'saveFavoriteList', 91 | 'deleteFavoriteList' 92 | ]) 93 | } 94 | } 95 | 96 | export const searchMixin = { 97 | computed: { 98 | ...mapGetters([ 99 | 'searchHistory' 100 | ]) 101 | }, 102 | data() { 103 | return { 104 | query: '', 105 | refreshDelay: 100, 106 | } 107 | }, 108 | methods: { 109 | blurInput() { 110 | this.$refs.searchBox.blur() 111 | }, 112 | saveSearch() { 113 | this.saveSearchHistory(this.query) 114 | }, 115 | onQueryChange(query){ 116 | this.query = query 117 | }, 118 | addQuery(query) { 119 | this.$refs.searchBox.setQuery(query) 120 | }, 121 | ...mapActions([ 122 | 'saveSearchHistory', 123 | 'deleteSearchHistory' 124 | ]) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/common/js/singer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 构造一个Singer类 3 | * JavaScript constructor 属性返回对创建此对象的数组函数的引用 4 | */ 5 | export default class Singer { 6 | constructor({id, name}) { 7 | //将参数全部拷贝到当前实例中 8 | this.id = id 9 | this.name = name 10 | this.avatar = `https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?max_age=2592000` 11 | } 12 | } -------------------------------------------------------------------------------- /src/common/js/song.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 构造一个Song类 3 | * JavaScript constructor 属性返回对创建此对象的数组函数的引用 4 | * 5 | * 设计为类而不是对象的好处: 6 | * 1.可以把代码集中的一个地方维护 7 | * 2.类的扩展器比对象的扩展器强很多,而且它是一种面向对象的编程方式 8 | */ 9 | 10 | import {getLyric} from '@/api/song' 11 | import {ERR_OK} from '@/api/config' 12 | import {Base64} from 'js-base64' 13 | 14 | export default class Song { 15 | constructor({id, mid, singer, name, album, duration, image, url}){ 16 | this.id = id 17 | this.mid = mid 18 | this.singer = singer 19 | this.name = name 20 | this.album = album 21 | this.duration = duration 22 | this.image = image 23 | this.url = url 24 | } 25 | 26 | getLyric() { 27 | if(this.lyric){ 28 | return Promise.resolve() 29 | } 30 | 31 | return new Promise((resolve, reject) => { 32 | getLyric(this.mid).then((res) => { 33 | if(res.retcode === ERR_OK){ 34 | this.lyric = Base64.decode(res.lyric)//解码 得到字符串 35 | // console.log(this.lyric) 36 | resolve(this.lyric) 37 | }else{ 38 | reject('no lyric') 39 | } 40 | }) 41 | }) 42 | } 43 | } 44 | 45 | //抽象出一个工厂方法:传入musicData对象参数,实例化一个Song 46 | export function createSong(musicData, songVkey){ 47 | return new Song({ 48 | id: musicData.songid, 49 | mid: musicData.songmid, 50 | singer: filterSinger(musicData.singer), 51 | name: musicData.songname, 52 | album: musicData.albumname, 53 | duration: musicData.interval, //歌曲时长s 54 | image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.albummid}.jpg?max_age=2592000`, 55 | // url: `http://ws.stream.qqmusic.qq.com/${musicData.songid}.m4a?fromtag=46` 56 | //注意guid以实时数据为主 57 | url: `http://ws.stream.qqmusic.qq.com/C400${musicData.songmid}.m4a?vkey=${songVkey}&guid=6319873028&uin=0&fromtag=66` 58 | }) 59 | } 60 | 61 | //格式化处理singer数据 62 | export function filterSinger(singer){ 63 | let ret = [] 64 | if(!singer){ 65 | return '' 66 | } 67 | singer.forEach((s) => { 68 | ret.push(s.name) 69 | }) 70 | return ret.join('/') 71 | } 72 | 73 | export function creatSongList (musicData, songVkey) { 74 | return new Song({ 75 | id: musicData.id, 76 | mid: musicData.mid, 77 | singer: filterSinger(musicData.singer), 78 | name: musicData.name, 79 | album: musicData.albumname, 80 | duration: musicData.interval, 81 | image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.album.mid}.jpg?max_age=2592000`, 82 | //注意guid以实时数据为主 83 | url: `http://ws.stream.qqmusic.qq.com/C400${musicData.mid}.m4a?vkey=${songVkey}&guid=6319873028&uin=0&fromtag=66` 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/common/js/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具函数 3 | */ 4 | 5 | function getRandomInt(min, max){ 6 | return Math.floor(Math.random() * (max - min + 1) + min) 7 | } 8 | 9 | //洗牌: 遍历arr, 从0-i 之间随机取一个数j,使arr[i]与arr[j]互换 10 | export function shuffle(arr){ 11 | let _arr = arr.slice() //改变副本,不修改原数组 避免副作用 12 | for(let i = 0; i<_arr.length; i++){ 13 | let j = getRandomInt(0, i) 14 | let t = _arr[i] 15 | _arr[i] = _arr[j] 16 | _arr[j] = t 17 | } 18 | return _arr 19 | } 20 | 21 | //截流 22 | //对一个函数做截流,就会返回新的函数,新函数是在延迟执行原函数 23 | //如果很快的多次调用新函数,timer会被清空,不能多次调用原函数,实现截流 24 | export function debounce(func, delay){ 25 | let timer 26 | 27 | return function (...args) { 28 | if (timer) { 29 | clearTimeout(timer) 30 | } 31 | timer = setTimeout(() => { 32 | func.apply(this, args) 33 | }, delay) 34 | } 35 | } -------------------------------------------------------------------------------- /src/common/stylus/base.styl: -------------------------------------------------------------------------------- 1 | @import "variable.styl" 2 | 3 | body, html 4 | line-height: 1 5 | font-family: 'PingFang SC', 'STHeitiSC-Light', 'Helvetica-Light', arial, sans-serif, 'Droid Sans Fallback' 6 | user-select: none 7 | -webkit-tap-highlight-color: transparent 8 | background: $color-background 9 | color: $color-text -------------------------------------------------------------------------------- /src/common/stylus/icon.styl: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-family: 'music-icon' 3 | src: url('../fonts/music-icon.eot?2qevqt') 4 | src: url('../fonts/music-icon.eot?2qevqt#iefix') format('embedded-opentype'), 5 | url('../fonts/music-icon.ttf?2qevqt') format('truetype'), 6 | url('../fonts/music-icon.woff?2qevqt') format('woff'), 7 | url('../fonts/music-icon.svg?2qevqt#music-icon') format('svg') 8 | font-weight: normal 9 | font-style: normal 10 | 11 | [class^="icon-"], [class*=" icon-"] 12 | /* use !important to prevent issues with browser extensions that change fonts */ 13 | font-family: 'music-icon' !important 14 | speak: none 15 | font-style: normal 16 | font-weight: normal 17 | font-variant: normal 18 | text-transform: none 19 | line-height: 1 20 | 21 | /* Better Font Rendering =========== */ 22 | -webkit-font-smoothing: antialiased 23 | -moz-osx-font-smoothing: grayscale 24 | 25 | .icon-ok:before 26 | content: "\e900" 27 | 28 | .icon-close:before 29 | content: "\e901" 30 | 31 | .icon-add:before 32 | content: "\e902" 33 | 34 | .icon-play-mini:before 35 | content: "\e903" 36 | 37 | .icon-playlist:before 38 | content: "\e904" 39 | 40 | .icon-music:before 41 | content: "\e905" 42 | 43 | .icon-search:before 44 | content: "\e906" 45 | 46 | .icon-clear:before 47 | content: "\e907" 48 | 49 | .icon-delete:before 50 | content: "\e908" 51 | 52 | .icon-favorite:before 53 | content: "\e909" 54 | 55 | .icon-not-favorite:before 56 | content: "\e90a" 57 | 58 | .icon-pause:before 59 | content: "\e90b" 60 | 61 | .icon-play:before 62 | content: "\e90c" 63 | 64 | .icon-prev:before 65 | content: "\e90d" 66 | 67 | .icon-loop:before 68 | content: "\e90e" 69 | 70 | .icon-sequence:before 71 | content: "\e90f" 72 | 73 | .icon-random:before 74 | content: "\e910" 75 | 76 | .icon-back:before 77 | content: "\e911" 78 | 79 | .icon-mine:before 80 | content: "\e912" 81 | 82 | .icon-next:before 83 | content: "\e913" 84 | 85 | .icon-dismiss:before 86 | content: "\e914" 87 | 88 | .icon-pause-mini:before 89 | content: "\e915" 90 | -------------------------------------------------------------------------------- /src/common/stylus/index.styl: -------------------------------------------------------------------------------- 1 | @import "./reset.styl" 2 | @import "./base.styl" 3 | @import "./icon.styl" -------------------------------------------------------------------------------- /src/common/stylus/mixin.styl: -------------------------------------------------------------------------------- 1 | // 背景图片 2 | bg-image($url) 3 | background-image: url($url + "@2x.png") 4 | @media (-webkit-min-device-pixel-ratio: 3),(min-device-pixel-ratio: 3) 5 | background-image: url($url + "@3x.png") 6 | 7 | // 不换行 8 | no-wrap() 9 | text-overflow: ellipsis 10 | overflow: hidden 11 | white-space: nowrap 12 | 13 | // 扩展点击区域 14 | extend-click() 15 | position: relative 16 | &:before 17 | content: '' 18 | position: absolute 19 | top: -10px 20 | left: -10px 21 | right: -10px 22 | bottom: -10px -------------------------------------------------------------------------------- /src/common/stylus/reset.styl: -------------------------------------------------------------------------------- 1 | /** 2 | * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/) 3 | * http://cssreset.com 4 | */ 5 | html, body, div, span, applet, object, iframe, 6 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 7 | a, abbr, acronym, address, big, cite, code, 8 | del, dfn, em, img, ins, kbd, q, s, samp, 9 | small, strike, strong, sub, sup, tt, var, 10 | b, u, i, center, 11 | dl, dt, dd, ol, ul, li, 12 | fieldset, form, label, legend, 13 | table, caption, tbody, tfoot, thead, tr, th, td, 14 | article, aside, canvas, details, embed, 15 | figure, figcaption, footer, header, 16 | menu, nav, output, ruby, section, summary, 17 | time, mark, audio, video, input 18 | margin: 0 19 | padding: 0 20 | border: 0 21 | font-size: 100% 22 | font-weight: normal 23 | vertical-align: baseline 24 | 25 | /* HTML5 display-role reset for older browsers */ 26 | article, aside, details, figcaption, figure, 27 | footer, header, menu, nav, section 28 | display: block 29 | 30 | body 31 | line-height: 1 32 | 33 | blockquote, q 34 | quotes: none 35 | 36 | blockquote:before, blockquote:after, 37 | q:before, q:after 38 | content: none 39 | 40 | table 41 | border-collapse: collapse 42 | border-spacing: 0 43 | 44 | /* custom */ 45 | 46 | a 47 | color: #7e8c8d 48 | -webkit-backface-visibility: hidden 49 | text-decoration: none 50 | 51 | li 52 | list-style: none 53 | 54 | body 55 | -webkit-text-size-adjust: none 56 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0) 57 | -------------------------------------------------------------------------------- /src/common/stylus/variable.styl: -------------------------------------------------------------------------------- 1 | // 颜色定义规范 2 | $color-background = #222 3 | $color-background-d = rgba(0, 0, 0, 0.3) 4 | $color-highlight-background = #333 5 | $color-dialog-background = #666 6 | $color-theme = #ffcd32 7 | $color-theme-d = rgba(255, 205, 49, 0.5) 8 | $color-sub-theme = #d93f30 9 | $color-text = #fff 10 | $color-text-d = rgba(255, 255, 255, 0.3) 11 | $color-text-l = rgba(255, 255, 255, 0.5) 12 | $color-text-ll = rgba(255, 255, 255, 0.8) 13 | 14 | //字体定义规范 15 | $font-size-small-s = 10px 16 | $font-size-small = 12px 17 | $font-size-medium = 14px 18 | $font-size-medium-x = 16px 19 | $font-size-large = 18px 20 | $font-size-large-x = 22px -------------------------------------------------------------------------------- /src/components/add-song/add-song.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 119 | 120 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /src/components/disc/disc.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 76 | 77 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/components/m-header/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/components/m-header/logo@2x.png -------------------------------------------------------------------------------- /src/components/m-header/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/src/components/m-header/logo@3x.png -------------------------------------------------------------------------------- /src/components/m-header/m-header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 52 | -------------------------------------------------------------------------------- /src/components/music-list/music-list.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 154 | 155 | 238 | -------------------------------------------------------------------------------- /src/components/player/player.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 452 | 453 | 693 | 694 | -------------------------------------------------------------------------------- /src/components/playlist/playlist.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 135 | 136 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /src/components/rank/rank.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 74 | 75 | 76 | 119 | -------------------------------------------------------------------------------- /src/components/recommend/recommend.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/components/search/search.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 39 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/components/singer-detail/singer-detail.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 80 | 81 | 97 | -------------------------------------------------------------------------------- /src/components/singer/singer.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /src/components/suggest/suggest.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 213 | 214 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /src/components/tab/tab.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 43 | -------------------------------------------------------------------------------- /src/components/top-list/top-list.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 79 | 80 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/components/user-center/user-center.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 116 | 117 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import 'babel-polyfill' 4 | import Vue from 'vue' 5 | import App from './App' 6 | import router from './router' 7 | import store from './store' 8 | import fastclick from 'fastclick' 9 | import VueLazyload from 'vue-lazyload' 10 | 11 | import './common/stylus/index.styl' 12 | 13 | fastclick.attach(document.body) 14 | 15 | Vue.config.productionTip = false 16 | 17 | Vue.use(VueLazyload, { 18 | loading: require('@/common/image/default.png') 19 | }) 20 | 21 | /* eslint-disable no-new */ 22 | new Vue({ 23 | el: '#app', 24 | router, 25 | store, 26 | components: { App }, 27 | template: '' 28 | }) 29 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Recommend from '@/components/recommend/recommend' 4 | import Singer from '@/components/singer/singer' 5 | import Rank from '@/components/rank/rank' 6 | import Search from '@/components/search/search' 7 | import SingerDetail from '@/components/singer-detail/singer-detail' 8 | import Disc from '@/components/disc/disc' 9 | import TopList from '@/components/top-list/top-list' 10 | import UserCenter from '@/components/user-center/user-center' 11 | 12 | Vue.use(Router) 13 | 14 | export default new Router({ 15 | routes: [ 16 | { 17 | path: '/', 18 | redirect: '/recommend' 19 | }, 20 | { 21 | path: '/recommend', 22 | component: Recommend, 23 | children: [ 24 | { 25 | path: ':id', 26 | component: Disc 27 | } 28 | ] 29 | }, 30 | { 31 | path: '/rank', 32 | component: Rank, 33 | children: [ 34 | { 35 | path: ':id', 36 | component: TopList 37 | } 38 | ] 39 | }, 40 | { 41 | path: '/search', 42 | component: Search, 43 | children: [ 44 | { 45 | path: ':id', 46 | component: SingerDetail 47 | } 48 | ] 49 | }, 50 | { 51 | path: '/singer', 52 | component: Singer, 53 | children: [ 54 | { 55 | path: ':id', 56 | component: SingerDetail 57 | } 58 | ] 59 | }, 60 | { 61 | path: '/user', 62 | component: UserCenter 63 | } 64 | ] 65 | }) 66 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 处理异步操作和修改、以及对mutation操作的封装 3 | * 在一个动作中多次修改mutations,在actions中封装 4 | */ 5 | 6 | import * as types from './mutation-types' 7 | import {playMode} from '@/common/js/config' 8 | import {shuffle} from '@/common/js/util' 9 | import {saveSearch, deleteSearch, clearSearch, savePlay, saveFavorite, deleteFavorite} from '@/common/js/catch' 10 | 11 | function findIndex(list, song){ 12 | return list.findIndex((item) => { 13 | return item.id === song.id 14 | }) 15 | } 16 | 17 | export const selectPlay = function ({commit, state}, {list, index}) { 18 | //commit方法提交mutation 19 | commit(types.SET_SEQUENCE_LIST, list) 20 | if(state.mode === playMode.random) { 21 | let randomList = shuffle(list) 22 | commit(types.SET_PLAYLIST, randomList) 23 | index = findIndex(randomList, list[index]) 24 | }else{ 25 | commit(types.SET_PLAYLIST, list) 26 | } 27 | commit(types.SET_CURRENT_INDEX, index) 28 | commit(types.SET_FULL_SCREEN, true) 29 | commit(types.SET_PLAYING_STATE, true) 30 | } 31 | 32 | export const randomPlay = function ({commit},{list}){ 33 | commit(types.SET_PLAY_MODE, playMode.random) 34 | commit(types.SET_SEQUENCE_LIST, list) 35 | let randomList = shuffle(list) 36 | commit(types.SET_PLAYLIST, randomList) 37 | commit(types.SET_CURRENT_INDEX, 0) 38 | commit(types.SET_FULL_SCREEN, true) 39 | commit(types.SET_PLAYING_STATE, true) 40 | } 41 | 42 | export const insertSong = function ({commit, state}, song){ 43 | let playlist = state.playlist.slice() //副本 44 | let sequenceList = state.sequenceList.slice() //副本 45 | let currentIndex = state.currentIndex 46 | //记录当前歌曲 47 | let currentSong = playlist[currentIndex] 48 | //查找当前列表中是否有待插入的歌曲并返回其索引 49 | let fpIndex = findIndex(playlist, song) 50 | //因为是插入歌曲,所以索引+1 51 | currentIndex++ 52 | //插入这首歌到当前索引位置 53 | playlist.splice(currentIndex, 0, song) 54 | //如果已经包含了这首歌 55 | if(fpIndex > -1) { 56 | //如果当前插入的序号大于列表中的序号 57 | if(currentIndex > fpIndex) { 58 | playlist.splice(fpIndex, 1) 59 | currentIndex-- 60 | }else{ 61 | playlist.splice(fpIndex+1, 1) 62 | } 63 | } 64 | 65 | let currentSIndex = findIndex(sequenceList, currentSong) + 1 66 | 67 | let fsIndex = findIndex(sequenceList, song) 68 | 69 | sequenceList.splice(currentSIndex, 0, song) 70 | 71 | if(fsIndex > -1){ 72 | if(currentSIndex > fsIndex){ 73 | sequenceList.splice(fsIndex, 1) 74 | }else{ 75 | sequenceList.splice(fsIndex + 1, 1) 76 | } 77 | } 78 | 79 | commit(types.SET_PLAYLIST, playlist) 80 | commit(types.SET_SEQUENCE_LIST, sequenceList) 81 | commit(types.SET_CURRENT_INDEX, currentIndex) 82 | commit(types.SET_FULL_SCREEN, true) 83 | commit(types.SET_PLAYING_STATE, true) 84 | } 85 | 86 | export const deleteSong = function ({commit, state}, song){ 87 | let playlist = state.playlist.slice() //副本 88 | let sequenceList = state.sequenceList.slice() //副本 89 | let currentIndex = state.currentIndex 90 | 91 | let pIndex = findIndex(playlist, song) 92 | playlist.splice(pIndex, 1) 93 | 94 | let sIndex = findIndex(sequenceList, song) 95 | sequenceList.splice(sIndex, 1) 96 | 97 | if(currentIndex > pIndex || currentIndex === playlist.length){ 98 | currentIndex-- 99 | } 100 | commit(types.SET_PLAYLIST, playlist) 101 | commit(types.SET_SEQUENCE_LIST, sequenceList) 102 | commit(types.SET_CURRENT_INDEX, currentIndex) 103 | 104 | const playingState = playlist.length > 0 105 | commit(types.SET_PLAYING_STATE,playingState) 106 | } 107 | 108 | export const saveSearchHistory = function({commit}, query){ 109 | commit(types.SET_SEARCH_HISTORY, saveSearch(query)) 110 | } 111 | 112 | export const deleteSearchHistory = function({commit}, query){ 113 | commit(types.SET_SEARCH_HISTORY, deleteSearch(query)) 114 | } 115 | 116 | export const clearSearchHistory = function ({commit}){ 117 | commit(types.SET_SEARCH_HISTORY, clearSearch()) 118 | } 119 | 120 | export const deleteSongList = function ({commit}){ 121 | //将所有值都重置为初始状态 122 | commit(types.SET_PLAYLIST, []) 123 | commit(types.SET_SEQUENCE_LIST, []) 124 | commit(types.SET_CURRENT_INDEX, -1) 125 | commit(types.SET_PLAYING_STATE, false) 126 | } 127 | 128 | export const savePlayHistory = function({commit}, song){ 129 | commit(types.SET_PLAY_HISTORY, savePlay(song)) 130 | } 131 | 132 | export const saveFavoriteList = function ({commit}, song) { 133 | commit(types.SET_FAVORITE_LIST, saveFavorite(song)) 134 | } 135 | 136 | export const deleteFavoriteList = function ({commit}, song) { 137 | commit(types.SET_FAVORITE_LIST, deleteFavorite(song)) 138 | } 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 对获取的state 做一些映射 (类似于计算属性) 3 | */ 4 | 5 | export const singer = state => state.singer 6 | 7 | export const playing = state => state.playing 8 | 9 | export const fullScreen = state => state.fullScreen 10 | 11 | export const playlist = state => state.playlist 12 | 13 | export const sequenceList = state => state.sequenceList 14 | 15 | export const mode = state => state.mode 16 | 17 | export const currentIndex = state => state.currentIndex 18 | 19 | export const currentSong = (state) => { 20 | return state.playlist[state.currentIndex] || {} 21 | } 22 | 23 | export const disc = state => state.disc 24 | 25 | export const topList = state => state.topList 26 | 27 | export const searchHistory = state => state.searchHistory 28 | 29 | export const playHistory = state => state.playHistory 30 | 31 | export const favoriteList = state => state.favoriteList -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 初始化Vuex入口文件 3 | */ 4 | 5 | import Vue from 'vue' 6 | import Vuex from 'vuex' 7 | 8 | import * as actions from './actions' 9 | import * as getters from './getters' 10 | import state from './state' 11 | import mutations from './mutations' 12 | 13 | //Vuex 内置日志插件用于一般的调试 14 | import createLogger from 'vuex/dist/logger' 15 | 16 | Vue.use(Vuex) 17 | 18 | //只在开发环境时启动严格模式 19 | const debug = process.env.NODE_ENV !== 'production' 20 | 21 | //工厂方法输出一个单例Vuex.Store模式 22 | export default new Vuex.Store({ 23 | actions, 24 | getters, 25 | state, 26 | mutations, 27 | // strict: debug, 28 | plugins: debug ? [createLogger()] : [] 29 | }) 30 | -------------------------------------------------------------------------------- /src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 管理所有mutation 事件类型(type)--字符串常量 3 | * mutation中都是动作,前缀SET、UPDATE等 4 | */ 5 | 6 | export const SET_SINGER = 'SET_SINGER' 7 | 8 | export const SET_PLAYING_STATE = 'SET_PLAYING_STATE' 9 | 10 | export const SET_FULL_SCREEN = 'SET_FULL_SCREEN' 11 | 12 | export const SET_PLAYLIST = 'SET_PLAYLIST' 13 | 14 | export const SET_SEQUENCE_LIST = 'SET_SEQUENCE_LIST' 15 | 16 | export const SET_PLAY_MODE = 'SET_PLAY_MODE' 17 | 18 | export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX' 19 | 20 | export const SET_DISC = 'SET_DISC' 21 | 22 | export const SET_TOP_LIST = 'SET_TOP_LIST' 23 | 24 | export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY' 25 | 26 | export const SET_PLAY_HISTORY = 'SET_PLAY_HISTORY' 27 | 28 | export const SET_FAVORITE_LIST = 'SET_FAVORITE_LIST' -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 管理所有mutation: 更改 Vuex 的 store 中状态state的唯一方法 3 | */ 4 | 5 | import * as types from './mutation-types' 6 | 7 | const mutations = { 8 | [types.SET_SINGER](state, singer){ 9 | state.singer = singer 10 | }, 11 | [types.SET_PLAYING_STATE](state, flag){ 12 | state.playing = flag 13 | }, 14 | [types.SET_FULL_SCREEN](state, flag){ 15 | state.fullScreen = flag 16 | }, 17 | [types.SET_PLAYLIST](state, list){ 18 | state.playlist = list 19 | }, 20 | [types.SET_SEQUENCE_LIST](state, list){ 21 | state.sequenceList = list 22 | }, 23 | [types.SET_PLAY_MODE](state, mode){ 24 | state.mode = mode 25 | }, 26 | [types.SET_CURRENT_INDEX](state, index){ 27 | state.currentIndex = index 28 | }, 29 | [types.SET_DISC](state, disc){ 30 | state.disc = disc 31 | }, 32 | [types.SET_TOP_LIST](state, topList){ 33 | state.topList = topList 34 | }, 35 | [types.SET_SEARCH_HISTORY](state, history){ 36 | state.searchHistory = history 37 | }, 38 | [types.SET_PLAY_HISTORY](state, history){ 39 | state.playHistory = history 40 | }, 41 | [types.SET_FAVORITE_LIST](state, list){ 42 | state.favoriteList = list 43 | } 44 | } 45 | 46 | export default mutations 47 | -------------------------------------------------------------------------------- /src/store/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 管理所有状态 state 3 | */ 4 | import {playMode} from '@/common/js/config' 5 | import {loadSearch, loadPlay, loadFavorite} from '@/common/js/catch' 6 | 7 | const state = { 8 | singer: {}, 9 | playing: false, 10 | fullScreen: false, 11 | playlist: [], 12 | sequenceList: [], 13 | mode: playMode.sequence, 14 | currentIndex: -1, 15 | disc: {}, 16 | topList: {}, 17 | searchHistory: loadSearch(), //获取本地缓存中的数据 18 | playHistory: loadPlay(), 19 | favoriteList: loadFavorite() 20 | } 21 | 22 | export default state -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/.gitkeep -------------------------------------------------------------------------------- /static/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/1.png -------------------------------------------------------------------------------- /static/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/2.png -------------------------------------------------------------------------------- /static/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/3.png -------------------------------------------------------------------------------- /static/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/4.png -------------------------------------------------------------------------------- /static/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/66Web/ljq_vue_music/39041b3c7b37709fa682f984fc83e73352967e79/static/5.png -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // The assertion name is the filename. 3 | // Example usage: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // For more information on custom assertions see: 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | 10 | exports.assertion = function (selector, count) { 11 | this.message = 'Testing if element <' + selector + '> has count: ' + count 12 | this.expected = count 13 | this.pass = function (val) { 14 | return val === this.expected 15 | } 16 | this.value = function (res) { 17 | return res.value 18 | } 19 | this.command = function (cb) { 20 | var self = this 21 | return this.api.execute(function (selector) { 22 | return document.querySelectorAll(selector).length 23 | }, [selector], function (res) { 24 | cb.call(self, res) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register') 2 | var config = require('../../config') 3 | 4 | // http://nightwatchjs.org/gettingstarted#settings-file 5 | module.exports = { 6 | src_folders: ['test/e2e/specs'], 7 | output_folder: 'test/e2e/reports', 8 | custom_assertions_path: ['test/e2e/custom-assertions'], 9 | 10 | selenium: { 11 | start_process: true, 12 | server_path: require('selenium-server').path, 13 | host: '127.0.0.1', 14 | port: 4444, 15 | cli_args: { 16 | 'webdriver.chrome.driver': require('chromedriver').path 17 | } 18 | }, 19 | 20 | test_settings: { 21 | default: { 22 | selenium_port: 4444, 23 | selenium_host: 'localhost', 24 | silent: true, 25 | globals: { 26 | devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port) 27 | } 28 | }, 29 | 30 | chrome: { 31 | desiredCapabilities: { 32 | browserName: 'chrome', 33 | javascriptEnabled: true, 34 | acceptSslCerts: true 35 | } 36 | }, 37 | 38 | firefox: { 39 | desiredCapabilities: { 40 | browserName: 'firefox', 41 | javascriptEnabled: true, 42 | acceptSslCerts: true 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | 4 | const webpack = require('webpack') 5 | const DevServer = require('webpack-dev-server') 6 | 7 | const webpackConfig = require('../../build/webpack.prod.conf') 8 | const devConfigPromise = require('../../build/webpack.dev.conf') 9 | 10 | let server 11 | 12 | devConfigPromise.then(devConfig => { 13 | const devServerOptions = devConfig.devServer 14 | const compiler = webpack(webpackConfig) 15 | server = new DevServer(compiler, devServerOptions) 16 | const port = devServerOptions.port 17 | const host = devServerOptions.host 18 | return server.listen(port, host) 19 | }) 20 | .then(() => { 21 | // 2. run the nightwatch test suite against it 22 | // to run in additional browsers: 23 | // 1. add an entry in test/e2e/nightwatch.conf.js under "test_settings" 24 | // 2. add it to the --env flag below 25 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 26 | // For more information on Nightwatch's config file, see 27 | // http://nightwatchjs.org/guide#settings-file 28 | let opts = process.argv.slice(2) 29 | if (opts.indexOf('--config') === -1) { 30 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 31 | } 32 | if (opts.indexOf('--env') === -1) { 33 | opts = opts.concat(['--env', 'chrome']) 34 | } 35 | 36 | const spawn = require('cross-spawn') 37 | const runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 38 | 39 | runner.on('exit', function (code) { 40 | server.close() 41 | process.exit(code) 42 | }) 43 | 44 | runner.on('error', function (err) { 45 | server.close() 46 | throw err 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | module.exports = { 5 | 'default e2e tests': function (browser) { 6 | // automatically uses dev Server port from /config.index.js 7 | // default: http://localhost:8080 8 | // see nightwatch.conf.js 9 | const devServer = browser.globals.devServerURL 10 | 11 | browser 12 | .url(devServer) 13 | .waitForElementVisible('#app', 5000) 14 | .assert.elementPresent('.hello') 15 | .assert.containsText('h1', 'Welcome to Your Vue.js App') 16 | .assert.elementCount('img', 1) 17 | .end() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "globals": { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/unit/jest.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | rootDir: path.resolve(__dirname, '../../'), 5 | moduleFileExtensions: [ 6 | 'js', 7 | 'json', 8 | 'vue' 9 | ], 10 | moduleNameMapper: { 11 | '^@/(.*)$': '/src/$1' 12 | }, 13 | transform: { 14 | '^.+\\.js$': '/node_modules/babel-jest', 15 | '.*\\.(vue)$': '/node_modules/vue-jest' 16 | }, 17 | testPathIgnorePatterns: [ 18 | '/test/e2e' 19 | ], 20 | snapshotSerializers: ['/node_modules/jest-serializer-vue'], 21 | setupFiles: ['/test/unit/setup'], 22 | mapCoverage: true, 23 | coverageDirectory: '/test/unit/coverage', 24 | collectCoverageFrom: [ 25 | 'src/**/*.{js,vue}', 26 | '!src/main.js', 27 | '!src/router/index.js', 28 | '!**/node_modules/**' 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.config.productionTip = false 4 | -------------------------------------------------------------------------------- /test/unit/specs/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import HelloWorld from '@/components/HelloWorld' 3 | 4 | describe('HelloWorld.vue', () => { 5 | it('should render correct contents', () => { 6 | const Constructor = Vue.extend(HelloWorld) 7 | const vm = new Constructor().$mount() 8 | expect(vm.$el.querySelector('.hello h1').textContent) 9 | .toEqual('Welcome to Your Vue.js App') 10 | }) 11 | }) 12 | --------------------------------------------------------------------------------