Compare commits

..

62 Commits

Author SHA1 Message Date
alger
9fcf455c08 🌈 style: 更新版本 1.5.0 2024-05-31 08:56:16 +08:00
alger
9b14906a46 🌈 style: add LICENSE. 2024-05-27 18:18:02 +08:00
alger
14ce428951 🌈 style: 修改README 2024-05-27 11:52:10 +08:00
alger
8c93124311 🌈 style: 修改README 2024-05-27 11:51:15 +08:00
alger
c09707867b 🦄 refactor: 适配 web移动端 改造 2024-05-23 17:12:35 +08:00
alger
a2af0f3904 feat: 优化搜索功能 2024-05-22 19:20:57 +08:00
alger
73982f0e84 feat: 添加manifest.json 2024-05-22 15:38:43 +08:00
alger
449a6fd335 feat: 登录问题修复 2024-05-22 15:14:26 +08:00
alger
32b39c7927 feat: 添加每日推荐 样式, 请求等大量优化 2024-05-22 12:07:48 +08:00
alger
c6f1e0b233 🌈 style: 修改issue模板 2024-05-21 11:36:06 +08:00
alger
7c1a3ae4bc 🌈 style: 添加issue模板 2024-05-21 11:33:57 +08:00
alger
6bd6622484 🌈 style: 添加gzip压缩配置 2024-05-21 11:24:33 +08:00
alger
433aff385d 🌈 style: 更换ico文件 2024-05-21 11:06:20 +08:00
alger
c37ad07f93 🌈 style: 优化类型 2024-05-21 11:01:23 +08:00
alger
e4c1f855fb 🐞 fix: 修复关闭报错 2024-05-21 10:27:46 +08:00
alger
6978656061 🌈 style: 更新logo.png 2024-05-21 10:24:32 +08:00
alger
973d60c98f 🌈 style: 完善gitgnore 2024-05-21 10:21:51 +08:00
alger
5a43ba2576 🌈 style: 删除无用配置 2024-05-21 10:18:40 +08:00
alger
e52a02cf3c 🌈 style: 去除无用代码 2024-05-21 10:16:30 +08:00
alger
da8216e2ca 🦄 refactor: 重构打包方式 2024-05-21 08:52:34 +08:00
alger
bd0e2ec35c feat: 历史纪录去除按钮 2024-05-20 19:55:52 +08:00
alger
7c8598ffa5 feat: 优化歌词体验 2024-05-20 19:54:00 +08:00
alger
50e594b91d 🐞 fix: 修复歌词界面无法打开问题 2024-05-16 19:57:20 +08:00
alger
a9e5bb33e4 feat: 添加eslint 和 桌面歌词(未完成) 2024-05-16 18:54:30 +08:00
alger
5e8676a039 📃 docs: 完善文档 2024-05-14 18:11:54 +08:00
alger
3522011224 🐞 fix: 修复登录状态问题 2024-01-04 10:18:00 +08:00
alger
820597e903 🐞 fix: 修复菜单问题 2024-01-04 10:02:57 +08:00
alger
f8efbe8ec6 🐞 fix: 修复播放次序问题 2024-01-04 09:55:41 +08:00
alger
67d42a2291 feat: 限制只能启动一个应用 2024-01-04 09:39:37 +08:00
algerkong
7ab43d2e9e feat: 样式优化 2024-01-03 22:27:58 +08:00
algerkong
a59351adf7 feat: 修复样式问题 2024-01-02 22:19:39 +08:00
alger
ad5d5458f1 🐞 fix: 修复播放历史不展示上下一首的问题 2024-01-02 11:08:02 +08:00
alger
adb539fbde 🌈 style: 去除无用代码 2024-01-02 09:22:31 +08:00
alger
ecd7a56df0 feat: 添加专辑列表播放 2024-01-01 00:06:52 +08:00
alger
2dbf5dbf03 feat: 添加播放历史计数 2023-12-29 16:13:05 +08:00
alger
492164d008 feat: 添加播放历史页面 2023-12-29 16:04:44 +08:00
alger
8da7fdabe5 🐞 fix: 修复搜索的播放列表错误问题 2023-12-28 11:40:29 +08:00
alger
a2c49d354e feat: 添加设置页面 可配置代理开关 2023-12-28 10:45:11 +08:00
algerkong
c7c1143cb4 feat: 修改搜索列表 2023-12-27 23:25:26 +08:00
algerkong
f5d097e975 feat: 优化路由持久化 2023-12-27 21:44:55 +08:00
algerkong
d04aeef40b feat: 优化mv播放 2023-12-27 21:44:32 +08:00
algerkong
a504b914fe feat: 优化播放条和mv播放时没有暂停音乐的问题 2023-12-27 21:05:25 +08:00
alger
62d414d659 feat: 添加热门mv页面 2023-12-27 18:21:01 +08:00
alger
6c57e77969 🎈 perf: 添加自动导入,优化性能 2023-12-27 14:40:22 +08:00
alger
70139e3ca4 feat: 优化样式,添加播放列表 2023-12-27 14:39:52 +08:00
alger
4dde40ac60 feat(MusicFull): 添加歌词背景动画 2023-12-22 09:50:03 +08:00
alger
be83a79b05 🐞 fix(Audio): 修复搜索跳转时 音乐一直播放暂停 2023-12-21 18:09:12 +08:00
alger
a77afb57fd feat(app): 修改app图标 2023-12-21 16:59:27 +08:00
alger
cd11db63eb feat: 完善播放列表问题 修复 滚动 2023-12-21 16:45:06 +08:00
alger
f81127432e 📃 docs: 添加概述 2023-12-21 16:44:17 +08:00
alger
4466713d1a 🐞 fix(app): 修复托盘透明问题 2023-12-21 16:43:51 +08:00
alger
73c915d184 🦄 refactor(MusicList): 重构播放列表组件 2023-12-21 11:26:51 +08:00
alger
7e6788a057 🐞 fix(Play): 修复播放监听和vip歌曲解析问题 2023-12-21 11:26:03 +08:00
alger
19140cd680 🐞 fix: 修复解析方法的问题 2023-12-20 16:19:16 +08:00
alger
a1780bc9d4 feat(music): 添加自动解析 并修改获取url的逻辑 2023-12-20 15:53:33 +08:00
alger
bb1b07e0b3 feat: 添加快捷键和关闭提示以及最小化功能 2023-12-20 10:23:15 +08:00
alger
7cb1b5fc7c 🐞 fix: 修复用户头像显示问题 2023-12-19 14:48:27 +08:00
alger
f70aa9e0a0 🐞 fix: 修复用户背景不展示的问题 2023-12-19 14:45:12 +08:00
alger
6c8229a21d feat: 修改样式和启动命令 2023-12-19 14:42:53 +08:00
algerkong
9211dcd3bb feat(build): 完善打包 2023-12-18 23:07:44 +08:00
alger
043ad5906b feat(打包初始化): 2023-12-18 19:39:36 +08:00
algerkc@qq.com
cf598f1c9c feat: 添加依赖以及配置 2023-12-18 16:41:56 +08:00
109 changed files with 5170 additions and 1607 deletions

View File

@@ -1,4 +1,3 @@
VITE_API = /api
VITE_API_MT = /mt
VITE_API_MUSIC = /music
VITE_API_PROXY = http://110.42.251.190:9856

View File

@@ -1,4 +1,3 @@
VITE_API = http://110.42.251.190:9898
VITE_API_MT = http://mt.myalger.top
VITE_API_MUSIC = http://myalger.top:4000
VITE_API_MUSIC = http://110.42.251.190:4100
VITE_API_PROXY = http://110.42.251.190:9856

13
.eslintignore Normal file
View File

@@ -0,0 +1,13 @@
snapshot*
dist
lib
es
esm
node_modules
static
cypress
script/test/cypress
_site
temp*
static/
!.prettierrc.js

133
.eslintrc Normal file
View File

@@ -0,0 +1,133 @@
{
"extends": [
"plugin:@typescript-eslint/recommended",
"eslint-config-airbnb-base",
"@vue/typescript/recommended",
"plugin:vue/vue3-recommended",
"plugin:vue-scoped-css/base",
"plugin:prettier/recommended"
],
"env": {
"browser": true,
"node": true,
"jest": true,
"es6": true
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly"
},
"plugins": [
"vue",
"@typescript-eslint",
"simple-import-sort"
],
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module",
"allowImportExportEverywhere": true,
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"import/extensions": [
".js",
".jsx",
".ts",
".tsx"
]
},
"rules": {
"no-console": "off",
"no-continue": "off",
"no-restricted-syntax": "off",
"no-plusplus": "off",
"no-param-reassign": "off",
"no-shadow": "off",
"guard-for-in": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": "off",
"import/prefer-default-export": "off",
"import/first": "off", // https://github.com/vuejs/vue-eslint-parser/issues/58
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"vue/first-attribute-linebreak": 0,
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off",
"class-methods-use-this": "off", // 因为AxiosCancel必须实例化而能静态化所以加的规则如果有办法解决可以取消
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
},
"overrides": [
{
"files": [
"*.vue"
],
"rules": {
"vue/component-name-in-template-casing": [
2,
"kebab-case"
],
"vue/require-default-prop": 0,
"vue/multi-word-component-names": 0,
"vue/no-reserved-props": 0,
"vue/no-v-html": 0,
"vue-scoped-css/enforce-style-type": [
"error",
{
"allows": [
"scoped"
]
}
]
}
},
{
"files": [
"*.ts",
"*.tsx"
], // https://github.com/typescript-eslint eslint-recommended
"rules": {
"constructor-super": "off", // ts(2335) & ts(2377)
"getter-return": "off", // ts(2378)
"no-const-assign": "off", // ts(2588)
"no-dupe-args": "off", // ts(2300)
"no-dupe-class-members": "off", // ts(2393) & ts(2300)
"no-dupe-keys": "off", // ts(1117)
"no-func-assign": "off", // ts(2539)
"no-import-assign": "off", // ts(2539) & ts(2540)
"no-new-symbol": "off", // ts(2588)
"no-obj-calls": "off", // ts(2349)
"no-redeclare": "off", // ts(2451)
"no-setter-return": "off", // ts(2408)
"no-this-before-super": "off", // ts(2376)
"no-undef": "off", // ts(2304)
"no-unreachable": "off", // ts(7027)
"no-unsafe-negation": "off", // ts(2365) & ts(2360) & ts(2358)
"no-var": "error", // ts transpiles let/const to var, so no need for vars any more
"prefer-const": "error", // ts provides better types with const
"prefer-rest-params": "error", // ts provides better types with rest args over arguments
"prefer-spread": "error", // ts transpiles spread to apply, so no need for manual apply
"valid-typeof": "off" // ts(2367)
}
}
]
}

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @algerkong

View File

@@ -0,0 +1,70 @@
name: 反馈 Bug
description: 通过 github 模板进行 Bug 反馈。
title: "描述问题的标题"
body:
- type: markdown
attributes:
value: |
# 欢迎你的参与
Issue 列表接受 bug 报告或是新功能请求。
在发布一个 Issue 前,请确保:
- 在Issue中搜索过你的问题。你的问题可能已有人提出也可能已在最新版本中被修正
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
- type: input
id: reproduce
attributes:
label: 重现链接
description: 请提供尽可能精简的 CodePen、CodeSandbox 或 GitHub 仓库的链接。请不要填无关链接,否则你的 Issue 将被关闭。
placeholder: 请填写
- type: textarea
id: reproduceSteps
attributes:
label: 重现步骤
description: 请清晰的描述重现该 Issue 的步骤,这能帮助我们快速定位问题。没有清晰重现步骤将不会被修复,标有 'need reproduction' 的 Issue 在 7 天内不提供相关步骤,将被关闭。
placeholder: 请填写
- type: textarea
id: expect
attributes:
label: 期望结果
placeholder: 请填写
- type: textarea
id: actual
attributes:
label: 实际结果
placeholder: 请填写
- type: input
id: frameworkVersion
attributes:
label: 框架版本
placeholder: Vue(3.3.0)
- type: input
id: browsersVersion
attributes:
label: 浏览器版本
placeholder: Chrome(8.213.231.123)
- type: input
id: systemVersion
attributes:
label: 系统版本
placeholder: MacOS(11.2.3)
- type: input
id: nodeVersion
attributes:
label: Node版本
placeholder: 请填写
- type: textarea
id: remarks
attributes:
label: 补充说明
description: 可以是遇到这个 bug 的业务场景、上下文等信息。
placeholder: 请填写

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name:
url:
about:

View File

@@ -0,0 +1,29 @@
name: 反馈新功能
description: 通过 github 模板进行新功能反馈。
title: "描述问题的标题"
body:
- type: markdown
attributes:
value: |
# 欢迎你的参与
在发布一个 Issue 前,请确保:
- 在 Issue 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
- type: textarea
id: functionContent
attributes:
label: 这个功能解决了什么问题
description: 请详尽说明这个需求的用例和场景。最重要的是:解释清楚是怎样的用户体验需求催生了这个功能上的需求。我们将考虑添加在现有 API 无法轻松实现的功能。新功能的用例也应当足够常见。
placeholder: 请填写
validations:
required: true
- type: textarea
id: functionalExpectations
attributes:
label: 你建议的方案是什么
placeholder: 请填写
validations:
required: true

51
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,51 @@
<!--
首先,感谢你的贡献!😄
PR 在维护者审核通过后会合并,谢谢!
-->
### 🤔 这个 PR 的性质是?
- [ ] 日常 bug 修复
- [ ] 新特性提交
- [ ] 文档改进
- [ ] 演示代码改进
- [ ] 组件样式/交互改进
- [ ] CI/CD 改进
- [ ] 重构
- [ ] 代码风格优化
- [ ] 测试用例
- [ ] 分支合并
- [ ] 其他
### 🔗 相关 Issue
<!--
1. 描述相关需求的来源,如相关的 issue 讨论链接。
-->
### 💡 需求背景和解决方案
<!--
1. 要解决的具体问题。
2. 列出最终的 API 实现和用法。
3. 涉及UI/交互变动需要有截图或 GIF。
-->
### 📝 更新日志
<!--
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
-->
- fix(组件名称): 处理问题或特性描述 ...
- [ ] 本条 PR 不需要纳入 Changelog
### ☑️ 请求合并前的自查清单
⚠️ 请自检并全部**勾选全部选项**。⚠️
- [ ] 文档已补充或无须补充
- [ ] 代码演示已提供或无须提供
- [ ] TypeScript 定义已补充或无须补充
- [ ] Changelog 已提供或无须提供

20
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
# Basic dependabot.yml file with
# minimum configuration for two package managers
version: 2
updates:
# Enable version updates for npm
- package-ecosystem: 'npm'
# Look for `package.json` and `lock` files in the `root` directory
directory: '/'
# Check the npm registry for updates every day (weekdays)
schedule:
interval: 'monthly'
# Enable version updates for Docker
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `root` directory
directory: '/'
# Check for updates once a week
schedule:
interval: 'monthly'

8
.github/issue-shoot.md vendored Normal file
View File

@@ -0,0 +1,8 @@
## IssueShoot
- 预估时长: {{ .duration }}
- 期望完成时间: {{ .deadline }}
- 开发难度: {{ .level }}
- 参与人数: 1
- 需求对接人: ivringpeng
- 验收标准: 实现期望改造效果,提 PR 并通过验收无误
- 备注: 最终激励以实际提交 `pull request` 并合并为准

10
.gitignore vendored
View File

@@ -1,8 +1,16 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
dist_electron
.idea
# lock
yarn.lock
pnpm-lock.yaml
dist.zip
package-lock.json
dist.zip
.vscode

39
.prettierrc.js Normal file
View File

@@ -0,0 +1,39 @@
module.exports = {
// 一行最多 120 字符..
printWidth: 120,
// 使用 2 个空格缩进
tabWidth: 2,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾需要有逗号
trailingComma: 'all',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// vue 文件中的 script 和 style 内不用缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf
endOfLine: 'lf',
};

View File

@@ -1,3 +0,0 @@
{
"compile-hero.disable-compile-files-on-did-save-code": true
}

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,5 +1,43 @@
# Vue 3 + Typescript + Vite
# 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
主要功能如下
vue3 + TypeScript + NaiveUI + animateCss + Vuex + VueRouter + Axios等实现音乐桌面web端
实现各项功能
网站地址http://mc.myalger.top/
- 音乐推荐
- 音乐播放
- 网易云登录
- 播放历史
- 桌面歌词
- 歌单 mv 搜索 专辑等功能
## 项目运行
```bash
# 安装依赖
npm install
# 运行项目 web
npm run dev
# 运行项目 electron
npm run start
# 打包项目 web
npm run build
# 打包项目 electron
npm run win ...
# 具体看 package.json
```
## 软件截图
![首页](./docs/img/image.png)
![歌单](./docs/img/image-1.png)
![搜索](./docs/img/image-2.png)
![mv](./docs/img/image-3.png)
![历史](./docs/img/image-4.png)
![我的](./docs/img/image-5.png)
## 欢迎提Issues
## 免责声明
本软件仅用于学习交流,禁止用于商业用途,否则后果自负。

