Compare commits

...

28 Commits

Author SHA1 Message Date
alger
ba992b7c33 📃 docs: v3.4.0 2025-01-11 19:03:06 +08:00
alger
24d7c839c7 🌈 style: 添加 "animate.css" 2025-01-11 18:51:40 +08:00
alger
a4f3df80c9 📃 docs: v3.4.0 2025-01-11 18:45:42 +08:00
alger
866fec6ee3 feat: 优化收藏逻辑 本地和线上同步 添加批量下载 2025-01-11 18:38:34 +08:00
alger
8f7d6fbb8d feat: 设置页 添加捐赠支持列表 2025-01-11 18:22:14 +08:00
alger
62e26cae7d 🌈 style: 优化代码格式化 2025-01-10 22:49:55 +08:00
alger
ddb814da10 feat: v3.3.0 2025-01-06 22:33:13 +08:00
alger
e266ea8ef8 🐞 fix: 修复类型校验问题 2025-01-06 22:24:37 +08:00
alger
a894954641 🐞 fix: 修复类型校验问题 2025-01-06 22:15:25 +08:00
alger
f640ab9969 feat: v3.3.0 2025-01-06 22:10:20 +08:00
alger
9eb17fd978 feat: 优化登录失效 2025-01-06 22:03:50 +08:00
alger
020aca7384 feat: 添加音质选择 优化灰色歌曲解析 2025-01-06 20:54:42 +08:00
alger
fcc47dc0ff feat: 添加退出登录 2025-01-05 15:58:48 +08:00
alger
17ce268da6 feat: 修复未登录 收藏问题 2025-01-05 15:01:55 +08:00
alger
43c64b1b43 feat: 收藏功能改为接口对接 2025-01-04 16:58:08 +08:00
alger
11ced6b418 feat: 优化更新检查 下载 功能 2025-01-04 16:13:37 +08:00
alger
3d3992154a 🐞 fix: 修复歌词滚动问题 2025-01-04 00:26:30 +08:00
alger
81e7b67c7f 📃 docs: 3.2.0 2025-01-03 23:59:07 +08:00
alger
d7e94a342b feat: 添加代理功能和 realIP配置功能 2025-01-03 23:53:07 +08:00
alger
46f8067577 feat: 关闭应用的提示修改 可存储配置最小化 还是 关闭 2025-01-03 22:24:13 +08:00
alger
1dc7d0ceca 🐞 fix: 修复歌词页面与底栏冲突问题(#26) 修复搜索歌曲列表页面显示错误问题 (#33)
closed #26   #33
2025-01-03 22:03:26 +08:00
alger
ba64631a17 🐞 fix: 修复搜索类型切换 没有重新加载搜索的问题(#25)
closed #25
2025-01-03 21:28:48 +08:00
alger
cdb9524f04 feat: 解决检查更新请求失败问题 2025-01-02 00:45:01 +08:00
alger
5213aa13c5 🌈 style: 修复格式问题 2025-01-02 00:27:31 +08:00
alger
d870d0198f 🌈 style: 修复格式问题 2025-01-02 00:25:54 +08:00
alger
976a9afd2f 📃 docs: v3.1.0 2025-01-02 00:18:41 +08:00
alger
018218a5bf feat: 优化主入口代码 添加歌曲下载功能 2025-01-02 00:14:05 +08:00
alger
38a9d6ed31 feat: 完善网页版 安装应用功能 2025-01-01 22:42:25 +08:00
51 changed files with 2158 additions and 446 deletions

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
out
.gitignore

135
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,135 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-airbnb-base',
'@vue/typescript/recommended',
'plugin:vue/vue3-recommended',
'plugin:vue-scoped-css/base',
'@electron-toolkit',
'@electron-toolkit/eslint-config-ts/eslint-recommended',
'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: {
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'no-nested-ternary': 'off',
'no-console': 'off',
'no-continue': 'off',
'no-restricted-syntax': 'off',
'no-return-assign': 'off',
'no-unused-expressions': 'off',
'no-return-await': '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',
'@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',
'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']
}
],
'@typescript-eslint/explicit-function-return-type': 'off',
// 需要行尾分号
'prettier/prettier': ['error', { endOfLine: 'auto' }]
}
},
{
files: ['*.ts', '*.tsx'],
rules: {
'no-await-in-loop': 'off',
'dot-notation': 'off',
'constructor-super': 'off',
'getter-return': 'off',
'no-const-assign': 'off',
'no-dupe-args': 'off',
'no-dupe-class-members': 'off',
'no-dupe-keys': 'off',
'no-func-assign': 'off',
'no-import-assign': 'off',
'no-new-symbol': 'off',
'no-obj-calls': 'off',
'no-redeclare': 'off',
'no-setter-return': 'off',
'no-this-before-super': 'off',
'no-undef': 'off',
'no-unreachable': 'off',
'no-unsafe-negation': 'off',
'no-var': 'error',
'prefer-const': 'error',
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'valid-typeof': 'off',
'consistent-return': 'off',
'no-promise-executor-return': 'off',
'prefer-promise-reject-errors': 'off',
'@typescript-eslint/explicit-function-return-type': 'off'
}
}
]
};

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

5
.prettierrc.yaml Normal file
View File

@@ -0,0 +1,5 @@
singleQuote: true
semi: true
printWidth: 100
trailingComma: none
endOfLine: auto

View File

@@ -1,21 +1,8 @@
# 更新日志
## [v3.0.0] - 2024-03-21
## [v3.4.0] - 2025-01-11
### ✨ 新功能
- 新增自动更新检测功能
- 新增 GitHub Actions 自动构建和发布
- 新增主题色切换功能,支持日间/夜间模式 (#19, #21)
- 新增随机播放功能 (#20)
- 优化主题效果和图片清晰度
### 🏗️ 架构重构
- 重构整个项目架构
- 优化打包配置
- 修改后台服务为本地运行
- 优化项目结构
### 🐞 问题修复
- 修复 web 移动端页面空白问题 (#24)
- 修复无用导入问题
- 优化错误处理
- 添加捐赠支持列表
- 添加收藏列表 批量下载功能
- 优化搜藏列表 可本地和线上收藏合并 如果收藏失败 会自动收藏到本地

View File

@@ -1,6 +1,6 @@
{
"name": "AlgerMusicPlayer",
"version": "3.0.0",
"version": "3.4.0",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js",
@@ -26,11 +26,9 @@
"@unblockneteasemusic/server": "^0.27.8-patch.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"netease-cloud-music-api-alger": "^4.25.0"
},
"devDependencies": {
"marked": "^15.0.4",
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
@@ -63,6 +61,7 @@
"eslint-plugin-vue-scoped-css": "^2.7.2",
"howler": "^2.2.4",
"lodash": "^4.17.21",
"marked": "^15.0.4",
"naive-ui": "^2.39.0",
"postcss": "^8.4.49",
"prettier": "^3.3.2",
@@ -79,16 +78,19 @@
"vue": "^3.4.30",
"vue-router": "^4.4.3",
"vue-tsc": "^2.0.22",
"vuex": "^4.1.0"
"vuex": "^4.1.0",
"animate.css": "^4.1.1"
},
"build": {
"appId": "com.alger.music",
"productName": "AlgerMusicPlayer",
"publish": [{
"provider": "github",
"owner": "algerkong",
"repo": "AlgerMusicPlayer"
}],
"publish": [
{
"provider": "github",
"owner": "algerkong",
"repo": "AlgerMusicPlayer"
}
],
"mac": {
"icon": "resources/icon.icns",
"target": [
@@ -117,7 +119,10 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
"arch": [
"x64",
"ia32"
]
}
],
"artifactName": "${productName}-${version}-win-${arch}.${ext}",
@@ -128,11 +133,15 @@
"target": [
{
"target": "AppImage",
"arch": ["x64"]
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": ["x64"]
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",

View File

@@ -1,121 +1,68 @@
import { electronApp, is, optimizer } from '@electron-toolkit/utils';
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, shell, Tray } from 'electron';
import Store from 'electron-store';
import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, globalShortcut, ipcMain, nativeImage } from 'electron';
import { join } from 'path';
import set from './set.json';
import { loadLyricWindow } from './lyric';
import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager';
import { initializeTray } from './modules/tray';
import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
const icon = nativeImage.createFromPath(
process.platform === 'darwin'
? join(iconPath, 'icon.icns')
: process.platform === 'win32'
? join(iconPath, 'favicon.ico')
: join(iconPath, 'icon.png')
? join(iconPath, 'favicon.ico')
: join(iconPath, 'icon.png')
);
import { loadLyricWindow } from './lyric';
import { startMusicApi } from './server';
let mainWindow: Electron.BrowserWindow;
let mainWindow: BrowserWindow;
function createWindow(): void {
// 初始化应用
function initialize() {
// 初始化各个模块
initializeConfig();
initializeFileManager();
// 创建主窗口
mainWindow = createMainWindow(icon);
// 初始化窗口管理
initializeWindowManager();
// 初始化托盘
initializeTray(iconPath, mainWindow);
// 启动音乐API
startMusicApi();
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
height: 780,
show: false,
frame: false,
autoHideMenuBar: true,
icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true
}
});
mainWindow.setMinimumSize(1200, 780);
mainWindow.on('ready-to-show', () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
// 创建托盘图标
const trayIcon = nativeImage.createFromPath(join(iconPath, 'icon_16x16.png')).resize({ width: 16, height: 16 });
const tray = new Tray(trayIcon);
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
mainWindow.show();
},
},
{
label: '退出',
click: () => {
mainWindow.destroy();
app.quit();
},
},
]);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
// 加载歌词窗口
loadLyricWindow(ipcMain, mainWindow);
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
// 应用程序准备就绪时的处理
app.whenReady().then(() => {
// Set app user model id for windows
// 设置应用ID
electronApp.setAppUserModelId('com.alger.music');
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
// 监听窗口创建事件
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC test
ipcMain.on('ping', () => console.log('pong'));
createWindow();
// 初始化应用
initialize();
// macOS 激活应用时的处理
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow();
if (mainWindow === null) initialize();
});
});
// 应用程序准备就绪后的快捷键设置
app.on('ready', () => {
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
if (mainWindow.isVisible()) {
@@ -126,84 +73,20 @@ app.on('ready', () => {
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
// 所有窗口关闭时的处理
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.minimize();
}
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
}
});
ipcMain.on('close-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.destroy();
app.quit();
}
});
ipcMain.on('drag-start', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.webContents.beginFrameSubscription((frameBuffer) => {
event.reply('frame-buffer', frameBuffer);
});
}
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.hide();
}
});
// 重启
// 重启应用
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
const store = new Store({
name: 'config', // 配置文件名
defaults: {
set: set
}
});
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {
store.set(key, value);
});
ipcMain.on('get-store-value', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
});
// 添加 IPC 处理程序
// 获取系统架构信息
ipcMain.on('get-arch', (event) => {
event.returnValue = process.arch;
});
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -0,0 +1,43 @@
import { app, ipcMain } from 'electron';
import Store from 'electron-store';
import set from '../set.json';
interface StoreType {
set: {
isProxy: boolean;
noAnimate: boolean;
animationSpeed: number;
author: string;
authorUrl: string;
musicApiPort: number;
};
}
let store: Store<StoreType>;
/**
* 初始化配置管理
*/
export function initializeConfig() {
store = new Store<StoreType>({
name: 'config',
defaults: {
set
}
});
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {
store.set(key, value);
});
ipcMain.on('get-store-value', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
});
return store;
}

