├── src ├── types │ ├── song.ts │ ├── rank.ts │ ├── player.ts │ ├── recommend.ts │ ├── singer.ts │ ├── index.ts │ └── search.ts ├── shims-vue.d.ts ├── assets │ ├── images │ │ ├── empty.png │ │ ├── first.png │ │ ├── logo.png │ │ ├── three.png │ │ ├── default.png │ │ ├── loading.gif │ │ └── second.png │ ├── fonts │ │ ├── music-icon.eot │ │ ├── music-icon.ttf │ │ └── music-icon.woff │ ├── js │ │ ├── data.ts │ │ ├── singer.ts │ │ ├── playList.ts │ │ ├── jsonp.ts │ │ ├── throttle-debounce.ts │ │ ├── search.ts │ │ ├── player.ts │ │ └── song.ts │ └── styles │ │ ├── transition.scss │ │ ├── mixin.scss │ │ ├── variables.scss │ │ ├── index.scss │ │ └── icon.scss ├── shims-tsx.d.ts ├── store │ ├── modules │ │ ├── disc.ts │ │ ├── singer.ts │ │ ├── top.ts │ │ ├── history.ts │ │ └── player.ts │ ├── index.ts │ ├── mutation-type.ts │ ├── types.ts │ └── getters.ts ├── api │ ├── config.ts │ ├── rank.ts │ ├── singer.ts │ ├── search.ts │ ├── recommend.ts │ └── song.ts ├── main.ts ├── utils │ ├── axios.ts │ ├── dom.ts │ ├── utils.ts │ └── cache.ts ├── base │ ├── empty │ │ └── index.vue │ ├── loading │ │ └── index.vue │ ├── confirm │ │ ├── index.ts │ │ └── index.vue │ ├── switches │ │ └── index.vue │ ├── notify │ │ └── index.vue │ ├── scroll │ │ └── index.vue │ └── slider │ │ └── index.vue ├── components │ ├── header │ │ └── index.vue │ ├── tab │ │ └── index.vue │ ├── progress-circle │ │ └── index.vue │ ├── search-box │ │ └── index.vue │ ├── song-list │ │ └── index.vue │ ├── progress-bar │ │ └── index.vue │ ├── suggestion │ │ └── index.vue │ ├── add-song │ │ └── index.vue │ ├── playlist │ │ └── index.vue │ ├── music-list │ │ └── index.vue │ └── list-view │ │ └── index.vue ├── views │ ├── recommend-detail │ │ └── detail.vue │ ├── singer-detail │ │ └── detail.vue │ ├── rank-detail │ │ └── detail.vue │ ├── singer │ │ └── index.vue │ ├── rank │ │ └── index.vue │ ├── user │ │ └── index.vue │ ├── recommend │ │ └── index.vue │ └── search │ │ └── index.vue ├── router │ └── index.ts └── App.vue ├── public ├── favicon.ico └── index.html ├── .browserslistrc ├── babel.config.js ├── .editorconfig ├── tests └── unit │ ├── components │ ├── __snapshots__ │ │ ├── scroll.spec.ts.snap │ │ ├── empty.spec.ts.snap │ │ ├── switches.spec.ts.snap │ │ ├── slider.spec.ts.snap │ │ ├── loading.spec.ts.snap │ │ ├── notify.spec.ts.snap │ │ ├── header.spec.ts.snap │ │ ├── confirm.spec.ts.snap │ │ ├── suggestion.spec.ts.snap │ │ └── tab.spec.ts.snap │ ├── header.spec.ts │ ├── loading.spec.ts │ ├── empty.spec.ts │ ├── tab.spec.ts │ ├── switches.spec.ts │ ├── scroll.spec.ts │ ├── confirm.spec.ts │ ├── slider.spec.ts │ ├── notify.spec.ts │ └── suggestion.spec.ts │ ├── store │ ├── singer.spec.ts │ ├── top.spec.ts │ ├── disc.spec.ts │ ├── history.spec.ts │ └── player.spec.ts │ └── utils │ ├── dom.spec.js │ ├── utils.spec.js │ └── cache.spec.js ├── .gitignore ├── postcss.config.js ├── jest.config.js ├── tsconfig.json ├── .eslintrc.js ├── package.json ├── vue.config.js └── README.md /src/types/song.ts: -------------------------------------------------------------------------------- 1 | export interface SongUrlMap { 2 | [propName: string]: string 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | not ie <= 8 3 | last 2 versions 4 | > 1% 5 | iOS >= 7 6 | Android >= 4.0 -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/images/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/empty.png -------------------------------------------------------------------------------- /src/assets/images/first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/first.png -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/assets/images/three.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/three.png -------------------------------------------------------------------------------- /src/assets/images/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/default.png -------------------------------------------------------------------------------- /src/assets/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/loading.gif -------------------------------------------------------------------------------- /src/assets/images/second.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/images/second.png -------------------------------------------------------------------------------- /src/assets/fonts/music-icon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/fonts/music-icon.eot -------------------------------------------------------------------------------- /src/assets/fonts/music-icon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/fonts/music-icon.ttf -------------------------------------------------------------------------------- /src/assets/fonts/music-icon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangtunan/vue-music-ts/HEAD/src/assets/fonts/music-icon.woff -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/scroll.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scroll.vue match snapshot 1`] = ` 4 |
5 |
6 |
7 | `; 8 | -------------------------------------------------------------------------------- /src/types/rank.ts: -------------------------------------------------------------------------------- 1 | export interface SongConfig { 2 | singername: string; 3 | songname: string; 4 | } 5 | export interface RankList { 6 | id: number; 7 | picUrl: string; 8 | topTitle: string; 9 | songList: SongConfig[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/js/data.ts: -------------------------------------------------------------------------------- 1 | import { Tab } from '@/types/index' 2 | 3 | export const tabList: Tab[] = [ 4 | { name: '推荐', path: '/recommend' }, 5 | { name: '歌手', path: '/singer' }, 6 | { name: '排行', path: '/rank' }, 7 | { name: '搜索', path: '/search' } 8 | ] 9 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/empty.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`empty.vue match snapshot 1`] = ` 4 |
5 |

抱歉,暂无数据

6 |
7 | `; 8 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/switches.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`switches.vue match snapshot 1`] = ` 4 | 8 | `; 9 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/slider.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`slider.vue match snapshot 1`] = ` 4 |
5 |
6 |
7 |
8 | `; 9 | -------------------------------------------------------------------------------- /src/assets/js/singer.ts: -------------------------------------------------------------------------------- 1 | export default class Singer { 2 | id: string 3 | name: string 4 | avatar: string 5 | constructor (id: string, name: string) { 6 | this.id = id 7 | this.name = name 8 | this.avatar = `https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?max_age=2592000` 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/styles/transition.scss: -------------------------------------------------------------------------------- 1 | .slide-enter-active, .slide-leave-active { 2 | transition: all 0.3s 3 | } 4 | .slide-enter, .slide-leave-to { 5 | transform: translate3d(100%, 0, 0) 6 | } 7 | .slide-in-enter-active{ 8 | transition: all 0.3s 9 | } 10 | .slide-in-enter{ 11 | transform: translate3d(100%, 0, 0) 12 | } -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/loading.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`loading.vue test snapshot 1`] = ` 4 |
5 |
6 |

正在加载中...

7 |
8 |
9 | `; 10 | -------------------------------------------------------------------------------- /src/types/player.ts: -------------------------------------------------------------------------------- 1 | import Song from '@/assets/js/song' 2 | 3 | export interface SelectPlay { 4 | list: Song[]; 5 | index: number | string; 6 | } 7 | 8 | export enum PlayMode { 9 | sequence = 0, 10 | loop = 1, 11 | random = 2 12 | } 13 | 14 | export interface LyricParams { 15 | lineNum: number; 16 | txt: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/types/recommend.ts: -------------------------------------------------------------------------------- 1 | export interface Banner { 2 | id: string 3 | linkUrl: string 4 | picUrl: string 5 | } 6 | 7 | export interface DiscCreator { 8 | name: string, 9 | avatarUrl?: string 10 | } 11 | 12 | export interface Disc { 13 | dissid: string | number 14 | dissname: string 15 | imgurl: string 16 | creator: DiscCreator 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /tests/unit/coverage 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/notify.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`notify.vue match snapshot 1`] = ` 4 | 5 |
6 |

已添加到播放列表

7 |
8 |
9 | `; 10 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/styles/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin ellipsis() { 2 | text-overflow: ellipsis; 3 | overflow: hidden; 4 | white-space: nowrap; 5 | } 6 | 7 | @mixin extend-click($extend: -10px) { 8 | position: relative; 9 | &::after { 10 | content: ''; 11 | position: absolute; 12 | left: $extend; 13 | right: $extend; 14 | top: $extend; 15 | bottom: $extend; 16 | } 17 | } -------------------------------------------------------------------------------- /src/assets/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $color-theme: #ffcd32; 2 | $color-theme-d: rgba(255,205,49,0.5); 3 | $color-sub-theme: #d93f30; 4 | $color-background: #222; 5 | $color-background-d: rgba(0, 0, 0, 0.3); 6 | $color-highlight-background: #333; 7 | 8 | $color-text: #fff; 9 | $color-text-d: rgba(255, 255, 255, 0.3); 10 | $color-text-l: rgba(255, 255, 255, 0.5); 11 | $color-text-ll: rgba(255, 255, 255, 0.8); -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/header.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`header.vue match shapshot 1`] = ` 4 | 8 | `; 9 | -------------------------------------------------------------------------------- /src/store/modules/disc.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-type' 2 | import { Disc } from '@/types/recommend' 3 | import { DiscState } from '../types' 4 | const state = { 5 | disc: {} 6 | } 7 | 8 | const mutations = { 9 | [types.SET_DISC] (state: DiscState, disc: Disc) { 10 | state.disc = disc 11 | } 12 | } 13 | 14 | export default { 15 | namespaced: true, 16 | state, 17 | mutations 18 | } 19 | -------------------------------------------------------------------------------- /src/api/config.ts: -------------------------------------------------------------------------------- 1 | import { MusicParams, Jsonpoptions } from '@/types/index' 2 | 3 | export const ERR_OK = 0 4 | 5 | export const BASE_URL = '' 6 | 7 | export const commonParams: MusicParams = { 8 | g_tk: 1928093487, 9 | inCharset: 'utf-8', 10 | outCharset: 'utf-8', 11 | notice: 0, 12 | format: 'jsonp' 13 | } 14 | 15 | export const jsonpOptions: Jsonpoptions = { 16 | param: 'jsonpCallback', 17 | prefix: 'jp' 18 | } 19 | -------------------------------------------------------------------------------- /src/store/modules/singer.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-type' 2 | import Singer from '@/assets/js/singer' 3 | import { SingerState } from '../types' 4 | 5 | const state = { 6 | singer: {} 7 | } 8 | 9 | const mutations = { 10 | [types.SET_SINGER] (state: SingerState, singer: Singer) { 11 | state.singer = singer 12 | } 13 | } 14 | 15 | export default { 16 | namespaced: true, 17 | state, 18 | mutations 19 | } 20 | -------------------------------------------------------------------------------- /src/store/modules/top.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-type' 2 | import { TopState } from '../types' 3 | import { RankList } from '@/types/rank' 4 | 5 | const state = { 6 | topList: {} 7 | } 8 | 9 | const mutations = { 10 | [types.SET_TOP_LIST] (state: TopState, topList: RankList) { 11 | state.topList = topList 12 | } 13 | } 14 | 15 | export default { 16 | namespaced: true, 17 | state, 18 | mutations 19 | } 20 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | import VueLazyload from 'vue-lazyload' 6 | import 'normalize.css' 7 | import '@/assets/styles/index.scss' 8 | Vue.use(VueLazyload, { 9 | loading: require('@/assets/images/default.png') 10 | }) 11 | Vue.config.productionTip = false 12 | 13 | new Vue({ 14 | router, 15 | store, 16 | render: h => h(App) 17 | }).$mount('#app') 18 | -------------------------------------------------------------------------------- /tests/unit/store/singer.spec.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store/index' 2 | import * as types from '@/store/mutation-type' 3 | 4 | describe('vuex module singer', () => { 5 | it('SET_SINGER mutations', () => { 6 | const singer = { 7 | id: '001', 8 | name: '陈奕迅', 9 | avatar: 'https://www.baidu.com/avatar/1.jpg' 10 | } 11 | store.commit(`singer/${types.SET_SINGER}`, singer) 12 | expect(store.state.singer.singer).toEqual(singer) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/assets/js/playList.ts: -------------------------------------------------------------------------------- 1 | import Song from './song' 2 | import { Component, Vue, Watch } from 'vue-property-decorator' 3 | import { Getter } from 'vuex-class' 4 | @Component 5 | export default class PlayList extends Vue { 6 | @Getter('playList') playList!: Song[] 7 | @Watch('playList') 8 | onPlayListChange () { 9 | this.handlePlayList() 10 | } 11 | public handlePlayList () { 12 | throw new Error('component must implement handlePlayList method') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import disc from './modules/disc' 4 | import singer from './modules/singer' 5 | import top from './modules/top' 6 | import history from './modules/history' 7 | import player from './modules/player' 8 | import * as getters from './getters' 9 | Vue.use(Vuex) 10 | 11 | export default new Vuex.Store({ 12 | getters, 13 | modules: { 14 | disc, 15 | singer, 16 | top, 17 | history, 18 | player 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/confirm.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`confirm.vue match snapshot 1`] = ` 4 | 5 | 14 | 15 | `; 16 | -------------------------------------------------------------------------------- /src/types/singer.ts: -------------------------------------------------------------------------------- 1 | import Singer from '@/assets/js/singer' 2 | 3 | export interface MusicSingerConfig { 4 | Findex: string; 5 | Fsinger_id: string; 6 | Fsinger_mid: string; 7 | Fsinger_name: string; 8 | } 9 | 10 | export interface ListViewConfig { 11 | title: string; 12 | items: Singer[]; 13 | } 14 | 15 | export interface MapListConfig { 16 | [propName: string]: ListViewConfig 17 | } 18 | 19 | export interface TouchConfig { 20 | y1: number; 21 | y2: number; 22 | anchorIndex?: string | null; 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import './transition.scss'; 3 | @import './icon.scss'; 4 | body, html { 5 | line-height: 1; 6 | font-family: 'PingFang SC', 'STHeitiSC-Light', 'Helvetica-Light', arial, sans-serif, 'Droid Sans Fallback'; 7 | user-select: none; 8 | -webkit-tap-highlight-color: transparent; 9 | background: $color-background; 10 | color: $color-text; 11 | } 12 | h1, h2, h3, h4, h5, h6, p, ul { 13 | margin: 0; 14 | padding: 0; 15 | } 16 | ul { 17 | list-style: none; 18 | } -------------------------------------------------------------------------------- /tests/unit/components/header.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import MHeader from '@/components/header/index.vue' 3 | 4 | describe('header.vue', () => { 5 | let wrapper: Wrapper 6 | beforeEach(() => { 7 | wrapper = shallowMount(MHeader, { 8 | stubs: ['router-link'] 9 | }) 10 | }) 11 | it('match shapshot', () => { 12 | expect(wrapper).toMatchSnapshot() 13 | }) 14 | it('mounted success', () => { 15 | expect(wrapper.exists()).toBe(true) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /tests/unit/store/top.spec.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store/index' 2 | import * as types from '@/store/mutation-type' 3 | 4 | describe('vuex module top', () => { 5 | it('SET_TOP_LIST mutation', () => { 6 | const songList = [ 7 | { songname: '夜曲', singername: '周杰伦' } 8 | ] 9 | const topList = [ 10 | { id: '001', picUrl: '1.jpg', topTitle: '标题一', songList: songList } 11 | ] 12 | store.commit(`top/${types.SET_TOP_LIST}`, topList) 13 | expect(store.state.top.topList).toEqual(topList) 14 | }) 15 | }) -------------------------------------------------------------------------------- /src/assets/js/jsonp.ts: -------------------------------------------------------------------------------- 1 | import originJsonp from 'jsonp' 2 | import { Jsonpoptions } from '@/types/index' 3 | import { combineParams } from '@/utils/utils' 4 | 5 | export default function jsonp (url: string, data: any, options: Jsonpoptions): Promise { 6 | url += (url.indexOf('?') < 0 ? '?' : '&') + combineParams(data) 7 | return new Promise((resolve, reject) => { 8 | originJsonp(url, options, (err, data) => { 9 | if (!err) { 10 | resolve(data) 11 | } else { 12 | reject(err) 13 | } 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /tests/unit/store/disc.spec.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store/index' 2 | import * as types from '@/store/mutation-type' 3 | 4 | describe('vuex modules disc', () => { 5 | it('SET_DISC mutation', () => { 6 | const disc = { 7 | dissid: '001', 8 | dissname: '夜曲', 9 | imgurl: 'https://www.baidu.com/imgurl/1.jpg', 10 | creator: { 11 | name: '周杰伦', 12 | avatarUrl: 'https://www.baidu.com/avatar/1.jpg' 13 | } 14 | } 15 | store.commit(`disc/${types.SET_DISC}`, disc) 16 | expect(store.state.disc.disc).toEqual(disc) 17 | }) 18 | }) -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-px-to-viewport': { 5 | unitToConvert: 'px', 6 | viewportWidth: 375, 7 | unitPrecision: 8, 8 | propList: ['*'], 9 | viewportUnit: 'vw', 10 | fontViewportUnit: 'vw', 11 | selectorBlackList: [], 12 | minPixelValue: 1, 13 | mediaQuery: false, 14 | replace: true, 15 | exclude: /(\/|\\)(node_modules)(\/|\\)/, 16 | landscape: false, 17 | landscapeUnit: 'vw', 18 | landscapeWidth: 667 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/unit/components/loading.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import Loading from '@/base/loading/index.vue' 3 | 4 | describe('loading.vue', () => { 5 | let wrapper: Wrapper 6 | const title = '正在加载中...' 7 | beforeEach(() => { 8 | wrapper = shallowMount(Loading, { 9 | propsData: { 10 | title: title 11 | } 12 | }) 13 | }) 14 | 15 | it('test snapshot', () => { 16 | expect(wrapper).toMatchSnapshot() 17 | }) 18 | it('test props.title', () => { 19 | expect(wrapper.text()).toContain(title) 20 | }) 21 | }) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | moduleNameMapper: { 4 | '^@/(.*)$': '/src/$1' 5 | }, 6 | snapshotSerializers: ['jest-serializer-vue'], 7 | testMatch: [ 8 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 9 | ], 10 | collectCoverageFrom: [ 11 | 'src/base/**/*.vue', 12 | 'src/utils/**/*.ts', 13 | 'src/store/modules/*.ts', 14 | '!src/utils/axios.ts' 15 | ], 16 | collectCoverage: true, 17 | coverageDirectory: '/tests/unit/coverage' 18 | } 19 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/suggestion.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`suggestion.vue match snaphots 1`] = ` 4 |
5 |
    6 | 11 |
12 |
13 |

抱歉,暂无搜索结果!

14 |
15 |
16 | `; 17 | -------------------------------------------------------------------------------- /src/store/mutation-type.ts: -------------------------------------------------------------------------------- 1 | export const SET_DISC = 'SET_DISC' 2 | export const SET_SINGER = 'SET_SINGER' 3 | export const SET_TOP_LIST = 'SET_TOP_LIST' 4 | export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY' 5 | export const SET_PLAY_LIST = 'SET_PLAY_LIST' 6 | export const SET_SEQUENCE_LIST = 'SET_SEQUENCE_LIST' 7 | export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX' 8 | export const SET_FULL_SCREEN = 'SET_FULL_SCREEN' 9 | export const SET_PLAY_MODE = 'SET_PLAY_MODE' 10 | export const SET_FAVORITE_LIST = 'SET_FAVORITE_LIST' 11 | export const SET_PLAY_STATE = 'SET_PLAY_STATE' 12 | export const SET_PLAY_HISTORY = 'SET_PLAY_HISTORY' 13 | -------------------------------------------------------------------------------- /tests/unit/components/empty.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import Empty from '@/base/empty/index.vue' 3 | 4 | describe('empty.vue', () => { 5 | let wrapper: Wrapper 6 | beforeEach(() => { 7 | wrapper = shallowMount(Empty) 8 | }) 9 | it('match snapshot', () => { 10 | expect(wrapper).toMatchSnapshot() 11 | }) 12 | it('default props.title', () => { 13 | expect(wrapper.props('title')).toBe('抱歉,暂无数据') 14 | }) 15 | it('pass props.title', () => { 16 | const title = '暂时没有相关数据' 17 | wrapper = shallowMount(Empty, { 18 | propsData: { 19 | title 20 | } 21 | }) 22 | expect(wrapper.find('.title').text()).toBe(title) 23 | }) 24 | }) -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import Singer from '@/assets/js/singer' 2 | import Song from '@/assets/js/song' 3 | import { Disc } from '@/types/recommend' 4 | import { RankList } from '@/types/rank' 5 | 6 | export interface DiscState { 7 | disc: Disc; 8 | } 9 | 10 | export interface SingerState { 11 | singer: Singer; 12 | } 13 | 14 | export interface TopState { 15 | topList: RankList; 16 | } 17 | 18 | export interface HistoryState { 19 | searchHistory: string[]; 20 | playHistory: Song[]; 21 | } 22 | 23 | export interface PlayerState { 24 | currentIndex: number; 25 | fullScreen: boolean; 26 | mode: number; 27 | playing: boolean; 28 | playList: Song[]; 29 | sequenceList: Song[]; 30 | favoriteList: Song[]; 31 | } 32 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/unit/components/__snapshots__/tab.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`tab.vue match snapshot 1`] = ` 4 |
5 |
6 | 7 | 推荐 8 | 9 | 10 | 歌手 11 | 12 | 13 | 排行 14 | 15 | 16 | 搜索 17 | 18 |
19 |
20 | `; 21 | -------------------------------------------------------------------------------- /src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' 2 | import { BASE_URL } from '@/api/config' 3 | 4 | const service = axios.create({ 5 | timeout: 10000, 6 | baseURL: BASE_URL 7 | }) 8 | 9 | // 请求拦截 10 | service.interceptors.request.use( 11 | (config: AxiosRequestConfig): AxiosRequestConfig => { 12 | return config 13 | } 14 | ) 15 | 16 | // 响应拦截 17 | service.interceptors.response.use( 18 | (response: AxiosResponse): Promise => { 19 | const { status, data } = response 20 | if (status === 200) { 21 | return data 22 | } else { 23 | return Promise.reject(new Error('服务器异常!')) 24 | } 25 | }, 26 | (error: AxiosError): Promise => { 27 | return Promise.reject(error.message) 28 | } 29 | ) 30 | 31 | export default service 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/base/empty/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 15 | 35 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Tab { 2 | name: string; 3 | path: string; 4 | } 5 | 6 | export interface MusicParams { 7 | g_tk: number; 8 | inCharset: string; 9 | outCharset: string; 10 | notice: number; 11 | format: string; 12 | [propName: string]: any; 13 | } 14 | 15 | export interface MusicResponse { 16 | code: number; 17 | data: any; 18 | message?: string; 19 | } 20 | 21 | export type Direction = 'horizontal' | 'vertical' 22 | 23 | export enum DirectionEnum { 24 | horizontal = 'horizontal', 25 | vertical = 'vertical' 26 | } 27 | 28 | export interface Jsonpoptions { 29 | param: string; 30 | prefix: string; 31 | } 32 | 33 | export interface Position { 34 | x: number; 35 | y: number; 36 | } 37 | 38 | export interface Confirm { 39 | message: string; 40 | cancelButtonText?: string; 41 | confirmButtonText?: string; 42 | showCancelButton?: boolean; 43 | showConfirmButton?: boolean; 44 | [propName: string]: any; 45 | } 46 | -------------------------------------------------------------------------------- /src/assets/js/throttle-debounce.ts: -------------------------------------------------------------------------------- 1 | export function throttle (fn: () => any, interval = 500): () => void { 2 | let timer: number | undefined 3 | let firstTime = true 4 | const _fn = fn 5 | return function (...agrs) { 6 | // @ts-ignore 7 | const self = this 8 | if (firstTime) { 9 | _fn.apply(self, agrs) 10 | firstTime = false 11 | return 12 | } 13 | if (timer) { 14 | return 15 | } 16 | timer = setTimeout(() => { 17 | clearTimeout(timer) 18 | timer = undefined 19 | _fn.apply(self, agrs) 20 | }, interval) 21 | } 22 | } 23 | 24 | export function debounce (fn: () => any, delay = 300): () => void { 25 | let timer: number | undefined 26 | return function (...args) { 27 | // @ts-ignore 28 | const self = this 29 | if (timer) { 30 | return 31 | } 32 | timer = setTimeout(() => { 33 | clearTimeout(timer) 34 | timer = undefined 35 | fn.apply(self, args) 36 | }, delay) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/api/rank.ts: -------------------------------------------------------------------------------- 1 | import jsonp from '@/assets/js/jsonp' 2 | import { commonParams, jsonpOptions } from './config' 3 | import { MusicResponse } from '@/types/index' 4 | 5 | export function getRankList (): Promise { 6 | const url = 'https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg' 7 | const params = Object.assign({}, commonParams, { 8 | uin: 0, 9 | needNewCode: 1, 10 | platform: 'h5' 11 | }) 12 | return jsonp(url, params, jsonpOptions) 13 | } 14 | 15 | export function getTopList (topId: string | number): Promise { 16 | const url = 'https://c.y.qq.com/v8/fcg-bin/fcg_v8_toplist_cp.fcg' 17 | const params = Object.assign({}, commonParams, { 18 | topid: topId, 19 | needNewCode: 1, 20 | uin: 0, 21 | tpl: 3, 22 | page: 'detail', 23 | type: 'top', 24 | platform: 'h5' 25 | }) 26 | 27 | return jsonp(url, params, jsonpOptions).then(res => { 28 | return { 29 | code: 0, 30 | data: res.songlist 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/js/search.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vue } from 'vue-property-decorator' 2 | import { Action, Getter } from 'vuex-class' 3 | @Component 4 | export default class Search extends Vue { 5 | protected keyword = '' 6 | // vuex 7 | @Getter('searchHistory') searchHistory!: string[] 8 | @Action('history/saveSearchHistory') saveSearchHistory!: (keyword: string) => void 9 | @Action('history/deleteSearchHistory') deleteSearchHistory!: (keyword: string) => void 10 | @Action('history/clearSearchHistory') clearSearchHistory!: () => void 11 | 12 | // methods方法 13 | public handleSearch (keyword: string) { 14 | this.keyword = keyword.trim() 15 | } 16 | public handleAddHistory (keyword: string) { 17 | this.keyword = keyword.trim() 18 | this.saveSearchHistory(this.keyword) 19 | } 20 | public handledeleteSearchHistory (keyword: string) { 21 | this.deleteSearchHistory(keyword.trim()) 22 | } 23 | public handleclearSearchHistory () { 24 | this.clearSearchHistory() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/search.ts: -------------------------------------------------------------------------------- 1 | export interface HotKey { 2 | k: string; 3 | n: number; 4 | } 5 | 6 | export interface SongPay { 7 | payalbumprice: number; 8 | [propName: string]: any; 9 | } 10 | 11 | export interface Album { 12 | type: string | number; 13 | albumid: number; 14 | albummid: string; 15 | albumname: string; 16 | singername: string; 17 | singerid: number; 18 | singermid: string; 19 | [propName: string]: any; 20 | } 21 | 22 | export interface SongData { 23 | songid: string; 24 | songmid: string; 25 | singer: string; 26 | songname: string; 27 | albummid: string; 28 | albumname: string; 29 | interval: number; 30 | pay: SongPay | any; 31 | url: string; 32 | [propName: string]: any; 33 | } 34 | 35 | export interface SongPage { 36 | curnum: number; 37 | curpage: number; 38 | totalnum: number; 39 | list: SongData[]; 40 | [propName: string]: any; 41 | } 42 | 43 | 44 | export interface SearchResult { 45 | song: SongPage; 46 | zhida: Album; 47 | [propName: string]: any; 48 | } -------------------------------------------------------------------------------- /src/base/loading/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 16 | 39 | -------------------------------------------------------------------------------- /src/api/singer.ts: -------------------------------------------------------------------------------- 1 | import jsonp from '@/assets/js/jsonp' 2 | import { commonParams, jsonpOptions } from './config' 3 | import { MusicResponse } from '@/types/index' 4 | 5 | export function getSingerList (): Promise { 6 | const url = 'https://c.y.qq.com/v8/fcg-bin/v8.fcg' 7 | const data = Object.assign({}, commonParams, { 8 | channel: 'singer', 9 | page: 'list', 10 | key: 'all_all_all', 11 | pagesize: 100, 12 | pagenum: 1, 13 | hostUin: 0, 14 | needNewCode: 0, 15 | platform: 'yqq' 16 | }) 17 | return jsonp(url, data, jsonpOptions) 18 | } 19 | 20 | export function getSingerDetail (singerId: string): Promise { 21 | const url = 'https://c.y.qq.com/v8/fcg-bin/fcg_v8_singer_track_cp.fcg' 22 | const data = Object.assign({}, commonParams, { 23 | hostUin: 0, 24 | needNewCode: 0, 25 | platform: 'yqq', 26 | order: 'listen', 27 | begin: 0, 28 | num: 80, 29 | songstatus: 1, 30 | singermid: singerId 31 | }) 32 | return jsonp(url, data, jsonpOptions) 33 | } 34 | -------------------------------------------------------------------------------- /src/base/confirm/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import ConfirmConstructor from './index.vue' 3 | import { Confirm } from '@/types/index' 4 | let instance: any 5 | const defaultOptions = { 6 | message: '', 7 | cancelButtonText: '取消', 8 | confirmButtonText: '确定', 9 | showCancelButton: true, 10 | showConfirmButton: true 11 | } 12 | const Confirm = (options: Confirm) => { 13 | return new Promise((resolve, reject) => { 14 | if (typeof options === 'string') { 15 | options = { 16 | message: options as string 17 | } 18 | } 19 | instance = new ConfirmConstructor({ 20 | el: document.createElement('div') 21 | }) 22 | options = Object.assign({}, defaultOptions, options, { 23 | isProto: true, 24 | resolve, 25 | reject 26 | }) 27 | for (const key in options) { 28 | instance[key] = options[key] 29 | } 30 | instance.$mount() 31 | document.body.appendChild(instance.$el) 32 | Vue.nextTick(() => { 33 | instance.visible = true 34 | }) 35 | }) 36 | } 37 | 38 | export default Confirm 39 | -------------------------------------------------------------------------------- /src/api/search.ts: -------------------------------------------------------------------------------- 1 | import jsonp from '@/assets/js/jsonp' 2 | import { commonParams, jsonpOptions } from './config' 3 | import { MusicResponse } from '@/types/index' 4 | import axios from '@/utils/axios' 5 | 6 | export function getHotKeys (): Promise { 7 | const url = 'https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg' 8 | const params = Object.assign({}, commonParams, { 9 | uin: 0, 10 | needNewCode: 1, 11 | platform: 'h5' 12 | }) 13 | return jsonp(url, params, jsonpOptions) 14 | } 15 | 16 | export function search (keyword: string, page: number, zhida: boolean, perpage: number): Promise { 17 | const url = '/api/search' 18 | const params = { 19 | w: keyword, 20 | p: page, 21 | perpage, 22 | n: perpage, 23 | catZhida: zhida ? 1 : 0, 24 | zhidaqu: 1, 25 | t: 0, 26 | flag: 1, 27 | ie: 'utf-8', 28 | sem: 1, 29 | aggr: 0, 30 | remoteplace: 'txt.mqq.all', 31 | uin: 0, 32 | needNewCode: 1, 33 | platform: 'h5', 34 | format: 'json' 35 | } 36 | return axios.get(url, { params }) 37 | } 38 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | 'lines-between-class-members': 'off', 18 | 'prefer-spread': 'off', 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | '@typescript-eslint/camelcase': 'off', 21 | '@typescript-eslint/no-var-requires': 'off', 22 | '@typescript-eslint/no-non-null-assertion': 'off', 23 | '@typescript-eslint/ban-ts-ignore': 'off', 24 | '@typescript-eslint/no-this-alias': 'off', 25 | '@typescript-eslint/no-use-before-define': 'off', 26 | '@typescript-eslint/interface-name-prefix': 'off', 27 | '@typescript-eslint/member-delimiter-style': 'off' 28 | }, 29 | overrides: [ 30 | { 31 | files: [ 32 | '**/__tests__/*.{j,t}s?(x)', 33 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 34 | ], 35 | env: { 36 | jest: true 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tests/unit/components/tab.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import { tabList } from '@/assets/js/data' 3 | import Tab from '@/components/tab/index.vue' 4 | 5 | describe('tab.vue', () => { 6 | let wrapper: Wrapper 7 | beforeEach(() => { 8 | wrapper = shallowMount(Tab, { 9 | propsData: { 10 | list: tabList 11 | }, 12 | stubs: ['router-link'] 13 | }) 14 | }) 15 | it('match snapshot', () => { 16 | expect(wrapper).toMatchSnapshot() 17 | }) 18 | it('passed props.list', () => { 19 | expect(wrapper.props('list').length).toBe(tabList.length) 20 | }) 21 | it('default activeIndex', () => { 22 | expect(wrapper.vm.$data.activeIndex).toBe(0) 23 | }) 24 | it('no passed props.list', () => { 25 | wrapper = shallowMount(Tab, { 26 | stubs: ['router-link'] 27 | }) 28 | expect(wrapper.props('list').length).toBe(0) 29 | }) 30 | it('render success props.list', () => { 31 | const tabItems = wrapper.findAll('.tab-item') 32 | expect(tabItems.length).toBe(tabList.length) 33 | }) 34 | it('change index after click', () => { 35 | wrapper.findAll('.tab-item').at(1).trigger('click') 36 | expect(wrapper.vm.$data.activeIndex).toBe(1) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | interface Vendor { 2 | webkit: string; 3 | Moz: string; 4 | O: string; 5 | ms: string; 6 | standard: string; 7 | [propName: string]: any; 8 | } 9 | const domStyle = document.createElement('div').style 10 | 11 | export const vendor = (function () { 12 | const vendorsName: Vendor = { 13 | webkit: 'webkitTransform', 14 | Moz: 'MozTransform', 15 | O: 'OTransform', 16 | ms: 'msTransform', 17 | standard: 'transform' 18 | } 19 | for (const key in vendorsName) { 20 | const val = vendorsName[key] 21 | if (domStyle[val] !== undefined) { 22 | return key 23 | } 24 | } 25 | return 'standard' 26 | })() 27 | 28 | export function hasClass (el: HTMLElement, className: string): boolean { 29 | return el.classList.contains(className) 30 | } 31 | 32 | export function addClass (el: HTMLElement, className: string) { 33 | el.classList.add(className) 34 | } 35 | 36 | export function getDomData (el: HTMLElement, name: string): string | null { 37 | const prefix = 'data-' 38 | return el.getAttribute(prefix + name) 39 | } 40 | 41 | export function getVendorsPrefix (style: string, venderName = vendor): string { 42 | if (venderName === 'standard') { 43 | return style 44 | } 45 | return `${venderName}${style.charAt(0).toUpperCase()}${style.substring(1)}` 46 | } 47 | -------------------------------------------------------------------------------- /src/store/getters.ts: -------------------------------------------------------------------------------- 1 | import { DiscState, SingerState, TopState, HistoryState, PlayerState } from './types' 2 | 3 | export const disc = (state: any) => (state.disc as DiscState).disc 4 | 5 | export const singer = (state: any) => (state.singer as SingerState).singer 6 | 7 | export const topList = (state: any) => (state.top as TopState).topList 8 | 9 | export const searchHistory = (state: any) => (state.history as HistoryState).searchHistory 10 | 11 | export const playHistory = (state: any) => (state.history as HistoryState).playHistory 12 | 13 | export const playList = (state: any) => (state.player as PlayerState).playList 14 | 15 | export const sequenceList = (state: any) => (state.player as PlayerState).sequenceList 16 | 17 | export const favoriteList = (state: any) => (state.player as PlayerState).favoriteList 18 | 19 | export const currentIndex = (state: any) => (state.player as PlayerState).currentIndex 20 | 21 | export const currentSong = (state: any) => { 22 | const player = state.player as PlayerState 23 | return player.playList[player.currentIndex] || {} 24 | } 25 | 26 | export const fullScreen = (state: any) => (state.player as PlayerState).fullScreen 27 | 28 | export const mode = (state: any) => (state.player as PlayerState).mode 29 | 30 | export const playing = (state: any) => (state.player as PlayerState).playing 31 | -------------------------------------------------------------------------------- /src/components/header/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 16 | 17 | 51 | -------------------------------------------------------------------------------- /src/base/switches/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 26 | 50 | -------------------------------------------------------------------------------- /src/store/modules/history.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-type' 2 | import Song from '@/assets/js/song' 3 | import { Commit } from 'vuex' 4 | import { HistoryState } from '../types' 5 | import { getSearchHistory, saveSearchHistory, deleteSearchHistory, clearSearchHistory, savePlayHistory, getPlayHistory } from '@/utils/cache' 6 | const state = { 7 | searchHistory: getSearchHistory(), 8 | playHistory: getPlayHistory() 9 | } 10 | 11 | const mutations = { 12 | [types.SET_SEARCH_HISTORY] (state: HistoryState, searchHistory: string[]) { 13 | state.searchHistory = searchHistory 14 | }, 15 | [types.SET_PLAY_HISTORY] (state: HistoryState, history: Song[]) { 16 | state.playHistory = history 17 | } 18 | } 19 | 20 | const actions = { 21 | saveSearchHistory (context: { commit: Commit }, keyword: string) { 22 | context.commit(types.SET_SEARCH_HISTORY, saveSearchHistory(keyword)) 23 | }, 24 | deleteSearchHistory (context: { commit: Commit }, keyword: string) { 25 | context.commit(types.SET_SEARCH_HISTORY, deleteSearchHistory(keyword)) 26 | }, 27 | clearSearchHistory (context: { commit: Commit }) { 28 | context.commit(types.SET_SEARCH_HISTORY, clearSearchHistory()) 29 | }, 30 | savePlayHistory (context: { commit: Commit }, song: Song) { 31 | context.commit(types.SET_PLAY_HISTORY, savePlayHistory(song)) 32 | } 33 | } 34 | 35 | export default { 36 | namespaced: true, 37 | state, 38 | mutations, 39 | actions 40 | } 41 | -------------------------------------------------------------------------------- /tests/unit/components/switches.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import Switches from '@/base/switches/index.vue' 3 | 4 | describe('switches.vue', () => { 5 | let wrapper: Wrapper 6 | beforeEach(() => { 7 | wrapper = shallowMount(Switches, { 8 | propsData: { 9 | active: 1, 10 | switches: ['我喜欢的', '最近听的'] 11 | } 12 | }) 13 | }) 14 | it('match snapshot', () => { 15 | expect(wrapper).toMatchSnapshot() 16 | }) 17 | it('render switches array success', () => { 18 | const switchItems = wrapper.findAll('.switch-item') 19 | expect(switchItems.length).toBe(2) 20 | expect(switchItems.at(1).text()).toBe('最近听的') 21 | }) 22 | it('bind active class', () => { 23 | const switchItems = wrapper.findAll('.switch-item') 24 | expect(switchItems.at(1).classes('active')).toBe(true) 25 | }) 26 | it('no pass props.switches', () => { 27 | wrapper = shallowMount(Switches) 28 | expect(wrapper.props('switches').length).toBe(0) 29 | }) 30 | it('change active after item click', async () => { 31 | wrapper = shallowMount(Switches, { 32 | propsData: { 33 | switches: ['我喜欢的', '最近听的'] 34 | } 35 | }) 36 | const switchItems = wrapper.findAll('.switch-item') 37 | switchItems.at(1).trigger('click') 38 | wrapper.setProps({ 39 | active: 1 40 | }) 41 | await wrapper.vm.$nextTick() 42 | expect(wrapper.props('active')).toBe(1) 43 | }) 44 | }) -------------------------------------------------------------------------------- /tests/unit/utils/dom.spec.js: -------------------------------------------------------------------------------- 1 | import { hasClass, addClass, getDomData, getVendorsPrefix, vendor } from '@/utils/dom' 2 | 3 | describe('dom.ts', () => { 4 | let dom 5 | beforeEach(() => { 6 | dom = document.createElement('div') 7 | dom.classList.add('test-class') 8 | }) 9 | it('test hasClass', () => { 10 | expect(hasClass(dom, 'test-class')).toBe(true) 11 | expect(hasClass(dom, 'test-no-class')).toBe(false) 12 | }) 13 | it('test addClass', () => { 14 | const className = 'add-class' 15 | addClass(dom, className) 16 | expect(hasClass(dom, className)).toBe(true) 17 | }) 18 | it('test dom data', () => { 19 | const dom = document.createElement('div') 20 | const indexVal = '100' 21 | dom.setAttribute('data-index', indexVal) 22 | expect(getDomData(dom, 'index')).toBe(indexVal) 23 | expect(getDomData(dom, 'index123')).toBeNull() 24 | }) 25 | it('test vendor name', () => { 26 | const vendors = ['webkit', 'Moz', 'O', 'ms', 'standard'] 27 | expect(vendors.includes(vendor)).toBe(true) 28 | }) 29 | it('test vender prefix', () => { 30 | const transform = getVendorsPrefix('transform') 31 | const transformMap = ['webkitTransform', 'MozTransform', 'OTransform', 'msTransform', 'transform'] 32 | expect(transformMap.includes(transform)).toBe(true) 33 | }) 34 | it('test standard prefix', () => { 35 | const transform = getVendorsPrefix('transform', 'standard') 36 | expect(transform).toBe('transform') 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /tests/unit/components/scroll.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper, config } from '@vue/test-utils' 2 | import Scroll from '@/base/scroll/index.vue' 3 | config.showDeprecationWarnings = false 4 | 5 | describe('scroll.vue', () => { 6 | let wrapper: Wrapper 7 | beforeEach(() => { 8 | wrapper = shallowMount(Scroll,{ 9 | propsData: { 10 | beforeScroll: true, 11 | listenScroll: true, 12 | pullUp: true, 13 | data: [1, 2, 3] 14 | }, 15 | slots: { 16 | default: `
` 17 | } 18 | }) 19 | }) 20 | it('match snapshot', () => { 21 | expect(wrapper).toMatchSnapshot() 22 | }) 23 | it('render slot', () => { 24 | expect(wrapper.find('.scroll-slot').exists()).toBe(true) 25 | }) 26 | it('no pass props.data', () => { 27 | wrapper = shallowMount(Scroll, { 28 | slots: { 29 | default: `
` 30 | } 31 | }) 32 | expect(wrapper.props('data')).toEqual([]) 33 | }) 34 | it('change data and emit onDataChange function', async () => { 35 | const methods = { 36 | refresh: jest.fn() 37 | } 38 | wrapper = shallowMount(Scroll,{ 39 | propsData: { 40 | data: [1, 2, 3] 41 | }, 42 | slots: { 43 | default: `
` 44 | }, 45 | methods: methods 46 | }) 47 | wrapper.setProps({ 48 | data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 49 | }) 50 | await wrapper.vm.$nextTick() 51 | expect(methods.refresh).toHaveBeenCalled() 52 | }) 53 | }) -------------------------------------------------------------------------------- /src/views/recommend-detail/detail.vue: -------------------------------------------------------------------------------- 1 | 6 | 53 | -------------------------------------------------------------------------------- /src/components/tab/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 32 | 33 | 62 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter, { RouteConfig } from 'vue-router' 3 | const Recommend = () => import('@/views/recommend/index.vue') 4 | const RecommendDetail = () => import('@/views/recommend-detail/detail.vue') 5 | const Singer = () => import('@/views/singer/index.vue') 6 | const SingerDetail = () => import('@/views/singer-detail/detail.vue') 7 | const Rank = () => import('@/views/rank/index.vue') 8 | const RankDetail = () => import('@/views/rank-detail/detail.vue') 9 | const Search = () => import('@/views/search/index.vue') 10 | const User = () => import('@/views/user/index.vue') 11 | 12 | Vue.use(VueRouter) 13 | 14 | const routes: Array = [ 15 | { 16 | path: '/', 17 | redirect: '/recommend' 18 | }, 19 | { 20 | path: '/recommend', 21 | name: 'Recommend', 22 | component: Recommend, 23 | children: [ 24 | { path: ':id', component: RecommendDetail } 25 | ] 26 | }, 27 | { 28 | path: '/singer', 29 | name: 'Singer', 30 | component: Singer, 31 | children: [ 32 | { path: ':id', component: SingerDetail } 33 | ] 34 | }, 35 | { 36 | path: '/rank', 37 | name: 'Rank', 38 | component: Rank, 39 | children: [ 40 | { path: ':id', component: RankDetail } 41 | ] 42 | }, 43 | { 44 | path: '/search', 45 | name: 'Search', 46 | component: Search, 47 | children: [ 48 | { path: ':id', component: SingerDetail } 49 | ] 50 | }, 51 | { 52 | path: '/user', 53 | name: 'User', 54 | component: User 55 | } 56 | ] 57 | 58 | const router = new VueRouter({ 59 | routes 60 | }) 61 | 62 | export default router 63 | -------------------------------------------------------------------------------- /tests/unit/store/history.spec.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store/index' 2 | describe('vuex modules history', () => { 3 | beforeEach(() => { 4 | store.dispatch(`history/clearSearchHistory`) 5 | }) 6 | it('saveSearchHistory action', () => { 7 | store.dispatch(`history/saveSearchHistory`, '周杰伦') 8 | store.dispatch(`history/saveSearchHistory`, '林俊杰') 9 | expect(store.state.history.searchHistory).toEqual(['林俊杰', '周杰伦']) 10 | }) 11 | it('deleteSearchHistory action', () => { 12 | store.dispatch(`history/saveSearchHistory`, '周杰伦') 13 | store.dispatch(`history/saveSearchHistory`, '林俊杰') 14 | store.dispatch(`history/saveSearchHistory`, '张宇') 15 | expect(store.state.history.searchHistory).toEqual(['张宇', '林俊杰', '周杰伦']) 16 | 17 | store.dispatch(`history/deleteSearchHistory`, '林俊杰') 18 | expect(store.state.history.searchHistory).toEqual(['张宇', '周杰伦']) 19 | 20 | store.dispatch(`history/deleteSearchHistory`, '周杰伦') 21 | expect(store.state.history.searchHistory).toEqual(['张宇']) 22 | 23 | store.dispatch(`history/deleteSearchHistory`, '张宇') 24 | expect(store.state.history.searchHistory.length).toBe(0) 25 | }) 26 | 27 | it('savePlayHistory action', () => { 28 | const songs = [ 29 | { id: '001', mid: '001', singer: '周杰伦' }, 30 | { id: '002', mid: '002', singer: '林俊杰' } 31 | ] 32 | expect(store.state.history.playHistory.length).toBe(0) 33 | store.dispatch('history/savePlayHistory', songs[0]) 34 | store.dispatch('history/savePlayHistory', songs[1]) 35 | expect(store.state.history.playHistory.length).toBe(2) 36 | expect(store.state.history.playHistory).toEqual([songs[1], songs[0]]) 37 | }) 38 | }) -------------------------------------------------------------------------------- /src/views/singer-detail/detail.vue: -------------------------------------------------------------------------------- 1 | 6 | 54 | -------------------------------------------------------------------------------- /tests/unit/components/confirm.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper } from '@vue/test-utils' 2 | import Confirm from '@/base/confirm/index.vue' 3 | 4 | describe('confirm.vue', () => { 5 | let wrapper: Wrapper 6 | beforeEach(() => { 7 | wrapper = shallowMount(Confirm) 8 | }) 9 | 10 | it('match snapshot', () => { 11 | expect(wrapper).toMatchSnapshot() 12 | }) 13 | it('default props', () => { 14 | expect(wrapper.props('visible')).toBe(false) 15 | expect(wrapper.props('message')).toBe('') 16 | expect(wrapper.props('cancelButtonText')).toBe('取消') 17 | expect(wrapper.props('confirmButtonText')).toBe('确定') 18 | expect(wrapper.props('showCancelButton')).toBe(true) 19 | expect(wrapper.props('showConfirmButton')).toBe(true) 20 | }) 21 | it('customer props', () => { 22 | const message = '是否清空播放列表?' 23 | wrapper = shallowMount(Confirm, { 24 | propsData: { 25 | message: message, 26 | confirmButtonText: '清空', 27 | showCancelButton: false 28 | } 29 | }) 30 | expect(wrapper.find('.text').text()).toBe(message) 31 | expect(wrapper.find('.confirm').text()).toBe('清空') 32 | expect(wrapper.find('.cancel').exists()).toBe(false) 33 | }) 34 | it('cancel click', async () => { 35 | const cancelBtn = wrapper.find('.cancel') 36 | cancelBtn.trigger('click') 37 | await wrapper.vm.$nextTick() 38 | expect(wrapper.props('visible')).toBe(false) 39 | }) 40 | it('confirm click', async () => { 41 | const confirmButton = wrapper.find('.confirm') 42 | confirmButton.trigger('click') 43 | await wrapper.vm.$nextTick() 44 | expect(wrapper.props('visible')).toBe(false) 45 | wrapper.vm.$emit('confirm') 46 | expect(wrapper.emitted().confirm).toBeTruthy() 47 | }) 48 | }) -------------------------------------------------------------------------------- /src/components/progress-circle/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 46 | 64 | -------------------------------------------------------------------------------- /src/views/rank-detail/detail.vue: -------------------------------------------------------------------------------- 1 | 6 | 61 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function getUid (len = 10): string { 2 | if (len === 0 || !len) { 3 | return '' 4 | } 5 | const t = new Date().getMilliseconds() 6 | let ret = `${Math.round(2147483647 * Math.random()) * t % Math.pow(10, len)}` 7 | ret = fillString(ret, 0, len) 8 | return ret 9 | } 10 | 11 | export function pxToVw (px: number, viewportWidth = 375, unitPrecision = 8): number { 12 | if (px === 0) { 13 | return 0 14 | } 15 | return parseFloat((100 / viewportWidth * px).toFixed(unitPrecision)) 16 | } 17 | 18 | export function getRandomNumber (min: number, max: number): number { 19 | max = Math.max(min, max) 20 | min = Math.min(min, max) 21 | return Math.floor(Math.random() * (max - min + 1) + min) 22 | } 23 | 24 | export function shuffle (array: any[]): any[] { 25 | if (array.length === 0 || array.length === 1) { 26 | return array 27 | } 28 | const arr = array.slice() 29 | for (let index = 0; index < arr.length; index++) { 30 | const randomIndex = getRandomNumber(0, index) 31 | const temp = arr[index] 32 | arr[index] = arr[randomIndex] 33 | arr[randomIndex] = temp 34 | } 35 | return arr 36 | } 37 | 38 | export function formatSecond (second: number): string { 39 | const m = second / 60 | 0 40 | const s = second % 60 | 0 41 | return `${fillString(m)}:${fillString(s)}` 42 | } 43 | 44 | export function fillString (value: number | string, fill = 0, len = 2): string { 45 | let val = value.toString() 46 | while (val.length < len) { 47 | val = `${fill}${val}` 48 | } 49 | return val 50 | } 51 | 52 | export function combineParams (data: any): string { 53 | let url = '' 54 | for (const key in data) { 55 | if (data[key] !== undefined) { 56 | url += `&${key}=${encodeURIComponent(data[key])}` 57 | } 58 | } 59 | return url ? url.substring(1) : '' 60 | } 61 | -------------------------------------------------------------------------------- /src/assets/js/player.ts: -------------------------------------------------------------------------------- 1 | import Song from '@/assets/js/song' 2 | import { Component, Vue } from 'vue-property-decorator' 3 | import { Getter, Mutation, Action } from 'vuex-class' 4 | import { PlayMode } from '@/types/player' 5 | @Component 6 | export default class Player extends Vue { 7 | @Getter('mode') mode!: number 8 | @Getter('currentSong') currentSong!: Song 9 | @Getter('favoriteList') favoriteList!: Song[] 10 | @Getter('sequenceList') sequenceList!: Song[] 11 | @Getter('playList') playList!: Song[] 12 | @Mutation('player/SET_PLAY_MODE') setPlayMode!: (mode: number) => void 13 | @Mutation('player/SET_PLAY_STATE') setPlayState!: (playing: boolean) => void 14 | @Mutation('player/SET_CURRENT_INDEX') setCurrentIndex!: (index: number) => void 15 | @Action('player/saveFavoriteList') saveFavoriteList!: (song: Song) => void 16 | @Action('player/deleteFavoriteList') deleteFavoriteList!: (song: Song) => void 17 | 18 | // methods方法 19 | public handleModeChange () { 20 | const mode = (this.mode + 1) % 3 21 | this.setPlayMode(mode) 22 | } 23 | public handleToggleFavorite (song: Song) { 24 | if (this.isFavorite(song)) { 25 | this.deleteFavoriteList(song) 26 | } else { 27 | this.saveFavoriteList(song) 28 | } 29 | } 30 | private isFavorite (song: Song): boolean { 31 | return this.favoriteList.findIndex(item => { 32 | return item.id === song.id 33 | }) > -1 34 | } 35 | private getFavoriteIcon (song: Song): string { 36 | return this.isFavorite(song) ? 'icon-favorite' : 'icon-not-favorite' 37 | } 38 | 39 | // 计算属性 40 | private get modeIcon () { 41 | return this.mode === PlayMode.sequence ? 'icon-sequence' : this.mode === PlayMode.loop ? 'icon-loop' : 'icon-random' 42 | } 43 | private get faviroteIcon () { 44 | return this.getFavoriteIcon(this.currentSong) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/base/notify/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 35 | 63 | -------------------------------------------------------------------------------- /tests/unit/utils/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | getUid, 3 | pxToVw, 4 | getRandomNumber, 5 | fillString, 6 | formatSecond, 7 | combineParams, 8 | shuffle 9 | } from '@/utils/utils.ts' 10 | 11 | describe('utils.ts', () => { 12 | it('test getUid function', () => { 13 | expect(getUid(0)).toBe('') 14 | expect(getUid().length).toBe(10) 15 | expect(getUid(5).length).toBe(5) 16 | }) 17 | it('test pxToVw function', () => { 18 | expect(pxToVw(0)).toBe(0) 19 | expect(pxToVw(375)).toBe(100) 20 | expect(pxToVw(10)).toBeCloseTo(2.667) 21 | expect(pxToVw(100, 414, 0)).toBe(24) 22 | expect(pxToVw(50, 414, 3)).toBeCloseTo(12.077) 23 | }) 24 | it('test getRandomNumber function', () => { 25 | let random = getRandomNumber(1, 3) 26 | expect(random).toBeGreaterThanOrEqual(1) 27 | expect(random).toBeLessThanOrEqual(3) 28 | random = getRandomNumber(5, 2) 29 | expect(random).toBeGreaterThanOrEqual(2) 30 | expect(random).toBeLessThanOrEqual(5) 31 | }) 32 | it('test fillString function', () => { 33 | expect(fillString(0)).toBe('00') 34 | expect(fillString(0, '*')).toBe('*0') 35 | expect(fillString('12', 0, 5)).toBe('00012') 36 | }) 37 | it('test formatSecond function', () => { 38 | expect(formatSecond(0)).toBe('00:00') 39 | expect(formatSecond(23)).toBe('00:23') 40 | expect(formatSecond(61)).toBe('01:01') 41 | expect(formatSecond(3700)).toBe('61:40') 42 | }) 43 | it('test combineParams function', () => { 44 | const params = { a: 1, b: true, c: '2', d: undefined } 45 | expect(combineParams({})).toBe('') 46 | expect(combineParams(params)).toBe(`a=${params.a}&b=${params.b}&c=${params.c}`) 47 | }) 48 | it('test shuffle function', () => { 49 | const arr = [1, 2, 3, 4, 5] 50 | expect(shuffle([])).toEqual([]) 51 | expect(shuffle([1])).toEqual([1]) 52 | expect(shuffle(arr)).not.toEqual(arr) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /tests/unit/components/slider.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper, createLocalVue } from '@vue/test-utils' 2 | import Slider from '@/base/slider/index.vue' 3 | 4 | describe('slider.vue', () => { 5 | let wrapper: Wrapper 6 | beforeEach(() => { 7 | wrapper = shallowMount(Slider) 8 | }) 9 | afterAll(() => { 10 | wrapper.destroy() 11 | }) 12 | it('match snapshot', () => { 13 | expect(wrapper).toMatchSnapshot() 14 | }) 15 | it('default props', () => { 16 | expect(wrapper.props('loop')).toBe(true) 17 | expect(wrapper.props('autoPlay')).toBe(true) 18 | expect(wrapper.props('interval')).toBe(4000) 19 | expect(wrapper.props('showDots')).toBe(true) 20 | }) 21 | it('show dots', () => { 22 | expect(wrapper.find('.slider-dots').exists()).toBe(true) 23 | wrapper = shallowMount(Slider, { 24 | propsData: { 25 | showDots: false 26 | } 27 | }) 28 | expect(wrapper.find('.slider-dots').exists()).toBe(false) 29 | }) 30 | it('render slots', async () => { 31 | wrapper = shallowMount(Slider, { 32 | propsData: { 33 | loop: false 34 | }, 35 | slots: { 36 | default: ` 37 |
1
38 |
2
39 |
3
40 | ` 41 | } 42 | }) 43 | await wrapper.vm.$nextTick() 44 | expect(wrapper.findAll('.slider-group > div').length).toBe(3) 45 | wrapper.setProps({ 46 | loop: true 47 | }) 48 | }) 49 | it('auto play', () => { 50 | jest.useFakeTimers() 51 | wrapper = shallowMount(Slider, { 52 | propsData: { 53 | loop: false, 54 | autoPlay: true, 55 | interval: 1000 56 | }, 57 | slots: { 58 | default: ` 59 |
1
60 |
2
61 |
3
62 | ` 63 | } 64 | }) 65 | jest.advanceTimersByTime(200) 66 | expect(wrapper.props('autoPlay')).toBe(true) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /tests/unit/components/notify.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, Wrapper} from '@vue/test-utils' 2 | import Notify from '@/base/notify/index.vue' 3 | 4 | describe('notify.vue', () => { 5 | let wrapper: Wrapper 6 | const message = '已添加到播放列表' 7 | beforeEach(() => { 8 | wrapper = shallowMount(Notify, { 9 | propsData: { 10 | visible: true, 11 | message: message 12 | } 13 | }) 14 | }) 15 | 16 | it('match snapshot', () => { 17 | expect(wrapper).toMatchSnapshot() 18 | }) 19 | it('render when visible is true', async () => { 20 | expect(wrapper.find('.m-notify').exists()).toBe(true) 21 | wrapper.setProps({ 22 | visible: false 23 | }) 24 | await wrapper.vm.$nextTick() 25 | expect(wrapper.find('m-notify').exists()).toBe(false) 26 | }) 27 | it('render message when pass message', async () => { 28 | expect(wrapper.find('.notify-message').text()).toContain(message) 29 | wrapper.setProps({ 30 | message: '' 31 | }) 32 | await wrapper.vm.$nextTick() 33 | expect(wrapper.find('.notify-message').findAll('span').length).toBe(1) 34 | }) 35 | it('emit visible change', async () => { 36 | jest.useFakeTimers() 37 | wrapper = shallowMount(Notify, { 38 | propsData: { 39 | visible: false 40 | } 41 | }) 42 | expect(wrapper.props('visible')).toBe(false) 43 | wrapper.setProps({ 44 | visible: true 45 | }) 46 | await wrapper.vm.$nextTick() 47 | jest.advanceTimersByTime(3100) 48 | expect(wrapper.props('visible')).toBe(true) 49 | }) 50 | it('render icon success', async() => { 51 | const icon = 'icon-warning' 52 | wrapper.setProps({ 53 | icon: icon 54 | }) 55 | await wrapper.vm.$nextTick() 56 | expect(wrapper.find('.notify-icon').classes().includes(icon)).toBe(true) 57 | }) 58 | it('destory component and clearTimeout', () => { 59 | wrapper.vm.$destroy() 60 | expect(wrapper.exists()).toBe(false) 61 | }) 62 | }) -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 53 | 64 | -------------------------------------------------------------------------------- /src/components/search-box/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 34 | 70 | -------------------------------------------------------------------------------- /tests/unit/components/suggestion.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, Wrapper, config } from '@vue/test-utils' 2 | config.showDeprecationWarnings = false 3 | import Suggestion from '@/components/suggestion/index.vue' 4 | 5 | describe('suggestion.vue', () => { 6 | let wrapper: Wrapper 7 | beforeEach(() => { 8 | wrapper = mount(Suggestion) 9 | }) 10 | it('match snaphots', () => { 11 | expect(wrapper).toMatchSnapshot() 12 | }) 13 | it('render empty when resultList is empty and no more', async () => { 14 | expect(wrapper.find('.suggestion-empty').exists()).toBe(true) 15 | wrapper.setData({ 16 | resultList: [1, 2, 3] 17 | }) 18 | await wrapper.vm.$nextTick() 19 | expect(wrapper.find('.suggestion-empty').isVisible()).toBe(false) 20 | expect(wrapper.find('ul').isVisible()).toBe(true) 21 | wrapper.setData({ 22 | hasMore: false, 23 | resultList: [] 24 | }) 25 | await wrapper.vm.$nextTick() 26 | expect(wrapper.find('.suggestion-empty').isVisible()).toBe(true) 27 | expect(wrapper.find('ul').isVisible()).toBe(false) 28 | }) 29 | it('change keyword and emit onKeywordChange function', async () => { 30 | const methods = { 31 | getSuggestionList: jest.fn() 32 | } 33 | wrapper = mount(Suggestion, { 34 | methods: methods 35 | }) 36 | // 搜索不为空时,触发事件 37 | wrapper.setProps({ 38 | keyword: '张宇' 39 | }) 40 | await wrapper.vm.$nextTick() 41 | expect(wrapper.props('keyword')).toBe('张宇') 42 | expect(methods.getSuggestionList).toBeCalledTimes(1) 43 | // 搜索为空时,不触发事件 44 | wrapper.setProps({ 45 | keyword: '' 46 | }) 47 | await wrapper.vm.$nextTick() 48 | expect(wrapper.props('keyword')).toBe('') 49 | expect(methods.getSuggestionList).toBeCalledTimes(1) 50 | }) 51 | it('render resultList success', () => { 52 | const resultList = [1, 2, 3] 53 | wrapper = mount(Suggestion, { 54 | data () { 55 | return { 56 | resultList: resultList 57 | } 58 | } 59 | }) 60 | expect(wrapper.findAll('.suggestion-item').length).toBe(resultList.length) 61 | }) 62 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-music-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "axios": "^0.19.2", 13 | "better-scroll": "^1.15.2", 14 | "core-js": "^3.6.5", 15 | "good-storage": "^1.1.1", 16 | "js-base64": "^2.6.2", 17 | "jsonp": "^0.2.1", 18 | "lyric-parser": "^1.0.1", 19 | "normalize.css": "^8.0.1", 20 | "vue": "^2.6.11", 21 | "vue-class-component": "^7.2.3", 22 | "vue-lazyload": "^1.3.3", 23 | "vue-property-decorator": "^8.4.2", 24 | "vue-router": "^3.2.0", 25 | "vuex": "^3.4.0", 26 | "vuex-class": "^0.3.2" 27 | }, 28 | "devDependencies": { 29 | "@types/axios": "^0.14.0", 30 | "@types/better-scroll": "^1.12.2", 31 | "@types/fastclick": "^1.0.29", 32 | "@types/good-storage": "^1.1.0", 33 | "@types/jest": "^24.0.19", 34 | "@types/js-base64": "^2.3.2", 35 | "@types/jsonp": "^0.2.0", 36 | "@types/lyric-parser": "^1.0.2", 37 | "@typescript-eslint/eslint-plugin": "^2.33.0", 38 | "@typescript-eslint/parser": "^2.33.0", 39 | "@vue/cli-plugin-babel": "~4.4.0", 40 | "@vue/cli-plugin-eslint": "~4.4.0", 41 | "@vue/cli-plugin-router": "~4.4.0", 42 | "@vue/cli-plugin-typescript": "~4.4.0", 43 | "@vue/cli-plugin-unit-jest": "~4.4.0", 44 | "@vue/cli-plugin-vuex": "~4.4.0", 45 | "@vue/cli-service": "~4.4.0", 46 | "@vue/eslint-config-standard": "^5.1.2", 47 | "@vue/eslint-config-typescript": "^5.0.2", 48 | "@vue/test-utils": "^1.0.3", 49 | "eslint": "^6.7.2", 50 | "eslint-plugin-import": "^2.20.2", 51 | "eslint-plugin-node": "^11.1.0", 52 | "eslint-plugin-promise": "^4.2.1", 53 | "eslint-plugin-standard": "^4.0.0", 54 | "eslint-plugin-vue": "^6.2.2", 55 | "postcss-px-to-viewport": "^1.1.1", 56 | "sass": "^1.26.5", 57 | "sass-loader": "^8.0.2", 58 | "typescript": "~3.9.3", 59 | "vue-template-compiler": "^2.6.11" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/assets/styles/icon.scss: -------------------------------------------------------------------------------- 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 | font-style: normal; 15 | font-weight: normal; 16 | font-variant: normal; 17 | text-transform: none; 18 | line-height: 1; 19 | /* Better Font Rendering =========== */ 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | .icon-ok:before { 24 | content: "\e900" 25 | } 26 | .icon-close:before { 27 | content: "\e901" 28 | } 29 | .icon-add:before { 30 | content: "\e902" 31 | } 32 | .icon-play-mini:before { 33 | content: "\e903" 34 | } 35 | .icon-playlist:before { 36 | content: "\e904" 37 | } 38 | .icon-music:before { 39 | content: "\e905" 40 | } 41 | .icon-search:before { 42 | content: "\e906" 43 | } 44 | .icon-clear:before { 45 | content: "\e907" 46 | } 47 | .icon-delete:before { 48 | content: "\e908" 49 | } 50 | .icon-favorite:before { 51 | content: "\e909" 52 | } 53 | .icon-not-favorite:before { 54 | content: "\e90a" 55 | } 56 | .icon-pause:before { 57 | content: "\e90b" 58 | } 59 | .icon-play:before { 60 | content: "\e90c" 61 | } 62 | .icon-prev:before { 63 | content: "\e90d" 64 | } 65 | .icon-loop:before { 66 | content: "\e90e" 67 | } 68 | .icon-sequence:before { 69 | content: "\e90f" 70 | } 71 | .icon-random:before { 72 | content: "\e910" 73 | } 74 | .icon-back:before { 75 | content: "\e911" 76 | } 77 | .icon-mine:before { 78 | content: "\e912" 79 | } 80 | .icon-next:before { 81 | content: "\e913" 82 | } 83 | .icon-dismiss:before { 84 | content: "\e914" 85 | } 86 | .icon-pause-mini:before { 87 | content: "\e915" 88 | } 89 | -------------------------------------------------------------------------------- /src/api/recommend.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/utils/axios' 2 | import { MusicParams, MusicResponse } from '@/types/index' 3 | import { commonParams } from '@/api/config' 4 | 5 | export function getRecommendList (): Promise { 6 | const params: MusicParams = Object.assign({}, commonParams, { 7 | platform: 'yqq.json', 8 | hostUin: 0, 9 | needNewCode: 0, 10 | inCharset: 'utf8', 11 | format: 'json', 12 | '-': 'recom' + (Math.random() + '').replace('0.', ''), 13 | data: { 14 | comm: { ct: 24 }, 15 | category: { method: 'get_hot_category', param: { qq: '' }, module: 'music.web_category_svr' }, 16 | recomPlaylist: { 17 | method: 'get_hot_recommend', 18 | param: { async: 1, cmd: 2 }, 19 | module: 'playlist.HotRecommendServer' 20 | }, 21 | playlist: { 22 | method: 'get_playlist_by_category', 23 | param: { id: 8, curPage: 1, size: 40, order: 5, titleid: 8 }, 24 | module: 'playlist.PlayListPlazaServer' 25 | }, 26 | new_song: { module: 'newsong.NewSongServer', method: 'get_new_song_info', param: { type: 5 } }, 27 | new_album: { 28 | module: 'newalbum.NewAlbumServer', 29 | method: 'get_new_album_info', 30 | param: { area: 1, sin: 0, num: 10 } 31 | }, 32 | new_album_tag: { module: 'newalbum.NewAlbumServer', method: 'get_new_album_area', param: {} }, 33 | toplist: { module: 'musicToplist.ToplistInfoServer', method: 'GetAll', param: {} }, 34 | focus: { module: 'QQMusic.MusichallServer', method: 'GetFocus', param: {} } 35 | } 36 | }) 37 | return axios.get('/api/getTopBanner', { params }) 38 | } 39 | 40 | export function getDiscList (): Promise { 41 | const params: MusicParams = Object.assign({}, commonParams, { 42 | platform: 'yqq', 43 | hostUin: 0, 44 | sin: 0, 45 | ein: 29, 46 | sortId: 5, 47 | needNewCode: 0, 48 | categoryId: 10000000, 49 | rnd: Math.random(), 50 | format: 'json' 51 | }) 52 | return axios.get('/api/getDiscList', { params }) 53 | } 54 | 55 | export function getSongList (disstid: string | number): Promise { 56 | const url = '/api/getCdInfo' 57 | const params = Object.assign({}, commonParams, { 58 | disstid, 59 | type: 1, 60 | json: 1, 61 | utf8: 1, 62 | onlysong: 0, 63 | platform: 'yqq', 64 | hostUin: 0, 65 | needNewCode: 0 66 | }) 67 | return axios.get(url, { params }) 68 | } 69 | -------------------------------------------------------------------------------- /src/base/scroll/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 82 | -------------------------------------------------------------------------------- /src/api/song.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/utils/axios' 2 | import Song from '@/assets/js/song' 3 | import { commonParams, ERR_OK } from './config' 4 | import { SongUrlMap } from '@/types/song' 5 | import { getUid } from '@/utils/utils' 6 | import { MusicResponse } from '@/types' 7 | let tryCount = 3 8 | function createSongMids (songs: Song[]): object { 9 | const mids = songs.map(song => song.mid) 10 | const types = new Array(mids.length).fill(0) 11 | const guid = getUid() 12 | return { 13 | module: 'vkey.GetVkeyServer', 14 | method: 'CgiGetVkey', 15 | param: { 16 | guid, 17 | songmid: mids, 18 | songtype: types, 19 | uin: '0', 20 | loginflag: 0, 21 | platform: '23' 22 | } 23 | } 24 | } 25 | 26 | export function getSongUrl (songs: Song[]): Promise { 27 | const url = '/api/getPurlUrl' 28 | const midParams = createSongMids(songs) 29 | const dataParams = Object.assign({}, commonParams, { 30 | g_tk: 5381, 31 | format: 'json', 32 | platform: 'h5', 33 | needNewCode: 1, 34 | uin: 0 35 | }) 36 | return new Promise((resolve, reject) => { 37 | function reTry () { 38 | if (--tryCount > 0) { 39 | requestUrl() 40 | } else { 41 | reject(new Error('get song url fail')) 42 | } 43 | } 44 | 45 | function requestUrl () { 46 | return axios.post(url, { 47 | comm: dataParams, 48 | req_0: midParams 49 | }).then(res => { 50 | const { code, req_0 } = res as any 51 | if (code !== ERR_OK) { 52 | reTry() 53 | return 54 | } 55 | if (!req_0 || req_0.code !== ERR_OK) { 56 | reTry() 57 | return 58 | } 59 | if (code === ERR_OK && req_0 && req_0.code === ERR_OK) { 60 | const sougUrlMap: SongUrlMap = {} 61 | req_0.data.midurlinfo.forEach((item: any) => { 62 | if (item.purl) { 63 | sougUrlMap[item.songmid] = item.purl 64 | } 65 | }) 66 | if (Object.keys(sougUrlMap).length <= 0) { 67 | reTry() 68 | return 69 | } 70 | resolve(sougUrlMap) 71 | } 72 | }) 73 | } 74 | 75 | requestUrl() 76 | }) 77 | } 78 | 79 | export function getLyric (mid: string): Promise { 80 | const url = '/api/lyric' 81 | const params = Object.assign({}, commonParams, { 82 | songmid: mid, 83 | platform: 'yqq', 84 | hostUin: 0, 85 | needNewCode: 0, 86 | categoryId: 10000000, 87 | pcachetime: +new Date(), 88 | format: 'json' 89 | }) 90 | return axios.get(url, { params }) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/song-list/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 47 | 100 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import storage from 'good-storage' 2 | import Song from '@/assets/js/song' 3 | const SEARCH_KEY = 'music_search' 4 | const SEARCH_LEN = 10 5 | const FAVORITE_KEY = 'music_favorite' 6 | const FAVORIYE_LEN = 200 7 | const PLAY_KEY = 'music_play' 8 | const PLAY_LEN = 500 9 | 10 | export function insertArray (array: any[], value: any, compare: (item: any) => boolean, max: number) { 11 | const findIndex = array.findIndex(compare) 12 | if (findIndex === 0) { 13 | return 14 | } 15 | if (findIndex > 0) { 16 | array.splice(findIndex, 1) 17 | } 18 | array.unshift(value) 19 | if (max && array.length > max) { 20 | array.pop() 21 | } 22 | } 23 | 24 | export function deleteArray (array: any[], compare: (item: any) => boolean) { 25 | const findIndex = array.findIndex(compare) 26 | if (findIndex > -1) { 27 | array.splice(findIndex, 1) 28 | } 29 | } 30 | 31 | // 搜索历史 32 | export function getSearchHistory (): string[] { 33 | return storage.get(SEARCH_KEY, []) 34 | } 35 | export function saveSearchHistory (val: string): string[] { 36 | const historyArr: string[] = storage.get(SEARCH_KEY, []) 37 | insertArray(historyArr, val, (item: string) => { 38 | return item === val 39 | }, SEARCH_LEN) 40 | storage.set(SEARCH_KEY, historyArr) 41 | return historyArr 42 | } 43 | export function deleteSearchHistory (val: string): string[] { 44 | const historyArr: string[] = storage.get(SEARCH_KEY, []) 45 | deleteArray(historyArr, (item: string) => { 46 | return item === val 47 | }) 48 | storage.set(SEARCH_KEY, historyArr) 49 | return historyArr 50 | } 51 | export function clearSearchHistory (): [] { 52 | storage.remove(SEARCH_KEY) 53 | return [] 54 | } 55 | 56 | // 收藏歌曲 57 | export function getFavoriteList (): Song[] { 58 | const favoriteList: Song[] = storage.get(FAVORITE_KEY, []) 59 | return favoriteList 60 | } 61 | export function saveFavoriteList (song: Song): Song[] { 62 | const favoriteList: Song[] = storage.get(FAVORITE_KEY, []) 63 | insertArray(favoriteList, song, (item: Song) => { 64 | return item.id === song.id 65 | }, FAVORIYE_LEN) 66 | storage.set(FAVORITE_KEY, favoriteList) 67 | return favoriteList 68 | } 69 | export function deleteFavoriteList (song: Song): Song[] { 70 | const favoriteList: Song[] = storage.get(FAVORITE_KEY, []) 71 | deleteArray(favoriteList, (item: Song) => { 72 | return item.id === song.id 73 | }) 74 | storage.set(FAVORITE_KEY, favoriteList) 75 | return favoriteList 76 | } 77 | export function clearFavoriteList (): [] { 78 | storage.remove(FAVORITE_KEY) 79 | return [] 80 | } 81 | 82 | // 播放历史 83 | export function getPlayHistory (): Song[] { 84 | return storage.get(PLAY_KEY, []) 85 | } 86 | export function savePlayHistory (song: Song): Song[] { 87 | const playHistoryList = storage.get(PLAY_KEY, []) 88 | insertArray(playHistoryList, song, (item: Song) => { 89 | return item.id === song.id 90 | }, PLAY_LEN) 91 | storage.set(PLAY_KEY, playHistoryList) 92 | return playHistoryList 93 | } 94 | export function clearPlayHistoy (): [] { 95 | storage.remove(PLAY_KEY) 96 | return [] 97 | } 98 | -------------------------------------------------------------------------------- /src/base/confirm/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 35 | 103 | -------------------------------------------------------------------------------- /tests/unit/utils/cache.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | insertArray, 3 | deleteArray, 4 | getSearchHistory, 5 | saveSearchHistory, 6 | clearSearchHistory, 7 | deleteSearchHistory, 8 | saveFavoriteList, 9 | getFavoriteList, 10 | deleteFavoriteList, 11 | clearFavoriteList, 12 | getPlayHistory, 13 | savePlayHistory, 14 | clearPlayHistoy 15 | } from '@/utils/cache' 16 | 17 | describe('cache.ts', () => { 18 | const max = 5 19 | beforeEach(() => { 20 | clearSearchHistory() 21 | clearFavoriteList() 22 | clearPlayHistoy() 23 | }) 24 | it('test insertArray function', () => { 25 | const array = [] 26 | insertArray(array, 'AAA', (item) => item === 'AAA', max) 27 | insertArray(array, 'AAA', (item) => item === 'AAA', max) 28 | expect(array).toEqual(['AAA']) 29 | insertArray(array, 'BBB', (item) => item === 'BBB', max) 30 | expect(array).toEqual(['BBB', 'AAA']) 31 | insertArray(array, 'AAA', (item) => item === 'AAA', max) 32 | expect(array).toEqual(['AAA', 'BBB']) 33 | insertArray(array, 'CCC', (item) => item === 'CCC', max) 34 | insertArray(array, 'DDD', (item) => item === 'DDD', max) 35 | insertArray(array, 'EEE', (item) => item === 'EEE', max) 36 | insertArray(array, 'FFF', (item) => item === 'FFF', max) 37 | expect(array).toEqual(['FFF', 'EEE', 'DDD', 'CCC', 'AAA']) 38 | }) 39 | it('test deleteArray function', () => { 40 | const array = ['AAA'] 41 | deleteArray(array, (item) => item === 'BBB') 42 | expect(array).toEqual(['AAA']) 43 | deleteArray(array, (item) => item === 'AAA') 44 | expect(array.length).toBe(0) 45 | }) 46 | it('test getSearchHistory function', () => { 47 | expect(getSearchHistory()).toEqual([]) 48 | }) 49 | it('test saveSearchHistory function', () => { 50 | expect(saveSearchHistory('AAA')).toEqual(['AAA']) 51 | expect(saveSearchHistory('BBB')).toEqual(['BBB', 'AAA']) 52 | expect(getSearchHistory()).toEqual(['BBB', 'AAA']) 53 | }) 54 | it('test deleteSearchHistory function', () => { 55 | saveSearchHistory('AAA') 56 | saveSearchHistory('BBB') 57 | saveSearchHistory('CCC') 58 | expect(getSearchHistory()).toEqual(['CCC', 'BBB', 'AAA']) 59 | expect(deleteSearchHistory('AAA')).toEqual(['CCC', 'BBB']) 60 | }) 61 | it('test saveFavoriteList function', () => { 62 | const songA = { id: 'AAA', name: 'songA' } 63 | const songB = { id: 'BBB', name: 'songB' } 64 | expect(getFavoriteList()).toEqual([]) 65 | expect(saveFavoriteList(songA)).toEqual([songA]) 66 | expect(saveFavoriteList(songB)).toEqual([songB, songA]) 67 | }) 68 | it('test deleteFavoriteList function', () => { 69 | const songA = { id: 'AAA', name: 'songA' } 70 | const songB = { id: 'BBB', name: 'songB' } 71 | expect(getFavoriteList()).toEqual([]) 72 | expect(saveFavoriteList(songA)).toEqual([songA]) 73 | expect(saveFavoriteList(songB)).toEqual([songB, songA]) 74 | expect(deleteFavoriteList(songA)).toEqual([songB]) 75 | }) 76 | it('test savePlayHistory function', () => { 77 | const songA = { id: 'AAA', name: 'songA' } 78 | const songB = { id: 'BBB', name: 'songB' } 79 | expect(getPlayHistory()).toEqual([]) 80 | expect(savePlayHistory(songB)).toEqual([songB]) 81 | expect(savePlayHistory(songA)).toEqual([songA, songB]) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /src/assets/js/song.ts: -------------------------------------------------------------------------------- 1 | import { SongData } from '@/types/search' 2 | import { getSongUrl, getLyric } from '@/api/song' 3 | import { SongUrlMap } from '@/types/song' 4 | import { ERR_OK } from '@/api/config' 5 | import { Base64 } from 'js-base64' 6 | function filterSinger (singer: [] | any): string { 7 | const result: string[] = [] 8 | if (!singer) { 9 | return '' 10 | } 11 | if (Array.isArray(singer)) { 12 | singer.forEach(item => { 13 | result.push(item.name) 14 | }) 15 | } 16 | return result.join('/') 17 | } 18 | 19 | interface ISong { 20 | id: string; 21 | mid: string; 22 | singer: string; 23 | name: string; 24 | album: string; 25 | duration: number; 26 | image: string; 27 | filename: string; 28 | url: string; 29 | type: string; 30 | lyric: string; 31 | } 32 | export default class Song implements ISong { 33 | public id: string; 34 | public mid: string; 35 | public singer: string; 36 | public name: string; 37 | public album: string; 38 | public duration: number; 39 | public image: string; 40 | public filename: string; 41 | public url: string; 42 | public type: string; 43 | public lyric: string; 44 | constructor ({ id, mid, singer, name, album, duration, image, filename, url, type, lyric }: ISong) { 45 | this.id = id 46 | this.mid = mid 47 | this.singer = singer 48 | this.name = name 49 | this.album = album 50 | this.duration = duration 51 | this.image = image 52 | this.filename = filename 53 | this.url = url 54 | this.type = type 55 | this.lyric = lyric 56 | } 57 | 58 | public getLyric (): Promise { 59 | if (this.lyric) { 60 | return Promise.resolve(this.lyric) 61 | } 62 | return new Promise((resolve, reject) => { 63 | getLyric(this.mid).then(res => { 64 | const { code, data } = res 65 | if (code === ERR_OK) { 66 | this.lyric = Base64.decode(data) 67 | resolve(this.lyric) 68 | } else { 69 | reject(new Error('no lyric')) 70 | } 71 | }) 72 | }) 73 | } 74 | } 75 | 76 | export function createSong (songData: SongData): Song { 77 | return new Song({ 78 | id: songData.songid, 79 | mid: songData.songmid, 80 | singer: filterSinger(songData.singer), 81 | name: songData.songname, 82 | album: songData.albumname, 83 | duration: songData.interval, 84 | image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${songData.albummid}.jpg?max_age=2592000`, 85 | filename: `C400${songData.songmid}.m4a`, 86 | url: songData.url, 87 | type: 'song', 88 | lyric: '' 89 | }) 90 | } 91 | 92 | export function isValid (songData: SongData): boolean { 93 | return !!(songData.songid && songData.albummid && (!songData.pay || songData.pay.payalbumprice === 0)) 94 | } 95 | 96 | export function processSongUrl (songs: Song[]): Promise { 97 | return getSongUrl(songs).then((res: SongUrlMap) => { 98 | songs = songs.filter(song => { 99 | const songUrl = res[song.mid] 100 | if (songUrl) { 101 | song.url = songUrl.indexOf('http') === -1 ? `http://dl.stream.qqmusic.qq.com/${songUrl}` : songUrl 102 | return true 103 | } 104 | return false 105 | }) 106 | return songs 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /src/views/singer/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 97 | 106 | -------------------------------------------------------------------------------- /src/views/rank/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 86 | 140 | -------------------------------------------------------------------------------- /src/components/progress-bar/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 91 | 128 | -------------------------------------------------------------------------------- /src/base/slider/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 110 | 150 | -------------------------------------------------------------------------------- /src/views/user/index.vue: -------------------------------------------------------------------------------- 1 | 36 | 93 | 155 | -------------------------------------------------------------------------------- /src/store/modules/player.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-type' 2 | import Song from '@/assets/js/song' 3 | import { Commit } from 'vuex' 4 | import { PlayerState } from '../types' 5 | import { SelectPlay, PlayMode } from '@/types/player' 6 | import { getFavoriteList, saveFavoriteList, deleteFavoriteList } from '@/utils/cache' 7 | import { shuffle } from '@/utils/utils' 8 | const state = { 9 | currentIndex: -1, 10 | fullScreen: false, 11 | mode: PlayMode.sequence, 12 | playing: false, 13 | playList: [], 14 | sequenceList: [], 15 | favoriteList: getFavoriteList() 16 | } 17 | 18 | const mutations = { 19 | [types.SET_PLAY_LIST] (state: PlayerState, list: Song[]) { 20 | state.playList = list 21 | }, 22 | [types.SET_CURRENT_INDEX] (state: PlayerState, index: number) { 23 | state.currentIndex = index 24 | }, 25 | [types.SET_FULL_SCREEN] (state: PlayerState, fullScreen: boolean) { 26 | state.fullScreen = fullScreen 27 | }, 28 | [types.SET_PLAY_MODE] (state: PlayerState, mode: number) { 29 | state.mode = mode 30 | }, 31 | [types.SET_FAVORITE_LIST] (state: PlayerState, favoriteList: Song[]) { 32 | state.favoriteList = favoriteList 33 | }, 34 | [types.SET_PLAY_STATE] (state: PlayerState, playing: boolean) { 35 | state.playing = playing 36 | }, 37 | [types.SET_SEQUENCE_LIST] (state: PlayerState, list: Song[]) { 38 | state.sequenceList = list 39 | } 40 | } 41 | 42 | const actions = { 43 | selectPlay (context: { commit: Commit }, { list, index }: SelectPlay) { 44 | context.commit(types.SET_PLAY_LIST, list) 45 | context.commit(types.SET_SEQUENCE_LIST, list) 46 | context.commit(types.SET_CURRENT_INDEX, index) 47 | context.commit(types.SET_FULL_SCREEN, true) 48 | context.commit(types.SET_PLAY_STATE, true) 49 | }, 50 | randomPlay (context: { commit: Commit }, list: Song[]) { 51 | const randomPlayList = shuffle(list) 52 | context.commit(types.SET_PLAY_MODE, PlayMode.random) 53 | context.commit(types.SET_PLAY_LIST, randomPlayList) 54 | context.commit(types.SET_SEQUENCE_LIST, list) 55 | context.commit(types.SET_CURRENT_INDEX, 0) 56 | context.commit(types.SET_FULL_SCREEN, true) 57 | context.commit(types.SET_PLAY_STATE, true) 58 | }, 59 | saveFavoriteList (context: { commit: Commit }, song: Song) { 60 | context.commit(types.SET_FAVORITE_LIST, saveFavoriteList(song)) 61 | }, 62 | deleteFavoriteList (context: { commit: Commit }, song: Song) { 63 | context.commit(types.SET_FAVORITE_LIST, deleteFavoriteList(song)) 64 | }, 65 | insertSong (context: { commit: Commit, state: PlayerState }, song: Song) { 66 | const playList: Song[] = state.playList.slice() 67 | const sequenceList: Song[] = state.sequenceList.slice() 68 | let currentIndex = state.currentIndex 69 | const pIndex = playList.findIndex(item => item.id === song.id) 70 | const sIndex = sequenceList.findIndex(item => item.id === song.id) 71 | if (pIndex === -1) { 72 | playList.unshift(song) 73 | } else { 74 | playList.splice(pIndex, 1) 75 | playList.unshift(song) 76 | } 77 | currentIndex = 0 78 | 79 | if (sIndex === -1) { 80 | sequenceList.unshift(song) 81 | } else { 82 | sequenceList.splice(sIndex, 1) 83 | sequenceList.unshift(song) 84 | } 85 | 86 | context.commit(types.SET_PLAY_LIST, playList) 87 | context.commit(types.SET_SEQUENCE_LIST, sequenceList) 88 | context.commit(types.SET_CURRENT_INDEX, currentIndex) 89 | context.commit(types.SET_PLAY_STATE, true) 90 | context.commit(types.SET_FULL_SCREEN, true) 91 | }, 92 | deleteSong (context: { commit: Commit, state: PlayerState }, song: Song) { 93 | const playList: Song[] = state.playList.slice() 94 | const sequenceList: Song[] = state.sequenceList.slice() 95 | let currentIndex = state.currentIndex 96 | const pIndex = playList.findIndex(item => item.id === song.id) 97 | const sIndex = sequenceList.findIndex(item => item.id === song.id) 98 | playList.splice(pIndex, 1) 99 | sequenceList.splice(sIndex, 1) 100 | if (currentIndex > pIndex || currentIndex === playList.length) { 101 | currentIndex-- 102 | } 103 | context.commit(types.SET_PLAY_LIST, playList) 104 | context.commit(types.SET_SEQUENCE_LIST, sequenceList) 105 | context.commit(types.SET_CURRENT_INDEX, currentIndex) 106 | if (!playList.length) { 107 | context.commit(types.SET_PLAY_STATE, false) 108 | } else { 109 | context.commit(types.SET_PLAY_STATE, true) 110 | } 111 | }, 112 | deleteSongList (context: { commit: Commit }) { 113 | context.commit(types.SET_CURRENT_INDEX, -1) 114 | context.commit(types.SET_PLAY_LIST, []) 115 | context.commit(types.SET_SEQUENCE_LIST, []) 116 | context.commit(types.SET_PLAY_STATE, false) 117 | context.commit(types.SET_FULL_SCREEN, false) 118 | } 119 | } 120 | 121 | export default { 122 | namespaced: true, 123 | state, 124 | mutations, 125 | actions 126 | } 127 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const bodyParser = require('body-parser') 3 | module.exports = { 4 | devServer: { 5 | before (app) { 6 | // 轮播 7 | app.get('/api/getTopBanner', function (req, res) { 8 | const url = 'https://u.y.qq.com/cgi-bin/musicu.fcg' 9 | const jumpPrefixMap = { 10 | 10002: 'https://y.qq.com/n/yqq/album/', 11 | 10014: 'https://y.qq.com/n/yqq/playlist/', 12 | 10012: 'https://y.qq.com/n/yqq/mv/v/' 13 | } 14 | axios.get(url, { 15 | headers: { 16 | referer: 'https://u.y.qq.com/', 17 | host: 'u.y.qq.com' 18 | }, 19 | params: req.query 20 | }).then((response) => { 21 | response = response.data 22 | if (response.code === 0) { 23 | const slider = [] 24 | const content = response.focus.data && response.focus.data.content 25 | if (content) { 26 | for (let i = 0; i < content.length; i++) { 27 | const item = content[i] 28 | const sliderItem = {} 29 | const jumpPrefix = jumpPrefixMap[item.type || 10002] 30 | sliderItem.id = item.id 31 | sliderItem.linkUrl = jumpPrefix + item.jump_info.url + '.html' 32 | sliderItem.picUrl = item.pic_info.url 33 | slider.push(sliderItem) 34 | } 35 | } 36 | res.json({ 37 | code: 0, 38 | data: { 39 | slider 40 | } 41 | }) 42 | } else { 43 | res.json(response) 44 | } 45 | }).catch((e) => { 46 | console.log(e) 47 | }) 48 | }) 49 | // 推荐歌单 50 | app.get('/api/getDiscList', function (req, res) { 51 | const url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg' 52 | axios.get(url, { 53 | headers: { 54 | referer: 'https://c.y.qq.com/', 55 | host: 'c.y.qq.com' 56 | }, 57 | params: req.query 58 | }).then((response) => { 59 | const { status, data } = response 60 | if (status === 200 && data.data) { 61 | res.json({ 62 | code: 0, 63 | data: data.data.list 64 | }) 65 | } else { 66 | res.json({ 67 | code: 0, 68 | data: [] 69 | }) 70 | } 71 | }).catch((e) => { 72 | console.log(e) 73 | }) 74 | }) 75 | // 搜索 76 | app.get('/api/search', function (req, res) { 77 | const url = 'https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp' 78 | axios.get(url, { 79 | headers: { 80 | referer: 'https://c.y.qq.com/', 81 | host: 'c.y.qq.com' 82 | }, 83 | params: req.query 84 | }).then((response) => { 85 | res.json(response.data) 86 | }).catch((e) => { 87 | console.log(e) 88 | }) 89 | }) 90 | // 歌单信息 91 | app.get('/api/getCdInfo', function (req, res) { 92 | const url = 'https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg' 93 | axios.get(url, { 94 | headers: { 95 | referer: 'https://c.y.qq.com/', 96 | host: 'c.y.qq.com' 97 | }, 98 | params: req.query 99 | }).then((response) => { 100 | let ret = response.data 101 | if (typeof ret === 'string') { 102 | const reg = /^\w+\(({.+})\)$/ 103 | const matches = ret.match(reg) 104 | if (matches) { 105 | ret = JSON.parse(matches[1]) 106 | } 107 | } 108 | res.json({ 109 | code: 0, 110 | data: ret.cdlist[0].songlist 111 | }) 112 | }).catch((e) => { 113 | console.log(e) 114 | }) 115 | }) 116 | // 歌曲播放地址 117 | app.post('/api/getPurlUrl', bodyParser.json(), function (req, res) { 118 | const url = 'https://u.y.qq.com/cgi-bin/musicu.fcg' 119 | axios.post(url, req.body, { 120 | headers: { 121 | referer: 'https://y.qq.com/', 122 | origin: 'https://y.qq.com', 123 | 'Content-type': 'application/x-www-form-urlencoded' 124 | } 125 | }).then((response) => { 126 | res.json(response.data) 127 | }).catch((e) => { 128 | console.log(e) 129 | }) 130 | }) 131 | // 歌词 132 | app.get('/api/lyric', function (req, res) { 133 | const url = 'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg' 134 | 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 | let ret = response.data 143 | if (typeof ret === 'string') { 144 | const reg = /^\w+\(({.+})\)$/ 145 | const matches = ret.match(reg) 146 | if (matches) { 147 | ret = JSON.parse(matches[1]) 148 | } 149 | } 150 | res.json({ 151 | code: ret.code, 152 | data: ret.lyric 153 | }) 154 | }).catch((e) => { 155 | console.log(e) 156 | }) 157 | }) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/views/recommend/index.vue: -------------------------------------------------------------------------------- 1 | 44 | 106 | 181 | -------------------------------------------------------------------------------- /tests/unit/store/player.spec.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store/index' 2 | import * as types from '@/store/mutation-type' 3 | import { PlayMode } from '@/types/player' 4 | describe('vuex module player', () => { 5 | const songs = [ 6 | { id: '001', min: '001', singer: '周杰伦', name: '夜曲' }, 7 | { id: '002', min: '002', singer: '周杰伦', name: '稻香' }, 8 | { id: '003', min: '003', singer: '周杰伦', name: '菊花台' }, 9 | ] 10 | const playerState = store.state.player 11 | beforeEach(() => { 12 | playerState.currentIndex = -1 13 | playerState.fullScreen = false 14 | playerState.mode = PlayMode.sequence 15 | playerState.playing = false 16 | playerState.playList = [] 17 | playerState.sequenceList = [] 18 | playerState.favoriteList = [] 19 | }) 20 | 21 | it('SET_CURRENT_INDEX mutation', () => { 22 | store.commit(`player/${types.SET_CURRENT_INDEX}`, 100) 23 | expect(playerState.currentIndex).toBe(100) 24 | store.commit(`player/${types.SET_CURRENT_INDEX}`, 0) 25 | expect(playerState.currentIndex).toBe(0) 26 | }) 27 | it('SET_FULL_SCREEN mutation', () => { 28 | store.commit(`player/${types.SET_FULL_SCREEN}`, false) 29 | expect(playerState.fullScreen).toBe(false) 30 | store.commit(`player/${types.SET_FULL_SCREEN}`, true) 31 | expect(playerState.fullScreen).toBe(true) 32 | }) 33 | it('SET_PLAY_MODE mutation', () => { 34 | store.commit(`player/${types.SET_PLAY_MODE}`, PlayMode.loop) 35 | expect(playerState.mode).toBe(PlayMode.loop) 36 | store.commit(`player/${types.SET_PLAY_MODE}`, PlayMode.random) 37 | expect(playerState.mode).toBe(PlayMode.random) 38 | }) 39 | it('SET_PLAY_STATE mutation', () => { 40 | store.commit(`player/${types.SET_PLAY_STATE}`, true) 41 | expect(playerState.playing).toBe(true) 42 | store.commit(`player/${types.SET_PLAY_STATE}`, false) 43 | expect(playerState.playing).toBe(false) 44 | }) 45 | it('selectPlay action', () => { 46 | store.dispatch(`player/selectPlay`, { list: songs, index: 2 }) 47 | expect(playerState.playList).toEqual(songs) 48 | expect(playerState.sequenceList).toEqual(songs) 49 | expect(playerState.currentIndex).toBe(2) 50 | expect(playerState.fullScreen).toBe(true) 51 | expect(playerState.playing).toBe(true) 52 | }) 53 | it('randomPlay action', () => { 54 | store.dispatch('player/randomPlay', songs) 55 | expect(playerState.mode).toBe(PlayMode.random) 56 | expect(playerState.playList.length).toBe(songs.length) 57 | expect(playerState.sequenceList).toEqual(songs) 58 | expect(playerState.fullScreen).toBe(true) 59 | expect(playerState.playing).toBe(true) 60 | }) 61 | it('saveFavoriteList action', () => { 62 | store.dispatch('player/saveFavoriteList', songs[2]) 63 | store.dispatch('player/saveFavoriteList', songs[0]) 64 | store.dispatch('player/saveFavoriteList', songs[1]) 65 | expect(playerState.favoriteList).toEqual([songs[1], songs[0], songs[2]]) 66 | }) 67 | it('deleteFavoriteList action', () => { 68 | store.dispatch('player/saveFavoriteList', songs[2]) 69 | store.dispatch('player/saveFavoriteList', songs[0]) 70 | store.dispatch('player/saveFavoriteList', songs[1]) 71 | expect(playerState.favoriteList).toEqual([songs[1], songs[0], songs[2]]) 72 | 73 | store.dispatch('player/deleteFavoriteList', songs[0]) 74 | expect(playerState.favoriteList).toEqual([songs[1], songs[2]]) 75 | store.dispatch('player/deleteFavoriteList', songs[2]) 76 | expect(playerState.favoriteList).toEqual([songs[1]]) 77 | }) 78 | it('insertSong action', () => { 79 | store.dispatch('player/insertSong', songs[2]) 80 | store.dispatch('player/insertSong', songs[1]) 81 | store.dispatch('player/insertSong', songs[2]) 82 | expect(playerState.playList).toEqual([songs[2], songs[1]]) 83 | expect(playerState.sequenceList).toEqual([songs[2], songs[1]]) 84 | expect(playerState.currentIndex).toBe(0) 85 | expect(playerState.playing).toBe(true) 86 | expect(playerState.fullScreen).toBe(true) 87 | }) 88 | it('deleteSong action', () => { 89 | store.dispatch('player/insertSong', songs[2]) 90 | store.dispatch('player/insertSong', songs[1]) 91 | store.dispatch('player/insertSong', songs[0]) 92 | store.commit(`player/${types.SET_CURRENT_INDEX}`, 2) 93 | expect(playerState.playList).toEqual(songs) 94 | expect(playerState.sequenceList).toEqual(songs) 95 | expect(playerState.currentIndex).toBe(2) 96 | expect(playerState.playing).toBe(true) 97 | expect(playerState.fullScreen).toBe(true) 98 | 99 | store.dispatch('player/deleteSong', songs[1]) 100 | expect(playerState.playList).toEqual([songs[0], songs[2]]) 101 | expect(playerState.sequenceList).toEqual([songs[0], songs[2]]) 102 | expect(playerState.currentIndex).toBe(1) 103 | expect(playerState.playing).toBe(true) 104 | 105 | store.dispatch('player/deleteSong', songs[2]) 106 | store.dispatch('player/deleteSong', songs[0]) 107 | expect(playerState.playList.length).toBe(0) 108 | expect(playerState.sequenceList.length).toBe(0) 109 | expect(playerState.currentIndex).toBe(-1) 110 | expect(playerState.playing).toBe(false) 111 | }) 112 | it('deleteSongList action', () => { 113 | store.dispatch('player/insertSong', songs[2]) 114 | store.dispatch('player/insertSong', songs[1]) 115 | store.dispatch('player/insertSong', songs[0]) 116 | store.commit(`player/${types.SET_CURRENT_INDEX}`, 2) 117 | expect(playerState.playList).toEqual(songs) 118 | expect(playerState.sequenceList).toEqual(songs) 119 | expect(playerState.currentIndex).toBe(2) 120 | expect(playerState.playing).toBe(true) 121 | expect(playerState.fullScreen).toBe(true) 122 | 123 | store.dispatch('player/deleteSongList') 124 | expect(playerState.playList).toEqual([]) 125 | expect(playerState.sequenceList).toEqual([]) 126 | expect(playerState.currentIndex).toBe(-1) 127 | expect(playerState.playing).toBe(false) 128 | expect(playerState.fullScreen).toBe(false) 129 | }) 130 | }) -------------------------------------------------------------------------------- /src/components/suggestion/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 145 | 181 | -------------------------------------------------------------------------------- /src/components/add-song/index.vue: -------------------------------------------------------------------------------- 1 | 58 | 115 | 201 | -------------------------------------------------------------------------------- /src/components/playlist/index.vue: -------------------------------------------------------------------------------- 1 | 38 | 86 | 196 | -------------------------------------------------------------------------------- /src/views/search/index.vue: -------------------------------------------------------------------------------- 1 | 59 | 131 | 212 | -------------------------------------------------------------------------------- /src/components/music-list/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 142 | 238 | -------------------------------------------------------------------------------- /src/components/list-view/index.vue: -------------------------------------------------------------------------------- 1 | 58 | 198 | 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-music-ts 2 | ## 安装 3 | ``` 4 | # 依赖依赖 5 | npm install 6 | 7 | # 本地开发 8 | npm run serve 9 | 10 | # 打包 11 | npm run build 12 | 13 | # 自动化测试 14 | npm run test:unit 15 | 16 | # 代码格式检查/修正 17 | npm run lint 18 | ``` 19 | ## 前言 20 | 项目使用主要`TypeScript`+`Jest`重构`Vue-Music`音乐app,所以重点在于对`TypeScript`、`Jest`的使用。本篇文章并不会花大量的篇幅去说明如何抓取接口,布局以及撰写CSS等方面的知识。也不会全面、系统的去介绍`TypeScript`或者`Jest`方面的基础知识,如果你对这两方面还不是很了解的话,你可以访问下面的链接去学习:
21 | [TypeScript官网](https://www.typescriptlang.org/)
22 | [Jest官网](https://jestjs.io/)
23 | [Vue-Test-Utils官网](https://vue-test-utils.vuejs.org/zh/guides/)
24 | 25 | 如果你想具体看`Vue-Music`是如何实现的,你可以观看正版视频[Vue2.0开发企业级移动端音乐Web App](https://coding.imooc.com/class/107.html)。 26 | 27 | 如果你想看本篇文章的源码,可以点击[源码仓库](https://github.com/wangtunan/vue-music-ts),觉得写得不错,请给一个Star。 28 | 29 | ## 技术栈/工具 30 | * `normalize.css`:重置样式。 31 | * `good-storage`: 基于`localStorage`本地缓存封装的库。 32 | * `jsonp`:发送`jsonp`请求的库。 33 | * `vue-lazyload`:图片懒加载。 34 | * `vue-property-decorator`:依赖于`vue-class-component`的库。 35 | * `axios`:`http`请求库。 36 | * `sass`:`css`预处理器。 37 | * `better-scroll`:一个处理移动端滚动事件的库。 38 | * `lyric-parser`:歌词解析库。 39 | * `jest`:一个前端自动化测试工具。 40 | * `vue-test-utils`:`vue`单元测试工具,可以配合`Jest`或者其他测试框架使用。 41 | * `postcss-px-to-viewport`:移动端自动适配工具。 42 | * `typescript`:`JavaScript`超集,提供静态类型检查。 43 | * `js-base64`:提供`Base64`加密和解密。 44 | * `vuex-class`:提供一种Vuex的包裹写法,方便在`vue-class-component`中使用。 45 | * `vue-router`:路由 46 | 47 | ## 环境搭建 48 | 49 | ### 脚手架生成项目 50 | 我们使用`Vue-Cli4.0+`来新建我们的项目,执行如下命令: 51 | ```sh 52 | # 创建项目 53 | $ vue create vue-music-ts 54 | ``` 55 | 并选择自定义配置 56 | ```sh 57 | ? Please pick a preset 58 | default (babel, eslint) 59 | > Manually select features 60 | ``` 61 | 随后勾选我们需要用到的功能: 62 | ```sh 63 | ? Check the features needed for your project 64 | (*) Babel 65 | (*) TypeScript 66 | ( ) Progressive Web App (PWA) Support 67 | (*) Router 68 | (*) Vuex 69 | (*) CSS Pre-processors 70 | (*) Linter / Formatter 71 | >(*) Unit Testing 72 | ( ) E2E Testing 73 | ``` 74 | 按回车后,随后会询问我们,我们进行如下选择: 75 | ```sh 76 | ? Use class-style component syntax? Yes 77 | ? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes 78 | ? Use history mode for router? (Requires proper server setup for index fallback in production) No 79 | ``` 80 | 紧接着,`CSS`预处理器我们选择了`Scss`,单元测试工具我们选择了`Jest`。安装完毕后,我们需要将项目目录改造成下面这样: 81 | ```sh 82 | |-- src 83 | | |-- api # 存放请求目录 84 | | |-- assets # 资源目录 85 | | | |-- fonts # 字体 86 | | | |-- images # 图片 87 | | | |-- js # js 88 | | | |-- styles # 样式 89 | | |-- components # 公共组件 90 | | |-- router # 路由 91 | | |-- store # vuex 92 | | |-- types # 类型定义 93 | | |-- utils # 工具函数 94 | | |-- views # 页面 95 | | | |-- recommend # 推荐页面 96 | | | |-- singer # 歌手页面 97 | | |-- App.vue # 入口Vue文件 98 | | |-- main.ts # 入口ts文件 99 | | |-- shims-tsx.d.ts # 支持jsx语法 100 | | |-- shims-vue.d.ts # 使ts支持识别.vue文件 101 | ``` 102 | 其中`src/views/recommend/index.vue`的代码如下: 103 | ```ts 104 | 107 | 113 | ``` 114 | 115 | 路由部分如下所示: 116 | ```ts 117 | import Vue from 'vue' 118 | import VueRouter, { RouteConfig } from 'vue-router' 119 | const Recommend = () => import('@/views/recommend/index.vue') 120 | const Singer = () => import('@/views/singer/index.vue') 121 | Vue.use(VueRouter) 122 | 123 | const routes: Array = [ 124 | { 125 | path: '/', 126 | redirect: '/recommend' 127 | }, 128 | { 129 | path: '/recommend', 130 | name: 'Recommend', 131 | component: Recommend 132 | }, 133 | { 134 | path: '/singer', 135 | name: 'Singer', 136 | component: Singer 137 | } 138 | ] 139 | 140 | const router = new VueRouter({ 141 | routes 142 | }) 143 | 144 | export default router 145 | ``` 146 | 随后我们,使用下面的命令启动我们的项目: 147 | ```sh 148 | $ npm run serve 149 | ``` 150 | 启动完毕后,你将会看到`recommend`页面的内容,至此我们使用脚手架搭建项目就完毕了,下一步我们将安装一些工具。 151 | 152 | **注意:** 项目中需要使用到的图片、字体、以及样式等相关的东西需要自己去引入。 153 | 154 | 155 | ### 安装移动端适配工具 156 | 由于原版`Vue-Music`课程并没有做移动端适配,那么可以自主选择一种移动端适配方案,关于移动端适配方案有很多,我们选择了`postcss-px-to-viewport`。 157 | 158 | 在安装之前,需要我们先在`public/index.html`里添加一段代码: 159 | ```html 160 | 161 | ``` 162 | 添加完毕后,使用下面的命令安装: 163 | ```sh 164 | # 安装 165 | $ npm install postcss-px-to-viewport -D 166 | ``` 167 | 随后在根目录下新建`postcss.config.js`,并配置如下: 168 | ```js 169 | module.exports = { 170 | plugins: { 171 | autoprefixer: {}, 172 | 'postcss-px-to-viewport': { 173 | unitToConvert: 'px', 174 | viewportWidth: 375, // 对应视觉稿,750的视觉稿就写750 175 | unitPrecision: 8, // px转vm的过程中,最多保留的小数位数 176 | propList: ['*'], 177 | viewportUnit: 'vw', // 转换后的单位 178 | fontViewportUnit: 'vw', 179 | selectorBlackList: [], 180 | minPixelValue: 1, 181 | mediaQuery: false, 182 | replace: true, 183 | exclude: /(\/|\\)(node_modules)(\/|\\)/, 184 | landscape: false, 185 | landscapeUnit: 'vw', 186 | landscapeWidth: 667 187 | } 188 | } 189 | } 190 | ``` 191 | 192 | 193 | ### 安装Axios及Axios拦截 194 | 同样的道理,使用如下命令去安装`axios`及其类型定义文件 195 | ```sh 196 | # 安装axios到dependencies 197 | $ npm install axios --save 198 | 199 | # 安装axios类型定义文件到devDependencies 200 | $ npm install @types/axios -D 201 | ``` 202 | 安装完毕后,我们需要对`axios`做一下简单的封装,在封装之前我们约定所有的请求响应结构如下所示: 203 | ```json 204 | // 1.code为0代表请求成功,number类型且必须存在 205 | // 2.data可以为任意类型且必须存在 206 | // 3.message为可选字段,约定为string类型。 207 | { 208 | "code": 0, 209 | "data": {}, 210 | "message": "" 211 | } 212 | ``` 213 | 根据我们约定的响应结构,我们首先需要在`src/api`目录下新建`config.js`文件: 214 | ```js 215 | // 0代表请求成功 216 | export const ERR_OK = 0 217 | ``` 218 | 随后需要在`src/types`目录下新建`index.ts`,并添加如下代码: 219 | ```js 220 | export interface MusicResponse { 221 | code: number; 222 | data: any; 223 | message?: string; 224 | } 225 | ``` 226 | 227 | 紧接着在`src/utils`目录下新建`axios.ts`,并添加如下代码: 228 | ```ts 229 | import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' 230 | const service = axios.create({ 231 | timeout: 10000, 232 | baseURL: '' 233 | }) 234 | 235 | // 请求拦截 236 | service.interceptors.request.use( 237 | (config: AxiosRequestConfig): AxiosRequestConfig => { 238 | return config 239 | } 240 | ) 241 | 242 | // 响应拦截 243 | service.interceptors.response.use( 244 | (response: AxiosResponse): Promise => { 245 | const { status, data } = response 246 | if (status === 200) { 247 | return data 248 | } else { 249 | return Promise.reject(new Error('服务器异常!')) 250 | } 251 | }, 252 | (error: AxiosError): Promise => { 253 | return Promise.reject(error.message) 254 | } 255 | ) 256 | 257 | export default service 258 | ``` 259 | 260 | 最后,我们需要在`src/api`目录下去撰写我们的请求接口,这里以`song.ts`代码为例: 261 | ```ts 262 | import axios from '@/utils/axios' 263 | import { MusicResponse } from '@/types/index' 264 | export function getLyric (mid: string): Promise { 265 | const url = '/api/lyric' 266 | const params = Object.assign({}, commonParams, { 267 | songmid: mid, 268 | platform: 'yqq', 269 | hostUin: 0, 270 | needNewCode: 0, 271 | categoryId: 10000000, 272 | pcachetime: +new Date(), 273 | format: 'json' 274 | }) 275 | return axios.get(url, { params }) 276 | } 277 | ``` 278 | 指定`getLyric()`方法的返回数据格式后,我们就可以在组件中正确的推导出返回数据的类型: 279 | ```ts 280 | const mid = 'SDMODNTJ12323123SDS' 281 | getLyric(mid).then(res => { 282 | console.log(res.code) 283 | console.log(res.data) 284 | console.log(res.message) 285 | }) 286 | ``` 287 | 288 | ### Jest单元测试 289 | 我们脚手架创建项目后,会在根目录下生成`tests`文件夹,它包含一个简单的测试用例: 290 | ```ts 291 | import { shallowMount } from '@vue/test-utils' 292 | import HelloWorld from '@/components/HelloWorld.vue' 293 | 294 | describe('HelloWorld.vue', () => { 295 | it('renders props.msg when passed', () => { 296 | const msg = 'new message' 297 | const wrapper = shallowMount(HelloWorld, { 298 | propsData: { msg } 299 | }) 300 | expect(wrapper.text()).toMatch(msg) 301 | }) 302 | }) 303 | ``` 304 | 305 | 在撰写测试用例之前,我们需要进行一些必要的改造。 306 | 307 | 首先,我们需要把`tests`目录改造成下面这样: 308 | ```sh 309 | |-- tests 310 | | |-- unit 311 | | | |-- components # 组件测试目录 312 | | | |-- utils # 工具函数测试目录 313 | ``` 314 | 315 | 随后,需要我们进行`Jest`配置,根目录下`jest.config.js`: 316 | ```js 317 | module.exports = { 318 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 319 | // 别名配置 320 | moduleNameMapper: { 321 | '^@/(.*)$': '/src/$1' 322 | }, 323 | // 组件快照 324 | snapshotSerializers: ['jest-serializer-vue'], 325 | // 测试文件 326 | testMatch: [ 327 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 328 | ], 329 | // 测试覆盖率相关 330 | collectCoverageFrom: [ 331 | 'src/components/**/*.vue', 332 | 'src/utils/**/*.ts', 333 | '!src/utils/axios.ts' 334 | ], 335 | collectCoverage: true, 336 | coverageDirectory: '/tests/unit/coverage' 337 | } 338 | ``` 339 | 紧接着删除`example.spec.ts`文件,并新建`tab.spec.ts`,我们`tab`组件为例,其完整代码如下: 340 | ```vue 341 | 359 | 372 | ``` 373 | 根据`@/components/tab/index.vue`的代码,我们在`tab.spec.ts`撰写如下测试代码: 374 | ```ts 375 | import { shallowMount, Wrapper } from '@vue/test-utils' 376 | import { tabList } from '@/assets/js/data' 377 | import Tab from '@/components/tab/index.vue' 378 | 379 | describe('tab.vue', () => { 380 | let wrapper: Wrapper 381 | beforeEach(() => { 382 | wrapper = shallowMount(Tab, { 383 | propsData: { 384 | list: tabList 385 | }, 386 | stubs: ['router-link'] 387 | }) 388 | }) 389 | it('match snapshot', () => { 390 | expect(wrapper).toMatchSnapshot() 391 | }) 392 | it('passed props.list', () => { 393 | expect(wrapper.props('list').length).toBe(tabList.length) 394 | }) 395 | it('default activeIndex', () => { 396 | expect(wrapper.vm.$data.activeIndex).toBe(0) 397 | }) 398 | it('no passed props.list', () => { 399 | wrapper = shallowMount(Tab, { 400 | stubs: ['router-link'] 401 | }) 402 | expect(wrapper.props('list').length).toBe(0) 403 | }) 404 | it('render success props.list', () => { 405 | const tabItems = wrapper.findAll('.tab-item') 406 | expect(tabItems.length).toBe(tabList.length) 407 | }) 408 | it('change index after click', () => { 409 | wrapper.findAll('.tab-item').at(1).trigger('click') 410 | expect(wrapper.vm.$data.activeIndex).toBe(1) 411 | }) 412 | }) 413 | ``` 414 | 415 | 测试代码说明: 416 | * `beforeEach`:会在每一个`it`测试用例运行开始之前执行,上面的代码的意思是:在每一个测试用例开始之前去挂载组件,一方面避免多个测试用例互相影响,另一方面节省代码量,不需要再在每个测试用例去重复挂载组件。 417 | * `shallowMount`:浅渲染,还有一个`mount`。二者的区别是,`shallowMount`不会去渲染子组件,如果有子组件的话仅仅只是在父组件中占位,不影响父组件的功能。显而易见,如果我们要单独测试某一个组件,而不希望受其子组件影响的话,就需要使用`shallowMount`去挂载组件。 418 | * `stubs`:意思是存根,如果我们的组件中存在`router-link`或者`router-view`,则需要在挂载组件的时候手动撰写对应的存根,否则会报错。 419 | * `toMatchSnapshot`:组件快照,使用快照后既意味着将当前组件的UI快照保存起来,如果下一次`tab`组件改动了,那么两次的快照会不匹配,从而导致`match snapshot`测试用例不通过。如果你是误改动,可以依据此提示信息进行还原,如果确认是最新的改动可以更新快照,更新后测试用例通过。 420 | 421 | **注意**:如果我们的`Prop`传递的字段是对象或者数组,而我们又提供了其`default`,根据`Vue`规定`default`必须为一个函数: 422 | ```js 423 | // list default 424 | default () { 425 | return [] 426 | } 427 | ``` 428 | 而在`Jest`中,这属于一个`Function`,如果我们没有测试到这个函数:既本例中不传递`list`参数,它会影响最后的`% Funcs`覆盖率,因此`it('no passed props.list')`测试用例不能少。` 429 | 430 | 431 | `VSCode`插件,在使用`VSCode`编辑器撰写`Jest`测试用例的时候,我们可以按照`Jest`插件。安装完毕后,它可以在我们不运行`npm run test:unit`的情况就提示测试用例是否通过。 432 | ![Jest插件](https://user-gold-cdn.xitu.io/2020/6/29/172feefae987bdcc?w=1192&h=642&f=png&s=105692) 433 | 434 | 最后,我们使用如下命令去运行测试用例: 435 | ```sh 436 | # 运行测试用例 437 | $ npm run test:unit 438 | ``` 439 | 由于我们配置了测试覆盖率,运行此命令后你可以在终端上看到类似下面的输出结果: 440 | ![测试覆盖率](https://user-gold-cdn.xitu.io/2020/6/29/172fef339db3536b?w=1177&h=1014&f=png&s=166491) 441 | 442 | ## TypeScript准备知识 443 | **注意**:此部分只涉及项目中已经使用过的知识,更多内容请阅读其官方文档。 444 | ### 基本类型 445 | ### 字符串字面量 446 | ### 联合类型 447 | ### 接口 448 | ### 函数的类型 449 | ### 类型断言 450 | ### 枚举 451 | ### 类 452 | 453 | 454 | 455 | ## Vue组件的TS写法 456 | **注意**:此部分只涉及项目中已经使用过的知识,更多内容请阅读其官方文档。 457 | ### 生命周期 458 | 生命周期的写法非常简单,唯一一点值得注意的地方就是需要在生命周期函数前面添加`private`修饰符,将其生命周期标记为私有的,其它修饰符还有`public`和`protected`: 459 | ```ts 460 | import { Component, Vue } from 'vue-property-decorator' 461 | @Component 462 | export default class HelloWorld extends Vue { 463 | private created () { 464 | // TDD 465 | } 466 | private mounted () { 467 | // TDD 468 | } 469 | private activated () { 470 | // TDD 471 | } 472 | } 473 | ``` 474 | ### 响应式变量和普遍变量 475 | 在不使用`TS`写`Vue`组件之前,我们可以把不需要响应式的变量直接挂在到`this`上,而不是全部都写在`data`上: 476 | ```ts 477 | export default { 478 | data () { 479 | return { 480 | // name 和 age为响应式变量,其值更新后会触发视图更新 481 | name: 'AAA', 482 | age: 21 483 | } 484 | }, 485 | created () { 486 | // addres为普通变量,其值改变不会触发视图更新 487 | this.address = '广东省' 488 | }, 489 | mounted () { 490 | console.log(this.address) // 广东省 491 | } 492 | } 493 | ``` 494 | 495 | 在使用`TS`写`Vue`组件时,如果我们为其提供一个默认值那么他会被定义为响应式变量,相反不提供默认值则为非响应式变量: 496 | ```ts 497 | import { Component, Vue } from 'vue-property-decorator' 498 | @Component 499 | export default class HelloWorld extends Vue { 500 | private name = 'AAA' 501 | private age = 21 502 | private address!: string 503 | 504 | private created () { 505 | this.address = '广东省' 506 | } 507 | private mounted () { 508 | console.log(this.address) // 广东省 509 | } 510 | } 511 | ``` 512 | 如果你使用`devtools`观察`HelloWorld.vue`组件的话,它的`data`里面只有`name`和`age`两个变量,这是十分符合我们的预期的。 513 | ### Prop和PropSync 514 | 在使用`TS`写`Vue`组件时,我们使用`Prop`和`PropSync`来分别处理不带`.sync`和带`.sync`的参数: 515 | ```ts 516 | import { Component, Prop, PropSync, Vue } from 'vue-property-decorator' 517 | @Component 518 | export default class Recommend extends Vue { 519 | @Prop({ type: String, default: '' }) name!: string 520 | @Prop({ type: Number, default: 0 }) age!: number 521 | @PropSync('active', { type: Number, default: 0 }) activeIndex!: number 522 | } 523 | ``` 524 | 以上代码用相当于: 525 | ```js 526 | export default { 527 | props: { 528 | name: { 529 | type: String, 530 | default: '' 531 | }, 532 | age: { 533 | type: Number, 534 | default: 0 535 | }, 536 | active: { 537 | type: Number, 538 | default: 0 539 | } 540 | }, 541 | computed: { 542 | activeIndex: { 543 | get () { 544 | return this.active 545 | }, 546 | set (val) { 547 | this.$emit('update:active', val) 548 | } 549 | } 550 | } 551 | } 552 | ``` 553 | ### 组件注册 554 | `@Component`可以接受参数,我们可以在其中传入`Components`,我们以`App.vue`为例: 555 | ```ts 556 | import MHeader from '@/components/header/index.vue' 557 | import MTab from '@/components/tab/index.vue' 558 | import Player from '@/components/player/index.vue' 559 | import { Component, Vue } from 'vue-property-decorator' 560 | @Component({ 561 | components: { 562 | MHeader, 563 | MTab, 564 | Player 565 | } 566 | }) 567 | export default class App extends Vue { 568 | } 569 | ``` 570 | ### 计算属性 571 | 如果你对`class`有一点了解的话,那么你会非常容易理解计算属性的写法,这里以`@/components/player/index.vue`组件代码为例: 572 | ```ts 573 | import { Component, Vue } from 'vue-property-decorator' 574 | @Component 575 | export default class MPlayer extends Vue { 576 | private get percent () { 577 | return this.currentTime / this.currentSong.duration 578 | } 579 | private set percent (percent: number) { 580 | this.currentTime = percent * this.currentSong.duration 581 | this.currentLyric && this.currentLyric.seek(this.currentTime * 1000) 582 | } 583 | 584 | private get playIcon () { 585 | return this.playing ? 'icon-pause' : 'icon-play' 586 | } 587 | private get miniPlayIcon () { 588 | return this.playing ? 'icon-pause-mini' : 'icon-play-mini' 589 | } 590 | } 591 | ``` 592 | 根据以上代码我们可以发现: 593 | * `percent`、`playIcon`和`miniPlayIcon`都定义了属性的`get`方法,当访问属性的时候触发此属性的`get`方法,然后返回此方法的返回值。 594 | * `percent`除了`get`,还定义了`set`。一般而言我们不用去定义计算属性的`set`,上面代码定义`set`是为了处理其他业务逻辑。其中`set`里面的逻辑完全可以使用`Watch('percent')`去改写: 595 | ```js 596 | import { Component, Vue, Watch } from 'vue-property-decorator' 597 | @Component 598 | export default class MPlayer extends Vue { 599 | @Watch('percent') 600 | onPercentChange (percent: number) { 601 | this.currentTime = percent * this.currentSong.duration 602 | this.currentLyric && this.currentLyric.seek(this.currentTime * 1000) 603 | } 604 | private get percent () { 605 | return this.currentTime / this.currentSong.duration 606 | } 607 | private get playIcon () { 608 | return this.playing ? 'icon-pause' : 'icon-play' 609 | } 610 | private get miniPlayIcon () { 611 | return this.playing ? 'icon-pause-mini' : 'icon-play-mini' 612 | } 613 | } 614 | ``` 615 | ### 获取Getters和属性监听 616 | 可以使用`@Watch`来监听某一个变量,这里依然以`@/components/player/index.vue`组件代码为例: 617 | ```ts 618 | import { Component, Vue, Watch } from 'vue-property-decorator' 619 | import { Getter} from 'vuex-class' 620 | @Component 621 | export default class MPlayer extends Vue { 622 | @Getter('playing') playing!: boolean 623 | @Watch('playing') 624 | onPlayingChange (playing: boolean) { 625 | playing ? this.audio.play() : this.audio.pause() 626 | } 627 | } 628 | ``` 629 | 以上代码相当于: 630 | ```js 631 | import { mapGetters } from 'vuex' 632 | export default { 633 | computed: { 634 | ...mapGetters(['playing']), 635 | }, 636 | watch: { 637 | playing (playing) { 638 | playing ? this.audio.play() : this.audio.pause() 639 | } 640 | } 641 | } 642 | ``` 643 | ### 获取Mutations和Actions 644 | 获取`Mutation`和`Action`与获取`Getters`形式类似,以`@/components/player/index.vue`组件代码为例: 645 | ```ts 646 | import { Component, Vue, Watch } from 'vue-property-decorator' 647 | import { Mutation, Action} from 'vuex-class' 648 | @Component 649 | export default class MPlayer extends Vue { 650 | @Mutation('player/SET_FULL_SCREEN') setFullScreen!: (fullscreen: boolean) => void 651 | @Mutation('player/SET_CURRENT_INDEX') setCurrentIndex!: (index: number) => void 652 | @Action('history/setPlayHistory') setPlayHistory!: (song: Song) => void 653 | } 654 | ``` 655 | 以上代码相当于: 656 | ```js 657 | import { mapMutations, mapActions } from 'vuex' 658 | export default { 659 | methods: { 660 | ...mapMutations({ 661 | setFullScreen: 'player/SET_FULL_SCREEN', 662 | setCurrentIndex: 'player/SET_CURRENT_INDEX' 663 | }) 664 | ...mapActions({ 665 | setPlayHistory: 'history/setPlayHistory' 666 | }) 667 | } 668 | } 669 | ``` 670 | ### 组件Mixin 671 | 组件`mixin`有2种方式,第一种可以在`@Component`里面传递`mixins`参数,第二种可以使用`Mixins`装饰器,然后让组件去`extends`,以`@views/search/index.vue`代码为例: 672 | ```ts 673 | import Search from '@/assets/js/search' 674 | import PlayList from '@/assets/js/playList' 675 | import { Component, Mixins, Vue } from 'vue-property-decorator' 676 | @Component({ 677 | mixins: [Search, PlayList] 678 | }) 679 | export default class MSearch extends Vue { 680 | } 681 | // 省略其它代码 682 | ``` 683 | **注意**:这种`mixin`方式,可以正常运行,但是`TypeScript`可能会提示找不到`mixins`模块里面的某个方法或者属性,我们更推荐使用第二种方式。 684 | 685 | ```ts 686 | import Search from '@/assets/js/search' 687 | import PlayList from '@/assets/js/playList' 688 | import { Component, Mixins } from 'vue-property-decorator' 689 | @Component 690 | export default class MSearch extends Mixins(Search, PlayList) { 691 | // 省略其它代码 692 | } 693 | ``` 694 | ### Ref类型 695 | `vue-property-decorator`提供了`Ref`装饰器,我们可以使用这个装饰器来获取`refs`,这里以`src/views/search/index.vue`组件为例: 696 | ```js 697 | import { Component, Mixins, Ref } from 'vue-property-decorator' 698 | @Component 699 | export default class MSearch extends Mixins(Search, PlayList) { 700 | @Ref('search') readonly searchRef!: HTMLElement 701 | @Ref('searchBox') readonly searchBoxRef!: SearchBox 702 | @Ref('searchScroll') readonly searchScrollRef!: Scroll 703 | @Ref('searchSuggestion') readonly searchSuggestionRef!: Suggestion 704 | @Ref('searchSuggestionBox') readonly searchSuggestionBoxRef!: HTMLElement 705 | 706 | // 省略其它 707 | } 708 | ``` 709 | 以上代码相当于: 710 | ```js 711 | export default { 712 | mounted () { 713 | this.searchRef = this.$refs.search 714 | this.searchBoxRef = this.$refs.searchBox 715 | this.searchScrollRef = this.$refs.searchScroll 716 | this.searchSuggestionRef = this.$refs.searchSuggestion 717 | this.searchSuggestionBoxRef = this.$refs.searchSuggestionBox 718 | } 719 | // 省略其它 720 | } 721 | ``` 722 | 当然如果你的组件及其简单,不用担心命名冲突问题的话,可以不给`Ref`传递参数,例如: 723 | ```js 724 | import { Component, Ref, Vue } from 'vue-property-decorator' 725 | @Component 726 | export default class Search extends Vue { 727 | @Ref() readonly button!: HTMLButtonElement 728 | // 省略其它 729 | private mounted () { 730 | console.log(this.button) 731 | } 732 | } 733 | ``` 734 | 735 | 736 | 737 | ## Jest和Vue-Test-Utils准备知识 738 | **注意**:此部分只涉及项目中已经使用过的知识,更多内容请阅读其官方文档。 739 | 740 | ## 重难点功能分析 741 | ### 推荐页面 742 | ### 歌手页面 743 | ### 搜索页面 744 | ### 播放器页面 745 | ### 个人中心页面 746 | 747 | --------------------------------------------------------------------------------