145
app.js Normal file
View File

@@ -0,0 +1,145 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, globalShortcut, nativeImage } = require('electron');
const path = require('path');
const Store = require('electron-store');
const setJson = require('./electron/set.json');
const { loadLyricWindow } = require('./electron/lyric');
let mainWin = null;
function createWindow() {
mainWin = new BrowserWindow({
width: 1200,
height: 780,
frame: false,
webPreferences: {
nodeIntegration: true,
// contextIsolation: false,
preload: path.join(__dirname, '/electron/preload.js'),
},
});
const win = mainWin;
win.setMinimumSize(1200, 780);
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'detach' });
win.loadURL('http://localhost:4678/');
} else {
win.loadURL(`file://${__dirname}/dist/index.html`);
}
const image = nativeImage.createFromPath(path.join(__dirname, 'public/icon.png'));
const tray = new Tray(image);
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
win.show();
},
},
{
label: '退出',
click: () => {
win.destroy();
app.quit();
},
},
]);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (win.isVisible()) {
win.hide();
} else {
win.show();
}
});
const set = store.get('set');
// store.set('set', setJson)
if (!set) {
store.set('set', setJson);
}
loadLyricWindow(ipcMain);
}
// 限制只能启动一个应用
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
}
app.whenReady().then(createWindow);
app.on('ready', () => {
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
if (mainWin.isVisible()) {
mainWin.hide();
} else {
mainWin.show();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.minimize();
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
});
ipcMain.on('close-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.destroy();
app.quit();
});
ipcMain.on('drag-start', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.webContents.beginFrameSubscription((frameBuffer) => {
event.reply('frame-buffer', frameBuffer);
});
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.hide();
});
// 重启
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
const store = new Store();
// 定义ipcRenderer监听事件
ipcMain.on('setStore', (_, key, value) => {
store.set(key, value);
});
ipcMain.on('getStore', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
});

70
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}

31
build/win32.json Normal file
View File