View File

@@ -0,0 +1,72 @@
import axios from 'axios';
import { app, dialog, ipcMain, shell } from 'electron';
import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
/**
* 初始化文件管理相关的IPC监听
*/
export function initializeFileManager() {
// 通用的选择目录处理
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: '选择目录'
});
return result;
});
// 通用的打开目录处理
ipcMain.on('open-directory', (_, path) => {
shell.openPath(path);
});
// 下载音乐处理
ipcMain.on('download-music', downloadMusic);
}
/**
* 下载音乐功能
*/
async function downloadMusic(
event: Electron.IpcMainEvent,
{ url, filename }: { url: string; filename: string }
) {
try {
const store = new Store();
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
// 直接使用配置的下载路径
const filePath = path.join(downloadPath, `${filename}.mp3`);
// 检查文件是否已存在,如果存在则添加序号
let finalFilePath = filePath;
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
}
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
});
const writer = fs.createWriteStream(finalFilePath);
response.data.pipe(writer);
writer.on('finish', () => {
event.reply('music-download-complete', { success: true, path: finalFilePath });
});
writer.on('error', (err) => {
event.reply('music-download-complete', { success: false, error: err.message });
});
} catch (error: any) {
event.reply('music-download-complete', { success: false, error: error.message });
}
}

45
src/main/modules/tray.ts Normal file
View File

@@ -0,0 +1,45 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron';
import { join } from 'path';
let tray: Tray | null = null;
/**
* 初始化系统托盘
*/
export function initializeTray(iconPath: string, mainWindow: BrowserWindow) {
const trayIcon = nativeImage
.createFromPath(join(iconPath, 'icon_16x16.png'))
.resize({ width: 16, height: 16 });
tray = new Tray(trayIcon);
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
mainWindow.show();
}
},
{
label: '退出',
click: () => {
mainWindow.destroy();
app.quit();
}
}
]);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
return tray;
}

119
src/main/modules/window.ts Normal file
View File

@@ -0,0 +1,119 @@
import { is } from '@electron-toolkit/utils';
import { app, BrowserWindow, ipcMain, session, shell } from 'electron';
import Store from 'electron-store';
import { join } from 'path';
const store = new Store();
/**
* 初始化代理设置
*/
function initializeProxy() {
const defaultConfig = {
enable: false,
protocol: 'http',
host: '127.0.0.1',
port: 7890
};
const proxyConfig = store.get('set.proxyConfig', defaultConfig) as {
enable: boolean;
protocol: string;
host: string;
port: number;
};
if (proxyConfig?.enable) {
const proxyRules = `${proxyConfig.protocol}://${proxyConfig.host}:${proxyConfig.port}`;
session.defaultSession.setProxy({ proxyRules });
} else {
session.defaultSession.setProxy({ proxyRules: '' });
}
}
/**
* 初始化窗口管理相关的IPC监听
*/
export function initializeWindowManager() {
// 初始化代理设置
initializeProxy();
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.minimize();
}
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
}
});
ipcMain.on('close-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.destroy();
app.quit();
}
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.hide();
}
});
// 监听代理设置变化
store.onDidChange('set.proxyConfig', () => {
initializeProxy();
});
}
/**
* 创建主窗口
*/
export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
const mainWindow = new BrowserWindow({
width: 1200,
height: 780,
show: false,
frame: false,
autoHideMenuBar: true,
icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true
}
});
mainWindow.setMinimumSize(1200, 780);
mainWindow.on('ready-to-show', () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
return mainWindow;
}

View File

