feat: 新增歌手页 & fix: 修复一些小问题

This commit is contained in:
imsyy
2023-11-27 18:23:01 +08:00
parent b435b2152e
commit e4e0af1d9d
32 changed files with 870 additions and 117 deletions

View File

@@ -29,7 +29,7 @@
- 自动进行每日签到及云贝签到
- 封面主题色自适应
- 本地歌曲管理及分类 ~~以及音乐标签编辑~~
- 支持 [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server),自动替换变灰歌曲(客户端独占功能)
- **支持播放部分无版权歌曲(可能会与原曲不匹配,客户端独占功能)**
- 下载歌曲(最高支持 Hi-Res
- 新建歌单及歌单编辑
- 收藏 / 取消收藏歌单或歌手
@@ -48,11 +48,13 @@
- 支持评论区及评论点赞
- 明暗模式自动 / 手动切换
- ~~移动端基础适配~~
- `i18n` 支持(暂时去除,感觉不需要)
- ~~`i18n` 支持~~
#### 待办
- [ ] 电台节目支持
- [ ] 完善音乐频谱
- [ ] 添加桌面歌词
- [ ] 多种布局方式
- [ ] 发表评论
## 😍 Screenshots
@@ -62,35 +64,42 @@
<details>
<summary>主页面</summary>
![主页面](/screenshots/SPlayer%20-%20%E4%B8%BB%E9%A1%B5%E9%9D%A2.jpg)
![主页面](/screenshots/SPlayer%20-%20主页面.jpg)
</details>
<details>
<summary>播放页面</summary>
![播放页面](/screenshots/SPlayer%20-%20%E6%92%AD%E6%94%BE%E9%A1%B5%E9%9D%A2.jpg)
![播放页面](/screenshots/SPlayer%20-%20播放页面.jpg)
</details>
<details>
<summary>发现页面</summary>
![发现页面](/screenshots/SPlayer%20-%20%E5%8F%91%E7%8E%B0%E9%A1%B5%E9%9D%A2.jpg)
![发现页面](/screenshots/SPlayer%20-%20发现页面.jpg)
</details>
<details>
<summary>歌单页面</summary>
> 待装修
![发现页面](/screenshots/SPlayer%20-%20歌单页面.jpg)
</details>
<details>
<summary>评论页面</summary>
> 待装修
![发现页面](/screenshots/SPlayer%20-%20评论页面.jpg)
</details>
<details>
<summary>本地音乐</summary>
![发现页面](/screenshots/SPlayer%20-%20本地音乐.jpg)
</details>
@@ -140,6 +149,10 @@ npm build
构建完成后可将生成的 `out/renderer` 文件夹内的文件上传至服务器
若使用的为第三方部署平台,比如 `Vercel`,请将 `Build and Output Settings` 中的 `Output Directory` 改为 `out/renderer`
![build](/screenshots/build.png)
### 构建客户端
```bash

1
components.d.ts vendored
View File

@@ -43,6 +43,7 @@ declare module 'vue' {
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH3: typeof import('naive-ui')['NH3']
NH4: typeof import('naive-ui')['NH4']
NH6: typeof import('naive-ui')['NH6']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']

View File

@@ -1,7 +1,7 @@
# 应用程序的唯一标识符
appId: com.splayer-imsyy.app
appId: com.imsyy.splayer
# 应用程序的产品名称
productName: splayer-desktop-dev
productName: splayer-desktop
# 构建资源所在的目录
directories:
buildResources: build
@@ -18,7 +18,7 @@ asarUnpack:
# Windows 平台配置
win:
# 可执行文件名
executableName: splayer-desktop-dev
executableName: splayer-desktop
# 应用程序的图标文件路径
icon: public/images/logo/favicon_256.png
# 构建的目标类型
@@ -37,7 +37,7 @@ nsis:
# 创建桌面图标
createDesktopShortcut: always
# 是否允许 UAC 提升权限
allowElevation: false
allowElevation: true
# 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true
# macOS 平台配置
@@ -64,7 +64,7 @@ linux:
- snap
- deb
# 维护者信息
maintainer: electronjs.org
maintainer: imsyy.top
# 应用程序类别
category: Utility
# AppImage 配置
@@ -76,6 +76,8 @@ npmRebuild: false
# 自动更新的配置
publish:
# 更新提供商
provider: generic
provider: github
# 自动更新检查的 URL
url: https://example.com/auto-updates
# url: https://example.com/auto-updates
owner: "imsyy"
repo: "splayer"

View File

@@ -3,9 +3,11 @@ import { app, protocol, shell, BrowserWindow, globalShortcut } from "electron";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { startNcmServer } from "@main/startNcmServer";
import { startMainServer } from "@main/startMainServer";
import createSystemInfo from "@main/createSystemInfo";
import createGlobalShortcut from "@main/createGlobalShortcut";
import { configureAutoUpdater } from "@main/utils/checkUpdates";
import createSystemInfo from "@main/utils/createSystemInfo";
import createGlobalShortcut from "@main/utils/createGlobalShortcut";
import mainIpcMain from "@main/mainIpcMain";
import log from "electron-log";
// 主窗口
let mainWindow;
@@ -21,6 +23,14 @@ protocol.registerSchemesAsPrivileged([
{ scheme: "imsyy-splayer", privileges: { standard: true, secure: true } },
]);
// 配置 log
log.transports.file.resolvePathFn = () =>
join(app.getPath("documents"), "/SPlayer/splayer-log.txt");
// 设置日志文件的最大大小为 10 MB
log.transports.file.maxSize = 10 * 1024 * 1024;
// 绑定 console.log
console.log = log.log.bind(log);
// 创建主窗口
const createWindow = () => {
// 创建浏览器窗口
@@ -84,7 +94,7 @@ app.whenReady().then(async () => {
if (!gotTheLock) {
// 如果获取不到单例锁,表示已经有一个实例在运行
app.quit();
console.error("已有一个程序正在运行");
log.error("已有一个程序正在运行");
return false;
}
@@ -128,6 +138,9 @@ app.whenReady().then(async () => {
// 注册快捷键
createGlobalShortcut(mainWindow);
// 检测更新
configureAutoUpdater(process.platform);
});
// 将要退出

View File

@@ -1,9 +1,9 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { readDirAsync } from "@main/readDirAsync";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { write } from "node-id3";
import { download } from "electron-dl";
import getNeteaseMusicUrl from "@main/getNeteaseMusicUrl";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import axios from "axios";
import fs from "fs/promises";

View File

@@ -0,0 +1,29 @@
const { autoUpdater } = require("electron-updater");
const checkForUpdates = () => {
autoUpdater.checkForUpdates();
};
export const configureAutoUpdater = () => {
checkForUpdates();
// 监听检查更新的事件
autoUpdater.on("checking-for-update", () => {
console.log("Checking for update...");
});
autoUpdater.on("update-available", (info) => {
console.log("Update available:", info);
});
autoUpdater.on("update-not-available", () => {
console.log("Update not available.");
});
autoUpdater.on("update-downloaded", () => {
console.log("Update downloaded. Ready to install.");
// 在需要的时候,触发安装更新
autoUpdater.quitAndInstall();
});
};

View File

@@ -1,10 +1,14 @@
{
"name": "splayer",
"version": "2.0.0-beta.1",
"description": "An Electron application with Vue",
"version": "2.0.0-beta.2",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://www.electronjs.org",
"author": "imsyy",
"home": "https://imsyy.top",
"github": "https://github.com/imsyy/SPlayer",
"engines": {
"node": ">=16.16.0"
},
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
@@ -24,6 +28,7 @@
"axios": "^1.4.0",
"colorthief": "^2.4.0",
"electron-dl": "^3.5.1",
"electron-updater": "^6.1.7",
"express": "^4.18.2",
"express-http-proxy": "^1.6.3",
"howler": "^2.2.3",
@@ -46,6 +51,7 @@
"@vue/eslint-config-prettier": "^8.0.0",
"electron": "^25.6.0",
"electron-builder": "^24.6.3",
"electron-log": "^5.0.1",
"electron-vite": "^1.0.27",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",

54
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ dependencies:
electron-dl:
specifier: ^3.5.1
version: 3.5.1
electron-updater:
specifier: ^6.1.7
version: 6.1.7
express:
specifier: ^4.18.2
version: 4.18.2
@@ -88,6 +91,9 @@ devDependencies:
electron-builder:
specifier: ^24.6.3
version: 24.6.3
electron-log:
specifier: ^5.0.1
version: 5.0.1
electron-vite:
specifier: ^1.0.27
version: 1.0.27(vite@4.4.9)
@@ -1208,7 +1214,6 @@ packages:
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
/array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
@@ -1416,6 +1421,16 @@ packages:
- supports-color
dev: true
/builder-util-runtime@9.2.3:
resolution: {integrity: sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==}
engines: {node: '>=12.0.0'}
dependencies:
debug: 4.3.4
sax: 1.2.4
transitivePeerDependencies:
- supports-color
dev: false
/builder-util@24.5.0:
resolution: {integrity: sha512-STnBmZN/M5vGcv01u/K8l+H+kplTaq4PAIn3yeuufUKSpcdro0DhJWxPI81k5XcNfC//bjM3+n9nr8F9uV4uAQ==}
dependencies:
@@ -1981,6 +1996,11 @@ packages:
unused-filename: 2.1.0
dev: false
/electron-log@5.0.1:
resolution: {integrity: sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==}
engines: {node: '>= 14'}
dev: true
/electron-publish@24.5.0:
resolution: {integrity: sha512-zwo70suH15L15B4ZWNDoEg27HIYoPsGJUF7xevLJLSI7JUPC8l2yLBdLGwqueJ5XkDL7ucYyRZzxJVR8ElV9BA==}
dependencies:
@@ -1999,6 +2019,21 @@ packages:
resolution: {integrity: sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA==}
dev: true
/electron-updater@6.1.7:
resolution: {integrity: sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==}
dependencies:
builder-util-runtime: 9.2.3
fs-extra: 10.1.0
js-yaml: 4.1.0
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.5.4
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
dev: false
/electron-vite@1.0.27(vite@4.4.9):
resolution: {integrity: sha512-T8UVt9HtMFMMqU78dhv8TsRHYxMkuMTIZBIFYHzfeEoJ1Go3tVemgY/kO6sTTv94jIhkhcZIkvwmq4liABFjmA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -2575,7 +2610,6 @@ packages:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
/fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
@@ -3140,7 +3174,6 @@ packages:
hasBin: true
dependencies:
argparse: 2.0.1
dev: true
/jsbn@0.1.1:
resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
@@ -3191,7 +3224,6 @@ packages:
universalify: 2.0.0
optionalDependencies:
graceful-fs: 4.2.11
dev: true
/jsprim@1.4.2:
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
@@ -3210,7 +3242,6 @@ packages:
/lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
dev: true
/levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
@@ -3259,6 +3290,14 @@ packages:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: true
/lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
dev: false
/lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
dev: false
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -4473,6 +4512,10 @@ packages:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: false
/tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
dev: false
/titleize@3.0.0:
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
engines: {node: '>=12'}
@@ -4616,7 +4659,6 @@ packages:
/universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
dev: true
/unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
screenshots/build.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -39,3 +39,92 @@ export const getArtistDetail = (id) => {
},
});
};
/**
* 获取歌手部分信息和热门歌曲
* @param {number} id - 歌手id
*/
export const getArtistSongs = (id) => {
return axios({
method: "GET",
url: "/artists",
params: {
id,
timestamp: new Date().getTime(),
},
});
};
/**
* 获取歌手全部歌曲
* @param {number} id - 歌手id
* @param {number} [limit=50] - 返回数量默认50
* @param {number} [offset=0] - 偏移数量默认0
* @param {string} order - hot: 热门, time: 时间
*/
export const getArtistAllSongs = (id, limit = 50, offset = 0, order = "hot") => {
return axios({
method: "GET",
url: "/artist/songs",
params: {
id,
limit,
offset,
order,
timestamp: new Date().getTime(),
},
});
};
/**
* 获取歌手专辑
* @param {number} id - 歌手id
* @param {number} [limit=50] - 返回数量默认50
* @param {number} [offset=0] - 偏移数量默认0
*/
export const getArtistAblums = (id, limit = 50, offset = 0) => {
return axios({
method: "GET",
url: "/artist/album",
params: {
id,
limit,
offset,
},
});
};
/**
* 获取歌手视频
* @param {number} id - 歌手id
* @param {number} [limit=50] - 返回数量默认50
* @param {number} [offset=0] - 偏移数量默认0
*/
export const getArtistVideos = (id, limit = 50, offset = 0) => {
return axios({
method: "GET",
url: "/artist/mv",
params: {
id,
limit,
offset,
},
});
};
/**
* 收藏/取消收藏歌手
* @param {number} t - 操作类型1 为收藏,其他为取消收藏
* @param {number} id - 歌手id
*/
export const likeArtist = (t, id) => {
return axios({
method: "GET",
url: "/artist/sub",
params: {
t,
id,
timestamp: new Date().getTime(),
},
});
};

View File

@@ -199,6 +199,7 @@ export const likePlaylist = (t, id) => {
return axios({
method: "GET",
url: "/playlist/subscribe",
noCookie: true, // 临时解决无法收藏歌单
params: {
t,
id,

View File

@@ -78,5 +78,7 @@
"more": "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2z",
"more-vert": "M12 8c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2z",
"download": "M16.59 9H15V4c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1v5H7.41c-.89 0-1.34 1.08-.71 1.71l4.59 4.59c.39.39 1.02.39 1.41 0l4.59-4.59c.63-.63.19-1.71-.7-1.71zM5 19c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1z",
"radio": "M3.24 6.15C2.51 6.43 2 7.17 2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.89-2-2-2H8.3l8.26-3.34L15.88 1L3.24 6.15zM7 20c-1.66 0-3-1.34-3-3s1.34-3 3-3s3 1.34 3 3s-1.34 3-3 3zm13-8h-2v-2h-2v2H4V8h16v4z"
"radio": "M3.24 6.15C2.51 6.43 2 7.17 2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.89-2-2-2H8.3l8.26-3.34L15.88 1L3.24 6.15zM7 20c-1.66 0-3-1.34-3-3s1.34-3 3-3s3 1.34 3 3s-1.34 3-3 3zm13-8h-2v-2h-2v2H4V8h16v4z",
"person-add": "M15.39 14.56C13.71 13.7 11.53 13 9 13s-4.71.7-6.39 1.56A2.97 2.97 0 0 0 1 17.22V20h16v-2.78c0-1.12-.61-2.15-1.61-2.66zM9 12c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4zm11-3V7c0-.55-.45-1-1-1s-1 .45-1 1v2h-2c-.55 0-1 .45-1 1s.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1h-2z",
"person-remove": "M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4s4-1.79 4-4zM2 18v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4s-8 1.34-8 4zm16-8h4c.55 0 1 .45 1 1s-.45 1-1 1h-4c-.55 0-1-.45-1-1s.45-1 1-1z"
}

View File

@@ -56,7 +56,7 @@
<n-text class="add-desc">{{ item.desc }}</n-text>
</div>
<!-- 播放按钮 -->
<n-icon class="play" @click.stop>
<n-icon class="play">
<SvgIcon :icon="type !== 'artist' ? 'play-circle' : 'account-music'" />
</n-icon>
</div>

View File

@@ -2,7 +2,7 @@
<template>
<Transition name="fade" mode="out-in" @after-enter="checkHasPlaying">
<div v-if="data !== 'empty' && data?.length && data[0] !== 'empty'" class="song-list">
<div class="song-list-header">
<div v-if="showTitle" class="song-list-header">
<n-text class="num" depth="3"> # </n-text>
<n-text :class="{ info: true, 'has-cover': data[0].cover && showCover }" depth="3">
歌曲
@@ -113,7 +113,12 @@
</div>
<!-- 歌手 -->
<div v-if="Array.isArray(item.artists)" class="artist">
<n-text v-for="ar in item.artists" :key="ar.id" class="ar">
<n-text
v-for="ar in item.artists"
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
>
{{ ar.name }}
</n-text>
</div>
@@ -230,6 +235,11 @@ const props = defineProps({
type: Boolean,
default: true,
},
// 是否显示表头
showTitle: {
type: Boolean,
default: true,
},
});
// 分页数据

View File

@@ -120,15 +120,25 @@
</div>
</n-popover>
</div>
<span v-if="music.getPlaySongData.alia" class="alia">{{
music.getPlaySongData.alia
}}</span>
<span v-if="music.getPlaySongData.alia" class="alia">
{{ music.getPlaySongData.alia }}
</span>
<div class="artist">
<n-icon depth="3" size="20">
<SvgIcon icon="account-music" />
</n-icon>
<div v-if="Array.isArray(music.getPlaySongData.artists)" class="all-ar">
<span v-for="ar in music.getPlaySongData.artists" :key="ar.id" class="ar">
<span
v-for="ar in music.getPlaySongData.artists"
:key="ar.id"
class="ar"
@click.stop="
() => {
showFullPlayer = false;
router.push(`/artist?id=${ar.id}`);
}
"
>
{{ ar.name }}
</span>
</div>

View File

@@ -109,7 +109,12 @@
music.getPlaySongData?.artists && Array.isArray(music.getPlaySongData.artists)
"
>
<n-text v-for="ar in music.getPlaySongData.artists" :key="ar.id" class="ar">
<n-text
v-for="ar in music.getPlaySongData.artists"
:key="ar.id"
class="ar"
@click.stop="router.push(`/artist?id=${ar.id}`)"
>
{{ ar.name }}
</n-text>
</template>

View File

@@ -102,7 +102,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
artists: v.artists,
desc: v.copywriter,
cover,
coverSize: getCoverUrl(v.picUrl || v.cover, "464y260"),
coverSize: getCoverUrl(v.imgurl16v9 || v.picUrl || v.cover, "464y260"),
duration: v.duration,
playCount: v.playCount,
};

View File

@@ -0,0 +1,92 @@
<template>
<div class="artist-albums">
<Transition name="fade" mode="out-in">
<div v-if="artistAblums !== 'empty'" class="list">
<!-- 列表 -->
<MainCover :data="artistAblums" type="album" />
<!-- 分页 -->
<Pagination
v-if="artistAblums?.length"
:totalCount="totalCount"
:pageNumber="pageNumber"
@pageNumberChange="pageNumberChange"
/>
</div>
<n-empty
v-else
description="当前歌手暂无专辑"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { siteSettings } from "@/stores";
import { getArtistAblums } from "@/api/artist";
import formatData from "@/utils/formatData";
const router = useRouter();
const settings = siteSettings();
// 歌手数据
const artistId = ref(router.currentRoute.value.query.id);
const artistAblums = ref(null);
const totalCount = ref(0);
const pageNumber = ref(Number(router.currentRoute.value.query?.page) || 1);
// 获取歌手全部专辑
const getArtistAblumsData = async (id, limit = settings.loadSize, offset = 0) => {
try {
artistAblums.value = null;
const result = await getArtistAblums(id, limit, offset);
// 数据总数
totalCount.value = result.artist.albumSize;
if (totalCount.value === 0) return (artistAblums.value = "empty");
// 处理数据
artistAblums.value = formatData(result.hotAlbums, "album");
} catch (error) {
console.error("获取歌手专辑失败:", error);
}
};
// 页数变化
const pageNumberChange = (page) => {
router.push({
path: "/artist/albums",
query: {
id: artistId.value,
page: page,
},
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
if (val.name === "ar-albums") {
// 更改参数
artistId.value = val.query.id;
pageNumber.value = Number(val.query?.page) || 1;
// 调用接口
await getArtistAblumsData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
}
},
);
onBeforeMount(async () => {
await getArtistAblumsData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
});
</script>

View File

@@ -1,3 +1,94 @@
<template>
<div class="artist-hot">歌手热门</div>
<div class="artist-hot">
<n-h4 class="title" prefix="bar">
<n-text class="name">热门歌曲</n-text>
<div class="more" @click="router.push(`/artist/songs?id=${artistId}`)">
<n-text depth="3">查看全部</n-text>
<n-icon class="more" depth="3">
<SvgIcon icon="chevron-right" />
</n-icon>
</div>
</n-h4>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<SongList
v-if="artistHotSongs"
:data="artistHotSongs"
:showPagination="false"
:showTitle="false"
/>
<div v-else class="loading">
<n-skeleton :repeat="10" text />
</div>
</Transition>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { getArtistSongs } from "@/api/artist";
import formatData from "@/utils/formatData";
const router = useRouter();
// 歌手数据
const artistId = ref(router.currentRoute.value.query.id);
const artistHotSongs = ref(null);
// 获取歌手热门数据
const getArtistHotData = async (id) => {
try {
artistHotSongs.value = null;
// 获取热门歌曲
const result = await getArtistSongs(id);
artistHotSongs.value = formatData(result.hotSongs, "song");
} catch (error) {
console.error("获取歌手热门数据失败:", error);
}
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
artistId.value = val.query.id;
if (val.name === "ar-hot") {
await getArtistHotData(artistId.value);
}
},
);
onBeforeMount(async () => {
await getArtistHotData(artistId.value);
});
</script>
<style lang="scss" scoped>
.artist-hot {
.title {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
margin-left: 6px;
margin-top: 16px;
padding-left: 16px;
cursor: pointer;
.more {
display: flex;
flex-direction: row;
align-items: center;
margin-left: auto;
font-size: 14px;
}
}
:deep(.n-skeleton) {
&:nth-of-type(1) {
margin-top: 0;
}
height: 70px;
margin-top: 12px;
border-radius: 8px;
}
}
</style>

View File

@@ -1,91 +1,155 @@
<template>
<div class="artist">
<div v-if="artistData && Object.keys(artistData)?.length" class="detail">
<div class="cover">
<!-- 头像 -->
<n-image
:src="artistData.coverSize.m"
:previewed-img-props="{ style: { borderRadius: '8px' } }"
:preview-src="artistData.cover"
class="cover-img"
show-toolbar-tooltip
@load="
(e) => {
e.target.style.opacity = 1;
}
"
>
<template #placeholder>
<div class="cover-loading">
<img class="loading-img" src="/images/pic/avatar.jpg?assest" alt="avatar" />
</div>
</template>
</n-image>
<!-- 头像背板 -->
<n-image :src="artistData.coverSize.s" class="cover-shadow" preview-disabled />
</div>
<div class="data">
<!-- 名称 -->
<n-text class="name">{{ artistData.name || "未知艺术家" }}</n-text>
<!-- 别名 -->
<div class="alias">
<n-text v-if="artistData?.alias?.length" class="alias-text">
{{ artistData.alias[0] }}
</n-text>
<n-text v-if="artistData?.identify" class="identify">{{ artistData.identify }}</n-text>
<div v-if="artistId" class="artist">
<Transition name="fade" mode="out-in">
<div v-if="artistData && Object.keys(artistData)?.length" class="detail">
<div class="cover">
<!-- 头像 -->
<n-image
:src="artistData.coverSize.m"
:previewed-img-props="{ style: { borderRadius: '8px' } }"
:preview-src="artistData.cover"
class="cover-img"
show-toolbar-tooltip
@load="
(e) => {
e.target.style.opacity = 1;
}
"
>
<template #placeholder>
<div class="cover-loading">
<img class="loading-img" src="/images/pic/avatar.jpg?assest" alt="avatar" />
</div>
</template>
</n-image>
<!-- 头像背板 -->
<n-image :src="artistData.coverSize.s" class="cover-shadow" preview-disabled />
</div>
<!-- 数量 -->
<n-space class="num">
<div v-if="artistData.size?.music" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
</n-icon>
<n-text depth="3">{{ artistData.size.music }}</n-text>
<div class="data">
<!-- 名称 -->
<div class="name">
<n-text class="name-text">{{ artistData.name || "未知艺术家" }}</n-text>
<n-text v-if="artistData?.alias?.length" class="name-alias" depth="3">
{{ artistData.alias[0] }}
</n-text>
</div>
<div v-if="artistData.size?.album" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="album" />
</n-icon>
<n-text depth="3">{{ artistData.size.album }}</n-text>
</div>
<div v-if="artistData.size?.mv" class="num-item">
<n-icon depth="3" size="18">
<SvgIcon icon="video" />
</n-icon>
<n-text depth="3">{{ artistData.size.mv }}</n-text>
</div>
</n-space>
<!-- 简介 -->
<n-ellipsis
v-if="artistData?.description"
:tooltip="false"
class="description"
expand-trigger="click"
line-clamp="2"
>
<n-text depth="3">{{ artistData.description }}</n-text>
</n-ellipsis>
<n-text v-else class="description">竟然没有简介</n-text>
<!-- 职业 -->
<n-text v-if="artistData?.identify" class="identify" depth="3">
{{ artistData.identify }}
</n-text>
<!-- 数量 -->
<n-space class="num">
<div
v-if="artistData.size?.music"
class="num-item"
@click="router.push(`/artist/songs?id=${artistId}`)"
>
<n-icon depth="3" size="18">
<SvgIcon icon="music-note" />
</n-icon>
<n-text depth="3">{{ artistData.size.music }}</n-text>
</div>
<div
v-if="artistData.size?.album"
class="num-item"
@click="router.push(`/artist/albums?id=${artistId}`)"
>
<n-icon depth="3" size="18">
<SvgIcon icon="album" />
</n-icon>
<n-text depth="3">{{ artistData.size.album }}</n-text>
</div>
<div
v-if="artistData.size?.mv"
class="num-item"
@click="router.push(`/artist/videos?id=${artistId}`)"
>
<n-icon depth="3" size="18">
<SvgIcon icon="video" />
</n-icon>
<n-text depth="3">{{ artistData.size.mv }}</n-text>
</div>
</n-space>
<!-- 简介 -->
<n-ellipsis
v-if="artistData?.description"
:tooltip="false"
:line-clamp="artistData?.identify ? 2 : 3"
class="description"
expand-trigger="click"
>
<n-text depth="3">{{ artistData.description }}</n-text>
</n-ellipsis>
<n-text v-else class="description">竟然没有简介</n-text>
<!-- 功能区 -->
<n-space class="menu" justify="space-between">
<n-button size="large" round strong secondary @click="likeOrDislike(artistId)">
<template #icon>
<n-icon>
<SvgIcon :icon="isLikeOrDislike(artistId) ? 'person-add' : 'person-remove'" />
</n-icon>
</template>
{{ isLikeOrDislike(artistId) ? "关注歌手" : "取消关注" }}
</n-button>
</n-space>
</div>
</div>
</div>
<div v-else class="detail">
<n-skeleton class="cover" />
<div class="data">
<n-skeleton :repeat="4" text />
</div>
</div>
</Transition>
<!-- 标签页 -->
<n-tabs v-model:value="tabValue" class="tabs" type="segment" @update:value="tabChange">
<n-tab name="ar-hot"> 热门 </n-tab>
<n-tab name="ar-songs"> 单曲 </n-tab>
<n-tab name="ar-albums"> 专辑 </n-tab>
<n-tab name="ar-videos"> 视频 </n-tab>
</n-tabs>
<!-- 路由页面 -->
<router-view v-slot="{ Component }" :mvSize="artistData ? artistData.size?.mv : null">
<keep-alive>
<Transition name="router" mode="out-in">
<component :is="Component" />
</Transition>
</keep-alive>
</router-view>
</div>
<div v-else class="title">
<n-text class="key">参数不完整</n-text>
<n-button class="back" strong secondary @click="router.go(-1)"> 返回上一页 </n-button>
</div>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { getArtistDetail } from "@/api/artist";
import { getArtistDetail, likeArtist } from "@/api/artist";
import { siteData } from "@/stores";
import { isLogin } from "@/utils/auth";
import formatData from "@/utils/formatData";
import debounce from "@/utils/debounce";
const router = useRouter();
const data = siteData();
const { userLikeData } = storeToRefs(data);
// 歌手数据
const artistId = ref(router.currentRoute.value.query.id);
const artistData = ref(null);
// 默认选中
const tabValue = ref(router.currentRoute.value?.name ?? "ar-hot");
// 获取歌手详情
const getArtistDetailData = async () => {
const getArtistDetailData = async (id) => {
try {
const result = await getArtistDetail(artistId.value);
if (!id) return false;
// 清空数据
artistData.value = null;
const result = await getArtistDetail(id);
artistData.value = formatData(result.data.artist, "artist")[0];
// 附加身份
artistData.value.identify = result.data.identify?.imageDesc;
@@ -96,6 +160,57 @@ const getArtistDetailData = async () => {
}
};
// 标签页切换
const tabChange = (val) => {
const routerPath = val.replace(/^ar-/, "");
router.push({
path: `/artist/${routerPath}`,
query: {
id: artistId.value,
},
});
};
// 判断收藏还是取消
const isLikeOrDislike = (id) => {
const artists = userLikeData.value.artists;
if (artists.length) {
return !artists.some((item) => item.id === Number(id));
}
return true;
};
// 关注 / 取消关注歌手
const likeOrDislike = debounce(async (id) => {
try {
if (!isLogin()) return $message.warning("请登录后使用");
const type = isLikeOrDislike(id) ? 1 : 2;
const result = await likeArtist(type, id);
if (result.code === 200) {
$message.success((type === 1 ? "关注" : "取消关注") + "成功");
// 更新用户歌单
await data.setUserLikeArtists();
} else {
$message.error((type === 1 ? "关注" : "取消关注") + "失败,请重试");
}
} catch (error) {
console.error("关注出错:", error);
$message.error("关注操作出现错误");
}
}, 300);
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
tabValue.value = "ar-" + val.path.split("/")[2];
if (val.path.split("/")[1] === "artist" && val.query.id !== artistId.value) {
artistId.value = val.query.id;
await getArtistDetailData(artistId.value);
}
},
);
onBeforeMount(async () => {
await getArtistDetailData(artistId.value);
});
@@ -111,9 +226,9 @@ onBeforeMount(async () => {
.cover {
position: relative;
display: flex;
width: 260px;
height: 260px;
min-width: 260px;
width: 240px;
height: 240px;
min-width: 240px;
margin-right: 20px;
border-radius: 50%;
.cover-img {
@@ -148,14 +263,34 @@ onBeforeMount(async () => {
}
}
.data {
margin-top: 10px;
width: 100%;
.name {
display: flex;
flex-direction: row;
align-items: center;
font-size: 30px;
font-weight: bold;
margin-bottom: 12px;
.name-alias {
&::before {
content: "";
margin-right: 6px;
}
&::after {
content: "";
margin-left: 6px;
}
}
}
.identify {
margin-left: 2px;
margin-top: 2px;
font-size: 18px;
}
.num {
margin-top: 12px;
margin-top: 8px;
cursor: pointer;
.num-item {
display: flex;
flex-direction: row;
@@ -173,6 +308,9 @@ onBeforeMount(async () => {
display: initial;
}
}
.menu {
margin-top: 12px;
}
:deep(.n-skeleton) {
&:first-child {
width: 60%;
@@ -185,5 +323,21 @@ onBeforeMount(async () => {
}
}
}
.tabs {
margin-bottom: 20px;
}
}
.title {
display: flex;
flex-direction: column;
.key {
margin: 10px 0;
font-size: 36px;
font-weight: bold;
margin-right: 8px;
}
.back {
width: 98px;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="artist-songs">
<Transition name="fade" mode="out-in">
<div v-if="artistAllSongs !== 'empty'" class="list">
<!-- 列表 -->
<SongList :data="artistAllSongs" :showPagination="false" />
<!-- 分页 -->
<Pagination
v-if="artistAllSongs?.length"
:totalCount="totalCount"
:pageNumber="pageNumber"
@pageNumberChange="pageNumberChange"
/>
</div>
<n-empty
v-else
description="当前歌手暂无歌曲"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { siteSettings } from "@/stores";
import { getSongDetail } from "@/api/song";
import { getArtistAllSongs } from "@/api/artist";
import formatData from "@/utils/formatData";
const router = useRouter();
const settings = siteSettings();
// 歌手数据
const artistId = ref(router.currentRoute.value.query.id);
const artistAllSongs = ref(null);
const totalCount = ref(0);
const pageNumber = ref(Number(router.currentRoute.value.query?.page) || 1);
// 获取歌手全部歌曲
const getArtistAllSongsData = async (id, limit = settings.loadSize, offset = 0) => {
try {
const result = await getArtistAllSongs(id, limit, offset);
// 数据总数
totalCount.value = result.total;
if (totalCount.value === 0) return (artistAllSongs.value = "empty");
// 处理数据
const ids = result.songs.map((song) => song.id).join(",");
const songsDetail = await getSongDetail(ids);
artistAllSongs.value = formatData(songsDetail.songs, "song");
} catch (error) {
console.error("获取歌手全部歌曲失败:", error);
}
};
// 页数变化
const pageNumberChange = (page) => {
router.push({
path: "/artist/songs",
query: {
id: artistId.value,
page: page,
},
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
if (val.name === "ar-songs") {
// 更改参数
artistId.value = val.query.id;
pageNumber.value = Number(val.query?.page) || 1;
// 调用接口
await getArtistAllSongsData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
}
},
);
onBeforeMount(async () => {
await getArtistAllSongsData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
});
</script>

View File

@@ -0,0 +1,99 @@
<template>
<div class="artist-videos">
<Transition name="fade" mode="out-in">
<div v-if="artistVideos !== 'empty'" class="list">
<!-- 列表 -->
<MainCover :data="artistVideos" columns="1 s:2 m:3 l:4 xl:5" type="mv" />
<!-- 分页 -->
<Pagination
v-if="artistVideos?.length"
:totalCount="totalCount"
:pageNumber="pageNumber"
@pageNumberChange="pageNumberChange"
/>
</div>
<n-empty
v-else
description="当前歌手暂无视频"
class="tip"
style="margin-top: 60px"
size="large"
/>
</Transition>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { siteSettings } from "@/stores";
import { getArtistVideos } from "@/api/artist";
import formatData from "@/utils/formatData";
const router = useRouter();
const settings = siteSettings();
const props = defineProps({
// 视频总数
mvSize: {
type: Number,
default: 0,
},
});
// 歌手数据
const artistId = ref(router.currentRoute.value.query.id);
const artistVideos = ref(null);
const totalCount = ref(0);
const pageNumber = ref(Number(router.currentRoute.value.query?.page) || 1);
// 获取歌手全部视频
const getArtistVideosData = async (id, limit = settings.loadSize, offset = 0) => {
try {
artistVideos.value = null;
const result = await getArtistVideos(id, limit, offset);
// 数据总数
totalCount.value = props.mvSize;
if (totalCount.value === 0) return (artistVideos.value = "empty");
// 处理数据
artistVideos.value = formatData(result.mvs, "mv");
} catch (error) {
console.error("获取歌手视频失败:", error);
}
};
// 页数变化
const pageNumberChange = (page) => {
router.push({
path: "/artist/videos",
query: {
id: artistId.value,
page: page,
},
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
if (val.name === "ar-videos") {
// 更改参数
artistId.value = val.query.id;
pageNumber.value = Number(val.query?.page) || 1;
// 调用接口
await getArtistVideosData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
}
},
);
onBeforeMount(async () => {
await getArtistVideosData(
artistId.value,
settings.loadSize,
(pageNumber.value - 1) * settings.loadSize,
);
});
</script>

View File

@@ -431,11 +431,11 @@ const likeOrDislike = debounce(async (id) => {
const type = isLikeOrDislike(id) ? 1 : 2;
const result = await likePlaylist(type, id);
if (result.code === 200) {
$message.success(type === 1 ? "收藏" : "取消收藏" + "成功");
$message.success((type === 1 ? "收藏" : "取消收藏") + "成功");
// 更新用户歌单
await data.setUserLikePlaylists();
} else {
$message.error(type === 1 ? "收藏" : "取消收藏" + "失败,请重试");
$message.error((type === 1 ? "收藏" : "取消收藏") + "失败,请重试");
}
} catch (error) {
console.error("收藏出错:", error);