├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── alias.config.js ├── env.d.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ ├── base.css │ ├── css │ │ ├── common.css │ │ └── transition.css │ ├── img │ │ └── play-bar.png │ ├── js │ │ ├── constant.ts │ │ ├── formateLocaltion.js │ │ ├── location.json │ │ └── region.json │ └── template.vue ├── components │ ├── Base │ │ ├── BaseEmpty.vue │ │ ├── CategoryTab.vue │ │ ├── ChangeTheme.vue │ │ ├── ListLoading.vue │ │ ├── LoadImg.vue │ │ ├── PlayIcon.vue │ │ ├── SliderBar.vue │ │ └── VideoPlayer.vue │ ├── CommentList │ │ ├── CommentList.vue │ │ └── RepliedCommentModal.vue │ ├── Icon │ │ ├── OrderPlay.vue │ │ ├── RandomIcon.vue │ │ ├── SingleLoop.vue │ │ └── StopIcon.vue │ ├── Layout │ │ ├── Layout.vue │ │ ├── components │ │ │ ├── LayoutHeader.vue │ │ │ ├── LayoutHeaderSearch.vue │ │ │ ├── LayoutLeftMenu.vue │ │ │ └── LoginModal.vue │ │ └── hook │ │ │ └── useHistoryRoutePath.ts │ ├── MvList │ │ ├── MvList.vue │ │ ├── MvListImgItem.vue │ │ └── MvListSkeleton.vue │ ├── Player │ │ ├── FooterPlayer.vue │ │ ├── MusicDetail.vue │ │ ├── MusicLyric.vue │ │ ├── PlayList.vue │ │ ├── RotateCd.vue │ │ └── hook │ │ │ ├── useAudioLoadProgress.ts │ │ │ └── useBlurLineGradient.ts │ ├── SongsList │ │ ├── MusicList.vue │ │ ├── SelectSongListTagModal.vue │ │ ├── SongList.vue │ │ └── SongListSkeleton.vue │ └── common │ │ ├── HeartIcon.vue │ │ ├── PlayAllButton.vue │ │ └── SubscribePlayListModal.vue ├── hook │ ├── useCheckLogin.ts │ ├── useDbClickPlay.ts │ ├── useLazyLoad.ts │ ├── useMemorizeRequest.ts │ ├── useMemoryScrollTop.ts │ ├── useNanoid.ts │ ├── useThemeStyle.ts │ └── useValidateVipSong.ts ├── index.css ├── main.ts ├── router │ └── index.ts ├── service │ ├── album.ts │ ├── index.ts │ ├── login.ts │ ├── mv.ts │ ├── playlist.ts │ ├── request.ts │ ├── search.ts │ ├── songs.ts │ └── user.ts ├── stores │ ├── main.ts │ └── state.ts ├── utils │ ├── arr-map.ts │ ├── getPixelColor.ts │ ├── image.ts │ ├── index.ts │ ├── initIndexDb.ts │ ├── lyric.ts │ ├── markSearhKeyword.ts │ ├── obverser.ts │ └── store.ts ├── views │ ├── home │ │ └── DiscoveryView.vue │ ├── music │ │ └── LatestMusicView.vue │ ├── mv │ │ ├── LatestMvView.vue │ │ └── MvDetail.vue │ ├── search │ │ └── SearchResult.vue │ ├── songList │ │ ├── EditSongList.vue │ │ ├── RecommendSongListView.vue │ │ └── SongListDetail.vue │ └── user │ │ └── UserInfoEdit.vue └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution'); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript/recommended', 10 | 'plugin:vue/vue3-recommended', 11 | 'plugin:tailwindcss/recommended' 12 | ], 13 | env: { 14 | 'vue/setup-compiler-macros': true, 15 | 'browser': true, 16 | 'amd': true, 17 | 'node': true 18 | }, 19 | plugins: ['tailwindcss'], 20 | rules: { 21 | '@typescript-eslint/ban-ts-ignore': 'off', 22 | '@typescript-eslint/no-unused-vars': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | '@typescript-eslint/no-var-requires': 'off', 26 | '@typescript-eslint/no-empty-function': 'off', 27 | '@typescript-eslint/no-use-before-define': 'off', 28 | '@typescript-eslint/ban-ts-comment': 'off', 29 | '@typescript-eslint/ban-types': 'off', 30 | '@typescript-eslint/no-non-null-assertion': 'off', 31 | '@typescript-eslint/explicit-module-boundary-types': 'off', 32 | 'no-var': 'error', 33 | // 使用单引号 34 | quotes: [2, 'single', 'avoid-escape'], 35 | // 使用 === 替代 == 36 | eqeqeq: [2, 'allow-null'], 37 | //强制使用分号结尾 38 | semi: ['error', 'always'], 39 | // 强制使用两个空格执行一致的缩进样式 40 | indent: ['error', 2], 41 | //禁止出现多个空格 42 | 'no-multi-spaces': 'error', 43 | // 禁止多行字符串 44 | 'no-multi-str': 'error', 45 | //要求使用一致的 return 语句 46 | 'consistent-return': 'error', 47 | //在数组开括号后和闭括号前强制换行 48 | 'array-bracket-newline': [ 49 | 'error', { 50 | 'minItems': 4, 51 | multiline: true 52 | } 53 | ], 54 | // 大括号后必须换行 55 | 'brace-style': 'error', 56 | // 强制使用骆驼拼写法命名约定 57 | 'camelcase': 'error', 58 | // 不允许多个空行 59 | 'no-multiple-empty-lines': 'error', 60 | // 禁止或强制在代码块中开括号前和闭括号后有空格 61 | 'block-spacing': 'error', 62 | // 强制在对象字面量的键和值之间使用一致的空格 63 | 'key-spacing': ['error', { 'mode': 'strict' }], 64 | // 强制在函数括号内使用一致的换行, 函数参数超过2个换行 65 | 'function-paren-newline': ['error', { 'minItems': 3 }], 66 | // 强制回调函数最大嵌套深度 67 | 'max-nested-callbacks': ['warn', 3], 68 | // 强制关键字周围空格的一致性 关键字前后必须有空格 69 | 'keyword-spacing': [ 70 | 'error', { 71 | 'before': true, 72 | 'after': true 73 | } 74 | ], 75 | // 强制分号之后有空格,禁止分号之前有空格。 76 | 'semi-spacing': 'error', 77 | //强制在块之前使用一致的空格 78 | 'space-before-blocks': 'error', 79 | //强制在花括号中使用一致的空格 80 | 'object-curly-spacing': ['error', 'always'], 81 | // 强制在圆括号内使用一致的空格 82 | 'space-in-parens': 'error', 83 | // 强制在一元操作符前后使用一致的空格 84 | 'space-unary-ops': 'error', 85 | 'arrow-spacing': 'error', 86 | // 强制在逗号周围使用空格 87 | 'comma-spacing': [ 88 | 'error', { 89 | 'before': false, 90 | 'after': true 91 | } 92 | ], 93 | // 'object-property-newline': 'error', 94 | // 禁止多余的 return 语句 95 | 'no-useless-return': 'error', 96 | // 强制可嵌套的块的最大深度4 97 | 'max-depth': 'warn', 98 | // 要求方法链中每个调用都有一个换行符 99 | 'newline-per-chained-call': ['warn', { ignoreChainWithDepth: 2 }], 100 | 'tailwindcss/no-custom-classname': ['off'], 101 | // 禁止使用拖尾逗号 102 | 'comma-dangle': ['error', 'never'], 103 | 'vue/max-attributes-per-line': [ 104 | 'error', { 105 | 'singleline': { 'max': 3 }, 106 | 'multiline': { 'max': 3 } 107 | } 108 | ], 109 | 'multiline-ternary': ['error', 'always'], 110 | 'object-curly-newline': ['error', { 'multiline': true }] 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | components.d.ts 30 | yarn.lock -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .eslintrc.cjs 4 | pnpm-lock.yaml 5 | coverage -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "flow", 3 | "tabWidth": 2, 4 | // 使用单引号 5 | "singleQuote": true, 6 | // 强制使用分号结尾 7 | "semi": true 8 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", // Vue 3 Language Features 4 | "MisterJ.vue-volar-extention-pack" // Vue Volar extension pack (includes many extensions) 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "camelcase", 4 | "classname", 5 | "discovr", 6 | "eqeqeq", 7 | "evnet", 8 | "followeds", 9 | "highquality", 10 | "ionicons", 11 | "mvid", 12 | "obverser", 13 | "pinia", 14 | "popconfirm", 15 | "qrimg", 16 | "realkeyword", 17 | "rushstack", 18 | "sider", 19 | "Simi", 20 | "tailwindcss", 21 | "tlyric", 22 | "unikey", 23 | "unplugin", 24 | "vicons", 25 | "Videocam", 26 | "vite", 27 | "vitest", 28 | "vueuse" 29 | ], 30 | // // 保存时自动启用 eslint --fix 自动修复 31 | "editor.codeActionsOnSave": { 32 | "source.fixAll": "explicit" 33 | }, 34 | "files.associations": { 35 | "*.json": "jsonc", 36 | ".prettierrc": "jsonc" 37 | }, 38 | //disable the lint rule of unknownAtRules. 39 | "css.lint.unknownAtRules": "ignore", 40 | "scss.lint.unknownAtRules": "ignore", 41 | // 不折叠文件夹 42 | "explorer.compactFolders": false, 43 | "typescript.tsdk": "node_modules/typescript/lib", 44 | "vue.codeActions.enabled": false 45 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloud-music 2 | 这是一个主要基于Vue3+TypeScript+Vite构建的高仿网易云pc端的音乐流媒体网站. 3 | 4 | ### 前言 5 | 6 | 为了探索最前沿的前端技术栈, 一切为了好玩, 肝代码不易,觉得喜欢的可以点个star,本项目仅供参考学习. 7 | 谢谢大家star. 8 | 9 | ### 常见问题 10 | 由于网易云服务器限制,加上ffmpeg需要设置COOP和COEP 导致获取媒体资源和图片会存在跨域问题, 可以使用Allow CORS: Access-Control-Allow-Origin 这个插件来解决, 使用项目部署在vercel,导致需要使用vpn访问, 导致图片和音频可能无法加载, 在clash添加一条规则 将music.126.net, 类型选择DOMAIN-KEYWORD 域名设置成直连. 11 | ### 项目部署 12 | 13 | 本项目预览地址采用vercel部署, 推荐大家使用vercel部署, 部署步骤非常简单, fork本仓库,在vercel中导入该仓库,按照提示点击部署即可. 14 | 15 | 16 | ### 主要技术栈如下 17 | 18 | 1. [Vue3](https://vuejs.org/): 用于快速构建web用户界面的框架. 19 | 20 | 2. [naive-ui](https://www.naiveui.com/zh-CN/light): 基于Vue3的组件库,可自定义主题,支持夜间模式. 21 | 22 | 3. [Vite](https://vitejs.dev/): 下一代的前端构建工具,支持HRM,TypeScript,JSX,开箱即用. 23 | 24 | 4. [axios](https://axios-http.com/): 是用于浏览器和node.js的基于Promise的http库,用于请求Api. 25 | 26 | 5. [dayjs](https://day.js.org/): 轻量级,用于处理时间相关格式化问题的库. 27 | 28 | 6. [color.js](https://www.npmjs.com/package/color.js): 用于不可变颜色的转换和操作的JavaScript库. 29 | 30 | 7. [eslint](https://eslint.org/): 基于AST模式的JavaScript代码风格检查工具. 31 | 32 | 8. [lodash](https://lodash.com/docs/): 实用JavaScript工具函数库,提供了类似防抖节流,深拷贝开箱即用的函数. 33 | 34 | 9. [nanoid](https://github.com/ai/nanoid/blob/main/README.zh-CN.md): 小型的安全的,用于生成唯一字符串id的JavaScript库. 35 | 36 | 10. [normalize.css](https://github.com/necolas/normalize.css): 一个用于现代化的重置css的库,用于磨平各个浏览器之间不同样式的兼容问题. 37 | 38 | 11. [pinia](https://pinia.vuejs.org/): 基于Proxy使用TypeScript编写的灵活的且类型安全的下一代Vue状态管理库. 39 | 40 | 12. [qs](https://www.npmjs.com/package/qs): 安全的用于字符串序列化的库,通常用于处理请求参数. 41 | 42 | 13. [colorthief](https://www.npmjs.com/package/colorthief): 可以在浏览器和node中使用,用于从图像中提取主色. 43 | 44 | 14. [vue-router4](https://router.vuejs.org/zh/introduction.html): 基于Vue3路由管理库 45 | 46 | 15. [vue-virtual-scroller](https://github.com/Akryum/vue-virtual-scroller): 基于Vue的虚拟滚动JavaScript库. 47 | 48 | 16. [xgplayer](https://v2.h5player.bytedance.com/): 西瓜播放器(HTML5),带解析器,能节省流量 提供开箱即用的HTML5视频播放器库 49 | 50 | 17. [tailwindcss](https://tailwindcss.com/): 基于原子化css的实用程序优先的css框架,用于快速构建自定义用户界面. 51 | 52 | ## 功能亮点如下 53 | 54 | 18. 基于图片主色混入的canvas渐变背景 55 | 56 | 19. 歌曲随机,顺序,单曲循环播放.切换播放上一首,下一首. 57 | 58 | 20. 自定义封装歌曲进度条,可点击拖拽切换到指定播放时间, 添加buffer加载进度显示. 59 | 60 | 21. 歌词自动同步滚动高亮,滚动选择歌词切换播放时间, 歌词逐字播放. 61 | 62 | 22. 歌词底部头部基于当前所在滚动位置背景色添加虚化渐变遮罩. 63 | 64 | 23. 歌曲播放或暂停时cd旋转或停止旋转, 高度拟真cd. 65 | 66 | 24. 兼容夜间模式,骨架屏加载显示. 67 | 68 | 25. 歌单内部搜索歌曲,收藏歌单,编辑歌单,新建歌单. 69 | 70 | 26. 添加喜欢的音乐,获取我创建的歌单和收藏的歌单. 71 | 72 | 27. 歌单列表下拉加载. 73 | 74 | 28. 自定义图片懒加载,缓冲loading显示. 75 | 76 | 29. 扫码登录,编辑用户信息,签到.退出登录. 77 | 78 | 30. 记忆当前滚动位置.刷新页面时更新滚动位置. 79 | 80 | 31. 搜索歌曲或歌单,提供搜索建议.搜索历史记录缓存. 81 | 82 | 32. mv列表,播放mv 83 | 84 | 33. 评论列表,点击评论,回复评论,添加评论.发表评论 85 | 86 | 34. 基于ffmpeg-wasm和IndexedDB缓存压缩歌曲数据到本地,实现离线快速播放. 87 | 88 | ## 项目预览 89 | ![](https://upload-images.jianshu.io/upload_images/24914540-3610a62506e089c7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 90 | ![](https://upload-images.jianshu.io/upload_images/24914540-f75bd93f493a0630.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 91 | ![](https://upload-images.jianshu.io/upload_images/24914540-3037f3b2061af382.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 92 | ![](https://upload-images.jianshu.io/upload_images/24914540-33da12dc895b574a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 93 | ![](https://upload-images.jianshu.io/upload_images/24914540-282aa2c065314f7a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 94 | 95 | ![](https://upload-images.jianshu.io/upload_images/24914540-6e666b06fa902d43.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 96 | 97 | ![](https://upload-images.jianshu.io/upload_images/24914540-1d77d01057781aaf.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 98 | ![](https://upload-images.jianshu.io/upload_images/20032554-89d3aae105f66885.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 99 | 100 | 101 | ![](https://upload-images.jianshu.io/upload_images/20032554-e5898c2b509dabea.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 102 | 103 | ## 项目运行 104 | 105 | 推荐使用pnpm安装依赖,推荐node版本14.18+, 16+。 106 | 建议使用nvm管理不同node版本,nvm安装前建议卸载当前已经安装的node。 107 | 108 | ### nvm仓库 109 | 110 | 在首页点击Releases进入版本页面,下载最新版本即可 111 | https://github.com/coreybutler/nvm-windows 112 | 113 | ### pnpm 安装 114 | 115 | ```shell 116 | npm i pnpm -g 117 | ``` 118 | 119 | ### 依赖安装 120 | 121 | ```shell 122 | pnpm i 123 | ``` 124 | 125 | ### 运行 126 | 127 | ```shell 128 | pnpm dev 129 | ``` 130 | 131 | ### 构建 132 | 133 | ```shell 134 | pnpm build 135 | ``` 136 | 137 | ### 预览 138 | 139 | ```shell 140 | pnpm preview 141 | ``` 142 | -------------------------------------------------------------------------------- /alias.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { resolve: { alias: { '@': path.resolve(__dirname, 'src') } } }; -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | import type { MessageApiInjection } from 'naive-ui/lib/message/src/MessageProvider'; 2 | /// 3 | // naive-ui组件库全局类型声明 给编辑器提供更好的类型提示 4 | export * from 'naive-ui/volar'; 5 | export { }; 6 | 7 | type localValue = 'zh-cn' | 'en'; 8 | declare global { 9 | interface Window { 10 | $message: MessageApiInjection; 11 | } 12 | interface ImportMeta { 13 | env: Record 14 | } 15 | } 16 | declare module 'vue' { 17 | export interface Window { 18 | $message: MessageApiInjection; 19 | } 20 | } 21 | // 任意键值对对象类型 22 | export type AnyObject = { 23 | [key: string]: any; 24 | }; 25 | 26 | declare module 'rgbaster' { 27 | interface Opts { 28 | ignore?: string[]; 29 | scale?: number; 30 | skipTransparentPixels?: boolean; 31 | } 32 | export default function (src: string, opts?: Opts): Promise<{ 33 | color: string; 34 | count: number; 35 | }[]>; 36 | export { }; 37 | }; 38 | // audio data 39 | export type AudioIndexedData = { 40 | id: number; 41 | name: string; 42 | blob:Blob; 43 | } 44 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 奇妙音乐屋 12 | 135 | 136 | 137 | 138 |
139 |
140 |
141 | 142 | 144 | 145 | 146 | 加载中... 147 |
148 |
149 |
150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloud-music-vue3", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vue-tsc --noEmit && vite build", 7 | "preview": "vite preview --port 5050", 8 | "test:unit": "vitest --environment jsdom", 9 | "typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", 10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 11 | }, 12 | "dependencies": { 13 | "@ffmpeg/ffmpeg": "^0.12.10", 14 | "@ffmpeg/util": "^0.12.1", 15 | "@vueuse/core": "^11.2.0", 16 | "axios": "^1.7.7", 17 | "color": "^4.2.3", 18 | "colorthief": "^2.6.0", 19 | "dayjs": "^1.11.13", 20 | "eslint-plugin-tailwindcss": "^3.17.5", 21 | "express": "^4.21.1", 22 | "lodash": "^4.17.21", 23 | "nanoid": "^5.0.8", 24 | "normalize.css": "^8.0.1", 25 | "pinia": "^2.2.6", 26 | "qs": "^6.13.0", 27 | "unplugin-vue-components": "^0.27.4", 28 | "vue": "^3.5.13", 29 | "vue-router": "^4.4.5", 30 | "vue-virtual-scroller": "2.0.0-beta.8", 31 | "xgplayer": "^3.0.20" 32 | }, 33 | "devDependencies": { 34 | "@rushstack/eslint-patch": "^1.10.4", 35 | "@types/color": "^4.2.0", 36 | "@types/jsdom": "^21.1.7", 37 | "@types/node": "^22.9.0", 38 | "@types/qs": "^6.9.17", 39 | "@vicons/antd": "^0.12.0", 40 | "@vicons/carbon": "^0.12.0", 41 | "@vicons/ionicons5": "^0.12.0", 42 | "@vicons/material": "^0.12.0", 43 | "@vitejs/plugin-vue": "^5.2.0", 44 | "@vitejs/plugin-vue-jsx": "^4.1.0", 45 | "@vue/eslint-config-prettier": "^10.1.0", 46 | "@vue/eslint-config-typescript": "^14.1.3", 47 | "@vue/test-utils": "^2.4.6", 48 | "@vue/tsconfig": "^0.6.0", 49 | "autoprefixer": "^10.4.20", 50 | "eslint": "^9.15.0", 51 | "eslint-plugin-vue": "^9.31.0", 52 | "jsdom": "^25.0.1", 53 | "less": "^4.2.0", 54 | "naive-ui": "^2.40.1", 55 | "postcss": "^8.4.49", 56 | "prettier": "^3.3.3", 57 | "sass": "^1.81.0", 58 | "tailwindcss": "^3.4.15", 59 | "typescript": "~5.6.3", 60 | "vfonts": "^0.0.3", 61 | "vite": "^5.4.11", 62 | "vite-plugin-compression": "^0.5.1", 63 | "vitest": "^2.1.5", 64 | "vue-tsc": "^2.1.10" 65 | }, 66 | "type":"module", 67 | "repository": { 68 | "type": "git", 69 | "url": "https://github.com/path-yu/vue3-cloud-music" 70 | }, 71 | "homepage": "https://cloud-music-eight-nu.vercel.app/#/discovery" 72 | } 73 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/path-yu/vue3-cloud-music/20a73d8fadd7488c28b3cc991cd933f7014c764b/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 29 | 30 | 45 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-color: #ffffff; 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/css/common.css: -------------------------------------------------------------------------------- 1 | .no-select { 2 | -webkit-user-select: none; 3 | -moz-user-select: none; 4 | -ms-user-select: none; 5 | user-select: none; 6 | } 7 | 8 | .position-center { 9 | top: 0; 10 | left: 0; 11 | right: 0; 12 | bottom: 0; 13 | margin: auto; 14 | } 15 | 16 | .card-mask { 17 | border-bottom-right-radius: 8px; 18 | border-bottom-left-radius: 8px; 19 | background-image: linear-gradient(0, 20 | rgba(0, 0, 0, 0) 0%, 21 | rgba(0, 0, 0, 0) 30%, 22 | rgba(0, 0, 0, 0.6) 100%); 23 | } 24 | 25 | .bg-linear-mask { 26 | border-bottom-right-radius: 8px; 27 | border-bottom-left-radius: 8px; 28 | background-image: linear-gradient(180deg, 29 | rgba(0, 0, 0, 0) 0%, 30 | rgba(0, 0, 0, 0.8) 100%); 31 | } -------------------------------------------------------------------------------- /src/assets/css/transition.css: -------------------------------------------------------------------------------- 1 | /* global transition css */ 2 | 3 | /* fade */ 4 | .fade-enter-active, 5 | .fade-leave-active { 6 | transition: opacity 0.6s ease; 7 | } 8 | 9 | .fade-enter-from, 10 | .fade-leave-to { 11 | opacity: 0; 12 | } 13 | 14 | /* fade-transform */ 15 | .fade-transform-leave-active, 16 | .fade-transform-enter-active { 17 | transition: all .5s; 18 | } 19 | 20 | .fade-transform-enter { 21 | opacity: 0; 22 | transform: translateX(-30px); 23 | } 24 | 25 | .fade-transform-leave-to { 26 | opacity: 0; 27 | transform: translateX(30px); 28 | } 29 | 30 | /* breadcrumb transition */ 31 | .breadcrumb-enter-active, 32 | .breadcrumb-leave-active { 33 | transition: all .5s; 34 | } 35 | 36 | .breadcrumb-enter, 37 | .breadcrumb-leave-active { 38 | opacity: 0; 39 | transform: translateX(20px); 40 | } 41 | 42 | .breadcrumb-move { 43 | transition: all .5s; 44 | } 45 | 46 | .breadcrumb-leave-active { 47 | position: absolute; 48 | } 49 | /* 从底部弹出渐变 */ 50 | .slide-enter-active, 51 | .slide-leave-active { 52 | transition: all cubic-bezier(0.165, 0.84, 0.44, 1) 0.6s; 53 | z-index:30; 54 | } 55 | 56 | .slide-enter-from{ 57 | opacity: 0; 58 | transform: translateY(80px); 59 | } 60 | .slide-enter-to{ 61 | opacity: 1; 62 | transform: translateY(0px); 63 | } 64 | .slide-leave-to { 65 | opacity: 0; 66 | transform: translateY(80px); 67 | } 68 | .scale-enter-active, 69 | .scale-leave-active { 70 | transition: all ease-in-out .3s; 71 | } 72 | .scale-enter-to{ 73 | opacity: 1; 74 | transform: scale(1.2); 75 | } 76 | .scale-leave-to{ 77 | opacity: 0; 78 | transform: scale(1); 79 | } 80 | /* */ 81 | .fade-in-scale-up-enter-active { 82 | transition: all cubic-bezier(.4, 0, .2, 1) .4s; 83 | } 84 | .fade-in-scale-up-leave-active{ 85 | transition: all cubic-bezier(0, 0, .2, 1) .4s; 86 | } 87 | .fade-in-scale-up-enter-from,.fade-in-scale-up-leave-to{ 88 | opacity: 0; 89 | transform: background-color .3s var(--n-bezier), box-shadow .3s var(--n-bezier) scale(0.9); 90 | } 91 | .fade-in-scale-up-leave-from,.fade-in-scale-up-enter-to{ 92 | opacity: 1; 93 | transform: background-color .3s var(--n-bezier), box-shadow .3s var(--n-bezier) scale(1); 94 | } -------------------------------------------------------------------------------- /src/assets/img/play-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/path-yu/vue3-cloud-music/20a73d8fadd7488c28b3cc991cd933f7014c764b/src/assets/img/play-bar.png -------------------------------------------------------------------------------- /src/assets/js/constant.ts: -------------------------------------------------------------------------------- 1 | const LOCALE = '__LOCALE__'; 2 | 3 | export const CONSTANT = { LOCALE }; -------------------------------------------------------------------------------- /src/assets/js/formateLocaltion.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | fs.readFile(path.resolve(__dirname, './location.json'), (err, data) => { 4 | if (err) throw err; 5 | formate(JSON.parse(data)); 6 | }); 7 | 8 | const formate = (locationJson) => { 9 | let arr = []; 10 | for (let key in locationJson) { 11 | let val = locationJson[key]; 12 | if (val.name.indexOf('市') > -1) { 13 | if (!arr[0]) { 14 | arr[0] = { 15 | label: '直辖市', 16 | value: 1, 17 | provinceList: [{ [val.name]: +val.code }], 18 | cityList: [{ label: val.name, value: +getFirstKey(val.cities[key]['districts']) }] 19 | }; 20 | } else { 21 | arr[0].provinceList.push({ [val.name]: +val.code }); 22 | arr[0].cityList.push({ label: val.name, value: +getFirstKey(val.cities[key]['districts']) }); 23 | } 24 | } else { 25 | if (val.name.includes('特别行政区')) { 26 | let item = { 27 | label: '特别行政区', 28 | value: 2, 29 | provinceList: [{ [val.name]: +val.code }], 30 | cityList: [{ label: val.name, value: +getFirstKey(val.cities[key]['districts']) }] 31 | }; 32 | let originTarget = arr.find(item => item.label === '特别行政区'); 33 | if (originTarget) { 34 | originTarget.provinceList.push({ [val.name]: +val.code }); 35 | originTarget.cityList.push({ label: val.name, value: +getFirstKey(val.cities[key]['districts']) }); 36 | } else { 37 | arr.push(item); 38 | } 39 | } else { 40 | let item = { 41 | label: val.name, 42 | value: +key, 43 | cityList: [] 44 | }; 45 | if (item.label.includes('自治区')) { 46 | item.label = item.label.replace(/壮族自治区|回族自治区|维吾尔自治区|自治区/, ''); 47 | } 48 | for (let cityKey in val.cities) { 49 | item.cityList.push({ 50 | label: val.cities[cityKey].name, 51 | value: +cityKey 52 | }); 53 | } 54 | arr.push(item); 55 | } 56 | } 57 | 58 | 59 | } 60 | fs.writeFile( 61 | path.resolve(__dirname, './region.json'), JSON.stringify(arr), (err) => { 62 | if (err) throw err; 63 | console.log('写入成功'); 64 | } 65 | ); 66 | }; 67 | 68 | // 获取对象第一个属性 69 | // eslint-disable-next-line consistent-return 70 | const getFirstKey = (obj) => { 71 | for (let key in obj) { 72 | return key; 73 | } 74 | }; -------------------------------------------------------------------------------- /src/assets/template.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /src/components/Base/BaseEmpty.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 25 | -------------------------------------------------------------------------------- /src/components/Base/CategoryTab.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | 46 | 55 | -------------------------------------------------------------------------------- /src/components/Base/ChangeTheme.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/Base/ListLoading.vue: -------------------------------------------------------------------------------- 1 | 59 | 74 | 83 | -------------------------------------------------------------------------------- /src/components/Base/LoadImg.vue: -------------------------------------------------------------------------------- 1 | 119 | 125 | -------------------------------------------------------------------------------- /src/components/Base/PlayIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | 25 | 32 | -------------------------------------------------------------------------------- /src/components/Base/SliderBar.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | 129 | 130 | 141 | -------------------------------------------------------------------------------- /src/components/Base/VideoPlayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 65 | 66 | -------------------------------------------------------------------------------- /src/components/CommentList/CommentList.vue: -------------------------------------------------------------------------------- 1 | 60 | -------------------------------------------------------------------------------- /src/components/CommentList/RepliedCommentModal.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | -------------------------------------------------------------------------------- /src/components/Icon/OrderPlay.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/RandomIcon.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/SingleLoop.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/StopIcon.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/Layout/components/LayoutHeader.vue: -------------------------------------------------------------------------------- 1 | 105 | 200 | -------------------------------------------------------------------------------- /src/components/Layout/components/LayoutLeftMenu.vue: -------------------------------------------------------------------------------- 1 | 232 | 256 | 267 | -------------------------------------------------------------------------------- /src/components/Layout/components/LoginModal.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 144 | -------------------------------------------------------------------------------- /src/components/Layout/hook/useHistoryRoutePath.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from 'vue'; 2 | import { useRoute } from 'vue-router'; 3 | 4 | export function userHistory() { 5 | const route = useRoute(); 6 | const backPath = ref(history.state.back); 7 | const forwardPath = ref(history.state.forward); 8 | 9 | watch(() => route.path, () => { 10 | backPath.value = history.state.back; 11 | forwardPath.value = history.state.forward; 12 | }); 13 | 14 | return { 15 | backPath, 16 | forwardPath 17 | }; 18 | } -------------------------------------------------------------------------------- /src/components/MvList/MvList.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 38 | 39 | 48 | -------------------------------------------------------------------------------- /src/components/MvList/MvListImgItem.vue: -------------------------------------------------------------------------------- 1 | 27 | 58 | -------------------------------------------------------------------------------- /src/components/MvList/MvListSkeleton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Player/PlayList.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 100 | 101 | 131 | -------------------------------------------------------------------------------- /src/components/Player/RotateCd.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 65 | 66 | 201 | -------------------------------------------------------------------------------- /src/components/Player/hook/useAudioLoadProgress.ts: -------------------------------------------------------------------------------- 1 | import { ref, type Ref } from 'vue'; 2 | 3 | export function useAudioLoadProgress(audio:Ref, duration:number) { 4 | const progressValue = ref(0); 5 | 6 | const updateBuffer = () => { 7 | if (!audio.value) return; 8 | const buffered = audio.value!.buffered; 9 | if (buffered!.length && audio.value?.duration) { 10 | progressValue.value = Math.round(100 * (buffered.end(buffered.length - 1) / audio.value.duration)); 11 | } 12 | }; 13 | return { updateBuffer, progressValue }; 14 | } -------------------------------------------------------------------------------- /src/components/Player/hook/useBlurLineGradient.ts: -------------------------------------------------------------------------------- 1 | import { getPixelColor } from '@/utils/getPixelColor'; 2 | import obverser from '@/utils/obverser'; 3 | import { nextTick, type CSSProperties } from 'vue'; 4 | 5 | let lyricFooterMaskELement:HTMLElement; 6 | let lyricTopMaskElement:HTMLElement; 7 | 8 | export function useBlurLineGradient() { 9 | 10 | let footerMaskStyle:CSSProperties; 11 | let topMaskStyle:CSSProperties; 12 | const eleHeight = 35; 13 | const updateFooterMaskColor = async (context:CanvasRenderingContext2D) => { 14 | await nextTick(); 15 | if (!lyricFooterMaskELement) { 16 | lyricFooterMaskELement = document.querySelector('.footer-mask') as HTMLElement; 17 | } 18 | if (!lyricTopMaskElement) { 19 | lyricTopMaskElement = document.querySelector('.top-mask') as HTMLElement; 20 | } 21 | // dom 还未在页面显示 可能为0 22 | let { top: footerEleTop } = lyricFooterMaskELement.getBoundingClientRect(); 23 | let { top: topEleTop } = lyricTopMaskElement.getBoundingClientRect(); 24 | if (footerEleTop <= 0) { 25 | footerEleTop = 0; 26 | } 27 | if (topEleTop <= 0) { 28 | topEleTop = 0; 29 | } 30 | const { rgb: footerRgb } = getPixelColor( 31 | context, 0, footerEleTop+eleHeight 32 | ); 33 | const { rgb: topRgb } = getPixelColor( 34 | context, 0, topEleTop 35 | ); 36 | if (topRgb === 'rgb(0,0,0)') { 37 | topMaskStyle = { background: 'transparent' }; 38 | } else { 39 | topMaskStyle = { background: `linear-gradient(${topRgb} , rgba(255, 255, 255, 0)` }; 40 | } 41 | if (footerRgb === 'rgb(0,0,0)') { 42 | footerMaskStyle = { background: 'transparent' }; 43 | } else { 44 | footerMaskStyle = { background: `linear-gradient(-180deg, rgba(255, 255, 255, 0) 0%, ${footerRgb} 80%)` }; 45 | 46 | } 47 | obverser.emit('updateLyricMaskStyle', { footerMaskStyle, topMaskStyle }); 48 | }; 49 | const resetBackground = () => { 50 | footerMaskStyle = { background: 'transparent' }; 51 | topMaskStyle = { background: 'transparent' }; 52 | obverser.emit('updateLyricMaskStyle', { footerMaskStyle, topMaskStyle }); 53 | }; 54 | 55 | return { updateFooterMaskColor, resetBackground }; 56 | } -------------------------------------------------------------------------------- /src/components/SongsList/MusicList.vue: -------------------------------------------------------------------------------- 1 | 182 | 183 | 204 | -------------------------------------------------------------------------------- /src/components/SongsList/SelectSongListTagModal.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | -------------------------------------------------------------------------------- /src/components/SongsList/SongList.vue: -------------------------------------------------------------------------------- 1 | 15 | 58 | 59 | 66 | -------------------------------------------------------------------------------- /src/components/SongsList/SongListSkeleton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/common/HeartIcon.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 65 | -------------------------------------------------------------------------------- /src/components/common/PlayAllButton.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/common/SubscribePlayListModal.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 139 | 140 | 161 | -------------------------------------------------------------------------------- /src/hook/useCheckLogin.ts: -------------------------------------------------------------------------------- 1 | import { useMainStore } from './../stores/main'; 2 | export function userCheckLogin(callback:() => void, message='请先登录') { 3 | const mainStore = useMainStore(); 4 | if (!mainStore.isLogin) { 5 | window.$message.error(message); 6 | } else { 7 | callback(); 8 | } 9 | } -------------------------------------------------------------------------------- /src/hook/useDbClickPlay.ts: -------------------------------------------------------------------------------- 1 | import { useMainStore } from '@/stores/main'; 2 | import type { Ref } from 'vue'; 3 | import useValidateVipSong from './useValidateVipSong'; 4 | 5 | export function useDbClickPlay() { 6 | let isLoad = false; 7 | return async ( 8 | list:any[]| Ref, playListId:string, item:any, index:number 9 | ) => { 10 | const value = useValidateVipSong(item); 11 | if (value) return; 12 | if (isLoad) return; 13 | const mainStore = useMainStore(); 14 | let songList; 15 | if (list instanceof Array) { 16 | songList = list; 17 | } else { 18 | songList = list.value; 19 | } 20 | isLoad = true; 21 | const message = '亲爱的, 暂无版权'; 22 | // 初始化歌曲列表 23 | if (!mainStore.playList.length) { 24 | await mainStore.initPlayList( 25 | songList, index, playListId, message 26 | ); 27 | } else { 28 | if (mainStore.currentPlayListId === playListId) { 29 | await mainStore.changePlayIndex(index); 30 | } else { 31 | await mainStore.initPlayList( 32 | songList, index, playListId, message 33 | ); 34 | } 35 | } 36 | isLoad = false; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/hook/useLazyLoad.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, ref } from 'vue'; 2 | 3 | 4 | export default function useLazyLoad(src: string) { 5 | const imageRef = ref(null); 6 | 7 | let observer: null | IntersectionObserver = new IntersectionObserver(callback); 8 | 9 | function callback(entries: IntersectionObserverEntry[]) { 10 | if (entries[0].isIntersecting) { 11 | const img = new Image(); 12 | img.crossOrigin = "anonymous" 13 | img.src = src; 14 | observer?.disconnect(); 15 | } 16 | } 17 | onMounted(() => { 18 | imageRef.value && observer?.observe(imageRef.value); 19 | }); 20 | 21 | onUnmounted(() => { 22 | imageRef.value && observer?.unobserve(imageRef.value); 23 | observer?.disconnect(); 24 | observer = null; 25 | }); 26 | return { imageRef }; 27 | } -------------------------------------------------------------------------------- /src/hook/useMemorizeRequest.ts: -------------------------------------------------------------------------------- 1 | import { computed, onUnmounted, ref } from 'vue'; 2 | import type { AxiosResponse } from 'axios'; 3 | import { cloneDeep } from 'lodash'; 4 | export const useMemorizeRequest = ( 5 | requestFn:(params:any) => Promise>, requestKey:string, cacheTime = 180000 6 | ) => { 7 | const cacheResponseMap = new Map(); 8 | const requestLoadingMaps = ref({ [requestKey]: true }); 9 | // 记录缓存时间 默认三分钟 1000 * 60 * 3 = 180000 10 | const cacheTimeMap = new Map(); 11 | const requestLoading = computed(() => { 12 | return requestLoadingMaps.value[requestKey]; 13 | }); 14 | const getKey = (params?:any) => { 15 | const cloneParams = cloneDeep(params || 'key'); 16 | const key = requestKey + JSON.stringify(cloneParams); 17 | return key; 18 | }; 19 | const wrapRequest = (params?:any) => { 20 | const key = getKey(params); 21 | const request = () => { 22 | const requestData = requestFn(params); 23 | cacheResponseMap.set(key, requestData); 24 | requestLoadingMaps.value[requestKey] = true; 25 | cacheTimeMap.set(key, Date.now()); 26 | return requestData; 27 | }; 28 | 29 | if (!cacheResponseMap.has(key)) { 30 | return request(); 31 | } else { 32 | // 如果缓存时间超过了设置的时间 则重新请求 33 | if (Date.now() - cacheTimeMap.get(key) > cacheTime) { 34 | return request(); 35 | } 36 | return cacheResponseMap.get(key); 37 | } 38 | }; 39 | // 删除指定缓存 40 | const removeCache = (params?:any) => { 41 | const key = getKey(params); 42 | if (cacheResponseMap.has(key)) { 43 | cacheResponseMap.delete(key); 44 | } 45 | }; 46 | const loadSuccess = () => { 47 | requestLoadingMaps.value[requestKey] = false; 48 | }; 49 | onUnmounted(() => { 50 | cacheResponseMap.clear(); 51 | }); 52 | 53 | return { 54 | wrapRequest, 55 | loadSuccess, 56 | removeCache, 57 | requestLoading 58 | }; 59 | }; -------------------------------------------------------------------------------- /src/hook/useMemoryScrollTop.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from '@/utils'; 2 | import { nextTick, onMounted, onUnmounted, onUpdated, type Ref } from 'vue'; 3 | 4 | export function useMemoryScrollTop(ref:Ref | string) { 5 | let targetEle : HTMLElement | null | Window = null; 6 | let setScrollTopLock = false; 7 | // 设置滚动位置 8 | const setScrollPosition = (key:string) => { 9 | setScrollTopLock = true; 10 | const scrollTop = sessionStorage.getItem(key); 11 | 12 | if (scrollTop) { 13 | const options:ScrollToOptions = { 14 | behavior: 'smooth', 15 | top: +scrollTop 16 | }; 17 | if (targetEle instanceof Window) { 18 | document.documentElement.scrollTo(options); 19 | document.body.scrollTo(options); 20 | setScrollTopLock = false; 21 | } else { 22 | nextTick(() => { 23 | targetEle!.scrollTo(options); 24 | setScrollTopLock = false; 25 | }); 26 | } 27 | } 28 | }; 29 | const handleListenScroll = () => { 30 | if (setScrollTopLock) return; 31 | 32 | let scrollTop; 33 | // 是否为window 34 | if (targetEle instanceof Window) { 35 | scrollTop = document.documentElement.scrollTop || document.body.scrollTop; 36 | } else { 37 | scrollTop = targetEle!.scrollTop; 38 | } 39 | 40 | sessionStorage.setItem('scrollTop', scrollTop!.toString()); 41 | 42 | }; 43 | const throttleFn = throttle(handleListenScroll, 500); 44 | 45 | onMounted(() => { 46 | if (!ref) { 47 | targetEle = window; 48 | return; 49 | } 50 | if (typeof ref === 'string') { 51 | const dom = document.querySelector(ref); 52 | targetEle = (dom 53 | ? dom 54 | : window) as HTMLElement | Window; 55 | } else { 56 | if (ref?.value) { 57 | targetEle = ref.value; 58 | } else { 59 | targetEle = window; 60 | } 61 | } 62 | targetEle.addEventListener('scroll', throttleFn); 63 | setScrollPosition('scrollTop'); 64 | }); 65 | onUnmounted(() => { 66 | targetEle!.removeEventListener('scroll', throttleFn); 67 | sessionStorage.setItem('scrollTop', '0'); 68 | targetEle!.scrollTo({ top: 0 }); 69 | }); 70 | 71 | onUpdated(() => { 72 | setScrollPosition('scrollTop'); 73 | }); 74 | } -------------------------------------------------------------------------------- /src/hook/useNanoid.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { nanoid } from 'nanoid'; 3 | export function useNanoid() { 4 | const idMaps = new Map(); 5 | const currentId = ref(''); 6 | return { 7 | set(key:any) { 8 | if (!idMaps.has(key)) { 9 | const id = nanoid(); 10 | currentId.value = id; 11 | idMaps.set(key, id); 12 | } else { 13 | currentId.value = idMaps.get(key); 14 | } 15 | }, 16 | currentId 17 | }; 18 | } -------------------------------------------------------------------------------- /src/hook/useThemeStyle.ts: -------------------------------------------------------------------------------- 1 | import { isEven } from '@/utils'; 2 | import { useThemeVars } from 'naive-ui'; 3 | import { computed, type CSSProperties } from 'vue'; 4 | 5 | export default function useThemeStyle(otherStyle?:CSSProperties) { 6 | const themeVars = useThemeVars(); 7 | const tableStripedStyle = computed(() => { 8 | return (index:number) => { 9 | let value:CSSProperties = { 10 | background: isEven(index) 11 | ? themeVars.value.tableColorStriped 12 | : themeVars.value.tableColor 13 | }; 14 | if (otherStyle) { 15 | value = { ...value, ...otherStyle }; 16 | } 17 | return value; 18 | }; 19 | }); 20 | const tagColor = computed(() => { 21 | return { 22 | textColor: themeVars.value.primaryColor, 23 | borderColor: themeVars.value.primaryColor 24 | }; 25 | }); 26 | const baseColor = computed(() => { 27 | return themeVars.value.baseColor; 28 | }); 29 | const primaryColor = computed(() => { 30 | return themeVars.value.primaryColor; 31 | }); 32 | const stripedClass = computed(() => { 33 | return (index:number) => { 34 | let classes = ''; 35 | if (isEven(index)) { 36 | classes = 'bg-white hover:bg-gray-100 dark:bg-black/50 dark:hover:bg-gray-200/10'; 37 | } else { 38 | classes = 'bg-gray-50 hover:bg-gray-100 dark:bg-black dark:hover:bg-gray-200/10'; 39 | } 40 | return classes; 41 | }; 42 | }); 43 | return { tableStripedStyle, themeVars, tagColor, baseColor, primaryColor, stripedClass }; 44 | } -------------------------------------------------------------------------------- /src/hook/useValidateVipSong.ts: -------------------------------------------------------------------------------- 1 | import { useMainStore } from '@/stores/main'; 2 | 3 | export default function useValidateVipSong(song: any) { 4 | const mainStore = useMainStore(); 5 | // 如果为vip歌曲 6 | if (song.fee === 1) { 7 | // 未登录下, 不能进行播放 8 | if (!mainStore.isLogin) { 9 | return window.$message.warning('歌曲为vip专享,请先登录!'); 10 | } else { 11 | // 非vip不能播放 12 | if (mainStore.userProfile?.profile?.vipType === 0) { 13 | return window.$message.warning('歌曲为会员专享'); 14 | } 15 | } 16 | } 17 | return undefined; 18 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .dark-text-color { 6 | @apply text-white dark:text-black; 7 | } 8 | 9 | .bg-reverse-second-main{ 10 | @apply bg-secondBack dark:bg-zinc-100; 11 | } 12 | 13 | @layer base { 14 | .flex-items-justify-center { 15 | @apply flex items-center justify-center; 16 | } 17 | .group-hover-opacity{ 18 | @apply group-hover:opacity-1 opacity-0 transition-opacity duration-300; 19 | } 20 | .group-hover-scale{ 21 | @apply group-hover:scale-110 scale-100 transition-transform duration-300; 22 | } 23 | .border-left{ 24 | @apply border-0 border-l border-solid; 25 | } 26 | .flex-items-center{ 27 | @apply flex items-center; 28 | } 29 | .text-primary{ 30 | @apply text-sky-500; 31 | } 32 | .base-hover-bg{ 33 | @apply hover:bg-gray-100 dark:hover:bg-gray-100/20; 34 | } 35 | } 36 | 37 | p{ 38 | margin: 0; 39 | } 40 | .v-binder-follower-content{ 41 | max-width: 50vw; 42 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'normalize.css'; 2 | import { createPinia } from 'pinia'; 3 | import VueVirtualScroller from 'vue-virtual-scroller'; 4 | import './utils/initIndexDb'; 5 | import { createApp } from 'vue'; 6 | import App from './App.vue'; 7 | import './assets/css/common.css'; 8 | import './assets/css/transition.css'; 9 | import './index.css'; 10 | import router from './router'; 11 | const app = createApp(App); 12 | app.use(createPinia()); 13 | app.use(VueVirtualScroller); 14 | app.use(router); 15 | app.mount('#app'); -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, type NavigationGuardWithThis } from 'vue-router'; 2 | const router = createRouter({ 3 | history: createWebHashHistory(import.meta.env.BASE_URL), 4 | routes: [ 5 | { 6 | path: '/', 7 | redirect: '/discovery', 8 | meta: { auth: false } 9 | }, 10 | { 11 | path: '/songList', 12 | name: 'songs', 13 | component: () => import('@/views/songList/RecommendSongListView.vue'), 14 | meta: { auth: false } 15 | }, 16 | { 17 | path: '/songList/:id', 18 | name: 'songListDetail', 19 | component: () => import('@/views/songList/SongListDetail.vue'), 20 | meta: { auth: false } 21 | }, 22 | { 23 | path: '/songList/edit/:id', 24 | name: 'songListEdit', 25 | component: () => import('@/views/songList/EditSongList.vue'), 26 | meta: { auth: true } 27 | }, 28 | { 29 | path: '/discovery', 30 | name: 'discovery', 31 | component: () => import('@/views/home/DiscoveryView.vue'), 32 | meta: { auth: false } 33 | }, 34 | { 35 | path: '/latestMusic', 36 | name: 'latestMusic', 37 | component: () => import('@/views/music/LatestMusicView.vue'), 38 | meta: { auth: false } 39 | }, 40 | { 41 | path: '/latestMv', 42 | name: 'latestMv', 43 | component: () => import('@/views/mv/LatestMvView.vue'), 44 | meta: { auth: false } 45 | }, 46 | { 47 | path: '/mv/:id', 48 | component: () => import('@/views/mv/MvDetail.vue'), 49 | meta: { hidden: true, auth: false } 50 | }, 51 | { 52 | path: '/userInfoEdit', 53 | name: 'userInfoEdit', 54 | component: () => import('@/views/user/UserInfoEdit.vue'), 55 | meta: { auth: true } 56 | }, 57 | { 58 | path: '/searchResult', 59 | name: 'searchResult', 60 | component: () => import('@/views/search/SearchResult.vue') 61 | } 62 | ] 63 | }); 64 | export const registerRouteHook = (beforeEachFn: NavigationGuardWithThis, beforeResolveFn: NavigationGuardWithThis) => { 65 | router.beforeEach(beforeEachFn); 66 | router.beforeResolve(beforeResolveFn); 67 | }; 68 | export default router; -------------------------------------------------------------------------------- /src/service/album.ts: -------------------------------------------------------------------------------- 1 | import service from './request'; 2 | 3 | // 获取专辑详情 4 | export function getAlbumDetail(id:string) { 5 | return service.get('/album?id='+id); 6 | } -------------------------------------------------------------------------------- /src/service/index.ts: -------------------------------------------------------------------------------- 1 | import service from './request'; 2 | // 获取轮播 3 | export function getBanner() { 4 | return service.get('/banner'); 5 | } 6 | // 批量请求接口 7 | export function batchRequest(data: { 8 | [key: string]: any 9 | }) { 10 | return service.post('/batch', data); 11 | } 12 | export * from './mv'; 13 | export * from './playlist'; 14 | export * from './songs'; 15 | export * from './login'; 16 | export * from './user'; 17 | export * from './search'; 18 | export * from './album'; -------------------------------------------------------------------------------- /src/service/login.ts: -------------------------------------------------------------------------------- 1 | import service from './request'; 2 | 3 | //二维码 key 生成接口 4 | export function getQrCode() { 5 | return service.get('/login/qr/key?timestamp=' + new Date().getTime()); 6 | } 7 | // 二维码生成 8 | export function getQrCodeImg(key:string) { 9 | return service.get(`/login/qr/create?key=${key}×tamp=${new Date().getTime()}&qrimg=true`); 10 | } 11 | // 二维码检查扫码状态 12 | export function getQrCodeStatus(key:string) { 13 | return service.get(`/login/qr/check?key=${key}×tamp=${new Date().getTime()}`); 14 | } 15 | // 退出登录 16 | export function logout() { 17 | return service.get('/logout?timestamp=' + new Date().getTime()); 18 | } -------------------------------------------------------------------------------- /src/service/mv.ts: -------------------------------------------------------------------------------- 1 | import service from './request'; 2 | // 全部mv 列表 3 | export function getMvList({ 4 | limit = 50, 5 | offset = 0, area = '全部', 6 | order = '最热', type = '全部' 7 | }) { 8 | return service.get(`/mv/all?limit=${limit}&offset=${offset}&area=${area}&order=${order}&type=${type}`); 9 | } 10 | // mv详情 11 | export function getMvDetail(mvid: number) { 12 | return service.get(`/mv/detail?mvid=${mvid}`); 13 | } 14 | // 相似推荐mv 15 | export function getSimiMv(mvid: number) { 16 | return service.get(`/simi/mv?mvid=${mvid}`); 17 | } 18 | // 获取mv 地址 19 | export function getVideoUrl(id: number) { 20 | return service.get(`/mv/url?id=${id}`); 21 | } 22 | // 推荐 mv 23 | export function getRecommendMv() { 24 | return service.get('/mv/first?limit=4'); 25 | } 26 | // mv评论 27 | export function getMvComment({ id='', limit = 20, offset = 0, before='' }) { 28 | let qs = `id=${id}&limit=${limit}&offset=${offset}×tamp=${Date.now()}`; 29 | if (before) { 30 | qs+=`&before=${before}`; 31 | } 32 | return service.get(`/comment/mv?${qs}`); 33 | } 34 | -------------------------------------------------------------------------------- /src/service/playlist.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | import service from './request'; 3 | // 获取精品歌单 4 | export function getTopPlayList({ cat = '全部', limit = 10, before = '' }) { 5 | return service.get(`/top/playlist/highquality?cat=${cat}&limit=${limit}&before=${before}`); 6 | } 7 | // 精品歌单标签列表 8 | export function getTopPlayListTags() { 9 | return service.get('/playlist/highquality/tags'); 10 | } 11 | // 推荐歌单 12 | export function getPersonalized() { 13 | return service.get('/personalized?limit=15'); 14 | } 15 | // 获取歌单详情 16 | export function getPlaylistDetail(id: string) { 17 | const query = qs.stringify({ 18 | id, 19 | timestamp: Date.now() 20 | }); 21 | return service.get('/playlist/detail?'+query); 22 | } 23 | // 获取歌单所有数据 24 | export function getPlaylistAllDetail(data:{ 25 | id: string, 26 | limit?: number, 27 | offset?: number, 28 | }) { 29 | const query = qs.stringify({ 30 | ...data, 31 | timestamp: Date.now(), 32 | limit:data.limit ? data.limit : 300, 33 | offset:data.offset ? data.offset : 0, 34 | }); 35 | return service.get('/playlist/track/all?'+query); 36 | } 37 | // 更新歌单标签 38 | export function updatePlaylistTags(data: { 39 | id: string, 40 | tags: string 41 | }) { 42 | const query = qs.stringify({ 43 | ...data, 44 | timestamp: Date.now() 45 | }); 46 | return service.get('/playlist/tags/update?'+query); 47 | } 48 | // 编辑歌单 49 | export function updatePlayListInfo(data:{ 50 | id:string; 51 | name:string; 52 | tags:string; 53 | desc:string; 54 | }) { 55 | const query = qs.stringify({ 56 | id: data.id, 57 | name: data.name, 58 | tags: data.tags, 59 | desc: data.desc, 60 | timestamp: Date.now() 61 | }); 62 | return service.get('/playlist/update?'+query); 63 | } 64 | // 更新歌单封面 65 | export function updatePlayListCover( 66 | file: File, imgSize:number, id:string 67 | ) { 68 | const formData = new FormData(); 69 | formData.append('imgFile', file); 70 | const params = { timestamp: Date.now(), imgSize, id }; 71 | const url = '/playlist/cover/update?'+qs.stringify(params); 72 | return service.post( 73 | url, formData, { headers: { 'Content-Type': 'multipart/form-data' } } 74 | ); 75 | } 76 | // 收藏/取消收藏歌单 77 | export function updatePlayListSubscribe(data:{ 78 | id:string; 79 | t:number;// 1:收藏,2:取消收藏 80 | }) { 81 | const query = qs.stringify({ 82 | id: data.id, 83 | t: data.t, 84 | timestamp: Date.now() 85 | }); 86 | return service.get('/playlist/subscribe?'+query); 87 | } 88 | // 歌单评论 89 | export function getPlaylistComment(data:{ 90 | id:string; 91 | limit?:number; 92 | offset?:number; 93 | before?:string; 94 | }) { 95 | const query = qs.stringify({ 96 | ...data, 97 | timestamp:Date.now() 98 | }); 99 | return service.get('/comment/playlist?'+query); 100 | } 101 | // 相似歌单 102 | export function getSimilarPlaylist(id:string) { 103 | return service.get('/simi/playlist?id='+id); 104 | } 105 | // 相似歌曲 106 | export function getSimilarSong(id:string) { 107 | return service.get('/simi/song?id='+id); 108 | } 109 | //对歌单添加或删除歌曲 110 | export function updatePlaylistTracks(data:{ 111 | tracks:number;//歌曲id 112 | pid:number;// 歌单id 113 | op:'add'|'del';// 1:添加,2:删除 114 | }) { 115 | const query = qs.stringify({ 116 | tracks: data.tracks, 117 | pid: data.pid, 118 | op: data.op, 119 | timestamp: Date.now() 120 | }); 121 | return service.get('/playlist/tracks?'+query); 122 | } 123 | // 新建歌单 124 | export function createPlaylist(data:{ 125 | name:string; 126 | privacy?:string;// 默认否,传'10'则设置成隐私歌单 127 | }) { 128 | const query = qs.stringify({ 129 | name: data.name, 130 | privacy: data.privacy, 131 | timestamp: Date.now() 132 | }); 133 | return service.get('/playlist/create?'+query); 134 | } -------------------------------------------------------------------------------- /src/service/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | // create axios instance 3 | const instance = axios.create({ 4 | // 由于网易限制,此项目在国外服务器上vercel使用会受到限制 导致媒体资源加载不出来 5 | baseURL: 'https://musicapi-git-main-pathyus-projects.vercel.app/', 6 | // baseURL:"http://localhost:3000", 7 | method: 'get', 8 | withCredentials: true, 9 | 10 | }); 11 | 12 | //add request interceptor 13 | instance.interceptors.request.use((config) => { 14 | return config; 15 | }, err => { 16 | return Promise.reject(err); 17 | }); 18 | 19 | //response interceptor 20 | instance.interceptors.response.use((data) => { 21 | return data; 22 | }, err => { 23 | window.$message.error('network error'); 24 | return Promise.reject(err); 25 | }); 26 | 27 | export default instance; -------------------------------------------------------------------------------- /src/service/search.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | import service from './request'; 3 | // 默认搜索关键词 4 | export function getDefaultSearchKeyword() { 5 | return service.get('/search/default'); 6 | } 7 | // 热搜列表 8 | export function getHotSearchList() { 9 | return service.get('/search/hot/detail'); 10 | } 11 | // 搜索建议 12 | export function getSuggestSearchList(keyword: string) { 13 | return service.get(`/search/suggest?keywords=${keyword}`); 14 | } 15 | export interface SearchParams{ 16 | keywords:string;// 关键词 17 | type:string;//1 单曲 1000歌单 18 | limit:number;// 返回数量 19 | offset?:number 20 | } 21 | //搜索 22 | export function search(data:SearchParams) { 23 | const query = qs.stringify({ 24 | ...data, 25 | timestamp: Date.now() 26 | }); 27 | return service.get('/cloudsearch?'+query); 28 | } -------------------------------------------------------------------------------- /src/service/songs.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | import service from './request'; 3 | // 推荐歌曲 4 | export function getRecommendSong() { 5 | return service.get('/recommend/songs'); 6 | } 7 | // 新歌速递 8 | export function getTopSong(type: 0 | 7 | 96 |8 | 16=0) { 9 | return service.get(`/top/song?type=${type}`); 10 | } 11 | // 获取歌手单曲可 获得歌手部分信息和热门歌曲 12 | export function getSingerSong(id: number) { 13 | return service.get(`/artists?id=${id}`); 14 | } 15 | // 我喜欢的音乐列表 16 | export function getLikeList(uid: number) { 17 | const query = qs.stringify({ 18 | uid, 19 | }); 20 | return service.get(`/likelist?${query}`); 21 | } 22 | // 获取音乐url 23 | export function getMusicUrl(id:string) { 24 | // 音乐evel standard => 标准,higher => 较高, exhigh=>极高, lossless=>无损, 25 | //hires=>Hi-Res, jyeffect => 高清环绕声, sky => 沉浸环绕声, dolby => 杜比全景声, jymaster => 超清母带 26 | const query = qs.stringify({ 27 | level:'standard', 28 | id 29 | }); 30 | return service.get('/song/url/v1?'+query); 31 | } 32 | // 获取歌词 33 | export function getLyric(id:string) { 34 | return service.get('/lyric?id='+id); 35 | } 36 | // 获取逐字歌词 37 | export function getNewLyric(id:string) { 38 | return service.get('/lyric/new?id='+id); 39 | } 40 | // 检查音乐是否可用 41 | export function checkMusic(id:string) { 42 | return service.get('/check/music?id='+id); 43 | } 44 | // 歌曲评论 45 | export function getMusicComment(data:{ 46 | id:string; 47 | limit?:number; 48 | offset?:number; 49 | before?:string;}) { 50 | const query = qs.stringify({ 51 | ...data, 52 | }); 53 | return service.get('/comment/music?'+query); 54 | } 55 | // 喜欢音乐 56 | export function likeMusic(id:number, like:boolean) { 57 | return service.get(`/like?id=${id}&like=${like}`); 58 | } -------------------------------------------------------------------------------- /src/service/user.ts: -------------------------------------------------------------------------------- 1 | import service from './request'; 2 | import qs from 'qs'; 3 | // 获取账号信息 4 | export function getUserInfo() { 5 | return service.get('/user/account'); 6 | } 7 | //获取用户详情 8 | export function getUserDetail(uid:string) { 9 | return service.get('/user/detail?uid='+uid); 10 | } 11 | // 签到 12 | export function signIn() { 13 | return service.post('/daily_signin?timestamp=' + Date.now()+'&type=1'); 14 | } 15 | //更新用户信息 16 | export function updateUserInfo(data:{ 17 | nickname: string, 18 | signature?: string, 19 | gender:number; 20 | birthday?: number|string, 21 | province?: number|string, 22 | city?: number|string, 23 | }) { 24 | const params = qs.stringify({ 25 | ...data, 26 | timestamp: Date.now() 27 | }); 28 | return service.get('/user/update?'+params); 29 | } 30 | // 更新头像 31 | export function updateUserAvatar(file: File, imgSize:number) { 32 | const formData = new FormData(); 33 | formData.append('imgFile', file); 34 | const params = { timestamp: Date.now(), imgSize }; 35 | const url = '/avatar/upload?'+qs.stringify(params); 36 | return service.post( 37 | url, formData, { headers: { 'Content-Type': 'multipart/form-data' } } 38 | ); 39 | } 40 | // 发送/删除评论 41 | export function sendComment(data: { 42 | t:number;///1 发送, 2 回复 0 删除 43 | type: number, //0: 歌曲1: mv 2: 歌单 3: 专辑 4: 电台 5: 视频 6: 动态 44 | id: number, //对应资源 id 45 | content: string, //要发送的内容 46 | commentId?: number//:回复的评论 id (回复评论时必填) 47 | }) { 48 | const params = qs.stringify({ 49 | ...data, 50 | timestamp: Date.now() 51 | }); 52 | return service.get('/comment?'+params); 53 | } 54 | //获取用户歌单 55 | export function getUserPlaylist(uid:number) { 56 | return service.get('/user/playlist?uid='+uid); 57 | } 58 | // 给评论点赞 59 | export function likeComment(data:{ 60 | t:number;// 是否点赞 1 为点赞 0 为取消点赞 61 | cid: number, //评论 id 62 | type: number, //0: 歌曲1: mv 2: 歌单 3: 专辑 4: 电台 5: 视频 6: 动态 63 | id: number, //对应资源 id 64 | }) { 65 | const params = qs.stringify({ 66 | ...data, 67 | timestamp: Date.now() 68 | }); 69 | return service.get('/comment/like?'+params); 70 | } 71 | // 获取登录状态 72 | export function getLoginStatus() { 73 | return service.get('/login/status?timestamp='+Date.now()); 74 | } -------------------------------------------------------------------------------- /src/stores/state.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from './../../env.d'; 2 | export interface StoreState{ 3 | theme: 'dark' | 'light', 4 | backTopLeft: string; 5 | isLogin: boolean; 6 | likeSongs: number[]; 7 | playList: any[]; 8 | userProfile: AnyObject; 9 | currentPlayIndex: number; 10 | playMode: playMode, 11 | playWaiting:boolean; 12 | currentPlayListId: string, 13 | playing:boolean, 14 | mySubscribeSongList:any[], 15 | playListIdList:string[], 16 | searchHistory:string[]; 17 | showMusicDetail:boolean; 18 | searchKeyword:string; 19 | currentPlayLyric:string; 20 | canvasBackground?:string; 21 | primaryColorMap:AnyObject; 22 | } 23 | export type playMode = 'order' | 'random' | 'singleLoop'; 24 | const initState = ( 25 | key:string, defaultVal:any, parse=true 26 | ) => { 27 | return localStorage[key] 28 | ? parse 29 | ? JSON.parse(localStorage[key]) 30 | : localStorage[key] 31 | : defaultVal; 32 | }; 33 | const state:StoreState = { 34 | theme: initState( 35 | 'theme', 'light', false 36 | ), 37 | backTopLeft: initState( 38 | 'backTopLeft', '7vw', false 39 | ), 40 | canvasBackground:initState( 41 | 'canvasBackground', 'var(--n-color);', false 42 | ), 43 | isLogin: initState('isLogin', false), 44 | userProfile: initState('userProfile', {}), 45 | likeSongs: initState('likeSongs', []), 46 | playList: initState('playList', []), 47 | currentPlayIndex: initState('currentPlayIndex', 0), 48 | playMode: initState( 49 | 'playMode', 'order', false 50 | ), 51 | currentPlayListId: initState( 52 | 'currentPlayListId', '', false 53 | ), 54 | mySubscribeSongList: initState('mySubscribeSongList', []), 55 | playListIdList: initState('playListIdList', []), 56 | searchHistory: initState('searchHistory', []), 57 | searchKeyword: '', 58 | playWaiting: true, 59 | playing: false, 60 | showMusicDetail: false, 61 | currentPlayLyric: '', 62 | primaryColorMap: initState('primaryColorMap', {}), 63 | }; 64 | 65 | export default state; -------------------------------------------------------------------------------- /src/utils/arr-map.ts: -------------------------------------------------------------------------------- 1 | import { formateSongsAuthor } from '.'; 2 | 3 | export function mapSongs(songs:any[]) { 4 | return songs.map((item:any) => { 5 | const target = item.song 6 | ? item.song 7 | : item; 8 | item.dt = target.dt ? target.dt : target.duration; 9 | item.al = target.al ? target.al: target.album; 10 | item.ar = target.ar ? target.ar : target.artists; 11 | item.formatAuthor = formateSongsAuthor( item.ar ); 12 | return item; 13 | }); 14 | } -------------------------------------------------------------------------------- /src/utils/getPixelColor.ts: -------------------------------------------------------------------------------- 1 | export function getPixelColor( 2 | context:CanvasRenderingContext2D, x:number, y:number 3 | ) { 4 | // x轴坐标 , y轴坐标, 1,1取色的范围像素值 5 | const imageData = context.getImageData( 6 | x, y, 1, 1 7 | ); 8 | const pixel = imageData.data; 9 | const r = pixel[0]; 10 | const g = pixel[1]; 11 | const b = pixel[2]; 12 | let rHex = r.toString(16); 13 | r < 16 && (rHex = '0' + rHex); 14 | let gHex = g.toString(16); 15 | g < 16 && (gHex = '0' + gHex); 16 | let bHex = b.toString(16); 17 | b < 16 && (bHex = '0' + bHex); 18 | const rgbColor = 'rgb(' + r + ',' + g + ',' + b + ')'; 19 | const hexColor = '#' + rHex + gHex + bHex; 20 | return { 21 | rgb: rgbColor, 22 | hex: hexColor 23 | }; 24 | } -------------------------------------------------------------------------------- /src/utils/image.ts: -------------------------------------------------------------------------------- 1 | import ColorThief from 'colorthief'; 2 | 3 | export function analyze(src:string) :Promise{ 4 | 5 | return new Promise((resolve, reject) => { 6 | const colorThief = new ColorThief(); 7 | const image = new Image(); 8 | image.src = src; 9 | image.crossOrigin = 'Anonymous'; 10 | function handleLoad (){ 11 | const color = colorThief.getColor(image); 12 | resolve(`rgb(${color.join(',')})`); 13 | image.removeEventListener('load', handleLoad); 14 | } 15 | image.addEventListener('load',handleLoad) 16 | image.onerror = (err) => { 17 | reject(err); 18 | }; 19 | }); 20 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // 格式化数字 2 | export function formateNumber(num:number) { 3 | // 小于999的数字直接返回 4 | if (num < 99999) return num.toString(); 5 | // 大于1万 小于1亿 6 | if (num < 100000000) return Math.round(num / 10000) + '万'; 7 | // 大于1亿 8 | if (num >= 100000000) return Math.round(num / 100000000) + '亿'; 9 | 10 | return num.toString(); 11 | } 12 | // 获取数组最后一位 13 | export const getArrLast = (arr:any[]) => { 14 | return arr[arr.length - 1]; 15 | }; 16 | 17 | // 节流 18 | export const throttle = (fn:Function, delay:number) => { 19 | let timer:any = null; 20 | return function () { 21 | if (timer) return; 22 | timer = setTimeout((...rest) => { 23 | fn(...rest); 24 | timer = null; 25 | }, delay); 26 | }; 27 | }; 28 | // 防抖 29 | export const debounce = (fn:Function, delay:number) => { 30 | let timer:any = null; 31 | return function (...rest: any) { 32 | if (timer) clearTimeout(timer); 33 | timer = setTimeout(() => { 34 | fn.apply(rest); 35 | timer = null; 36 | }, delay); 37 | }; 38 | }; 39 | // 根据不同类型记忆函数 40 | export const memorize = (fn:Function) => { 41 | const cache = new Map(); 42 | return (...args:any[]) => { 43 | const key = JSON.stringify(args); 44 | if (cache.has(key)) { 45 | return cache.get(key); 46 | } 47 | const result = fn(...args); 48 | cache.set(key, result); 49 | return result; 50 | }; 51 | }; 52 | export const formateSongsAuthor = (attr: any[]) => { 53 | return attr.map(item => item.name).join(' / '); 54 | }; 55 | // 根据指定的数量将数组切片 56 | export const sliceArr = (count=20, list:any[]) => { 57 | const arr = []; 58 | let index = 0; 59 | let nextSliceIndex = 0; 60 | while (index < list.length) { 61 | const item = list.slice(nextSliceIndex, nextSliceIndex+count); 62 | arr.push(item); 63 | index++; 64 | nextSliceIndex+=count; 65 | } 66 | return arr; 67 | }; 68 | 69 | export const generalTimeOptions = ( 70 | start:number, end:number, label:string 71 | ) => { 72 | const arr = []; 73 | for (let i = start; i <= end; i++) { 74 | arr.push({ 75 | label: i+label, 76 | value: i 77 | }); 78 | } 79 | return arr; 80 | }; 81 | export const getDayOptions = (month:number, year:number=new Date().getFullYear()) => { 82 | let day; 83 | if ([ 84 | 1, 3, 5, 7, 8, 10, 12 85 | ].includes(month)) { 86 | day = 31; 87 | } 88 | if ([ 89 | 4, 6, 9, 11 90 | ].includes(month)) { 91 | day = 30; 92 | } 93 | // 如果此时为闰年 并且是2月 94 | if (month === 2) { 95 | if (year % 4 === 0) { 96 | day = 29; 97 | } else { 98 | day = 28; 99 | } 100 | } 101 | return generalTimeOptions( 102 | 1, day as number, '日' 103 | ); 104 | }; 105 | // 对比两个对象是否相等 106 | export const compareObject = (obj1:any, obj2:any) => { 107 | const keys1 = Object.keys(obj1); 108 | const keys2 = Object.keys(obj2); 109 | if (keys1.length !== keys2.length) return false; 110 | for (const key of keys1) { 111 | if (obj1[key] !== obj2[key]) return false; 112 | } 113 | return true; 114 | }; 115 | export function getImgSize(file: File):Promise<{width:number, height:number}> { 116 | return new Promise((resolve, reject) => { 117 | const reader = new FileReader(); 118 | reader.readAsDataURL(file); 119 | reader.onload = function (theFile) { 120 | const image = new Image(); 121 | if (theFile.target) { 122 | image.src = theFile.target.result as string; 123 | image.onload = function () { 124 | resolve({ 125 | width: image.width, 126 | height: image.height 127 | }); 128 | }; 129 | } 130 | 131 | }; 132 | }); 133 | } 134 | //记录下标所对应的生成随机下标 135 | const cacheRandomNumMap = new Map(); 136 | // 得到一个两数之间的随机整数,包括两个数在内, 不包括指定下标 137 | // 并对指定下标生成的随机下标进行缓存 138 | export function getRandomIntInclusive( 139 | min:number, max:number, index:number 140 | ) { 141 | if (cacheRandomNumMap.has(index)) { 142 | return cacheRandomNumMap.get(index); 143 | } 144 | min = Math.ceil(min); 145 | max = Math.floor(max); 146 | let randomIndex = Math.floor(Math.random() * (max - min + 1)) + min; //含最大值,含最小值 147 | while (randomIndex === index) { 148 | randomIndex = Math.floor(Math.random() * (max - min + 1)) + min; 149 | } 150 | cacheRandomNumMap.set(index, randomIndex); 151 | return randomIndex; 152 | } 153 | // 重置 154 | export function restRandomNumMap() { 155 | cacheRandomNumMap.clear(); 156 | } 157 | // 当值等于最大值时,返回0,否则+1 158 | export function getNextIndex(index:number, max:number) { 159 | return index === max 160 | ? 0 161 | : index + 1; 162 | } 163 | // 当值等于0时,返回最大值,否则-1 164 | export function getPrevIndex(index:number, max:number) { 165 | return index === 0 166 | ? max 167 | : index - 1; 168 | } 169 | // 是否为偶数 170 | export function isEven(num:number) { 171 | return num % 2 === 0; 172 | } 173 | // 对象是否为空 174 | export function isEmptyObject(obj:any) { 175 | return Object.keys(obj).length === 0; 176 | } 177 | // sleep function with promise 178 | export function sleep(ms:number) { 179 | return new Promise((resolve) => { 180 | setTimeout(resolve, ms); 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /src/utils/initIndexDb.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | // 打开或创建 IndexedDB 数据库 4 | let request = indexedDB.open('audioDB', 1); 5 | const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm' 6 | import { FFmpeg } from '@ffmpeg/ffmpeg' 7 | 8 | import { fetchFile, toBlobURL } from '@ffmpeg/util' 9 | const ffmpeg = new FFmpeg(); 10 | // 保存当前需要操作的回调函数 11 | let currentResolve: () => void; 12 | // 是否初始化 13 | let isInit = false; 14 | 15 | ffmpeg.on('log', ({ message: msg }: { message: string }) => { 16 | console.log('ffmpeg-message:' + msg); 17 | }) 18 | ffmpeg.on('progress', (e) => { 19 | console.log('ffmpeg-progress:' + e); 20 | }); 21 | 22 | let dbInstance: IDBDatabase | null = null; 23 | 24 | export function openDatabase(): Promise { 25 | return new Promise((resolve, reject) => { 26 | if (dbInstance) { 27 | resolve(dbInstance); 28 | } else { 29 | const request = indexedDB.open('audioDB', 1); 30 | request.onsuccess = (event) => { 31 | dbInstance = (event.target as IDBRequest).result as IDBDatabase; 32 | resolve(dbInstance); 33 | }; 34 | request.onerror = (event) => { 35 | reject(event); 36 | }; 37 | } 38 | }); 39 | } 40 | 41 | 42 | request.onupgradeneeded = function (event) { 43 | const db = (event.target! as IDBRequest).result as IDBDatabase; 44 | 45 | // 创建 ObjectStore 用于存储 Blob 数据,设置 'id' 为主键 46 | const store = db.createObjectStore('audioFiles', { keyPath: 'id' }); 47 | // 为 id 字段创建索引 48 | store.createIndex('idIndex', 'id', { unique: true }); 49 | // 可选:为 ObjectStore 添加索引(比如按文件名索引) 50 | store.createIndex('name', 'name', { unique: false }); 51 | }; 52 | export async function saveSong(data: { id: number, name: string, url: string }) { 53 | if (!isInit) { 54 | currentResolve = () => callback(data) 55 | return; 56 | }; 57 | callback(data) 58 | } 59 | async function callback(data: { id: number, name: string, url: string }) { 60 | await ffmpeg.writeFile(`${data.name}.mp3`, await fetchFile(data.url)); 61 | await ffmpeg.exec(['-i', `${data.name}.mp3`,'-strict','-2', '-c:a', 'opus', '-b:a', '48k', `${data.name}.opus`]) 62 | const result = await ffmpeg.readFile(`${data.name}.opus`) as Uint8Array; 63 | storeOpusBlobData({ 64 | blob: result, 65 | id: data.id, 66 | name: data.name, 67 | }) 68 | } 69 | request.onsuccess = async function () { 70 | try { 71 | console.log('open db succeed'); 72 | 73 | // 直接使用 db 对象 74 | await ffmpeg.load({ 75 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), 76 | wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), 77 | workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, 'text/javascript') 78 | }); 79 | isInit = true; 80 | console.log('ffmpeg init succeed'); 81 | currentResolve && currentResolve(); 82 | } catch (error) { 83 | console.log('ffmpeg load error',error); 84 | } 85 | }; 86 | 87 | request.onerror = function (event) { 88 | const target = event.target as IDBRequest 89 | console.error('Database error:', target.error); 90 | }; 91 | 92 | // 存储 OPUS 格式的 Blob 数据 93 | function storeOpusBlobData(data: { blob: Uint8Array, id: number, name: string }) { 94 | const transaction = dbInstance!.transaction(['audioFiles'], 'readwrite'); 95 | const store = transaction.objectStore('audioFiles'); 96 | 97 | // 假设你已经有了一个 OPUS 格式的 Blob 数据 98 | const opusBlob = new Blob([data.blob], { type: 'audio/opus' }); 99 | 100 | const audioData = { 101 | id: data.id, // 使用时间戳作为唯一 id 102 | name: data.name, 103 | blob: opusBlob 104 | }; 105 | 106 | const requestResult = store.put(audioData); // 将数据添加到 ObjectStore 107 | 108 | requestResult.onsuccess = function () { 109 | console.log('OPUS audio data added to IndexedDB successfully'); 110 | }; 111 | 112 | requestResult.onerror = function (event) { 113 | const target = event.target as IDBRequest 114 | console.error('Error adding OPUS audio data to IndexedDB:', target.error); 115 | }; 116 | } 117 | export function getOpusBlobDataByIdUsingIndex(id: number) { 118 | return new Promise((resolve, reject) => { 119 | if (id == undefined) return resolve(null); 120 | if (!dbInstance) return resolve(null); 121 | const transaction = dbInstance!.transaction(['audioFiles'], 'readonly'); 122 | const store = transaction.objectStore('audioFiles'); 123 | const index = store.index('idIndex'); 124 | const requestResult = index.get(id); 125 | 126 | requestResult.onsuccess = function (event) { 127 | const result = (event.target! as IDBRequest).result as IDBDatabase; 128 | if (result) { 129 | resolve(result); 130 | } else { 131 | resolve(null); 132 | } 133 | }; 134 | 135 | requestResult.onerror = function (event) { 136 | const target = event.target as IDBRequest 137 | reject(target.error); 138 | }; 139 | }); 140 | } -------------------------------------------------------------------------------- /src/utils/lyric.ts: -------------------------------------------------------------------------------- 1 | interface MetaItem { 2 | t: number; 3 | c: { tx: string }[]; 4 | } 5 | function paseMetaData(lyric: string) { 6 | // 正则匹配元数据 {"t": 0, "c": [...]} 7 | const metaReg = /{.*?"t":\s*(\d+)(?!.*?"tx":\s*"").*?"c":\s*(\[\{.*?\}\])/g; 8 | // 解析元数据 9 | const metadata: MetaItem[] = []; 10 | let metaMatch; 11 | while ((metaMatch = metaReg.exec(lyric)) !== null) { 12 | const time = Number(metaMatch[1]); // 提取时间戳 13 | const contentJson = metaMatch[2]; // 提取元数据内容 14 | try { 15 | // 解析元数据 JSON 16 | const metaItem = { 17 | t: time, 18 | c: JSON.parse(contentJson) 19 | }; 20 | if (contentJson) { 21 | metadata.push(metaItem); 22 | } 23 | } catch (error) { 24 | console.error('Error parsing meta data:', error); 25 | } 26 | } 27 | return metadata 28 | } 29 | export function parseLyric(lrc: string, yrcLyric?: string): LyricItem[] { 30 | 31 | let lrcObj: LyricItem[] = []; 32 | let yrcLyricResult: string | any[] = []; 33 | const metadata = paseMetaData(lrc); 34 | 35 | let metaResult = metadata.map(item => { 36 | const { t, c } = item; 37 | let content: string = ''; 38 | c.forEach(item => { 39 | content += item.tx 40 | }); 41 | return { 42 | time: t, 43 | content, 44 | translateContent: '' 45 | } 46 | }); 47 | 48 | if (yrcLyric) { 49 | yrcLyricResult = parseLyricWithWords(yrcLyric); 50 | return [...metaResult, ...yrcLyricResult]; 51 | } else { 52 | lrcObj = parseBaseLyric(lrc); 53 | return [...metaResult, ...lrcObj]; 54 | } 55 | 56 | 57 | } 58 | 59 | export function parseBaseLyric(lyric: string) { 60 | const lyrics = lyric.split('\n'); 61 | let lrcObj: LyricItem[] = []; 62 | for (let i = 0; i < lyrics.length; i++) { 63 | const lyric = decodeURIComponent(lyrics[i]); 64 | const timeReg = /\[\d*:\d*((\.|:)\d*)*\]/g; 65 | const timeRegExpArr = lyric.match(timeReg); 66 | 67 | if (!timeRegExpArr) continue; 68 | const content = lyric.replace(timeReg, ''); 69 | for (let k = 0, h = timeRegExpArr.length; k < h; k++) { 70 | const t = timeRegExpArr[k]; 71 | 72 | const min = Number(String(t.match(/\[\d*/i)).slice(1)); 73 | let sec: number; 74 | const secondMatch = t.match(/:(\d{2}\.\d*)/); 75 | if (secondMatch) { 76 | sec = +secondMatch[1]; 77 | } else { 78 | sec = 0; 79 | } 80 | const newTime = Math.round(min * 60 * 1000 + sec * 1000); 81 | if (content !== '') { 82 | lrcObj.push({ 83 | time: newTime, content, 84 | }); 85 | } 86 | } 87 | } 88 | return lrcObj.map((item, index) => ({ ...item, index: index })) 89 | } 90 | 91 | export function parseRangeLyric(lyricList: LyricItem[]) { 92 | const map = new Map(); 93 | let currentIndex = 0; 94 | let nextIndex = 1; 95 | 96 | // 如果第一项播放时间不为0,则手动插入一个 97 | if (lyricList[currentIndex]?.time !== 0 && lyricList[currentIndex]?.content !== '纯音乐,请欣赏') { 98 | lyricList.unshift({ 99 | ...lyricList[currentIndex], 100 | time: 0 101 | }); 102 | } 103 | while (currentIndex !== lyricList.length - 1) { 104 | const cur = lyricList[currentIndex]; 105 | const next = lyricList[nextIndex]; 106 | for (let start = cur.time; start < next.time; start++) { 107 | map.set(start, { 108 | ...cur, 109 | index: currentIndex 110 | }); 111 | } 112 | if (next) { 113 | currentIndex++; 114 | nextIndex++; 115 | } 116 | if (currentIndex === lyricList.length - 1) { 117 | map.set(next.time, { 118 | ...next, 119 | index: currentIndex 120 | }); 121 | } 122 | } 123 | return map; 124 | } 125 | export interface LyricItem { 126 | time: number; 127 | content: string; 128 | translateContent?: string; 129 | // newTime:number; 130 | lineStartTime?: number, lineDuration?: number, words?: WordData[], 131 | index?: number, 132 | } 133 | export interface RangeLyricItem extends LyricItem { 134 | index: number; 135 | } 136 | 137 | interface WordData { 138 | content: string; 139 | startTime: number; 140 | duration: number; 141 | } 142 | 143 | export function parseLyricWithWords(input: string): { lineStartTime: number, lineDuration: number, words: WordData[], index: number }[] { 144 | const result: { lineStartTime: number, lineDuration: number, words: WordData[], content: string, time: number, index: number }[] = []; 145 | 146 | // 正则表达式匹配歌词行的时间范围 [start, duration],排除换行符 147 | const lineRegex = /\[(\d+),(\d+)\]/g; 148 | 149 | // 正则表达式:匹配时间戳 (startTime, duration, 0) 后面跟着一个完整的单词或空格 150 | const wordTimeRegex = /\((\d+),(\d+),\d+\)([^\(\)\n]+)/g; 151 | 152 | let lineMatch; 153 | let index = 0; 154 | // 使用 lineRegex 匹配每一行的开始和持续时间 155 | while ((lineMatch = lineRegex.exec(input)) !== null) { 156 | // 解析每一行的起始时间和持续时间 157 | const lineStartTime = parseInt(lineMatch[1]); 158 | const lineDuration = parseInt(lineMatch[2]); 159 | 160 | // 获取该行的歌词内容(确保只提取当前行的内容,排除换行符) 161 | const lineContent = input.slice(lineRegex.lastIndex, input.indexOf('\n', lineRegex.lastIndex) !== -1 ? input.indexOf('\n', lineRegex.lastIndex) : undefined).trim(); 162 | 163 | // 提取这一行的所有字 164 | const words: WordData[] = []; 165 | let wordMatch; 166 | 167 | // 使用 wordTimeRegex 提取逐字的时间和字 168 | while ((wordMatch = wordTimeRegex.exec(lineContent)) !== null) { 169 | const wordStartTime = parseInt(wordMatch[1]); 170 | const wordDuration = parseInt(wordMatch[2]); 171 | const wordContent = wordMatch[3]; 172 | 173 | // 确保每个字属于当前行的时间范围 174 | if (wordStartTime >= lineStartTime && wordStartTime < (lineStartTime + lineDuration)) { 175 | words.push({ content: wordContent, startTime: wordStartTime, duration: wordDuration }); 176 | } 177 | } 178 | 179 | // 将解析到的行信息存储到结果中 180 | result.push({ 181 | lineStartTime, 182 | lineDuration, 183 | words, 184 | time: lineStartTime, 185 | index, 186 | content: words.map(item => item.content).join('') // 拼接该行的文字内容 187 | }); 188 | index++; 189 | } 190 | 191 | return result; 192 | } 193 | -------------------------------------------------------------------------------- /src/utils/markSearhKeyword.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isPlainObject } from 'lodash'; 2 | 3 | export const markSearchKeyword = ( 4 | data:any[], keys:(string | string[])[], keyword:string, color:string 5 | ) => { 6 | return data.map(item => { 7 | keys.forEach(key => { 8 | if (typeof key === 'string') { 9 | const itemValue = item[key]; 10 | if (typeof itemValue === 'string') { 11 | const value = replaceMarkValue( 12 | itemValue, keyword, color 13 | ); 14 | item[key+'RichText'] = value; 15 | } 16 | if (isArray(itemValue) && typeof itemValue[0] === 'string') { 17 | item[key] = item[key].map((target:string) => { 18 | const replaceValue = replaceMarkValue( 19 | target, keyword, color 20 | ); 21 | if (replaceValue) return replaceValue; 22 | return target; 23 | }); 24 | } 25 | } else { 26 | const itemValue = item[key[0]]; 27 | const target = item[key[0]][key[1]]; 28 | if (isPlainObject(itemValue)) { 29 | item[key[0]][key[1]+'RichText'] = replaceMarkValue( 30 | target, keyword, color 31 | ); 32 | } 33 | } 34 | }); 35 | return item; 36 | }); 37 | }; 38 | const replaceMarkValue = ( 39 | value:string, keyword:string, color:string 40 | ) => { 41 | if (value.includes(keyword)) { 42 | let markValue = value. 43 | replace(keyword, `${keyword}`); 44 | if (value !== keyword) { 45 | markValue = markValue.padStart(markValue.length+6, ''); 46 | markValue = markValue.padEnd(markValue.length+7, ''); 47 | } 48 | return markValue; 49 | } 50 | return `${value}`; 51 | }; -------------------------------------------------------------------------------- /src/utils/obverser.ts: -------------------------------------------------------------------------------- 1 | class Observer { 2 | subscribes:{ 3 | [key:string]:Function[] 4 | }; 5 | constructor() { 6 | //储存订阅者 7 | this.subscribes = {}; 8 | } 9 | //订阅 10 | on(name:string, callback:(...data: any) => void) { 11 | // 如果不存在这个订阅者就添加这个订阅者 12 | if (!this.subscribes[name]) { 13 | this.subscribes[name] = []; 14 | } 15 | this.subscribes[name].push(callback); 16 | } 17 | // 发布 18 | emit(name:string, ...data: any[]) { 19 | // 如果不存在这个订阅者就打断函数执行 20 | if (!this.subscribes[name]) throw new Error('未找到订阅者'); 21 | this.subscribes[name].forEach(fn => fn(...data)); 22 | } 23 | } 24 | 25 | export default new Observer(); -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | export {}; -------------------------------------------------------------------------------- /src/views/home/DiscoveryView.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 141 | 142 | 159 | -------------------------------------------------------------------------------- /src/views/music/LatestMusicView.vue: -------------------------------------------------------------------------------- 1 | 87 | 88 | 140 | 141 | 162 | -------------------------------------------------------------------------------- /src/views/mv/LatestMvView.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 102 | -------------------------------------------------------------------------------- /src/views/search/SearchResult.vue: -------------------------------------------------------------------------------- 1 | 107 | 108 | 178 | 179 | 188 | -------------------------------------------------------------------------------- /src/views/songList/EditSongList.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 130 | -------------------------------------------------------------------------------- /src/views/songList/RecommendSongListView.vue: -------------------------------------------------------------------------------- 1 | 113 | 114 | 181 | 182 | 214 | -------------------------------------------------------------------------------- /src/views/user/UserInfoEdit.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 272 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | 4 | declare module "*.png"; 5 | declare module "*.svg"; 6 | declare module "*.jpeg"; 7 | declare module "*.jpg"; -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 3 | theme: { 4 | extend: { 5 | colors: { 6 | 'black': '#252525', 7 | 'secondBack': '#202020', 8 | 'white': 'rgba(255, 255, 255, 0.82);' 9 | }, 10 | screens: { 'sm': { 'min': '350px', 'max': '767px' } , 'max': '1900px','3xl':'1750px'}, 11 | height: { 'main': 'calc(100vh - 3.5rem)' }, 12 | transitionProperty: { 13 | 'height': 'height', 14 | 'width': 'width', 15 | 'spacing': 'margin, padding' 16 | }, 17 | width: { 18 | '50': '206px', 19 | '74': '302px', 20 | 'xs': '240px' 21 | } 22 | } 23 | }, 24 | darkMode: 'class', 25 | plugins: [], 26 | corePlugins: { 27 | // 解决组件库中的组件样式冲突 28 | preflight: false 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | /* Linting */ 19 | "strict": false, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "paths": { 25 | "@/*": ["./src/*"], 26 | "env": ["./env.d.ts"] 27 | }, 28 | }, 29 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue",] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/(.*)", 5 | "headers": [ 6 | { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }, 7 | { "key":"Cross-Origin-Opener-Policy", "value": "same-origin" } 8 | ] 9 | }], 10 | "buildCommand": "pnpm build" 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | import vueJsx from '@vitejs/plugin-vue-jsx'; 3 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; 4 | import Components from 'unplugin-vue-components/vite'; 5 | import { fileURLToPath, URL } from 'url'; 6 | import { defineConfig } from 'vite'; 7 | // import viteCompression from 'vite-plugin-compression'; 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | vue(), 13 | vueJsx(), 14 | // viteCompression(), 15 | Components({ resolvers: [NaiveUiResolver()] ,}), 16 | ], 17 | resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, 18 | optimizeDeps: { 19 | exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"], 20 | }, 21 | css: { 22 | preprocessorOptions: { 23 | scss: { 24 | api: 'modern-compiler' // or "modern" 25 | } 26 | } 27 | }, 28 | server: { 29 | port: 8081, 30 | open: 'http://localhost:8081', 31 | headers:{ 32 | "Cross-Origin-Opener-Policy": "same-origin", 33 | "Cross-Origin-Embedder-Policy": "require-corp", 34 | } 35 | }, 36 | }); --------------------------------------------------------------------------------