@@ -0,0 +1,31 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_x86.${ext}",
"directories": {
"output": "dist_electron/win-x86"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["ia32"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

31
build/win64.json Normal file
View File

@@ -0,0 +1,31 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_x64.${ext}",
"directories": {
"output": "dist_electron/win-x64"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

31
build/winarm64.json Normal file
View File

@@ -0,0 +1,31 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_arm64.${ext}",
"directories": {
"output": "dist_electron/win-arm64"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*", "!node_modules/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["arm64"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

41
components.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
MPop: typeof import('./src/components/common/MPop.vue')['default']
MusicList: typeof import('./src/components/MusicList.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTooltip: typeof import('naive-ui')['NTooltip']
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
PlaylistType: typeof import('./src/components/PlaylistType.vue')['default']
PlayVideo: typeof import('./src/components/common/PlayVideo.vue')['default']
RecommendAlbum: typeof import('./src/components/RecommendAlbum.vue')['default']
RecommendSinger: typeof import('./src/components/RecommendSinger.vue')['default']
RecommendSonglist: typeof import('./src/components/RecommendSonglist.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchItem: typeof import('./src/components/common/SearchItem.vue')['default']
SongItem: typeof import('./src/components/common/SongItem.vue')['default']
}
}

BIN
docs/img/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

BIN
docs/img/image-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
docs/img/image-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
docs/img/image-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
docs/img/image-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

BIN
docs/img/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

71
electron/lyric.js Normal file
View File

@@ -0,0 +1,71 @@
const { BrowserWindow } = require('electron');
const path = require('path');
let lyricWindow = null;
const createWin = () => {
lyricWindow = new BrowserWindow({
width: 800,
height: 300,
frame: false,
show: false,
transparent: true,
webPreferences: {
nodeIntegration: true,
preload: `${__dirname}/preload.js`,
contextIsolation: false,
},
});
};
const loadLyricWindow = (ipcMain) => {
ipcMain.on('open-lyric', () => {
if (lyricWindow) {
if (lyricWindow.isMinimized()) lyricWindow.restore();
lyricWindow.focus();
lyricWindow.show();
return;
}
createWin();
if (process.env.NODE_ENV === 'development') {
lyricWindow.webContents.openDevTools({ mode: 'detach' });
lyricWindow.loadURL('http://localhost:4678/#/lyric');
} else {
const distPath = path.resolve(__dirname, '../dist');
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
}
lyricWindow.setMinimumSize(600, 200);
// 隐藏任务栏
lyricWindow.setSkipTaskbar(true);
lyricWindow.show();
});
ipcMain.on('send-lyric', (e, data) => {
if (lyricWindow) {
lyricWindow.webContents.send('receive-lyric', data);
}
});
ipcMain.on('top-lyric', (e, data) => {
lyricWindow.setAlwaysOnTop(data);
});
ipcMain.on('close-lyric', () => {
lyricWindow.close();
lyricWindow = null;
});
ipcMain.on('mouseenter-lyric', () => {
lyricWindow.setIgnoreMouseEvents(true);
});
ipcMain.on('mouseleave-lyric', () => {
lyricWindow.setIgnoreMouseEvents(false);
});
};
module.exports = {
loadLyricWindow,
};

28
electron/preload.js Normal file
View File

@@ -0,0 +1,28 @@
const { contextBridge, ipcRenderer, app } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
minimize: () => ipcRenderer.send('minimize-window'),
maximize: () => ipcRenderer.send('maximize-window'),
close: () => ipcRenderer.send('close-window'),
dragStart: (data) => ipcRenderer.send('drag-start', data),
miniTray: () => ipcRenderer.send('mini-tray'),
restart: () => ipcRenderer.send('restart'),
openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
});
const electronHandler = {
ipcRenderer: {
setStoreValue: (key, value) => {
ipcRenderer.send('setStore', key, value);
},
getStoreValue(key) {
const resp = ipcRenderer.sendSync('getStore', key);
return resp;
},
},
app,
};
contextBridge.exposeInMainWorld('electron', electronHandler);

5
electron/set.json Normal file
View File

@@ -0,0 +1,5 @@
{
"version": "1.5.0",
"isProxy": false,
"author": "alger"
}

View File

@@ -1,24 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>网抑云 | algerkong</title>
<link
rel="stylesheet"
href="//at.alicdn.com/t/font_2685283_5bo4ekd5wh.css"
/>
<link rel="stylesheet" href="./public/css/animate.css" />
<style>
:root {
--animate-delay: 0.5s;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>网抑云 | algerkong</title>
<link rel="manifest" href="./public/manifest.json" />
<link rel="stylesheet" href="./public/icon/iconfont.css" />
<link rel="stylesheet" href="./public/css/animate.css" />
<link rel="stylesheet" href="./public/css/base.css" />
<style>
:root {
--animate-delay: 0.5s;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,33 +1,61 @@
{
"version": "0.0.0",
"name": "alger-music",
"version": "1.5.0",
"description": "这是一个用于音乐播放的应用程序。",
"author": "Alger <algerkc@qq.com>",
"main": "app.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
"serve": "vite preview",
"start": "set NODE_ENV=development&&electron .",
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
"b:win:x64": "electron-builder --config ./build/win64.json",
"b:win:x86": "electron-builder --config ./build/win32.json",
"b:win:arm": "electron-builder --config ./build/winarm64.json"
},
"dependencies": {
"@tailwindcss/postcss7-compat": "^2.2.4",
"@vue/runtime-core": "^3.3.4",
"autoprefixer": "^9.8.6",
"axios": "^0.21.1",
"lodash": "^4.17.21",
"postcss": "^7.0.36",
"sass": "^1.35.2",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vuex": "^4.1.0"
"electron-store": "^8.1.0"
},
"devDependencies": {
"@sicons/antd": "^0.10.0",
"@vicons/antd": "^0.10.0",
"@tailwindcss/postcss7-compat": "^2.2.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/compiler-sfc": "^3.3.4",
"naive-ui": "^2.34.4",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/runtime-core": "^3.3.4",
"@vueuse/core": "^10.7.1",
"@vueuse/electron": "^10.9.0",
"autoprefixer": "^9.8.6",
"axios": "^0.21.1",
"electron": "^30.0.0",
"electron-builder": "^24.13.0",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-vue": "^9.21.1",
"eslint-plugin-vue-scoped-css": "^2.7.2",
"lodash": "^4.17.21",
"naive-ui": "^2.38.2",
"postcss": "^7.0.36",
"prettier": "^3.2.5",
"remixicon": "^4.2.0",
"sass": "^1.35.2",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.2",
"unplugin-auto-import": "^0.17.2",
"unplugin-vue-components": "^0.26.0",
"vfonts": "^0.1.0",
"vite": "^4.4.7",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "1.0.0-beta.5",
"vue-tsc": "^0.0.24"
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vue-tsc": "^0.0.24",
"vuex": "^4.1.0"
}
}

3
public/css/base.css Normal file
View File

@@ -0,0 +1,3 @@
body{
background-color: #000;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 178 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

283
public/icon/iconfont.css Normal file
View File

@@ -0,0 +1,283 @@
@font-face {
font-family: "iconfont"; /* Project id 2685283 */
src: url('iconfont.woff2?t=1703643214551') format('woff2'),
url('iconfont.woff?t=1703643214551') format('woff'),
url('iconfont.ttf?t=1703643214551') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-list:before {
content: "\e603";
}
.icon-maxsize:before {
content: "\e692";
}
.icon-close:before {
content: "\e616";
}
.icon-minisize:before {
content: "\e602";
}
.icon-shuaxin:before {
content: "\e627";
}
.icon-icon_error:before {
content: "\e615";
}
.icon-a-3User:before {
content: "\e601";
}
.icon-Chat:before {
content: "\e605";
}
.icon-Category:before {
content: "\e606";
}
.icon-Document:before {
content: "\e607";
}
.icon-Heart:before {
content: "\e608";
}
.icon-Hide:before {
content: "\e609";
}
.icon-Home:before {
content: "\e60a";
}
.icon-a-Image2:before {
content: "\e60b";
}
.icon-Profile:before {
content: "\e60c";
}
.icon-Search:before {
content: "\e60d";
}
.icon-Paper:before {
content: "\e60e";
}
.icon-Play:before {
content: "\e60f";
}
.icon-Setting:before {
content: "\e610";
}
.icon-a-TicketStar:before {
content: "\e611";
}
.icon-a-VolumeOff:before {
content: "\e612";
}
.icon-a-VolumeUp:before {
content: "\e613";
}
.icon-a-VolumeDown:before {
content: "\e614";
}
.icon-stop:before {
content: "\e600";
}
.icon-next:before {
content: "\e6a9";
}
.icon-prev:before {
content: "\e6ac";
}
.icon-play:before {
content: "\e6aa";
}
.icon-xiasanjiaoxing:before {
content: "\e642";
}
.icon-videofill:before {
content: "\e7c7";
}
.icon-favorfill:before {
content: "\e64b";
}
.icon-favor:before {
content: "\e64c";
}
.icon-loading:before {
content: "\e64f";
}
.icon-search:before {
content: "\e65c";
}
.icon-likefill:before {
content: "\e668";
}
.icon-like:before {
content: "\e669";
}
.icon-notificationfill:before {
content: "\e66a";
}
.icon-notification:before {
content: "\e66b";
}
.icon-evaluate:before {
content: "\e672";
}
.icon-homefill:before {
content: "\e6bb";
}
.icon-link:before {
content: "\e6bf";
}
.icon-roundaddfill:before {
content: "\e6d8";
}
.icon-roundadd:before {
content: "\e6d9";
}
.icon-add:before {
content: "\e6da";
}
.icon-appreciatefill:before {
content: "\e6e3";
}
.icon-forwardfill:before {
content: "\e6ea";
}
.icon-voicefill:before {
content: "\e6f0";
}
.icon-wefill:before {
content: "\e6f4";
}
.icon-keyboard:before {
content: "\e71b";
}
.icon-picfill:before {
content: "\e72c";
}
.icon-markfill:before {
content: "\e730";
}
.icon-presentfill:before {
content: "\e732";
}
.icon-peoplefill:before {
content: "\e735";
}
.icon-read:before {
content: "\e742";
}
.icon-backwardfill:before {
content: "\e74d";
}
.icon-playfill:before {
content: "\e74f";
}
.icon-all:before {
content: "\e755";
}
.icon-hotfill:before {
content: "\e757";
}
.icon-recordfill:before {
content: "\e7a4";
}
.icon-full:before {
content: "\e7bc";
}
.icon-favor_fill_light:before {
content: "\e7ec";
}
.icon-round_favor_fill:before {
content: "\e80a";
}
.icon-round_location_fill:before {
content: "\e80b";
}
.icon-round_like_fill:before {
content: "\e80c";
}
.icon-round_people_fill:before {
content: "\e80d";
}
.icon-round_skin_fill:before {
content: "\e80e";
}
.icon-broadcast_fill:before {
content: "\e81d";
}
.icon-card_fill:before {
content: "\e81f";
}

1
public/icon/iconfont.js Normal file

File diff suppressed because one or more lines are too long

478
public/icon/iconfont.json Normal file
View File

@@ -0,0 +1,478 @@
{
"id": "2685283",
"name": "music",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "1111849",
"name": "list",
"font_class": "list",
"unicode": "e603",
"unicode_decimal": 58883
},
{
"icon_id": "1306794",
"name": "maxsize",
"font_class": "maxsize",
"unicode": "e692",
"unicode_decimal": 59026
},
{
"icon_id": "4437591",
"name": "close",
"font_class": "close",
"unicode": "e616",
"unicode_decimal": 58902
},
{
"icon_id": "5383753",
"name": "minisize",
"font_class": "minisize",
"unicode": "e602",
"unicode_decimal": 58882
},
{
"icon_id": "13075017",
"name": "刷新",
"font_class": "shuaxin",
"unicode": "e627",
"unicode_decimal": 58919
},
{
"icon_id": "24457556",
"name": "icon_error",
"font_class": "icon_error",
"unicode": "e615",
"unicode_decimal": 58901
},
{
"icon_id": "24492642",
"name": "3 User",
"font_class": "a-3User",
"unicode": "e601",
"unicode_decimal": 58881
},
{
"icon_id": "24492643",
"name": "Chat",
"font_class": "Chat",
"unicode": "e605",
"unicode_decimal": 58885
},
{
"icon_id": "24492646",
"name": "Category",
"font_class": "Category",
"unicode": "e606",
"unicode_decimal": 58886
},
{
"icon_id": "24492661",
"name": "Document",
"font_class": "Document",
"unicode": "e607",
"unicode_decimal": 58887
},
{
"icon_id": "24492662",
"name": "Heart",
"font_class": "Heart",
"unicode": "e608",
"unicode_decimal": 58888
},
{
"icon_id": "24492665",
"name": "Hide",
"font_class": "Hide",
"unicode": "e609",
"unicode_decimal": 58889
},
{
"icon_id": "24492667",
"name": "Home",
"font_class": "Home",
"unicode": "e60a",
"unicode_decimal": 58890
},
{
"icon_id": "24492678",
"name": "Image 2",
"font_class": "a-Image2",
"unicode": "e60b",
"unicode_decimal": 58891
},
{
"icon_id": "24492684",
"name": "Profile",
"font_class": "Profile",
"unicode": "e60c",
"unicode_decimal": 58892
},
{
"icon_id": "24492685",
"name": "Search",
"font_class": "Search",
"unicode": "e60d",
"unicode_decimal": 58893
},
{
"icon_id": "24492687",
"name": "Paper",
"font_class": "Paper",
"unicode": "e60e",
"unicode_decimal": 58894
},
{
"icon_id": "24492690",
"name": "Play",
"font_class": "Play",
"unicode": "e60f",
"unicode_decimal": 58895
},
{
"icon_id": "24492698",
"name": "Setting",
"font_class": "Setting",
"unicode": "e610",
"unicode_decimal": 58896
},
{
"icon_id": "24492708",
"name": "Ticket Star",
"font_class": "a-TicketStar",
"unicode": "e611",
"unicode_decimal": 58897
},
{
"icon_id": "24492712",
"name": "Volume Off",
"font_class": "a-VolumeOff",
"unicode": "e612",
"unicode_decimal": 58898
},
{
"icon_id": "24492713",
"name": "Volume Up",
"font_class": "a-VolumeUp",
"unicode": "e613",
"unicode_decimal": 58899
},
{
"icon_id": "24492714",
"name": "Volume Down",
"font_class": "a-VolumeDown",
"unicode": "e614",
"unicode_decimal": 58900
},
{
"icon_id": "18875422",
"name": "暂停 停止 灰色",
"font_class": "stop",
"unicode": "e600",
"unicode_decimal": 58880
},
{
"icon_id": "15262786",
"name": "1_music82",
"font_class": "next",
"unicode": "e6a9",
"unicode_decimal": 59049
},
{
"icon_id": "15262807",
"name": "1_music83",
"font_class": "prev",
"unicode": "e6ac",
"unicode_decimal": 59052
},
{
"icon_id": "15262830",
"name": "1_music81",
"font_class": "play",
"unicode": "e6aa",
"unicode_decimal": 59050
},
{
"icon_id": "15367",
"name": "下三角形",
"font_class": "xiasanjiaoxing",
"unicode": "e642",
"unicode_decimal": 58946
},
{
"icon_id": "1096518",
"name": "video_fill",
"font_class": "videofill",
"unicode": "e7c7",
"unicode_decimal": 59335
},
{
"icon_id": "29930",
"name": "favor_fill",
"font_class": "favorfill",
"unicode": "e64b",
"unicode_decimal": 58955
},
{
"icon_id": "29931",
"name": "favor",
"font_class": "favor",
"unicode": "e64c",
"unicode_decimal": 58956
},
{
"icon_id": "29934",
"name": "loading",
"font_class": "loading",
"unicode": "e64f",
"unicode_decimal": 58959
},
{
"icon_id": "29947",
"name": "search",
"font_class": "search",
"unicode": "e65c",
"unicode_decimal": 58972
},
{
"icon_id": "30417",
"name": "like_fill",
"font_class": "likefill",
"unicode": "e668",
"unicode_decimal": 58984
},
{
"icon_id": "30418",
"name": "like",
"font_class": "like",
"unicode": "e669",
"unicode_decimal": 58985
},
{
"icon_id": "30419",
"name": "notification_fill",
"font_class": "notificationfill",
"unicode": "e66a",
"unicode_decimal": 58986
},
{
"icon_id": "30420",
"name": "notification",
"font_class": "notification",
"unicode": "e66b",
"unicode_decimal": 58987
},
{
"icon_id": "30434",
"name": "evaluate",
"font_class": "evaluate",
"unicode": "e672",
"unicode_decimal": 58994
},
{
"icon_id": "33519",
"name": "home_fill",
"font_class": "homefill",
"unicode": "e6bb",
"unicode_decimal": 59067
},
{
"icon_id": "34922",
"name": "link",
"font_class": "link",
"unicode": "e6bf",
"unicode_decimal": 59071
},
{
"icon_id": "38744",
"name": "round_add_fill",
"font_class": "roundaddfill",
"unicode": "e6d8",
"unicode_decimal": 59096
},
{
"icon_id": "38746",
"name": "round_add",
"font_class": "roundadd",
"unicode": "e6d9",
"unicode_decimal": 59097
},
{
"icon_id": "38747",
"name": "add",
"font_class": "add",
"unicode": "e6da",
"unicode_decimal": 59098
},
{
"icon_id": "43903",
"name": "appreciate_fill",
"font_class": "appreciatefill",
"unicode": "e6e3",
"unicode_decimal": 59107
},
{
"icon_id": "52506",
"name": "forward_fill",
"font_class": "forwardfill",
"unicode": "e6ea",
"unicode_decimal": 59114
},
{
"icon_id": "55448",
"name": "voice_fill",
"font_class": "voicefill",
"unicode": "e6f0",
"unicode_decimal": 59120
},
{
"icon_id": "61146",
"name": "we_fill",
"font_class": "wefill",
"unicode": "e6f4",
"unicode_decimal": 59124
},
{
"icon_id": "90847",
"name": "keyboard",
"font_class": "keyboard",
"unicode": "e71b",
"unicode_decimal": 59163
},
{
"icon_id": "127305",
"name": "pic_fill",
"font_class": "picfill",
"unicode": "e72c",
"unicode_decimal": 59180
},
{
"icon_id": "143738",
"name": "mark_fill",
"font_class": "markfill",
"unicode": "e730",
"unicode_decimal": 59184
},
{
"icon_id": "143740",
"name": "present_fill",
"font_class": "presentfill",
"unicode": "e732",
"unicode_decimal": 59186
},
{
"icon_id": "158873",
"name": "people_fill",
"font_class": "peoplefill",
"unicode": "e735",
"unicode_decimal": 59189
},
{
"icon_id": "176313",
"name": "read",
"font_class": "read",
"unicode": "e742",
"unicode_decimal": 59202
},
{
"icon_id": "212324",
"name": "backward_fill",
"font_class": "backwardfill",
"unicode": "e74d",
"unicode_decimal": 59213
},
{
"icon_id": "212328",
"name": "play_fill",
"font_class": "playfill",
"unicode": "e74f",
"unicode_decimal": 59215
},
{
"icon_id": "240126",
"name": "all",
"font_class": "all",
"unicode": "e755",
"unicode_decimal": 59221
},
{
"icon_id": "240128",
"name": "hot_fill",
"font_class": "hotfill",
"unicode": "e757",
"unicode_decimal": 59223
},
{
"icon_id": "747747",
"name": "record_fill",
"font_class": "recordfill",
"unicode": "e7a4",
"unicode_decimal": 59300
},
{
"icon_id": "1005712",
"name": "full",
"font_class": "full",
"unicode": "e7bc",
"unicode_decimal": 59324
},
{
"icon_id": "1512759",
"name": "favor_fill_light",
"font_class": "favor_fill_light",
"unicode": "e7ec",
"unicode_decimal": 59372
},
{
"icon_id": "4110741",
"name": "round_favor_fill",
"font_class": "round_favor_fill",
"unicode": "e80a",
"unicode_decimal": 59402
},
{
"icon_id": "4110743",
"name": "round_location_fill",
"font_class": "round_location_fill",
"unicode": "e80b",
"unicode_decimal": 59403
},
{
"icon_id": "4110745",
"name": "round_like_fill",
"font_class": "round_like_fill",
"unicode": "e80c",
"unicode_decimal": 59404
},
{
"icon_id": "4110746",
"name": "round_people_fill",
"font_class": "round_people_fill",
"unicode": "e80d",
"unicode_decimal": 59405
},
{
"icon_id": "4110750",
"name": "round_skin_fill",
"font_class": "round_skin_fill",
"unicode": "e80e",
"unicode_decimal": 59406
},
{
"icon_id": "11778953",
"name": "broadcast_fill",
"font_class": "broadcast_fill",
"unicode": "e81d",
"unicode_decimal": 59421
},
{
"icon_id": "12625085",
"name": "card_fill",
"font_class": "card_fill",
"unicode": "e81f",
"unicode_decimal": 59423
}
]
}

BIN
public/icon/iconfont.ttf Normal file

Binary file not shown.

BIN
public/icon/iconfont.woff Normal file

Binary file not shown.

BIN
public/icon/iconfont.woff2 Normal file

Binary file not shown.

BIN
public/icon1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

10
public/manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "Alger Music PWA",
"icons": [
{
"src": "./icon.png",
"type": "image/png",
"sizes": "256x256"
}
]
}

View File

@@ -1,22 +1,50 @@
<template>
<div class="app">
<div class="app" :class="isMobile ? 'mobile' : ''">
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
<n-config-provider :theme="darkTheme">
<router-view></router-view>
<n-dialog-provider>
<keep-alive>
<router-view></router-view>
</keep-alive>
</n-dialog-provider>
</n-config-provider>
</div>
</template>
<script lang="ts" setup>
import { darkTheme } from 'naive-ui'
import { darkTheme } from 'naive-ui';
import store from '@/store';
import { isMobile } from './utils';
const playMusicUrl = computed(() => store.state.playMusicUrl as string);
// 是否播放
const play = computed(() => store.state.play as boolean);
const windowData = window as any;
onMounted(() => {
if (windowData.electron) {
const setData = windowData.electron.ipcRenderer.getStoreValue('set');
store.commit('setSetData', setData);
}
});
</script>
<style lang="scss" scoped >
<style lang="scss" scoped>
div {
box-sizing: border-box;
}
.app {
user-select: none;
}
.mobile {
.text-base {
font-size: 14px !important;
}
}
.html:has(.mobile) {
font-size: 14px;
}
</style>

View File

@@ -1,9 +1,11 @@
import request from "@/utils/request";
import { IHotSinger } from "@/type/singer";
import { ISearchKeyword, IHotSearch } from "@/type/search";
import { IPlayListSort } from "@/type/playlist";
import { IRecommendMusic } from "@/type/music";
import { IAlbumNew } from "@/type/album";
import { IData } from '@/type';
import { IAlbumNew } from '@/type/album';
import { IDayRecommend } from '@/type/day_recommend';
import { IRecommendMusic } from '@/type/music';
import { IPlayListSort } from '@/type/playlist';
import { IHotSearch, ISearchKeyword } from '@/type/search';
import { IHotSinger } from '@/type/singer';
import request from '@/utils/request';
interface IHotSingerParams {
offset: number;
@@ -16,30 +18,35 @@ interface IRecommendMusicParams {
// 获取热门歌手
export const getHotSinger = (params: IHotSingerParams) => {
return request.get<IHotSinger>("/top/artists", { params });
return request.get<IHotSinger>('/top/artists', { params });
};
// 获取搜索推荐词
export const getSearchKeyword = () => {
return request.get<ISearchKeyword>("/search/default");
return request.get<ISearchKeyword>('/search/default');
};
// 获取热门搜索
export const getHotSearch = () => {
return request.get<IHotSearch>("/search/hot/detail");
return request.get<IHotSearch>('/search/hot/detail');
};
// 获取歌单分类
export const getPlaylistCategory = () => {
return request.get<IPlayListSort>("/playlist/catlist");
return request.get<IPlayListSort>('/playlist/catlist');
};
// 获取推荐音乐
export const getRecommendMusic = (params: IRecommendMusicParams) => {
return request.get<IRecommendMusic>("/personalized/newsong", { params });
return request.get<IRecommendMusic>('/personalized/newsong', { params });
};
// 获取每日推荐
export const getDayRecommend = () => {
return request.get<IData<IDayRecommend>>('/recommend/songs');
};
// 获取最新专辑推荐
export const getNewAlbum = () => {
return request.get<IAlbumNew>("/album/newest");
return request.get<IAlbumNew>('/album/newest');
};

View File

@@ -1,6 +1,6 @@
import request from "@/utils/request";
import { IList } from "@/type/list";
import type { IListDetail } from "@/type/listDetail";
import { IList } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import request from '@/utils/request';
interface IListByTagParams {
tag: string;
@@ -16,22 +16,27 @@ interface IListByCatParams {
// 根据tag 获取歌单列表
export function getListByTag(params: IListByTagParams) {
return request.get<IList>("/top/playlist/highquality", { params: params });
return request.get<IList>('/top/playlist/highquality', { params });
}
// 根据cat 获取歌单列表
export function getListByCat(params: IListByCatParams) {
return request.get("/top/playlist", {
params: params,
return request.get('/top/playlist', {
params,
});
}
// 获取推荐歌单
export function getRecommendList(limit: number = 30) {
return request.get("/personalized", { params: { limit } });
return request.get('/personalized', { params: { limit } });
}
// 获取歌单详情
export function getListDetail(id: number | string) {
return request.get<IListDetail>("/playlist/detail", { params: { id } });
return request.get<IListDetail>('/playlist/detail', { params: { id } });
}
// 获取专辑内容
export function getAlbum(id: number | string) {
return request.get('/album', { params: { id } });
}

View File

@@ -1,46 +1,46 @@
import request from "@/utils/request";
import request from '@/utils/request';
// 创建二维码key
// /login/qr/key
export function getQrKey() {
return request.get("/login/qr/key");
return request.get('/login/qr/key');
}
// 创建二维码
// /login/qr/create
export function createQr(key: any) {
return request.get("/login/qr/create", { params: { key: key, qrimg: true } });
return request.get('/login/qr/create', { params: { key, qrimg: true } });
}
// 获取二维码状态
// /login/qr/check
export function checkQr(key: any) {
return request.get("/login/qr/check", { params: { key: key } });
return request.get('/login/qr/check', { params: { key } });
}
// 获取登录状态
// /login/status
export function getLoginStatus() {
return request.get("/login/status");
return request.get('/login/status');
}
// 获取用户信息
// /user/account
export function getUserDetail() {
return request.get("/user/account");
return request.get('/user/account');
}
// 退出登录
// /logout
export function logout() {
return request.get("/logout");
return request.get('/logout');
}
// 手机号登录
// /login/cellphone
export function loginByCellphone(phone: any, password: any) {
return request.post("/login/cellphone", {
phone: phone,
password: password,
export function loginByCellphone(phone: string, password: string) {
return request.post('/login/cellphone', {
phone,
password,
});
}

View File

@@ -1,17 +1,22 @@
import { IPlayMusicUrl } from "@/type/music"
import { ILyric } from "@/type/lyric"
import request from "@/utils/request"
import requestMusic from "@/utils/request_music"
// 根据音乐Id获取音乐播放URl
export const getMusicUrl = (id: number) => {
return request.get<IPlayMusicUrl>("/song/url", { params: { id: id } })
}
// 根据音乐Id获取音乐歌词
export const getMusicLrc = (id: number) => {
return request.get<ILyric>("/lyric", { params: { id: id } })
}
export const getParsingMusicUrl = (id: number) => {
return requestMusic.get<any>("/music", { params: { id: id } })
}
import { ILyric } from '@/type/lyric';
import { IPlayMusicUrl } from '@/type/music';
import request from '@/utils/request';
import requestMusic from '@/utils/request_music';
// 根据音乐Id获取音乐播放URl
export const getMusicUrl = (id: number) => {
return request.get<IPlayMusicUrl>('/song/url', { params: { id } });
};
// 获取歌曲详情
export const getMusicDetail = (ids: Array<number>) => {
return request.get('/song/detail', { params: { ids: ids.join(',') } });
};
// 根据音乐Id获取音乐歌词
export const getMusicLrc = (id: number) => {
return request.get<ILyric>('/lyric', { params: { id } });
};
export const getParsingMusicUrl = (id: number) => {
return requestMusic.get<any>('/music', { params: { id } });
};

30
src/api/mv.ts Normal file
View File

@@ -0,0 +1,30 @@
import { IData } from '@/type';
import { IMvItem, IMvUrlData } from '@/type/mv';
import request from '@/utils/request';
// 获取 mv 排行
export const getTopMv = (limit: number) => {
return request.get<IData<Array<IMvItem>>>('/top/mv', {
params: {
limit,
},
});
};
// 获取 mv 数据
export const getMvDetail = (mvid: string) => {
return request.get('/mv/detail', {
params: {
mvid,
},
});
};
// 获取 mv 地址
export const getMvUrl = (id: Number) => {
return request.get<IData<IMvUrlData>>('/mv/url', {
params: {
id,
},
});
};

View File

@@ -1,9 +1,12 @@
import request from "@/utils/request"
import { ISearchDetail } from "@/type/search"
import request from '@/utils/request';
// 搜索内容
export const getSearch = (keywords: any) => {
return request.get<any>("/cloudsearch", {
params: { keywords: keywords, type: 1 },
})
interface IParams {
keywords: string;
type: number;
}
// 搜索内容
export const getSearch = (params: IParams) => {
return request.get<any>('/cloudsearch', {
params,
});
};

View File

@@ -1,17 +1,17 @@
import request from "@/utils/request";
import request from '@/utils/request';
// /user/detail
export function getUserDetail(uid: number) {
return request.get("/user/detail", { params: { uid } });
return request.get('/user/detail', { params: { uid } });
}
// /user/playlist
export function getUserPlaylist(uid: number) {
return request.get("/user/playlist", { params: { uid } });
return request.get('/user/playlist', { params: { uid } });
}
// 播放历史
// /user/record?uid=32953014&type=1
export function getUserRecord(uid: number, type: number = 0) {
return request.get("/user/record", { params: { uid, type } });
return request.get('/user/record', { params: { uid, type } });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,92 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100vh' : '70vh'"
placement="bottom"
:drawer-style="{ backgroundColor: 'transparent' }"
>
<div class="music-page">
<i class="iconfont icon-icon_error music-close" @click="close"></i>
<div class="music-title text-el">{{ name }}</div>
<!-- 歌单歌曲列表 -->
<div class="music-list">
<n-scrollbar>
<div
v-for="(item, index) in songList"
:key="item.id"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const store = useStore();
const props = defineProps<{
show: boolean;
name: string;
songList: any[];
}>();
const emit = defineEmits(['update:show']);
const formatDetail = computed(() => (detail: any) => {
const song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id,
};
detail.song = song;
detail.picUrl = detail.al.picUrl;
return detail;
});
const handlePlay = () => {
const tracks = props.songList || [];
store.commit('setPlayList', tracks);
};
const close = () => {
emit('update:show', false);
};
</script>
<style scoped lang="scss">
.music {
&-page {
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-title {
@apply text-lg font-bold text-white p-4;
}
&-close {
@apply absolute top-4 right-8 cursor-pointer text-white text-3xl;
}
&-list {
height: calc(100% - 60px);
}
}
.mobile {
.music-page {
@apply px-4;
}
}
</style>

View File

@@ -1,39 +1,37 @@
<template>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<n-layout>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
class="play-list-type-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
v-show="isShowAllPlaylistCategory || index <= 19"
@click="handleClickPlaylistType(item.name)"
>{{ item.name }}</span>
</template>
<div
class="play-list-type-showall"
:class="setAnimationClass('animate__bounceIn')"
:style="
setAnimationDelay(
!isShowAllPlaylistCategory
? 25
: playlistCategory?.sub.length || 100 + 30
)
"
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
>{{ !isShowAllPlaylistCategory ? "显示全部" : "隐藏一些" }}</div>
</n-layout>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<div>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
v-show="isShowAllPlaylistCategory || index <= 19"
class="play-list-type-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
@click="handleClickPlaylistType(item.name)"
>{{ item.name }}</span
>
</template>
<div
class="play-list-type-showall"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
>
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getPlaylistCategory } from "@/api/home";
import type { IPlayListSort } from "@/type/playlist";
import { setAnimationDelay, setAnimationClass } from "@/utils";
import { useRoute, useRouter } from "vue-router";
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import type { IPlayListSort } from '@/type/playlist';
import { setAnimationClass, setAnimationDelay } from '@/utils';
// 歌单分类
const playlistCategory = ref<IPlayListSort>();
// 是否显示全部歌单分类
@@ -41,39 +39,45 @@ const isShowAllPlaylistCategory = ref<boolean>(false);
// 加载歌单分类
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = data;
const { data } = await getPlaylistCategory();
playlistCategory.value = data;
};
const router = useRouter();
const handleClickPlaylistType = (type: any) => {
router.push({
path: "/list",
query: {
type: type,
}
});
const handleClickPlaylistType = (type: string) => {
router.push({
path: '/list',
query: {
type,
},
});
};
// 页面初始化
onMounted(() => {
loadPlaylistCategory();
loadPlaylistCategory();
});
</script>
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4;
}
.play-list-type {
width: 250px;
@apply mx-6;
&-item,
&-showall {
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 transition;
background-color: #1a1a1a;
}
&-showall {
@apply block text-center;
}
width: 250px;
@apply mx-6;
&-item,
&-showall {
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 transition;
background-color: #1a1a1a;
}
&-showall {
@apply block text-center;
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>

View File

@@ -1,69 +1,86 @@
<template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
<div class="recommend-album-list">
<template v-for="(item,index) in albumData?.albums" :key="item.id">
<div
v-if="index < 6"
class="recommend-album-list-item"
:class="setAnimationClass('animate__backInUp')"
:style="setAnimationDelay(index, 100)"
>
<n-image
class="recommend-album-list-item-img"
:src="getImgUrl( item.blurPicUrl, '200y200')"
lazy
preview-disabled
/>
<div class="recommend-album-list-item-content">{{ item.name }}</div>
</div>
</template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
<div class="recommend-album-list">
<template v-for="(item, index) in albumData?.albums" :key="item.id">
<div
v-if="index < 6"
class="recommend-album-list-item"
:class="setAnimationClass('animate__backInUp')"
:style="setAnimationDelay(index, 100)"
@click="handleClick(item)"
>
<n-image
class="recommend-album-list-item-img"
:src="getImgUrl(item.blurPicUrl, '200y200')"
lazy
preview-disabled
/>
<div class="recommend-album-list-item-content">{{ item.name }}</div>
</div>
</template>
</div>
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" />
</div>
</template>
<script lang="ts" setup>
import { getNewAlbum } from "@/api/home"
import { ref, onMounted } from "vue";
import type { IAlbumNew } from "@/type/album"
import { setAnimationClass, setAnimationDelay, getImgUrl } from "@/utils";
import { onMounted, ref } from 'vue';
import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
const albumData = ref<IAlbumNew>()
const albumData = ref<IAlbumNew>();
const loadAlbumList = async () => {
const { data } = await getNewAlbum();
albumData.value = data
}
const { data } = await getNewAlbum();
albumData.value = data;
};
const showMusic = ref(false);
const songList = ref([]);
const albumName = ref('');
const handleClick = async (item: any) => {
albumName.value = item.name;
showMusic.value = true;
const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || item.picUrl;
return song;
});
};
onMounted(() => {
loadAlbumList()
})
loadAlbumList();
});
</script>
<style lang="scss" scoped>
.recommend-album {
@apply flex-1 mx-5;
.title {
@apply text-lg font-bold mb-4;
}
@apply flex-1 mx-5;
.title {
@apply text-lg font-bold mb-4;
}
.recommend-album-list {
@apply grid grid-cols-2 grid-rows-3 gap-2;
&-item {
@apply rounded-xl overflow-hidden relative;
&-img {
@apply rounded-xl transition w-full h-full;
}
&:hover img {
filter: brightness(50%);
}
&-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black;
}
&-content:hover {
opacity: 1;
}
}
.recommend-album-list {
@apply grid grid-cols-2 grid-rows-3 gap-2;
&-item {
@apply rounded-xl overflow-hidden relative;
&-img {
@apply rounded-xl transition w-full h-full;
}
&:hover img {
filter: brightness(50%);
}
&-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black;
}
&-content:hover {
opacity: 1;
}
}
}
}
</style>

View File

@@ -1,85 +1,141 @@
<template>
<!-- 推荐歌手 -->
<!-- 推荐歌手 -->
<n-scrollbar :size="100" :x-scrollable="true">
<div class="recommend-singer">
<div class="recommend-singer-list">
<div
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
v-for="(item, index) in hotSingerData?.artists"
:style="setAnimationDelay(index, 100)"
:key="item.id"
>
<div
:style="setBackgroundImg(getImgUrl(item.picUrl,'300y300'))"
class="recommend-singer-item-bg"
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10"
>{{ item.musicSize }}</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i>
</div>
<div class="ml-4">
<div class="recommend-singer-item-info-name">{{ item.name }}</div>
<div class="recommend-singer-item-info-name">{{ item.name }}</div>
</div>
</div>
<div class="recommend-singer-list">
<div
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(0, 100)"
>
<div
:style="setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '300y300'))"
class="recommend-singer-item-bg"
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
>
<div class="font-bold text-xl">每日推荐</div>
<div class="mt-2">
<p v-for="item in dayRecommendData?.dailySongs.slice(0, 5)" :key="item.id" class="text-el">
{{ item.name }}
<br />
</p>
</div>
</div>
</div>
<div
v-for="(item, index) in hotSingerData?.artists.slice(0, 4)"
:key="item.id"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index + 1, 100)"
>
<div :style="setBackgroundImg(getImgUrl(item.picUrl, '300y300'))" class="recommend-singer-item-bg"></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">{{ item.musicSize }}</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i>
</div>
<div class="ml-4">
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
name="每日推荐列表"
:song-list="dayRecommendData?.dailySongs"
/>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { setBackgroundImg, setAnimationDelay, setAnimationClass,getImgUrl } from "@/utils";
import { onMounted, ref } from "vue";
import { getHotSinger } from "@/api/home";
import type { IHotSinger } from "@/type/singer";
import router from "@/router";
import { onMounted, ref } from 'vue';
import { getDayRecommend, getHotSinger } from '@/api/home';
import router from '@/router';
import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
// // 加载推荐歌手
// const loadSingerList = async () => {
// const { data } = await getHotSinger({ offset: 0, limit: 5 });
// hotSingerData.value = data;
// };
//加载推荐歌手
const loadSingerList = async () => {
const { data } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = data;
};
// const loadDayRecommend = async () => {
// const { data } = await getDayRecommend();
// dayRecommendData.value = data.data;
// };
// 页面初始化
onMounted(() => {
loadSingerList();
onMounted(async () => {
try {
const [{ data: singerData }, { data: dayRecommend }] = await Promise.all([
getHotSinger({ offset: 0, limit: 5 }),
getDayRecommend(),
]);
hotSingerData.value = singerData;
dayRecommendData.value = dayRecommend.data;
} catch (error) {
console.error('error', error);
}
});
const toSearchSinger = (keyword: string) => {
router.push({
path: "/search",
query: {
keyword: keyword,
},
});
router.push({
path: '/search',
query: {
keyword,
},
});
};
</script>
<style lang="scss" scoped>
.recommend-singer {
&-list {
@apply flex;
height: 350px;
&-list {
@apply flex;
height: 280px;
}
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
&-bg {
@apply bg-gray-900 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between;
&-bg {
@apply bg-gray-900 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(80%);
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer;
}
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer;
}
}
}
}
.mobile .recommend-singer {
&-list {
height: 180px;
@apply ml-4;
}
&-item {
@apply p-4 rounded-xl;
&-bg {
@apply rounded-xl;
}
}
}
</style>

View File

@@ -1,19 +1,10 @@
<template>
<div class="recommend-music">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
本周最热音乐
</div>
<div
class="recommend-music-list"
:class="setAnimationClass('animate__bounceInUp')"
v-show="recommendMusic?.result"
>
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
<div v-show="recommendMusic?.result" class="recommend-music-list" :class="setAnimationClass('animate__bounceInUp')">
<!-- 推荐音乐列表 -->
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
<div
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 100)"
>
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
<song-item :item="item" @play="handlePlay" />
</div>
</template>
@@ -22,32 +13,32 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { getRecommendMusic } from '@/api/home'
import type { IRecommendMusic } from '@/type/music'
import { setAnimationClass, setAnimationDelay } from '@/utils'
import SongItem from './common/SongItem.vue'
import { useStore } from 'vuex'
import { useStore } from 'vuex';
import { getRecommendMusic } from '@/api/home';
import type { IRecommendMusic } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from './common/SongItem.vue';
const store = useStore();
// 推荐歌曲
const recommendMusic = ref<IRecommendMusic>()
const recommendMusic = ref<IRecommendMusic>();
// 加载推荐歌曲
const loadRecommendMusic = async () => {
const { data } = await getRecommendMusic({ limit: 10 })
recommendMusic.value = data
}
const { data } = await getRecommendMusic({ limit: 10 });
recommendMusic.value = data;
};
// 页面初始化
onMounted(() => {
loadRecommendMusic()
})
loadRecommendMusic();
});
const handlePlay = (item: any) => {
const musicIndex = (recommendMusic.value?.result.findIndex((music: any) => music.id == item.id) || 0) + 1
store.commit('setPlayList', recommendMusic.value?.result.slice(musicIndex))
}
const handlePlay = () => {
store.commit('setPlayList', recommendMusic.value?.result);
};
</script>
<style lang="scss" scoped>

View File

@@ -1,31 +1,28 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { setAnimationClass, setAnimationDelay } from "@/utils";
import { setAnimationClass } from '@/utils';
const props = defineProps({
showPop: {
type: Boolean,
default: false
default: false,
},
showClose: {
type: Boolean,
default: true
default: true,
},
})
});
const musicFullClass = computed(() => {
if (props.showPop) {
return setAnimationClass('animate__fadeInUp')
} else {
return setAnimationClass('animate__fadeOutDown')
return setAnimationClass('animate__fadeInUp');
}
})
return setAnimationClass('animate__fadeOutDown');
});
</script>
<template>
<div class="pop-page" v-show="props.showPop" :class="musicFullClass">
<i class="iconfont icon-icon_error close" v-if="props.showClose" @click="close()"></i>
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
<slot></slot>
</div>
@@ -40,4 +37,4 @@ const musicFullClass = computed(() => {
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
}
}
</style>
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
defineProps({
height: {
type: String,
default: undefined,
},
});
</script>
<style lang="scss" scoped>
.bottom {
@apply h-28;
}
</style>

