mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-24 19:12:53 +08:00
1. 歌单
- 新增右键移除歌曲 - local 页歌单右键操作 - 歌单页支持修改封面 2. debug:右键菜单二级菜单位置决策
This commit is contained in:
12
.github/workflows/main.yml
vendored
12
.github/workflows/main.yml
vendored
@@ -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 个文件"
|
||||
|
||||
@@ -7,24 +7,28 @@
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 📋 基础功能
|
||||
|
||||
- ✅ **可配置菜单项** - 支持图标、文字、快捷键显示
|
||||
- ✅ **多级子菜单** - 支持无限层级嵌套
|
||||
- ✅ **菜单项状态** - 支持禁用、隐藏、分割线
|
||||
- ✅ **事件回调** - 完整的点击事件处理机制
|
||||
|
||||
### 🎨 样式与主题
|
||||
|
||||
- ✅ **自定义主题** - 支持亮色/暗色/自动主题切换
|
||||
- ✅ **现代化设计** - 圆角、阴影、渐变、动画效果
|
||||
- ✅ **响应式布局** - 适配不同屏幕尺寸
|
||||
- ✅ **无障碍支持** - 高对比度、减少动画模式
|
||||
|
||||
### 🔧 智能定位与边界处理
|
||||
|
||||
- ✅ **智能定位** - 自动检测屏幕边界并调整位置
|
||||
- ✅ **向上展开** - 底部空间不足时自动向上显示
|
||||
- ✅ **滚动支持** - 菜单过长时支持滚动和滚动指示器
|
||||
- ✅ **子菜单定位** - 子菜单智能避让边界
|
||||
|
||||
### ⌨️ 交互优化
|
||||
|
||||
- ✅ **键盘导航** - 支持方向键、ESC、回车等快捷键
|
||||
- ✅ **鼠标交互** - 悬停显示子菜单,点击外部关闭
|
||||
- ✅ **滚轮支持** - 长菜单支持滚轮滚动
|
||||
@@ -48,10 +52,8 @@ src/renderer/src/components/ContextMenu/
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div @contextmenu="handleContextMenu">
|
||||
右键点击此区域
|
||||
</div>
|
||||
|
||||
<div @contextmenu="handleContextMenu">右键点击此区域</div>
|
||||
|
||||
<ContextMenu
|
||||
v-model:visible="visible"
|
||||
:items="menuItems"
|
||||
@@ -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 {
|
||||
@@ -239,4 +252,4 @@ createMenuItem('custom', '自定义', {
|
||||
5. **高度可定制** - 灵活的配置选项
|
||||
6. **无障碍友好** - 支持各种用户需求
|
||||
|
||||
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
||||
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] 歌单页支持修改封面
|
||||
|
||||
@@ -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. **设置功能页**
|
||||
- 缓存路径支持自定义
|
||||
- 下载路径支持自定义
|
||||
@@ -41,4 +53,4 @@
|
||||
- 播放页面唱针可以拖动问题
|
||||
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
|
||||
- **SMTC** 功能 系统显示**未知应用**问题
|
||||
- 播放页歌词**字体粗细**偶现丢失问题
|
||||
- 播放页歌词**字体粗细**偶现丢失问题
|
||||
|
||||
@@ -23,6 +23,4 @@
|
||||
|
||||
歌单将自动选取第一首 **有效封面**[^1] 为歌单
|
||||
|
||||
|
||||
|
||||
[^1]: url正确的歌曲封面
|
||||
[^1]: url正确的歌曲封面
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 类型定义
|
||||
- ✨ 响应式设计和深色主题支持
|
||||
- ✨ 完善的错误处理机制
|
||||
- ✨ 完善的错误处理机制
|
||||
|
||||
@@ -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",
|
||||
@@ -102,4 +102,4 @@
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9584
qodana.sarif.json
9584
qodana.sarif.json
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
version: "1.0"
|
||||
version: '1.0'
|
||||
profile:
|
||||
name: qodana.starter
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import zlib from 'zlib'
|
||||
|
||||
export default () => {
|
||||
|
||||
14
src/preload/index.d.ts
vendored
14
src/preload/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -77,6 +77,6 @@ body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
.t-dialog__mask{
|
||||
.t-dialog__mask {
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,8 +52,8 @@ const menuItems = ref<ContextMenuItem[]>([
|
||||
```vue
|
||||
<template>
|
||||
<div class="song-list">
|
||||
<div
|
||||
v-for="song in songs"
|
||||
<div
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="song-item"
|
||||
@contextmenu.prevent="handleSongContextMenu(song, $event)"
|
||||
@@ -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,12 +148,13 @@ createSeparator(): ContextMenuItem
|
||||
```
|
||||
|
||||
#### calculateMenuPosition
|
||||
|
||||
智能计算菜单位置
|
||||
|
||||
```typescript
|
||||
calculateMenuPosition(
|
||||
event: MouseEvent,
|
||||
menuWidth?: number,
|
||||
event: MouseEvent,
|
||||
menuWidth?: number,
|
||||
menuHeight?: number
|
||||
): ContextMenuPosition
|
||||
```
|
||||
@@ -177,14 +180,12 @@ const menuItems = [
|
||||
|
||||
```typescript
|
||||
const dynamicMenuItems = computed(() => {
|
||||
const items = [
|
||||
createMenuItem('play', '播放')
|
||||
]
|
||||
|
||||
const items = [createMenuItem('play', '播放')]
|
||||
|
||||
if (user.value.isPremium) {
|
||||
items.push(createMenuItem('download', '下载高音质'))
|
||||
}
|
||||
|
||||
|
||||
return items
|
||||
})
|
||||
```
|
||||
@@ -227,14 +228,17 @@ const menuItems = [
|
||||
## 故障排除
|
||||
|
||||
### 菜单位置不正确
|
||||
|
||||
确保使用 `calculateMenuPosition` 函数计算位置。
|
||||
|
||||
### 菜单项点击无效
|
||||
|
||||
检查 `onClick` 回调函数是否正确绑定。
|
||||
|
||||
### 样式冲突
|
||||
|
||||
使用 `className` 属性添加自定义样式类。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来改进这个组件。
|
||||
欢迎提交 Issue 和 Pull Request 来改进这个组件。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user