Compare commits

...

11 Commits

Author SHA1 Message Date
sqj
6f56f5e240 fix: flac格式使用ffmpeg 修复高音质下载失效 2025-09-27 08:07:49 +08:00
sqj
7af7779e5c fix: flac格式使用ffmpeg 2025-09-27 07:37:20 +08:00
sqj
669a348218 fix: flac格式使用ffmpeg 2025-09-27 07:35:42 +08:00
sqj
f02264c80c 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-27 00:14:45 +08:00
sqj
d0d5f918bd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:48:21 +08:00
sqj
761d265d18 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:27:50 +08:00
sqj
204df64535 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:25:22 +08:00
sqj
cc814eddbd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:08:18 +08:00
sqj
51df14a9e9 1. 歌单
- 新增右键移除歌曲
   - local 页歌单右键操作
   - 歌单页支持修改封面
2. debug:右键菜单二级菜单位置决策
2025-09-25 19:56:45 +08:00
sqj
2473b36928 feat:列表新增右键菜单;fix:播放列表滚动条,搜索页切换源重新加载 2025-09-25 02:43:02 +08:00
sqj
dbba7a3d26 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:36:07 +08:00
40 changed files with 6062 additions and 7486 deletions

View File

@@ -94,13 +94,13 @@ jobs:
# 获取当前标签对应的 release 信息
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
# 获取 release 详细信息
response=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
release_id=$(echo "$response" | jq -r '.id')
echo "release_id=$release_id" >> $GITHUB_OUTPUT
echo "找到 Release ID: $release_id"
@@ -109,7 +109,7 @@ jobs:
run: |
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
echo "Release ID: $RELEASE_ID"
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
@@ -131,7 +131,7 @@ jobs:
# 先创建版本目录
dir_path="/yd/ceru/$TAG_NAME"
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
echo "创建版本目录: $dir_path"
curl -s -X MKCOL \
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
@@ -140,7 +140,7 @@ jobs:
# 处理每个asset
success_count=0
failed_count=0
for i in $(seq 0 $(($assets_count - 1))); do
asset=$(echo "$assets_json" | jq -c ".[$i]")
asset_name=$(echo "$asset" | jq -r '.name')
@@ -198,7 +198,7 @@ jobs:
failed_count=$((failed_count + 1))
fi
done
echo "========================================"
echo "🎉 同步完成!"
echo "成功: $success_count 个文件"

View File