View File

View File

@@ -0,0 +1,70 @@
<template>
<n-drawer :show="show" height="100vh" placement="bottom" :z-index="999999999">
<div class="mv-detail">
<video :src="url" controls autoplay></video>
<div class="mv-detail-title">
<div class="title">{{ title }}</div>
<button @click="close">
<i class="iconfont icon-xiasanjiaoxing"></i>
</button>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
const props = defineProps<{
show: boolean;
title: string;
url: string;
}>();
const store = useStore();
watch(
() => props.show,
(val) => {
if (val) {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
}
},
);
const emit = defineEmits(['update:show']);
const close = () => {
emit('update:show', false);
};
</script>
<style scoped lang="scss">
.mv-detail {
@apply w-full h-full bg-black relative;
&-title {
@apply absolute w-full left-0 flex justify-between h-16 px-6 py-2 text-xl font-bold items-center z-50 transition-all duration-300 ease-in-out -top-24;
background: linear-gradient(0, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%);
button .icon-xiasanjiaoxing {
@apply text-3xl;
}
button:hover {
@apply text-green-400;
}
}
video {
@apply w-full h-full;
}
video:hover + .mv-detail-title {
@apply top-0;
}
.mv-detail-title:hover {
@apply top-0;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="search-item" @click="handleClick">
<div class="search-item-img">
<n-image :src="getImgUrl(item.picUrl, '200y200')" lazy preview-disabled />
</div>
<div class="search-item-info">
<div class="search-item-name">{{ item.name }}</div>
<div class="search-item-artist">{{ item.desc }}</div>
</div>
<MusicList
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
:song-list="songList"
/>
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
</div>
</template>
<script setup lang="ts">
import { getAlbum, getListDetail } from '@/api/list';
import { getMvUrl } from '@/api/mv';
import { getImgUrl } from '@/utils';
const props = defineProps<{
item: {
picUrl: string;
name: string;
desc: string;
type: string;
[key: string]: any;
};
}>();
const url = ref('');
const songList = ref<any[]>([]);
const showPop = ref(false);
const handleClick = async () => {
if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl;
return song;
});
}
if (props.item.type === 'playlist') {
showPop.value = true;
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
}
if (props.item.type === 'mv') {
const res = await getMvUrl(props.item.id);
url.value = res.data.data.url;
showPop.value = true;
}
};
</script>
<style scoped lang="scss">
.search-item {
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition cursor-pointer;
margin: 0 10px;
.search-item-img {
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
}
.search-item-info {
&-name {
@apply text-white text-sm text-center;
}
&-artist {
@apply text-gray-400 text-xs text-center;
}
}
}
</style>

View File

@@ -1,37 +1,25 @@
<template>
<div class="recommend-music-list-item">
<n-image
:src="getImgUrl( item.picUrl, '40y40')"
class="recommend-music-list-item-img"
lazy
preview-disabled
/>
<div class="recommend-music-list-item-content">
<div class="recommend-music-list-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{
item.name
}}</n-ellipsis>
<div class="song-item" :class="{ 'song-mini': mini }">
<n-image v-if="item.picUrl" :src="getImgUrl(item.picUrl, '40y40')" class="song-item-img" lazy preview-disabled />
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="recommend-music-list-item-content-name">
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span
v-for="(artists, artistsindex) in item.song.artists"
:key="artistsindex"
>{{ artists.name
}}{{
artistsindex < item.song.artists.length - 1 ? ' / ' : ''
}}</span
<span v-for="(artists, artistsindex) in item.song.artists" :key="artistsindex"
>{{ artists.name }}{{ artistsindex < item.song.artists.length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
</div>
<div class="recommend-music-list-item-operating">
<div class="recommend-music-list-item-operating-like">
<div class="song-item-operating">
<div class="song-item-operating-like">
<i class="iconfont icon-likefill"></i>
</div>
<div
class="recommend-music-list-item-operating-play bg-black"
:class="isPlaying ? 'bg-green-600' : ''"
class="song-item-operating-play bg-black animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
@@ -42,45 +30,57 @@
</template>
<script lang="ts" setup>
import { useStore } from 'vuex'
import type { SongResult } from '@/type/music'
import { computed } from 'vue'
import { getImgUrl } from '@/utils'
import { useStore } from 'vuex';
const props = defineProps({
item: {
type: Object,
required: true,
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const props = withDefaults(
defineProps<{
item: SongResult;
mini?: boolean;
}>(),
{
mini: false,
},
})
);
const store = useStore()
const store = useStore();
const play = computed(() => store.state.play as boolean)
const play = computed(() => store.state.play as boolean);
const playMusic = computed(() => store.state.playMusic)
const playMusic = computed(() => store.state.playMusic);
const playLoading = computed(() => playMusic.value.id === props.item.id && playMusic.value.playLoading);
// 判断是否为正在播放的音乐
const isPlaying = computed(() => {
return playMusic.value.id == props.item.id
})
return playMusic.value.id === props.item.id;
});
const emits = defineEmits(['play'])
const emits = defineEmits(['play']);
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = (item: any) => {
store.commit('setPlay', item)
store.commit('setIsPlay', true)
store.state.playListIndex = 0
emits('play', item)
}
const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
return;
}
await store.commit('setPlay', item);
store.commit('setIsPlay', true);
emits('play', item);
};
</script>
<style lang="scss" scoped>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.recommend-music-list-item {
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition;
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
@@ -96,7 +96,7 @@ const playMusicEvent = (item: any) => {
}
}
&-operating {
@apply flex items-center pl-4 rounded-full border border-gray-700;
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
background-color: #0d0d0d;
.iconfont {
@apply text-xl;
@@ -110,6 +110,38 @@ const playMusicEvent = (item: any) => {
}
&-play {
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition;
animation-iteration-count: infinite;
}
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}

65
src/const/bar-const.ts Normal file
View File

@@ -0,0 +1,65 @@
export const USER_SET_OPTIONS = [
// {
// label: '打卡',
// key: 'card',
// },
// {
// label: '听歌升级',
// key: 'card_music',
// },
// {
// label: '歌曲次数',
// key: 'listen',
// },
{
label: '退出登录',
key: 'logout',
},
{
label: '设置',
key: 'set',
},
];
export const SEARCH_TYPES = [
{
label: '单曲',
key: 1,
},
{
label: '专辑',
key: 10,
},
// {
// label: '歌手',
// key: 100,
// },
{
label: '歌单',
key: 1000,
},
// {
// label: '用户',
// key: 1002,
// },
{
label: 'MV',
key: 1004,
},
// {
// label: '歌词',
// key: 1006,
// },
// {
// label: '电台',
// key: 1009,
// },
// {
// label: '视频',
// key: 1014,
// },
// {
// label: '综合',
// key: 1018,
// },
];

10
src/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare global {
interface Window {
electronAPI: {
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: () => void;
};
}
}

View File

@@ -0,0 +1,39 @@
// musicHistoryHooks
import { useLocalStorage } from '@vueuse/core';
import type { SongResult } from '@/type/music';
export const useMusicHistory = () => {
const musicHistory = useLocalStorage<SongResult[]>('musicHistory', []);
const addMusic = (music: SongResult) => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
} else {
musicHistory.value.unshift({ ...music, count: 1 });
}
};
const delMusic = (music: SongResult) => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value.splice(index, 1);
}
};
const musicList = ref(musicHistory.value);
watch(
() => musicHistory.value,
() => {
musicList.value = musicHistory.value;
},
);
return {
musicHistory,
musicList,
addMusic,
delMusic,
};
};