@@ -1,6 +1,7 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs';
import server from 'netease-cloud-music-api-alger/server';
import os from 'os';
import path from 'path';
@@ -12,13 +13,10 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
}
// 处理解锁音乐请求
ipcMain.handle('unblock-music', async (_, id) => {
return unblockMusic(id);
ipcMain.handle('unblock-music', async (_, id, data) => {
return unblockMusic(id, data);
});
import server from 'netease-cloud-music-api-alger/server';
async function startMusicApi(): Promise<void> {
console.log('MUSIC API STARTED');

View File

@@ -1,8 +1,18 @@
{
"isProxy": false,
"proxyConfig": {
"enable": false,
"protocol": "http",
"host": "127.0.0.1",
"port": 7890
},
"enableRealIP": false,
"realIP": "",
"noAnimate": false,
"animationSpeed": 1,
"author": "Alger",
"authorUrl": "https://github.com/algerkong",
"musicApiPort": 30488
"musicApiPort": 30488,
"closeAction": "ask",
"musicQuality": "higher"
}

View File

@@ -1,8 +1,8 @@
import match from '@unblockneteasemusic/server';
const unblockMusic = async (id: any): Promise<any> => {
const unblockMusic = async (id: any, songData: any): Promise<any> => {
return new Promise((resolve, reject) => {
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'])
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'], songData)
.then((data) => {
resolve({
data: {

View File

@@ -12,7 +12,8 @@ declare global {
dragStart: (data: string) => void;
miniTray: () => void;
restart: () => void;
unblockMusic: (id: number) => Promise<any>;
unblockMusic: (id: number, data: any) => Promise<any>;
};
$message: any;
}
}

View File

@@ -13,10 +13,10 @@
<script setup lang="ts">
import { darkTheme, lightTheme } from 'naive-ui';
import { onMounted } from 'vue';
import { isElectron } from '@/utils';
import homeRouter from '@/router/home';
import store from '@/store';
import { isElectron } from '@/utils';
import { isMobile } from './utils';
@@ -32,10 +32,6 @@ onMounted(() => {
'setMenus',
homeRouter.filter((item) => item.meta.isMobile)
);
console.log(
'qqq ',
homeRouter.filter((item) => item.meta.isMobile)
);
}
});
</script>

View File

@@ -1,11 +1,33 @@
import store from '@/store';
import { ILyric } from '@/type/lyric';
import { IPlayMusicUrl } from '@/type/music';
import { isElectron } from '@/utils';
import request from '@/utils/request';
import requestMusic from '@/utils/request_music';
// 获取音乐音质详情
export const getMusicQualityDetail = (id: number) => {
return request.get('/song/music/detail', { params: { id } });
};
// 根据音乐Id获取音乐播放URl
export const getMusicUrl = (id: number) => {
return request.get<IPlayMusicUrl>('/song/url', { params: { id } });
export const getMusicUrl = async (id: number) => {
const res = await request.get('/song/download/url/v1', {
params: {
id,
level: store.state.setData.musicQuality || 'higher'
}
});
if (res.data.data.url) {
return { data: { data: [{ url: res.data.data.url }] } };
}
return await request.get('/song/url/v1', {
params: {
id,
level: store.state.setData.musicQuality || 'higher'
}
});
};
// 获取歌曲详情
@@ -18,9 +40,19 @@ export const getMusicLrc = (id: number) => {
return request.get<ILyric>('/lyric', { params: { id } });
};
export const getParsingMusicUrl = (id: number) => {
export const getParsingMusicUrl = (id: number, data: any) => {
if (isElectron) {
return window.api.unblockMusic(id);
return window.api.unblockMusic(id, data);
}
return requestMusic.get<any>('/music', { params: { id } });
};
// 收藏歌曲
export const likeSong = (id: number, like: boolean = true) => {
return request.get('/like', { params: { id, like } });
};
// 获取用户喜欢的音乐列表
export const getLikedList = () => {
return request.get('/likelist');
};

View File

@@ -17,6 +17,8 @@ declare module 'vue' {
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
@@ -25,10 +27,12 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -50,6 +50,9 @@
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
@@ -59,11 +62,11 @@ const copyQQ = () => {
defineProps({
alipayQR: {
type: String,
required: true
default: alipay
},
wechatQR: {
type: String,
required: true
default: wechat
}
});
</script>

View File

@@ -313,4 +313,13 @@ watch(
.double-item {
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
.mobile {
.music-info {
@apply hidden;
}
.music-list-content {
@apply pb-[100px];
}
}
</style>

View File

@@ -38,11 +38,6 @@
:step="0.1"
@update:value="handleProgressChange"
>
<template #rail>
<div class="progress-rail">
<div class="progress-buffer" :style="{ width: `${bufferedProgress}%` }"></div>
</div>
</template>
</n-slider>
</div>
@@ -643,10 +638,44 @@ const isMobile = computed(() => store.state.isMobile);
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: #10b981;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-dark: theme('colors.gray.700');
--n-fill-color: theme('colors.green.500');
--n-handle-size: 12px;
--n-handle-color: #10b981;
--n-handle-color: theme('colors.green.500');
&.n-slider--vertical {
height: 100%;
.n-slider-rail {
width: 4px;
}
&:hover {
.n-slider-rail {
width: 6px;
}
.n-slider-handle {
width: 14px;
height: 14px;
}
}
}
.n-slider-rail {
@apply overflow-hidden transition-all duration-200;
@apply bg-gray-500 dark:bg-dark-300 bg-opacity-10 !important;
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0;
}
&:hover .n-slider-handle {
opacity: 1;
}
}
}

View File

@@ -20,7 +20,7 @@
</div>
</template>
</div>
<MusicList
<music-list
v-model:show="showMusic"
:name="albumName"
:song-list="songList"
@@ -36,9 +36,9 @@ import { onMounted, ref } from 'vue';
import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const albumData = ref<IAlbumNew>();
const loadAlbumList = async () => {

View File

@@ -53,7 +53,6 @@
</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>
@@ -75,11 +74,11 @@ import { onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getDayRecommend, getHotSinger } from '@/api/home';
import MusicList from '@/components/MusicList.vue';
import router from '@/router';
import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const store = useStore();

View File

@@ -0,0 +1,354 @@
<template>
<div class="donation-container">
<div class="donation-grid">
<div
v-for="(donor, index) in sortedDonors"
:key="donor.id"
class="donation-card animate__animated"
:class="getAnimationClass(index)"
:style="{ animationDelay: `${index * 0.1}s` }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="card-content">
<div class="donor-avatar">
<n-avatar
:src="donor.avatar"
:fallback-src="defaultAvatar"
round
size="large"
class="animate__animated animate__pulse animate__infinite avatar-img"
/>
<div class="donor-badge" :class="getBadgeClass(donor.badge)">
{{ donor.badge }}
</div>
</div>
<div class="donor-info">
<div class="donor-name">{{ donor.name }}</div>
<div class="donation-meta">
<n-tag
:type="getAmountTagType(donor.amount)"
size="small"
class="donation-amount animate__animated"
round
bordered
>
{{ donor.amount }}
</n-tag>
<span class="donation-date">{{ donor.date }}</span>
</div>
<div v-if="donor.message" class="donation-message">
<i class="ri-double-quotes-l quote-icon"></i>
{{ donor.message }}
<i class="ri-double-quotes-r quote-icon"></i>
</div>
</div>
</div>
<div class="card-sparkles"></div>
</div>
</div>
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
<div class="flex justify-between">
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipay"
alt="支付宝收款码"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechat"
alt="微信收款码"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import 'animate.css';
import axios from 'axios';
import { computed, ref } from 'vue';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
// 默认头像
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
// 捐赠者数据
interface Donor {
id: number;
name: string;
amount: number;
date: string;
message?: string;
avatar?: string;
badge: string;
badgeColor: string;
}
const donors = ref<Donor[]>([
{
id: 6,
name: '*桤',
amount: 5,
date: '2025-01-01',
badge: '开源赞助',
badgeColor: '#FF69B4'
},
{
id: 5,
name: '*木',
amount: 1,
date: '2024-12-26',
badge: '开源赞助',
badgeColor: '#FF69B4'
},
{
id: 4,
name: '**兴',
amount: 5,
date: '2024-12-25',
badge: '开源赞助',
badgeColor: '#FF69B4'
},
{
id: 3,
name: 's*r',
amount: 1.68,
date: '2024-12-25',
badge: '开源赞助',
badgeColor: '#FF69B4'
},
{
id: 2,
name: 'G*Y',
amount: 1.99,
date: '2024-12-18',
badge: '开源赞助',
badgeColor: '#FF69B4'
},
{
id: 1,
name: '*辉',
amount: 1,
date: '2024-12-15',
badge: '开源赞助',
badgeColor: '#FF69B4'
}
]);
onMounted(async () => {
try {
const response = await axios.get(
'https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/data/main/donors.json'
);
donors.value = response.data.map((donor: Donor, index: number) => ({
...donor,
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${index}`
}));
} catch (error) {
console.error('Failed to fetch donors:', error);
}
});
// 动画类名列表
const animationClasses = [
'animate__fadeInUp',
'animate__fadeInLeft',
'animate__fadeInRight',
'animate__zoomIn'
];
// 获取随机动画类名
const getAnimationClass = (index: number) => {
return animationClasses[index % animationClasses.length];
};
// 根据金额获取标签类型
const getAmountTagType = (amount: number): 'success' | 'warning' | 'error' | 'info' => {
if (amount >= 5) return 'warning';
if (amount >= 2) return 'success';
return 'info';
};
// 获取徽章样式类名
const getBadgeClass = (badge: string): string => {
if (badge.includes('金牌')) return 'badge-gold';
if (badge.includes('银牌')) return 'badge-silver';
return 'badge-bronze';
};
// 鼠标悬停效果
const handleMouseEnter = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement;
card.style.transform = 'translateY(-5px) scale(1.02)';
card.style.boxShadow = '0 8px 30px rgba(0, 0, 0, 0.12)';
// 添加金额标签动画
const amountTag = card.querySelector('.donation-amount');
if (amountTag) {
amountTag.classList.add('animate__tada');
}
};
const handleMouseLeave = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement;
card.style.transform = 'translateY(0) scale(1)';
card.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';
// 移除金额标签动画
const amountTag = card.querySelector('.donation-amount');
if (amountTag) {
amountTag.classList.remove('animate__tada');
}
};
// 按金额排序的捐赠列表
const sortedDonors = computed(() => {
return [...donors.value].sort((a, b) => {
// 首先按金额从大到小排序
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) return amountDiff;
// 金额相同时,按日期从新到旧排序
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
});
</script>
<style lang="scss" scoped>
.donation-container {
@apply w-full overflow-hidden;
}
.donation-grid {
@apply grid gap-3 px-2 py-3;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.donation-card {
@apply relative overflow-hidden rounded-lg p-3;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.08));
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation-duration: 0.8s;
&:hover {
.card-sparkles {
opacity: 0.4;
}
.donor-avatar {
transform: scale(1.05);
}
}
}
.card-content {
@apply relative z-10 flex items-start gap-3;
}
.donor-avatar {
@apply relative flex-shrink-0;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
.avatar-img {
@apply border border-white/20 dark:border-gray-800/50;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
width: 40px !important;
height: 36px !important;
}
}
.donor-badge {
@apply absolute -bottom-0.5 -right-0.5 px-1 py-0.5 text-xs font-medium text-white/90 rounded-full;
font-size: 0.5rem;
transform: scale(0.8);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
line-height: 1;
background: linear-gradient(45deg, #ff69b4, #ff1493);
opacity: 0.85;
}
.donor-info {
@apply flex-1 min-w-0;
}
.donor-name {
@apply text-sm font-medium mb-0.5 truncate;
color: var(--color-text);
}
.donation-meta {
@apply flex items-center gap-2 mb-1;
}
.donation-amount {
@apply text-xs font-medium;
}
.donation-date {
@apply text-xs text-gray-400/80 dark:text-gray-500/80;
}
.donation-message {
@apply text-sm text-gray-600 dark:text-gray-300 leading-relaxed;
@apply relative pl-4 pr-4;
font-style: italic;
.quote-icon {
@apply text-gray-400 dark:text-gray-500 absolute;
font-size: 0.8rem;
&:first-child {
left: 0;
top: 0;
}
&:last-child {
right: 0;
bottom: 0;
}
}
}
.card-sparkles {
@apply absolute inset-0 pointer-events-none opacity-0;
background-image: radial-gradient(
1.5px 1.5px at 20px 30px,
rgba(255, 255, 255, 0.95),
rgba(0, 0, 0, 0)
),
radial-gradient(1.5px 1.5px at 40px 70px, rgba(255, 255, 255, 0.95), rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 50px 160px, rgba(255, 255, 255, 0.95), rgba(0, 0, 0, 0)),
radial-gradient(1.5px 1.5px at 90px 40px, rgba(255, 255, 255, 0.95), rgba(0, 0, 0, 0)),
radial-gradient(2px 2px at 130px 80px, rgba(255, 255, 255, 0.95), rgba(0, 0, 0, 0));
background-size: 200% 200%;
animation: sparkle 5s ease infinite;
transition: opacity 0.5s ease;
}
@keyframes sparkle {
0%,
100% {
background-position: 0% 0%;
opacity: 0.4;
}
50% {
background-position: 100% 100%;
opacity: 0.8;
}
}
</style>

View File

@@ -41,11 +41,13 @@
import { onMounted, ref } from 'vue';
import { isElectron, isMobile } from '@/utils';
import { getLatestReleaseInfo } from '@/utils/update';
import config from '../../../../package.json';
const showModal = ref(false);
const noPrompt = ref(false);
const releaseInfo = ref<any>(null);
const closeModal = () => {
showModal.value = false;
@@ -54,7 +56,7 @@ const closeModal = () => {
}
};
onMounted(() => {
onMounted(async () => {
// 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) {
return;
@@ -65,33 +67,56 @@ onMounted(() => {
if (isDismissed) {
return;
}
// 获取最新版本信息
releaseInfo.value = await getLatestReleaseInfo();
showModal.value = true;
});
const handleInstall = async (): Promise<void> => {
const assets = releaseInfo.value?.assets || [];
const { userAgent } = navigator;
console.log('userAgent', userAgent);
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
const isMac = userAgent.toLowerCase().includes('mac');
const isWindows = userAgent.toLowerCase().includes('win');
const isLinux = userAgent.toLowerCase().includes('linux');
const isX64 =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = config.version as string;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
let downloadUrl = '';
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
// 根据平台和架构选择对应的安装包
if (isMac) {
// macOS
const macAsset = assets.find((asset) => asset.name.includes('mac'));
downloadUrl = macAsset?.browser_download_url || '';
} else if (isWindows) {
// Windows
let winAsset = assets.find(
(asset) =>
asset.name.includes('win') &&
(isX64 ? asset.name.includes('x64') : asset.name.includes('ia32'))
);
if (!winAsset) {
winAsset = assets.find((asset) => asset.name.includes('win.exe'));
}
downloadUrl = winAsset?.browser_download_url || '';
} else if (isLinux) {
// Linux
const linuxAsset = assets.find(
(asset) =>
(asset.name.endsWith('.AppImage') || asset.name.endsWith('.deb')) &&
asset.name.includes('x64')
);
downloadUrl = linuxAsset?.browser_download_url || '';
}
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
if (downloadUrl) {
window.open(`https://ghproxy.cn/${downloadUrl}`, '_blank');
} else {
// 如果没有找到对应的安装包,跳转到 release 页面
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
}
closeModal();
};
</script>

View File

@@ -15,7 +15,7 @@
<p class="search-item-artist">{{ item.desc }}</p>
</div>
<MusicList
<music-list
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
@@ -40,6 +40,7 @@ import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
import MusicList from '../MusicList.vue';
const props = defineProps<{

View File

@@ -1,5 +1,12 @@
<template>
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
<div
class="song-item"
:class="{ 'song-mini': mini, 'song-list': list }"
@contextmenu.prevent="handleContextMenu"
>
<div v-if="selectable" class="song-item-select" @click.stop="toggleSelect">
<n-checkbox :checked="selected" />
</div>
<n-image
v-if="item.picUrl"
ref="songImg"
@@ -57,16 +64,30 @@
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
<n-dropdown
v-if="isElectron"
:show="showDropdown"
:options="dropdownOptions"
:x="dropdownX"
:y="dropdownY"
placement="bottom-start"
@clickoutside="showDropdown = false"
@select="handleSelect"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { cloneDeep } from 'lodash';
import type { MenuOption } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, h, ref, useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import { getSongUrl } from '@/hooks/MusicListHook';
import { audioService } from '@/services/audioService';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const props = withDefaults(
@@ -75,31 +96,109 @@ const props = withDefaults(
mini?: boolean;
list?: boolean;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true
favorite: true,
selectable: false,
selected: false
}
);
const store = useStore();
const message = useMessage();
const play = computed(() => store.state.play as boolean);
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;
});
const emits = defineEmits(['play']);
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isDownloading = ref(false);
const dropdownOptions = computed<MenuOption[]>(() => [
{
label: isDownloading.value ? '下载中...' : `下载 ${props.item.name}`,
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' }),
disabled: isDownloading.value
}
]);
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
const handleSelect = (key: string | number) => {
showDropdown.value = false;
if (key === 'download') {
downloadMusic();
}
};
// 下载音乐
const downloadMusic = async () => {
if (isDownloading.value) {
message.warning('正在下载中,请稍候...');
return;
}
try {
isDownloading.value = true;
const loadingMessage = message.loading('正在下载中...', { duration: 0 });
const url = await getSongUrl(props.item.id, cloneDeep(props.item));
if (!url) {
loadingMessage.destroy();
message.error('获取音乐下载地址失败');
isDownloading.value = false;
return;
}
// 先移除可能存在的旧监听器
window.electron.ipcRenderer.removeAllListeners('music-download-complete');
// 发送下载请求
window.electron.ipcRenderer.send('download-music', {
url,
filename: `${props.item.name} - ${(props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(',')}`
});
// 添加新的一次性监听器
window.electron.ipcRenderer.once('music-download-complete', (_, result) => {
isDownloading.value = false;
loadingMessage.destroy();
if (result.success) {
message.success('下载成功');
} else if (result.canceled) {
// 用户取消了保存
message.info('已取消下载');
} else {
message.error(`下载失败: ${result.error}`);
}
});
} catch (error) {
isDownloading.value = false;
message.destroyAll();
message.error('下载失败');
}
};
const emits = defineEmits(['play', 'select']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
@@ -144,6 +243,11 @@ const toggleFavorite = async (e: Event) => {
store.commit('addToFavorite', props.item.id);
}
};
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
};
</script>
<style lang="scss" scoped>
@@ -207,6 +311,18 @@ const toggleFavorite = async (e: Event) => {
@apply bg-green-500 border-green-500 text-white;
}
}
&-download {
@apply mr-2 cursor-pointer;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
}
&-select {
@apply mr-3 cursor-pointer;
}
}

View File

@@ -44,9 +44,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { marked } from 'marked';
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
// 配置 marked
@@ -64,6 +67,28 @@ const updateInfo = ref<UpdateResult>({
releaseInfo: null
});
const store = useStore();
// 添加计算属性
const showUpdateModalState = computed({
get: () => store.state.showUpdateModal,
set: (val) => store.commit('setShowUpdateModal', val)
});
// 替换原来的 watch
watch(showUpdateModalState, (newVal) => {
if (newVal) {
showModal.value = true;
}
});
watch(
() => showModal.value,
(newVal) => {
showUpdateModalState.value = newVal;
}
);
// 解析 Markdown
const parsedReleaseNotes = computed(() => {
if (!updateInfo.value.releaseInfo?.body) return '';
@@ -98,43 +123,58 @@ const checkForUpdates = async () => {
const handleUpdate = async () => {
const assets = updateInfo.value.releaseInfo?.assets || [];
const platform = window.electron.process.platform;
const { platform } = window.electron.process;
const arch = window.electron.ipcRenderer.sendSync('get-arch');
console.log(arch);
console.log(platform);
console.log('arch', arch);
console.log('platform', platform);
const version = updateInfo.value.latestVersion;
const downUrls = {
win32: {
all: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-win.exe`,
x64: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-win-x64.exe`,
ia32: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-win-ia32.exe`
},
darwin: {
all: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}AlgerMusicPlayer-${version}-mac-universal.dmg`
},
linux: {
AppImage: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-linux-x64.AppImage`,
deb: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-linux-x64.deb`
}
};
let downloadUrl = '';
// 根据平台和架构选择对应的安装包
if (platform === 'darwin') {
// macOS
const macAsset = assets.find(asset =>
asset.name.includes('mac')
);
downloadUrl = macAsset?.browser_download_url || '';
const macAsset = assets.find((asset) => asset.name.includes('mac'));
downloadUrl = macAsset?.browser_download_url || downUrls.darwin.all || '';
} else if (platform === 'win32') {
// Windows
const winAsset = assets.find(asset =>
asset.name.includes('win') &&
(arch === 'x64' ? asset.name.includes('x64') : asset.name.includes('ia32'))
const winAsset = assets.find(
(asset) =>
asset.name.includes('win') &&
(arch === 'x64' ? asset.name.includes('x64') : asset.name.includes('ia32'))
);
downloadUrl = winAsset?.browser_download_url || '';
downloadUrl =
winAsset?.browser_download_url || downUrls.win32[arch] || downUrls.win32.all || '';
} else if (platform === 'linux') {
// Linux
const linuxAsset = assets.find(asset =>
(asset.name.endsWith('.AppImage') || asset.name.endsWith('.deb')) &&
asset.name.includes('x64')
const linuxAsset = assets.find(
(asset) =>
(asset.name.endsWith('.AppImage') || asset.name.endsWith('.deb')) &&
asset.name.includes('x64')
);
downloadUrl = linuxAsset?.browser_download_url || '';
downloadUrl = linuxAsset?.browser_download_url || downUrls.linux[arch] || '';
}
if (downloadUrl) {
window.open(downloadUrl, '_blank');
window.open(`https://www.ghproxy.cn/${downloadUrl}`, '_blank');
} else {
// 如果没有找到对应的安装包,跳转到 release 页面
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
}
closeModal();
};
onMounted(() => {
@@ -174,7 +214,7 @@ onMounted(() => {
}
.update-body {
@apply p-4 pt-2 text-gray-600 dark:text-gray-300 rounded-lg overflow-hidden;
:deep(h1) {
@apply text-xl font-bold mb-3;
}
@@ -216,7 +256,8 @@ onMounted(() => {
}
:deep(table) {
@apply w-full mb-3;
th, td {
th,
td {
@apply px-3 py-2 border border-gray-200 dark:border-gray-600;
}
th {
@@ -245,4 +286,4 @@ onMounted(() => {
}
}
}
</style>
</style>

View File

@@ -3,8 +3,8 @@ import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import store from '@/store';
import type { ILyricText, SongResult } from '@/type/music';
import { getTextColors } from '@/utils/linearColor';
import { isElectron } from '@/utils';
import { getTextColors } from '@/utils/linearColor';
const windowData = window as any;
@@ -18,7 +18,7 @@ export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
export const textColors = ref(getTextColors());
export const textColors = ref<any>(getTextColors());
document.onkeyup = (e) => {
// 检查事件目标是否是输入框元素

View File

@@ -1,34 +1,34 @@
import { Howl } from 'howler';
import { cloneDeep } from 'lodash';
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { audioService } from '@/services/audioService';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl, getMusicProxyUrl } from '@/utils';
import { getImgUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
const musicHistory = useMusicHistory();
// 获取歌曲url
const getSongUrl = async (id: number) => {
export const getSongUrl = async (id: number, songData: any) => {
const { data } = await getMusicUrl(id);
let url = '';
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id);
console.log('res', res);
const res = await getParsingMusicUrl(id, songData);
url = res.data.data.url;
}
} catch (error) {
console.error('error', error);
}
url = url || data.data[0].url;
return getMusicProxyUrl(url);
return url;
};
const getSongDetail = async (playMusic: SongResult) => {
playMusic.playLoading = true;
const playMusicUrl = await getSongUrl(playMusic.id);
const playMusicUrl = await getSongUrl(playMusic.id, cloneDeep(playMusic));
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic

View File

@@ -33,7 +33,6 @@
<!-- 样式表 -->
<link rel="stylesheet" href="./assets/icon/iconfont.css" />
<link rel="stylesheet" href="./assets/css/animate.css" />
<link rel="stylesheet" href="./assets/css/base.css" />
<script defer src="https://cn.vercount.one/js"></script>

View File

@@ -21,14 +21,14 @@
</router-view>
</div>
<play-bottom height="5rem" />
<app-menu v-if="isMobile" class="menu" :menus="menus" />
<app-menu v-if="isMobile && !store.state.musicFull" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" />
<play-bar v-if="isPlay" :style="isMobile && store.state.musicFull ? 'bottom: 0;' : ''" />
</div>
<install-app-modal></install-app-modal>
<update-modal />
<install-app-modal v-if="!isElectron"></install-app-modal>
<update-modal v-if="isElectron" />
</div>
</template>
@@ -39,9 +39,9 @@ import { useStore } from 'vuex';
import InstallAppModal from '@/components/common/InstallAppModal.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import UpdateModal from '@/components/common/UpdateModal.vue';
import homeRouter from '@/router/home';
import { isElectron, isMobile } from '@/utils';
import UpdateModal from '@/components/common/UpdateModal.vue';
const keepAliveInclude = computed(() =>
homeRouter

View File

@@ -79,7 +79,7 @@ import {
textColors,
useLyricProgress
} from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
import { getImgUrl, isMobile } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
// 定义 refs
@@ -116,9 +116,15 @@ const lrcScroll = (behavior = 'smooth') => {
const debouncedLrcScroll = useDebounceFn(lrcScroll, 200);
const mouseOverLayout = () => {
if (isMobile.value) {
return;
}
isMouse.value = true;
};
const mouseLeaveLayout = () => {
if (isMobile.value) {
return;
}
setTimeout(() => {
isMouse.value = false;
lrcScroll();
@@ -321,6 +327,9 @@ defineExpose({
.music-lrc-text {
@apply text-xl text-center;
}
.music-content {
@apply h-[calc(100vh-120px)];
}
}
}

View File

@@ -1,6 +1,5 @@
<template>
<!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 -->
<div
@@ -141,6 +140,7 @@
</n-popover>
</div>
<!-- 播放音乐 -->
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
</div>
</template>
@@ -159,7 +159,7 @@ import {
textColors
} from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, isMobile, secondToMinute, setAnimationClass, isElectron } from '@/utils';
import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
import MusicFull from './MusicFull.vue';
@@ -294,6 +294,7 @@ const musicFullVisible = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value;
store.commit('setMusicFull', musicFullVisible.value);
};
const palyListRef = useTemplateRef('palyListRef');
@@ -432,8 +433,7 @@ const openLyricWindow = () => {
.mobile {
.music-play-bar {
@apply px-4;
bottom: 70px;
@apply px-4 bottom-[70px] transition-all duration-300;
}
.music-time {
display: none;

View File

@@ -48,6 +48,10 @@
<i class="iconfont ri-login-box-line"></i>
<span>去登录</span>
</div>
<div v-if="store.state.user" class="menu-item" @click="selectItem('logout')">
<i class="iconfont ri-logout-box-r-line"></i>
<span>退出登录</span>
</div>
<!-- 切换主题 -->
<div class="menu-item" @click="selectItem('set')">
<i class="iconfont ri-settings-3-line"></i>
@@ -92,7 +96,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref, watchEffect, computed } from 'vue';
import { computed, onMounted, ref, watchEffect } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -175,13 +179,17 @@ const search = () => {
router.push({
path: '/search',
query: {
keyword: value
keyword: value,
type: store.state.searchType
}
});
};
const selectSearchType = (key: number) => {
store.state.searchType = key;
if (searchValue.value) {
search();
}
};
const searchTypeOptions = ref(SEARCH_TYPES);
@@ -191,8 +199,7 @@ const selectItem = async (key: string) => {
switch (key) {
case 'logout':
logout().then(() => {
store.state.user = null;
localStorage.clear();
store.commit('logout');
router.push('/login');
});
break;
@@ -233,7 +240,7 @@ const checkForUpdates = async () => {
const toGithubRelease = () => {
if (updateInfo.value.hasUpdate) {
window.open(updateInfo.value.releaseInfo?.html_url || 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
store.commit('setShowUpdateModal', true);
} else {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
}
@@ -310,7 +317,7 @@ const toGithubRelease = () => {
.version-info {
@apply ml-auto flex items-center;
.version-number {
@apply text-xs px-2 py-0.5 rounded;
@apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300;

View File

@@ -10,14 +10,38 @@
</button>
</div>
</div>
<n-modal
v-model:show="showCloseModal"
preset="dialog"
title="关闭应用"
:style="{ width: '400px' }"
:mask-closable="true"
>
<div class="close-dialog-content">
<p>请选择关闭方式</p>
<div class="remember-choice">
<n-checkbox v-model:checked="rememberChoice">记住我的选择</n-checkbox>
</div>
</div>
<template #action>
<div class="dialog-footer">
<n-button type="primary" @click="handleAction('minimize')">最小化到托盘</n-button>
<n-button @click="handleAction('close')">退出应用</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { useDialog } from 'naive-ui';
import { ref } from 'vue';
import { useStore } from 'vuex';
import { isElectron } from '@/utils';
const dialog = useDialog();
const store = useStore();
const showCloseModal = ref(false);
const rememberChoice = ref(false);
const minimize = () => {
if (!isElectron) {
@@ -26,22 +50,40 @@ const minimize = () => {
window.api.minimize();
};
const handleAction = (action: 'minimize' | 'close') => {
if (rememberChoice.value) {
store.commit('setSetData', {
...store.state.setData,
closeAction: action
});
}
if (action === 'minimize') {
window.api.miniTray();
} else {
window.api.close();
}
showCloseModal.value = false;
};
const close = () => {
if (!isElectron) {
return;
}
dialog.warning({
title: '提示',
content: '确定要退出吗?',
positiveText: '最小化',
negativeText: '关闭',
onPositiveClick: () => {
window.api.minimize();
},
onNegativeClick: () => {
window.api.close();
}
});
const { closeAction } = store.state.setData;
if (closeAction === 'minimize') {
window.api.miniTray();
return;
}
if (closeAction === 'close') {
window.api.close();
return;
}
showCloseModal.value = true;
};
const drag = (event: MouseEvent) => {
@@ -68,4 +110,12 @@ const drag = (event: MouseEvent) => {
button {
@apply text-gray-600 dark:text-gray-400 hover:text-green-500;
}
.close-dialog-content {
@apply flex flex-col gap-4;
}
.dialog-footer {
@apply flex gap-4 justify-end;
}
</style>

View File

@@ -2,6 +2,7 @@ import 'vfonts/Lato.css';
import 'vfonts/FiraCode.css';
// tailwind css
import './index.css';
import 'animate.css';
import 'remixicon/fonts/remixicon.css';
import { createApp } from 'vue';

View File

@@ -1,19 +1,15 @@
import { createStore } from 'vuex';
import setData from '@/../main/set.json';
import { getLikedList, likeSong } from '@/api/music';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import type { SongResult } from '@/type/music';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
import { isElectron } from '@/utils';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
// 默认设置
const defaultSettings = {
isProxy: false,
noAnimate: false,
animationSpeed: 1,
author: 'Alger',
authorUrl: 'https://github.com/algerkong'
};
const defaultSettings = setData;
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
const item = localStorage.getItem(key);
@@ -37,6 +33,8 @@ interface State {
favoriteList: number[];
playMode: number;
theme: ThemeType;
musicFull: boolean;
showUpdateModal: boolean;
}
const state: State = {
@@ -55,7 +53,9 @@ const state: State = {
searchType: 1,
favoriteList: getLocalStorageItem('favoriteList', []),
playMode: getLocalStorageItem('playMode', 0),
theme: getCurrentTheme()
theme: getCurrentTheme(),
musicFull: false,
showUpdateModal: false
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -73,6 +73,9 @@ const mutations = {
setPlayMusic(state: State, play: boolean) {
state.play = play;
},
setMusicFull(state: State, musicFull: boolean) {
state.musicFull = musicFull;
},
setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
state.playList = playList;
@@ -90,20 +93,44 @@ const mutations = {
// 'set',
// JSON.parse(JSON.stringify(setData))
// );
window.electron.ipcRenderer.send('set-store-value', 'set', JSON.parse(JSON.stringify(setData)));
window.electron.ipcRenderer.send(
'set-store-value',
'set',
JSON.parse(JSON.stringify(setData))
);
} else {
localStorage.setItem('appSettings', JSON.stringify(setData));
}
},
addToFavorite(state: State, songId: number) {
async addToFavorite(state: State, songId: number) {
// 先添加到本地
if (!state.favoriteList.includes(songId)) {
state.favoriteList = [songId, ...state.favoriteList];
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
}
// 如果用户已登录,尝试同步到服务器
if (state.user && localStorage.getItem('token')) {
try {
await likeSong(songId, true);
} catch (error) {
console.error('同步收藏到服务器失败,但已保存在本地:', error);
}
}
},
removeFromFavorite(state: State, songId: number) {
async removeFromFavorite(state: State, songId: number) {
// 先从本地移除
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
// 如果用户已登录,尝试同步到服务器
if (state.user && localStorage.getItem('token')) {
try {
await likeSong(songId, false);
} catch (error) {
console.error('同步取消收藏到服务器失败,但已在本地移除:', error);
}
}
},
togglePlayMode(state: State) {
state.playMode = (state.playMode + 1) % 3;
@@ -112,13 +139,20 @@ const mutations = {
toggleTheme(state: State) {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
applyTheme(state.theme);
},
setShowUpdateModal(state, value) {
state.showUpdateModal = value;
},
logout(state: State) {
state.user = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
}
};
const actions = {
initializeSettings({ commit }: { commit: any }) {
if (isElectron) {
// const setData = (window as any).electron.ipcRenderer.getStoreValue('set');
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
commit('setSetData', setData || defaultSettings);
} else {
@@ -135,6 +169,34 @@ const actions = {
},
initializeTheme({ state }: { state: State }) {
applyTheme(state.theme);
},
async initializeFavoriteList({ state }: { state: State }) {
// 先获取本地收藏列表
const localFavoriteList = localStorage.getItem('favoriteList');
const localList: number[] = localFavoriteList ? JSON.parse(localFavoriteList) : [];
// 如果用户已登录,尝试获取服务器收藏列表并合并
if (state.user && localStorage.getItem('token')) {
try {
const res = await getLikedList();
if (res.data?.ids) {
// 合并本地和服务器的收藏列表,去重
const serverList = res.data.ids.reverse();
const mergedList = Array.from(new Set([...localList, ...serverList]));
state.favoriteList = mergedList;
} else {
state.favoriteList = localList;
}
} catch (error) {
console.error('获取服务器收藏列表失败,使用本地数据:', error);
state.favoriteList = localList;
}
} else {
state.favoriteList = localList;
}
// 更新本地存储
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
}
};

View File

@@ -43,7 +43,7 @@ interface Profile {
createTime: number;
nickname: string;
avatarUrl: string;
experts: Experts;
experts: any;
expertTags?: any;
djStatus: number;
accountStatus: number;
@@ -79,8 +79,6 @@ interface Profile {
newFollows: number;
}
interface Experts {}
interface UserPoint {
userId: number;
balance: number;

View File

@@ -19,4 +19,4 @@ declare global {
interface Window {
api: IElectronAPI;
}
}
}

View File

@@ -0,0 +1,32 @@
import type { MessageApi } from 'naive-ui';
/**
* 选择目录
* @param message MessageApi 实例
* @returns Promise<string | undefined> 返回选择的目录路径,如果取消则返回 undefined
*/
export const selectDirectory = async (message: MessageApi): Promise<string | undefined> => {
try {
const result = await window.electron.ipcRenderer.invoke('select-directory');
if (result.filePaths?.[0]) {
return result.filePaths[0];
}
} catch (error) {
message.error('选择目录失败');
}
return undefined;
};
/**
* 打开目录
* @param path 要打开的目录路径
* @param message MessageApi 实例
* @param showTip 是否显示提示信息
*/
export const openDirectory = (path: string | undefined, message: MessageApi, showTip = true) => {
if (path) {
window.electron.ipcRenderer.send('open-directory', path);
} else if (showTip) {
message.info('目录不存在');
}
};

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue';
import store from '@/store';
// 设置歌手背景图片
@@ -54,34 +55,9 @@ export const formatNumber = (num: string | number) => {
return num.toString();
};
const windowData = window as any;
export const getIsMc = () => {
if (!windowData.electron) {
return false;
}
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
if (setData.isProxy) {
return true;
}
return false;
};
const ProxyUrl = import.meta.env.VITE_API_PROXY;
export const getMusicProxyUrl = (url: string) => {
if (!getIsMc()) {
return url;
}
const PUrl = url.split('').join('+');
return `${ProxyUrl}/mc?url=${PUrl}`;
};
export const getImgUrl = (url: string | undefined, size: string = '') => {
const bdUrl = 'https://image.baidu.com/search/down?url=';
const imgUrl = `${url}?param=${size}`;
if (!getIsMc()) {
return imgUrl;
}
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
return imgUrl;
};
export const isMobile = computed(() => {
@@ -96,4 +72,4 @@ export const isMobile = computed(() => {
return !!flag;
});
export const isElectron = (window as any).electron !== undefined;
export const isElectron = (window as any).electron !== undefined;

View File

@@ -1,13 +1,27 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { createDiscreteApi } from 'naive-ui';
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set')
import store from '@/store';
import { isElectron } from '.';
const { notification } = createDiscreteApi(['notification']);
let setData: any = null;
const getSetData = () => {
if (window.electron) {
setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
}
};
getSetData();
// 扩展请求配置接口
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
retryCount?: number;
}
const baseURL = window.electron ? `http://127.0.0.1:${setData.musicApiPort}` : import.meta.env.VITE_API;
const baseURL = window.electron
? `http://127.0.0.1:${setData?.musicApiPort}`
: import.meta.env.VITE_API;
const request = axios.create({
baseURL,
@@ -22,8 +36,11 @@ const RETRY_DELAY = 500;
// 请求拦截器
request.interceptors.request.use(
(config: CustomAxiosRequestConfig) => {
// 初始化重试次数
config.retryCount = 0;
getSetData();
// 只在retryCount未定义时初始化为0
if (config.retryCount === undefined) {
config.retryCount = 0;
}
// 在请求发送之前做一些处理
// 在get请求params中添加timestamp
@@ -34,7 +51,19 @@ request.interceptors.request.use(
};
const token = localStorage.getItem('token');
if (token) {
config.params.cookie = token;
config.params.cookie = `${token} os=pc;`;
} else {
config.params.cookie = 'os=pc;';
}
}
if (isElectron) {
const proxyConfig = setData?.proxyConfig;
if (proxyConfig?.enable && ['http', 'https'].includes(proxyConfig?.protocol)) {
config.params.proxy = `${proxyConfig.protocol}://${proxyConfig.host}:${proxyConfig.port}`;
}
if (setData.enableRealIP && setData.realIP) {
config.params.realIP = setData.realIP;
}
}
@@ -52,16 +81,42 @@ request.interceptors.response.use(
return response;
},
async (error) => {
console.log('error', error);
const config = error.config as CustomAxiosRequestConfig;
// 如果没有配置重试次数则初始化为0
if (!config || !config.retryCount) {
config.retryCount = 0;
// 如果没有配置,直接返回错误
if (!config) {
return Promise.reject(error);
}
// 处理 301 状态码
if (error.response?.status === 301) {
// 使用 store mutation 清除用户信息
store.commit('logout');
// 如果还可以重试,则重新发起请求
if (config.retryCount === undefined || config.retryCount < MAX_RETRIES) {
config.retryCount = (config.retryCount || 1) + 1;
console.log(`301 状态码,清除登录信息后重试第 ${config.retryCount}`);
notification.error({
content: '登录状态失效,请重新登录',
meta: '请重新登录',
duration: 2500,
keepAliveOnHover: true
});
// 延迟重试
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
// 重新发起请求
return request(config);
}
}
// 检查是否还可以重试
if (config.retryCount < MAX_RETRIES) {
if (config.retryCount !== undefined && config.retryCount < MAX_RETRIES) {
config.retryCount++;
console.log(`请求重试第 ${config.retryCount}`);
// 延迟重试
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
@@ -70,6 +125,7 @@ request.interceptors.response.use(
return request(config);
}
console.log(`重试${MAX_RETRIES}次后仍然失败`);
return Promise.reject(error);
}
);

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import config from '../../../package.json';
import { useDateFormat } from '@vueuse/core';
import axios from 'axios';
import config from '../../../package.json';
interface GithubReleaseInfo {
tag_name: string;
@@ -34,14 +35,47 @@ export interface UpdateResult {
*/
export const getLatestReleaseInfo = async (): Promise<GithubReleaseInfo | null> => {
try {
const response = await axios.get(
'https://api.github.com/repos/algerkong/AlgerMusicPlayer/releases/latest'
);
if (response.data) {
return response.data;
const token = import.meta.env.VITE_GITHUB_TOKEN;
const headers = {};
const apiUrls = [
// 原始地址
'https://api.github.com/repos/algerkong/AlgerMusicPlayer/releases/latest',
// 使用 ghproxy.com 代理
'https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/package.json'
// 使用 gitee 镜像(如果有的话)
// 'https://gitee.com/api/v5/repos/[用户名]/AlgerMusicPlayer/releases/latest'
];
if (token) {
headers['Authorization'] = `token ${token}`;
}
return null;
for (const url of apiUrls) {
try {
const response = await axios.get(url, { headers });
if (url.includes('package.json')) {
// 如果是 package.json直接读取版本号
return {
tag_name: response.data.version,
body: (
await axios.get(
'https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/CHANGELOG.md'
)
).data,
html_url: 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest',
assets: []
} as unknown as GithubReleaseInfo;
}
return response.data;
} catch (err) {
console.warn(`尝试访问 ${url} 失败:`, err);
continue;
}
}
throw new Error('所有 API 地址均无法访问');
} catch (error) {
console.error('获取 GitHub Release 信息失败:', error);
return null;
@@ -51,16 +85,19 @@ export const getLatestReleaseInfo = async (): Promise<GithubReleaseInfo | null>
/**
* 格式化时间
*/
const formatDate = (dateStr: string): string => {
export const formatDate = (dateStr: string): string => {
return useDateFormat(new Date(dateStr), 'YYYY-MM-DD HH:mm').value;
};
/**
* 检查更新
*/
export const checkUpdate = async (currentVersion: string = config.version): Promise<UpdateResult | null> => {
export const checkUpdate = async (
currentVersion: string = config.version
): Promise<UpdateResult | null> => {
try {
const releaseInfo = await getLatestReleaseInfo();
console.log('releaseInfo', releaseInfo);
if (!releaseInfo) {
return null;
}
@@ -69,6 +106,8 @@ export const checkUpdate = async (currentVersion: string = config.version): Prom
if (latestVersion === currentVersion) {
return null;
}
console.log('latestVersion', latestVersion);
console.log('currentVersion', currentVersion);
return {
hasUpdate: true,
@@ -76,9 +115,9 @@ export const checkUpdate = async (currentVersion: string = config.version): Prom
currentVersion,
releaseInfo: {
tag_name: latestVersion,
body: `## 更新内容\n\n- 版本: ${latestVersion}\n- 发布时间: ${formatDate(releaseInfo.published_at)}\n\n${releaseInfo.body}`,
body: `## 更新内容\n\n- 版本: ${latestVersion}\n${releaseInfo.body}`,
html_url: releaseInfo.html_url,
assets: releaseInfo.assets.map(asset => ({
assets: releaseInfo.assets.map((asset) => ({
browser_download_url: asset.browser_download_url,
name: asset.name
}))
@@ -88,4 +127,4 @@ export const checkUpdate = async (currentVersion: string = config.version): Prom
console.error('检查更新失败:', error);
return null;
}
};
};

View File

@@ -1,8 +1,51 @@
<template>
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
<div class="favorite-header" :class="setAnimationClass('animate__fadeInLeft')">
<h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div>
<div class="favorite-header-left">
<h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div>
</div>
<div v-if="!isComponent" class="favorite-header-right">
<n-button
v-if="!isSelecting"
secondary
type="primary"
size="small"
class="select-btn"
@click="startSelect"
>
<template #icon>
<i class="iconfont ri-checkbox-multiple-line"></i>
</template>
批量下载
</n-button>
<div v-else class="select-controls">
<n-checkbox
class="select-all-checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
全选
</n-checkbox>
<n-button-group class="operation-btns">
<n-button
type="primary"
size="small"
:loading="isDownloading"
:disabled="selectedSongs.length === 0"
class="download-btn"
@click="handleBatchDownload"
>
<template #icon>
<i class="iconfont ri-download-line"></i>
</template>
下载 ({{ selectedSongs.length }})
</n-button>
<n-button size="small" class="cancel-btn" @click="cancelSelect"> 取消 </n-button>
</n-button-group>
</div>
</div>
</div>
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
<n-scrollbar ref="scrollbarRef" class="favorite-content" @scroll="handleScroll">
@@ -17,7 +60,10 @@
:favorite="!isComponent"
:class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)"
:selectable="isSelecting"
:selected="selectedSongs.includes(song.id)"
@play="handlePlay"
@select="handleSelect"
/>
<div v-if="isComponent" class="favorite-list-more text-center">
<n-button text type="primary" @click="handleMore">查看更多</n-button>
@@ -35,22 +81,112 @@
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { getSongUrl } from '@/hooks/MusicListHook';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
const store = useStore();
const message = useMessage();
const favoriteList = computed(() => store.state.favoriteList);
const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false);
const noMore = ref(false);
const scrollbarRef = ref();
// 多选相关
const isSelecting = ref(false);
const selectedSongs = ref<number[]>([]);
const isDownloading = ref(false);
// 开始多选
const startSelect = () => {
isSelecting.value = true;
selectedSongs.value = [];
};
// 取消多选
const cancelSelect = () => {
isSelecting.value = false;
selectedSongs.value = [];
};
// 处理选择
const handleSelect = (songId: number, selected: boolean) => {
if (selected) {
selectedSongs.value.push(songId);
} else {
selectedSongs.value = selectedSongs.value.filter((id) => id !== songId);
}
};
// 批量下载
const handleBatchDownload = async () => {
if (isDownloading.value) {
message.warning('正在下载中,请稍候...');
return;
}
if (selectedSongs.value.length === 0) {
message.warning('请先选择要下载的歌曲');
return;
}
try {
isDownloading.value = true;
const loadingMessage = message.loading('正在下载中...', { duration: 0 });
let successCount = 0;
let failCount = 0;
// 移除旧的监听器
window.electron.ipcRenderer.removeAllListeners('music-download-complete');
// 添加新的监听器
window.electron.ipcRenderer.on('music-download-complete', (_, result) => {
if (result.success) {
successCount++;
} else if (!result.canceled) {
failCount++;
}
// 当所有下载完成时
if (successCount + failCount === selectedSongs.value.length) {
isDownloading.value = false;
loadingMessage.destroy();
message.success(`下载完成:成功 ${successCount} 首,失败 ${failCount}`);
cancelSelect();
}
});
// 开始下载选中的歌曲
for (const songId of selectedSongs.value) {
const song = favoriteSongs.value.find((s) => s.id === songId);
if (!song) continue;
const url = await getSongUrl(song.id, song);
if (!url) {
failCount++;
continue;
}
window.electron.ipcRenderer.send('download-music', {
url,
filename: `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`
});
}
} catch (error) {
isDownloading.value = false;
message.destroyAll();
message.error('下载失败');
}
};
// 无限滚动相关
const pageSize = 16;
const currentPage = ref(1);
@@ -64,10 +200,9 @@ const props = defineProps({
// 获取当前页的收藏歌曲ID
const getCurrentPageIds = () => {
const reversedList = [...favoriteList.value];
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
return reversedList.slice(startIndex, endIndex);
return favoriteList.value.slice(startIndex, endIndex);
};
// 获取收藏歌曲详情
@@ -120,6 +255,7 @@ const handleScroll = (e: any) => {
};
onMounted(() => {
store.dispatch('initializeFavoriteList');
getFavoriteSongs();
});
@@ -146,6 +282,26 @@ const router = useRouter();
const handleMore = () => {
router.push('/favorite');
};
// 全选相关
const isAllSelected = computed(() => {
return (
favoriteSongs.value.length > 0 && selectedSongs.value.length === favoriteSongs.value.length
);
});
const isIndeterminate = computed(() => {
return selectedSongs.value.length > 0 && selectedSongs.value.length < favoriteSongs.value.length;
});
// 处理全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
selectedSongs.value = favoriteSongs.value.map((song) => song.id);
} else {
selectedSongs.value = [];
}
};
</script>
<style lang="scss" scoped>
@@ -154,15 +310,68 @@ const handleMore = () => {
@apply bg-light dark:bg-black;
.favorite-header {
@apply flex items-center justify-between flex-shrink-0 px-4;
@apply flex items-center justify-between flex-shrink-0 px-4 pb-2;
h2 {
@apply text-xl font-bold pb-2;
@apply text-gray-900 dark:text-white;
&-left {
@apply flex items-center gap-4;
h2 {
@apply text-xl font-bold;
@apply text-gray-900 dark:text-white;
}
.favorite-count {
@apply text-gray-500 dark:text-gray-400 text-sm;
}
}
.favorite-count {
@apply text-gray-500 dark:text-gray-400 text-sm;
&-right {
@apply flex items-center;
.select-btn {
@apply rounded-full px-4 h-8;
@apply transition-all duration-300 ease-in-out;
@apply hover:bg-primary hover:text-white;
@apply dark:border-gray-600;
.iconfont {
@apply mr-1 text-lg;
}
}
.select-controls {
@apply flex items-center gap-3;
@apply bg-gray-50 dark:bg-gray-800;
@apply rounded-full px-3 py-1;
@apply border border-gray-200 dark:border-gray-700;
@apply transition-all duration-300;
.select-all-checkbox {
@apply text-sm text-gray-900 dark:text-gray-200;
}
.operation-btns {
@apply flex items-center gap-2 ml-2;
.download-btn {
@apply rounded-full px-4 h-7;
@apply bg-primary text-white;
@apply hover:bg-primary-dark;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
.iconfont {
@apply mr-1 text-lg;
}
}
.cancel-btn {
@apply rounded-full px-4 h-7;
@apply text-gray-600 dark:text-gray-300;
@apply hover:bg-gray-100 dark:hover:bg-gray-700;
@apply border-gray-300 dark:border-gray-600;
}
}
}
}
}

View File

@@ -34,10 +34,10 @@ import { onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from '@/components/common/SongItem.vue';
defineOptions({
name: 'History'

View File

@@ -18,7 +18,12 @@
</n-scrollbar>
</div>
<!-- 歌单列表 -->
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
<n-scrollbar
class="recommend"
style="height: calc(100% - 55px)"
:size="100"
@scroll="handleScroll"
>
<div v-loading="loading" class="recommend-list">
<div
v-for="(item, index) in recommendList"
@@ -218,8 +223,6 @@ watch(
}
.recommend {
@apply w-full h-full;
&-title {
@apply text-lg font-bold pb-2;
@apply text-gray-900 dark:text-white;
@@ -325,5 +328,8 @@ watch(
.play-list-type {
@apply mx-0 w-full;
}
.categories-wrapper {
@apply pl-4;
}
}
</style>

View File

@@ -292,4 +292,13 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
@apply text-center py-4 col-span-full;
@apply text-gray-500 dark:text-gray-400;
}
.mobile {
.mv-list-content {
@apply pl-4 pr-4;
}
.categories-wrapper {
@apply pl-4;
}
}
</style>

View File

@@ -49,7 +49,7 @@
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item" />
<search-item :item="item" />
</div>
</template>
</template>
@@ -67,10 +67,10 @@ import { useStore } from 'vuex';
import { getHotSearch } from '@/api/home';
import { getSearch } from '@/api/search';
import SearchItem from '@/components/common/SearchItem.vue';
import SongItem from '@/components/common/SongItem.vue';
import type { IHotSearch } from '@/type/search';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import SearchItem from '@/components/common/SearchItem.vue';
defineOptions({
name: 'Search'
@@ -104,6 +104,15 @@ watch(
}
);
watch(
() => searchType.value,
() => {
if (store.state.searchValue) {
loadSearch(store.state.searchValue);
}
}
);
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
const loadSearch = async (keywords: any, type: any = null) => {
hotKeyword.value = keywords;

View File

@@ -22,12 +22,10 @@
</div>
<n-switch v-model:value="setData.isProxy" />
</div> -->
<div class="set-item" v-if="isElectron">
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">音乐API端口</div>
<div class="set-item-content">
修改后需要重启应用
</div>
<div class="set-item-content">修改后需要重启应用</div>
</div>
<n-input-number v-model:value="setData.musicApiPort" />
</div>
@@ -55,6 +53,18 @@
</div>
</div>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">下载目录</div>
<div class="set-item-content">
{{ setData.downloadPath || '默认下载目录' }}
</div>
</div>
<div class="flex items-center gap-2">
<n-button size="small" @click="openDownloadPath">打开目录</n-button>
<n-button size="small" @click="selectDownloadPath">修改目录</n-button>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">版本</div>
@@ -89,31 +99,169 @@
@click="openAuthor"
>
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content">algerkong github</div>
<coffee>
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content">algerkong 点个star🌟</div>
</div>
</coffee>
</div>
<div>
<n-button size="small" type="primary" @click="openAuthor"
><i class="ri-github-line"></i>前往github</n-button
>
</div>
<div>{{ setData.author }}</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">音质设置</div>
<div class="set-item-content">选择音乐播放音质VIP</div>
</div>
<n-select
v-model:value="setData.musicQuality"
:options="[
{ label: '标准', value: 'standard' },
{ label: '较高', value: 'higher' },
{ label: '极高', value: 'exhigh' },
{ label: '无损', value: 'lossless' },
{ label: 'Hi-Res', value: 'hires' },
{ label: '高清环绕声', value: 'jyeffect' },
{ label: '沉浸环绕声', value: 'sky' },
{ label: '杜比全景声', value: 'dolby' },
{ label: '超清母带', value: 'jymaster' }
]"
style="width: 160px"
/>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">关闭行为</div>
<div class="set-item-content">
{{ closeActionLabels[setData.closeAction] || '每次询问' }}
</div>
</div>
<n-select
v-model:value="setData.closeAction"
:options="[
{ label: '每次询问', value: 'ask' },
{ label: '最小化到托盘', value: 'minimize' },
{ label: '直接退出', value: 'close' }
]"
style="width: 160px"
/>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">重启</div>
<div class="set-item-content">重启应用</div>
</div>
<n-button type="primary" @click="restartApp">重启</n-button>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">代理设置</div>
<div class="set-item-content">无法访问音乐时可以开启代理</div>
</div>
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.proxyConfig.enable">
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<n-button size="small" @click="showProxyModal = true">配置</n-button>
</div>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">realIP</div>
<div class="set-item-content">
由于限制,此项目在国外使用会受到限制可使用realIP参数,传进国内IP解决,:116.25.146.177
即可解决
</div>
</div>
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.enableRealIP">
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<n-input
v-if="setData.enableRealIP"
v-model:value="setData.realIP"
placeholder="realIP"
style="width: 200px"
@blur="validateAndSaveRealIP"
/>
</div>
</div>
<div
class="p-4 bg-light dark:bg-dark rounded-lg mb-4 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div>
<div class="set-item-title">捐赠支持</div>
<div class="set-item-content">感谢您的支持让我有动力能够持续改进</div>
</div>
<donation-list />
</div>
</div>
<PlayBottom/>
<play-bottom />
<n-modal
v-model:show="showProxyModal"
preset="dialog"
title="代理设置"
positive-text="确认"
negative-text="取消"
:show-icon="false"
@positive-click="handleProxyConfirm"
@negative-click="showProxyModal = false"
>
<n-form
ref="formRef"
:model="proxyForm"
:rules="proxyRules"
label-placement="left"
label-width="80"
require-mark-placement="right-hanging"
>
<n-form-item label="代理协议" path="protocol">
<n-select
v-model:value="proxyForm.protocol"
:options="[
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'SOCKS5', value: 'socks5' }
]"
/>
</n-form-item>
<n-form-item label="代理地址" path="host">
<n-input v-model:value="proxyForm.host" placeholder="请输入代理地址" />
</n-form-item>
<n-form-item label="代理端口" path="port">
<n-input-number
v-model:value="proxyForm.port"
placeholder="请输入代理端口"
:min="1"
:max="65535"
/>
</n-form-item>
</n-form>
</n-modal>
</n-scrollbar>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import type { FormRules } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { isElectron } from '@/utils';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import Coffee from '@/components/Coffee.vue';
import DonationList from '@/components/common/DonationList.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
const store = useStore();
const checking = ref(false);
@@ -124,11 +272,37 @@ const updateInfo = ref<UpdateResult>({
releaseInfo: null
});
const setData = computed(() => store.state.setData);
const closeActionLabels = {
ask: '每次询问',
minimize: '最小化到托盘',
close: '直接退出'
} as const;
watch(() => setData.value, (newVal) => {
store.commit('setSetData', newVal)
}, { deep: true });
const setData = computed(() => {
const data = store.state.setData;
// 确保代理配置存在
if (!data.proxyConfig) {
data.proxyConfig = {
enable: false,
protocol: 'http',
host: '127.0.0.1',
port: 7890
};
}
// 确保音质设置存在
if (!data.musicQuality) {
data.musicQuality = 'higher';
}
return data;
});
watch(
() => setData.value,
(newVal) => {
store.commit('setSetData', newVal);
},
{ deep: true }
);
const isDarkTheme = computed({
get: () => store.state.theme === 'dark',
@@ -166,17 +340,147 @@ const checkForUpdates = async (isClick = false) => {
};
const openReleasePage = () => {
window.open(updateInfo.value.releaseInfo?.html_url || 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
store.commit('setShowUpdateModal', true);
};
const selectDownloadPath = async () => {
const path = await selectDirectory(message);
if (path) {
store.commit('setSetData', {
...setData.value,
downloadPath: path
});
}
};
const openDownloadPath = () => {
openDirectory(setData.value.downloadPath, message);
};
const showProxyModal = ref(false);
const formRef = ref();
const proxyForm = ref({
protocol: 'http',
host: '127.0.0.1',
port: 7890
});
const proxyRules: FormRules = {
protocol: {
required: true,
message: '请选择代理协议',
trigger: ['blur', 'change']
},
host: {
required: true,
message: '请输入代理地址',
trigger: ['blur', 'change'],
validator: (_rule, value) => {
if (!value) return false;
// 简单的IP或域名验证
const ipRegex =
/^(\d{1,3}\.){3}\d{1,3}$|^localhost$|^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/;
return ipRegex.test(value);
}
},
port: {
required: true,
message: '请输入有效的端口号(1-65535)',
trigger: ['blur', 'change'],
validator: (_rule, value) => {
return value >= 1 && value <= 65535;
}
}
};
// 初始化时从store获取代理配置
onMounted(() => {
checkForUpdates();
if (setData.value.proxyConfig) {
proxyForm.value = { ...setData.value.proxyConfig };
}
// 确保enableRealIP有默认值
if (setData.value.enableRealIP === undefined) {
store.commit('setSetData', {
...setData.value,
enableRealIP: false
});
}
});
// 监听代理配置变化
watch(
() => setData.value.proxyConfig,
(newVal) => {
if (newVal) {
proxyForm.value = {
protocol: newVal.protocol || 'http',
host: newVal.host || '127.0.0.1',
port: newVal.port || 7890
};
}
},
{ immediate: true, deep: true }
);
const handleProxyConfirm = async () => {
try {
await formRef.value?.validate();
// 保存代理配置时保留enable状态
store.commit('setSetData', {
...setData.value,
proxyConfig: {
enable: setData.value.proxyConfig?.enable || false,
protocol: proxyForm.value.protocol,
host: proxyForm.value.host,
port: proxyForm.value.port
}
});
showProxyModal.value = false;
message.success('代理设置已保存,重启应用后生效');
} catch (err) {
message.error('请检查输入是否正确');
}
};
const validateAndSaveRealIP = () => {
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!setData.value.realIP || ipRegex.test(setData.value.realIP)) {
store.commit('setSetData', {
...setData.value,
realIP: setData.value.realIP,
enableRealIP: true
});
if (setData.value.realIP) {
message.success('真实IP设置已保存');
}
} else {
message.error('请输入有效的IP地址');
store.commit('setSetData', {
...setData.value,
realIP: ''
});
}
};
// 监听enableRealIP变化当关闭时清空realIP
watch(
() => setData.value.enableRealIP,
(newVal) => {
if (!newVal) {
store.commit('setSetData', {
...setData.value,
realIP: '',
enableRealIP: false
});
}
}
);
</script>
<style lang="scss" scoped>
.set-page {
@apply p-4 bg-light dark:bg-dark;
@apply p-4 bg-light dark:bg-dark pb-20;
}
.set-item {

View File

@@ -89,7 +89,7 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -112,42 +112,62 @@ const userDetail = ref<IUserDetail>();
const playList = ref<any[]>([]);
const recordList = ref();
const infoLoading = ref(false);
const user = computed(() => store.state.user);
const loadPage = async () => {
if (!user.value) {
router.push('/login');
return;
}
infoLoading.value = true;
const { data: userData } = await getUserDetail(user.value.userId);
userDetail.value = userData;
const { data: playlistData } = await getUserPlaylist(user.value.userId);
playList.value = playlistData.playlist;
const { data: recordData } = await getUserRecord(user.value.userId);
recordList.value = recordData.allData.map((item: any) => ({
...item,
...item.song,
picUrl: item.song.al.picUrl
}));
infoLoading.value = false;
};
onActivated(() => {
if (!user.value) {
router.push('/login');
} else {
loadPage();
}
});
const mounted = ref(true);
const isShowList = ref(false);
const list = ref<Playlist>();
const listLoading = ref(false);
const user = computed(() => store.state.user);
onBeforeUnmount(() => {
mounted.value = false;
});
const loadPage = async () => {
if (!mounted.value || !user.value) return;
try {
infoLoading.value = true;
const { data: userData } = await getUserDetail(user.value.userId);
if (!mounted.value) return;
userDetail.value = userData;
const { data: playlistData } = await getUserPlaylist(user.value.userId);
if (!mounted.value) return;
playList.value = playlistData.playlist;
const { data: recordData } = await getUserRecord(user.value.userId);
if (!mounted.value) return;
recordList.value = recordData.allData.map((item: any) => ({
...item,
...item.song,
picUrl: item.song.al.picUrl
}));
} catch (error) {
console.error('加载用户页面失败:', error);
} finally {
if (mounted.value) {
infoLoading.value = false;
}
}
};
// 监听用户状态变化
watch(
() => store.state.user,
(newUser) => {
if (!mounted.value) return;
if (!newUser) {
router.push('/login');
} else {
loadPage();
}
},
{ immediate: true }
);
// 展示歌单
const showPlaylist = async (id: number, name: string) => {
isShowList.value = true;