1. 歌单

- 新增右键移除歌曲
   - local 页歌单右键操作
   - 歌单页支持修改封面
2. debug:右键菜单二级菜单位置决策
This commit is contained in:
sqj
2025-09-25 19:56:45 +08:00
parent 2473b36928
commit 51df14a9e9
23 changed files with 3132 additions and 7473 deletions

View File

@@ -7,24 +7,28 @@
## 🚀 核心功能特性
### 📋 基础功能
-**可配置菜单项** - 支持图标、文字、快捷键显示
-**多级子菜单** - 支持无限层级嵌套
-**菜单项状态** - 支持禁用、隐藏、分割线
-**事件回调** - 完整的点击事件处理机制
### 🎨 样式与主题
-**自定义主题** - 支持亮色/暗色/自动主题切换
-**现代化设计** - 圆角、阴影、渐变、动画效果
-**响应式布局** - 适配不同屏幕尺寸
-**无障碍支持** - 高对比度、减少动画模式
### 🔧 智能定位与边界处理
-**智能定位** - 自动检测屏幕边界并调整位置
-**向上展开** - 底部空间不足时自动向上显示
-**滚动支持** - 菜单过长时支持滚动和滚动指示器
-**子菜单定位** - 子菜单智能避让边界
### ⌨️ 交互优化
-**键盘导航** - 支持方向键、ESC、回车等快捷键
-**鼠标交互** - 悬停显示子菜单,点击外部关闭
-**滚轮支持** - 长菜单支持滚轮滚动
@@ -48,9 +52,7 @@ src/renderer/src/components/ContextMenu/
```vue
<template>
<div @contextmenu="handleContextMenu">
右键点击此区域
</div>
<div @contextmenu="handleContextMenu">右键点击此区域</div>
<ContextMenu
v-model:visible="visible"
@@ -124,12 +126,14 @@ const menuItems = [
## 🎨 样式特性
### 现代化视觉效果
- **毛玻璃效果** - `backdrop-filter: blur(8px)`
- **多层阴影** - 立体感阴影效果
- **流畅动画** - `cubic-bezier` 缓动函数
- **悬停反馈** - 微妙的变换和颜色变化
### 响应式设计
- **桌面端** - 最小宽度 160px最大宽度 300px
- **平板端** - 适配中等屏幕尺寸
- **移动端** - 优化触摸交互,增大点击区域
@@ -137,6 +141,7 @@ const menuItems = [
## 🔧 高级功能
### 智能边界处理
```javascript
// 自动检测屏幕边界
if (x + menuWidth > viewportWidth) {
@@ -150,12 +155,14 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
```
### 滚动功能
- **自动滚动** - 菜单超出屏幕高度时启用
- **滚动指示器** - 显示可滚动方向
- **键盘滚动** - 支持方向键和 Home/End 键
- **鼠标滚轮** - 平滑滚动体验
### 无障碍支持
- **高对比度模式** - 自动适配系统设置
- **减少动画模式** - 尊重用户偏好设置
- **键盘导航** - 完整的键盘操作支持
@@ -173,12 +180,14 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
## 🎯 集成状态
### 已集成页面
-**本地音乐页面** (`src/renderer/src/views/music/local.vue`)
- 歌曲右键菜单
- 播放、收藏、添加到歌单等功能
- 多级歌单选择
### 菜单功能
- ✅ 播放歌曲
- ✅ 下一首播放
- ✅ 收藏歌曲
@@ -190,11 +199,13 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
## 🚀 性能优化
### 渲染优化
- **Teleport 渲染** - 避免 z-index 冲突
- **按需渲染** - 只在显示时渲染菜单
- **事件委托** - 高效的事件处理
### 内存管理
- **自动清理** - 组件卸载时清理事件监听
- **防抖处理** - 避免频繁的位置计算
- **缓存优化** - 计算结果缓存
@@ -202,6 +213,7 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
## 🔮 扩展性
### 自定义组件
```javascript
// 支持自定义图标组件
createMenuItem('custom', '自定义', {
@@ -211,6 +223,7 @@ createMenuItem('custom', '自定义', {
```
### 主题扩展
```css
/* 自定义主题变量 */
:root {

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' }
]
},
{
@@ -64,21 +62,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

@@ -171,12 +171,12 @@ html.dark #app {
// --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) ". ";
/* 主题颜色 */

View File

@@ -3,6 +3,7 @@
## 概述
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
@@ -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,13 +652,13 @@ 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'
}
})
```
@@ -659,6 +666,7 @@ cerumusic.NoticeCenter('update',{
### Q: 如何调试插件?
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)

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,25 +1,39 @@
# 澜音版本更新日志
## 日志
- ###### 2025-9-22 (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)**
- ###### 2025-9-17 **(v1.3.2)**
1. 目录结构调整
2. **支持插件更新提示**
@@ -27,13 +41,11 @@
**洛雪** 插件请手动重装适配
3. **debug**
- SMTC 问题
- 歌曲缓存播放多次请求和多次缓存问题
- ###### 2025-9-17 **v1.3.1**
1. **设置功能页**
- 缓存路径支持自定义
- 下载路径支持自定义

View File

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

View File

@@ -2,17 +2,17 @@
// 这个文件可以用来测试 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']
}
]

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,6 +208,7 @@ window.api.on('plugin-notice', (_, notice) => {
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.6",
"version": "1.3.7",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",

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

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

@@ -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,14 +116,13 @@ interface CustomAPI {
size: number
formatted: string
}>
}
// 用户配置API
getUserConfig: () => Promise<any>
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
}

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

@@ -55,7 +55,11 @@
<!-- 子菜单箭头 -->
<div v-if="item.children && item.children.length > 0" class="context-menu__arrow">
<i class="context-menu__arrow-icon"></i>
<chevron-right-icon
:fill-color="'transparent'"
:stroke-color="'#000000'"
:stroke-width="1.5"
/>
</div>
</template>
</li>
@@ -75,7 +79,13 @@
</div>
<!-- 子菜单 -->
<div v-if="activeSubmenu" class="context-menu__submenu-wrapper" :style="submenuWrapperStyle">
<div
v-if="activeSubmenu"
class="context-menu__submenu-wrapper"
:style="submenuWrapperStyle"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<ContextMenu
ref="submenuRef"
:visible="true"
@@ -102,6 +112,7 @@ import type {
AnimationConfig,
ScrollConfig
} from './types'
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
// 默认配置
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
@@ -187,7 +198,12 @@ const scrollContainerStyle = computed((): CSSProperties => {
})
const visibleItems = computed(() => {
return props.items.filter((item) => !item.separator || item.label)
return props.items.filter((item) => {
// 显示所有非分隔线项目
if (!item.separator) return true
// 显示所有分隔线项目无论是否有label
return true
})
})
const showScrollIndicator = computed(() => {
@@ -199,9 +215,7 @@ const canScrollDown = computed(() => scrollTop.value < scrollHeight.value - clie
const submenuWrapperStyle = computed((): CSSProperties => {
return {
position: 'absolute',
left: '100%',
top: '0',
position: 'fixed',
zIndex: props.zIndex + 1,
maxHeight: `${submenuMaxHeight.value}px`
}
@@ -362,6 +376,11 @@ const handleSubmenuItemClick = (item: ContextMenuItem, event: MouseEvent) => {
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
if (!menuRef.value) return
// 如果是相同的子菜单,不需要重新计算位置
if (activeSubmenu.value && activeSubmenu.value.id === item.id) {
return
}
// 移除未使用的变量声明
activeSubmenu.value = item
@@ -372,16 +391,119 @@ const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
const closeSubmenu = () => {
activeSubmenu.value = null
clearTimeout(submenuTimer.value)
}
const updateSubmenuPosition = () => {
if (!menuRef.value || !activeSubmenu.value) return
const menuRect = menuRef.value.getBoundingClientRect()
submenuPosition.value = {
x: menuRect.right - 2,
y: menuRect.top
// 初始位置:显示在右侧
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 = () => {
@@ -397,6 +519,18 @@ 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
@@ -476,10 +610,10 @@ defineExpose({
/* 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;
@@ -528,6 +662,8 @@ defineExpose({
padding: 0;
margin: 4px 0;
cursor: default;
height: auto;
min-height: auto;
}
.context-menu__item--has-children {
@@ -560,6 +696,9 @@ defineExpose({
right: 8px;
top: 50%;
transform: translateY(-50%);
justify-content: center;
align-items: center;
display: flex;
color: #999;
}
@@ -570,8 +709,10 @@ defineExpose({
.context-menu__separator {
height: 1px;
width: 100%;
background: #e0e0e0;
margin: 0 8px;
opacity: 0.8;
}
.context-menu__scroll-indicator {
@@ -658,7 +799,8 @@ defineExpose({
}
.context-menu__separator {
background: #404040;
background: #555555;
opacity: 0.9;
}
.context-menu__scroll-indicator-top,

View File

@@ -99,13 +99,13 @@ const handleSongContextMenu = (song, event) => {
### ContextMenu 组件属性
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | boolean | false | 控制菜单显示/隐藏 |
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
| maxHeight | number | 400 | 菜单最大高度 |
| zIndex | number | 1000 | 菜单层级 |
| 属性 | 类型 | 默认值 | 说明 |
| --------- | ------------------- | --------- | ----------------- |
| visible | boolean | false | 控制菜单显示/隐藏 |
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
| maxHeight | number | 400 | 菜单最大高度 |
| zIndex | number | 1000 | 菜单层级 |
### ContextMenuItem 类型
@@ -125,6 +125,7 @@ interface ContextMenuItem {
### 工具函数
#### createMenuItem
创建标准菜单项
```typescript
@@ -139,6 +140,7 @@ createMenuItem(id: string, label: string, options?: {
```
#### createSeparator
创建分隔线
```typescript
@@ -146,6 +148,7 @@ createSeparator(): ContextMenuItem
```
#### calculateMenuPosition
智能计算菜单位置
```typescript
@@ -177,9 +180,7 @@ const menuItems = [
```typescript
const dynamicMenuItems = computed(() => {
const items = [
createMenuItem('play', '播放')
]
const items = [createMenuItem('play', '播放')]
if (user.value.isPremium) {
items.push(createMenuItem('download', '下载高音质'))
@@ -227,12 +228,15 @@ const menuItems = [
## 故障排除
### 菜单位置不正确
确保使用 `calculateMenuPosition` 函数计算位置。
### 菜单项点击无效
检查 `onClick` 回调函数是否正确绑定。
### 样式冲突
使用 `className` 属性添加自定义样式类。
## 贡献指南

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>
@@ -105,7 +105,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, toRaw } from 'vue'
import { DownloadIcon, PlayCircleIcon, AddIcon, FolderIcon } from 'tdesign-icons-vue-next'
import {
DownloadIcon,
PlayCircleIcon,
AddIcon,
FolderIcon,
DeleteIcon
} from 'tdesign-icons-vue-next'
import ContextMenu from '../ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
@@ -136,6 +142,8 @@ interface Props {
showIndex?: boolean
showAlbum?: boolean
showDuration?: boolean
isLocalPlaylist?: boolean
playlistId?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -143,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>()
@@ -299,9 +316,6 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
)
}
// 添加分隔线
baseItems.push(createSeparator())
baseItems.push(
createMenuItem('download', '下载', {
icon: DownloadIcon,
@@ -312,6 +326,21 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
}
})
)
// 添加分隔线
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
})
@@ -426,7 +455,7 @@ onMounted(() => {
}
.col-title {
padding-left: 10px;
padding-left: 20px;
display: flex;
align-items: center;
}

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, toRaw } from 'vue'
import { ref, onMounted, 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
@@ -202,6 +201,103 @@ 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 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) {
@@ -299,9 +395,30 @@ onMounted(() => {
<div class="fixed-header">
<!-- 歌单信息 -->
<div class="playlist-header">
<div class="playlist-cover">
<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>
@@ -362,10 +479,13 @@ onMounted(() => {
: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"
/>
</div>
</div>
@@ -451,11 +571,58 @@ onMounted(() => {
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
position: relative;
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;
}
}
}

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, ListIcon, PlayCircleIcon, DeleteIcon } 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: ListIcon,
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"
@@ -1311,6 +1402,15 @@ onMounted(() => {
</div>
</div>
</t-dialog>
<!-- 歌单右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>