View File

@@ -1,104 +1,170 @@
import { getMusicLrc } from '@/api/music'
import { ILyric } from '@/type/lyric'
import { ref } from 'vue'
import { getMusicLrc } from '@/api/music';
import { ILyric } from '@/type/lyric';
interface ILrcData {
text: string
trText: string
text: string;
trText: string;
}
const lrcData = ref<ILyric>()
const newLrcIndex = ref<number>(0)
const lrcArray = ref<Array<ILrcData>>([])
const lrcTimeArray = ref<Array<Number>>([])
export const lrcData = ref<ILyric>();
export const newLrcIndex = ref<number>(0);
export const lrcArray = ref<Array<ILrcData>>([]);
export const lrcTimeArray = ref<Array<Number>>([]);
const parseTime = (timeString: string) => {
const [minutes, seconds] = timeString.split(':')
return parseInt(minutes) * 60 + parseFloat(seconds)
}
export const parseTime = (timeString: string) => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
};
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
function parseLyricLine(lyricLine: string) {
// [00:00.00] 作词 : 长友美知惠/
const timeText = lyricLine.match(TIME_REGEX)?.[0] || ''
const time = parseTime(timeText)
const text = lyricLine.replace(LRC_REGEX, '').trim()
return { time, text }
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
const time = parseTime(timeText);
const text = lyricLine.replace(LRC_REGEX, '').trim();
return { time, text };
}
interface ILyricText {
text: string
trText: string
text: string;
trText: string;
}
function parseLyrics(lyricsString: string) {
const lines = lyricsString.split('\n')
const lyrics: Array<ILyricText> = []
const times: number[] = []
const lines = lyricsString.split('\n');
const lyrics: Array<ILyricText> = [];
const times: number[] = [];
lines.forEach((line) => {
const { time, text } = parseLyricLine(line)
times.push(time)
lyrics.push({ text, trText: '' })
})
return { lyrics, times }
const { time, text } = parseLyricLine(line);
times.push(time);
lyrics.push({ text, trText: '' });
});
return { lyrics, times };
}
const loadLrc = async (playMusicId: number): Promise<void> => {
export const loadLrc = async (playMusicId: number): Promise<void> => {
try {
const { data } = await getMusicLrc(playMusicId)
const { lyrics, times } = parseLyrics(data.lrc.lyric)
lrcTimeArray.value = times
lrcArray.value = lyrics
const { data } = await getMusicLrc(playMusicId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
let tlyric: {
[key: string]: string;
} = {};
if (data.tlyric.lyric) {
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
tlyric = tLyrics.reduce((acc: any, cur, index) => {
acc[tTimes[index]] = cur.text;
return acc;
}, {});
}
if (Object.keys(tlyric).length) {
lyrics.forEach((item, index) => {
item.trText = item.text ? tlyric[times[index].toString()] : '';
});
}
lrcTimeArray.value = times;
lrcArray.value = lyrics;
} catch (err) {
console.error('err', err)
console.error('err', err);
}
}
};
// 歌词矫正时间Correction time
const correctionTime = ref(0)
const correctionTime = ref(0.4);
// 增加矫正时间
const addCorrectionTime = (time: number) => {
correctionTime.value += time
}
export const addCorrectionTime = (time: number) => {
correctionTime.value += time;
};
// 减少矫正时间
const reduceCorrectionTime = (time: number) => {
correctionTime.value -= time
}
export const reduceCorrectionTime = (time: number) => {
correctionTime.value -= time;
};
const isCurrentLrc = (index: any, time: number) => {
const currentTime = Number(lrcTimeArray.value[index])
const nextTime = Number(lrcTimeArray.value[index + 1])
const nowTime = time + correctionTime.value
const isTrue = nowTime > currentTime && nowTime < nextTime
export const isCurrentLrc = (index: number, time: number) => {
const currentTime = Number(lrcTimeArray.value[index]);
const nextTime = Number(lrcTimeArray.value[index + 1]);
const nowTime = time + correctionTime.value;
const isTrue = nowTime > currentTime && nowTime < nextTime;
if (isTrue) {
newLrcIndex.value = index
newLrcIndex.value = index;
}
return isTrue
}
return isTrue;
};
const nowTime = ref(0)
const allTime = ref(0)
export const nowTime = ref(0);
export const allTime = ref(0);
export const nowIndex = ref(0);
export const getLrcIndex = (time: number) => {
for (let i = 0; i < lrcTimeArray.value.length; i++) {
if (isCurrentLrc(i, time)) {
nowIndex.value = i || nowIndex.value;
return i;
}
}
return nowIndex.value;
};
// 设置当前播放时间
const setAudioTime = (index: any, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index] as number
audio.play()
}
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index] as number;
audio.play();
};
export {
lrcData,
lrcArray,
lrcTimeArray,
newLrcIndex,
loadLrc,
isCurrentLrc,
addCorrectionTime,
reduceCorrectionTime,
setAudioTime,
nowTime,
allTime,
}
// 计算这个歌词的播放时间
const getLrcTime = (index: number) => {
return Number(lrcTimeArray.value[index]);
};
// 获取当前播放的歌词
export const getCurrentLrc = () => {
const index = getLrcIndex(nowTime.value);
const currentLrc = lrcArray.value[index];
const nextLrc = lrcArray.value[index + 1];
return { currentLrc, nextLrc };
};
// 获取一句歌词播放时间是 几秒到几秒
export const getLrcTimeRange = (index: number) => {
const currentTime = Number(lrcTimeArray.value[index]);
const nextTime = Number(lrcTimeArray.value[index + 1]);
return { currentTime, nextTime };
};
export const sendLyricToWin = (isPlay: boolean = true) => {
try {
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
let lyricWinData = null;
if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value);
const { currentLrc, nextLrc } = getCurrentLrc();
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
lyricWinData = {
currentLrc,
nextLrc,
currentTime,
nextTime,
nowIndex,
lrcTimeArray: lrcTimeArray.value,
lrcArray: lrcArray.value,
nowTime: nowTime.value,
allTime: allTime.value,
startCurrentTime: getLrcTime(nowIndex),
isPlay,
};
const windowData = window as any;
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
}
} catch (error) {
console.error('error', error);
}
};
export const openLyric = () => {
const windowData = window as any;
windowData.electronAPI.openLyric();
sendLyricToWin();
};

View File

@@ -6,6 +6,10 @@
@tailwind utilities;
.n-image img {
@apply bg-gray-900;
background-color: #111111;
width: 100%;
}
}
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}

View File

@@ -1,78 +1,163 @@
<template>
<div class="layout-page">
<div class="layout-main">
<div class="flex">
<!-- 侧边菜单栏 -->
<app-menu class="menu" :menus="menus" />
<div class="main">
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content bg-black" :native-scrollbar="false">
<n-message-provider>
<router-view class="main-page" v-slot="{ Component }">
<!-- <keep-alive>
<component :is="Component" v-if="$route.meta.keepAlive" />
</keep-alive>
<component :is="Component" v-if="!$route.meta.keepAlive" />-->
<component :is="Component" />
</router-view>
</n-message-provider>
</div>
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" />
<div class="layout-page">
<div class="layout-main">
<title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 -->
<app-menu v-if="!isMobile" class="menu" :menus="menus" />
<div class="main">
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content bg-black" :native-scrollbar="false">
<n-message-provider>
<router-view
v-slot="{ Component }"
class="main-page"
:class="route.meta.noScroll && !isMobile ? 'pr-3' : ''"
>
<keep-alive :include="keepAliveInclude">
<component :is="Component" />
</keep-alive>
</router-view>
</n-message-provider>
</div>
<play-bottom height="5rem" />
<app-menu v-if="isMobile" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { SongResult } from '@/type/music';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
// import { AppMenu, PlayBar, SearchBar } from './components';
import { defineAsyncComponent } from 'vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import homeRouter from '@/router/home';
import { isMobile } from '@/utils';
const keepAliveInclude = computed(() =>
homeRouter
.filter((item) => {
return item.meta.keepAlive;
})
.map((item) => {
// return item.name;
// 首字母大写
return item.name.charAt(0).toUpperCase() + item.name.slice(1);
}),
);
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
const PlayBar = defineAsyncComponent(() => import('./components/PlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean)
const menus = store.state.menus;
const isPlay = computed(() => store.state.isPlay as boolean);
const { menus } = store.state;
const play = computed(() => store.state.play as boolean);
const route = useRoute();
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const windowData = window as any;
const isElectron = computed(() => {
return !!windowData.electronAPI;
});
onMounted(() => {
// 监听音乐是否播放
watch(
() => play.value,
(value) => {
if (value && audio.value) {
audioPlay();
} else {
audioPause();
}
},
);
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
playMusicEvent();
break;
default:
}
};
// 按下键盘按钮监听
document.onkeydown = (e) => {
switch (e.code) {
case 'Space':
return false;
default:
}
};
});
const audioPlay = () => {
if (audio.value) {
audio.value.play();
}
};
const audioPause = () => {
if (audio.value) {
audio.value.pause();
}
};
const playMusicEvent = async () => {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
};
</script>
<style lang="scss" scoped>
.layout-page {
width: 100vw;
height: 100vh;
@apply flex justify-center items-center overflow-hidden;
width: 100vw;
height: 100vh;
@apply flex justify-center items-center overflow-hidden;
}
.layout-main {
@apply bg-black rounded-lg text-white shadow-xl flex-col relative;
@apply bg-black text-white shadow-xl flex flex-col relative;
height: 100%;
width: 100%;
overflow: hidden;
&-page {
@apply flex flex-1 overflow-hidden;
}
.main {
@apply flex-1 box-border flex flex-col overflow-hidden;
height: 100%;
width: 100%;
overflow: hidden;
.menu {
width: 90px;
}
.main {
@apply pt-6 pr-6 flex-1 box-border;
height: 100vh;
&-content {
@apply rounded-2xl;
height: calc(100vh - 60px);
margin-bottom: 90px;
}
&-page {
margin: 20px 0;
}
&-content {
@apply box-border flex-1 overflow-hidden;
}
}
// :deep(.n-scrollbar-content) {
// @apply pr-3;
// }
}
</style>
.mobile {
.layout-main {
&-page {
@apply pt-4;
}
}
}
</style>

View File

@@ -4,18 +4,14 @@
<div class="app-menu">
<div class="app-menu-header">
<div class="app-menu-logo">
<img src="@/assets/logo.png" class="w-9 h-9 mt-2" alt="logo" />
<img src="/icon.png" class="w-9 h-9" alt="logo" />
</div>
</div>
<div class="app-menu-list">
<div class="app-menu-item" v-for="(item,index) in menus">
<div v-for="(item, index) in menus" :key="item.path" class="app-menu-item">
<router-link class="app-menu-item-link" :to="item.path">
<i
class="iconfont app-menu-item-icon"
:style="iconStyle(index)"
:class="item.mate.icon"
></i>
<span v-if="isText" class="app-menu-item-text ml-3">{{ item.mate.title }}</span>
<i class="iconfont app-menu-item-icon" :style="iconStyle(index)" :class="item.meta.icon"></i>
<span v-if="isText" class="app-menu-item-text ml-3">{{ item.meta.title }}</span>
</router-link>
</div>
</div>
@@ -24,50 +20,52 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from "@vue/runtime-core";
import { useRoute } from "vue-router";
import { useRoute } from 'vue-router';
const props = defineProps({
isText: {
type: Boolean,
default: false
default: false,
},
size: {
type: String,
default: '26px'
default: '26px',
},
color: {
type: String,
default: '#aaa'
default: '#aaa',
},
selectColor: {
type: String,
default: '#10B981'
default: '#10B981',
},
menus: {
type: Array as any,
default: []
}
})
default: () => [],
},
});
const route = useRoute();
const path = ref(route.path);
watch(() => route.path, async newParams => {
path.value = newParams
})
watch(
() => route.path,
async (newParams) => {
path.value = newParams;
},
);
const iconStyle = (index: any) => {
let style = {
const iconStyle = (index: number) => {
const style = {
fontSize: props.size,
color: path.value === props.menus[index].path ? props.selectColor : props.color
}
return style
}
color: path.value === props.menus[index].path ? props.selectColor : props.color,
};
return style;
};
</script>
<style lang="scss" scoped>
.app-menu {
@apply flex-col items-center justify-center p-6;
@apply flex-col items-center justify-center px-6;
max-width: 100px;
}
.app-menu-item-link,
@@ -84,4 +82,25 @@ const iconStyle = (index: any) => {
transform: scale(1.05);
transition: 0.2s ease-in-out;
}
</style>
.mobile {
.app-menu {
max-width: 100%;
width: 100vw;
position: relative;
z-index: 999999;
background-color: #000;
&-header {
display: none;
}
&-list {
@apply flex justify-between;
}
&-item {
&-link {
@apply my-4;
}
}
}
}
</style>

View File

