├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── bin ├── app.js └── www ├── config ├── build.js ├── config.js ├── dev-client.js ├── dev-server.js ├── routes.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── controllers └── music.js ├── lib ├── api.js └── request.js ├── models └── kuwo.js ├── package.json ├── public └── index.html ├── src ├── App.vue ├── api │ └── music.js ├── components │ ├── Header.vue │ ├── PlayList.vue │ ├── Player.vue │ ├── ProgressLine.vue │ ├── SearchHeader.vue │ └── SearchList.vue ├── index.html ├── less │ ├── 1px.less │ ├── base.less │ ├── icon.less │ ├── style.less │ └── variable.less ├── main.js ├── router.js ├── store │ ├── actions.js │ ├── index.js │ └── mutations.js └── view │ ├── Main.vue │ └── Search.vue └── test └── unit ├── .eslintrc ├── index.js ├── karma.conf.js ├── specs ├── Header.spec.js └── Search.spec.js └── util.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{json}] 12 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | ecmaVersion: 6, 6 | sourceType: 'module' 7 | }, 8 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 9 | extends: 'standard', 10 | // required to lint *.vue files 11 | plugins: [ 12 | 'html' 13 | ], 14 | 15 | // add your custom rules here 16 | rules: { 17 | // allow paren-less arrow functions 18 | 'arrow-parens': 0, 19 | // allow async-await 20 | 'generator-star-spacing': 0, 21 | // 22 | 'space-before-function-paren': [0, "always"], 23 | // 4 space indent 24 | 'indent': [2, 4, { "SwitchCase": 1 }], 25 | // disable no-new 26 | 'no-new': 0, 27 | // allow debugger during development 28 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependency directory 7 | node_modules/ 8 | public/ 9 | test/unit/coverage/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Music 2 | 3 | Webpack、Vue、Express集成,学习之用。 4 | 5 | ## 使用 6 | 7 | ``` 8 | # install 9 | npm install 10 | 11 | # dev server, localhost:8080 12 | npm run dev 13 | 14 | # build with compress 15 | npm run build 16 | 17 | # Express app server, localhost:4000 18 | npm start 19 | ``` 20 | 21 | -------------------------------------------------------------------------------- /bin/app.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var config = require('../config/config') 3 | 4 | module.exports = config(express) 5 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('./app') 8 | var debug = require('debug')('music:server') 9 | var http = require('http') 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '4000') 16 | app.set('port', port) 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app) 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port) 29 | server.on('error', onError) 30 | server.on('listening', onListening) 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10) 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port 47 | } 48 | 49 | return false 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges') 69 | process.exit(1) 70 | break 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use') 73 | process.exit(1) 74 | break 75 | default: 76 | throw error 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address() 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port 89 | debug('Listening on ' + bind) 90 | } 91 | -------------------------------------------------------------------------------- /config/build.js: -------------------------------------------------------------------------------- 1 | // https://github.com/shelljs/shelljs 2 | require('shelljs/global') 3 | env.NODE_ENV = 'production' 4 | 5 | var ora = require('ora') 6 | var webpack = require('webpack') 7 | var conf = require('./webpack.prod.conf') 8 | 9 | var spinner = ora('building for production...') 10 | spinner.start() 11 | 12 | rm('-rf', 'public') 13 | mkdir('public') 14 | // cp('-R', 'src', conf.output.path) 15 | 16 | webpack(conf, function(err, stats) { 17 | spinner.stop() 18 | if (err) throw err 19 | process.stdout.write(stats.toString({ 20 | colors: true, 21 | modules: false, 22 | children: false, 23 | chunks: false, 24 | chunkModules: false 25 | }) + '\n') 26 | }) 27 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var favicon = require('serve-favicon') 3 | var logger = require('morgan') 4 | var cookieParser = require('cookie-parser') 5 | var bodyParser = require('body-parser') 6 | var ejs = require('ejs') 7 | var routes = require('./routes') 8 | 9 | 10 | module.exports = function(express) { 11 | 12 | var app = express() 13 | 14 | // view engine setup 15 | //app.engine('.html', ejs.__express); 16 | //app.set('views', path.join(__dirname, '../views')); 17 | //app.set('view engine', 'html'); 18 | 19 | // uncomment after placing your favicon in /public 20 | // app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))) 21 | app.use(logger('dev')) 22 | app.use(bodyParser.json()) 23 | app.use(bodyParser.urlencoded({ 24 | extended: false 25 | })) 26 | app.use(cookieParser()) 27 | app.use(express.static(path.join(__dirname, '../public'))) 28 | 29 | routes(app) 30 | 31 | app.get('/', function(req, res) { 32 | res.sendFile(path.join(__dirname, '../public/index.html')) 33 | }) 34 | 35 | app.get('/favicon.ico', function(req, res) { 36 | res.status(404) 37 | }) 38 | 39 | // catch 404 and forward to error handler 40 | app.use(function(req, res, next) { 41 | var err = new Error('Not Found') 42 | err.status = 404 43 | next(err) 44 | }) 45 | 46 | // error handlers 47 | 48 | // development error handler 49 | // will print stacktrace 50 | if (app.get('env') === 'development') { 51 | app.use(function(err, req, res, next) { 52 | res.status(err.status || 500) 53 | res.render('error', { 54 | message: err.message, 55 | error: err 56 | }) 57 | }) 58 | } 59 | 60 | // production error handler 61 | // no stacktraces leaked to user 62 | app.use(function(err, req, res, next) { 63 | res.status(err.status || 500) 64 | res.render('error', { 65 | message: err.message, 66 | error: {} 67 | }) 68 | }) 69 | 70 | return app 71 | 72 | } 73 | -------------------------------------------------------------------------------- /config/dev-client.js: -------------------------------------------------------------------------------- 1 | require('eventsource-polyfill') 2 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 3 | 4 | hotClient.subscribe(function(event) { 5 | if (event.action === 'reload') { 6 | window.location.reload() 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /config/dev-server.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var webpack = require('webpack') 3 | var opn = require('opn') 4 | var config = require('./webpack.dev.conf') 5 | var routes = require('./routes') 6 | // var proxyMiddleware = require('http-proxy-middleware') 7 | 8 | var app = express() 9 | var compiler = webpack(config) 10 | 11 | // Define HTTP proxies to your custom API backend 12 | // https://github.com/chimurai/http-proxy-middleware 13 | // var proxyTable = { 14 | // // '/api': { 15 | // // target: 'http://jsonplaceholder.typicode.com', 16 | // // changeOrigin: true, 17 | // // pathRewrite: { 18 | // // '^/api': '' 19 | // // } 20 | // // } 21 | // } 22 | 23 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 24 | publicPath: config.output.publicPath, 25 | noInfo: true, 26 | stats: { 27 | colors: true, 28 | chunks: false 29 | } 30 | }) 31 | 32 | var hotMiddleware = require('webpack-hot-middleware')(compiler) 33 | // force page reload when html-webpack-plugin template changes 34 | compiler.plugin('compilation', function(compilation) { 35 | compilation.plugin('html-webpack-plugin-after-emit', function(data, cb) { 36 | hotMiddleware.publish({ action: 'reload' }) 37 | cb() 38 | }) 39 | }) 40 | 41 | // proxy api requests 42 | // Object.keys(proxyTable).forEach(function (context) { 43 | // var options = proxyTable[context] 44 | // if (typeof options === 'string') { 45 | // options = { target: options } 46 | // } 47 | // app.use(proxyMiddleware(context, options)) 48 | // }) 49 | 50 | // handle fallback for HTML5 history API 51 | app.use(require('connect-history-api-fallback')()) 52 | 53 | // serve webpack bundle output 54 | app.use(devMiddleware) 55 | 56 | // enable hot-reload and state-preserving 57 | // compilation error display 58 | app.use(hotMiddleware) 59 | 60 | // serve pure static assets 61 | app.use('/src', express.static('./src')) 62 | 63 | routes(app) 64 | 65 | module.exports = app.listen(8080, function(err) { 66 | if (err) { 67 | console.log(err) 68 | return 69 | } 70 | var uri = 'http://localhost:8080' 71 | console.log('Listening at ' + uri + '\n') 72 | opn(uri) 73 | }) 74 | -------------------------------------------------------------------------------- /config/routes.js: -------------------------------------------------------------------------------- 1 | var music = require('../controllers/music') 2 | var path = require('path') 3 | 4 | module.exports = function(app) { 5 | 6 | app.get('/search/:key/:pn', music.search) 7 | 8 | app.get('/song/:id', music.download) 9 | 10 | } 11 | -------------------------------------------------------------------------------- /config/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var projectRoot = path.resolve(__dirname, '../') 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 4 | var autoprefixer = require('autoprefixer') 5 | 6 | module.exports = { 7 | entry: { 8 | app: './src/main.js' 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, '../public/'), 12 | // publicPath: './public/', 13 | filename: '[name].js' 14 | }, 15 | resolve: { 16 | extensions: ['', '.js', '.vue'], 17 | fallback: [path.join(__dirname, '../node_modules')], 18 | alias: { 19 | 'src': path.resolve(__dirname, '../src'), 20 | 'components': 'src/components' 21 | } 22 | }, 23 | resolveLoader: { 24 | fallback: [path.join(__dirname, '../node_modules')] 25 | }, 26 | module: { 27 | preLoaders: [{ 28 | test: /\.vue$/, 29 | loader: 'eslint', 30 | include: projectRoot, 31 | exclude: /node_modules/ 32 | }, { 33 | test: /\.js$/, 34 | loader: 'eslint', 35 | include: projectRoot, 36 | exclude: /node_modules/ 37 | }], 38 | loaders: [{ 39 | test: /\.vue$/, 40 | loader: 'vue' 41 | }, { 42 | test: /\.(css|less)$/, 43 | loader: ExtractTextPlugin.extract('style-loader', 'css!postcss!less') 44 | }, { 45 | test: /\.js$/, 46 | loader: 'babel', 47 | include: projectRoot, 48 | exclude: /node_modules/ 49 | }, { 50 | test: /\.json$/, 51 | loader: 'json' 52 | }, { 53 | test: /\.html$/, 54 | loader: 'vue-html' 55 | }, { 56 | test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf|otf)(\?.*)?$/, 57 | loader: 'url', 58 | query: { 59 | limit: 10000, 60 | name: '[name].[hash:7].[ext]' 61 | } 62 | }] 63 | }, 64 | vue: { 65 | loaders: { 66 | css: 'vue-style!css', 67 | less: 'vue-style!css!less' 68 | }, 69 | postcss: [autoprefixer({ 70 | browsers: ['last 3 versions'] 71 | })] 72 | }, 73 | eslint: { 74 | formatter: require('eslint-friendly-formatter') 75 | }, 76 | babel: { 77 | presets: ['es2015', 'stage-2'], 78 | plugins: ['transform-runtime'] 79 | }, 80 | postcss: [autoprefixer({ 81 | browsers: ['last 3 versions'] 82 | })] 83 | } 84 | -------------------------------------------------------------------------------- /config/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var merge = require('webpack-merge') 3 | var baseConfig = require('./webpack.base.conf') 4 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | var HtmlWebpackPlugin = require('html-webpack-plugin') 6 | 7 | // add hot-reload related code to entry chunks 8 | Object.keys(baseConfig.entry).forEach(function (name) { 9 | baseConfig.entry[name] = ['./config/dev-client'].concat(baseConfig.entry[name]) 10 | }) 11 | 12 | module.exports = merge(baseConfig, { 13 | devtool: '#eval-source-map', 14 | output: { 15 | // necessary for the html plugin to work properly 16 | // when serving the html from in-memory 17 | publicPath: '/' 18 | }, 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': { 22 | NODE_ENV: '"development"' 23 | } 24 | }), 25 | new webpack.optimize.OccurenceOrderPlugin(), 26 | new webpack.HotModuleReplacementPlugin(), 27 | new ExtractTextPlugin("[name].css"), 28 | // new webpack.NoErrorsPlugin(), 29 | new HtmlWebpackPlugin({ 30 | filename: 'index.html', 31 | template: 'src/index.html', 32 | inject: true 33 | }) 34 | ] 35 | }) 36 | -------------------------------------------------------------------------------- /config/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var merge = require('webpack-merge') 3 | var baseConfig = require('./webpack.base.conf') 4 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | var HtmlWebpackPlugin = require('html-webpack-plugin') 6 | 7 | module.exports = merge(baseConfig, { 8 | devtool: '#source-map', 9 | output: { 10 | filename: '[name].[chunkhash].js', 11 | chunkFilename: '[id].[chunkhash].js' 12 | }, 13 | vue: { 14 | loaders: { 15 | css: ExtractTextPlugin.extract('vue-style-loader', 'css?sourceMap!postcss?sourceMap?sourceMap'), 16 | less: ExtractTextPlugin.extract('vue-style-loader', 'css?sourceMap!postcss?sourceMap!less?sourceMap') 17 | } 18 | }, 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': { 22 | NODE_ENV: '"production"' 23 | } 24 | }), 25 | new webpack.optimize.UglifyJsPlugin({ 26 | compress: { 27 | warnings: false 28 | } 29 | }), 30 | new webpack.optimize.OccurenceOrderPlugin(), 31 | new ExtractTextPlugin('[name].[contenthash].css'), 32 | new HtmlWebpackPlugin({ 33 | filename: '../public/index.html', 34 | template: 'src/index.html', 35 | inject: true, 36 | minify: { 37 | removeComments: true, 38 | collapseWhitespace: true, 39 | removeAttributeQuotes: true 40 | } 41 | }) 42 | ] 43 | }) 44 | -------------------------------------------------------------------------------- /controllers/music.js: -------------------------------------------------------------------------------- 1 | var kuwo = require('../models/kuwo'); 2 | 3 | exports.search = function(req, res) { 4 | 5 | kuwo.search(req.params).then(function(data) { 6 | 7 | res.send(data); 8 | }, function(e) { 9 | console.log(e); 10 | }); 11 | 12 | }; 13 | 14 | exports.download = function(req, res) { 15 | 16 | kuwo.download(req.params.id).then(function(data) { 17 | res.send(data); 18 | }, function(e) { 19 | console.log(e); 20 | }); 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | var cheerio = require('cheerio'); 2 | 3 | // 酷我 4 | exports.kuwo = { 5 | searchUrl: 'http://sou.kuwo.cn/ws/NSearch', 6 | songUrl: 'http://antiserver.kuwo.cn/anti.s?format=mp3|aac&type=convert_url&response=url', 7 | parse: function(body, key, pn) { 8 | 9 | // 解析 html 10 | var $ = cheerio.load(body), 11 | $list = $('.m_list ul li'), 12 | $page = $('.page'), 13 | list = []; 14 | 15 | $list.each(function(i, el) { 16 | var $this = $(el); 17 | 18 | list.push({ 19 | id: parseInt($this.find('input[name="musicNum"]').val()), 20 | name: $this.find('.m_name a').attr('title'), 21 | album: $this.find('.a_name a').attr('title'), 22 | singer: $this.find('.s_name a').attr('title') 23 | }); 24 | }); 25 | 26 | $page.find('a').each(function(i, el) { 27 | var $this = $(el), 28 | href = $this.attr('href'); 29 | 30 | if (href !== '#@') { 31 | var num = href.split('=').reverse()[0]; 32 | $this.attr('data-option', JSON.stringify({ 33 | key: key, 34 | pn: num 35 | })); 36 | } 37 | 38 | $this.attr('href', 'javascript:;'); 39 | }); 40 | 41 | return { 42 | page: $page.html(), 43 | list: list 44 | }; 45 | } 46 | }; 47 | 48 | // 网易云,这里挖个坑 49 | exports.cloud = { 50 | // 搜索歌曲 POST 51 | // type 单曲(1),歌手(100),专辑(10),歌单(1000),用户(1002) *(type)* 52 | // limit 获取多少条数据 53 | // http://music.163.com/api/search/get/web?s=%E8%8A%B1%E6%B5%B7&type=1&offset=0&total=true&limit=60 54 | 55 | // 歌曲URL GET 56 | // 单曲 http://music.163.com/api/song/detail/?id=185697&ids=[185697] 57 | // 多曲 http://music.163.com/api/song/detail/?ids=[185697,xxxx] 58 | }; 59 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var url = require('url'); 3 | var querystring = require('querystring'); 4 | 5 | function request(method, urlStr, args) { 6 | 7 | var option = url.parse(urlStr); 8 | option.method = method; 9 | 10 | if(args) { 11 | var query = '?' + querystring.stringify(args) + (option.query ? '&' + option.query : ''); 12 | option.path = option.pathname + query; 13 | } 14 | 15 | return new Promise(function(resolve, reject) { 16 | 17 | var req = http.request(option, function(res) { 18 | 19 | var body = ''; 20 | 21 | res.setEncoding('utf8'); 22 | res.on('data', function(data) { 23 | body += data; 24 | }); 25 | 26 | res.on('end', function() { 27 | 28 | resolve(body); 29 | }); 30 | 31 | }); 32 | 33 | req.on('error', function(e) { 34 | reject(e); 35 | }); 36 | 37 | req.end(); 38 | }); 39 | } 40 | 41 | module.exports = function(url) { 42 | 43 | return { 44 | get: function(args) { 45 | return request('GET', url, args); 46 | }, 47 | post: function(args) { 48 | return request('POST', url, args); 49 | }, 50 | put: function(args) { 51 | return request('PUT', url, args); 52 | }, 53 | delete: function(args) { 54 | return request('DELETE', url, args); 55 | } 56 | }; 57 | 58 | }; 59 | -------------------------------------------------------------------------------- /models/kuwo.js: -------------------------------------------------------------------------------- 1 | var request = require('../lib/request'); 2 | var api = require('../lib/api').kuwo; 3 | 4 | exports.search = function(params) { 5 | 6 | var key = params.key, 7 | pn = params.pn; 8 | 9 | return request(api.searchUrl + '?type=music') 10 | .get(params) 11 | .then(function(body) { 12 | return api.parse(body, key, pn); 13 | }); 14 | }; 15 | 16 | exports.download = function(id) { 17 | 18 | return request(api.songUrl) 19 | .get({ 20 | rid: 'MUSIC_' + id 21 | }) 22 | .then(function(body) { 23 | return body; 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-music", 3 | "version": "1.0.1", 4 | "private": true, 5 | "author": { 6 | "name": "yusen", 7 | "email": "634206017@qq.com" 8 | }, 9 | "scripts": { 10 | "start": "node ./bin/www", 11 | "dev": "node config/dev-server.js", 12 | "build": "node config/build.js", 13 | "unit": "karma start test/unit/karma.conf.js --single-run", 14 | "test": "npm run unit" 15 | }, 16 | "dependencies": { 17 | "body-parser": "~1.13.2", 18 | "cheerio": "^0.20.0", 19 | "cookie-parser": "~1.3.5", 20 | "debug": "~2.2.0", 21 | "ejs": "^2.4.1", 22 | "express": "^4.13.4", 23 | "morgan": "^1.7.0", 24 | "serve-favicon": "~2.3.0", 25 | "vue": "^2.0.5", 26 | "vue-directive-waves": "^2.0.2", 27 | "vue-resource": "^1.0.3", 28 | "vue-router": "^2.0.1", 29 | "vuex": "^2.0.0" 30 | }, 31 | "devDependencies": { 32 | "autoprefixer": "^6.5.2", 33 | "babel-core": "^6.7.7", 34 | "babel-eslint": "^7.1.0", 35 | "babel-loader": "^6.2.4", 36 | "babel-plugin-transform-runtime": "^6.7.5", 37 | "babel-preset-es2015": "^6.6.0", 38 | "babel-preset-stage-2": "^6.5.0", 39 | "babel-runtime": "^5.8.38", 40 | "chai": "^3.5.0", 41 | "connect-history-api-fallback": "^1.2.0", 42 | "cross-env": "^1.0.7", 43 | "css-loader": "^0.25.0", 44 | "eslint": "^3.9.1", 45 | "eslint-config-standard": "^6.2.1", 46 | "eslint-friendly-formatter": "^2.0.6", 47 | "eslint-loader": "^1.6.1", 48 | "eslint-plugin-html": "^1.6.0", 49 | "eslint-plugin-promise": "^3.3.1", 50 | "eslint-plugin-standard": "^2.0.1", 51 | "eventsource-polyfill": "^0.9.6", 52 | "extract-text-webpack-plugin": "^1.0.1", 53 | "file-loader": "^0.9.0", 54 | "html-webpack-plugin": "^2.14.0", 55 | "http-proxy-middleware": "^0.17.2", 56 | "isparta-loader": "^2.0.0", 57 | "json-loader": "^0.5.4", 58 | "karma": "^1.3.0", 59 | "karma-chrome-launcher": "^2.0.0", 60 | "karma-coverage": "^1.1.1", 61 | "karma-mocha": "^1.2.0", 62 | "karma-sinon-chai": "^1.2.4", 63 | "karma-sourcemap-loader": "^0.3.7", 64 | "karma-spec-reporter": "0.0.26", 65 | "karma-webpack": "^1.8.0", 66 | "less": "^2.7.1", 67 | "less-loader": "^2.2.3", 68 | "mocha": "^3.1.2", 69 | "opn": "^4.0.2", 70 | "ora": "^0.3.0", 71 | "postcss-loader": "^1.1.0", 72 | "shelljs": "^0.7.4", 73 | "sinon": "^1.17.6", 74 | "sinon-chai": "^2.8.0", 75 | "style-loader": "^0.13.1", 76 | "url-loader": "^0.5.7", 77 | "vue-hot-reload-api": "^1.3.2", 78 | "vue-html-loader": "^1.2.2", 79 | "vue-loader": "^9.4.0", 80 | "vue-style-loader": "^1.0.0", 81 | "webpack": "^1.13.2", 82 | "webpack-dev-middleware": "^1.8.3", 83 | "webpack-dev-server": "^1.14.1", 84 | "webpack-hot-middleware": "^2.12.2", 85 | "webpack-merge": "^0.14.1" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | MUSIC -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /src/api/music.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueResource from 'vue-resource' 3 | 4 | Vue.use(VueResource) 5 | 6 | export default { 7 | search(key) { 8 | return Vue.http.get(`/search/${key}/1`) 9 | .then(res => res.json(), 10 | res => console.error('error: ', res.status, res.statusText)) 11 | }, 12 | getSong(id) { 13 | return Vue.http.get(`/song/${id}`) 14 | .then(res => res.text(), 15 | res => console.error('error: ', res.status, res.statusText)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/components/PlayList.vue: -------------------------------------------------------------------------------- 1 | 21 | 39 | -------------------------------------------------------------------------------- /src/components/Player.vue: -------------------------------------------------------------------------------- 1 | 40 | 126 | -------------------------------------------------------------------------------- /src/components/ProgressLine.vue: -------------------------------------------------------------------------------- 1 | 6 | 20 | -------------------------------------------------------------------------------- /src/components/SearchHeader.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/SearchList.vue: -------------------------------------------------------------------------------- 1 | 12 | 26 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | MUSIC 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/less/1px.less: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/yscoder/border-1px 3 | */ 4 | .border-scale(@s) { 5 | -webkit-transform: scale(@s); 6 | transform: scale(@s); 7 | } 8 | .border-postion(@x, @y) { 9 | -webkit-transform-origin: @x @y; 10 | transform-origin: @x @y; 11 | } 12 | 13 | .bd() { 14 | border-width: 0; 15 | border-color: @color-border; 16 | border-style: solid; 17 | } 18 | 19 | .ratina-bd(@dpr) { 20 | 21 | &::before { 22 | width: 100% * @dpr; 23 | height: 100% * @dpr; 24 | .border-scale(1/@dpr); 25 | } 26 | } 27 | 28 | .bd-t { 29 | .bd; 30 | border-top-width: 1px; 31 | } 32 | .bd-r { 33 | .bd; 34 | border-right-width: 1px; 35 | } 36 | .bd-b { 37 | .bd; 38 | border-bottom-width: 1px; 39 | } 40 | .bd-l { 41 | .bd; 42 | border-left-width: 1px; 43 | } 44 | .bd-all { 45 | .bd; 46 | border-width: 1px; 47 | } 48 | .bd-radius { 49 | .bd-all; 50 | border-radius: @border-radius-size; 51 | } 52 | .bd-dashed { 53 | border-style: dashed!important; 54 | } 55 | 56 | hr { 57 | border: none; 58 | } 59 | hr, 60 | .hr { 61 | margin: .7em 0; 62 | width: 100%; 63 | height: 1px; 64 | background: @color-border; 65 | overflow: hidden; 66 | } 67 | 68 | @media screen and (-webkit-min-device-pixel-ratio: 1.5) { 69 | .ratina-bd { 70 | position: relative; 71 | border: none!important; 72 | 73 | &::before { 74 | content: ''; 75 | position: absolute; 76 | top: 0; 77 | left: 0; 78 | .bd; 79 | .border-postion(0, 0); 80 | pointer-events: none; 81 | } 82 | 83 | .ratina-bd(1.5); 84 | 85 | &.bd-t { 86 | &::before { 87 | border-top-width: 1px; 88 | } 89 | } 90 | 91 | &.bd-r { 92 | &::before { 93 | border-right-width: 1px; 94 | } 95 | } 96 | 97 | &.bd-b { 98 | &::before { 99 | border-bottom-width: 1px; 100 | } 101 | } 102 | 103 | &.bd-l { 104 | &::before { 105 | border-left-width: 1px; 106 | } 107 | } 108 | 109 | &.bd-all { 110 | &::before { 111 | border-width: 1px; 112 | } 113 | } 114 | 115 | &.bd-radius { 116 | &::before { 117 | border-width: 1px; 118 | border-radius: @border-radius-size * 1.5; 119 | } 120 | } 121 | 122 | &.bd-dashed { 123 | &::before { 124 | border-style: dashed!important; 125 | } 126 | } 127 | 128 | } 129 | 130 | hr, 131 | .hr { 132 | width: 100% * 1.5; 133 | .border-scale(1/1.5); 134 | .border-postion(0, 0); 135 | } 136 | } 137 | 138 | @media screen and (-webkit-min-device-pixel-ratio: 2) { 139 | .ratina-bd { 140 | .ratina-bd(2); 141 | 142 | &.bd-radius { 143 | &::before { 144 | border-radius: @border-radius-size * 2; 145 | } 146 | } 147 | } 148 | 149 | hr, 150 | .hr { 151 | width: 100% * 2; 152 | .border-scale(1/2); 153 | } 154 | } 155 | 156 | @media screen and (-webkit-min-device-pixel-ratio: 3) { 157 | .ratina-bd { 158 | .ratina-bd(3); 159 | 160 | &.bd-radius { 161 | &::before { 162 | border-radius: @border-radius-size * 3; 163 | } 164 | } 165 | } 166 | 167 | hr, 168 | .hr { 169 | width: 100% * 3; 170 | .border-scale(1/3); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/less/base.less: -------------------------------------------------------------------------------- 1 | 2 | html { 3 | width: 100%; 4 | max-height: 100%; 5 | background-color: #fff; 6 | color: @color-text; 7 | font-size: 100px; 8 | overflow: hidden; 9 | } 10 | ::-webkit-scrollbar { 11 | display: none; 12 | } 13 | 14 | *, *::before, *::after { 15 | -webkit-box-sizing: border-box; 16 | box-sizing: border-box; 17 | } 18 | 19 | body { 20 | -webkit-tap-highlight-color: rgba(255,255,255,0); 21 | } 22 | 23 | body, input, button, textarea, select, option { 24 | font: normal .14em "Helvetica Neue", Helvetica, Arial, sans-serif; 25 | } 26 | 27 | body, h1, h2, h3, h4, h5, h6, p, figure, pre, dl, dd, blockquote, button, input { 28 | margin: 0; 29 | } 30 | 31 | input, legend, input, textarea, button { 32 | padding: 0; 33 | } 34 | 35 | ul, ol, form, fieldset, th, td { 36 | margin: 0; 37 | padding: 0; 38 | } 39 | 40 | ul, ol, li { 41 | list-style: none; 42 | } 43 | 44 | a { 45 | color: inherit; 46 | text-decoration: none; 47 | } 48 | 49 | article, p { 50 | max-height: 100%; 51 | } 52 | 53 | 54 | img { 55 | width: 100%; 56 | max-width: 100%; 57 | border: none; 58 | } 59 | 60 | em, i, address { 61 | font-style: normal; 62 | } 63 | 64 | input[type="button"], 65 | input[type="submit"], 66 | input[type="search"], 67 | input[type="reset"] { 68 | -webkit-appearance: button; /* iOS下的样式问题 */ 69 | } 70 | 71 | input:focus, 72 | select:focus, 73 | button:focus, 74 | textarea:focus { 75 | outline: none; 76 | } 77 | 78 | textarea { 79 | resize: none; 80 | } 81 | 82 | .fl {float: left;} 83 | .fr {float: right;} 84 | .oh {overflow: hidden;} 85 | .tr { 86 | text-align: right; 87 | } 88 | .tc { 89 | text-align: center; 90 | } 91 | 92 | .row:after, .clearfix:after { 93 | content: ""; 94 | display: table; 95 | clear: both; 96 | overflow: hidden; 97 | } 98 | 99 | .ellipsis { 100 | overflow: hidden; 101 | text-overflow: ellipsis; 102 | white-space: nowrap; 103 | } 104 | 105 | /* flex布局 */ 106 | .flex-row, .flex-row-vertical { 107 | display: -webkit-box; 108 | display: -webkit-flex; 109 | display: -ms-flexbox; 110 | display: flex; 111 | } 112 | 113 | .flex-row-vertical { 114 | -webkit-box-orient: vertical; 115 | -webkit-box-direction: normal; 116 | -webkit-flex-direction: column; 117 | -ms-flex-direction: column; 118 | flex-direction: column; 119 | } 120 | 121 | /* 多行布局 */ 122 | .flex-row-wrap { 123 | -webkit-flex-wrap: wrap; 124 | -ms-flex-wrap: wrap; 125 | flex-wrap: wrap; 126 | } 127 | 128 | /* 子元素反向排列 */ 129 | .flex-row-reverse { 130 | -webkit-box-orient: horizontal; 131 | -webkit-box-direction: reverse; 132 | -webkit-flex-direction: row-reverse; 133 | -ms-flex-direction: row-reverse; 134 | flex-direction: row-reverse; 135 | } 136 | 137 | /* 子元素默认平均分布 */ 138 | .flex-row > * { 139 | display: block; /* hack低版本Android 2.3- UC,QQ浏览器 */ 140 | } 141 | .flex-col { 142 | -webkit-box-flex: 1; 143 | -webkit-flex: 1; 144 | -ms-flex: 1; 145 | flex: 1; 146 | } 147 | 148 | /* 垂直居中 */ 149 | .flex-middle { 150 | -webkit-box-align: center; 151 | -webkit-align-items: center; 152 | -ms-flex-align: center; 153 | align-items: center; 154 | } 155 | 156 | /* 底部对齐 */ 157 | .flex-bottom { 158 | -webkit-box-align: end; 159 | -webkit-align-items: flex-end; 160 | -ms-flex-align: end; 161 | align-items: flex-end; 162 | } 163 | 164 | /* 沿主轴方向排列方式 */ 165 | .flex-justify-start { 166 | -webkit-box-pack: start; 167 | -webkit-justify-content: flex-start; 168 | -ms-flex-pack: start; 169 | justify-content: flex-start; 170 | } 171 | .flex-justify-end { 172 | -webkit-box-pack: end; 173 | -webkit-justify-content: flex-end; 174 | -ms-flex-pack: end; 175 | justify-content: flex-end; 176 | } 177 | .flex-justify-between { 178 | -webkit-box-pack: justify; 179 | -webkit-justify-content: space-between; 180 | -ms-flex-pack: justify; 181 | justify-content: space-between; 182 | } 183 | 184 | /* 沿侧轴方向排列方式 */ 185 | .flex-align-start { 186 | -webkit-align-content: flex-start; 187 | -ms-flex-line-pack: start; 188 | align-content: flex-start; 189 | } 190 | .flex-align-end { 191 | -webkit-align-content: flex-end; 192 | -ms-flex-line-pack: end; 193 | align-content: flex-end; 194 | } 195 | .flex-align-around { 196 | -webkit-align-content: space-around; 197 | -ms-flex-line-pack: distribute; 198 | align-content: space-around; 199 | } 200 | .flex-align-between { 201 | -webkit-align-content: space-between; 202 | -ms-flex-line-pack: justify; 203 | align-content: space-between; 204 | } 205 | .flex-col-auto { /* 不进行伸缩 */ 206 | -webkit-box-flex: 0; 207 | -webkit-flex: 0 auto; 208 | -ms-flex: 0 auto; 209 | flex: 0 auto; 210 | } 211 | 212 | .container, .mr-both { 213 | margin-left: @g-margin; 214 | margin-right: @g-margin; 215 | } 216 | 217 | .ml { margin-left: @g-margin} 218 | .mr { margin-right: @g-margin} 219 | -------------------------------------------------------------------------------- /src/less/icon.less: -------------------------------------------------------------------------------- 1 | 2 | @font-face { 3 | font-family: "iconfont"; 4 | src: url('data:application/x-font-woff;charset=utf-8;base64,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') format('woff'), 5 | url('//at.alicdn.com/t/font_1461831434_5471206.ttf') format('truetype'); 6 | } 7 | 8 | .icon { 9 | display: inline-block; 10 | font-family: "iconfont" !important; 11 | font-size: .14rem; 12 | font-style: normal; 13 | font-weight: normal; 14 | text-rendering: auto; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | } 18 | .icon-lg { 19 | font-size: .2rem; 20 | } 21 | 22 | .icon-2x { 23 | font-size: .28rem; 24 | } 25 | .icon-3x { 26 | font-size: .42rem; 27 | } 28 | .icon-4x { 29 | font-size: .56rem; 30 | } 31 | .icon-5x { 32 | font-size: .7rem; 33 | } 34 | .icon-mode-loop:before { content: "\e602"; } 35 | .icon-mode-single:before { content: "\e603"; } 36 | .icon-mode-random:before { content: "\e604"; } 37 | .icon-search:before { content: "\e605"; } 38 | .icon-pause:before { content: "\e608"; } 39 | .icon-play:before { content: "\e607"; } 40 | .icon-music:before { content: "\e606"; } 41 | .icon-prev:before { content: "\e600"; } 42 | .icon-more:before { content: "\e601"; } 43 | .icon-next:before { content: "\e609"; } 44 | -------------------------------------------------------------------------------- /src/less/style.less: -------------------------------------------------------------------------------- 1 | @import 'variable'; 2 | @import 'base'; 3 | @import 'icon'; 4 | @import '1px'; 5 | 6 | html { 7 | background: #fafafa 8 | } 9 | 10 | .@{css-prefix} { 11 | 12 | // header 13 | &-hd { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | z-index: 10; 18 | width: 100%; 19 | height: 56px; 20 | line-height: 56px; 21 | color: #fff; 22 | background: @color-primary; 23 | font-size: .16rem; 24 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12); 25 | 26 | ::-webkit-input-placeholder { 27 | color: #9FD69B; 28 | } 29 | 30 | &-icon { 31 | width: 56px; 32 | height: 56px; 33 | line-height: 56px; 34 | text-align: center; 35 | color: #fff; 36 | } 37 | 38 | } 39 | &-key { 40 | border: none; 41 | display: block; 42 | width: 100%; 43 | height: 36px; 44 | padding: 0 40px 0 8px; 45 | color: #fff; 46 | font-size: .16rem; 47 | background: @color-primary; 48 | border-bottom: 1px solid #fff; 49 | 50 | &-wrap { 51 | position: relative; 52 | margin: .1rem .16rem .1rem 0; 53 | } 54 | 55 | &-icon { 56 | position: absolute; 57 | top: 0; 58 | right: 0; 59 | color: #fff; 60 | width: 36px; 61 | height: 36px; 62 | line-height: 36px; 63 | text-align: center; 64 | } 65 | } 66 | &-so { 67 | color: #C8E6C9; 68 | } 69 | 70 | // player 71 | &-player { 72 | position: absolute; 73 | left: 0; 74 | bottom: 0; 75 | z-index: 66; 76 | width: 100%; 77 | height: 40px; 78 | background: #fff; 79 | 80 | .container { 81 | margin: 8px 0; 82 | } 83 | } 84 | 85 | &-pic-s { 86 | width: 40px; 87 | height: 40px; 88 | margin-right: @g-margin / 2; 89 | overflow: hidden; 90 | 91 | } 92 | 93 | &-txt { 94 | padding: 5px 0; 95 | .singer { 96 | color: @color-gray; 97 | font-size: .12rem; 98 | } 99 | } 100 | 101 | &-act { 102 | 103 | button { 104 | border: none; 105 | width: 40px; 106 | height: 40px; 107 | color: @color-gray; 108 | text-align: center; 109 | background: #fff; 110 | } 111 | } 112 | 113 | // list 114 | &-main { 115 | position: absolute; 116 | top: 0; 117 | left: 0; 118 | z-index: 2; 119 | width: 100%; 120 | height: 100%; 121 | max-height: 100%; 122 | padding: 56px 0 39px; 123 | overflow-y: auto; 124 | } 125 | 126 | &-list { 127 | background: #fff 128 | } 129 | 130 | &-list-item { 131 | line-height: 1.8; 132 | font-size: .16rem; 133 | 134 | .container { 135 | display: block; 136 | height: 56px; 137 | line-height: 56px; 138 | } 139 | 140 | .sub { 141 | color: @color-gray; 142 | } 143 | } 144 | 145 | &-play-item { 146 | position: relative; 147 | 148 | &:after { 149 | position: absolute; 150 | top: 0; 151 | left: 0; 152 | content: attr(data-index); 153 | width: 48px; 154 | line-height: 62px; 155 | text-align: center; 156 | font-size: .2rem; 157 | color: @color-gray; 158 | } 159 | 160 | .container { 161 | margin-left: 48px; 162 | margin-right: 0; 163 | padding: 8px 48px 8px 0; 164 | height: auto; 165 | line-height: 1.7; 166 | font-size: .15rem; 167 | } 168 | .sub { 169 | font-size: .12rem; 170 | } 171 | .icon-more { 172 | position: absolute; 173 | top: 50%; 174 | right: 0; 175 | margin-top: -20px; 176 | width: 40px; 177 | height: 40px; 178 | line-height: 40px; 179 | text-align: center; 180 | color: @color-gray; 181 | &:active { 182 | background: none; 183 | } 184 | } 185 | } 186 | 187 | } 188 | 189 | .progress-line { 190 | position: absolute; 191 | bottom: 0; 192 | left: 0; 193 | height: 3px; 194 | background: @color-border; 195 | transition: width 1s ease; 196 | 197 | &.primary { 198 | background: @color-primary; 199 | z-index: 2; 200 | } 201 | } 202 | 203 | .fade-in-left-enter, 204 | .fade-in-left-leave-active { 205 | transform: translate3d(100%, 0, 0); 206 | box-shadow: none; 207 | } 208 | 209 | .fade-out-left-enter, 210 | .fade-out-left-leave-active { 211 | transform: translate3d(-100%, 0, 0); 212 | box-shadow: none; 213 | } 214 | 215 | .fade-in-bottom-enter, 216 | .fade-in-bottom-leave-active { 217 | transform: translate3d(0, 100%, 0); 218 | box-shadow: none; 219 | } 220 | 221 | 222 | .fade-in-left-leave-active, 223 | .fade-out-left-enter-active, 224 | .fade-in-bottom-enter-active, 225 | .fade-in-bottom-leave-active { 226 | transition: transform .2s ease; 227 | } 228 | .fade-in-left-enter-active, 229 | .fade-out-left-leave-active { 230 | transition: transform .2s ease; 231 | } 232 | 233 | -------------------------------------------------------------------------------- /src/less/variable.less: -------------------------------------------------------------------------------- 1 | @color-primary: #4CAF50; 2 | @color-primary-dark: #388E3C; 3 | @color-primary-light: #C8E6C9; 4 | @color-text: #212121; 5 | @color-gray: #727272; 6 | @color-border: #DADADA; 7 | @color-active-bg: rgba(0, 0, 0, .26); 8 | 9 | @g-margin: 16px; 10 | @border-radius-size: 4px; 11 | 12 | @css-prefix: m; 13 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Waves from 'vue-directive-waves' 3 | import App from './App' 4 | import router from './router' 5 | import store from './store' 6 | 7 | Vue.use(Waves) 8 | 9 | new Vue({ 10 | el: '#app', 11 | router, 12 | store, 13 | render: h => h(App) 14 | }) 15 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Search from './view/Search' 4 | import Main from './view/Main' 5 | 6 | Vue.use(VueRouter) 7 | 8 | export default new VueRouter({ 9 | routes: [ 10 | { path: '/', component: Main }, 11 | { path: '/search', component: Search } 12 | ] 13 | }) 14 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import Music from '../api/music' 2 | 3 | export default { 4 | addPlayList(store, song) { 5 | Music.getSong(song.id).then(data => { 6 | song.url = data 7 | store.commit('PUSHSONG', song) 8 | }) 9 | }, 10 | cutSongByIndex(store, index) { 11 | store.commit('CUTSONG', index) 12 | }, 13 | nextSong(store, mode) { 14 | let len = store.state.playList.length 15 | let index = store.state.songIndex 16 | 17 | switch (mode) { 18 | case 0: 19 | // 顺序 20 | index = index + 1 === len ? 0 : index + 1 21 | break 22 | case 2: 23 | // 随机 24 | index = Math.floor((Math.random() * (len - 1)) + 1) 25 | break 26 | } 27 | 28 | store.commit('CUTSONG', index) 29 | }, 30 | search(store, key) { 31 | Music.search(key).then(data => { 32 | store.commit('SEARCHLIST', data.list) 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import mutations from './mutations' 4 | import actions from './actions' 5 | 6 | Vue.use(Vuex) 7 | 8 | export default new Vuex.Store({ 9 | state: { 10 | songIndex: 0, 11 | playList: [], 12 | searchList: [] 13 | }, 14 | mutations, 15 | actions, 16 | getters: { 17 | currentSong(state) { 18 | return state.playList[state.songIndex] 19 | }, 20 | playList(state) { 21 | return state.playList 22 | }, 23 | searchList(state) { 24 | return state.searchList 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | PUSHSONG(state, song) { 3 | !state.playList.some(item => item.id === song.id) && state.playList.push(song) 4 | }, 5 | CUTSONG(state, index) { 6 | state.songIndex = index 7 | }, 8 | SEARCHLIST(state, data) { 9 | state.searchList = data 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/view/Main.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /src/view/Search.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | 21 | 32 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | // require all test files (files that ends with .spec.js) 2 | const testsContext = require.context('./specs', true, /\.spec$/) 3 | testsContext.keys().forEach(testsContext) 4 | 5 | // require all src files except main.js for coverage. 6 | // you can also change this to match only the subset of files that 7 | // you want coverage for. 8 | // const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 9 | const srcContext = require.context('../../src', true, /\.vue$/) 10 | srcContext.keys().forEach(srcContext) 11 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | 6 | var path = require('path') 7 | var merge = require('webpack-merge') 8 | var baseConfig = require('../../config/webpack.base.conf') 9 | var webpack = require('webpack') 10 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | var projectRoot = path.resolve(__dirname, '../../') 12 | 13 | var webpackConfig = merge(baseConfig, { 14 | // use inline sourcemap for karma-sourcemap-loader 15 | // module: { 16 | // loaders: [{ 17 | // test: /\.(css|less)$/, 18 | // loader: 'vue-style!css!less' 19 | // }] 20 | // }, 21 | devtool: '#inline-source-map', 22 | vue: { 23 | loaders: { 24 | js: 'isparta' 25 | } 26 | }, 27 | plugins: [ 28 | new webpack.DefinePlugin({ 29 | 'process.env': '"testing"' 30 | }), 31 | new webpack.optimize.OccurenceOrderPlugin(), 32 | new ExtractTextPlugin('[name].css') 33 | ] 34 | }) 35 | 36 | // no need for app entry during tests 37 | delete webpackConfig.entry 38 | 39 | // make sure isparta loader is applied before eslint 40 | webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [] 41 | webpackConfig.module.preLoaders.unshift({ 42 | test: /\.js$/, 43 | loader: 'isparta', 44 | include: path.resolve(projectRoot, 'src') 45 | }) 46 | 47 | // only apply babel for test files when using isparta 48 | webpackConfig.module.loaders.some(function (loader, i) { 49 | if (loader.loader === 'babel') { 50 | loader.include = path.resolve(projectRoot, 'test/unit') 51 | return true 52 | } 53 | }) 54 | 55 | module.exports = function (config) { 56 | config.set({ 57 | // to run in additional browsers: 58 | // 1. install corresponding karma launcher 59 | // http://karma-runner.github.io/0.13/config/browsers.html 60 | // 2. add it to the `browsers` array below. 61 | browsers: ['Chrome'], 62 | frameworks: ['mocha', 'sinon-chai'], 63 | reporters: ['spec', 'coverage'], 64 | files: ['./index.js'], 65 | preprocessors: { 66 | './index.js': ['webpack', 'sourcemap'] 67 | }, 68 | webpack: webpackConfig, 69 | webpackMiddleware: { 70 | noInfo: true 71 | }, 72 | coverageReporter: { 73 | dir: './coverage', 74 | reporters: [ 75 | { type: 'lcov', subdir: '.' }, 76 | { type: 'text-summary' } 77 | ] 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /test/unit/specs/Header.spec.js: -------------------------------------------------------------------------------- 1 | import { createVue, destroyVM } from '../util.js' 2 | import Header from 'components/Header' 3 | 4 | describe('Header.vue', () => { 5 | let vm 6 | 7 | afterEach(() => { 8 | destroyVM(vm) 9 | }) 10 | 11 | it('Header is displayed', () => { 12 | vm = createVue(Header) 13 | expect(vm.$el.querySelector('.title').textContent).to.equal('Just Music') 14 | }) 15 | }) 16 | 17 | -------------------------------------------------------------------------------- /test/unit/specs/Search.spec.js: -------------------------------------------------------------------------------- 1 | import { createVue, destroyVM } from '../util.js' 2 | import SearchView from 'src/view/Search' 3 | import SearchHeader from 'components/SearchHeader' 4 | import SearchList from 'components/SearchList' 5 | 6 | describe('SearchView.vue', () => { 7 | let vm 8 | 9 | afterEach(() => { 10 | destroyVM(vm) 11 | }) 12 | 13 | it('Input is normal', () => { 14 | vm = createVue(SearchView, { 15 | SearchHeader, 16 | SearchList 17 | }) 18 | 19 | vm.key = '曾经的你' 20 | 21 | vm.$nextTick(() => { 22 | expect(vm.$el.querySelector('.m-key').textContent).to.equal('曾经的你') 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/unit/util.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import router from 'src/router' 3 | import Waves from 'vue-directive-waves' 4 | 5 | Vue.use(Waves) 6 | 7 | let id = 0 8 | 9 | // 创建DOM节点 10 | const createElm = () => { 11 | const elm = document.createElement('div') 12 | 13 | elm.id = 'app' + ++id 14 | document.body.appendChild(elm) 15 | 16 | return elm 17 | } 18 | 19 | // 创建测试实例 20 | exports.createVue = (comp, deps) => { 21 | // 关联依赖 22 | comp.components = deps || {} 23 | 24 | return new Vue({ 25 | el: createElm(), 26 | router, 27 | render: (h) => h(comp) 28 | }) 29 | } 30 | 31 | // 销毁实例 32 | exports.destroyVM = (vm) => { 33 | vm.$el && 34 | vm.$el.parentNode && 35 | vm.$el.parentNode.removeChild(vm.$el) 36 | } 37 | 38 | // 触发事件 39 | exports.triggerEvent = (elm, name, ...opts) => { 40 | let eventName 41 | 42 | if (/^mouse|click/.test(name)) { 43 | eventName = 'MouseEvents' 44 | } else if (/^key/.test(name)) { 45 | eventName = 'KeyboardEvent' 46 | } else { 47 | eventName = 'HTMLEvents' 48 | } 49 | const evt = document.createEvent(eventName) 50 | 51 | evt.initEvent(name, ...opts) 52 | elm.dispatchEvent 53 | ? elm.dispatchEvent(evt) 54 | : elm.fireEvent('on' + name, evt) 55 | 56 | return elm 57 | } 58 | --------------------------------------------------------------------------------