@@ -7,8 +7,8 @@ export default defineConfig({
base: '/',
description:
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
markdown:{
config(md){
markdown: {
config(md) {
md.use(note)
}
},
@@ -28,13 +28,11 @@ export default defineConfig({
{ text: '安装教程', link: '/guide/' },
{
text: '使用教程',
items: [
{ text: '音乐播放列表', link: '/guide/used/playList' },
]
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
},
{ text: '软件设计文档', link: '/guide/design' },
{ 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/CeruMusicPluginDev' }
]
},{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],
@@ -64,21 +65,20 @@ export default defineConfig({
provider: 'local'
},
outline: {
level: [2,4],
level: [2, 4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新',
lastUpdatedText: '上次更新'
},
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -168,15 +168,15 @@ html.dark #app {
// --autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
// --autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
// --autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
// --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目录自动编号与上面一样即可 */
// --autonum-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --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-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --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) ". ";
/* 主题颜色 */
@@ -284,4 +284,4 @@ html .vp-doc div[class*='language-'] pre {
}
.VPDoc.has-aside .content-container {
max-width: none !important;
}
}

View File

@@ -3,6 +3,7 @@
## 概述
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
@@ -67,7 +68,7 @@ const sources = {
qualities: ['128k', '320k', 'flac', 'flac24bit']
},
tx:{
name: "QQ音乐",
name: "QQ音乐",
qualities: ['128k', '320k', 'flac']
}
};
@@ -132,23 +133,21 @@ module.exports = {
> #### PS:
>
> - `sources key` 取值
>
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
>
> - 导出
>
> ```javascript
> module.exports = {
> sources, // 你的音源支持
> };
> sources // 你的音源支持
> }
> ```
>
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
>
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
@@ -157,8 +156,6 @@ module.exports = {
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### CeruMusic API 参考
#### cerumusic.request(url, options)
@@ -166,6 +163,7 @@ module.exports = {
HTTP 请求方法,返回 Promise。
**参数:**
- `url` (string): 请求地址
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
@@ -174,6 +172,7 @@ HTTP 请求方法,返回 Promise。
- `timeout`: 超时时间(毫秒)
**返回值:**
```javascript
{
statusCode: 200,
@@ -206,16 +205,17 @@ cerumusic.utils.crypto.rsaEncrypt(data, key)
cerumusic.NoticeCenter('info', {
title: '通知标题',
content: '通知内容',
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
version: '版本号', // 当通知为update 版本跟新可传
pluginInfo: {
name: '插件名称',
type: 'cr', // 固定唯一标识
}// 当通知为update 版本跟新可传
});
type: 'cr' // 固定唯一标识
} // 当通知为update 版本跟新可传
})
```
**通知类型:**
- `'info'`: 信息通知
- `'success'`: 成功通知
- `'warn'`: 警告通知
@@ -247,46 +247,47 @@ const qualitys = {
'128k': '128',
'320k': '320',
flac: 'flac',
flac24bit: 'flac24bit',
flac24bit: 'flac24bit'
},
local: {},
local: {}
}
// HTTP 请求封装
const httpRequest = (url, options) => new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
const httpRequest = (url, options) =>
new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
})
})
})
// API 实现
const apis = {
kw: {
musicUrl({ songmid }, quality) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
}
},
local: {
musicUrl(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
pic(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
lyric(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return {
lyric: '...', // 歌曲歌词
tlyric: '...', // 翻译歌词,没有可为 null
rlyric: '...', // 罗马音歌词,没有可为 null
lxlyric: '...', // lx 逐字歌词,没有可为 null
lxlyric: '...' // lx 逐字歌词,没有可为 null
}
})
}
@@ -313,15 +314,15 @@ send(EVENT_NAMES.inited, {
name: '酷我音乐',
type: 'music',
actions: ['musicUrl'],
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
local: {
name: '本地音乐',
type: 'music',
actions: ['musicUrl', 'lyric', 'pic'],
qualitys: [],
},
},
qualitys: []
}
}
})
```
@@ -342,8 +343,8 @@ send(EVENT_NAMES.inited, {
```javascript
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result);
});
return Promise.resolve(result)
})
```
#### globalThis.lx.send(eventName, data)
@@ -369,18 +370,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
HTTP 请求方法:
```javascript
lx.request('https://api.example.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
}, (err, resp) => {
if (err) {
console.error('请求失败:', err);
return;
lx.request(
'https://api.example.com',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
},
(err, resp) => {
if (err) {
console.error('请求失败:', err)
return
}
console.log('响应:', resp.body)
}
console.log('响应:', resp.body);
});
)
```
#### globalThis.lx.utils
@@ -433,28 +438,28 @@ async function musicUrl(source, musicInfo, quality) {
try {
// 参数验证
if (!musicInfo || !musicInfo.id) {
throw new Error('音乐信息不完整');
throw new Error('音乐信息不完整')
}
// API 调用
const result = await cerumusic.request(url, options);
const result = await cerumusic.request(url, options)
// 结果验证
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) {
throw new Error('返回数据格式错误');
throw new Error('返回数据格式错误')
}
return result.body.url;
return result.body.url
} 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
```javascript
console.log('[插件名] 调试信息:', data);
console.warn('[插件名] 警告信息:', warning);
console.error('[插件名] 错误信息:', error);
console.log('[插件名] 调试信息:', data)
console.warn('[插件名] 警告信息:', warning)
console.error('[插件名] 错误信息:', error)
```
### 2. LX 插件开发者工具
@@ -491,8 +496,8 @@ send(EVENT_NAMES.inited, {
```javascript
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
console.error('未处理的 Promise 拒绝:', reason)
})
```
---
@@ -502,17 +507,17 @@ process.on('unhandledRejection', (reason, promise) => {
### 1. 请求缓存
```javascript
const cache = new Map();
const cache = new Map()
async function getCachedData(key, fetcher, ttl = 300000) {
const cached = cache.get(key);
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
return cached.data
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
const data = await fetcher()
cache.set(key, { data, timestamp: Date.now() })
return data
}
```
@@ -521,21 +526,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
```javascript
const result = await cerumusic.request(url, {
timeout: 10000 // 10秒超时
});
})
```
### 3. 并发控制
```javascript
// 限制并发请求数量
const semaphore = new Semaphore(3); // 最多3个并发请求
const semaphore = new Semaphore(3) // 最多3个并发请求
async function limitedRequest(url, options) {
await semaphore.acquire();
await semaphore.acquire()
try {
return await cerumusic.request(url, options);
return await cerumusic.request(url, options)
} finally {
semaphore.release();
semaphore.release()
}
}
```
@@ -549,14 +554,14 @@ async function limitedRequest(url, options) {
```javascript
function validateMusicInfo(musicInfo) {
if (!musicInfo || typeof musicInfo !== 'object') {
throw new Error('音乐信息格式错误');
throw new Error('音乐信息格式错误')
}
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
function isValidUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false;
return false
}
}
```
@@ -581,7 +586,7 @@ console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
});
})
```
---
@@ -605,13 +610,13 @@ async function testMusicUrl() {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
};
}
try {
const url = await musicUrl('kw', testMusicInfo, '320k');
console.log('测试通过:', url);
const url = await musicUrl('kw', testMusicInfo, '320k')
console.log('测试通过:', url)
} catch (error) {
console.error('测试失败:', error);
console.error('测试失败:', error)
}
}
```
@@ -619,6 +624,7 @@ async function testMusicUrl() {
### 3. 版本管理
使用语义化版本号:
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
@@ -631,6 +637,7 @@ async function testMusicUrl() {
### Q: 插件加载失败怎么办?
A: 检查以下几点:
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
@@ -645,20 +652,21 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
```javascript
cerumusic.NoticeCenter('update',{
title:'新版本更新',
content:'xxxx',
cerumusic.NoticeCenter('update', {
title: '新版本更新',
content: 'xxxx',
version: 'v1.0.3',
url:'https://shiqianjiang.cn',
pluginInfo:{
type:'cr'
url: 'https://shiqianjiang.cn',
pluginInfo: {
type: 'cr'
}
})
```
### Q: 如何调试插件?
A:
A:
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
@@ -668,5 +676,6 @@ A:
## 技术支持
如有问题或建议,请通过以下方式联系:
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,8 @@
# 赞助名单
## 鸣谢
| 昵称 | 赞助金额 |
| :------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |

View File

@@ -2,11 +2,14 @@
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
- [ ] 导航上面这几个按钮可以稍微优化一下
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
- [x] 点击搜索框的 源图标实现快速切换
- [ ] ai功能完善
- [ ] 支持歌词隐藏
- [x] 兼容多平台歌单导入
- [ ] 软件能不能记住上次打开的窗口大小,每次都要手动拉
- [x] 软件能不能记住上次打开的窗口大小,每次都要手动拉
- [x] 歌单右键菜单
- [x] 播放列表滚动条适配
- [ ] 暗色主题
- [x] 歌单页支持修改封面

View File

@@ -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. 目录结构调整
2. **支持插件更新提示**
@@ -13,13 +47,11 @@
**洛雪** 插件请手动重装适配
3. **debug**
- SMTC 问题
- 歌曲缓存播放多次请求和多次缓存问题
- ###### 2025-9-17 **V1.3.1**
- ###### 2025-9-17 **v1.3.1**
1. **设置功能页**
- 缓存路径支持自定义
- 下载路径支持自定义
@@ -27,4 +59,4 @@
- 播放页面唱针可以拖动问题
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
- **SMTC** 功能 系统显示**未知应用**问题
- 播放页歌词**字体粗细**偶现丢失问题
- 播放页歌词**字体粗细**偶现丢失问题

View File

@@ -23,6 +23,4 @@
歌单将自动选取第一首 **有效封面**[^1] 为歌单
[^1]: url正确的歌曲封面
[^1]: url正确的歌曲封面

View File

@@ -2,24 +2,24 @@
// 这个文件可以用来测试 NoticeCenter 功能
const pluginInfo = {
name: "测试通知插件",
version: "1.0.0",
author: "CeruMusic Team",
description: "用于测试插件通知功能的示例插件",
type: "cr"
name: '测试通知插件',
version: '1.0.0',
author: 'CeruMusic Team',
description: '用于测试插件通知功能的示例插件',
type: 'cr'
}
const sources = [
{
name: "test",
qualities: ["128k", "320k"]
name: 'test',
qualities: ['128k', '320k']
}
]
// 模拟音乐URL获取函数
async function musicUrl(source, musicInfo, quality) {
console.log('测试插件获取音乐URL')
// 测试不同类型的通知
setTimeout(() => {
// 测试信息通知
@@ -29,7 +29,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '插件正在正常工作'
})
}, 1000)
setTimeout(() => {
// 测试警告通知
this.cerumusic.NoticeCenter('warning', {
@@ -38,7 +38,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '请注意某些设置'
})
}, 2000)
setTimeout(() => {
// 测试成功通知
this.cerumusic.NoticeCenter('success', {
@@ -47,7 +47,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '音乐URL获取成功'
})
}, 3000)
setTimeout(() => {
// 测试更新通知
this.cerumusic.NoticeCenter('update', {
@@ -62,7 +62,7 @@ async function musicUrl(source, musicInfo, quality) {
}
})
}, 4000)
setTimeout(() => {
// 测试错误通知
this.cerumusic.NoticeCenter('error', {
@@ -71,7 +71,7 @@ async function musicUrl(source, musicInfo, quality) {
error: '模拟的错误信息'
})
}, 5000)
// 返回一个测试URL
return 'https://example.com/test-music.mp3'
}
@@ -81,4 +81,4 @@ module.exports = {
pluginInfo,
sources,
musicUrl
}
}

View File

@@ -81,27 +81,27 @@ this.cerumusic.NoticeCenter('update', {
#### 通用参数 (data 对象)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
| 参数 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------ |
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
#### 更新通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------------ | ---- | ---------------- |
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
#### 错误通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| error | string | 否 | 具体错误信息 |
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------ | ---- | ------------ |
| error | string | 否 | 具体错误信息 |
## 实现原理
@@ -208,8 +208,9 @@ window.api.on('plugin-notice', (_, notice) => {
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制
- ✨ 完善的错误处理机制

View File

@@ -12,6 +12,7 @@ files:
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
- node_modules/ffmpeg-static/**
win:
executableName: ceru-music
icon: 'resources/icons/icon.ico'

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.5",
"version": "1.3.9",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -44,6 +44,7 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
@@ -53,6 +54,8 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -63,7 +66,9 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"pinia": "^3.0.3",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
"zlib": "^1.0.5"
@@ -102,4 +107,4 @@
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.3"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
version: "1.0"
version: '1.0'
profile:
name: qodana.starter

View File

@@ -1,55 +1,79 @@
const fs = require('fs');
const path = require('path');
const fs = require('fs')
const path = require('path')
function generateTree(
dir,
prefix = '',
isLast = true,
excludeDirs = [
'node_modules',
'dist',
'out',
'.git',
'.kiro',
'.idea',
'.codebuddy',
'.vscode',
'.workflow',
'assets',
'resources',
'docs'
]
) {
const basename = path.basename(dir)
function generateTree(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)) {
return;
if (
basename.startsWith('.') &&
basename !== '.' &&
basename !== '..' &&
!['.github', '.workflow'].includes(basename)
) {
return
}
if (excludeDirs.includes(basename)) {
return;
return
}
// 当前项目显示
if (prefix === '') {
console.log(`${basename}/`);
console.log(`${basename}/`)
} else {
const connector = isLast ? '└── ' : '├── ';
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
console.log(prefix + connector + displayName);
const connector = isLast ? '└── ' : '├── '
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
console.log(prefix + connector + displayName)
}
if (!fs.statSync(dir).isDirectory()) {
return;
return
}
try {
const items = fs.readdirSync(dir)
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter(item => !excludeDirs.includes(item))
const items = fs
.readdirSync(dir)
.filter((item) => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter((item) => !excludeDirs.includes(item))
.sort((a, b) => {
// 目录排在前面,文件排在后面
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
});
const newPrefix = prefix + (isLast ? ' ' : '│ ');
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory()
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory()
if (aIsDir && !bIsDir) return -1
if (!aIsDir && bIsDir) return 1
return a.localeCompare(b)
})
const newPrefix = prefix + (isLast ? ' ' : '│ ')
items.forEach((item, index) => {
const isLastItem = index === items.length - 1;
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
});
const isLastItem = index === items.length - 1
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
})
} catch (error) {
console.error(`Error reading directory: ${dir}`, error.message);
console.error(`Error reading directory: ${dir}`, error.message)
}
}
// 使用示例
const targetDir = process.argv[2] || '.';
console.log('项目文件结构:');
generateTree(targetDir);
const targetDir = process.argv[2] || '.'
console.log('项目文件结构:')
generateTree(targetDir)

View File

@@ -19,9 +19,416 @@ import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
import { configManager } from '../ConfigManager'
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
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) {
const Api = musicSdk[source]
return {
@@ -91,7 +498,12 @@ function main(source: string) {
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 })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
@@ -167,6 +579,16 @@ function main(source: string) {
delete fileLock[songPath]
}
// 写入标签信息
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
}
}
return {
message: '下载成功',
path: songPath

View File

@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
info: PlaylistInfo
}
export interface TagWriteOptions {
basicInfo?: boolean
cover?: boolean
lyrics?: boolean
}
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}

View File

@@ -13,13 +13,15 @@ export default {
lineTime2: /^\[([\d:.]+)\]/,
wordTime: /\(\d+,\d+,\d+\)/,
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
timeLabelFixRxp: /(?:\.0+|0+)$/,
timeLabelFixRxp: /(?:\.0+|0+)$/
},
msFormat(timeMs) {
if (Number.isNaN(timeMs)) return ''
let ms = timeMs % 1000
timeMs /= 1000
let m = parseInt(timeMs / 60).toString().padStart(2, '0')
let m = parseInt(timeMs / 60)
.toString()
.padStart(2, '0')
timeMs %= 60
let s = parseInt(timeMs).toString().padStart(2, '0')
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
@@ -78,7 +80,7 @@ export default {
let currentStart = startMsTime
const processedTimes = []
times.forEach(time => {
times.forEach((time) => {
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
const duration = parseInt(result[2])
processedTimes.push(`(${currentStart},${duration},0)`)
@@ -91,7 +93,7 @@ export default {
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n')
}
},
getIntv(interval) {
@@ -171,8 +173,7 @@ export default {
lyric: '',
tlyric: '',
rlyric: '',
crlyric: '',
crlyric: ''
}
if (lrc) {
let { lyric } = this.parseCeru(this.removeTag(lrc))
@@ -208,11 +209,7 @@ export default {
return lrcLines.join('\n')
},
parseLyric(lrc, tlrc, rlrc) {
return this.parse(
decode(lrc),
decode(tlrc),
decode(rlrc)
)
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
},
getLyric(mInfo, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))

View File

@@ -1,4 +1,3 @@
import zlib from 'zlib'
export default () => {

View File

@@ -79,7 +79,7 @@ interface CustomAPI {
start: () => undefined
stop: () => undefined
}
// 目录设置API
directorySettings: {
getDirectories: () => Promise<{
@@ -96,10 +96,7 @@ interface CustomAPI {
path?: string
message?: string
}>
saveDirectories: (directories: {
cacheDir: string
downloadDir: string
}) => Promise<{
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
success: boolean
message: string
}>
@@ -119,15 +116,14 @@ interface CustomAPI {
size: number
formatted: string
}>
}
// 用户配置API
getUserConfig: () => Promise<any>
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
}
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
}
declare global {

View File

@@ -10,6 +10,8 @@ declare module 'vue' {
export interface GlobalComponents {
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.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']
FloatBall: typeof import('./src/components/AI/FloatBall.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']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']

View File

@@ -77,6 +77,6 @@ body {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.t-dialog__mask{
.t-dialog__mask {
backdrop-filter: blur(5px);
}

View 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>

View 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 来改进这个组件。

View 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
}
}

View 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>

View File

@@ -0,0 +1,4 @@
export { default as ContextMenu } from './ContextMenu.vue'
export * from './types'
export * from './utils'
export * from './composables'

View 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
}

View 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
})
}

View File

@@ -2,7 +2,7 @@
<div class="song-virtual-list">
<!-- 表头 -->
<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 v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
@@ -19,6 +19,7 @@
class="song-item"
@mouseenter="hoveredSong = song.id || song.songmid"
@mouseleave="hoveredSong = null"
@contextmenu="handleContextMenu($event, song)"
>
<!-- 序号或播放状态图标 -->
<div v-if="showIndex" class="col-index">
@@ -90,12 +91,33 @@
</div>
</div>
</div>
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import { ref, computed, onMounted, nextTick, toRaw } from 'vue'
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 {
id?: number
@@ -120,6 +142,8 @@ interface Props {
showIndex?: boolean
showAlbum?: boolean
showDuration?: boolean
isLocalPlaylist?: boolean
playlistId?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -127,10 +151,19 @@ const props = withDefaults(defineProps<Props>(), {
isPlaying: false,
showIndex: 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>()
@@ -142,6 +175,14 @@ const scrollTop = ref(0)
const visibleStartIndex = 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)
@@ -236,6 +277,131 @@ const onScroll = (event: 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(() => {
// 组件挂载后触发一次重新计算
nextTick(() => {
@@ -245,6 +411,9 @@ onMounted(() => {
onScroll(event)
}
})
// 加载歌单列表
loadPlaylists()
})
</script>
@@ -286,7 +455,7 @@ onMounted(() => {
}
.col-title {
padding-left: 10px;
padding-left: 20px;
display: flex;
align-items: center;
}

View File

@@ -691,8 +691,8 @@ const lightMainColor = computed(() => {
// bottom: max(2vw, 29px);
height: 200%;
transform: translateY(-25%);
height: 100%;
// transform: translateY(-25%);
* [class^='lyricMainLine'] {
font-weight: 600 !important;

View File

@@ -537,6 +537,33 @@ defineExpose({
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 {
background-color: rgba(255, 255, 255, 0.1);
}
@@ -589,7 +616,7 @@ defineExpose({
.playlist-content {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
// scrollbar-width: none;
margin: 10px 0;
padding: 0 8px;
}

View File

@@ -1,12 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface TagWriteOptions {
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
cover: boolean // 封面
lyrics: boolean // 普通歌词
}
export interface SettingsState {
showFloatBall: boolean
directories?: {
cacheDir: string
downloadDir: string
}
tagWriteOptions?: TagWriteOptions
}
export const useSettingsStore = defineStore('settings', () => {
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
// 默认设置
return {
showFloatBall: true
showFloatBall: true,
tagWriteOptions: {
basicInfo: true,
cover: true,
lyrics: true
}
}
}

View File

@@ -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 { toRaw } from 'vue'
import { useSettingsStore } from '@renderer/store/Settings'
import { toRaw, h } from 'vue'
interface MusicItem {
singer: string
@@ -12,7 +13,7 @@ interface MusicItem {
songmid: number
img: string
lrc: null | string
types: string[]
types: Array<{ type: string; size: string }>
_types: Record<string, any>
typeUrl: Record<string, any>
}
@@ -29,15 +30,166 @@ const qualityMap: Record<string, string> = {
}
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> {
try {
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)
// 如果是特殊音质,先尝试获取对应链接
// 如果选择的是特殊音质,先尝试下载
if (isSpecialQuality) {
try {
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
@@ -47,13 +199,14 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
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')) {
MessagePlugin.info(specialResult.message)
} else {
@@ -65,34 +218,48 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
return
}
console.log(`下载${qualityMap[quality]}音质失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载失败,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
} catch (specialError) {
console.log(`下载${qualityMap[quality]}音质出错,回退到标准逻辑:`, specialError)
// 特殊音质获取失败,继续执行原有逻辑
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载出错,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
}
MessagePlugin.error('下载失败了,向下兼容音质')
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
qualityKey.indexOf(
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
)
) {
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
// 检查选择的音质是否超出歌曲支持的最高音质
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
quality = songMaxQuality
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
}
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const result = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
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')) {
MessagePlugin.info(result.message)
} else {

View File

@@ -13,8 +13,8 @@ type PlaylistEvents = {
// 创建全局事件总线
const emitter = mitt<PlaylistEvents>()
// 将事件总线挂载到全局
; (window as any).musicEmitter = emitter
// 将事件总线挂载到全局
;(window as any).musicEmitter = emitter
const qualityMap: Record<string, string> = {
'128k': '标准音质',
'192k': '高品音质',
@@ -37,10 +37,10 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
const LocalUserDetail = LocalUserDetailStore()
// 通过统一的request方法获取真实的播放URL
let quality = LocalUserDetail.userSource.quality as string
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果是特殊音质,先尝试获取对应链接
if (isSpecialQuality) {
try {
@@ -51,13 +51,16 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
songInfo: song as any,
quality
})
// 如果成功获取特殊音质链接,直接返回
if (typeof specialUrlData === 'string' || (typeof specialUrlData === 'object' && !specialUrlData.error)) {
if (
typeof specialUrlData === 'string' ||
(typeof specialUrlData === 'object' && !specialUrlData.error)
) {
console.log(`成功获取${qualityMap[quality]}链接`)
return specialUrlData as string
}
console.log(`获取${qualityMap[quality]}链接失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
} catch (specialError) {
@@ -65,7 +68,7 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
// 特殊音质获取失败,继续执行原有逻辑
}
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
@@ -73,7 +76,7 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
) {
quality = (song.types[song.types.length - 1] as unknown as { type: any }).type
}
console.log(`使用音质: ${quality} - ${qualityMap[quality]}`)
const urlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
@@ -81,7 +84,7 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
songInfo: song as any,
quality
})
console.log(urlData)
if (typeof urlData === 'object' && urlData.error) {
throw new Error(urlData.error)

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, toRaw } from 'vue'
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { useRoute } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
interface MusicItem {
singer: string
@@ -191,7 +190,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
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) => {
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(() => {
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>
<template>
<div class="list-container">
<!-- 固定头部区域 -->
<div class="fixed-header">
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
<!-- 歌单信息 -->
<div class="playlist-header">
<div class="playlist-cover">
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
<div
class="playlist-cover"
:class="{ clickable: isLocalPlaylist }"
@click="handleCoverClick"
>
<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>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<div class="playlist-details">
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
<p class="playlist-author">by {{ playlistInfo.author }}</p>
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
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
theme="primary"
size="medium"
@@ -356,16 +519,21 @@ onMounted(() => {
<div v-else class="song-list-wrapper">
<SongVirtualList
ref="songListRef"
:songs="songs"
:current-song="currentSong"
:is-playing="isPlaying"
:show-index="true"
:show-album="true"
:show-duration="true"
:is-local-playlist="isLocalPlaylist"
:playlist-id="playlistInfo.id"
@play="handlePlay"
@pause="handlePause"
@download="handleDownload"
@add-to-playlist="handleAddToPlaylist"
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
@scroll="handleScroll"
/>
</div>
</div>
@@ -375,7 +543,7 @@ onMounted(() => {
<style lang="scss" scoped>
.list-container {
box-sizing: border-box;
background: #fafafa;
// background: #fafafa;
box-sizing: border-box;
width: 100%;
padding: 20px;
@@ -444,56 +612,160 @@ onMounted(() => {
background: #fff;
border-radius: 0.75rem;
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 {
width: 120px;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
img {
width: 100%;
height: 100%;
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 {
flex: 1;
.playlist-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
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 {
font-size: 1rem;
color: #6b7280;
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 {
font-size: 0.875rem;
color: #9ca3af;
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 {
display: flex;
gap: 0.75rem;
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,
.shuffle-btn {
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,
.shuffle-icon {
width: 16px;
height: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-actions.compact & {
width: 14px;
height: 14px;
}
}
}
}

View File

@@ -2,11 +2,18 @@
import { ref, onMounted, computed, toRaw } from 'vue'
import { useRouter } from 'vue-router'
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 type { SongList, Songs } from '@common/types/songList'
import defaultCover from '/default-cover.png'
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 类型以包含本地音乐的额外属性
interface LocalSong extends Songs {
@@ -134,6 +141,11 @@ const editPlaylistForm = ref({
// 当前编辑的歌单
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 => {
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(() => {
loadPlaylists()
@@ -894,7 +980,12 @@ onMounted(() => {
<!-- 歌单网格 -->
<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)">
<img
v-if="playlist.coverImgUrl"
@@ -944,7 +1035,11 @@ onMounted(() => {
size="small"
@click="viewPlaylist(playlist)"
>
<ListIcon />
<view-list-icon
:fill-color="'transparent'"
:stroke-color="'#000000'"
:stroke-width="1.5"
/>
</t-button>
</t-tooltip>
<t-tooltip content="编辑歌单">
@@ -1311,6 +1406,15 @@ onMounted(() => {
</div>
</div>
</t-dialog>
<!-- 歌单右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>

View File

@@ -34,6 +34,8 @@ const isPlaying = ref(false)
const search = searchValue()
onMounted(async () => {
const localUserStore = LocalUserDetailStore()
watch(
search,
async () => {
@@ -42,6 +44,17 @@ onMounted(async () => {
},
{ 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)
}

View File

@@ -21,9 +21,14 @@ import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettin
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
import Versions from '@renderer/components/Versions.vue'
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
import { useSettingsStore } from '@renderer/store/Settings'
const Store = LocalUserDetailStore()
const { userInfo } = storeToRefs(Store)
// 设置存储
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
// 当前选择的设置分类
const activeCategory = ref<string>('appearance')
// 应用版本号
@@ -308,6 +313,30 @@ const getCurrentSourceName = () => {
const openLink = (url: string) => {
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>
<template>
@@ -582,6 +611,44 @@ const openLink = (url: string) => {
<div style="margin-top: 20px" class="setting-group">
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
</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>
<!-- 关于页面 -->
@@ -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) {
.app-header {