@@ -1,32 +1,25 @@
<template>
<n-drawer
:show="musicFull"
height="100vh"
placement="bottom"
:drawer-style="{ backgroundColor: 'transparent' }"
>
<n-drawer :show="musicFull" height="100vh" placement="bottom" :drawer-style="{ backgroundColor: 'transparent' }">
<div id="drawer-target">
<div
class="drawer-back"
:class="{ paused: !isPlaying }"
:style="{ backgroundImage: `url(${getImgUrl(playMusic?.picUrl, '300y300')})` }"
></div>
<div class="music-img">
<n-image
ref="PicImgRef"
:src="getImgUrl(playMusic?.picUrl, '300y300')"
class="img"
lazy
preview-disabled
/>
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
</div>
<div class="music-content">
<div class="music-content-name">{{ playMusic.song.name }}</div>
<div class="music-content-singer">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name
}}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</div>
<n-layout
class="music-lrc"
style="height: 550px"
ref="lrcSider"
class="music-lrc"
style="height: 55vh"
:native-scrollbar="false"
@mouseover="mouseOverLayout"
@mouseleave="mouseLeaveLayout"
@@ -37,35 +30,37 @@
:class="{ 'now-text': isCurrentLrc(index, nowTime) }"
@click="setAudioTime(index, audio)"
>
{{ item.text }}
<div>{{ item.text }}</div>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
</div>
</template>
</n-layout>
<!-- 时间矫正 -->
<div class="music-content-time"></div>
<n-button @click="reduceCorrectionTime(0.2)">-0.2</n-button>
<n-button @click="addCorrectionTime(0.2)">+0.2</n-button>
<div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import type { SongResult } from '@/type/music'
import { getImgUrl } from '@/utils'
import { computed, ref } from 'vue'
import { useStore } from 'vuex'
import { useStore } from 'vuex';
import {
addCorrectionTime,
isCurrentLrc,
lrcArray,
newLrcIndex,
isCurrentLrc,
addCorrectionTime,
nowTime,
reduceCorrectionTime,
setAudioTime,
nowTime,
} from '@/hooks/MusicHook'
} from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const store = useStore()
const store = useStore();
const props = defineProps({
musicFull: {
@@ -76,37 +71,59 @@ const props = defineProps({
type: HTMLAudioElement,
default: null,
},
})
const emit = defineEmits(['update:musicFull'])
});
// 播放的音乐信息
const playMusic = computed(() => store.state.playMusic as SongResult)
const playMusic = computed(() => store.state.playMusic as SongResult);
const isPlaying = computed(() => store.state.play as boolean);
// 获取歌词滚动dom
const lrcSider = ref<any>(null)
const isMouse = ref(false)
const lrcSider = ref<any>(null);
const isMouse = ref(false);
// 歌词滚动方法
const lrcScroll = () => {
if (props.musicFull && !isMouse.value) {
let top = newLrcIndex.value * 50 - 225
lrcSider.value.scrollTo({ top: top, behavior: 'smooth' })
const top = newLrcIndex.value * 60 - 225;
lrcSider.value.scrollTo({ top, behavior: 'smooth' });
}
}
};
const mouseOverLayout = () => {
isMouse.value = true
}
isMouse.value = true;
};
const mouseLeaveLayout = () => {
setTimeout(() => {
isMouse.value = false
}, 3000)
}
isMouse.value = false;
}, 3000);
};
defineExpose({
lrcScroll,
})
});
</script>
<style scoped lang="scss">
@keyframes round {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.drawer-back {
@apply absolute bg-cover bg-center opacity-70;
filter: blur(80px) brightness(80%);
z-index: -1;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
animation: round 20s linear infinite;
}
.drawer-back.paused {
animation-play-state: paused;
}
#drawer-target {
@apply top-0 left-0 absolute w-full h-full overflow-hidden rounded px-24 pt-24 pb-48 flex items-center;
backdrop-filter: blur(20px);
@@ -117,8 +134,8 @@ defineExpose({
@apply flex-1 flex justify-center mr-24;
.img {
width: 450px;
height: 450px;
width: 350px;
height: 350px;
@apply rounded-xl;
}
}
@@ -135,23 +152,42 @@ defineExpose({
}
}
.music-content-time {
display: none;
@apply flex justify-center items-center;
}
.music-lrc {
background-color: inherit;
width: 800px;
width: 500px;
height: 550px;
.now-text {
@apply text-red-500;
}
&-text {
@apply text-white text-lg flex justify-center items-center cursor-pointer;
height: 50px;
@apply text-white text-lg flex flex-col justify-center items-center cursor-pointer font-bold;
height: 60px;
transition: all 0.2s ease-out;
&:hover {
@apply font-bold text-xl text-red-500;
@apply font-bold text-red-500;
}
&-tr {
@apply text-sm font-normal;
}
}
}
}
.now-text {
@apply font-bold text-xl text-red-500;
.mobile {
#drawer-target {
@apply flex-col p-4 pt-8;
.music-img {
display: none;
}
.music-lrc {
height: calc(100vh - 260px) !important;
}
}
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="(audio as HTMLAudioElement)" />
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="audio.value as HTMLAudioElement" />
<!-- 底部播放栏 -->
<div class="music-play-bar" :class="setAnimationClass('animate__bounceInUp')">
<n-image
@@ -19,293 +19,214 @@
<div class="music-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name
}}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</n-ellipsis>
</div>
</div>
<div class="music-buttons">
<div @click="handlePrev">
<div class="music-buttons-prev" @click="handlePrev">
<i class="iconfont icon-prev"></i>
</div>
<div class="music-buttons-play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div>
<div @click="handleEnded">
<div class="music-buttons-next" @click="handleEnded">
<i class="iconfont icon-next"></i>
</div>
</div>
<div class="music-time">
<div class="time">{{ getNowTime }}</div>
<n-slider
v-model:value="timeSlider"
:step="0.05"
:tooltip="false"
></n-slider>
<n-slider v-model:value="timeSlider" :step="0.05" :tooltip="false"></n-slider>
<div class="time">{{ getAllTime }}</div>
</div>
<div class="audio-volume">
<div>
<i class="iconfont icon-notificationfill"></i>
</div>
<n-slider
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
></n-slider>
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false"></n-slider>
</div>
<div class="audio-button">
<n-tooltip trigger="hover">
<!-- <n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-likefill"></i>
</template>
喜欢
</n-tooltip>
<n-tooltip trigger="hover">
</n-tooltip> -->
<!-- <n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-Play" @click="parsingMusic"></i>
</template>
解析播放
</n-tooltip>
<n-tooltip trigger="hover">
</n-tooltip> -->
<n-tooltip class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-full" @click="setMusicFull"></i>
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
</template>
歌词
</n-tooltip>
<n-popover trigger="click" :z-index="99999999" content-class="music-play" raw :show-arrow="false" :delay="200">
<template #trigger>
<n-tooltip trigger="manual" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-list"></i>
</template>
播放列表
</n-tooltip>
</template>
<div class="music-play-list">
<div class="music-play-list-back"></div>
<n-scrollbar>
<div class="music-play-list-content">
<song-item v-for="item in playList" :key="item.id" :item="item" mini></song-item>
</div>
</n-scrollbar>
</div>
</n-popover>
</div>
<!-- 播放音乐 -->
<audio ref="audio" :src="playMusicUrl" :autoplay="play"></audio>
</div>
</template>
<script lang="ts" setup>
import type { SongResult } from '@/type/music'
import { secondToMinute, getImgUrl } from '@/utils'
import { computed, onMounted, ref, watch } from 'vue'
import { useStore } from 'vuex'
import { setAnimationClass } from '@/utils'
import { getParsingMusicUrl } from '@/api/music'
import {
loadLrc,
nowTime,
allTime,
} from '@/hooks/MusicHook'
import MusicFull from './MusicFull.vue'
import { useStore } from 'vuex';
const store = useStore()
import SongItem from '@/components/common/SongItem.vue';
import { allTime, loadLrc, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
import MusicFull from './MusicFull.vue';
const store = useStore();
// 播放的音乐信息
const playMusic = computed(() => store.state.playMusic as SongResult)
const playMusic = computed(() => store.state.playMusic as SongResult);
// 是否播放
const play = computed(() => store.state.play as boolean)
// 播放链接
const ProxyUrl =
import.meta.env.VITE_API_PROXY + '' || 'http://110.42.251.190:9856'
const playMusicUrl = ref('')
const play = computed(() => store.state.play as boolean);
const playList = computed(() => store.state.playList as SongResult[]);
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
watch(
() => store.state.playMusicUrl,
async (value, oldValue) => {
const isUrlHasMc = location.href.includes('mc.')
if (value && isUrlHasMc) {
let playMusicUrl1 = value as string
if (!ProxyUrl) {
playMusicUrl.value = playMusicUrl1
return
}
const url = new URL(playMusicUrl1)
const pathname = url.pathname
const subdomain = url.origin.split('.')[0].split('//')[1]
playMusicUrl1 = `${ProxyUrl}/mc?m=${subdomain}&url=${pathname}`
// console.log('playMusicUrl1', playMusicUrl1)
// // 获取音频文件
// const { data } = await axios.get(playMusicUrl1, {
// responseType: 'blob'
// })
// const musicUrl = URL.createObjectURL(data)
// console.log('musicUrl', musicUrl)
// playMusicUrl.value = musicUrl
playMusicUrl.value = playMusicUrl1
console.log('playMusicUrl1', playMusicUrl1)
setTimeout(() => {
onAudio()
store.commit('setPlayMusic', true)
}, 100)
} else {
playMusicUrl.value = value
}
loadLrc(playMusic.value.id)
() => {
loadLrc(playMusic.value.id);
},
{ immediate: true }
)
// 获取音乐播放Dom
onMounted(() => {
// 监听音乐是否播放
watch(
() => play.value,
(value, oldValue) => {
if (value && audio.value) {
audioPlay()
onAudio()
} else {
audioPause()
}
}
)
watch(
() => playMusicUrl.value,
(value, oldValue) => {
if (!value) {
parsingMusic()
}
}
)
// 抬起键盘按钮监听
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
playMusicEvent()
}
}
// 按下键盘按钮监听
document.onkeydown = (e) => {
switch (e.code) {
case 'Space':
return false
}
}
})
const audio = ref<HTMLAudioElement | null>(null)
{ immediate: true },
);
const audioPlay = () => {
if (audio.value) {
audio.value.play()
audio.value.play();
}
}
const audioPause = () => {
if (audio.value) {
audio.value.pause()
}
}
};
// 计算属性 获取当前播放时间的进度
const timeSlider = computed({
get: () => (nowTime.value / allTime.value) * 100,
set: (value) => {
if (!audio.value) return
audio.value.currentTime = (value * allTime.value) / 100
audioPlay()
store.commit('setPlayMusic', true)
if (!audio.value) return;
audio.value.currentTime = (value * allTime.value) / 100;
audioPlay();
store.commit('setPlayMusic', true);
},
})
});
// 音量条
const audioVolume = ref(1)
const audioVolume = ref(1);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
if(!audio.value) return
audio.value.volume = value / 100
if (!audio.value) return;
audio.value.volume = value / 100;
},
})
});
// 获取当前播放时间
const getNowTime = computed(() => {
return secondToMinute(nowTime.value)
})
return secondToMinute(nowTime.value);
});
// 获取总时间
const getAllTime = computed(() => {
return secondToMinute(allTime.value)
})
return secondToMinute(allTime.value);
});
// 监听音乐播放 获取时间
const onAudio = () => {
if(audio.value){
audio.value.removeEventListener('timeupdate', handleGetAudioTime)
audio.value.removeEventListener('ended', handleEnded)
audio.value.addEventListener('timeupdate', handleGetAudioTime)
audio.value.addEventListener('ended', handleEnded)
if (audio.value) {
audio.value.removeEventListener('timeupdate', handleGetAudioTime);
audio.value.removeEventListener('ended', handleEnded);
audio.value.addEventListener('timeupdate', handleGetAudioTime);
audio.value.addEventListener('ended', handleEnded);
// 监听音乐播放暂停
audio.value.addEventListener('pause', () => {
store.commit('setPlayMusic', false);
});
audio.value.addEventListener('play', () => {
store.commit('setPlayMusic', true);
});
}
}
};
onAudio();
function handleEnded() {
store.commit('nextPlay')
store.commit('nextPlay');
}
function handlePrev() {
store.commit('prevPlay')
store.commit('prevPlay');
}
const MusicFullRef = ref<any>(null)
const MusicFullRef = ref<any>(null);
function handleGetAudioTime(this: any) {
function handleGetAudioTime(this: HTMLAudioElement) {
// 监听音频播放的实时时间事件
const audio = this as HTMLAudioElement
const audio = this as HTMLAudioElement;
// 获取当前播放时间
nowTime.value = Math.floor(audio.currentTime)
nowTime.value = Math.floor(audio.currentTime);
// 获取总时间
allTime.value = audio.duration
allTime.value = audio.duration;
// 获取音量
audioVolume.value = audio.volume
MusicFullRef.value?.lrcScroll()
audioVolume.value = audio.volume;
sendLyricToWin(store.state.isPlay);
MusicFullRef.value?.lrcScroll();
}
// 播放暂停按钮事件
const playMusicEvent = async () => {
if (play.value) {
store.commit('setPlayMusic', false)
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true)
store.commit('setPlayMusic', true);
}
}
};
const musicFull = ref(false)
const musicFull = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFull.value = !musicFull.value
}
// 解析音乐
const parsingMusic = async () => {
const { data } = await getParsingMusicUrl(playMusic.value.id)
store.state.playMusicUrl = data.data.url
}
musicFull.value = !musicFull.value;
};
</script>
<style lang="scss" scoped>
.musicPage-enter-active {
animation: fadeInUp 0.4s ease-in-out;
}
.musicPage-leave-active {
animation: fadeOutDown 0.4s ease-in-out;
}
.text-ellipsis {
width: 100%;
}
.music-play-bar {
@apply h-20 w-full absolute bottom-0 left-0 flex items-center rounded-t-2xl overflow-hidden box-border px-6 py-2;
z-index: 99999999;
backdrop-filter: blur(20px);
z-index: 9999;
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
background-color: rgba(0, 0, 0, 0.747);
animation-duration: 0.5s !important;
.music-content {
width: 200px;
width: 140px;
@apply ml-4;
&-title {
@@ -370,4 +291,50 @@ const parsingMusic = async () => {
@apply text-2xl hover:text-green-500 transition cursor-pointer m-4;
}
}
.music-play {
&-list {
height: 50vh;
@apply relative rounded-3xl overflow-hidden;
&-back {
backdrop-filter: blur(20px);
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
}
&-content {
padding: 10px;
}
}
}
.mobile {
.music-play-bar {
@apply px-4;
bottom: 70px;
}
.music-time {
display: none;
}
.ri-netease-cloud-music-line {
display: none;
}
.audio-volume {
display: none;
}
.audio-button {
@apply mx-0;
}
.music-buttons {
@apply m-0;
&-prev,
&-next {
display: none;
}
&-play {
@apply m-0;
}
}
.music-content {
flex: 1;
}
}
</style>

View File

@@ -1,166 +1,154 @@
<template>
<div class="search-box flex">
<div class="search-box-input flex-1">
<n-input
size="large"
round
v-model:value="searchValue"
:placeholder="hotSearchKeyword"
class="border border-gray-600"
@keydown.enter="search"
>
<template #prefix>
<i class="iconfont icon-search"></i>
</template>
</n-input>
</div>
<div class="user-box">
<n-dropdown trigger="hover" @select="selectItem" :options="options">
<i class="iconfont icon-xiasanjiaoxing"></i>
<div class="search-box flex">
<div class="search-box-input flex-1">
<n-input
v-model:value="searchValue"
size="medium"
round
:placeholder="hotSearchKeyword"
class="border border-gray-600"
@keydown.enter="search"
>
<template #prefix>
<i class="iconfont icon-search"></i>
</template>
<template #suffix>
<div class="w-20 px-3 flex justify-between items-center">
<div>{{ searchTypeOptions.find((item) => item.key === searchType)?.label }}</div>
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
<n-avatar
class="ml-2 cursor-pointer"
circle
size="large"
:src="store.state.user.avatarUrl"
v-if="store.state.user"
/>
<n-avatar
class="ml-2 cursor-pointer"
circle
size="large"
src="https://picsum.photos/200/300?random=1"
@click="toLogin()"
v-else
>登录</n-avatar>
</div>
</div>
</template>
</n-input>
</div>
<div class="user-box">
<n-dropdown trigger="hover" :options="userSetOptions" @select="selectItem">
<i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
<n-avatar
v-if="store.state.user"
class="ml-2 cursor-pointer"
circle
size="medium"
:src="getImgUrl(store.state.user.avatarUrl)"
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { getSearchKeyword, getHotSearch } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import request from '@/utils/request_mt'
const router = useRouter()
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { getImgUrl } from '@/utils';
const router = useRouter();
const store = useStore();
const userSetOptions = ref(USER_SET_OPTIONS);
// 推荐热搜词
const hotSearchKeyword = ref("搜索点什么吧...")
const hotSearchValue = ref("")
const hotSearchKeyword = ref('搜索点什么吧...');
const hotSearchValue = ref('');
const loadHotSearchKeyword = async () => {
const { data } = await getSearchKeyword();
hotSearchKeyword.value = data.data.showKeyword
hotSearchValue.value = data.data.realkeyword
}
store.state.user = JSON.parse(localStorage.getItem('user') || '{}')
const { data } = await getSearchKeyword();
hotSearchKeyword.value = data.data.showKeyword;
hotSearchValue.value = data.data.realkeyword;
};
const loadPage = async () => {
const { data } = await getUserDetail()
store.state.user = data.profile
localStorage.setItem('user', JSON.stringify(data.profile))
}
const token = localStorage.getItem('token');
if (!token) return;
const { data } = await getUserDetail();
store.state.user = data.profile;
localStorage.setItem('user', JSON.stringify(data.profile));
};
loadPage();
watchEffect(() => {
if (store.state.user) {
userSetOptions.value = USER_SET_OPTIONS;
} else {
userSetOptions.value = USER_SET_OPTIONS.filter((item) => item.key !== 'logout');
}
});
const toLogin = () => {
router.push('/login')
}
router.push('/login');
};
// 页面初始化
onMounted(() => {
loadHotSearchKeyword()
loadPage()
})
loadHotSearchKeyword();
loadPage();
});
// 搜索词
const searchValue = ref("")
const searchValue = ref('');
const searchType = ref(1);
const search = () => {
const { value } = searchValue;
if (value === '') {
searchValue.value = hotSearchValue.value;
return;
}
let value = searchValue.value
if (value == "") {
searchValue.value = hotSearchValue.value
} else {
router.push({
path: "/search",
query: {
keyword: value
}
})
}
}
router.push({
path: '/search',
query: {
keyword: value,
type: searchType.value,
},
});
};
const value = 'Drive My Car'
const options = [
{
label: '打卡',
key: 'card'
},
{
label: '听歌升级',
key: 'card_music'
},
{
label: '歌曲次数',
key: 'listen'
},
{
label: '登录',
key: 'login'
},
{
label: '退出登录',
key: 'logout'
}
]
const selectSearchType = (key: number) => {
searchType.value = key;
};
const selectItem = async (key: any) => {
// switch 判断
switch (key) {
case 'card':
await request.get('/?do=sign')
.then(res => {
console.log(res)
})
break;
case 'card_music':
await request.get('/?do=daka')
.then(res => {
console.log(res)
})
break;
case 'listen':
await request.get('/?do=listen&id=1885175990&time=300')
.then(res => {
console.log(res)
})
break;
case 'logout':
logout().then(() => {
store.state.user = null
localStorage.clear()
})
break;
case 'login':
router.push("/login")
break;
}
}
const searchTypeOptions = ref(SEARCH_TYPES);
const selectItem = async (key: string) => {
// switch 判断
switch (key) {
case 'logout':
logout().then(() => {
store.state.user = null;
localStorage.clear();
router.push('/login');
});
break;
case 'login':
router.push('/login');
break;
case 'set':
router.push('/set');
break;
default:
}
};
</script>
<style lang="scss" scoped>
.user-box {
@apply ml-6 flex text-lg justify-center items-center rounded-full pl-3 border border-gray-600;
background: #1a1a1a;
@apply ml-4 flex text-lg justify-center items-center rounded-full pl-3 border border-gray-600;
background: #1a1a1a;
}
.search-box {
@apply pb-4 pr-4;
}
.search-box-input {
@apply relative;
}
.search-box-input {
@apply relative;
.mobile {
.search-box {
@apply pl-4;
}
}
</style>
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div id="title-bar" @mousedown="drag">
<div id="title">Alger Music</div>
<div id="buttons">
<button @click="minimize">
<i class="iconfont icon-minisize"></i>
</button>
<button @click="close">
<i class="iconfont icon-close"></i>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useDialog } from 'naive-ui';
const dialog = useDialog();
const windowData = window as any;
const minimize = () => {
windowData.electronAPI.minimize();
};
const close = () => {
dialog.warning({
title: '提示',
content: '确定要退出吗?',
positiveText: '最小化',
negativeText: '关闭',
onPositiveClick: () => {
windowData.electronAPI.miniTray();
},
onNegativeClick: () => {
windowData.electronAPI.close();
},
});
};
const drag = (event: MouseEvent) => {
windowData.electronAPI.dragStart(event);
};
</script>
<style scoped lang="scss">
#title-bar {
-webkit-app-region: drag;
@apply flex justify-between text-white px-6 py-2 select-none relative;
z-index: 9999999;
}
#buttons {
@apply flex gap-4;
-webkit-app-region: no-drag;
}
button {
@apply hover:text-green-500;
}
</style>

