mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 19:37:38 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f56f5e240 | ||
|
|
7af7779e5c | ||
|
|
669a348218 | ||
|
|
f02264c80c | ||
|
|
d0d5f918bd | ||
|
|
761d265d18 | ||
|
|
204df64535 | ||
|
|
cc814eddbd | ||
|
|
51df14a9e9 | ||
|
|
2473b36928 | ||
|
|
dbba7a3d26 |
@@ -7,8 +7,8 @@ export default defineConfig({
|
|||||||
base: '/',
|
base: '/',
|
||||||
description:
|
description:
|
||||||
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
|
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
|
||||||
markdown:{
|
markdown: {
|
||||||
config(md){
|
config(md) {
|
||||||
md.use(note)
|
md.use(note)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -28,13 +28,11 @@ export default defineConfig({
|
|||||||
{ text: '安装教程', link: '/guide/' },
|
{ text: '安装教程', link: '/guide/' },
|
||||||
{
|
{
|
||||||
text: '使用教程',
|
text: '使用教程',
|
||||||
items: [
|
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
|
||||||
{ text: '音乐播放列表', link: '/guide/used/playList' },
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{ text: '软件设计文档', link: '/guide/design' },
|
{ text: '软件设计文档', link: '/guide/design' },
|
||||||
{ text: '更新日志', link: '/guide/updateLog' },
|
{ text: '更新日志', link: '/guide/updateLog' },
|
||||||
{ text: '更新计划', link: '/guide/update'}
|
{ text: '更新计划', link: '/guide/update' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,6 +41,9 @@ export default defineConfig({
|
|||||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||||
]
|
]
|
||||||
|
},{
|
||||||
|
text: '鸣谢名单',
|
||||||
|
link: '/guide/sponsorship'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -64,21 +65,20 @@ export default defineConfig({
|
|||||||
provider: 'local'
|
provider: 'local'
|
||||||
},
|
},
|
||||||
outline: {
|
outline: {
|
||||||
level: [2,4],
|
level: [2, 4],
|
||||||
label: '文章导航'
|
label: '文章导航'
|
||||||
},
|
},
|
||||||
docFooter: {
|
docFooter: {
|
||||||
next: '下一篇',
|
next: '下一篇',
|
||||||
prev: '上一篇'
|
prev: '上一篇'
|
||||||
},
|
},
|
||||||
lastUpdatedText: '上次更新',
|
lastUpdatedText: '上次更新'
|
||||||
|
|
||||||
},
|
},
|
||||||
sitemap: {
|
sitemap: {
|
||||||
hostname: 'https://ceru.docs.shiqianjiang.cn'
|
hostname: 'https://ceru.docs.shiqianjiang.cn'
|
||||||
},
|
},
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
|
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
|
||||||
})
|
})
|
||||||
console.log(process.env.BASE_URL_DOCS)
|
console.log(process.env.BASE_URL_DOCS)
|
||||||
// Smooth scrolling functions
|
// Smooth scrolling functions
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ html.dark #app {
|
|||||||
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
|
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
|
||||||
|
|
||||||
/* 下面是文章内Toc目录自动编号,与上面一样即可 */
|
/* 下面是文章内Toc目录自动编号,与上面一样即可 */
|
||||||
// --autonum-h1toc: counter(h1toc) ". ";
|
// --autonum-h1toc: counter(h1toc) ". ";
|
||||||
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
||||||
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
||||||
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
||||||
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
||||||
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
|
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
|
||||||
|
|
||||||
/* 主题颜色 */
|
/* 主题颜色 */
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
CeruMusic 支持两种类型的插件:
|
CeruMusic 支持两种类型的插件:
|
||||||
|
|
||||||
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
|
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
|
||||||
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
|
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
|
||||||
|
|
||||||
@@ -132,7 +133,6 @@ module.exports = {
|
|||||||
> #### PS:
|
> #### PS:
|
||||||
>
|
>
|
||||||
> - `sources key` 取值
|
> - `sources key` 取值
|
||||||
>
|
|
||||||
> - wy 网易云音乐 |
|
> - wy 网易云音乐 |
|
||||||
> - tx QQ音乐 |
|
> - tx QQ音乐 |
|
||||||
> - kg 酷狗音乐 |
|
> - kg 酷狗音乐 |
|
||||||
@@ -143,12 +143,11 @@ module.exports = {
|
|||||||
>
|
>
|
||||||
> ```javascript
|
> ```javascript
|
||||||
> module.exports = {
|
> module.exports = {
|
||||||
> sources, // 你的音源支持
|
> sources // 你的音源支持
|
||||||
> };
|
> }
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
|
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
|
||||||
>
|
|
||||||
> - `128k`: 128kbps
|
> - `128k`: 128kbps
|
||||||
> - `320k`: 320kbps
|
> - `320k`: 320kbps
|
||||||
> - `flac`: FLAC 无损
|
> - `flac`: FLAC 无损
|
||||||
@@ -157,8 +156,6 @@ module.exports = {
|
|||||||
> - `atmos`: 杜比全景声
|
> - `atmos`: 杜比全景声
|
||||||
> - `master`: 母带音质
|
> - `master`: 母带音质
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### CeruMusic API 参考
|
### CeruMusic API 参考
|
||||||
|
|
||||||
#### cerumusic.request(url, options)
|
#### cerumusic.request(url, options)
|
||||||
@@ -166,6 +163,7 @@ module.exports = {
|
|||||||
HTTP 请求方法,返回 Promise。
|
HTTP 请求方法,返回 Promise。
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
|
|
||||||
- `url` (string): 请求地址
|
- `url` (string): 请求地址
|
||||||
- `options` (object): 请求选项
|
- `options` (object): 请求选项
|
||||||
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
|
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
|
||||||
@@ -174,6 +172,7 @@ HTTP 请求方法,返回 Promise。
|
|||||||
- `timeout`: 超时时间(毫秒)
|
- `timeout`: 超时时间(毫秒)
|
||||||
|
|
||||||
**返回值:**
|
**返回值:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -210,12 +209,13 @@ cerumusic.NoticeCenter('info', {
|
|||||||
version: '版本号', // 当通知为update 版本跟新可传
|
version: '版本号', // 当通知为update 版本跟新可传
|
||||||
pluginInfo: {
|
pluginInfo: {
|
||||||
name: '插件名称',
|
name: '插件名称',
|
||||||
type: 'cr', // 固定唯一标识
|
type: 'cr' // 固定唯一标识
|
||||||
}// 当通知为update 版本跟新可传
|
} // 当通知为update 版本跟新可传
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**通知类型:**
|
**通知类型:**
|
||||||
|
|
||||||
- `'info'`: 信息通知
|
- `'info'`: 信息通知
|
||||||
- `'success'`: 成功通知
|
- `'success'`: 成功通知
|
||||||
- `'warn'`: 警告通知
|
- `'warn'`: 警告通知
|
||||||
@@ -247,46 +247,47 @@ const qualitys = {
|
|||||||
'128k': '128',
|
'128k': '128',
|
||||||
'320k': '320',
|
'320k': '320',
|
||||||
flac: 'flac',
|
flac: 'flac',
|
||||||
flac24bit: 'flac24bit',
|
flac24bit: 'flac24bit'
|
||||||
},
|
},
|
||||||
local: {},
|
local: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP 请求封装
|
// HTTP 请求封装
|
||||||
const httpRequest = (url, options) => new Promise((resolve, reject) => {
|
const httpRequest = (url, options) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
request(url, options, (err, resp) => {
|
request(url, options, (err, resp) => {
|
||||||
if (err) return reject(err)
|
if (err) return reject(err)
|
||||||
resolve(resp.body)
|
resolve(resp.body)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// API 实现
|
// API 实现
|
||||||
const apis = {
|
const apis = {
|
||||||
kw: {
|
kw: {
|
||||||
musicUrl({ songmid }, quality) {
|
musicUrl({ songmid }, quality) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
local: {
|
local: {
|
||||||
musicUrl(info) {
|
musicUrl(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
pic(info) {
|
pic(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
lyric(info) {
|
lyric(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return {
|
return {
|
||||||
lyric: '...', // 歌曲歌词
|
lyric: '...', // 歌曲歌词
|
||||||
tlyric: '...', // 翻译歌词,没有可为 null
|
tlyric: '...', // 翻译歌词,没有可为 null
|
||||||
rlyric: '...', // 罗马音歌词,没有可为 null
|
rlyric: '...', // 罗马音歌词,没有可为 null
|
||||||
lxlyric: '...', // lx 逐字歌词,没有可为 null
|
lxlyric: '...' // lx 逐字歌词,没有可为 null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,15 +314,15 @@ send(EVENT_NAMES.inited, {
|
|||||||
name: '酷我音乐',
|
name: '酷我音乐',
|
||||||
type: 'music',
|
type: 'music',
|
||||||
actions: ['musicUrl'],
|
actions: ['musicUrl'],
|
||||||
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
|
qualitys: ['128k', '320k', 'flac', 'flac24bit']
|
||||||
},
|
},
|
||||||
local: {
|
local: {
|
||||||
name: '本地音乐',
|
name: '本地音乐',
|
||||||
type: 'music',
|
type: 'music',
|
||||||
actions: ['musicUrl', 'lyric', 'pic'],
|
actions: ['musicUrl', 'lyric', 'pic'],
|
||||||
qualitys: [],
|
qualitys: []
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -342,8 +343,8 @@ send(EVENT_NAMES.inited, {
|
|||||||
```javascript
|
```javascript
|
||||||
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
|
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
|
||||||
// 必须返回 Promise
|
// 必须返回 Promise
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result)
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### globalThis.lx.send(eventName, data)
|
#### globalThis.lx.send(eventName, data)
|
||||||
@@ -369,18 +370,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
|
|||||||
HTTP 请求方法:
|
HTTP 请求方法:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
lx.request('https://api.example.com', {
|
lx.request(
|
||||||
|
'https://api.example.com',
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
}, (err, resp) => {
|
},
|
||||||
|
(err, resp) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('请求失败:', err);
|
console.error('请求失败:', err)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
console.log('响应:', resp.body);
|
console.log('响应:', resp.body)
|
||||||
});
|
}
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### globalThis.lx.utils
|
#### globalThis.lx.utils
|
||||||
@@ -433,28 +438,28 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
try {
|
try {
|
||||||
// 参数验证
|
// 参数验证
|
||||||
if (!musicInfo || !musicInfo.id) {
|
if (!musicInfo || !musicInfo.id) {
|
||||||
throw new Error('音乐信息不完整');
|
throw new Error('音乐信息不完整')
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 调用
|
// API 调用
|
||||||
const result = await cerumusic.request(url, options);
|
const result = await cerumusic.request(url, options)
|
||||||
|
|
||||||
// 结果验证
|
// 结果验证
|
||||||
if (!result || result.statusCode !== 200) {
|
if (!result || result.statusCode !== 200) {
|
||||||
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
|
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.body || !result.body.url) {
|
if (!result.body || !result.body.url) {
|
||||||
throw new Error('返回数据格式错误');
|
throw new Error('返回数据格式错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.body.url;
|
return result.body.url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 记录错误日志
|
// 记录错误日志
|
||||||
console.error(`[${source}] 获取音乐链接失败:`, error.message);
|
console.error(`[${source}] 获取音乐链接失败:`, error.message)
|
||||||
|
|
||||||
// 重新抛出错误供上层处理
|
// 重新抛出错误供上层处理
|
||||||
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
|
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -473,9 +478,9 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
### 1. 使用 console.log
|
### 1. 使用 console.log
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
console.log('[插件名] 调试信息:', data);
|
console.log('[插件名] 调试信息:', data)
|
||||||
console.warn('[插件名] 警告信息:', warning);
|
console.warn('[插件名] 警告信息:', warning)
|
||||||
console.error('[插件名] 错误信息:', error);
|
console.error('[插件名] 错误信息:', error)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. LX 插件开发者工具
|
### 2. LX 插件开发者工具
|
||||||
@@ -491,8 +496,8 @@ send(EVENT_NAMES.inited, {
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
console.error('未处理的 Promise 拒绝:', reason);
|
console.error('未处理的 Promise 拒绝:', reason)
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -502,17 +507,17 @@ process.on('unhandledRejection', (reason, promise) => {
|
|||||||
### 1. 请求缓存
|
### 1. 请求缓存
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const cache = new Map();
|
const cache = new Map()
|
||||||
|
|
||||||
async function getCachedData(key, fetcher, ttl = 300000) {
|
async function getCachedData(key, fetcher, ttl = 300000) {
|
||||||
const cached = cache.get(key);
|
const cached = cache.get(key)
|
||||||
if (cached && Date.now() - cached.timestamp < ttl) {
|
if (cached && Date.now() - cached.timestamp < ttl) {
|
||||||
return cached.data;
|
return cached.data
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetcher();
|
const data = await fetcher()
|
||||||
cache.set(key, { data, timestamp: Date.now() });
|
cache.set(key, { data, timestamp: Date.now() })
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -521,21 +526,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
|
|||||||
```javascript
|
```javascript
|
||||||
const result = await cerumusic.request(url, {
|
const result = await cerumusic.request(url, {
|
||||||
timeout: 10000 // 10秒超时
|
timeout: 10000 // 10秒超时
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 并发控制
|
### 3. 并发控制
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 限制并发请求数量
|
// 限制并发请求数量
|
||||||
const semaphore = new Semaphore(3); // 最多3个并发请求
|
const semaphore = new Semaphore(3) // 最多3个并发请求
|
||||||
|
|
||||||
async function limitedRequest(url, options) {
|
async function limitedRequest(url, options) {
|
||||||
await semaphore.acquire();
|
await semaphore.acquire()
|
||||||
try {
|
try {
|
||||||
return await cerumusic.request(url, options);
|
return await cerumusic.request(url, options)
|
||||||
} finally {
|
} finally {
|
||||||
semaphore.release();
|
semaphore.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -549,14 +554,14 @@ async function limitedRequest(url, options) {
|
|||||||
```javascript
|
```javascript
|
||||||
function validateMusicInfo(musicInfo) {
|
function validateMusicInfo(musicInfo) {
|
||||||
if (!musicInfo || typeof musicInfo !== 'object') {
|
if (!musicInfo || typeof musicInfo !== 'object') {
|
||||||
throw new Error('音乐信息格式错误');
|
throw new Error('音乐信息格式错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
|
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
|
||||||
throw new Error('音乐 ID 无效');
|
throw new Error('音乐 ID 无效')
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -565,10 +570,10 @@ function validateMusicInfo(musicInfo) {
|
|||||||
```javascript
|
```javascript
|
||||||
function isValidUrl(url) {
|
function isValidUrl(url) {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url)
|
||||||
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -581,7 +586,7 @@ console.log('请求参数:', {
|
|||||||
...params,
|
...params,
|
||||||
token: '***', // 隐藏敏感信息
|
token: '***', // 隐藏敏感信息
|
||||||
password: '***'
|
password: '***'
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -605,13 +610,13 @@ async function testMusicUrl() {
|
|||||||
id: 'test123',
|
id: 'test123',
|
||||||
name: '测试歌曲',
|
name: '测试歌曲',
|
||||||
artist: '测试歌手'
|
artist: '测试歌手'
|
||||||
};
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = await musicUrl('kw', testMusicInfo, '320k');
|
const url = await musicUrl('kw', testMusicInfo, '320k')
|
||||||
console.log('测试通过:', url);
|
console.log('测试通过:', url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('测试失败:', error);
|
console.error('测试失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -619,6 +624,7 @@ async function testMusicUrl() {
|
|||||||
### 3. 版本管理
|
### 3. 版本管理
|
||||||
|
|
||||||
使用语义化版本号:
|
使用语义化版本号:
|
||||||
|
|
||||||
- `1.0.0`: 主版本.次版本.修订版本
|
- `1.0.0`: 主版本.次版本.修订版本
|
||||||
- 主版本:不兼容的 API 修改
|
- 主版本:不兼容的 API 修改
|
||||||
- 次版本:向下兼容的功能性新增
|
- 次版本:向下兼容的功能性新增
|
||||||
@@ -631,6 +637,7 @@ async function testMusicUrl() {
|
|||||||
### Q: 插件加载失败怎么办?
|
### Q: 插件加载失败怎么办?
|
||||||
|
|
||||||
A: 检查以下几点:
|
A: 检查以下几点:
|
||||||
|
|
||||||
1. 文件编码是否为 UTF-8
|
1. 文件编码是否为 UTF-8
|
||||||
2. 插件信息注释格式是否正确
|
2. 插件信息注释格式是否正确
|
||||||
3. JavaScript 语法是否有错误
|
3. JavaScript 语法是否有错误
|
||||||
@@ -645,13 +652,13 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
|
|||||||
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
|
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
cerumusic.NoticeCenter('update',{
|
cerumusic.NoticeCenter('update', {
|
||||||
title:'新版本更新',
|
title: '新版本更新',
|
||||||
content:'xxxx',
|
content: 'xxxx',
|
||||||
version: 'v1.0.3',
|
version: 'v1.0.3',
|
||||||
url:'https://shiqianjiang.cn',
|
url: 'https://shiqianjiang.cn',
|
||||||
pluginInfo:{
|
pluginInfo: {
|
||||||
type:'cr'
|
type: 'cr'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -659,6 +666,7 @@ cerumusic.NoticeCenter('update',{
|
|||||||
### Q: 如何调试插件?
|
### Q: 如何调试插件?
|
||||||
|
|
||||||
A:
|
A:
|
||||||
|
|
||||||
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
|
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
|
||||||
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
|
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
|
||||||
3. 查看 CeruMusic 的插件日志
|
3. 查看 CeruMusic 的插件日志
|
||||||
@@ -668,5 +676,6 @@ A:
|
|||||||
## 技术支持
|
## 技术支持
|
||||||
|
|
||||||
如有问题或建议,请通过以下方式联系:
|
如有问题或建议,请通过以下方式联系:
|
||||||
|
|
||||||
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
|
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
|
||||||
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)
|
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)
|
||||||
|
|||||||
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8
docs/guide/sponsorship.md
Normal file
8
docs/guide/sponsorship.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 赞助名单
|
||||||
|
|
||||||
|
## 鸣谢
|
||||||
|
|
||||||
|
| 昵称 | 赞助金额 |
|
||||||
|
| :------------------------: | :------: |
|
||||||
|
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
||||||
|
|
||||||
@@ -8,5 +8,8 @@
|
|||||||
- [ ] ai功能完善
|
- [ ] ai功能完善
|
||||||
- [ ] 支持歌词隐藏
|
- [ ] 支持歌词隐藏
|
||||||
- [x] 兼容多平台歌单导入
|
- [x] 兼容多平台歌单导入
|
||||||
- [ ] 软件能不能记住上次打开的窗口大小,每次都要手动拉
|
- [x] 软件能不能记住上次打开的窗口大小,每次都要手动拉
|
||||||
|
- [x] 歌单右键菜单
|
||||||
|
- [x] 播放列表滚动条适配
|
||||||
|
- [ ] 暗色主题
|
||||||
|
- [x] 歌单页支持修改封面
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
# 澜音版本更新日志
|
# 澜音版本更新日志
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 日志
|
## 日志
|
||||||
|
|
||||||
- ###### 2025-9-17 **(V1.3.2)**
|
- ###### 2025-9-26 (v1.3.8)
|
||||||
|
|
||||||
|
1. 写入歌曲tag信息
|
||||||
|
2. 歌曲下载 选择音质
|
||||||
|
3. 歌单 头部自动压缩
|
||||||
|
|
||||||
|
- ###### 2025-9-25 (v1.3.7)
|
||||||
|
|
||||||
|
1. 歌单
|
||||||
|
- 新增右键移除歌曲
|
||||||
|
- local 页歌单右键操作
|
||||||
|
- 歌单页支持修改封面
|
||||||
|
2. debug:右键菜单二级菜单位置决策
|
||||||
|
|
||||||
|
- ###### 2025-9-22 (v1.3.6)
|
||||||
|
|
||||||
|
1. 歌单列表可以右键操作
|
||||||
|
- 播放
|
||||||
|
- 下载
|
||||||
|
- 添加到歌单
|
||||||
|
- 添加到播放列表
|
||||||
|
2. 播放列表滚动条
|
||||||
|
3. 搜索页切换源重新加载
|
||||||
|
|
||||||
|
- ###### 2025-9-22 (v1.3.5)
|
||||||
|
|
||||||
|
1. 软件启动位置 宽高记忆 限制软件最大宽高
|
||||||
|
2. debug: 修复歌曲音质支持短缺问题
|
||||||
|
|
||||||
|
- ###### 2025-9-21 (v1.3.4)
|
||||||
|
1. 紧急修复QQ音乐歌词失效问题
|
||||||
|
|
||||||
|
- ###### 2025-9-21(v1.3.3)
|
||||||
|
1. 兼容多平台歌单导入
|
||||||
|
2. 点击搜索框的 源图标实现快速切换
|
||||||
|
3. debug: fix:列表删除按钮冒泡
|
||||||
|
|
||||||
|
- ###### 2025-9-17 **(v1.3.2)**
|
||||||
1. 目录结构调整
|
1. 目录结构调整
|
||||||
|
|
||||||
2. **支持插件更新提示**
|
2. **支持插件更新提示**
|
||||||
@@ -13,13 +47,11 @@
|
|||||||
**洛雪** 插件请手动重装适配
|
**洛雪** 插件请手动重装适配
|
||||||
|
|
||||||
3. **debug**
|
3. **debug**
|
||||||
|
|
||||||
- SMTC 问题
|
- SMTC 问题
|
||||||
|
|
||||||
- 歌曲缓存播放多次请求和多次缓存问题
|
- 歌曲缓存播放多次请求和多次缓存问题
|
||||||
|
|
||||||
- ###### 2025-9-17 **(V1.3.1)**
|
- ###### 2025-9-17 **(v1.3.1)**
|
||||||
|
|
||||||
1. **设置功能页**
|
1. **设置功能页**
|
||||||
- 缓存路径支持自定义
|
- 缓存路径支持自定义
|
||||||
- 下载路径支持自定义
|
- 下载路径支持自定义
|
||||||
|
|||||||
@@ -23,6 +23,4 @@
|
|||||||
|
|
||||||
歌单将自动选取第一首 **有效封面**[^1] 为歌单
|
歌单将自动选取第一首 **有效封面**[^1] 为歌单
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[^1]: url正确的歌曲封面
|
[^1]: url正确的歌曲封面
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
// 这个文件可以用来测试 NoticeCenter 功能
|
// 这个文件可以用来测试 NoticeCenter 功能
|
||||||
|
|
||||||
const pluginInfo = {
|
const pluginInfo = {
|
||||||
name: "测试通知插件",
|
name: '测试通知插件',
|
||||||
version: "1.0.0",
|
version: '1.0.0',
|
||||||
author: "CeruMusic Team",
|
author: 'CeruMusic Team',
|
||||||
description: "用于测试插件通知功能的示例插件",
|
description: '用于测试插件通知功能的示例插件',
|
||||||
type: "cr"
|
type: 'cr'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
name: "test",
|
name: 'test',
|
||||||
qualities: ["128k", "320k"]
|
qualities: ['128k', '320k']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ this.cerumusic.NoticeCenter('update', {
|
|||||||
#### 通用参数 (data 对象)
|
#### 通用参数 (data 对象)
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ------- | ------ | ---- | ------------------------------ |
|
||||||
| title | string | 否 | 通知标题,不提供时使用默认标题 |
|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
|
||||||
| message | string | 否 | 通知消息内容 |
|
| message | string | 否 | 通知消息内容 |
|
||||||
| content | string | 否 | 详细内容(与 message 二选一) |
|
| content | string | 否 | 详细内容(与 message 二选一) |
|
||||||
@@ -90,7 +90,7 @@ this.cerumusic.NoticeCenter('update', {
|
|||||||
#### 更新通知特有参数
|
#### 更新通知特有参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ----------------------- | ------------ | ---- | ---------------- |
|
||||||
| url | string | 是 | 插件更新下载链接 |
|
| url | string | 是 | 插件更新下载链接 |
|
||||||
| version | string | 否 | 新版本号 |
|
| version | string | 否 | 新版本号 |
|
||||||
| pluginInfo.name | string | 否 | 插件名称 |
|
| pluginInfo.name | string | 否 | 插件名称 |
|
||||||
@@ -100,7 +100,7 @@ this.cerumusic.NoticeCenter('update', {
|
|||||||
#### 错误通知特有参数
|
#### 错误通知特有参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ----- | ------ | ---- | ------------ |
|
||||||
| error | string | 否 | 具体错误信息 |
|
| error | string | 否 | 具体错误信息 |
|
||||||
|
|
||||||
## 实现原理
|
## 实现原理
|
||||||
@@ -208,6 +208,7 @@ window.api.on('plugin-notice', (_, notice) => {
|
|||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
### v1.0.0 (2025-09-20)
|
### v1.0.0 (2025-09-20)
|
||||||
|
|
||||||
- ✨ 初始版本发布
|
- ✨ 初始版本发布
|
||||||
- ✨ 支持 5 种通知类型
|
- ✨ 支持 5 种通知类型
|
||||||
- ✨ 完整的 TypeScript 类型定义
|
- ✨ 完整的 TypeScript 类型定义
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ files:
|
|||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
|
- node_modules/ffmpeg-static/**
|
||||||
win:
|
win:
|
||||||
executableName: ceru-music
|
executableName: ceru-music
|
||||||
icon: 'resources/icons/icon.ico'
|
icon: 'resources/icons/icon.ico'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.3.5",
|
"version": "1.3.9",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/needle": "^3.3.0",
|
"@types/needle": "^3.3.0",
|
||||||
"NeteaseCloudMusicApi": "^4.27.0",
|
"NeteaseCloudMusicApi": "^4.27.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
@@ -53,6 +54,8 @@
|
|||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"hpagent": "^1.2.0",
|
"hpagent": "^1.2.0",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
@@ -63,7 +66,9 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"needle": "^3.3.1",
|
"needle": "^3.3.1",
|
||||||
"node-fetch": "2",
|
"node-fetch": "2",
|
||||||
|
"node-id3": "^0.2.9",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"tdesign-icons-vue-next": "^0.4.1",
|
||||||
"tdesign-vue-next": "^1.15.2",
|
"tdesign-vue-next": "^1.15.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"zlib": "^1.0.5"
|
"zlib": "^1.0.5"
|
||||||
|
|||||||
9582
qodana.sarif.json
9582
qodana.sarif.json
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
version: "1.0"
|
version: '1.0'
|
||||||
profile:
|
profile:
|
||||||
name: qodana.starter
|
name: qodana.starter
|
||||||
|
|||||||
@@ -1,55 +1,79 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs')
|
||||||
const path = require('path');
|
const path = require('path')
|
||||||
|
|
||||||
function generateTree(dir, prefix = '', isLast = true, excludeDirs = ['node_modules', 'dist', 'out', '.git','.kiro','.idea','.codebuddy','.vscode','.workflow','assets','resources','docs']) {
|
function generateTree(
|
||||||
const basename = path.basename(dir);
|
dir,
|
||||||
|
prefix = '',
|
||||||
|
isLast = true,
|
||||||
|
excludeDirs = [
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'out',
|
||||||
|
'.git',
|
||||||
|
'.kiro',
|
||||||
|
'.idea',
|
||||||
|
'.codebuddy',
|
||||||
|
'.vscode',
|
||||||
|
'.workflow',
|
||||||
|
'assets',
|
||||||
|
'resources',
|
||||||
|
'docs'
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const basename = path.basename(dir)
|
||||||
|
|
||||||
// 跳过排除的目录和隐藏文件
|
// 跳过排除的目录和隐藏文件
|
||||||
if (basename.startsWith('.') && basename !== '.' && basename !== '..' && !['.github', '.workflow'].includes(basename)) {
|
if (
|
||||||
return;
|
basename.startsWith('.') &&
|
||||||
|
basename !== '.' &&
|
||||||
|
basename !== '..' &&
|
||||||
|
!['.github', '.workflow'].includes(basename)
|
||||||
|
) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (excludeDirs.includes(basename)) {
|
if (excludeDirs.includes(basename)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前项目显示
|
// 当前项目显示
|
||||||
if (prefix === '') {
|
if (prefix === '') {
|
||||||
console.log(`${basename}/`);
|
console.log(`${basename}/`)
|
||||||
} else {
|
} else {
|
||||||
const connector = isLast ? '└── ' : '├── ';
|
const connector = isLast ? '└── ' : '├── '
|
||||||
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
|
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
|
||||||
console.log(prefix + connector + displayName);
|
console.log(prefix + connector + displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.statSync(dir).isDirectory()) {
|
if (!fs.statSync(dir).isDirectory()) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = fs.readdirSync(dir)
|
const items = fs
|
||||||
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
|
.readdirSync(dir)
|
||||||
.filter(item => !excludeDirs.includes(item))
|
.filter((item) => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
|
||||||
|
.filter((item) => !excludeDirs.includes(item))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// 目录排在前面,文件排在后面
|
// 目录排在前面,文件排在后面
|
||||||
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
|
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory()
|
||||||
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
|
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory()
|
||||||
if (aIsDir && !bIsDir) return -1;
|
if (aIsDir && !bIsDir) return -1
|
||||||
if (!aIsDir && bIsDir) return 1;
|
if (!aIsDir && bIsDir) return 1
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b)
|
||||||
});
|
})
|
||||||
|
|
||||||
const newPrefix = prefix + (isLast ? ' ' : '│ ');
|
const newPrefix = prefix + (isLast ? ' ' : '│ ')
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const isLastItem = index === items.length - 1;
|
const isLastItem = index === items.length - 1
|
||||||
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
|
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading directory: ${dir}`, error.message);
|
console.error(`Error reading directory: ${dir}`, error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用示例
|
// 使用示例
|
||||||
const targetDir = process.argv[2] || '.';
|
const targetDir = process.argv[2] || '.'
|
||||||
console.log('项目文件结构:');
|
console.log('项目文件结构:')
|
||||||
generateTree(targetDir);
|
generateTree(targetDir)
|
||||||
|
|||||||
@@ -19,9 +19,416 @@ import axios from 'axios'
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { configManager } from '../ConfigManager'
|
import { configManager } from '../ConfigManager'
|
||||||
|
import NodeID3 from 'node-id3'
|
||||||
|
import ffmpegStatic from 'ffmpeg-static'
|
||||||
|
import ffmpeg from 'fluent-ffmpeg'
|
||||||
|
|
||||||
const fileLock: Record<string, boolean> = {}
|
const fileLock: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换LRC格式
|
||||||
|
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
|
||||||
|
* @param lrcContent 原始LRC内容
|
||||||
|
* @returns 转换后的LRC内容
|
||||||
|
*/
|
||||||
|
function convertLrcFormat(lrcContent: string): string {
|
||||||
|
if (!lrcContent) return ''
|
||||||
|
|
||||||
|
const lines = lrcContent.split('\n')
|
||||||
|
const convertedLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 跳过空行
|
||||||
|
if (!line.trim()) {
|
||||||
|
convertedLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||||
|
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
|
||||||
|
if (newFormatMatch) {
|
||||||
|
const [, startTimeMs, , content] = newFormatMatch
|
||||||
|
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
|
||||||
|
convertedLines.push(convertedLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||||
|
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
|
||||||
|
if (oldFormatMatch) {
|
||||||
|
const [, timestamp, content] = oldFormatMatch
|
||||||
|
|
||||||
|
// 如果内容中没有位置信息,直接返回原行
|
||||||
|
if (!content.includes('(') || !content.includes(')')) {
|
||||||
|
convertedLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertedLine = convertOldFormat(timestamp, content)
|
||||||
|
convertedLines.push(convertedLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他行直接保留
|
||||||
|
convertedLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
|
||||||
|
* @param timeMs 毫秒时间戳
|
||||||
|
* @returns 格式化的时间字符串
|
||||||
|
*/
|
||||||
|
function formatTimestamp(timeMs: number): string {
|
||||||
|
const minutes = Math.floor(timeMs / 60000)
|
||||||
|
const seconds = Math.floor((timeMs % 60000) / 1000)
|
||||||
|
const milliseconds = timeMs % 1000
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||||
|
*/
|
||||||
|
function convertNewFormat(baseTimeMs: number, content: string): string {
|
||||||
|
const baseTimestamp = formatTimestamp(baseTimeMs)
|
||||||
|
let convertedContent = `<${baseTimestamp}>`
|
||||||
|
|
||||||
|
// 匹配模式:(开始时间,字符持续时间,0)字符
|
||||||
|
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
|
||||||
|
let match
|
||||||
|
let isFirstChar = true
|
||||||
|
|
||||||
|
while ((match = charPattern.exec(content)) !== null) {
|
||||||
|
const [, charStartMs, , , char] = match
|
||||||
|
const charTimeMs = parseInt(charStartMs)
|
||||||
|
const charTimestamp = formatTimestamp(charTimeMs)
|
||||||
|
|
||||||
|
if (isFirstChar) {
|
||||||
|
// 第一个字符直接添加
|
||||||
|
convertedContent += char.trim()
|
||||||
|
isFirstChar = false
|
||||||
|
} else {
|
||||||
|
convertedContent += `<${charTimestamp}>${char.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${baseTimestamp}]${convertedContent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||||
|
*/
|
||||||
|
function convertOldFormat(timestamp: string, content: string): string {
|
||||||
|
// 解析基础时间戳(毫秒)
|
||||||
|
const [minutes, seconds] = timestamp.split(':')
|
||||||
|
const [sec, ms] = seconds.split('.')
|
||||||
|
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
|
||||||
|
|
||||||
|
let convertedContent = `<${timestamp}>`
|
||||||
|
|
||||||
|
// 匹配所有字符(偏移,持续时间)的模式
|
||||||
|
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
|
||||||
|
let match
|
||||||
|
let lastIndex = 0
|
||||||
|
let isFirstChar = true
|
||||||
|
|
||||||
|
while ((match = charPattern.exec(content)) !== null) {
|
||||||
|
const [fullMatch, char, offsetMs, _durationMs] = match
|
||||||
|
const charTimeMs = baseTimeMs + parseInt(offsetMs)
|
||||||
|
const charTimestamp = formatTimestamp(charTimeMs)
|
||||||
|
|
||||||
|
// 添加匹配前的普通文本
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const beforeText = content.substring(lastIndex, match.index)
|
||||||
|
if (beforeText.trim()) {
|
||||||
|
convertedContent += beforeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加带时间戳的字符
|
||||||
|
if (isFirstChar) {
|
||||||
|
// 第一个字符直接添加,不需要额外的时间戳
|
||||||
|
convertedContent += char
|
||||||
|
isFirstChar = false
|
||||||
|
} else {
|
||||||
|
convertedContent += `<${charTimestamp}>${char}`
|
||||||
|
}
|
||||||
|
lastIndex = match.index + fullMatch.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余的普通文本
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
const remainingText = content.substring(lastIndex)
|
||||||
|
if (remainingText.trim()) {
|
||||||
|
convertedContent += remainingText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${timestamp}]${convertedContent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入音频标签的辅助函数
|
||||||
|
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
try {
|
||||||
|
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
|
||||||
|
|
||||||
|
// 获取文件扩展名来判断格式
|
||||||
|
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
|
||||||
|
console.log('文件格式:', fileExtension)
|
||||||
|
|
||||||
|
// 根据文件格式选择不同的标签写入方法
|
||||||
|
if (fileExtension === 'mp3') {
|
||||||
|
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
|
||||||
|
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
|
||||||
|
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else {
|
||||||
|
console.warn('不支持的音频格式:', fileExtension)
|
||||||
|
// 尝试使用 NodeID3 作为后备方案
|
||||||
|
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入音频标签时发生错误:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP3 格式标签写入
|
||||||
|
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
const tags: any = {}
|
||||||
|
|
||||||
|
// 写入基础信息
|
||||||
|
if (tagWriteOptions.basicInfo) {
|
||||||
|
tags.title = songInfo.name || ''
|
||||||
|
tags.artist = songInfo.singer || ''
|
||||||
|
tags.album = songInfo.albumName || ''
|
||||||
|
tags.year = songInfo.year || ''
|
||||||
|
tags.genre = songInfo.genre || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入歌词
|
||||||
|
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||||
|
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||||
|
tags.unsynchronisedLyrics = {
|
||||||
|
language: 'chi',
|
||||||
|
shortText: 'Lyrics',
|
||||||
|
text: convertedLrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入封面
|
||||||
|
if (tagWriteOptions.cover && songInfo.img) {
|
||||||
|
try {
|
||||||
|
const coverResponse = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: songInfo.img,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coverResponse.data) {
|
||||||
|
tags.image = {
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
type: {
|
||||||
|
id: 3,
|
||||||
|
name: 'front cover'
|
||||||
|
},
|
||||||
|
description: 'Cover',
|
||||||
|
imageBuffer: Buffer.from(coverResponse.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (coverError) {
|
||||||
|
console.warn('获取封面失败:', coverError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入标签到文件
|
||||||
|
if (Object.keys(tags).length > 0) {
|
||||||
|
const success = NodeID3.write(tags, filePath)
|
||||||
|
if (success) {
|
||||||
|
console.log('MP3音频标签写入成功:', filePath)
|
||||||
|
} else {
|
||||||
|
console.warn('MP3音频标签写入失败:', filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
|
||||||
|
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
try {
|
||||||
|
console.log('开始写入 FLAC 标签:', filePath)
|
||||||
|
|
||||||
|
// 准备新的标签数据
|
||||||
|
const newTags: any = {}
|
||||||
|
|
||||||
|
// 写入基础信息
|
||||||
|
if (tagWriteOptions.basicInfo) {
|
||||||
|
if (songInfo.name) newTags.TITLE = songInfo.name
|
||||||
|
if (songInfo.singer) newTags.ARTIST = songInfo.singer
|
||||||
|
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
|
||||||
|
if (songInfo.year) newTags.DATE = songInfo.year.toString()
|
||||||
|
if (songInfo.genre) newTags.GENRE = songInfo.genre
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入歌词
|
||||||
|
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||||
|
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||||
|
newTags.LYRICS = convertedLrc
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('准备写入的标签:', newTags)
|
||||||
|
|
||||||
|
// 使用 ffmpeg-static 写入 FLAC 标签
|
||||||
|
if (path.extname(filePath).toLowerCase() === '.flac') {
|
||||||
|
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
|
||||||
|
} else {
|
||||||
|
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入 Vorbis Comment 标签失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 fluent-ffmpeg 写入 FLAC 标签
|
||||||
|
async function writeFLACTagsWithFFmpeg(
|
||||||
|
filePath: string,
|
||||||
|
tags: any,
|
||||||
|
songInfo: any,
|
||||||
|
tagWriteOptions: any
|
||||||
|
) {
|
||||||
|
let tempOutputPath: string | null = null
|
||||||
|
let tempCoverPath: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!ffmpegStatic) {
|
||||||
|
throw new Error('ffmpeg-static 不可用')
|
||||||
|
}
|
||||||
|
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
|
||||||
|
|
||||||
|
// 创建临时输出文件
|
||||||
|
tempOutputPath = filePath + '.temp.flac'
|
||||||
|
|
||||||
|
// 创建 fluent-ffmpeg 实例
|
||||||
|
let command = ffmpeg(filePath)
|
||||||
|
.audioCodec('copy') // 复制音频编解码器,不重新编码
|
||||||
|
.output(tempOutputPath)
|
||||||
|
|
||||||
|
// 添加元数据标签
|
||||||
|
for (const [key, value] of Object.entries(tags)) {
|
||||||
|
if (value) {
|
||||||
|
// fluent-ffmpeg 会自动处理特殊字符转义
|
||||||
|
command = command.outputOptions(['-metadata', `${key}=${value}`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理封面
|
||||||
|
if (tagWriteOptions.cover && songInfo.img) {
|
||||||
|
try {
|
||||||
|
console.log('开始下载封面:', songInfo.img)
|
||||||
|
const coverResponse = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: songInfo.img,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coverResponse.data) {
|
||||||
|
// 保存临时封面文件
|
||||||
|
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
|
||||||
|
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
|
||||||
|
|
||||||
|
// 添加封面作为输入
|
||||||
|
command = command.input(tempCoverPath).outputOptions([
|
||||||
|
'-map',
|
||||||
|
'0:a', // 映射原始文件的音频流
|
||||||
|
'-map',
|
||||||
|
'1:v', // 映射封面的视频流
|
||||||
|
'-c:v',
|
||||||
|
'copy', // 复制视频编解码器
|
||||||
|
'-disposition:v:0',
|
||||||
|
'attached_pic' // 设置为附加图片
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('封面已添加到命令中')
|
||||||
|
}
|
||||||
|
} catch (coverError) {
|
||||||
|
console.warn(
|
||||||
|
'下载封面失败,跳过封面写入:',
|
||||||
|
coverError instanceof Error ? coverError.message : coverError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 ffmpeg 命令
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
command
|
||||||
|
.on('start', () => {
|
||||||
|
console.log('执行 ffmpeg 命令')
|
||||||
|
})
|
||||||
|
.on('progress', (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log('处理进度:', Math.round(progress.percent) + '%')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
console.log('ffmpeg 处理完成')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.on('error', (err, _, stderr) => {
|
||||||
|
console.error('ffmpeg 错误:', err.message)
|
||||||
|
if (stderr) {
|
||||||
|
console.error('ffmpeg stderr:', stderr)
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查临时文件是否创建成功
|
||||||
|
if (!fs.existsSync(tempOutputPath)) {
|
||||||
|
throw new Error('ffmpeg 未能创建输出文件')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换原文件
|
||||||
|
await fsPromise.rename(tempOutputPath, filePath)
|
||||||
|
tempOutputPath = null // 标记已处理,避免重复清理
|
||||||
|
|
||||||
|
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 清理所有临时文件
|
||||||
|
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
for (const tempFile of filesToClean) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempFile)) {
|
||||||
|
await fsPromise.unlink(tempFile)
|
||||||
|
console.log('已清理临时文件:', tempFile)
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(
|
||||||
|
'清理临时文件失败:',
|
||||||
|
tempFile,
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : cleanupError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4/M4A 格式标签写入
|
||||||
|
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
|
||||||
|
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
|
||||||
|
// 可以使用 ffmpeg 或其他工具实现
|
||||||
|
}
|
||||||
|
|
||||||
function main(source: string) {
|
function main(source: string) {
|
||||||
const Api = musicSdk[source]
|
const Api = musicSdk[source]
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +498,12 @@ function main(source: string) {
|
|||||||
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
|
async downloadSingleSong({
|
||||||
|
pluginId,
|
||||||
|
songInfo,
|
||||||
|
quality,
|
||||||
|
tagWriteOptions
|
||||||
|
}: DownloadSingleSongArgs) {
|
||||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||||
|
|
||||||
@@ -167,6 +579,16 @@ function main(source: string) {
|
|||||||
delete fileLock[songPath]
|
delete fileLock[songPath]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 写入标签信息
|
||||||
|
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||||
|
try {
|
||||||
|
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('写入音频标签失败:', error)
|
||||||
|
throw ffmpegStatic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: '下载成功',
|
message: '下载成功',
|
||||||
path: songPath
|
path: songPath
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
|
|||||||
info: PlaylistInfo
|
info: PlaylistInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagWriteOptions {
|
||||||
|
basicInfo?: boolean
|
||||||
|
cover?: boolean
|
||||||
|
lyrics?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
||||||
path?: string
|
path?: string
|
||||||
|
tagWriteOptions?: TagWriteOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ export default {
|
|||||||
lineTime2: /^\[([\d:.]+)\]/,
|
lineTime2: /^\[([\d:.]+)\]/,
|
||||||
wordTime: /\(\d+,\d+,\d+\)/,
|
wordTime: /\(\d+,\d+,\d+\)/,
|
||||||
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
||||||
timeLabelFixRxp: /(?:\.0+|0+)$/,
|
timeLabelFixRxp: /(?:\.0+|0+)$/
|
||||||
},
|
},
|
||||||
msFormat(timeMs) {
|
msFormat(timeMs) {
|
||||||
if (Number.isNaN(timeMs)) return ''
|
if (Number.isNaN(timeMs)) return ''
|
||||||
let ms = timeMs % 1000
|
let ms = timeMs % 1000
|
||||||
timeMs /= 1000
|
timeMs /= 1000
|
||||||
let m = parseInt(timeMs / 60).toString().padStart(2, '0')
|
let m = parseInt(timeMs / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')
|
||||||
timeMs %= 60
|
timeMs %= 60
|
||||||
let s = parseInt(timeMs).toString().padStart(2, '0')
|
let s = parseInt(timeMs).toString().padStart(2, '0')
|
||||||
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
||||||
@@ -78,7 +80,7 @@ export default {
|
|||||||
let currentStart = startMsTime
|
let currentStart = startMsTime
|
||||||
const processedTimes = []
|
const processedTimes = []
|
||||||
|
|
||||||
times.forEach(time => {
|
times.forEach((time) => {
|
||||||
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
|
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
|
||||||
const duration = parseInt(result[2])
|
const duration = parseInt(result[2])
|
||||||
processedTimes.push(`(${currentStart},${duration},0)`)
|
processedTimes.push(`(${currentStart},${duration},0)`)
|
||||||
@@ -91,7 +93,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
lyric: lrcLines.join('\n'),
|
lyric: lrcLines.join('\n'),
|
||||||
lxlyric: lxlrcLines.join('\n'),
|
lxlyric: lxlrcLines.join('\n')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getIntv(interval) {
|
getIntv(interval) {
|
||||||
@@ -171,8 +173,7 @@ export default {
|
|||||||
lyric: '',
|
lyric: '',
|
||||||
tlyric: '',
|
tlyric: '',
|
||||||
rlyric: '',
|
rlyric: '',
|
||||||
crlyric: '',
|
crlyric: ''
|
||||||
|
|
||||||
}
|
}
|
||||||
if (lrc) {
|
if (lrc) {
|
||||||
let { lyric } = this.parseCeru(this.removeTag(lrc))
|
let { lyric } = this.parseCeru(this.removeTag(lrc))
|
||||||
@@ -208,11 +209,7 @@ export default {
|
|||||||
return lrcLines.join('\n')
|
return lrcLines.join('\n')
|
||||||
},
|
},
|
||||||
parseLyric(lrc, tlrc, rlrc) {
|
parseLyric(lrc, tlrc, rlrc) {
|
||||||
return this.parse(
|
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
|
||||||
decode(lrc),
|
|
||||||
decode(tlrc),
|
|
||||||
decode(rlrc)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
getLyric(mInfo, retryNum = 0) {
|
getLyric(mInfo, retryNum = 0) {
|
||||||
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import zlib from 'zlib'
|
import zlib from 'zlib'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
|||||||
8
src/preload/index.d.ts
vendored
8
src/preload/index.d.ts
vendored
@@ -96,10 +96,7 @@ interface CustomAPI {
|
|||||||
path?: string
|
path?: string
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
saveDirectories: (directories: {
|
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
|
||||||
cacheDir: string
|
|
||||||
downloadDir: string
|
|
||||||
}) => Promise<{
|
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
}>
|
}>
|
||||||
@@ -119,14 +116,13 @@ interface CustomAPI {
|
|||||||
size: number
|
size: number
|
||||||
formatted: string
|
formatted: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户配置API
|
// 用户配置API
|
||||||
getUserConfig: () => Promise<any>
|
getUserConfig: () => Promise<any>
|
||||||
|
|
||||||
pluginNotice: {
|
pluginNotice: {
|
||||||
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
|
onPluginNotice: (listener: (...args: any[]) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
src/renderer/components.d.ts
vendored
3
src/renderer/components.d.ts
vendored
@@ -10,6 +10,8 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
|
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
|
||||||
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
|
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
|
||||||
|
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
|
||||||
|
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
|
||||||
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
|
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
|
||||||
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
|
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
|
||||||
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
|
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
|
||||||
@@ -30,6 +32,7 @@ declare module 'vue' {
|
|||||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
TBadge: typeof import('tdesign-vue-next')['Badge']
|
||||||
TButton: typeof import('tdesign-vue-next')['Button']
|
TButton: typeof import('tdesign-vue-next')['Button']
|
||||||
TCard: typeof import('tdesign-vue-next')['Card']
|
TCard: typeof import('tdesign-vue-next')['Card']
|
||||||
|
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
|
||||||
TContent: typeof import('tdesign-vue-next')['Content']
|
TContent: typeof import('tdesign-vue-next')['Content']
|
||||||
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
||||||
TDivider: typeof import('tdesign-vue-next')['Divider']
|
TDivider: typeof import('tdesign-vue-next')['Divider']
|
||||||
|
|||||||
@@ -77,6 +77,6 @@ body {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||||
}
|
}
|
||||||
.t-dialog__mask{
|
.t-dialog__mask {
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|||||||
811
src/renderer/src/components/ContextMenu/ContextMenu.vue
Normal file
811
src/renderer/src/components/ContextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="visible" to="body">
|
||||||
|
<!-- 遮罩层 -->
|
||||||
|
<div
|
||||||
|
class="context-menu-backdrop"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
@contextmenu="handleBackdropContextMenu"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- 右键菜单容器 -->
|
||||||
|
<div
|
||||||
|
ref="menuRef"
|
||||||
|
class="context-menu"
|
||||||
|
:class="[className, { 'context-menu--scrolling': isScrolling }]"
|
||||||
|
:style="menuStyle"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
>
|
||||||
|
<!-- 菜单项列表容器 -->
|
||||||
|
<div
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="context-menu__scroll-container"
|
||||||
|
:style="scrollContainerStyle"
|
||||||
|
>
|
||||||
|
<!-- 菜单项列表 -->
|
||||||
|
<ul class="context-menu__list">
|
||||||
|
<li
|
||||||
|
v-for="item in visibleItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="context-menu__item"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'context-menu__item--disabled': item.disabled,
|
||||||
|
'context-menu__item--separator': item.separator,
|
||||||
|
'context-menu__item--has-children': item.children && item.children.length > 0
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
]"
|
||||||
|
@mouseenter="handleItemMouseEnter(item, $event)"
|
||||||
|
@mouseleave="handleItemMouseLeave(item)"
|
||||||
|
@click="handleItemClick(item, $event)"
|
||||||
|
>
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div v-if="item.separator" class="context-menu__separator"></div>
|
||||||
|
|
||||||
|
<!-- 普通菜单项 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 图标 -->
|
||||||
|
<div v-if="item.icon" class="context-menu__icon">
|
||||||
|
<component :is="item.icon" size="16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签 -->
|
||||||
|
<span class="context-menu__label">{{ item.label }}</span>
|
||||||
|
|
||||||
|
<!-- 子菜单箭头 -->
|
||||||
|
<div v-if="item.children && item.children.length > 0" class="context-menu__arrow">
|
||||||
|
<chevron-right-icon
|
||||||
|
:fill-color="'transparent'"
|
||||||
|
:stroke-color="'#000000'"
|
||||||
|
:stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 滚动指示器 -->
|
||||||
|
<div v-if="showScrollIndicator" class="context-menu__scroll-indicator">
|
||||||
|
<div
|
||||||
|
class="context-menu__scroll-indicator-top"
|
||||||
|
:class="{ 'context-menu__scroll-indicator--visible': canScrollUp }"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="context-menu__scroll-indicator-bottom"
|
||||||
|
:class="{ 'context-menu__scroll-indicator--visible': canScrollDown }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子菜单 -->
|
||||||
|
<div
|
||||||
|
v-if="activeSubmenu"
|
||||||
|
class="context-menu__submenu-wrapper"
|
||||||
|
:style="submenuWrapperStyle"
|
||||||
|
@mouseenter="handleSubmenuMouseEnter"
|
||||||
|
@mouseleave="handleSubmenuMouseLeave"
|
||||||
|
>
|
||||||
|
<ContextMenu
|
||||||
|
ref="submenuRef"
|
||||||
|
:visible="true"
|
||||||
|
:position="submenuPosition"
|
||||||
|
:items="activeSubmenu.children || []"
|
||||||
|
:width="width"
|
||||||
|
:max-height="Math.min(maxHeight, 300)"
|
||||||
|
:z-index="zIndex + 1"
|
||||||
|
@item-click="handleSubmenuItemClick"
|
||||||
|
@close="closeSubmenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted, type CSSProperties } from 'vue'
|
||||||
|
import type {
|
||||||
|
ContextMenuProps,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuPosition,
|
||||||
|
EdgeDetectionConfig,
|
||||||
|
AnimationConfig,
|
||||||
|
ScrollConfig
|
||||||
|
} from './types'
|
||||||
|
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
|
||||||
|
threshold: 10,
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ANIMATION_CONFIG: AnimationConfig = {
|
||||||
|
duration: 200,
|
||||||
|
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_CONFIG: ScrollConfig = {
|
||||||
|
scrollbarWidth: 6,
|
||||||
|
scrollSpeed: 40,
|
||||||
|
showScrollbar: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性
|
||||||
|
const props = withDefaults(defineProps<ContextMenuProps>(), {
|
||||||
|
visible: false,
|
||||||
|
position: () => ({ x: 0, y: 0 }),
|
||||||
|
items: () => [],
|
||||||
|
className: '',
|
||||||
|
width: 200,
|
||||||
|
maxHeight: 400,
|
||||||
|
zIndex: 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
close: []
|
||||||
|
'item-click': [item: ContextMenuItem, event: MouseEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 响应式引用
|
||||||
|
const menuRef = ref<HTMLElement>()
|
||||||
|
const scrollContainer = ref<HTMLElement>()
|
||||||
|
const submenuRef = ref<any>()
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const isScrolling = ref(false)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const scrollHeight = ref(0)
|
||||||
|
const clientHeight = ref(0)
|
||||||
|
const activeSubmenu = ref<ContextMenuItem | null>(null)
|
||||||
|
const submenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
|
const submenuTimer = ref<NodeJS.Timeout>()
|
||||||
|
const submenuMaxHeight = ref(300)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const menuStyle = computed((): CSSProperties => {
|
||||||
|
const style: CSSProperties = {
|
||||||
|
'--menu-width': `${props.width}px`,
|
||||||
|
'--menu-max-height': `${props.maxHeight}px`,
|
||||||
|
'--menu-z-index': props.zIndex,
|
||||||
|
'--animation-duration': `${DEFAULT_ANIMATION_CONFIG.duration}ms`,
|
||||||
|
'--animation-easing': DEFAULT_ANIMATION_CONFIG.easing
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!menuRef.value) {
|
||||||
|
return {
|
||||||
|
...style,
|
||||||
|
left: `${props.position.x}px`,
|
||||||
|
top: `${props.position.y}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedPosition = adjustMenuPosition(props.position)
|
||||||
|
return {
|
||||||
|
...style,
|
||||||
|
left: `${adjustedPosition.x}px`,
|
||||||
|
top: `${adjustedPosition.y}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollContainerStyle = computed((): CSSProperties => {
|
||||||
|
return {
|
||||||
|
maxHeight: `${props.maxHeight}px`,
|
||||||
|
transform: `translateY(-${scrollTop.value}px)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
return props.items.filter((item) => {
|
||||||
|
// 显示所有非分隔线项目
|
||||||
|
if (!item.separator) return true
|
||||||
|
// 显示所有分隔线项目(无论是否有label)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const showScrollIndicator = computed(() => {
|
||||||
|
return DEFAULT_SCROLL_CONFIG.showScrollbar && scrollHeight.value > clientHeight.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canScrollUp = computed(() => scrollTop.value > 0)
|
||||||
|
const canScrollDown = computed(() => scrollTop.value < scrollHeight.value - clientHeight.value)
|
||||||
|
|
||||||
|
const submenuWrapperStyle = computed((): CSSProperties => {
|
||||||
|
return {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: props.zIndex + 1,
|
||||||
|
maxHeight: `${submenuMaxHeight.value}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听器
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(newVisible) => {
|
||||||
|
if (newVisible) {
|
||||||
|
nextTick(() => {
|
||||||
|
initializeScroll()
|
||||||
|
updateSubmenuPosition()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
closeSubmenu()
|
||||||
|
resetScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
() => {
|
||||||
|
if (props.visible) {
|
||||||
|
nextTick(initializeScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
window.addEventListener('resize', handleWindowResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
window.removeEventListener('resize', handleWindowResize)
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法定义
|
||||||
|
const adjustMenuPosition = (position: ContextMenuPosition): ContextMenuPosition => {
|
||||||
|
if (!DEFAULT_EDGE_CONFIG.enabled || !menuRef.value) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const threshold = DEFAULT_EDGE_CONFIG.threshold
|
||||||
|
|
||||||
|
let adjustedX = position.x
|
||||||
|
let adjustedY = position.y
|
||||||
|
|
||||||
|
// 水平边缘检测
|
||||||
|
if (position.x + menuRect.width > viewportWidth - threshold) {
|
||||||
|
adjustedX = viewportWidth - menuRect.width - threshold
|
||||||
|
} else if (position.x < threshold) {
|
||||||
|
adjustedX = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// 垂直边缘检测
|
||||||
|
if (position.y + menuRect.height > viewportHeight - threshold) {
|
||||||
|
adjustedY = viewportHeight - menuRect.height - threshold
|
||||||
|
} else if (position.y < threshold) {
|
||||||
|
adjustedY = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: adjustedX, y: adjustedY }
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeScroll = () => {
|
||||||
|
if (!scrollContainer.value) return
|
||||||
|
|
||||||
|
const container = scrollContainer.value
|
||||||
|
scrollHeight.value = container.scrollHeight
|
||||||
|
clientHeight.value = container.clientHeight
|
||||||
|
scrollTop.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetScroll = () => {
|
||||||
|
scrollTop.value = 0
|
||||||
|
scrollHeight.value = 0
|
||||||
|
clientHeight.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollTo = (targetScrollTop: number) => {
|
||||||
|
const maxScrollTop = scrollHeight.value - clientHeight.value
|
||||||
|
scrollTop.value = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollBy = (delta: number) => {
|
||||||
|
scrollTo(scrollTop.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (!showScrollIndicator.value) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const delta =
|
||||||
|
event.deltaY > 0 ? DEFAULT_SCROLL_CONFIG.scrollSpeed : -DEFAULT_SCROLL_CONFIG.scrollSpeed
|
||||||
|
scrollBy(delta)
|
||||||
|
isScrolling.value = true
|
||||||
|
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
submenuTimer.value = setTimeout(() => {
|
||||||
|
isScrolling.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemMouseEnter = (item: ContextMenuItem, event: MouseEvent) => {
|
||||||
|
if (item.disabled || item.separator) return
|
||||||
|
|
||||||
|
// 清除之前的子菜单定时器
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
submenuTimer.value = setTimeout(() => {
|
||||||
|
openSubmenu(item, event)
|
||||||
|
}, 200)
|
||||||
|
} else {
|
||||||
|
closeSubmenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemMouseLeave = (item: ContextMenuItem) => {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
|
||||||
|
if (item.disabled || item.separator) return
|
||||||
|
|
||||||
|
// 调用菜单项的点击回调
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick(item, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发射组件事件
|
||||||
|
emit('item-click', item, event)
|
||||||
|
|
||||||
|
// 如果没有子菜单,关闭菜单
|
||||||
|
if (!item.children || item.children.length === 0) {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmenuItemClick = (item: ContextMenuItem, event: MouseEvent) => {
|
||||||
|
emit('item-click', item, event)
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (!menuRef.value) return
|
||||||
|
|
||||||
|
// 如果是相同的子菜单,不需要重新计算位置
|
||||||
|
if (activeSubmenu.value && activeSubmenu.value.id === item.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除未使用的变量声明
|
||||||
|
activeSubmenu.value = item
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
updateSubmenuPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSubmenu = () => {
|
||||||
|
activeSubmenu.value = null
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
}
|
||||||
|
const updateSubmenuPosition = () => {
|
||||||
|
if (!menuRef.value || !activeSubmenu.value) return
|
||||||
|
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect()
|
||||||
|
// 初始位置:显示在右侧
|
||||||
|
const x = menuRect.right
|
||||||
|
const y = menuRect.top
|
||||||
|
|
||||||
|
// 先设置初始位置,让子菜单渲染
|
||||||
|
submenuPosition.value = { x, y }
|
||||||
|
|
||||||
|
// 等待子菜单渲染完成后调整位置
|
||||||
|
setTimeout(() => {
|
||||||
|
// 子菜单通过 Teleport 渲染到 body 中,需要在 body 中查找
|
||||||
|
// 查找所有的 context-menu 元素,找到 z-index 最高的(即子菜单)
|
||||||
|
const allMenus = document.querySelectorAll('.context-menu')
|
||||||
|
console.log('All menus found:', allMenus.length)
|
||||||
|
|
||||||
|
let submenuEl: Element | null = null
|
||||||
|
let maxZIndex = props.zIndex
|
||||||
|
|
||||||
|
allMenus.forEach((menu) => {
|
||||||
|
const style = window.getComputedStyle(menu)
|
||||||
|
const zIndex = parseInt(style.zIndex) || 0
|
||||||
|
console.log('Menu z-index:', zIndex, 'Current max:', maxZIndex)
|
||||||
|
|
||||||
|
if (zIndex > maxZIndex) {
|
||||||
|
maxZIndex = zIndex
|
||||||
|
submenuEl = menu as Element
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Found submenu:', submenuEl)
|
||||||
|
|
||||||
|
if (submenuEl) {
|
||||||
|
const submenuRect = (submenuEl as HTMLElement).getBoundingClientRect()
|
||||||
|
console.log('submenuRect:', submenuRect)
|
||||||
|
|
||||||
|
if (submenuRect.width > 0) {
|
||||||
|
// 计算包含滚动条的实际宽度
|
||||||
|
const scrollContainer = (submenuEl as HTMLElement).querySelector(
|
||||||
|
'.context-menu__scroll-container'
|
||||||
|
) as HTMLElement | null
|
||||||
|
let actualWidth = submenuRect.width
|
||||||
|
|
||||||
|
if (scrollContainer) {
|
||||||
|
// 检查是否有滚动条
|
||||||
|
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
|
||||||
|
if (hasScrollbar) {
|
||||||
|
// 添加滚动条宽度(通常是6-17px,这里使用默认的6px)
|
||||||
|
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
|
||||||
|
actualWidth += scrollbarWidth
|
||||||
|
console.log('Added scrollbar width:', scrollbarWidth, 'Total width:', actualWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustSubmenuPosition(actualWidth)
|
||||||
|
} else {
|
||||||
|
// 如果宽度为0,再等一下
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryRect = (submenuEl as HTMLElement).getBoundingClientRect()
|
||||||
|
console.log('retryRect:', retryRect)
|
||||||
|
if (retryRect.width > 0) {
|
||||||
|
// 重试时也要考虑滚动条
|
||||||
|
const scrollContainer = (submenuEl as HTMLElement).querySelector(
|
||||||
|
'.context-menu__scroll-container'
|
||||||
|
) as HTMLElement | null
|
||||||
|
let actualWidth = retryRect.width
|
||||||
|
|
||||||
|
if (scrollContainer) {
|
||||||
|
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
|
||||||
|
if (hasScrollbar) {
|
||||||
|
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
|
||||||
|
actualWidth += scrollbarWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustSubmenuPosition(actualWidth)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取位置调整逻辑为独立函数
|
||||||
|
const adjustSubmenuPosition = (submenuWidth: number) => {
|
||||||
|
if (!menuRef.value || !activeSubmenu.value) return
|
||||||
|
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const threshold = 10
|
||||||
|
|
||||||
|
// 重新计算位置
|
||||||
|
let adjustedX = menuRect.right
|
||||||
|
const y = menuRect.top
|
||||||
|
|
||||||
|
// 检查右侧是否有足够空间显示子菜单
|
||||||
|
if (adjustedX + submenuWidth > viewportWidth - threshold) {
|
||||||
|
// 如果右侧空间不足,显示在左侧:父元素的left - 子菜单宽度
|
||||||
|
adjustedX = menuRect.left - submenuWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保子菜单不会超出左边界
|
||||||
|
if (adjustedX < threshold) {
|
||||||
|
adjustedX = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Final position:', { x: adjustedX, y })
|
||||||
|
// 更新最终位置
|
||||||
|
submenuPosition.value = { x: adjustedX, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackdropContextMenu = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmenuMouseEnter = () => {
|
||||||
|
// 鼠标进入子菜单区域,清除关闭定时器
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmenuMouseLeave = () => {
|
||||||
|
// 鼠标离开子菜单区域,延迟关闭子菜单
|
||||||
|
submenuTimer.value = setTimeout(() => {
|
||||||
|
closeSubmenu()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!props.visible) return
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault()
|
||||||
|
closeMenu()
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault()
|
||||||
|
scrollBy(-DEFAULT_SCROLL_CONFIG.scrollSpeed)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault()
|
||||||
|
scrollBy(DEFAULT_SCROLL_CONFIG.scrollSpeed)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
if (props.visible) {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
emit('update:visible', false)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
defineExpose({
|
||||||
|
updatePosition: (_position: ContextMenuPosition) => {
|
||||||
|
// 位置更新逻辑
|
||||||
|
},
|
||||||
|
updateItems: (_items: ContextMenuItem[]) => {
|
||||||
|
// 菜单项更新逻辑
|
||||||
|
},
|
||||||
|
hide: closeMenu
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: calc(var(--menu-z-index) - 1);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: var(--menu-width);
|
||||||
|
max-width: 300px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: var(--menu-z-index);
|
||||||
|
overflow: auto;
|
||||||
|
animation: contextMenuEnter var(--animation-duration) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu--scrolling {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-container {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
/* scrollbar-color: rgba(255, 255, 255, 0.3) transparent; */
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.context-menu__scroll-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
|
||||||
|
.context-menu__scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
} */
|
||||||
|
|
||||||
|
.context-menu__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--separator {
|
||||||
|
padding: 0;
|
||||||
|
margin: 4px 0;
|
||||||
|
cursor: default;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--has-children {
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__arrow-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__separator {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin: 0 8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: var(--scrollbar-width, 6px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator-top,
|
||||||
|
.context-menu__scroll-indicator-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.9), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator-top {
|
||||||
|
top: 0;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
@keyframes contextMenuEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.context-menu {
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题支持 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.context-menu {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #404040;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__icon {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__label {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__separator {
|
||||||
|
background: #555555;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__scroll-indicator-top,
|
||||||
|
.context-menu__scroll-indicator-bottom {
|
||||||
|
background: linear-gradient(to bottom, rgba(45, 45, 45, 0.9), transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
244
src/renderer/src/components/ContextMenu/README.md
Normal file
244
src/renderer/src/components/ContextMenu/README.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 自定义右键菜单组件
|
||||||
|
|
||||||
|
一个功能完整、可扩展的自定义右键菜单组件,专为歌曲列表等场景设计。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- ✅ **精确的边缘点击判定** - 智能计算位置,确保菜单始终在可视区域内
|
||||||
|
- ✅ **滚动支持** - 支持菜单项过多时的滚动选择
|
||||||
|
- ✅ **可扩展性** - 易于添加新的菜单项和功能
|
||||||
|
- ✅ **平滑动画** - 流畅的显示/隐藏动画效果
|
||||||
|
- ✅ **自适应显示** - 在不同屏幕尺寸下自动适配
|
||||||
|
- ✅ **完整TypeScript支持** - 提供完整的类型定义
|
||||||
|
|
||||||
|
## 安装和使用
|
||||||
|
|
||||||
|
### 基本使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div @contextmenu.prevent="handleContextMenu">
|
||||||
|
<!-- 你的内容 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="menuVisible"
|
||||||
|
:items="menuItems"
|
||||||
|
:position="menuPosition"
|
||||||
|
@item-click="handleMenuItemClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ContextMenu from './ContextMenu/ContextMenu.vue'
|
||||||
|
import { createMenuItem, createSeparator } from './ContextMenu/utils'
|
||||||
|
import type { ContextMenuItem } from './ContextMenu/types'
|
||||||
|
|
||||||
|
const menuVisible = ref(false)
|
||||||
|
const menuPosition = ref({ x: 0, y: 0 })
|
||||||
|
const menuItems = ref<ContextMenuItem[]>([
|
||||||
|
createMenuItem('play', '播放', {
|
||||||
|
onClick: (item, event) => console.log('播放点击')
|
||||||
|
}),
|
||||||
|
createSeparator(),
|
||||||
|
createMenuItem('download', '下载')
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在歌曲列表中使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="song-list">
|
||||||
|
<div
|
||||||
|
v-for="song in songs"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-item"
|
||||||
|
@contextmenu.prevent="handleSongContextMenu(song, $event)"
|
||||||
|
>
|
||||||
|
{{ song.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="contextMenuVisible"
|
||||||
|
:items="contextMenuItems"
|
||||||
|
:position="contextMenuPosition"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import ContextMenu from './ContextMenu/ContextMenu.vue'
|
||||||
|
import { createMenuItem, createSeparator } from './ContextMenu/utils'
|
||||||
|
|
||||||
|
const contextMenuVisible = ref(false)
|
||||||
|
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||||
|
const currentSong = ref(null)
|
||||||
|
|
||||||
|
const contextMenuItems = computed(() => [
|
||||||
|
createMenuItem('play', '播放', {
|
||||||
|
onClick: () => playSong(currentSong.value)
|
||||||
|
}),
|
||||||
|
createMenuItem('addToPlaylist', '添加到播放列表'),
|
||||||
|
createSeparator(),
|
||||||
|
createMenuItem('download', '下载')
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleSongContextMenu = (song, event) => {
|
||||||
|
currentSong.value = song
|
||||||
|
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||||
|
contextMenuVisible.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
### ContextMenu 组件属性
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
| --------- | ------------------- | --------- | ----------------- |
|
||||||
|
| visible | boolean | false | 控制菜单显示/隐藏 |
|
||||||
|
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
|
||||||
|
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
|
||||||
|
| maxHeight | number | 400 | 菜单最大高度 |
|
||||||
|
| zIndex | number | 1000 | 菜单层级 |
|
||||||
|
|
||||||
|
### ContextMenuItem 类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ContextMenuItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: any
|
||||||
|
disabled?: boolean
|
||||||
|
separator?: boolean
|
||||||
|
children?: ContextMenuItem[]
|
||||||
|
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工具函数
|
||||||
|
|
||||||
|
#### createMenuItem
|
||||||
|
|
||||||
|
创建标准菜单项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createMenuItem(id: string, label: string, options?: {
|
||||||
|
icon?: any
|
||||||
|
disabled?: boolean
|
||||||
|
separator?: boolean
|
||||||
|
children?: ContextMenuItem[]
|
||||||
|
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
|
||||||
|
className?: string
|
||||||
|
}): ContextMenuItem
|
||||||
|
```
|
||||||
|
|
||||||
|
#### createSeparator
|
||||||
|
|
||||||
|
创建分隔线
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createSeparator(): ContextMenuItem
|
||||||
|
```
|
||||||
|
|
||||||
|
#### calculateMenuPosition
|
||||||
|
|
||||||
|
智能计算菜单位置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
calculateMenuPosition(
|
||||||
|
event: MouseEvent,
|
||||||
|
menuWidth?: number,
|
||||||
|
menuHeight?: number
|
||||||
|
): ContextMenuPosition
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级用法
|
||||||
|
|
||||||
|
### 子菜单支持
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const menuItems = [
|
||||||
|
createMenuItem('playlist', '添加到歌单', {
|
||||||
|
children: [
|
||||||
|
createMenuItem('playlist1', '我的最爱'),
|
||||||
|
createMenuItem('playlist2', '开车音乐'),
|
||||||
|
createSeparator(),
|
||||||
|
createMenuItem('newPlaylist', '新建歌单')
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态菜单项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const dynamicMenuItems = computed(() => {
|
||||||
|
const items = [createMenuItem('play', '播放')]
|
||||||
|
|
||||||
|
if (user.value.isPremium) {
|
||||||
|
items.push(createMenuItem('download', '下载高音质'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义样式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const menuItems = [
|
||||||
|
createMenuItem('danger', '删除歌曲', {
|
||||||
|
className: 'danger-item'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.danger-item {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item:hover {
|
||||||
|
background-color: #fff2f0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **使用防抖处理频繁的右键事件**
|
||||||
|
2. **合理设置菜单最大高度,避免过长滚动**
|
||||||
|
3. **为重要操作添加确认对话框**
|
||||||
|
4. **根据用户权限动态显示菜单项**
|
||||||
|
5. **在移动端考虑触摸替代方案**
|
||||||
|
|
||||||
|
## 浏览器兼容性
|
||||||
|
|
||||||
|
- Chrome 60+
|
||||||
|
- Firefox 55+
|
||||||
|
- Safari 12+
|
||||||
|
- Edge 79+
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 菜单位置不正确
|
||||||
|
|
||||||
|
确保使用 `calculateMenuPosition` 函数计算位置。
|
||||||
|
|
||||||
|
### 菜单项点击无效
|
||||||
|
|
||||||
|
检查 `onClick` 回调函数是否正确绑定。
|
||||||
|
|
||||||
|
### 样式冲突
|
||||||
|
|
||||||
|
使用 `className` 属性添加自定义样式类。
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request 来改进这个组件。
|
||||||
397
src/renderer/src/components/ContextMenu/composables.ts
Normal file
397
src/renderer/src/components/ContextMenu/composables.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
import type { ContextMenuItem, ContextMenuPosition } from './types'
|
||||||
|
import { createMenuItem, createSeparator } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单组合式函数
|
||||||
|
*/
|
||||||
|
export function useContextMenu() {
|
||||||
|
const visible = ref(false)
|
||||||
|
const position = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
|
const items = ref<ContextMenuItem[]>([])
|
||||||
|
const currentData = ref<any>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示菜单
|
||||||
|
*/
|
||||||
|
const show = (event: MouseEvent, menuItems: ContextMenuItem[], data?: any) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
position.value = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value = menuItems
|
||||||
|
currentData.value = data
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏菜单
|
||||||
|
*/
|
||||||
|
const hide = () => {
|
||||||
|
visible.value = false
|
||||||
|
currentData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新菜单位置
|
||||||
|
*/
|
||||||
|
const updatePosition = (newPosition: ContextMenuPosition) => {
|
||||||
|
position.value = newPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新菜单项
|
||||||
|
*/
|
||||||
|
const updateItems = (newItems: ContextMenuItem[]) => {
|
||||||
|
items.value = newItems
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理菜单项点击
|
||||||
|
*/
|
||||||
|
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick(item, event)
|
||||||
|
}
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
visible: computed(() => visible.value),
|
||||||
|
position: computed(() => position.value),
|
||||||
|
items: computed(() => items.value),
|
||||||
|
currentData: computed(() => currentData.value),
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
updatePosition,
|
||||||
|
updateItems,
|
||||||
|
handleItemClick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌曲相关的右键菜单配置
|
||||||
|
*/
|
||||||
|
export function useSongContextMenu() {
|
||||||
|
const { show, hide, ...rest } = useContextMenu()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示歌曲右键菜单
|
||||||
|
*/
|
||||||
|
const showSongMenu = (
|
||||||
|
event: MouseEvent,
|
||||||
|
song: any,
|
||||||
|
options?: {
|
||||||
|
showPlay?: boolean
|
||||||
|
showAddToPlaylist?: boolean
|
||||||
|
showDownload?: boolean
|
||||||
|
showAddToSongList?: boolean
|
||||||
|
playlists?: any[]
|
||||||
|
onPlay?: (song: any) => void
|
||||||
|
onAddToPlaylist?: (song: any) => void
|
||||||
|
onDownload?: (song: any) => void
|
||||||
|
onAddToSongList?: (song: any, playlist: any) => void
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
showPlay = true,
|
||||||
|
showAddToPlaylist = true,
|
||||||
|
showDownload = true,
|
||||||
|
showAddToSongList = true,
|
||||||
|
playlists = [],
|
||||||
|
onPlay,
|
||||||
|
onAddToPlaylist,
|
||||||
|
onDownload,
|
||||||
|
onAddToSongList
|
||||||
|
} = options || {}
|
||||||
|
|
||||||
|
const menuItems: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
// 播放
|
||||||
|
if (showPlay) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('play', '播放', {
|
||||||
|
onClick: () => onPlay?.(song)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到播放列表
|
||||||
|
if (showAddToPlaylist) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('addToPlaylist', '添加到播放列表', {
|
||||||
|
onClick: () => onAddToPlaylist?.(song)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到歌单(如果有歌单)
|
||||||
|
if (showAddToSongList && playlists.length > 0) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('addToSongList', '加入歌单', {
|
||||||
|
children: playlists.map((playlist) =>
|
||||||
|
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
|
||||||
|
onClick: () => onAddToSongList?.(song, playlist)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
if (menuItems.length > 0) {
|
||||||
|
menuItems.push(createSeparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载
|
||||||
|
if (showDownload) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('download', '下载', {
|
||||||
|
onClick: () => onDownload?.(song)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
show(event, menuItems, song)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
showSongMenu,
|
||||||
|
hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表项右键菜单配置
|
||||||
|
*/
|
||||||
|
export function useListItemContextMenu() {
|
||||||
|
const { show, hide, ...rest } = useContextMenu()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示列表项右键菜单
|
||||||
|
*/
|
||||||
|
const showListItemMenu = (
|
||||||
|
event: MouseEvent,
|
||||||
|
item: any,
|
||||||
|
options?: {
|
||||||
|
showEdit?: boolean
|
||||||
|
showDelete?: boolean
|
||||||
|
showCopy?: boolean
|
||||||
|
showProperties?: boolean
|
||||||
|
onEdit?: (item: any) => void
|
||||||
|
onDelete?: (item: any) => void
|
||||||
|
onCopy?: (item: any) => void
|
||||||
|
onProperties?: (item: any) => void
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
showEdit = true,
|
||||||
|
showDelete = true,
|
||||||
|
showCopy = false,
|
||||||
|
showProperties = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCopy,
|
||||||
|
onProperties
|
||||||
|
} = options || {}
|
||||||
|
|
||||||
|
const menuItems: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
if (showEdit) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('edit', '编辑', {
|
||||||
|
onClick: () => onEdit?.(item)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制
|
||||||
|
if (showCopy) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('copy', '复制', {
|
||||||
|
onClick: () => onCopy?.(item)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
if (menuItems.length > 0 && (showDelete || showProperties)) {
|
||||||
|
menuItems.push(createSeparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
if (showDelete) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('delete', '删除', {
|
||||||
|
onClick: () => onDelete?.(item)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 属性
|
||||||
|
if (showProperties) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('properties', '属性', {
|
||||||
|
onClick: () => onProperties?.(item)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
show(event, menuItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
showListItemMenu,
|
||||||
|
hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本选择右键菜单配置
|
||||||
|
*/
|
||||||
|
export function useTextSelectionContextMenu() {
|
||||||
|
const { show, hide, ...rest } = useContextMenu()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示文本选择右键菜单
|
||||||
|
*/
|
||||||
|
const showTextSelectionMenu = (
|
||||||
|
event: MouseEvent,
|
||||||
|
selectedText: string,
|
||||||
|
options?: {
|
||||||
|
showCopy?: boolean
|
||||||
|
showSearch?: boolean
|
||||||
|
showTranslate?: boolean
|
||||||
|
onCopy?: (text: string) => void
|
||||||
|
onSearch?: (text: string) => void
|
||||||
|
onTranslate?: (text: string) => void
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
showCopy = true,
|
||||||
|
showSearch = true,
|
||||||
|
showTranslate = false,
|
||||||
|
onCopy,
|
||||||
|
onSearch,
|
||||||
|
onTranslate
|
||||||
|
} = options || {}
|
||||||
|
|
||||||
|
const menuItems: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
// 复制
|
||||||
|
if (showCopy) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('copy', '复制', {
|
||||||
|
onClick: () => onCopy?.(selectedText)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
if (showSearch) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('search', '搜索', {
|
||||||
|
onClick: () => onSearch?.(selectedText)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译
|
||||||
|
if (showTranslate) {
|
||||||
|
menuItems.push(
|
||||||
|
createMenuItem('translate', '翻译', {
|
||||||
|
onClick: () => onTranslate?.(selectedText)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
show(event, menuItems, selectedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
showTextSelectionMenu,
|
||||||
|
hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可复用的菜单配置
|
||||||
|
*/
|
||||||
|
export function createMenuConfig<T = any>(config: {
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
onItemClick?: (item: ContextMenuItem, data: T, event: MouseEvent) => void
|
||||||
|
onShow?: (data: T) => void
|
||||||
|
onHide?: () => void
|
||||||
|
}) {
|
||||||
|
const { items, onItemClick, onShow, onHide } = config
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: ref([...items]),
|
||||||
|
|
||||||
|
show: (_event: MouseEvent, data: T) => {
|
||||||
|
onShow?.(data)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleItemClick: (item: ContextMenuItem, event: MouseEvent, data: T) => {
|
||||||
|
onItemClick?.(item, data, event)
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: () => {
|
||||||
|
onHide?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项可见性控制
|
||||||
|
*/
|
||||||
|
export function useMenuVisibility<T extends ContextMenuItem>(
|
||||||
|
items: Ref<T[]>,
|
||||||
|
predicate: (item: T) => boolean
|
||||||
|
) {
|
||||||
|
const visibleItems = computed(() => items.value.filter(predicate))
|
||||||
|
|
||||||
|
const hasVisibleItems = computed(() => visibleItems.value.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleItems,
|
||||||
|
hasVisibleItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项动态启用/禁用控制
|
||||||
|
*/
|
||||||
|
export function useMenuItemsState<T extends ContextMenuItem>(
|
||||||
|
items: Ref<T[]>,
|
||||||
|
getState: (item: T) => { disabled?: boolean; visible?: boolean }
|
||||||
|
) {
|
||||||
|
const processedItems = computed(() =>
|
||||||
|
items.value
|
||||||
|
.map((item) => {
|
||||||
|
const state = getState(item)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
disabled: state.disabled ?? item.disabled,
|
||||||
|
// 如果visible为false,完全移除该项
|
||||||
|
...(state.visible === false ? { _hidden: true } : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => !(item as any)._hidden)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedItems
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/renderer/src/components/ContextMenu/demo.vue
Normal file
199
src/renderer/src/components/ContextMenu/demo.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="demo-container">
|
||||||
|
<h1>右键菜单组件演示</h1>
|
||||||
|
|
||||||
|
<!-- 测试区域 -->
|
||||||
|
<div class="test-area">
|
||||||
|
<div
|
||||||
|
class="test-box"
|
||||||
|
style="width: 300px; height: 200px; border: 2px dashed #ccc; padding: 20px"
|
||||||
|
@contextmenu.prevent="handleContextMenu($event)"
|
||||||
|
>
|
||||||
|
<p>在此区域右键点击测试菜单</p>
|
||||||
|
<p>菜单项数量:{{ menuItems.length }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="menuVisible"
|
||||||
|
:position="menuPosition"
|
||||||
|
:items="menuItems"
|
||||||
|
:max-height="200"
|
||||||
|
@item-click="handleMenuItemClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ContextMenu from './ContextMenu.vue'
|
||||||
|
import type { ContextMenuItem } from './types'
|
||||||
|
|
||||||
|
const menuVisible = ref(false)
|
||||||
|
const menuPosition = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
// 创建大量菜单项用于测试滚动
|
||||||
|
const menuItems = ref<ContextMenuItem[]>([
|
||||||
|
{
|
||||||
|
id: 'play',
|
||||||
|
label: '播放',
|
||||||
|
icon: '▶'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pause',
|
||||||
|
label: '暂停',
|
||||||
|
icon: '⏸'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-1',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'add-to-playlist',
|
||||||
|
label: '添加到播放列表',
|
||||||
|
icon: '➕'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remove-from-playlist',
|
||||||
|
label: '从播放列表移除',
|
||||||
|
icon: '➖'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-2',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'download',
|
||||||
|
label: '下载歌曲',
|
||||||
|
icon: '⬇️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share',
|
||||||
|
label: '分享',
|
||||||
|
icon: '↗️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-3',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'info',
|
||||||
|
label: '歌曲信息',
|
||||||
|
icon: 'ℹ️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit-tags',
|
||||||
|
label: '编辑标签',
|
||||||
|
icon: '✏️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-4',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate-1',
|
||||||
|
label: '评分:★☆☆☆☆',
|
||||||
|
icon: '⭐'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate-2',
|
||||||
|
label: '评分:★★☆☆☆',
|
||||||
|
icon: '⭐'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate-3',
|
||||||
|
label: '评分:★★★☆☆',
|
||||||
|
icon: '⭐'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate-4',
|
||||||
|
label: '评分:★★★★☆',
|
||||||
|
icon: '⭐'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate-5',
|
||||||
|
label: '评分:★★★★★',
|
||||||
|
icon: '⭐'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-5',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create-station',
|
||||||
|
label: '创建电台',
|
||||||
|
icon: '📻'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'similar-songs',
|
||||||
|
label: '相似歌曲',
|
||||||
|
icon: '🎵'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-6',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-link',
|
||||||
|
label: '复制链接',
|
||||||
|
icon: '🔗'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'properties',
|
||||||
|
label: '属性',
|
||||||
|
icon: '📋'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'separator-7',
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
icon: '🗑️',
|
||||||
|
className: 'danger'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
|
menuPosition.value = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY
|
||||||
|
}
|
||||||
|
menuVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuItemClick = (item: ContextMenuItem) => {
|
||||||
|
console.log('菜单项点击:', item.label)
|
||||||
|
// 这里可以添加具体的菜单项处理逻辑
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.demo-container {
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-area {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-box:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ff4444 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
src/renderer/src/components/ContextMenu/index.ts
Normal file
4
src/renderer/src/components/ContextMenu/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as ContextMenu } from './ContextMenu.vue'
|
||||||
|
export * from './types'
|
||||||
|
export * from './utils'
|
||||||
|
export * from './composables'
|
||||||
101
src/renderer/src/components/ContextMenu/types.ts
Normal file
101
src/renderer/src/components/ContextMenu/types.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 右键菜单位置类型定义
|
||||||
|
*/
|
||||||
|
export interface ContextMenuPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单项类型定义
|
||||||
|
*/
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
/** 菜单项唯一标识 */
|
||||||
|
id: string
|
||||||
|
/** 显示文本 */
|
||||||
|
label?: string
|
||||||
|
/** 图标组件 */
|
||||||
|
icon?: any
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean
|
||||||
|
/** 是否显示分隔线 */
|
||||||
|
separator?: boolean
|
||||||
|
/** 子菜单项 */
|
||||||
|
children?: ContextMenuItem[]
|
||||||
|
/** 点击回调函数 */
|
||||||
|
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
|
||||||
|
/** 自定义CSS类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单配置属性
|
||||||
|
*/
|
||||||
|
export interface ContextMenuProps {
|
||||||
|
/** 是否显示菜单 */
|
||||||
|
visible: boolean
|
||||||
|
/** 菜单位置 */
|
||||||
|
position: ContextMenuPosition
|
||||||
|
/** 菜单项列表 */
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
/** 自定义CSS类名 */
|
||||||
|
className?: string
|
||||||
|
/** 菜单宽度 */
|
||||||
|
width?: number
|
||||||
|
/** 最大高度(超出时显示滚动条) */
|
||||||
|
maxHeight?: number
|
||||||
|
/** 菜单层级 */
|
||||||
|
zIndex?: number
|
||||||
|
/** 关闭菜单回调 */
|
||||||
|
onClose?: () => void
|
||||||
|
/** 菜单项点击回调 */
|
||||||
|
onItemClick?: (item: ContextMenuItem, event: MouseEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 边缘检测配置
|
||||||
|
*/
|
||||||
|
export interface EdgeDetectionConfig {
|
||||||
|
/** 距离边缘的阈值(像素) */
|
||||||
|
threshold: number
|
||||||
|
/** 是否启用边缘检测 */
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动画配置
|
||||||
|
*/
|
||||||
|
export interface AnimationConfig {
|
||||||
|
/** 动画持续时间(毫秒) */
|
||||||
|
duration: number
|
||||||
|
/** 动画缓动函数 */
|
||||||
|
easing: string
|
||||||
|
/** 是否启用动画 */
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动配置
|
||||||
|
*/
|
||||||
|
export interface ScrollConfig {
|
||||||
|
/** 滚动条宽度 */
|
||||||
|
scrollbarWidth: number
|
||||||
|
/** 滚动速度 */
|
||||||
|
scrollSpeed: number
|
||||||
|
/** 是否显示滚动条 */
|
||||||
|
showScrollbar: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单实例方法
|
||||||
|
*/
|
||||||
|
export interface ContextMenuInstance {
|
||||||
|
/** 显示菜单 */
|
||||||
|
show: (position: ContextMenuPosition, items?: ContextMenuItem[]) => void
|
||||||
|
/** 隐藏菜单 */
|
||||||
|
hide: () => void
|
||||||
|
/** 更新菜单位置 */
|
||||||
|
updatePosition: (position: ContextMenuPosition) => void
|
||||||
|
/** 更新菜单项 */
|
||||||
|
updateItems: (items: ContextMenuItem[]) => void
|
||||||
|
}
|
||||||
266
src/renderer/src/components/ContextMenu/utils.ts
Normal file
266
src/renderer/src/components/ContextMenu/utils.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import type { ContextMenuItem, ContextMenuPosition } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标准菜单项
|
||||||
|
*/
|
||||||
|
export function createMenuItem(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
options?: {
|
||||||
|
icon?: any
|
||||||
|
disabled?: boolean
|
||||||
|
separator?: boolean
|
||||||
|
children?: ContextMenuItem[]
|
||||||
|
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
): ContextMenuItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon: options?.icon,
|
||||||
|
disabled: options?.disabled || false,
|
||||||
|
separator: options?.separator || false,
|
||||||
|
children: options?.children,
|
||||||
|
onClick: options?.onClick,
|
||||||
|
className: options?.className
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分隔线菜单项
|
||||||
|
*/
|
||||||
|
export function createSeparator(): ContextMenuItem {
|
||||||
|
return {
|
||||||
|
id: `separator-${Date.now()}`,
|
||||||
|
label: '',
|
||||||
|
separator: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算菜单位置,确保在可视区域内
|
||||||
|
*/
|
||||||
|
export function calculateMenuPosition(
|
||||||
|
event: MouseEvent,
|
||||||
|
menuWidth: number = 200,
|
||||||
|
menuHeight: number = 400
|
||||||
|
): ContextMenuPosition {
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const threshold = 10
|
||||||
|
|
||||||
|
let x = event.clientX
|
||||||
|
let y = event.clientY
|
||||||
|
|
||||||
|
// 水平边缘检测
|
||||||
|
if (x + menuWidth > viewportWidth - threshold) {
|
||||||
|
x = viewportWidth - menuWidth - threshold
|
||||||
|
} else if (x < threshold) {
|
||||||
|
x = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// 垂直边缘检测
|
||||||
|
if (y + menuHeight > viewportHeight - threshold) {
|
||||||
|
y = viewportHeight - menuHeight - threshold
|
||||||
|
} else if (y < threshold) {
|
||||||
|
y = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: NodeJS.Timeout
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => func(...args), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
limit: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle: boolean
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args)
|
||||||
|
inThrottle = true
|
||||||
|
setTimeout(() => (inThrottle = false), limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度克隆菜单项(避免引用问题)
|
||||||
|
*/
|
||||||
|
export function cloneMenuItem(item: ContextMenuItem): ContextMenuItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: item.children ? item.children.map(cloneMenuItem) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扁平化菜单项(用于搜索等功能)
|
||||||
|
*/
|
||||||
|
export function flattenMenuItems(items: ContextMenuItem[]): ContextMenuItem[] {
|
||||||
|
const result: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
result.push(item)
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
result.push(...flattenMenuItems(item.children))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找菜单项
|
||||||
|
*/
|
||||||
|
export function findMenuItemById(items: ContextMenuItem[], id: string): ContextMenuItem | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const found = findMenuItemById(item.children, id)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证菜单项配置
|
||||||
|
*/
|
||||||
|
export function validateMenuItem(item: ContextMenuItem): boolean {
|
||||||
|
if (!item.id || typeof item.id !== 'string') {
|
||||||
|
console.warn('菜单项必须包含有效的id字段')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.separator && (!item.label || typeof item.label !== 'string')) {
|
||||||
|
console.warn('非分隔线菜单项必须包含有效的label字段')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children && !Array.isArray(item.children)) {
|
||||||
|
console.warn('children字段必须是数组')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证菜单项列表
|
||||||
|
*/
|
||||||
|
export function validateMenuItems(items: ContextMenuItem[]): boolean {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
console.warn('菜单项列表必须是数组')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.every(validateMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤可见菜单项(移除禁用项和空分隔线)
|
||||||
|
*/
|
||||||
|
export function filterVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (item.disabled) return false
|
||||||
|
if (item.separator && !item.label) return true // 保留纯分隔线
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项排序工具
|
||||||
|
*/
|
||||||
|
export function sortMenuItems(
|
||||||
|
items: ContextMenuItem[],
|
||||||
|
compareFn?: (a: ContextMenuItem, b: ContextMenuItem) => number
|
||||||
|
): ContextMenuItem[] {
|
||||||
|
const sorted = [...items]
|
||||||
|
sorted.sort(
|
||||||
|
compareFn ||
|
||||||
|
((a, b) => {
|
||||||
|
if (!a.label || !b.label) return 0
|
||||||
|
return a.label.localeCompare(b.label)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 递归排序子菜单
|
||||||
|
return sorted.map((item) => ({
|
||||||
|
...item,
|
||||||
|
children: item.children ? sortMenuItems(item.children, compareFn) : undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项分组工具
|
||||||
|
*/
|
||||||
|
export function groupMenuItems(items: ContextMenuItem[], groupSize: number = 5): ContextMenuItem[] {
|
||||||
|
const result: ContextMenuItem[] = []
|
||||||
|
let currentGroup: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
currentGroup.push(item)
|
||||||
|
|
||||||
|
if (currentGroup.length >= groupSize || index === items.length - 1) {
|
||||||
|
if (currentGroup.length > 0) {
|
||||||
|
result.push(...currentGroup)
|
||||||
|
if (index < items.length - 1) {
|
||||||
|
result.push(createSeparator())
|
||||||
|
}
|
||||||
|
currentGroup = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项搜索工具
|
||||||
|
*/
|
||||||
|
export function searchMenuItems(items: ContextMenuItem[], searchText: string): ContextMenuItem[] {
|
||||||
|
if (!searchText.trim()) return items
|
||||||
|
|
||||||
|
const lowerSearchText = searchText.toLowerCase()
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (item.separator) return true
|
||||||
|
if (!item.label) return false
|
||||||
|
|
||||||
|
const matches = item.label.toLowerCase().includes(lowerSearchText)
|
||||||
|
|
||||||
|
if (matches) return true
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const matchingChildren = searchMenuItems(item.children, searchText)
|
||||||
|
if (matchingChildren.length > 0) {
|
||||||
|
item.children = matchingChildren
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="song-virtual-list">
|
<div class="song-virtual-list">
|
||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div v-if="showIndex" class="col-index"></div>
|
<div v-if="showIndex" class="col-index">#</div>
|
||||||
<div class="col-title">标题</div>
|
<div class="col-title">标题</div>
|
||||||
<div v-if="showAlbum" class="col-album">专辑</div>
|
<div v-if="showAlbum" class="col-album">专辑</div>
|
||||||
<div class="col-like">喜欢</div>
|
<div class="col-like">喜欢</div>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
class="song-item"
|
class="song-item"
|
||||||
@mouseenter="hoveredSong = song.id || song.songmid"
|
@mouseenter="hoveredSong = song.id || song.songmid"
|
||||||
@mouseleave="hoveredSong = null"
|
@mouseleave="hoveredSong = null"
|
||||||
|
@contextmenu="handleContextMenu($event, song)"
|
||||||
>
|
>
|
||||||
<!-- 序号或播放状态图标 -->
|
<!-- 序号或播放状态图标 -->
|
||||||
<div v-if="showIndex" class="col-index">
|
<div v-if="showIndex" class="col-index">
|
||||||
@@ -90,12 +91,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="contextMenuVisible"
|
||||||
|
:position="contextMenuPosition"
|
||||||
|
:items="contextMenuItems"
|
||||||
|
@item-click="handleContextMenuItemClick"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick, toRaw } from 'vue'
|
||||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
AddIcon,
|
||||||
|
FolderIcon,
|
||||||
|
DeleteIcon
|
||||||
|
} from 'tdesign-icons-vue-next'
|
||||||
|
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
||||||
|
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
||||||
|
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
|
||||||
|
import songListAPI from '@renderer/api/songList'
|
||||||
|
import type { SongList } from '@common/types/songList'
|
||||||
|
import { MessagePlugin } from 'tdesign-vue-next'
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id?: number
|
id?: number
|
||||||
@@ -120,6 +142,8 @@ interface Props {
|
|||||||
showIndex?: boolean
|
showIndex?: boolean
|
||||||
showAlbum?: boolean
|
showAlbum?: boolean
|
||||||
showDuration?: boolean
|
showDuration?: boolean
|
||||||
|
isLocalPlaylist?: boolean
|
||||||
|
playlistId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -127,10 +151,19 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
showIndex: true,
|
showIndex: true,
|
||||||
showAlbum: true,
|
showAlbum: true,
|
||||||
showDuration: true
|
showDuration: true,
|
||||||
|
isLocalPlaylist: false,
|
||||||
|
playlistId: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['play', 'pause', 'addToPlaylist', 'download', 'scroll'])
|
const emit = defineEmits([
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
|
'addToPlaylist',
|
||||||
|
'download',
|
||||||
|
'scroll',
|
||||||
|
'removeFromLocalPlaylist'
|
||||||
|
])
|
||||||
|
|
||||||
// 虚拟滚动相关状态
|
// 虚拟滚动相关状态
|
||||||
const scrollContainer = ref<HTMLElement>()
|
const scrollContainer = ref<HTMLElement>()
|
||||||
@@ -142,6 +175,14 @@ const scrollTop = ref(0)
|
|||||||
const visibleStartIndex = ref(0)
|
const visibleStartIndex = ref(0)
|
||||||
const visibleEndIndex = ref(0)
|
const visibleEndIndex = ref(0)
|
||||||
|
|
||||||
|
// 右键菜单相关状态
|
||||||
|
const contextMenuVisible = ref(false)
|
||||||
|
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
|
const contextMenuSong = ref<Song | null>(null)
|
||||||
|
|
||||||
|
// 歌单列表
|
||||||
|
const playlists = ref<SongList[]>([])
|
||||||
|
|
||||||
// 计算总高度
|
// 计算总高度
|
||||||
const totalHeight = computed(() => props.songs.length * itemHeight)
|
const totalHeight = computed(() => props.songs.length * itemHeight)
|
||||||
|
|
||||||
@@ -236,6 +277,131 @@ const onScroll = (event: Event) => {
|
|||||||
emit('scroll', event)
|
emit('scroll', event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 右键菜单项配置
|
||||||
|
const contextMenuItems = computed((): ContextMenuItem[] => {
|
||||||
|
const baseItems: ContextMenuItem[] = [
|
||||||
|
createMenuItem('play', '播放', {
|
||||||
|
icon: PlayCircleIcon,
|
||||||
|
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (contextMenuSong.value) {
|
||||||
|
handlePlay(contextMenuSong.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
createMenuItem('addToPlaylist', '添加到播放列表', {
|
||||||
|
icon: AddIcon,
|
||||||
|
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (contextMenuSong.value) {
|
||||||
|
handleAddToPlaylist(contextMenuSong.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
// 如果有歌单,添加"加入歌单"子菜单
|
||||||
|
if (playlists.value.length > 0) {
|
||||||
|
baseItems.push(
|
||||||
|
createMenuItem('addToSongList', '加入歌单', {
|
||||||
|
icon: FolderIcon,
|
||||||
|
children: playlists.value.map((playlist) =>
|
||||||
|
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
|
||||||
|
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (contextMenuSong.value) {
|
||||||
|
handleAddToSongList(contextMenuSong.value, playlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseItems.push(
|
||||||
|
createMenuItem('download', '下载', {
|
||||||
|
icon: DownloadIcon,
|
||||||
|
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (contextMenuSong.value) {
|
||||||
|
emit('download', contextMenuSong.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// 添加分隔线
|
||||||
|
baseItems.push(createSeparator())
|
||||||
|
// 如果是本地歌单,添加"移出本地歌单"选项
|
||||||
|
if (props.isLocalPlaylist) {
|
||||||
|
baseItems.push(
|
||||||
|
createMenuItem('removeFromLocalPlaylist', '移出当前歌单', {
|
||||||
|
icon: DeleteIcon,
|
||||||
|
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
if (contextMenuSong.value) {
|
||||||
|
emit('removeFromLocalPlaylist', contextMenuSong.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseItems
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理右键菜单
|
||||||
|
const handleContextMenu = (event: MouseEvent, song: Song) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// 设置菜单数据
|
||||||
|
contextMenuSong.value = song
|
||||||
|
|
||||||
|
// 使用智能位置计算,确保菜单在可视区域内
|
||||||
|
contextMenuPosition.value = calculateMenuPosition(event, 240, 300)
|
||||||
|
|
||||||
|
// 直接显示菜单
|
||||||
|
contextMenuVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理右键菜单项点击
|
||||||
|
const handleContextMenuItemClick = (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
// 菜单项的 onClick 回调已经在 ContextMenuItem 组件中调用
|
||||||
|
// 这里不需要额外关闭菜单,ContextMenu 组件会处理关闭逻辑
|
||||||
|
// 避免重复关闭导致菜单显示问题
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭右键菜单
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
contextMenuVisible.value = false
|
||||||
|
contextMenuSong.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载歌单列表
|
||||||
|
const loadPlaylists = async () => {
|
||||||
|
try {
|
||||||
|
const result = await songListAPI.getAll()
|
||||||
|
if (result.success) {
|
||||||
|
playlists.value = result.data || []
|
||||||
|
} else {
|
||||||
|
console.error('加载歌单失败:', result.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌单失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加歌曲到歌单
|
||||||
|
const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
||||||
|
try {
|
||||||
|
const result = await songListAPI.addSongs(playlist.id, [toRaw(song) as any])
|
||||||
|
if (result.success) {
|
||||||
|
MessagePlugin.success(`已将"${song.name}"添加到歌单"${playlist.name}"`)
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(result.error || '添加到歌单失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加到歌单失败:', error)
|
||||||
|
MessagePlugin.error('添加到歌单失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 组件挂载后触发一次重新计算
|
// 组件挂载后触发一次重新计算
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -245,6 +411,9 @@ onMounted(() => {
|
|||||||
onScroll(event)
|
onScroll(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 加载歌单列表
|
||||||
|
loadPlaylists()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -286,7 +455,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
padding-left: 10px;
|
padding-left: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -691,8 +691,8 @@ const lightMainColor = computed(() => {
|
|||||||
|
|
||||||
// bottom: max(2vw, 29px);
|
// bottom: max(2vw, 29px);
|
||||||
|
|
||||||
height: 200%;
|
height: 100%;
|
||||||
transform: translateY(-25%);
|
// transform: translateY(-25%);
|
||||||
|
|
||||||
* [class^='lyricMainLine'] {
|
* [class^='lyricMainLine'] {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
|||||||
@@ -537,6 +537,33 @@ defineExpose({
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全屏模式下的滚动条样式 - 只显示滑块 */
|
||||||
|
.playlist-container .playlist-content {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(91, 91, 91, 0.3) transparent;
|
||||||
|
}
|
||||||
|
.playlist-container.full-screen-mode .playlist-content {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-container.full-screen-mode .playlist-song:hover {
|
.playlist-container.full-screen-mode .playlist-song:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
@@ -589,7 +616,7 @@ defineExpose({
|
|||||||
.playlist-content {
|
.playlist-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
// scrollbar-width: none;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface TagWriteOptions {
|
||||||
|
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
|
||||||
|
cover: boolean // 封面
|
||||||
|
lyrics: boolean // 普通歌词
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
showFloatBall: boolean
|
showFloatBall: boolean
|
||||||
directories?: {
|
directories?: {
|
||||||
cacheDir: string
|
cacheDir: string
|
||||||
downloadDir: string
|
downloadDir: string
|
||||||
}
|
}
|
||||||
|
tagWriteOptions?: TagWriteOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
// 默认设置
|
// 默认设置
|
||||||
return {
|
return {
|
||||||
showFloatBall: true
|
showFloatBall: true,
|
||||||
|
tagWriteOptions: {
|
||||||
|
basicInfo: true,
|
||||||
|
cover: true,
|
||||||
|
lyrics: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NotifyPlugin, MessagePlugin } from 'tdesign-vue-next'
|
import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { toRaw } from 'vue'
|
import { useSettingsStore } from '@renderer/store/Settings'
|
||||||
|
import { toRaw, h } from 'vue'
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
singer: string
|
singer: string
|
||||||
@@ -12,7 +13,7 @@ interface MusicItem {
|
|||||||
songmid: number
|
songmid: number
|
||||||
img: string
|
img: string
|
||||||
lrc: null | string
|
lrc: null | string
|
||||||
types: string[]
|
types: Array<{ type: string; size: string }>
|
||||||
_types: Record<string, any>
|
_types: Record<string, any>
|
||||||
typeUrl: Record<string, any>
|
typeUrl: Record<string, any>
|
||||||
}
|
}
|
||||||
@@ -29,15 +30,166 @@ const qualityMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
const qualityKey = Object.keys(qualityMap)
|
const qualityKey = Object.keys(qualityMap)
|
||||||
|
|
||||||
|
// 创建音质选择弹窗
|
||||||
|
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
|
|
||||||
|
// 获取歌曲支持的音质列表
|
||||||
|
const availableQualities = songInfo.types || []
|
||||||
|
|
||||||
|
// 检查用户设置的音质是否为特殊音质
|
||||||
|
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
|
||||||
|
|
||||||
|
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
|
||||||
|
const qualityOptions = [...availableQualities]
|
||||||
|
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
|
||||||
|
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
|
||||||
|
if (!hasSpecialQuality) {
|
||||||
|
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按音质优先级排序
|
||||||
|
qualityOptions.sort((a, b) => {
|
||||||
|
const aIndex = qualityKey.indexOf(a.type)
|
||||||
|
const bIndex = qualityKey.indexOf(b.type)
|
||||||
|
return bIndex - aIndex // 降序排列,高音质在前
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
header: '选择下载音质(可滚动)',
|
||||||
|
width: 400,
|
||||||
|
placement: 'center',
|
||||||
|
body: () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-selector'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-list',
|
||||||
|
style: {
|
||||||
|
maxHeight:
|
||||||
|
'max(calc(calc(70vh - 2 * var(--td-comp-paddingTB-xxl)) - 24px - 32px - 32px),100px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qualityOptions.map((quality) =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
key: quality.type,
|
||||||
|
class: 'quality-item',
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
margin: '8px 0',
|
||||||
|
border: '1px solid #e7e7e7',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||||
|
},
|
||||||
|
onClick: () => {
|
||||||
|
dialog.destroy()
|
||||||
|
resolve(quality.type)
|
||||||
|
},
|
||||||
|
onMouseenter: (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
target.style.backgroundColor = '#f0f9ff'
|
||||||
|
target.style.borderColor = '#1890ff'
|
||||||
|
},
|
||||||
|
onMouseleave: (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
target.style.backgroundColor =
|
||||||
|
quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||||
|
target.style.borderColor = '#e7e7e7'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', { class: 'quality-info' }, [
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: quality.type === userQuality ? '#1890ff' : '#333'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qualityMap[quality.type] || quality.type
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
marginTop: '2px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quality.type.toUpperCase()
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-size',
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quality.size
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
confirmBtn: null,
|
||||||
|
cancelBtn: null,
|
||||||
|
footer: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const LocalUserDetail = LocalUserDetailStore()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
let quality = LocalUserDetail.userSource.quality as string
|
const userQuality = LocalUserDetail.userSource.quality as string
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
|
// 获取歌词
|
||||||
|
const { crlyric, lyric } = await window.api.music.requestSdk('getLyric', {
|
||||||
|
source: toRaw(songInfo.source),
|
||||||
|
songInfo: toRaw(songInfo) as any
|
||||||
|
})
|
||||||
|
console.log(songInfo)
|
||||||
|
songInfo.lrc = crlyric && songInfo.source !== 'tx' ? crlyric : lyric
|
||||||
|
|
||||||
|
// 显示音质选择弹窗
|
||||||
|
const selectedQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
|
||||||
|
// 如果用户取消选择,直接返回
|
||||||
|
if (!selectedQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let quality = selectedQuality
|
||||||
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
|
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
|
||||||
|
|
||||||
// 如果是特殊音质,先尝试获取对应链接
|
// 如果选择的是特殊音质,先尝试下载
|
||||||
if (isSpecialQuality) {
|
if (isSpecialQuality) {
|
||||||
try {
|
try {
|
||||||
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
|
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
|
||||||
@@ -47,13 +199,14 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||||
source: songInfo.source,
|
source: songInfo.source,
|
||||||
quality,
|
quality,
|
||||||
songInfo: toRaw(songInfo)
|
songInfo: toRaw(songInfo) as any,
|
||||||
|
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
|
||||||
})
|
})
|
||||||
|
|
||||||
; (await tip).close()
|
;(await tip).close()
|
||||||
|
|
||||||
// 如果成功获取特殊音质链接,处理结果并返回
|
// 如果成功获取特殊音质链接,处理结果并返回
|
||||||
if (specialResult && 'error' in specialResult && !specialResult.error) {
|
if (specialResult) {
|
||||||
if (!Object.hasOwn(specialResult, 'path')) {
|
if (!Object.hasOwn(specialResult, 'path')) {
|
||||||
MessagePlugin.info(specialResult.message)
|
MessagePlugin.info(specialResult.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -65,34 +218,48 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`下载${qualityMap[quality]}音质失败,回退到标准逻辑`)
|
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
|
||||||
// 如果获取特殊音质失败,继续执行原有逻辑
|
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||||
} catch (specialError) {
|
|
||||||
console.log(`下载${qualityMap[quality]}音质出错,回退到标准逻辑:`, specialError)
|
// 特殊音质下载失败,重新弹出选择框
|
||||||
// 特殊音质获取失败,继续执行原有逻辑
|
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
if (!retryQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quality = retryQuality
|
||||||
|
} catch (specialError) {
|
||||||
|
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
|
||||||
|
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||||
|
|
||||||
|
// 特殊音质下载出错,重新弹出选择框
|
||||||
|
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
if (!retryQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quality = retryQuality
|
||||||
}
|
}
|
||||||
MessagePlugin.error('下载失败了,向下兼容音质')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有逻辑:检查歌曲支持的最高音质
|
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||||
if (
|
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
|
||||||
qualityKey.indexOf(quality) >
|
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
|
||||||
qualityKey.indexOf(
|
quality = songMaxQuality
|
||||||
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
|
||||||
)
|
|
||||||
) {
|
|
||||||
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
||||||
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
||||||
|
|
||||||
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
||||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||||
source: songInfo.source,
|
source: songInfo.source,
|
||||||
quality,
|
quality,
|
||||||
songInfo: toRaw(songInfo)
|
songInfo: toRaw(songInfo) as any,
|
||||||
|
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
|
||||||
})
|
})
|
||||||
; (await tip).close()
|
|
||||||
|
;(await tip).close()
|
||||||
|
|
||||||
if (!Object.hasOwn(result, 'path')) {
|
if (!Object.hasOwn(result, 'path')) {
|
||||||
MessagePlugin.info(result.message)
|
MessagePlugin.info(result.message)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ type PlaylistEvents = {
|
|||||||
// 创建全局事件总线
|
// 创建全局事件总线
|
||||||
const emitter = mitt<PlaylistEvents>()
|
const emitter = mitt<PlaylistEvents>()
|
||||||
|
|
||||||
// 将事件总线挂载到全局
|
// 将事件总线挂载到全局
|
||||||
; (window as any).musicEmitter = emitter
|
;(window as any).musicEmitter = emitter
|
||||||
const qualityMap: Record<string, string> = {
|
const qualityMap: Record<string, string> = {
|
||||||
'128k': '标准音质',
|
'128k': '标准音质',
|
||||||
'192k': '高品音质',
|
'192k': '高品音质',
|
||||||
@@ -53,7 +53,10 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 如果成功获取特殊音质链接,直接返回
|
// 如果成功获取特殊音质链接,直接返回
|
||||||
if (typeof specialUrlData === 'string' || (typeof specialUrlData === 'object' && !specialUrlData.error)) {
|
if (
|
||||||
|
typeof specialUrlData === 'string' ||
|
||||||
|
(typeof specialUrlData === 'object' && !specialUrlData.error)
|
||||||
|
) {
|
||||||
console.log(`成功获取${qualityMap[quality]}链接`)
|
console.log(`成功获取${qualityMap[quality]}链接`)
|
||||||
return specialUrlData as string
|
return specialUrlData as string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, toRaw } from 'vue'
|
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
|
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
singer: string
|
singer: string
|
||||||
@@ -191,7 +190,7 @@ const handlePause = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (song: MusicItem) => {
|
const handleDownload = (song: any) => {
|
||||||
downloadSingleSong(song)
|
downloadSingleSong(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +201,109 @@ const handleAddToPlaylist = (song: MusicItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从本地歌单移出歌曲
|
||||||
|
const handleRemoveFromLocalPlaylist = async (song: MusicItem) => {
|
||||||
|
try {
|
||||||
|
const result = await window.api.songList.removeSongs(playlistInfo.value.id, [song.songmid])
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 从当前歌曲列表中移除
|
||||||
|
const index = songs.value.findIndex((s) => s.songmid === song.songmid)
|
||||||
|
if (index !== -1) {
|
||||||
|
songs.value.splice(index, 1)
|
||||||
|
// 更新歌单信息中的歌曲总数
|
||||||
|
playlistInfo.value.total = songs.value.length
|
||||||
|
}
|
||||||
|
MessagePlugin.success(`已将"${song.name}"从歌单中移出`)
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(result.error || '移出歌曲失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移出歌曲失败:', error)
|
||||||
|
MessagePlugin.error('移出歌曲失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是本地歌单
|
||||||
|
const isLocalPlaylist = computed(() => {
|
||||||
|
return route.query.type === 'local' || route.query.source === 'local'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件选择器引用
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// 滚动相关状态
|
||||||
|
const scrollY = ref(0)
|
||||||
|
const isHeaderCompact = ref(false)
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
const songListRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 点击封面修改图片(仅本地歌单)
|
||||||
|
const handleCoverClick = () => {
|
||||||
|
if (!isLocalPlaylist.value) return
|
||||||
|
|
||||||
|
// 触发文件选择器
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileSelect = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
MessagePlugin.error('请选择图片文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小(限制为5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
MessagePlugin.error('图片文件大小不能超过5MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 读取文件为base64
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const base64Data = e.target?.result as string
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用API更新歌单封面
|
||||||
|
const result = await window.api.songList.updateCover(playlistInfo.value.id, base64Data)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 更新本地显示的封面
|
||||||
|
playlistInfo.value.cover = base64Data
|
||||||
|
MessagePlugin.success('封面更新成功')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(result.error || '封面更新失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新封面失败:', error)
|
||||||
|
MessagePlugin.error('封面更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
MessagePlugin.error('读取图片文件失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理图片文件失败:', error)
|
||||||
|
MessagePlugin.error('处理图片文件失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空文件选择器的值,以便可以重复选择同一个文件
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
// 替换播放列表的通用函数
|
// 替换播放列表的通用函数
|
||||||
const replacePlaylist = (songsToReplace: MusicItem[], shouldShuffle = false) => {
|
const replacePlaylist = (songsToReplace: MusicItem[], shouldShuffle = false) => {
|
||||||
if (!(window as any).musicEmitter) {
|
if (!(window as any).musicEmitter) {
|
||||||
@@ -287,28 +389,89 @@ const handleShufflePlaylist = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 滚动事件处理
|
||||||
|
const handleScroll = (event?: Event) => {
|
||||||
|
let scrollTop = 0
|
||||||
|
|
||||||
|
if (event && event.target) {
|
||||||
|
scrollTop = (event.target as HTMLElement).scrollTop
|
||||||
|
} else if (scrollContainer.value) {
|
||||||
|
scrollTop = scrollContainer.value.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollY.value = scrollTop
|
||||||
|
// 当滚动超过100px时,启用紧凑模式
|
||||||
|
isHeaderCompact.value = scrollY.value > 100
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时获取数据
|
// 组件挂载时获取数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPlaylistSongs()
|
fetchPlaylistSongs()
|
||||||
|
|
||||||
|
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
|
||||||
|
setTimeout(() => {
|
||||||
|
// 查找 SongVirtualList 内部的虚拟滚动容器
|
||||||
|
const virtualListContainer = document.querySelector('.virtual-scroll-container')
|
||||||
|
|
||||||
|
if (virtualListContainer) {
|
||||||
|
scrollContainer.value = virtualListContainer as HTMLElement
|
||||||
|
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
console.log('滚动监听器已添加到:', virtualListContainer)
|
||||||
|
} else {
|
||||||
|
console.warn('未找到虚拟滚动容器')
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时清理事件监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (scrollContainer.value) {
|
||||||
|
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="list-container">
|
<div class="list-container">
|
||||||
<!-- 固定头部区域 -->
|
<!-- 固定头部区域 -->
|
||||||
<div class="fixed-header">
|
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
|
||||||
<!-- 歌单信息 -->
|
<!-- 歌单信息 -->
|
||||||
<div class="playlist-header">
|
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
|
||||||
<div class="playlist-cover">
|
<div
|
||||||
|
class="playlist-cover"
|
||||||
|
:class="{ clickable: isLocalPlaylist }"
|
||||||
|
@click="handleCoverClick"
|
||||||
|
>
|
||||||
<img :src="playlistInfo.cover" :alt="playlistInfo.title" />
|
<img :src="playlistInfo.cover" :alt="playlistInfo.title" />
|
||||||
|
<!-- 本地歌单显示编辑提示 -->
|
||||||
|
<div v-if="isLocalPlaylist" class="cover-overlay">
|
||||||
|
<svg class="edit-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>点击修改封面</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 隐藏的文件选择器 -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
<div class="playlist-details">
|
<div class="playlist-details">
|
||||||
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
||||||
<p class="playlist-author">by {{ playlistInfo.author }}</p>
|
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
|
||||||
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
|
by {{ playlistInfo.author }}
|
||||||
|
</p>
|
||||||
|
<p class="playlist-stats" :class="{ hidden: isHeaderCompact }">
|
||||||
|
{{ playlistInfo.total || songs.length }} 首歌曲
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- 播放控制按钮 -->
|
<!-- 播放控制按钮 -->
|
||||||
<div class="playlist-actions">
|
<div class="playlist-actions" :class="{ compact: isHeaderCompact }">
|
||||||
<t-button
|
<t-button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
size="medium"
|
size="medium"
|
||||||
@@ -356,16 +519,21 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="song-list-wrapper">
|
<div v-else class="song-list-wrapper">
|
||||||
<SongVirtualList
|
<SongVirtualList
|
||||||
|
ref="songListRef"
|
||||||
:songs="songs"
|
:songs="songs"
|
||||||
:current-song="currentSong"
|
:current-song="currentSong"
|
||||||
:is-playing="isPlaying"
|
:is-playing="isPlaying"
|
||||||
:show-index="true"
|
:show-index="true"
|
||||||
:show-album="true"
|
:show-album="true"
|
||||||
:show-duration="true"
|
:show-duration="true"
|
||||||
|
:is-local-playlist="isLocalPlaylist"
|
||||||
|
:playlist-id="playlistInfo.id"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@pause="handlePause"
|
@pause="handlePause"
|
||||||
@download="handleDownload"
|
@download="handleDownload"
|
||||||
@add-to-playlist="handleAddToPlaylist"
|
@add-to-playlist="handleAddToPlaylist"
|
||||||
|
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
|
||||||
|
@scroll="handleScroll"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,7 +543,7 @@ onMounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.list-container {
|
.list-container {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #fafafa;
|
// background: #fafafa;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -444,56 +612,160 @@ onMounted(() => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact .playlist-cover {
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px !important;
|
||||||
|
}
|
||||||
.playlist-cover {
|
.playlist-cover {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地歌单封面可点击样式
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.cover-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.edit-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-details {
|
.playlist-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.playlist-title {
|
.playlist-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-header.compact & {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-author {
|
.playlist-author {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-stats {
|
.playlist-stats {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-actions {
|
.playlist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.play-btn,
|
.play-btn,
|
||||||
.shuffle-btn {
|
.shuffle-btn {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-actions.compact & {
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.play-icon,
|
.play-icon,
|
||||||
.shuffle-icon {
|
.shuffle-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-actions.compact & {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
import { ref, onMounted, computed, toRaw } from 'vue'
|
import { ref, onMounted, computed, toRaw } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { Edit2Icon, ListIcon } from 'tdesign-icons-vue-next'
|
import { Edit2Icon, PlayCircleIcon, DeleteIcon, ViewListIcon } from 'tdesign-icons-vue-next'
|
||||||
import songListAPI from '@renderer/api/songList'
|
import songListAPI from '@renderer/api/songList'
|
||||||
import type { SongList, Songs } from '@common/types/songList'
|
import type { SongList, Songs } from '@common/types/songList'
|
||||||
import defaultCover from '/default-cover.png'
|
import defaultCover from '/default-cover.png'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
|
import ContextMenu from '@renderer/components/ContextMenu/ContextMenu.vue'
|
||||||
|
import {
|
||||||
|
createMenuItem,
|
||||||
|
createSeparator,
|
||||||
|
calculateMenuPosition
|
||||||
|
} from '@renderer/components/ContextMenu/utils'
|
||||||
|
import type { ContextMenuItem, ContextMenuPosition } from '@renderer/components/ContextMenu/types'
|
||||||
|
|
||||||
// 扩展 Songs 类型以包含本地音乐的额外属性
|
// 扩展 Songs 类型以包含本地音乐的额外属性
|
||||||
interface LocalSong extends Songs {
|
interface LocalSong extends Songs {
|
||||||
@@ -134,6 +141,11 @@ const editPlaylistForm = ref({
|
|||||||
// 当前编辑的歌单
|
// 当前编辑的歌单
|
||||||
const currentEditingPlaylist = ref<SongList | null>(null)
|
const currentEditingPlaylist = ref<SongList | null>(null)
|
||||||
|
|
||||||
|
// 右键菜单状态
|
||||||
|
const contextMenuVisible = ref(false)
|
||||||
|
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
|
const contextMenuPlaylist = ref<SongList | null>(null)
|
||||||
|
|
||||||
// 将时长字符串转换为秒数
|
// 将时长字符串转换为秒数
|
||||||
const parseInterval = (interval: string): number => {
|
const parseInterval = (interval: string): number => {
|
||||||
if (!interval) return 0
|
if (!interval) return 0
|
||||||
@@ -830,6 +842,80 @@ const deleteSong = (song: Songs): void => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 右键菜单项配置
|
||||||
|
const contextMenuItems = computed((): ContextMenuItem[] => {
|
||||||
|
if (!contextMenuPlaylist.value) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
createMenuItem('play', '播放歌单', {
|
||||||
|
icon: PlayCircleIcon,
|
||||||
|
onClick: () => {
|
||||||
|
if (contextMenuPlaylist.value) {
|
||||||
|
playPlaylist(contextMenuPlaylist.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
createMenuItem('view', '查看详情', {
|
||||||
|
icon: ViewListIcon,
|
||||||
|
onClick: () => {
|
||||||
|
if (contextMenuPlaylist.value) {
|
||||||
|
viewPlaylist(contextMenuPlaylist.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
createSeparator(),
|
||||||
|
createMenuItem('edit', '编辑歌单', {
|
||||||
|
icon: Edit2Icon,
|
||||||
|
onClick: () => {
|
||||||
|
if (contextMenuPlaylist.value) {
|
||||||
|
editPlaylist(contextMenuPlaylist.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
createMenuItem('delete', '删除歌单', {
|
||||||
|
icon: DeleteIcon,
|
||||||
|
onClick: async () => {
|
||||||
|
if (contextMenuPlaylist.value) {
|
||||||
|
try {
|
||||||
|
const result = await songListAPI.delete(contextMenuPlaylist.value.id)
|
||||||
|
if (result.success) {
|
||||||
|
MessagePlugin.success('歌单删除成功')
|
||||||
|
await loadPlaylists()
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(result.error || '删除歌单失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除歌单失败:', error)
|
||||||
|
MessagePlugin.error('删除歌单失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理歌单右键菜单
|
||||||
|
const handlePlaylistContextMenu = (event: MouseEvent, playlist: SongList) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
contextMenuPlaylist.value = playlist
|
||||||
|
contextMenuPosition.value = calculateMenuPosition(event)
|
||||||
|
contextMenuVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理右键菜单项点击
|
||||||
|
const handleContextMenuItemClick = (_item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
|
// 菜单项的 onClick 回调已经在 ContextMenuItem 组件中调用
|
||||||
|
// 这里不需要额外处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭右键菜单
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
contextMenuVisible.value = false
|
||||||
|
contextMenuPlaylist.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
// 组件挂载时加载数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPlaylists()
|
loadPlaylists()
|
||||||
@@ -894,7 +980,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 歌单网格 -->
|
<!-- 歌单网格 -->
|
||||||
<div v-else-if="playlists.length > 0" class="playlists-grid">
|
<div v-else-if="playlists.length > 0" class="playlists-grid">
|
||||||
<div v-for="playlist in playlists" :key="playlist.id" class="playlist-card">
|
<div
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-card"
|
||||||
|
@contextmenu="handlePlaylistContextMenu($event, playlist)"
|
||||||
|
>
|
||||||
<div class="playlist-cover" @click="viewPlaylist(playlist)">
|
<div class="playlist-cover" @click="viewPlaylist(playlist)">
|
||||||
<img
|
<img
|
||||||
v-if="playlist.coverImgUrl"
|
v-if="playlist.coverImgUrl"
|
||||||
@@ -944,7 +1035,11 @@ onMounted(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
@click="viewPlaylist(playlist)"
|
@click="viewPlaylist(playlist)"
|
||||||
>
|
>
|
||||||
<ListIcon />
|
<view-list-icon
|
||||||
|
:fill-color="'transparent'"
|
||||||
|
:stroke-color="'#000000'"
|
||||||
|
:stroke-width="1.5"
|
||||||
|
/>
|
||||||
</t-button>
|
</t-button>
|
||||||
</t-tooltip>
|
</t-tooltip>
|
||||||
<t-tooltip content="编辑歌单">
|
<t-tooltip content="编辑歌单">
|
||||||
@@ -1311,6 +1406,15 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t-dialog>
|
</t-dialog>
|
||||||
|
|
||||||
|
<!-- 歌单右键菜单 -->
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="contextMenuVisible"
|
||||||
|
:position="contextMenuPosition"
|
||||||
|
:items="contextMenuItems"
|
||||||
|
@item-click="handleContextMenuItemClick"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const isPlaying = ref(false)
|
|||||||
const search = searchValue()
|
const search = searchValue()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const localUserStore = LocalUserDetailStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
search,
|
search,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -42,6 +44,17 @@ onMounted(async () => {
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听 userSource 变化,重新加载页面
|
||||||
|
watch(
|
||||||
|
() => localUserStore.userSource,
|
||||||
|
async () => {
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
await performSearch(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 执行搜索
|
// 执行搜索
|
||||||
@@ -133,7 +146,7 @@ const handlePause = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (song: MusicItem) => {
|
const handleDownload = (song: any) => {
|
||||||
downloadSingleSong(song)
|
downloadSingleSong(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettin
|
|||||||
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
|
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
|
||||||
import Versions from '@renderer/components/Versions.vue'
|
import Versions from '@renderer/components/Versions.vue'
|
||||||
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
|
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
|
||||||
|
import { useSettingsStore } from '@renderer/store/Settings'
|
||||||
const Store = LocalUserDetailStore()
|
const Store = LocalUserDetailStore()
|
||||||
const { userInfo } = storeToRefs(Store)
|
const { userInfo } = storeToRefs(Store)
|
||||||
|
|
||||||
|
// 设置存储
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const { settings } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
// 当前选择的设置分类
|
// 当前选择的设置分类
|
||||||
const activeCategory = ref<string>('appearance')
|
const activeCategory = ref<string>('appearance')
|
||||||
// 应用版本号
|
// 应用版本号
|
||||||
@@ -308,6 +313,30 @@ const getCurrentSourceName = () => {
|
|||||||
const openLink = (url: string) => {
|
const openLink = (url: string) => {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签写入选项
|
||||||
|
const tagWriteOptions = ref({
|
||||||
|
basicInfo: settings.value.tagWriteOptions?.basicInfo ?? true,
|
||||||
|
cover: settings.value.tagWriteOptions?.cover ?? true,
|
||||||
|
lyrics: settings.value.tagWriteOptions?.lyrics ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新标签写入选项
|
||||||
|
const updateTagWriteOptions = () => {
|
||||||
|
settingsStore.updateSettings({
|
||||||
|
tagWriteOptions: { ...tagWriteOptions.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签选项状态描述
|
||||||
|
const getTagOptionsStatus = () => {
|
||||||
|
const enabled: string[] = []
|
||||||
|
if (tagWriteOptions.value.basicInfo) enabled.push('基础信息')
|
||||||
|
if (tagWriteOptions.value.cover) enabled.push('封面')
|
||||||
|
if (tagWriteOptions.value.lyrics) enabled.push('歌词')
|
||||||
|
|
||||||
|
return enabled.length > 0 ? enabled.join('、') : '未选择任何选项'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -582,6 +611,44 @@ const openLink = (url: string) => {
|
|||||||
<div style="margin-top: 20px" class="setting-group">
|
<div style="margin-top: 20px" class="setting-group">
|
||||||
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
|
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签写入设置 -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>下载标签写入设置</h3>
|
||||||
|
<p>选择下载歌曲时要写入的标签信息</p>
|
||||||
|
|
||||||
|
<div class="tag-options">
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.basicInfo" @change="updateTagWriteOptions">
|
||||||
|
基础信息
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">包括歌曲标题、艺术家、专辑名称等基本信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.cover" @change="updateTagWriteOptions">
|
||||||
|
封面
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">将专辑封面嵌入到音频文件中</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.lyrics" @change="updateTagWriteOptions">
|
||||||
|
普通歌词
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">将歌词信息写入到音频文件的标签中</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-options-status">
|
||||||
|
<div class="status-summary">
|
||||||
|
<span class="status-label">当前配置:</span>
|
||||||
|
<span class="status-value">
|
||||||
|
{{ getTagOptionsStatus() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 关于页面 -->
|
<!-- 关于页面 -->
|
||||||
@@ -1783,6 +1850,53 @@ const openLink = (url: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签写入设置样式
|
||||||
|
.tag-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.tag-option {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
margin: 0.5rem 0 0 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-options-status {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.status-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 响应式适配
|
// 响应式适配
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
|
|||||||
Reference in New Issue
Block a user