View File

@@ -1,5 +1,5 @@
import AppMenu from "./AppMenu.vue";
import PlayBar from "./PlayBar.vue";
import SearchBar from "./SearchBar.vue";
import AppMenu from './AppMenu.vue';
import PlayBar from './PlayBar.vue';
import SearchBar from './SearchBar.vue';
export { AppMenu, PlayBar, SearchBar };

View File

@@ -5,22 +5,20 @@
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
defineProps({
lrcList: {
type: Array,
default: () => []
default: () => [],
},
lrcIndex: {
type: Number,
default: 0
default: 0,
},
lrcTime: {
type: Number,
default: 0
default: 0,
},
})
});
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss"></style>

View File

@@ -1,19 +1,17 @@
import { createApp } from "vue";
import App from "./App.vue";
import naive from "naive-ui";
import "vfonts/Lato.css";
import "vfonts/FiraCode.css";
import 'vfonts/Lato.css';
import 'vfonts/FiraCode.css';
// tailwind css
import "./index.css";
import './index.css';
import 'remixicon/fonts/remixicon.css';
import router from "@/router";
import { createApp } from 'vue';
import store from "@/store";
import router from '@/router';
import store from '@/store';
import App from './App.vue';
const app = createApp(App);
app.use(router);
app.use(store);
app.use(naive);
app.mount("#app");
app.mount('#app');

View File

@@ -1,54 +1,65 @@
const layoutRouter = [
{
path: "/",
name: "home",
mate: {
path: '/',
name: 'home',
meta: {
title: '首页',
icon: 'icon-Home',
keepAlive: true,
title: "首页",
icon: "icon-Home",
},
component: () => import("@/views/home/index.vue"),
component: () => import('@/views/home/index.vue'),
},
{
path: "/search",
name: "search",
mate: {
title: "搜索",
path: '/search',
name: 'search',
meta: {
title: '搜索',
noScroll: true,
icon: 'icon-Search',
keepAlive: true,
icon: "icon-Search",
},
component: () => import("@/views/search/index.vue"),
component: () => import('@/views/search/index.vue'),
},
{
path: "/list",
name: "list",
mate: {
title: "歌单",
path: '/list',
name: 'list',
meta: {
title: '歌单',
icon: 'icon-Paper',
keepAlive: true,
icon: "icon-Paper",
},
component: () => import("@/views/list/index.vue"),
component: () => import('@/views/list/index.vue'),
},
{
path: "/user",
name: "user",
mate: {
title: "用户",
path: '/mv',
name: 'mv',
meta: {
title: 'MV',
icon: 'icon-recordfill',
keepAlive: true,
icon: "icon-Profile",
},
component: () => import("@/views/user/index.vue"),
component: () => import('@/views/mv/index.vue'),
},
{
path: "/test",
name: "test",
mate: {
title: "用户",
path: '/history',
name: 'history',
meta: {
title: '历史',
icon: 'icon-a-TicketStar',
keepAlive: true,
icon: "icon-Profile",
},
component: () => import("@/views/test/test.vue"),
component: () => import('@/views/history/index.vue'),
},
{
path: '/user',
name: 'user',
meta: {
title: '用户',
icon: 'icon-Profile',
keepAlive: true,
noScroll: true,
},
component: () => import('@/views/user/index.vue'),
},
];
export default layoutRouter;

View File

@@ -1,8 +1,9 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import AppLayout from '@/layout/AppLayout.vue'
import homeRouter from '@/router/home'
import { createRouter, createWebHashHistory } from 'vue-router';
let loginRouter = {
import AppLayout from '@/layout/AppLayout.vue';
import homeRouter from '@/router/home';
const loginRouter = {
path: '/login',
name: 'login',
mate: {
@@ -11,17 +12,32 @@ let loginRouter = {
icon: 'icon-Home',
},
component: () => import('@/views/login/index.vue'),
}
};
const setRouter = {
path: '/set',
name: 'set',
mate: {
keepAlive: true,
title: '设置',
icon: 'icon-Home',
},
component: () => import('@/views/set/index.vue'),
};
const routes = [
{
path: '/',
component: AppLayout,
children: [...homeRouter, loginRouter],
children: [...homeRouter, loginRouter, setRouter],
},
]
{
path: '/lyric',
component: () => import('@/views/lyric/index.vue'),
},
];
export default createRouter({
routes: routes,
routes,
history: createWebHashHistory(),
})
});

7
src/shims-vue.d.ts vendored
View File

@@ -1,5 +1,6 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@@ -1,17 +1,23 @@
import { createStore } from "vuex";
import { SongResult } from "@/type/music";
import { getMusicUrl } from "@/api/music";
import homeRouter from "@/router/home";
import { createStore } from 'vuex';
import { getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import homeRouter from '@/router/home';
import { SongResult } from '@/type/music';
import { getMusicProxyUrl } from '@/utils';
interface State {
menus: any[]
play: boolean
isPlay: boolean
playMusic: SongResult
playMusicUrl: string
user: any
playList: SongResult[]
playListIndex: number
menus: any[];
play: boolean;
isPlay: boolean;
playMusic: SongResult;
playMusicUrl: string;
user: any;
playList: SongResult[];
playListIndex: number;
setData: any;
lyric: any;
isMobile: boolean;
}
const state: State = {
@@ -20,57 +26,86 @@ const state: State = {
isPlay: false,
playMusic: {} as SongResult,
playMusicUrl: '',
user: null,
user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null,
playList: [],
playListIndex: 0,
}
setData: null,
lyric: {},
isMobile: false,
};
const windowData = window as any;
const musicHistory = useMusicHistory();
const mutations = {
setMenus(state: State, menus: any[]) {
state.menus = menus
state.menus = menus;
},
async setPlay(state: State, playMusic: SongResult) {
state.playMusic = playMusic
state.playMusicUrl = await getSongUrl(playMusic.id)
state.play = true
state.playMusic = { ...playMusic, playLoading: true };
state.playMusicUrl = await getSongUrl(playMusic.id);
state.play = true;
musicHistory.addMusic(playMusic);
state.playMusic.playLoading = false;
},
setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay
state.isPlay = isPlay;
},
setPlayMusic(state: State, play: boolean) {
state.play = play
state.play = play;
},
setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = 0
state.playList = playList
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
state.playList = playList;
},
async nextPlay(state: State) {
state.playListIndex = (state.playListIndex + 1) % state.playList.length
await updatePlayMusic(state)
if (state.playList.length === 0) {
state.play = true;
return;
}
state.playListIndex = (state.playListIndex + 1) % state.playList.length;
await updatePlayMusic(state);
},
async prevPlay(state: State) {
state.playListIndex =
(state.playListIndex - 1 + state.playList.length) % state.playList.length
await updatePlayMusic(state)
if (state.playList.length === 0) {
state.play = true;
return;
}
state.playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
await updatePlayMusic(state);
},
}
async setSetData(state: State, setData: any) {
state.setData = setData;
windowData.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
},
};
const getSongUrl = async (id: number) => {
const { data } = await getMusicUrl(id);
console.log(data.data[0].url);
return data.data[0].url;
let url = '';
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id);
url = res.data.data.url;
}
} catch (error) {
console.error('error', error);
}
url = url || data.data[0].url;
return getMusicProxyUrl(url);
};
const updatePlayMusic = async (state: State) => {
state.playMusic = state.playList[state.playListIndex]
state.playMusicUrl = await getSongUrl(state.playMusic.id)
state.play = true
}
state.playMusic = state.playList[state.playListIndex];
state.playMusicUrl = await getSongUrl(state.playMusic.id);
state.play = true;
musicHistory.addMusic(state.playMusic);
};
const store = createStore({
state: state,
mutations: mutations,
state,
mutations,
});
export default store;

View File

@@ -3,7 +3,7 @@ export interface IAlbumNew {
albums: Album[];
}
interface Album {
export interface Album {
name: string;
id: number;
type: string;

168
src/type/day_recommend.ts Normal file
View File

@@ -0,0 +1,168 @@
export interface IDayRecommend {
dailySongs: DailySong[];
orderSongs: any[];
recommendReasons: RecommendReason[];
mvResourceInfos: null;
}
interface RecommendReason {
songId: number;
reason: string;
reasonId: string;
targetUrl: null;
}
interface DailySong {
name: string;
id: number;
pst: number;
t: number;
ar: Ar[];
alia: string[];
pop: number;
st: number;
rt: null | string;
fee: number;
v: number;
crbt: null;
cf: string;
al: Al;
dt: number;
h: H;
m: H;
l: H;
sq: H | null;
hr: H | null;
a: null;
cd: string;
no: number;
rtUrl: null;
ftype: number;
rtUrls: any[];
djId: number;
copyright: number;
s_id: number;
mark: number;
originCoverType: number;
originSongSimpleData: OriginSongSimpleDatum | null;
tagPicList: null;
resourceState: boolean;
version: number;
songJumpInfo: null;
entertainmentTags: null;
single: number;
noCopyrightRcmd: null;
rtype: number;
rurl: null;
mst: number;
cp: number;
mv: number;
publishTime: number;
reason: null | string;
videoInfo: VideoInfo;
recommendReason: null | string;
privilege: Privilege;
alg: string;
tns?: string[];
s_ctrp?: string;
}
interface Privilege {
id: number;
fee: number;
payed: number;
realPayed: number;
st: number;
pl: number;
dl: number;
sp: number;
cp: number;
subp: number;
cs: boolean;
maxbr: number;
fl: number;
pc: null;
toast: boolean;
flag: number;
paidBigBang: boolean;
preSell: boolean;
playMaxbr: number;
downloadMaxbr: number;
maxBrLevel: string;
playMaxBrLevel: string;
downloadMaxBrLevel: string;
plLevel: string;
dlLevel: string;
flLevel: string;
rscl: null;
freeTrialPrivilege: FreeTrialPrivilege;
rightSource: number;
chargeInfoList: ChargeInfoList[];
}
interface ChargeInfoList {
rate: number;
chargeUrl: null;
chargeMessage: null;
chargeType: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
listenType: number;
cannotListenReason: number;
playReason: null;
}
interface VideoInfo {
moreThanOne: boolean;
video: Video | null;
}
interface Video {
vid: string;
type: number;
title: string;
playTime: number;
coverUrl: string;
publishTime: number;
artists: null;
alias: null;
}
interface OriginSongSimpleDatum {
songId: number;
name: string;
artists: Artist[];
albumMeta: Artist;
}
interface Artist {
id: number;
name: string;
}
interface H {
br: number;
fid: number;
size: number;
vd: number;
sr: number;
}
interface Al {
id: number;
name: string;
picUrl: string;
tns: string[];
pic_str?: string;
pic: number;
}
interface Ar {
id: number;
name: string;
tns: any[];
alias: any[];
}

5
src/type/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface IData<T> {
code: number;
data: T;
result: T;
}

View File

@@ -6,7 +6,7 @@ export interface IList {
total: number;
}
interface Playlist {
export interface Playlist {
name: string;
id: number;
trackNumberUpdateTime: number;
@@ -120,8 +120,8 @@ interface AvatarDetail {
}
interface Expert {
"2": string;
"1"?: string;
'2': string;
'1'?: string;
}
// 推荐歌单

View File

@@ -46,7 +46,7 @@ interface FreeTrialPrivilege {
userConsumable: boolean;
}
interface Playlist {
export interface Playlist {
id: number;
name: string;
coverImgId: number;

View File

@@ -1,14 +1,14 @@
export interface ILyric {
sgc: boolean;
sfy: boolean;
qfy: boolean;
lrc: Lrc;
klyric: Lrc;
tlyric: Lrc;
code: number;
}
interface Lrc {
version: number;
lyric: string;
}
export interface ILyric {
sgc: boolean;
sfy: boolean;
qfy: boolean;
lrc: Lrc;
klyric: Lrc;
tlyric: Lrc;
code: number;
}
interface Lrc {
version: number;
lyric: string;
}

View File

@@ -14,6 +14,8 @@ export interface SongResult {
trackNumberUpdateTime?: any;
song: Song;
alg: string;
count?: number;
playLoading?: boolean;
}
interface Song {

112
src/type/mv.ts Normal file
View File

@@ -0,0 +1,112 @@
export interface IMvItem {
id: number;
cover: string;
name: string;
playCount: number;
briefDesc?: any;
desc?: any;
artistName: string;
artistId: number;
duration: number;
mark: number;
mv: IMvData;
lastRank: number;
score: number;
subed: boolean;
artists: Artist[];
transNames?: string[];
alias?: string[];
}
export interface IMvData {
authId: number;
status: number;
id: number;
title: string;
subTitle: string;
appTitle: string;
aliaName: string;
transName: string;
pic4v3: number;
pic16v9: number;
caption: number;
captionLanguage: string;
style?: any;
mottos: string;
oneword?: any;
appword: string;
stars?: any;
desc: string;
area: string;
type: string;
subType: string;
neteaseonly: number;
upban: number;
topWeeks: string;
publishTime: string;
online: number;
score: number;
plays: number;
monthplays: number;
weekplays: number;
dayplays: number;
fee: number;
artists: Artist[];
videos: Video[];
}
interface Video {
tagSign: TagSign;
tag: string;
url: string;
duration: number;
size: number;
width: number;
height: number;
container: string;
md5: string;
check: boolean;
}
interface TagSign {
br: number;
type: string;
tagSign: string;
resolution: number;
mvtype: string;
}
interface Artist {
id: number;
name: string;
}
// {
// "id": 14686812,
// "url": "http://vodkgeyttp8.vod.126.net/cloudmusic/e18b/core/aa57/6f56150a35613ef77fc70b253bea4977.mp4?wsSecret=84a301277e05143de1dd912d2a4dbb0d&wsTime=1703668700",
// "r": 1080,
// "size": 215391070,
// "md5": "",
// "code": 200,
// "expi": 3600,
// "fee": 0,
// "mvFee": 0,
// "st": 0,
// "promotionVo": null,
// "msg": ""
// }
export interface IMvUrlData {
id: number;
url: string;
r: number;
size: number;
md5: string;
code: number;
expi: number;
fee: number;
mvFee: number;
st: number;
promotionVo: null | any;
msg: string;
}

View File

@@ -6,11 +6,11 @@ export interface IPlayListSort {
}
interface SortCategories {
"0": string;
"1": string;
"2": string;
"3": string;
"4": string;
'0': string;
'1': string;
'2': string;
'3': string;
'4': string;
}
interface SortAll {

View File

@@ -540,7 +540,7 @@ interface Song2 {
}
interface KsongInfos {
"347230": _347230;
'347230': _347230;
}
interface _347230 {

View File

@@ -1,38 +1,77 @@
import { computed } from 'vue'
import { computed } from 'vue';
import store from '@/store';
// 设置歌手背景图片
export const setBackgroundImg = (url: String) => {
return 'background-image:' + 'url(' + url + ')'
}
return `background-image:url(${url})`;
};
// 设置动画类型
export const setAnimationClass = (type: String) => {
return 'animate__animated ' + type
}
return `animate__animated ${type}`;
};
// 设置动画延时
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
return 'animation-delay:' + index * time + 'ms'
}
return `animation-delay:${index * time}ms`;
};
//将秒转换为分钟和秒
// 将秒转换为分钟和秒
export const secondToMinute = (s: number) => {
if (!s) {
return '00:00'
return '00:00';
}
let minute: number = Math.floor(s / 60)
let second: number = Math.floor(s % 60)
let minuteStr: string =
minute > 9 ? minute.toString() : '0' + minute.toString()
let secondStr: string =
second > 9 ? second.toString() : '0' + second.toString()
return minuteStr + ':' + secondStr
}
const minute: number = Math.floor(s / 60);
const second: number = Math.floor(s % 60);
const minuteStr: string = minute > 9 ? minute.toString() : `0${minute.toString()}`;
const secondStr: string = second > 9 ? second.toString() : `0${second.toString()}`;
return `${minuteStr}:${secondStr}`;
};
// 格式化数字 千,万, 百万, 千万,亿
export const formatNumber = (num: string | number) => {
num = Number(num);
if (num < 10000) {
return num;
}
if (num < 100000000) {
return `${(num / 10000).toFixed(1)}`;
}
return `${(num / 100000000).toFixed(1)}亿`;
};
const windowData = window as any;
export const getIsMc = () => {
return !!location.href.includes('mc.')
}
if (!windowData.electron) {
return false;
}
if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) {
return true;
}
return false;
};
const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';
export const getImgUrl = computed(() => (url: string, size: string) => {
const bdUrl = 'https://image.baidu.com/search/down?url='
const imgUrl = encodeURIComponent(`${url}?param=${size}`)
return getIsMc() ? `${bdUrl}${imgUrl}` : `${url}?param=${size}`
})
export const getMusicProxyUrl = (url: string) => {
if (!getIsMc()) {
return url;
}
const PUrl = url.split('').join('+');
return `${ProxyUrl}/mc?url=${PUrl}`;
};
export const getImgUrl = computed(() => (url: string | undefined, size: string = '') => {
const bdUrl = 'https://image.baidu.com/search/down?url=';
const imgUrl = encodeURIComponent(`${url}?param=${size}`);
return `${bdUrl}${imgUrl}`;
});
export const isMobile = computed(() => {
const flag = navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i,
);
store.state.isMobile = !!flag;
// 给html标签 添加mobile
if (flag) document.documentElement.classList.add('mobile');
return !!flag;
});

View File

@@ -1,8 +1,9 @@
import axios from "axios";
let baseURL = import.meta.env.VITE_API + "";
import axios from 'axios';
const baseURL = `${import.meta.env.VITE_API}`;
const request = axios.create({
baseURL: baseURL,
baseURL,
timeout: 10000,
});
@@ -11,12 +12,12 @@ request.interceptors.request.use(
(config) => {
// 在请求发送之前做一些处理
// 在get请求params中添加timestamp
if (config.method === "get") {
if (config.method === 'get') {
config.params = {
...config.params,
timestamp: Date.now(),
};
let token = localStorage.getItem("token");
const token = localStorage.getItem('token');
if (token) {
config.params.cookie = token;
}
@@ -27,7 +28,7 @@ request.interceptors.request.use(
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error);
}
},
);
export default request;

View File

@@ -1,19 +0,0 @@
import axios from "axios";
let baseURL = import.meta.env.VITE_API_MT + "";
const request = axios.create({
baseURL: baseURL,
timeout: 10000,
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
return config;
},
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error);
}
);
export default request;

View File

@@ -1,19 +1,20 @@
import axios from "axios"
let baseURL = import.meta.env.VITE_API_MUSIC + ""
import axios from 'axios';
const baseURL = `${import.meta.env.VITE_API_MUSIC}`;
const request = axios.create({
baseURL: baseURL,
baseURL,
timeout: 10000,
})
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
return config
return config;
},
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
export default request
export default request;

View File

@@ -0,0 +1,60 @@
<template>
<div class="history-page">
<div class="title">播放历史</div>
<n-scrollbar :size="100">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
v-for="(item, index) in musicList"
:key="item.id"
class="history-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" />
<div class="history-item-count">
{{ item.count }}
</div>
<div class="history-item-delete">
<i class="iconfont icon-close" @click="delMusic(item)"></i>
</div>
</div>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'History',
});
const { delMusic, musicList } = useMusicHistory();
</script>
<style scoped lang="scss">
.history-page {
@apply h-full w-full pt-2;
.title {
@apply pl-4 text-xl font-bold;
}
.history-list-content {
@apply px-4 mt-2 pb-28;
.history-item {
@apply flex items-center justify-between;
&-content {
@apply flex-1;
}
&-count {
@apply px-4 text-lg;
}
&-delete {
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
}
}
}
}
</style>

View File

@@ -1,32 +1,41 @@
<template>
<n-layout class="main-page" :native-scrollbar="false">
<!-- 推荐歌手 -->
<recommend-singer />
<div class="main-content">
<!-- 歌单分类列表 -->
<playlist-type />
<!-- 本周最热音乐 -->
<recommend-songlist />
<!-- 推荐最新专辑 -->
<recommend-album />
<n-scrollbar :size="100" :x-scrollable="false">
<div class="main-page">
<!-- 推荐歌手 -->
<recommend-singer />
<div class="main-content">
<!-- 歌单分类列表 -->
<playlist-type v-if="!isMobile" />
<!-- 本周最热音乐 -->
<recommend-songlist />
<!-- 推荐最新专辑 -->
<recommend-album />
</div>
</div>
</n-layout>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
const RecommendSinger = defineAsyncComponent(() => import("@/components/RecommendSinger.vue"));
const PlaylistType = defineAsyncComponent(() => import("@/components/PlaylistType.vue"));
const RecommendSonglist = defineAsyncComponent(() => import("@/components/RecommendSonglist.vue"));
const RecommendAlbum = defineAsyncComponent(() => import("@/components/RecommendAlbum.vue"));
import { isMobile } from '@/utils';
const RecommendSinger = defineAsyncComponent(() => import('@/components/RecommendSinger.vue'));
const PlaylistType = defineAsyncComponent(() => import('@/components/PlaylistType.vue'));
const RecommendSonglist = defineAsyncComponent(() => import('@/components/RecommendSonglist.vue'));
const RecommendAlbum = defineAsyncComponent(() => import('@/components/RecommendAlbum.vue'));
defineOptions({
name: 'Home',
});
</script>
<style lang="scss" scoped>
.main-page {
@apply mt-4 h-full;
@apply h-full w-full overflow-hidden;
}
.main-content {
@apply mt-6 flex mb-28;
}
.main-content {
@apply mt-6 flex;
.mobile .main-content {
@apply flex-col mx-4;
}
</style>

View File

@@ -1,119 +1,72 @@
<script lang="ts" setup>
import { getRecommendList, getListDetail, getListByTag, getListByCat } from '@/api/list'
import { computed, onMounted, ref, watch } from 'vue';
import type { IRecommendList, IRecommendItem } from "@/type/list";
import type { IListDetail } from "@/type/listDetail";
import { setAnimationClass, setAnimationDelay, getImgUrl } from "@/utils";
import SongItem from "@/components/common/SongItem.vue";
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import { getListByCat, getListDetail, getRecommendList } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
const store = useStore();
const recommendList = ref()
const showMusic = ref(false)
defineOptions({
name: 'List',
});
const recommendItem = ref<IRecommendItem>()
const listDetail = ref<IListDetail>()
const recommendList = ref();
const showMusic = ref(false);
const recommendItem = ref<IRecommendItem | null>();
const listDetail = ref<IListDetail | null>();
const selectRecommendItem = async (item: IRecommendItem) => {
showMusic.value = true
const { data } = await getListDetail(item.id)
recommendItem.value = item
listDetail.value = data
}
const closeMusic = () => {
showMusic.value = false
}
recommendItem.value = null;
listDetail.value = null;
showMusic.value = true;
const { data } = await getListDetail(item.id);
recommendItem.value = item;
listDetail.value = data;
};
const route = useRoute();
const listTitle = ref(route.query.type || "歌单列表");
const listTitle = ref(route.query.type || '歌单列表');
const loadList = async (type: any) => {
const loadList = async (type: string) => {
const params = {
cat: type || '',
limit: 30,
offset: 0
}
offset: 0,
};
const { data } = await getListByCat(params);
recommendList.value = data.playlists
}
recommendList.value = data.playlists;
};
if (route.query.type) {
loadList(route.query.type)
loadList(route.query.type as string);
} else {
getRecommendList().then((res: { data: { result: any; }; }) => {
recommendList.value = res.data.result
})
getRecommendList().then((res: { data: { result: any } }) => {
recommendList.value = res.data.result;
});
}
watch(
() => route.query,
async newParams => {
const params = {
tag: newParams.type || '',
limit: 30,
before: 0
async (newParams) => {
if (newParams.type) {
recommendList.value = null;
loadList(newParams.type as string);
}
loadList(newParams.type);
}
)
const musicFullClass = computed(() => {
if (recommendItem.value) {
return setAnimationClass('animate__fadeInUp')
} else {
return setAnimationClass('animate__fadeOutDown')
}
})
// 格式化数字 千,万, 百万, 千万,亿
const formatNumber = (num: any) => {
num = num * 1
if (num < 10000) {
return num
}
if (num < 100000000) {
return (num / 10000).toFixed(1) + '万'
}
return (num / 100000000).toFixed(1) + '亿'
}
const formatDetail = computed(() => (detail: any) => {
let song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id,
}
detail.song = song
detail.picUrl = detail.al.picUrl
return detail
})
const handlePlay = (item: any) => {
const list = listDetail.value?.playlist.tracks || []
console.log('list',list)
console.log('item',item)
const musicIndex = (list.findIndex((music: any) => music.id == item.id) || 0)
store.commit('setPlayList', list.slice(musicIndex))
}
},
);
</script>
<template>
<div class="list-page">
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
<!-- 歌单列表 -->
<n-layout class="recommend" :native-scrollbar="false" @click="showMusic = false">
<div
class="recommend-title"
:class="setAnimationClass('animate__bounceInLeft')"
>{{ listTitle }}</div>
<div class="recommend-list" v-if="recommendList">
<n-scrollbar class="recommend" :size="100" @click="showMusic = false">
<div v-if="recommendList" class="recommend-list">
<div
v-for="(item, index) in recommendList"
:key="item.id"
class="recommend-item"
v-for="(item,index) in recommendList"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
@click.stop="selectRecommendItem(item)"
@@ -121,7 +74,9 @@ const handlePlay = (item: any) => {
<div class="recommend-item-img">
<n-image
class="recommend-item-img-img"
:src="getImgUrl( (item.picUrl || item.coverImgUrl), '200y200')"
:src="getImgUrl(item.picUrl || item.coverImgUrl, '200y200')"
width="200"
height="200"
lazy
preview-disabled
/>
@@ -133,50 +88,30 @@ const handlePlay = (item: any) => {
<div class="recommend-item-title">{{ item.name }}</div>
</div>
</div>
</n-layout>
<transition name="musicPage">
<div class="music-page" v-if="showMusic">
<i class="iconfont icon-icon_error music-close" @click="closeMusic()"></i>
<div class="music-title">{{ recommendItem?.name }}</div>
<!-- 歌单歌曲列表 -->
<n-layout class="music-list" :native-scrollbar="false">
<div
v-for="(item, index) in listDetail?.playlist.tracks"
:key="item.id"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 50)"
>
<SongItem :item="formatDetail(item)" @play="handlePlay" />
</div>
</n-layout>
</div>
</transition>
</n-scrollbar>
<music-list
v-if="listDetail?.playlist"
v-model:show="showMusic"
:name="listDetail?.playlist.name"
:song-list="listDetail?.playlist.tracks"
/>
</div>
</template>
<style lang="scss" scoped>
.list-page {
@apply relative h-full;
}
.musicPage-enter-active {
animation: fadeInUp 0.8s ease-in-out;
}
.musicPage-leave-active {
animation: fadeOutDown 0.8s ease-in-out;
@apply relative h-full w-full px-4;
}
.recommend {
@apply w-full h-full;
@apply w-full h-full bg-none;
&-title {
@apply text-lg font-bold text-white py-4;
@apply text-lg font-bold text-white pb-4;
}
&-list {
@apply grid gap-6 pb-28;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(13%, 1fr));
}
&-item {
&-img {
@@ -185,8 +120,7 @@ const handlePlay = (item: any) => {
@apply hover:scale-110 transition-all duration-300 ease-in-out;
}
&-img {
width: 200px;
height: 200px;
@apply h-full w-full rounded-xl overflow-hidden;
}
.top {
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
@@ -218,29 +152,9 @@ const handlePlay = (item: any) => {
}
}
.music {
&-page {
// width: 100%;
// height: 70%;
// position: absolute;
// background-color: #000000f0;
// bottom: 0;
// left: 0;
// border-radius: 30px 30px 0 0;
// animation-duration: 300ms;
@apply w-full h-5/6 absolute bottom-0 left-0 bg-black rounded-t-3xl flex flex-col transition-all;
}
&-title {
@apply text-lg font-bold text-white p-4;
}
&-close {
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
}
&-list {
height: 594px;
background-color: #00000000;
.mobile {
.recommend-list {
grid-template-columns: repeat(auto-fill, minmax(25%, 1fr));
}
}
</style>
</style>

View File

@@ -1,77 +1,95 @@
<script lang="ts" setup>
import { getQrKey, createQr, checkQr, getLoginStatus } from '@/api/login'
import { onMounted } from '@vue/runtime-core';
import { ref } from 'vue';
import { getUserDetail, loginByCellphone } from '@/api/login';
import { useStore } from 'vuex';
import { useMessage } from 'naive-ui'
import { setAnimationClass, setAnimationDelay } from "@/utils";
import { useMessage } from 'naive-ui';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
import { isMobile, setAnimationClass } from '@/utils';
const message = useMessage()
defineOptions({
name: 'Login',
});
const message = useMessage();
const store = useStore();
const router = useRouter()
const router = useRouter();
const qrUrl = ref<string>()
const qrUrl = ref<string>();
onMounted(() => {
loadLogin()
})
loadLogin();
});
const timerRef = ref(null);
const loadLogin = async () => {
const qrKey = await getQrKey()
const key = qrKey.data.data.unikey
const { data } = await createQr(key)
qrUrl.value = data.data.qrimg
timerIsQr(key)
}
try {
const qrKey = await getQrKey();
const key = qrKey.data.data.unikey;
const { data } = await createQr(key);
qrUrl.value = data.data.qrimg;
const timer = timerIsQr(key);
// 添加对定时器的引用,以便在出现错误时可以清除
timerRef.value = timer as any;
} catch (error) {
console.error('加载登录信息时出错:', error);
}
};
// 使用 ref 来保存定时器,便于在任何地方清除它
const timerIsQr = (key: string) => {
const timer = setInterval(async () => {
const { data } = await checkQr(key)
try {
const { data } = await checkQr(key);
if (data.code === 800) {
clearInterval(timer)
if (data.code === 800) {
clearInterval(timer);
timerRef.value = null;
}
if (data.code === 803) {
localStorage.setItem('token', data.cookie);
const user = await getUserDetail();
store.state.user = user.data.profile;
localStorage.setItem('user', JSON.stringify(store.state.user));
message.success('登录成功');
clearInterval(timer);
timerRef.value = null;
router.push('/user');
}
} catch (error) {
console.error('检查二维码状态时出错:', error);
// 在出现错误时清除定时器
clearInterval(timer);
timerRef.value = null;
}
if (data.code === 803) {
// 将token存入localStorage
localStorage.setItem('token', data.cookie)
const user = await getUserDetail()
store.state.user = user.data.profile
message.success('登录成功')
await getLoginStatus().then(res => {
console.log(res);
})
clearInterval(timer)
}
}, 5000);
}
}, 2000);
return timer;
};
// 是否扫码登陆
const isQr = ref(true)
const isQr = ref(!isMobile.value);
const chooseQr = () => {
isQr.value = !isQr.value
}
isQr.value = !isQr.value;
};
// 手机号登录
const phone = ref('')
const password = ref('')
const phone = ref('');
const password = ref('');
const loginPhone = async () => {
const { data } = await loginByCellphone(phone.value, password.value)
const { data } = await loginByCellphone(phone.value, password.value);
if (data.code === 200) {
message.success('登录成功')
store.state.user = data.profile
localStorage.setItem('token', data.cookie)
message.success('登录成功');
store.state.user = data.profile;
localStorage.setItem('token', data.cookie);
setTimeout(() => {
router.push('/')
router.push('/user');
}, 1000);
}
}
};
</script>
<template>
@@ -79,12 +97,12 @@ const loginPhone = async () => {
<div class="phone-login">
<div class="bg"></div>
<div class="content">
<div class="phone" v-if="isQr" :class="setAnimationClass('animate__fadeInUp')">
<div v-if="isQr" class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">扫码登陆</div>
<img class="qr-img" :src="qrUrl" />
<div class="text">使用网易云APP扫码登录</div>
</div>
<div class="phone" v-else :class="setAnimationClass('animate__fadeInUp')">
<div v-else class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">手机号登录</div>
<div class="phone-page">
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
@@ -102,7 +120,7 @@ const loginPhone = async () => {
<style lang="scss" scoped>
.login-page {
@apply p-4 flex flex-col items-center justify-center p-20;
@apply flex flex-col items-center justify-center p-20 pt-20;
}
.login-title {
@@ -165,4 +183,4 @@ const loginPhone = async () => {
}
}
}
</style>
</style>

Some files were not shown because too many files have changed in this diff Show More