mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51df14a9e9 |
12
.github/workflows/main.yml
vendored
12
.github/workflows/main.yml
vendored
@@ -94,13 +94,13 @@ jobs:
|
|||||||
# 获取当前标签对应的 release 信息
|
# 获取当前标签对应的 release 信息
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# 获取 release 详细信息
|
# 获取 release 详细信息
|
||||||
response=$(curl -s \
|
response=$(curl -s \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
|
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
|
||||||
|
|
||||||
release_id=$(echo "$response" | jq -r '.id')
|
release_id=$(echo "$response" | jq -r '.id')
|
||||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT
|
echo "release_id=$release_id" >> $GITHUB_OUTPUT
|
||||||
echo "找到 Release ID: $release_id"
|
echo "找到 Release ID: $release_id"
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
|
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
|
||||||
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
|
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
|
||||||
|
|
||||||
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
|
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
|
||||||
echo "Release ID: $RELEASE_ID"
|
echo "Release ID: $RELEASE_ID"
|
||||||
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
# 先创建版本目录
|
# 先创建版本目录
|
||||||
dir_path="/yd/ceru/$TAG_NAME"
|
dir_path="/yd/ceru/$TAG_NAME"
|
||||||
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
||||||
|
|
||||||
echo "创建版本目录: $dir_path"
|
echo "创建版本目录: $dir_path"
|
||||||
curl -s -X MKCOL \
|
curl -s -X MKCOL \
|
||||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||||
@@ -140,7 +140,7 @@ jobs:
|
|||||||
# 处理每个asset
|
# 处理每个asset
|
||||||
success_count=0
|
success_count=0
|
||||||
failed_count=0
|
failed_count=0
|
||||||
|
|
||||||
for i in $(seq 0 $(($assets_count - 1))); do
|
for i in $(seq 0 $(($assets_count - 1))); do
|
||||||
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
||||||
asset_name=$(echo "$asset" | jq -r '.name')
|
asset_name=$(echo "$asset" | jq -r '.name')
|
||||||
@@ -198,7 +198,7 @@ jobs:
|
|||||||
failed_count=$((failed_count + 1))
|
failed_count=$((failed_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo "🎉 同步完成!"
|
echo "🎉 同步完成!"
|
||||||
echo "成功: $success_count 个文件"
|
echo "成功: $success_count 个文件"
|
||||||
|
|||||||
@@ -7,24 +7,28 @@
|
|||||||
## 🚀 核心功能特性
|
## 🚀 核心功能特性
|
||||||
|
|
||||||
### 📋 基础功能
|
### 📋 基础功能
|
||||||
|
|
||||||
- ✅ **可配置菜单项** - 支持图标、文字、快捷键显示
|
- ✅ **可配置菜单项** - 支持图标、文字、快捷键显示
|
||||||
- ✅ **多级子菜单** - 支持无限层级嵌套
|
- ✅ **多级子菜单** - 支持无限层级嵌套
|
||||||
- ✅ **菜单项状态** - 支持禁用、隐藏、分割线
|
- ✅ **菜单项状态** - 支持禁用、隐藏、分割线
|
||||||
- ✅ **事件回调** - 完整的点击事件处理机制
|
- ✅ **事件回调** - 完整的点击事件处理机制
|
||||||
|
|
||||||
### 🎨 样式与主题
|
### 🎨 样式与主题
|
||||||
|
|
||||||
- ✅ **自定义主题** - 支持亮色/暗色/自动主题切换
|
- ✅ **自定义主题** - 支持亮色/暗色/自动主题切换
|
||||||
- ✅ **现代化设计** - 圆角、阴影、渐变、动画效果
|
- ✅ **现代化设计** - 圆角、阴影、渐变、动画效果
|
||||||
- ✅ **响应式布局** - 适配不同屏幕尺寸
|
- ✅ **响应式布局** - 适配不同屏幕尺寸
|
||||||
- ✅ **无障碍支持** - 高对比度、减少动画模式
|
- ✅ **无障碍支持** - 高对比度、减少动画模式
|
||||||
|
|
||||||
### 🔧 智能定位与边界处理
|
### 🔧 智能定位与边界处理
|
||||||
|
|
||||||
- ✅ **智能定位** - 自动检测屏幕边界并调整位置
|
- ✅ **智能定位** - 自动检测屏幕边界并调整位置
|
||||||
- ✅ **向上展开** - 底部空间不足时自动向上显示
|
- ✅ **向上展开** - 底部空间不足时自动向上显示
|
||||||
- ✅ **滚动支持** - 菜单过长时支持滚动和滚动指示器
|
- ✅ **滚动支持** - 菜单过长时支持滚动和滚动指示器
|
||||||
- ✅ **子菜单定位** - 子菜单智能避让边界
|
- ✅ **子菜单定位** - 子菜单智能避让边界
|
||||||
|
|
||||||
### ⌨️ 交互优化
|
### ⌨️ 交互优化
|
||||||
|
|
||||||
- ✅ **键盘导航** - 支持方向键、ESC、回车等快捷键
|
- ✅ **键盘导航** - 支持方向键、ESC、回车等快捷键
|
||||||
- ✅ **鼠标交互** - 悬停显示子菜单,点击外部关闭
|
- ✅ **鼠标交互** - 悬停显示子菜单,点击外部关闭
|
||||||
- ✅ **滚轮支持** - 长菜单支持滚轮滚动
|
- ✅ **滚轮支持** - 长菜单支持滚轮滚动
|
||||||
@@ -48,10 +52,8 @@ src/renderer/src/components/ContextMenu/
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<template>
|
<template>
|
||||||
<div @contextmenu="handleContextMenu">
|
<div @contextmenu="handleContextMenu">右键点击此区域</div>
|
||||||
右键点击此区域
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
v-model:visible="visible"
|
v-model:visible="visible"
|
||||||
:items="menuItems"
|
:items="menuItems"
|
||||||
@@ -124,12 +126,14 @@ const menuItems = [
|
|||||||
## 🎨 样式特性
|
## 🎨 样式特性
|
||||||
|
|
||||||
### 现代化视觉效果
|
### 现代化视觉效果
|
||||||
|
|
||||||
- **毛玻璃效果** - `backdrop-filter: blur(8px)`
|
- **毛玻璃效果** - `backdrop-filter: blur(8px)`
|
||||||
- **多层阴影** - 立体感阴影效果
|
- **多层阴影** - 立体感阴影效果
|
||||||
- **流畅动画** - `cubic-bezier` 缓动函数
|
- **流畅动画** - `cubic-bezier` 缓动函数
|
||||||
- **悬停反馈** - 微妙的变换和颜色变化
|
- **悬停反馈** - 微妙的变换和颜色变化
|
||||||
|
|
||||||
### 响应式设计
|
### 响应式设计
|
||||||
|
|
||||||
- **桌面端** - 最小宽度 160px,最大宽度 300px
|
- **桌面端** - 最小宽度 160px,最大宽度 300px
|
||||||
- **平板端** - 适配中等屏幕尺寸
|
- **平板端** - 适配中等屏幕尺寸
|
||||||
- **移动端** - 优化触摸交互,增大点击区域
|
- **移动端** - 优化触摸交互,增大点击区域
|
||||||
@@ -137,6 +141,7 @@ const menuItems = [
|
|||||||
## 🔧 高级功能
|
## 🔧 高级功能
|
||||||
|
|
||||||
### 智能边界处理
|
### 智能边界处理
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 自动检测屏幕边界
|
// 自动检测屏幕边界
|
||||||
if (x + menuWidth > viewportWidth) {
|
if (x + menuWidth > viewportWidth) {
|
||||||
@@ -150,12 +155,14 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 滚动功能
|
### 滚动功能
|
||||||
|
|
||||||
- **自动滚动** - 菜单超出屏幕高度时启用
|
- **自动滚动** - 菜单超出屏幕高度时启用
|
||||||
- **滚动指示器** - 显示可滚动方向
|
- **滚动指示器** - 显示可滚动方向
|
||||||
- **键盘滚动** - 支持方向键和 Home/End 键
|
- **键盘滚动** - 支持方向键和 Home/End 键
|
||||||
- **鼠标滚轮** - 平滑滚动体验
|
- **鼠标滚轮** - 平滑滚动体验
|
||||||
|
|
||||||
### 无障碍支持
|
### 无障碍支持
|
||||||
|
|
||||||
- **高对比度模式** - 自动适配系统设置
|
- **高对比度模式** - 自动适配系统设置
|
||||||
- **减少动画模式** - 尊重用户偏好设置
|
- **减少动画模式** - 尊重用户偏好设置
|
||||||
- **键盘导航** - 完整的键盘操作支持
|
- **键盘导航** - 完整的键盘操作支持
|
||||||
@@ -173,12 +180,14 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
|||||||
## 🎯 集成状态
|
## 🎯 集成状态
|
||||||
|
|
||||||
### 已集成页面
|
### 已集成页面
|
||||||
|
|
||||||
- ✅ **本地音乐页面** (`src/renderer/src/views/music/local.vue`)
|
- ✅ **本地音乐页面** (`src/renderer/src/views/music/local.vue`)
|
||||||
- 歌曲右键菜单
|
- 歌曲右键菜单
|
||||||
- 播放、收藏、添加到歌单等功能
|
- 播放、收藏、添加到歌单等功能
|
||||||
- 多级歌单选择
|
- 多级歌单选择
|
||||||
|
|
||||||
### 菜单功能
|
### 菜单功能
|
||||||
|
|
||||||
- ✅ 播放歌曲
|
- ✅ 播放歌曲
|
||||||
- ✅ 下一首播放
|
- ✅ 下一首播放
|
||||||
- ✅ 收藏歌曲
|
- ✅ 收藏歌曲
|
||||||
@@ -190,11 +199,13 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
|||||||
## 🚀 性能优化
|
## 🚀 性能优化
|
||||||
|
|
||||||
### 渲染优化
|
### 渲染优化
|
||||||
|
|
||||||
- **Teleport 渲染** - 避免 z-index 冲突
|
- **Teleport 渲染** - 避免 z-index 冲突
|
||||||
- **按需渲染** - 只在显示时渲染菜单
|
- **按需渲染** - 只在显示时渲染菜单
|
||||||
- **事件委托** - 高效的事件处理
|
- **事件委托** - 高效的事件处理
|
||||||
|
|
||||||
### 内存管理
|
### 内存管理
|
||||||
|
|
||||||
- **自动清理** - 组件卸载时清理事件监听
|
- **自动清理** - 组件卸载时清理事件监听
|
||||||
- **防抖处理** - 避免频繁的位置计算
|
- **防抖处理** - 避免频繁的位置计算
|
||||||
- **缓存优化** - 计算结果缓存
|
- **缓存优化** - 计算结果缓存
|
||||||
@@ -202,6 +213,7 @@ if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
|||||||
## 🔮 扩展性
|
## 🔮 扩展性
|
||||||
|
|
||||||
### 自定义组件
|
### 自定义组件
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 支持自定义图标组件
|
// 支持自定义图标组件
|
||||||
createMenuItem('custom', '自定义', {
|
createMenuItem('custom', '自定义', {
|
||||||
@@ -211,6 +223,7 @@ createMenuItem('custom', '自定义', {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 主题扩展
|
### 主题扩展
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* 自定义主题变量 */
|
/* 自定义主题变量 */
|
||||||
:root {
|
:root {
|
||||||
@@ -239,4 +252,4 @@ createMenuItem('custom', '自定义', {
|
|||||||
5. **高度可定制** - 灵活的配置选项
|
5. **高度可定制** - 灵活的配置选项
|
||||||
6. **无障碍友好** - 支持各种用户需求
|
6. **无障碍友好** - 支持各种用户需求
|
||||||
|
|
||||||
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export default defineConfig({
|
|||||||
base: '/',
|
base: '/',
|
||||||
description:
|
description:
|
||||||
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
|
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
|
||||||
markdown:{
|
markdown: {
|
||||||
config(md){
|
config(md) {
|
||||||
md.use(note)
|
md.use(note)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -28,13 +28,11 @@ export default defineConfig({
|
|||||||
{ text: '安装教程', link: '/guide/' },
|
{ text: '安装教程', link: '/guide/' },
|
||||||
{
|
{
|
||||||
text: '使用教程',
|
text: '使用教程',
|
||||||
items: [
|
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
|
||||||
{ text: '音乐播放列表', link: '/guide/used/playList' },
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{ text: '软件设计文档', link: '/guide/design' },
|
{ text: '软件设计文档', link: '/guide/design' },
|
||||||
{ text: '更新日志', link: '/guide/updateLog' },
|
{ text: '更新日志', link: '/guide/updateLog' },
|
||||||
{ text: '更新计划', link: '/guide/update'}
|
{ text: '更新计划', link: '/guide/update' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,21 +62,20 @@ export default defineConfig({
|
|||||||
provider: 'local'
|
provider: 'local'
|
||||||
},
|
},
|
||||||
outline: {
|
outline: {
|
||||||
level: [2,4],
|
level: [2, 4],
|
||||||
label: '文章导航'
|
label: '文章导航'
|
||||||
},
|
},
|
||||||
docFooter: {
|
docFooter: {
|
||||||
next: '下一篇',
|
next: '下一篇',
|
||||||
prev: '上一篇'
|
prev: '上一篇'
|
||||||
},
|
},
|
||||||
lastUpdatedText: '上次更新',
|
lastUpdatedText: '上次更新'
|
||||||
|
|
||||||
},
|
},
|
||||||
sitemap: {
|
sitemap: {
|
||||||
hostname: 'https://ceru.docs.shiqianjiang.cn'
|
hostname: 'https://ceru.docs.shiqianjiang.cn'
|
||||||
},
|
},
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
|
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
|
||||||
})
|
})
|
||||||
console.log(process.env.BASE_URL_DOCS)
|
console.log(process.env.BASE_URL_DOCS)
|
||||||
// Smooth scrolling functions
|
// Smooth scrolling functions
|
||||||
|
|||||||
@@ -168,15 +168,15 @@ html.dark #app {
|
|||||||
// --autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
|
// --autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
|
||||||
// --autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
|
// --autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
|
||||||
// --autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
|
// --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目录自动编号,与上面一样即可 */
|
/* 下面是文章内Toc目录自动编号,与上面一样即可 */
|
||||||
// --autonum-h1toc: counter(h1toc) ". ";
|
// --autonum-h1toc: counter(h1toc) ". ";
|
||||||
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
||||||
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
||||||
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
||||||
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
||||||
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
|
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
|
||||||
|
|
||||||
/* 主题颜色 */
|
/* 主题颜色 */
|
||||||
|
|
||||||
@@ -284,4 +284,4 @@ html .vp-doc div[class*='language-'] pre {
|
|||||||
}
|
}
|
||||||
.VPDoc.has-aside .content-container {
|
.VPDoc.has-aside .content-container {
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
CeruMusic 支持两种类型的插件:
|
CeruMusic 支持两种类型的插件:
|
||||||
|
|
||||||
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
|
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
|
||||||
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
|
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ const sources = {
|
|||||||
qualities: ['128k', '320k', 'flac', 'flac24bit']
|
qualities: ['128k', '320k', 'flac', 'flac24bit']
|
||||||
},
|
},
|
||||||
tx:{
|
tx:{
|
||||||
name: "QQ音乐",
|
name: "QQ音乐",
|
||||||
qualities: ['128k', '320k', 'flac']
|
qualities: ['128k', '320k', 'flac']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,23 +133,21 @@ module.exports = {
|
|||||||
> #### PS:
|
> #### PS:
|
||||||
>
|
>
|
||||||
> - `sources key` 取值
|
> - `sources key` 取值
|
||||||
>
|
|
||||||
> - wy 网易云音乐 |
|
> - wy 网易云音乐 |
|
||||||
> - tx QQ音乐 |
|
> - tx QQ音乐 |
|
||||||
> - kg 酷狗音乐 |
|
> - kg 酷狗音乐 |
|
||||||
> - mg 咪咕音乐 |
|
> - mg 咪咕音乐 |
|
||||||
> - kw 酷我音乐
|
> - kw 酷我音乐
|
||||||
>
|
>
|
||||||
> - 导出
|
> - 导出
|
||||||
>
|
>
|
||||||
> ```javascript
|
> ```javascript
|
||||||
> module.exports = {
|
> module.exports = {
|
||||||
> sources, // 你的音源支持
|
> sources // 你的音源支持
|
||||||
> };
|
> }
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
|
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
|
||||||
>
|
|
||||||
> - `128k`: 128kbps
|
> - `128k`: 128kbps
|
||||||
> - `320k`: 320kbps
|
> - `320k`: 320kbps
|
||||||
> - `flac`: FLAC 无损
|
> - `flac`: FLAC 无损
|
||||||
@@ -157,8 +156,6 @@ module.exports = {
|
|||||||
> - `atmos`: 杜比全景声
|
> - `atmos`: 杜比全景声
|
||||||
> - `master`: 母带音质
|
> - `master`: 母带音质
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### CeruMusic API 参考
|
### CeruMusic API 参考
|
||||||
|
|
||||||
#### cerumusic.request(url, options)
|
#### cerumusic.request(url, options)
|
||||||
@@ -166,6 +163,7 @@ module.exports = {
|
|||||||
HTTP 请求方法,返回 Promise。
|
HTTP 请求方法,返回 Promise。
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
|
|
||||||
- `url` (string): 请求地址
|
- `url` (string): 请求地址
|
||||||
- `options` (object): 请求选项
|
- `options` (object): 请求选项
|
||||||
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
|
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
|
||||||
@@ -174,6 +172,7 @@ HTTP 请求方法,返回 Promise。
|
|||||||
- `timeout`: 超时时间(毫秒)
|
- `timeout`: 超时时间(毫秒)
|
||||||
|
|
||||||
**返回值:**
|
**返回值:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -206,16 +205,17 @@ cerumusic.utils.crypto.rsaEncrypt(data, key)
|
|||||||
cerumusic.NoticeCenter('info', {
|
cerumusic.NoticeCenter('info', {
|
||||||
title: '通知标题',
|
title: '通知标题',
|
||||||
content: '通知内容',
|
content: '通知内容',
|
||||||
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
|
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
|
||||||
version: '版本号', // 当通知为update 版本跟新可传
|
version: '版本号', // 当通知为update 版本跟新可传
|
||||||
pluginInfo: {
|
pluginInfo: {
|
||||||
name: '插件名称',
|
name: '插件名称',
|
||||||
type: 'cr', // 固定唯一标识
|
type: 'cr' // 固定唯一标识
|
||||||
}// 当通知为update 版本跟新可传
|
} // 当通知为update 版本跟新可传
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**通知类型:**
|
**通知类型:**
|
||||||
|
|
||||||
- `'info'`: 信息通知
|
- `'info'`: 信息通知
|
||||||
- `'success'`: 成功通知
|
- `'success'`: 成功通知
|
||||||
- `'warn'`: 警告通知
|
- `'warn'`: 警告通知
|
||||||
@@ -247,46 +247,47 @@ const qualitys = {
|
|||||||
'128k': '128',
|
'128k': '128',
|
||||||
'320k': '320',
|
'320k': '320',
|
||||||
flac: 'flac',
|
flac: 'flac',
|
||||||
flac24bit: 'flac24bit',
|
flac24bit: 'flac24bit'
|
||||||
},
|
},
|
||||||
local: {},
|
local: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP 请求封装
|
// HTTP 请求封装
|
||||||
const httpRequest = (url, options) => new Promise((resolve, reject) => {
|
const httpRequest = (url, options) =>
|
||||||
request(url, options, (err, resp) => {
|
new Promise((resolve, reject) => {
|
||||||
if (err) return reject(err)
|
request(url, options, (err, resp) => {
|
||||||
resolve(resp.body)
|
if (err) return reject(err)
|
||||||
|
resolve(resp.body)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// API 实现
|
// API 实现
|
||||||
const apis = {
|
const apis = {
|
||||||
kw: {
|
kw: {
|
||||||
musicUrl({ songmid }, quality) {
|
musicUrl({ songmid }, quality) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
local: {
|
local: {
|
||||||
musicUrl(info) {
|
musicUrl(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
pic(info) {
|
pic(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return data.url
|
return data.url
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
lyric(info) {
|
lyric(info) {
|
||||||
return httpRequest('http://xxx').then(data => {
|
return httpRequest('http://xxx').then((data) => {
|
||||||
return {
|
return {
|
||||||
lyric: '...', // 歌曲歌词
|
lyric: '...', // 歌曲歌词
|
||||||
tlyric: '...', // 翻译歌词,没有可为 null
|
tlyric: '...', // 翻译歌词,没有可为 null
|
||||||
rlyric: '...', // 罗马音歌词,没有可为 null
|
rlyric: '...', // 罗马音歌词,没有可为 null
|
||||||
lxlyric: '...', // lx 逐字歌词,没有可为 null
|
lxlyric: '...' // lx 逐字歌词,没有可为 null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,15 +314,15 @@ send(EVENT_NAMES.inited, {
|
|||||||
name: '酷我音乐',
|
name: '酷我音乐',
|
||||||
type: 'music',
|
type: 'music',
|
||||||
actions: ['musicUrl'],
|
actions: ['musicUrl'],
|
||||||
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
|
qualitys: ['128k', '320k', 'flac', 'flac24bit']
|
||||||
},
|
},
|
||||||
local: {
|
local: {
|
||||||
name: '本地音乐',
|
name: '本地音乐',
|
||||||
type: 'music',
|
type: 'music',
|
||||||
actions: ['musicUrl', 'lyric', 'pic'],
|
actions: ['musicUrl', 'lyric', 'pic'],
|
||||||
qualitys: [],
|
qualitys: []
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -342,8 +343,8 @@ send(EVENT_NAMES.inited, {
|
|||||||
```javascript
|
```javascript
|
||||||
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
|
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
|
||||||
// 必须返回 Promise
|
// 必须返回 Promise
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result)
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### globalThis.lx.send(eventName, data)
|
#### globalThis.lx.send(eventName, data)
|
||||||
@@ -369,18 +370,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
|
|||||||
HTTP 请求方法:
|
HTTP 请求方法:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
lx.request('https://api.example.com', {
|
lx.request(
|
||||||
method: 'POST',
|
'https://api.example.com',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify(data),
|
method: 'POST',
|
||||||
timeout: 10000
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}, (err, resp) => {
|
body: JSON.stringify(data),
|
||||||
if (err) {
|
timeout: 10000
|
||||||
console.error('请求失败:', err);
|
},
|
||||||
return;
|
(err, resp) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('请求失败:', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('响应:', resp.body)
|
||||||
}
|
}
|
||||||
console.log('响应:', resp.body);
|
)
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### globalThis.lx.utils
|
#### globalThis.lx.utils
|
||||||
@@ -433,28 +438,28 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
try {
|
try {
|
||||||
// 参数验证
|
// 参数验证
|
||||||
if (!musicInfo || !musicInfo.id) {
|
if (!musicInfo || !musicInfo.id) {
|
||||||
throw new Error('音乐信息不完整');
|
throw new Error('音乐信息不完整')
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 调用
|
// API 调用
|
||||||
const result = await cerumusic.request(url, options);
|
const result = await cerumusic.request(url, options)
|
||||||
|
|
||||||
// 结果验证
|
// 结果验证
|
||||||
if (!result || result.statusCode !== 200) {
|
if (!result || result.statusCode !== 200) {
|
||||||
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
|
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.body || !result.body.url) {
|
if (!result.body || !result.body.url) {
|
||||||
throw new Error('返回数据格式错误');
|
throw new Error('返回数据格式错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.body.url;
|
return result.body.url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 记录错误日志
|
// 记录错误日志
|
||||||
console.error(`[${source}] 获取音乐链接失败:`, error.message);
|
console.error(`[${source}] 获取音乐链接失败:`, error.message)
|
||||||
|
|
||||||
// 重新抛出错误供上层处理
|
// 重新抛出错误供上层处理
|
||||||
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
|
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -473,9 +478,9 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
### 1. 使用 console.log
|
### 1. 使用 console.log
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
console.log('[插件名] 调试信息:', data);
|
console.log('[插件名] 调试信息:', data)
|
||||||
console.warn('[插件名] 警告信息:', warning);
|
console.warn('[插件名] 警告信息:', warning)
|
||||||
console.error('[插件名] 错误信息:', error);
|
console.error('[插件名] 错误信息:', error)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. LX 插件开发者工具
|
### 2. LX 插件开发者工具
|
||||||
@@ -491,8 +496,8 @@ send(EVENT_NAMES.inited, {
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
console.error('未处理的 Promise 拒绝:', reason);
|
console.error('未处理的 Promise 拒绝:', reason)
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -502,17 +507,17 @@ process.on('unhandledRejection', (reason, promise) => {
|
|||||||
### 1. 请求缓存
|
### 1. 请求缓存
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const cache = new Map();
|
const cache = new Map()
|
||||||
|
|
||||||
async function getCachedData(key, fetcher, ttl = 300000) {
|
async function getCachedData(key, fetcher, ttl = 300000) {
|
||||||
const cached = cache.get(key);
|
const cached = cache.get(key)
|
||||||
if (cached && Date.now() - cached.timestamp < ttl) {
|
if (cached && Date.now() - cached.timestamp < ttl) {
|
||||||
return cached.data;
|
return cached.data
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetcher();
|
const data = await fetcher()
|
||||||
cache.set(key, { data, timestamp: Date.now() });
|
cache.set(key, { data, timestamp: Date.now() })
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -521,21 +526,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
|
|||||||
```javascript
|
```javascript
|
||||||
const result = await cerumusic.request(url, {
|
const result = await cerumusic.request(url, {
|
||||||
timeout: 10000 // 10秒超时
|
timeout: 10000 // 10秒超时
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 并发控制
|
### 3. 并发控制
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 限制并发请求数量
|
// 限制并发请求数量
|
||||||
const semaphore = new Semaphore(3); // 最多3个并发请求
|
const semaphore = new Semaphore(3) // 最多3个并发请求
|
||||||
|
|
||||||
async function limitedRequest(url, options) {
|
async function limitedRequest(url, options) {
|
||||||
await semaphore.acquire();
|
await semaphore.acquire()
|
||||||
try {
|
try {
|
||||||
return await cerumusic.request(url, options);
|
return await cerumusic.request(url, options)
|
||||||
} finally {
|
} finally {
|
||||||
semaphore.release();
|
semaphore.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -549,14 +554,14 @@ async function limitedRequest(url, options) {
|
|||||||
```javascript
|
```javascript
|
||||||
function validateMusicInfo(musicInfo) {
|
function validateMusicInfo(musicInfo) {
|
||||||
if (!musicInfo || typeof musicInfo !== 'object') {
|
if (!musicInfo || typeof musicInfo !== 'object') {
|
||||||
throw new Error('音乐信息格式错误');
|
throw new Error('音乐信息格式错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
|
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
|
||||||
throw new Error('音乐 ID 无效');
|
throw new Error('音乐 ID 无效')
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -565,10 +570,10 @@ function validateMusicInfo(musicInfo) {
|
|||||||
```javascript
|
```javascript
|
||||||
function isValidUrl(url) {
|
function isValidUrl(url) {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url)
|
||||||
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -581,7 +586,7 @@ console.log('请求参数:', {
|
|||||||
...params,
|
...params,
|
||||||
token: '***', // 隐藏敏感信息
|
token: '***', // 隐藏敏感信息
|
||||||
password: '***'
|
password: '***'
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -605,13 +610,13 @@ async function testMusicUrl() {
|
|||||||
id: 'test123',
|
id: 'test123',
|
||||||
name: '测试歌曲',
|
name: '测试歌曲',
|
||||||
artist: '测试歌手'
|
artist: '测试歌手'
|
||||||
};
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = await musicUrl('kw', testMusicInfo, '320k');
|
const url = await musicUrl('kw', testMusicInfo, '320k')
|
||||||
console.log('测试通过:', url);
|
console.log('测试通过:', url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('测试失败:', error);
|
console.error('测试失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -619,6 +624,7 @@ async function testMusicUrl() {
|
|||||||
### 3. 版本管理
|
### 3. 版本管理
|
||||||
|
|
||||||
使用语义化版本号:
|
使用语义化版本号:
|
||||||
|
|
||||||
- `1.0.0`: 主版本.次版本.修订版本
|
- `1.0.0`: 主版本.次版本.修订版本
|
||||||
- 主版本:不兼容的 API 修改
|
- 主版本:不兼容的 API 修改
|
||||||
- 次版本:向下兼容的功能性新增
|
- 次版本:向下兼容的功能性新增
|
||||||
@@ -631,6 +637,7 @@ async function testMusicUrl() {
|
|||||||
### Q: 插件加载失败怎么办?
|
### Q: 插件加载失败怎么办?
|
||||||
|
|
||||||
A: 检查以下几点:
|
A: 检查以下几点:
|
||||||
|
|
||||||
1. 文件编码是否为 UTF-8
|
1. 文件编码是否为 UTF-8
|
||||||
2. 插件信息注释格式是否正确
|
2. 插件信息注释格式是否正确
|
||||||
3. JavaScript 语法是否有错误
|
3. JavaScript 语法是否有错误
|
||||||
@@ -645,20 +652,21 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
|
|||||||
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
|
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
cerumusic.NoticeCenter('update',{
|
cerumusic.NoticeCenter('update', {
|
||||||
title:'新版本更新',
|
title: '新版本更新',
|
||||||
content:'xxxx',
|
content: 'xxxx',
|
||||||
version: 'v1.0.3',
|
version: 'v1.0.3',
|
||||||
url:'https://shiqianjiang.cn',
|
url: 'https://shiqianjiang.cn',
|
||||||
pluginInfo:{
|
pluginInfo: {
|
||||||
type:'cr'
|
type: 'cr'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Q: 如何调试插件?
|
### Q: 如何调试插件?
|
||||||
|
|
||||||
A:
|
A:
|
||||||
|
|
||||||
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
|
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
|
||||||
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
|
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
|
||||||
3. 查看 CeruMusic 的插件日志
|
3. 查看 CeruMusic 的插件日志
|
||||||
@@ -668,5 +676,6 @@ A:
|
|||||||
## 技术支持
|
## 技术支持
|
||||||
|
|
||||||
如有问题或建议,请通过以下方式联系:
|
如有问题或建议,请通过以下方式联系:
|
||||||
|
|
||||||
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
|
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
|
||||||
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)
|
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)
|
||||||
|
|||||||
@@ -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] 点击搜索框的 源图标实现快速切换
|
- [x] 点击搜索框的 源图标实现快速切换
|
||||||
- [ ] ai功能完善
|
- [ ] ai功能完善
|
||||||
- [ ] 支持歌词隐藏
|
- [ ] 支持歌词隐藏
|
||||||
- [x] 兼容多平台歌单导入
|
- [x] 兼容多平台歌单导入
|
||||||
- [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)
|
- ###### 2025-9-22 (v1.3.5)
|
||||||
|
|
||||||
1. 软件启动位置 宽高记忆 限制软件最大宽高
|
1. 软件启动位置 宽高记忆 限制软件最大宽高
|
||||||
2. debug: 修复歌曲音质支持短缺问题
|
2. debug: 修复歌曲音质支持短缺问题
|
||||||
|
|
||||||
- ###### 2025-9-21 (v1.3.4)
|
- ###### 2025-9-21 (v1.3.4)
|
||||||
|
|
||||||
1. 紧急修复QQ音乐歌词失效问题
|
1. 紧急修复QQ音乐歌词失效问题
|
||||||
|
|
||||||
- ###### 2025-9-21(v1.3.3)
|
- ###### 2025-9-21(v1.3.3)
|
||||||
|
|
||||||
1. 兼容多平台歌单导入
|
1. 兼容多平台歌单导入
|
||||||
2. 点击搜索框的 源图标实现快速切换
|
2. 点击搜索框的 源图标实现快速切换
|
||||||
3. debug: fix:列表删除按钮冒泡
|
3. debug: fix:列表删除按钮冒泡
|
||||||
- ###### 2025-9-17 **(v1.3.2)**
|
|
||||||
|
|
||||||
|
- ###### 2025-9-17 **(v1.3.2)**
|
||||||
1. 目录结构调整
|
1. 目录结构调整
|
||||||
|
|
||||||
2. **支持插件更新提示**
|
2. **支持插件更新提示**
|
||||||
@@ -27,13 +41,11 @@
|
|||||||
**洛雪** 插件请手动重装适配
|
**洛雪** 插件请手动重装适配
|
||||||
|
|
||||||
3. **debug**
|
3. **debug**
|
||||||
|
|
||||||
- SMTC 问题
|
- SMTC 问题
|
||||||
|
|
||||||
- 歌曲缓存播放多次请求和多次缓存问题
|
- 歌曲缓存播放多次请求和多次缓存问题
|
||||||
|
|
||||||
- ###### 2025-9-17 **(v1.3.1)**
|
- ###### 2025-9-17 **(v1.3.1)**
|
||||||
|
|
||||||
1. **设置功能页**
|
1. **设置功能页**
|
||||||
- 缓存路径支持自定义
|
- 缓存路径支持自定义
|
||||||
- 下载路径支持自定义
|
- 下载路径支持自定义
|
||||||
@@ -41,4 +53,4 @@
|
|||||||
- 播放页面唱针可以拖动问题
|
- 播放页面唱针可以拖动问题
|
||||||
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
|
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
|
||||||
- **SMTC** 功能 系统显示**未知应用**问题
|
- **SMTC** 功能 系统显示**未知应用**问题
|
||||||
- 播放页歌词**字体粗细**偶现丢失问题
|
- 播放页歌词**字体粗细**偶现丢失问题
|
||||||
|
|||||||
@@ -23,6 +23,4 @@
|
|||||||
|
|
||||||
歌单将自动选取第一首 **有效封面**[^1] 为歌单
|
歌单将自动选取第一首 **有效封面**[^1] 为歌单
|
||||||
|
|
||||||
|
[^1]: url正确的歌曲封面
|
||||||
|
|
||||||
[^1]: url正确的歌曲封面
|
|
||||||
|
|||||||
@@ -2,24 +2,24 @@
|
|||||||
// 这个文件可以用来测试 NoticeCenter 功能
|
// 这个文件可以用来测试 NoticeCenter 功能
|
||||||
|
|
||||||
const pluginInfo = {
|
const pluginInfo = {
|
||||||
name: "测试通知插件",
|
name: '测试通知插件',
|
||||||
version: "1.0.0",
|
version: '1.0.0',
|
||||||
author: "CeruMusic Team",
|
author: 'CeruMusic Team',
|
||||||
description: "用于测试插件通知功能的示例插件",
|
description: '用于测试插件通知功能的示例插件',
|
||||||
type: "cr"
|
type: 'cr'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
name: "test",
|
name: 'test',
|
||||||
qualities: ["128k", "320k"]
|
qualities: ['128k', '320k']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 模拟音乐URL获取函数
|
// 模拟音乐URL获取函数
|
||||||
async function musicUrl(source, musicInfo, quality) {
|
async function musicUrl(source, musicInfo, quality) {
|
||||||
console.log('测试插件:获取音乐URL')
|
console.log('测试插件:获取音乐URL')
|
||||||
|
|
||||||
// 测试不同类型的通知
|
// 测试不同类型的通知
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 测试信息通知
|
// 测试信息通知
|
||||||
@@ -29,7 +29,7 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
content: '插件正在正常工作'
|
content: '插件正在正常工作'
|
||||||
})
|
})
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 测试警告通知
|
// 测试警告通知
|
||||||
this.cerumusic.NoticeCenter('warning', {
|
this.cerumusic.NoticeCenter('warning', {
|
||||||
@@ -38,7 +38,7 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
content: '请注意某些设置'
|
content: '请注意某些设置'
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 测试成功通知
|
// 测试成功通知
|
||||||
this.cerumusic.NoticeCenter('success', {
|
this.cerumusic.NoticeCenter('success', {
|
||||||
@@ -47,7 +47,7 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
content: '音乐URL获取成功'
|
content: '音乐URL获取成功'
|
||||||
})
|
})
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 测试更新通知
|
// 测试更新通知
|
||||||
this.cerumusic.NoticeCenter('update', {
|
this.cerumusic.NoticeCenter('update', {
|
||||||
@@ -62,7 +62,7 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 测试错误通知
|
// 测试错误通知
|
||||||
this.cerumusic.NoticeCenter('error', {
|
this.cerumusic.NoticeCenter('error', {
|
||||||
@@ -71,7 +71,7 @@ async function musicUrl(source, musicInfo, quality) {
|
|||||||
error: '模拟的错误信息'
|
error: '模拟的错误信息'
|
||||||
})
|
})
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
|
||||||
// 返回一个测试URL
|
// 返回一个测试URL
|
||||||
return 'https://example.com/test-music.mp3'
|
return 'https://example.com/test-music.mp3'
|
||||||
}
|
}
|
||||||
@@ -81,4 +81,4 @@ module.exports = {
|
|||||||
pluginInfo,
|
pluginInfo,
|
||||||
sources,
|
sources,
|
||||||
musicUrl
|
musicUrl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,27 +81,27 @@ this.cerumusic.NoticeCenter('update', {
|
|||||||
|
|
||||||
#### 通用参数 (data 对象)
|
#### 通用参数 (data 对象)
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ------- | ------ | ---- | ------------------------------ |
|
||||||
| title | string | 否 | 通知标题,不提供时使用默认标题 |
|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
|
||||||
| message | string | 否 | 通知消息内容 |
|
| message | string | 否 | 通知消息内容 |
|
||||||
| content | string | 否 | 详细内容(与 message 二选一) |
|
| content | string | 否 | 详细内容(与 message 二选一) |
|
||||||
|
|
||||||
#### 更新通知特有参数
|
#### 更新通知特有参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ----------------------- | ------------ | ---- | ---------------- |
|
||||||
| url | string | 是 | 插件更新下载链接 |
|
| url | string | 是 | 插件更新下载链接 |
|
||||||
| version | string | 否 | 新版本号 |
|
| version | string | 否 | 新版本号 |
|
||||||
| pluginInfo.name | string | 否 | 插件名称 |
|
| pluginInfo.name | string | 否 | 插件名称 |
|
||||||
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
|
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
|
||||||
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
|
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
|
||||||
|
|
||||||
#### 错误通知特有参数
|
#### 错误通知特有参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ----- | ------ | ---- | ------------ |
|
||||||
| error | string | 否 | 具体错误信息 |
|
| error | string | 否 | 具体错误信息 |
|
||||||
|
|
||||||
## 实现原理
|
## 实现原理
|
||||||
|
|
||||||
@@ -208,8 +208,9 @@ window.api.on('plugin-notice', (_, notice) => {
|
|||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
### v1.0.0 (2025-09-20)
|
### v1.0.0 (2025-09-20)
|
||||||
|
|
||||||
- ✨ 初始版本发布
|
- ✨ 初始版本发布
|
||||||
- ✨ 支持 5 种通知类型
|
- ✨ 支持 5 种通知类型
|
||||||
- ✨ 完整的 TypeScript 类型定义
|
- ✨ 完整的 TypeScript 类型定义
|
||||||
- ✨ 响应式设计和深色主题支持
|
- ✨ 响应式设计和深色主题支持
|
||||||
- ✨ 完善的错误处理机制
|
- ✨ 完善的错误处理机制
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.3.6",
|
"version": "1.3.7",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -102,4 +102,4 @@
|
|||||||
"vue-eslint-parser": "^10.2.0",
|
"vue-eslint-parser": "^10.2.0",
|
||||||
"vue-tsc": "^3.0.3"
|
"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:
|
profile:
|
||||||
name: qodana.starter
|
name: qodana.starter
|
||||||
|
|||||||
@@ -1,55 +1,79 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs')
|
||||||
const path = require('path');
|
const path = require('path')
|
||||||
|
|
||||||
|
function generateTree(
|
||||||
|
dir,
|
||||||
|
prefix = '',
|
||||||
|
isLast = true,
|
||||||
|
excludeDirs = [
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'out',
|
||||||
|
'.git',
|
||||||
|
'.kiro',
|
||||||
|
'.idea',
|
||||||
|
'.codebuddy',
|
||||||
|
'.vscode',
|
||||||
|
'.workflow',
|
||||||
|
'assets',
|
||||||
|
'resources',
|
||||||
|
'docs'
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
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)) {
|
if (
|
||||||
return;
|
basename.startsWith('.') &&
|
||||||
|
basename !== '.' &&
|
||||||
|
basename !== '..' &&
|
||||||
|
!['.github', '.workflow'].includes(basename)
|
||||||
|
) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (excludeDirs.includes(basename)) {
|
if (excludeDirs.includes(basename)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前项目显示
|
// 当前项目显示
|
||||||
if (prefix === '') {
|
if (prefix === '') {
|
||||||
console.log(`${basename}/`);
|
console.log(`${basename}/`)
|
||||||
} else {
|
} else {
|
||||||
const connector = isLast ? '└── ' : '├── ';
|
const connector = isLast ? '└── ' : '├── '
|
||||||
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
|
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
|
||||||
console.log(prefix + connector + displayName);
|
console.log(prefix + connector + displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.statSync(dir).isDirectory()) {
|
if (!fs.statSync(dir).isDirectory()) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = fs.readdirSync(dir)
|
const items = fs
|
||||||
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
|
.readdirSync(dir)
|
||||||
.filter(item => !excludeDirs.includes(item))
|
.filter((item) => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
|
||||||
|
.filter((item) => !excludeDirs.includes(item))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// 目录排在前面,文件排在后面
|
// 目录排在前面,文件排在后面
|
||||||
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
|
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory()
|
||||||
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
|
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory()
|
||||||
if (aIsDir && !bIsDir) return -1;
|
if (aIsDir && !bIsDir) return -1
|
||||||
if (!aIsDir && bIsDir) return 1;
|
if (!aIsDir && bIsDir) return 1
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b)
|
||||||
});
|
})
|
||||||
|
|
||||||
const newPrefix = prefix + (isLast ? ' ' : '│ ');
|
const newPrefix = prefix + (isLast ? ' ' : '│ ')
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const isLastItem = index === items.length - 1;
|
const isLastItem = index === items.length - 1
|
||||||
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
|
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading directory: ${dir}`, error.message);
|
console.error(`Error reading directory: ${dir}`, error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用示例
|
// 使用示例
|
||||||
const targetDir = process.argv[2] || '.';
|
const targetDir = process.argv[2] || '.'
|
||||||
console.log('项目文件结构:');
|
console.log('项目文件结构:')
|
||||||
generateTree(targetDir);
|
generateTree(targetDir)
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ export default {
|
|||||||
lineTime2: /^\[([\d:.]+)\]/,
|
lineTime2: /^\[([\d:.]+)\]/,
|
||||||
wordTime: /\(\d+,\d+,\d+\)/,
|
wordTime: /\(\d+,\d+,\d+\)/,
|
||||||
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
||||||
timeLabelFixRxp: /(?:\.0+|0+)$/,
|
timeLabelFixRxp: /(?:\.0+|0+)$/
|
||||||
},
|
},
|
||||||
msFormat(timeMs) {
|
msFormat(timeMs) {
|
||||||
if (Number.isNaN(timeMs)) return ''
|
if (Number.isNaN(timeMs)) return ''
|
||||||
let ms = timeMs % 1000
|
let ms = timeMs % 1000
|
||||||
timeMs /= 1000
|
timeMs /= 1000
|
||||||
let m = parseInt(timeMs / 60).toString().padStart(2, '0')
|
let m = parseInt(timeMs / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')
|
||||||
timeMs %= 60
|
timeMs %= 60
|
||||||
let s = parseInt(timeMs).toString().padStart(2, '0')
|
let s = parseInt(timeMs).toString().padStart(2, '0')
|
||||||
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
||||||
@@ -78,7 +80,7 @@ export default {
|
|||||||
let currentStart = startMsTime
|
let currentStart = startMsTime
|
||||||
const processedTimes = []
|
const processedTimes = []
|
||||||
|
|
||||||
times.forEach(time => {
|
times.forEach((time) => {
|
||||||
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
|
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
|
||||||
const duration = parseInt(result[2])
|
const duration = parseInt(result[2])
|
||||||
processedTimes.push(`(${currentStart},${duration},0)`)
|
processedTimes.push(`(${currentStart},${duration},0)`)
|
||||||
@@ -91,7 +93,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
lyric: lrcLines.join('\n'),
|
lyric: lrcLines.join('\n'),
|
||||||
lxlyric: lxlrcLines.join('\n'),
|
lxlyric: lxlrcLines.join('\n')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getIntv(interval) {
|
getIntv(interval) {
|
||||||
@@ -171,8 +173,7 @@ export default {
|
|||||||
lyric: '',
|
lyric: '',
|
||||||
tlyric: '',
|
tlyric: '',
|
||||||
rlyric: '',
|
rlyric: '',
|
||||||
crlyric: '',
|
crlyric: ''
|
||||||
|
|
||||||
}
|
}
|
||||||
if (lrc) {
|
if (lrc) {
|
||||||
let { lyric } = this.parseCeru(this.removeTag(lrc))
|
let { lyric } = this.parseCeru(this.removeTag(lrc))
|
||||||
@@ -208,11 +209,7 @@ export default {
|
|||||||
return lrcLines.join('\n')
|
return lrcLines.join('\n')
|
||||||
},
|
},
|
||||||
parseLyric(lrc, tlrc, rlrc) {
|
parseLyric(lrc, tlrc, rlrc) {
|
||||||
return this.parse(
|
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
|
||||||
decode(lrc),
|
|
||||||
decode(tlrc),
|
|
||||||
decode(rlrc)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
getLyric(mInfo, retryNum = 0) {
|
getLyric(mInfo, retryNum = 0) {
|
||||||
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import zlib from 'zlib'
|
import zlib from 'zlib'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
|||||||
14
src/preload/index.d.ts
vendored
14
src/preload/index.d.ts
vendored
@@ -79,7 +79,7 @@ interface CustomAPI {
|
|||||||
start: () => undefined
|
start: () => undefined
|
||||||
stop: () => undefined
|
stop: () => undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目录设置API
|
// 目录设置API
|
||||||
directorySettings: {
|
directorySettings: {
|
||||||
getDirectories: () => Promise<{
|
getDirectories: () => Promise<{
|
||||||
@@ -96,10 +96,7 @@ interface CustomAPI {
|
|||||||
path?: string
|
path?: string
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
saveDirectories: (directories: {
|
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
|
||||||
cacheDir: string
|
|
||||||
downloadDir: string
|
|
||||||
}) => Promise<{
|
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
}>
|
}>
|
||||||
@@ -119,15 +116,14 @@ interface CustomAPI {
|
|||||||
size: number
|
size: number
|
||||||
formatted: string
|
formatted: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户配置API
|
// 用户配置API
|
||||||
getUserConfig: () => Promise<any>
|
getUserConfig: () => Promise<any>
|
||||||
|
|
||||||
pluginNotice: {
|
pluginNotice: {
|
||||||
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
|
onPluginNotice: (listener: (...args: any[]) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -77,6 +77,6 @@ body {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||||
}
|
}
|
||||||
.t-dialog__mask{
|
.t-dialog__mask {
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,11 @@
|
|||||||
|
|
||||||
<!-- 子菜单箭头 -->
|
<!-- 子菜单箭头 -->
|
||||||
<div v-if="item.children && item.children.length > 0" class="context-menu__arrow">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
@@ -75,7 +79,13 @@
|
|||||||
</div>
|
</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
|
<ContextMenu
|
||||||
ref="submenuRef"
|
ref="submenuRef"
|
||||||
:visible="true"
|
:visible="true"
|
||||||
@@ -102,6 +112,7 @@ import type {
|
|||||||
AnimationConfig,
|
AnimationConfig,
|
||||||
ScrollConfig
|
ScrollConfig
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
|
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
|
||||||
@@ -187,7 +198,12 @@ const scrollContainerStyle = computed((): CSSProperties => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const visibleItems = computed(() => {
|
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(() => {
|
const showScrollIndicator = computed(() => {
|
||||||
@@ -199,9 +215,7 @@ const canScrollDown = computed(() => scrollTop.value < scrollHeight.value - clie
|
|||||||
|
|
||||||
const submenuWrapperStyle = computed((): CSSProperties => {
|
const submenuWrapperStyle = computed((): CSSProperties => {
|
||||||
return {
|
return {
|
||||||
position: 'absolute',
|
position: 'fixed',
|
||||||
left: '100%',
|
|
||||||
top: '0',
|
|
||||||
zIndex: props.zIndex + 1,
|
zIndex: props.zIndex + 1,
|
||||||
maxHeight: `${submenuMaxHeight.value}px`
|
maxHeight: `${submenuMaxHeight.value}px`
|
||||||
}
|
}
|
||||||
@@ -362,6 +376,11 @@ const handleSubmenuItemClick = (item: ContextMenuItem, event: MouseEvent) => {
|
|||||||
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
|
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
|
||||||
if (!menuRef.value) return
|
if (!menuRef.value) return
|
||||||
|
|
||||||
|
// 如果是相同的子菜单,不需要重新计算位置
|
||||||
|
if (activeSubmenu.value && activeSubmenu.value.id === item.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 移除未使用的变量声明
|
// 移除未使用的变量声明
|
||||||
activeSubmenu.value = item
|
activeSubmenu.value = item
|
||||||
|
|
||||||
@@ -372,16 +391,119 @@ const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
|
|||||||
|
|
||||||
const closeSubmenu = () => {
|
const closeSubmenu = () => {
|
||||||
activeSubmenu.value = null
|
activeSubmenu.value = null
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSubmenuPosition = () => {
|
const updateSubmenuPosition = () => {
|
||||||
if (!menuRef.value || !activeSubmenu.value) return
|
if (!menuRef.value || !activeSubmenu.value) return
|
||||||
|
|
||||||
const menuRect = menuRef.value.getBoundingClientRect()
|
const menuRect = menuRef.value.getBoundingClientRect()
|
||||||
submenuPosition.value = {
|
// 初始位置:显示在右侧
|
||||||
x: menuRect.right - 2,
|
const x = menuRect.right
|
||||||
y: menuRect.top
|
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 = () => {
|
const handleBackdropClick = () => {
|
||||||
@@ -397,6 +519,18 @@ const handleMouseLeave = () => {
|
|||||||
clearTimeout(submenuTimer.value)
|
clearTimeout(submenuTimer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmenuMouseEnter = () => {
|
||||||
|
// 鼠标进入子菜单区域,清除关闭定时器
|
||||||
|
clearTimeout(submenuTimer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmenuMouseLeave = () => {
|
||||||
|
// 鼠标离开子菜单区域,延迟关闭子菜单
|
||||||
|
submenuTimer.value = setTimeout(() => {
|
||||||
|
closeSubmenu()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!props.visible) return
|
if (!props.visible) return
|
||||||
|
|
||||||
@@ -476,10 +610,10 @@ defineExpose({
|
|||||||
/* scrollbar-color: rgba(255, 255, 255, 0.3) transparent; */
|
/* scrollbar-color: rgba(255, 255, 255, 0.3) transparent; */
|
||||||
transition: transform 0.15s ease;
|
transition: transform 0.15s ease;
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
.context-menu__scroll-container::-webkit-scrollbar {
|
.context-menu__scroll-container::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
|
||||||
.context-menu__scroll-container::-webkit-scrollbar-track {
|
.context-menu__scroll-container::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -528,6 +662,8 @@ defineExpose({
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu__item--has-children {
|
.context-menu__item--has-children {
|
||||||
@@ -560,6 +696,9 @@ defineExpose({
|
|||||||
right: 8px;
|
right: 8px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,8 +709,10 @@ defineExpose({
|
|||||||
|
|
||||||
.context-menu__separator {
|
.context-menu__separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
background: #e0e0e0;
|
background: #e0e0e0;
|
||||||
margin: 0 8px;
|
margin: 0 8px;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu__scroll-indicator {
|
.context-menu__scroll-indicator {
|
||||||
@@ -658,7 +799,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-menu__separator {
|
.context-menu__separator {
|
||||||
background: #404040;
|
background: #555555;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu__scroll-indicator-top,
|
.context-menu__scroll-indicator-top,
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ const menuItems = ref<ContextMenuItem[]>([
|
|||||||
```vue
|
```vue
|
||||||
<template>
|
<template>
|
||||||
<div class="song-list">
|
<div class="song-list">
|
||||||
<div
|
<div
|
||||||
v-for="song in songs"
|
v-for="song in songs"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
class="song-item"
|
class="song-item"
|
||||||
@contextmenu.prevent="handleSongContextMenu(song, $event)"
|
@contextmenu.prevent="handleSongContextMenu(song, $event)"
|
||||||
@@ -99,13 +99,13 @@ const handleSongContextMenu = (song, event) => {
|
|||||||
|
|
||||||
### ContextMenu 组件属性
|
### ContextMenu 组件属性
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 说明 |
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
| --------- | ------------------- | --------- | ----------------- |
|
||||||
| visible | boolean | false | 控制菜单显示/隐藏 |
|
| visible | boolean | false | 控制菜单显示/隐藏 |
|
||||||
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
|
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
|
||||||
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
|
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
|
||||||
| maxHeight | number | 400 | 菜单最大高度 |
|
| maxHeight | number | 400 | 菜单最大高度 |
|
||||||
| zIndex | number | 1000 | 菜单层级 |
|
| zIndex | number | 1000 | 菜单层级 |
|
||||||
|
|
||||||
### ContextMenuItem 类型
|
### ContextMenuItem 类型
|
||||||
|
|
||||||
@@ -125,6 +125,7 @@ interface ContextMenuItem {
|
|||||||
### 工具函数
|
### 工具函数
|
||||||
|
|
||||||
#### createMenuItem
|
#### createMenuItem
|
||||||
|
|
||||||
创建标准菜单项
|
创建标准菜单项
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -139,6 +140,7 @@ createMenuItem(id: string, label: string, options?: {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### createSeparator
|
#### createSeparator
|
||||||
|
|
||||||
创建分隔线
|
创建分隔线
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -146,12 +148,13 @@ createSeparator(): ContextMenuItem
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### calculateMenuPosition
|
#### calculateMenuPosition
|
||||||
|
|
||||||
智能计算菜单位置
|
智能计算菜单位置
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
calculateMenuPosition(
|
calculateMenuPosition(
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
menuWidth?: number,
|
menuWidth?: number,
|
||||||
menuHeight?: number
|
menuHeight?: number
|
||||||
): ContextMenuPosition
|
): ContextMenuPosition
|
||||||
```
|
```
|
||||||
@@ -177,14 +180,12 @@ const menuItems = [
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const dynamicMenuItems = computed(() => {
|
const dynamicMenuItems = computed(() => {
|
||||||
const items = [
|
const items = [createMenuItem('play', '播放')]
|
||||||
createMenuItem('play', '播放')
|
|
||||||
]
|
|
||||||
|
|
||||||
if (user.value.isPremium) {
|
if (user.value.isPremium) {
|
||||||
items.push(createMenuItem('download', '下载高音质'))
|
items.push(createMenuItem('download', '下载高音质'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -227,14 +228,17 @@ const menuItems = [
|
|||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
### 菜单位置不正确
|
### 菜单位置不正确
|
||||||
|
|
||||||
确保使用 `calculateMenuPosition` 函数计算位置。
|
确保使用 `calculateMenuPosition` 函数计算位置。
|
||||||
|
|
||||||
### 菜单项点击无效
|
### 菜单项点击无效
|
||||||
|
|
||||||
检查 `onClick` 回调函数是否正确绑定。
|
检查 `onClick` 回调函数是否正确绑定。
|
||||||
|
|
||||||
### 样式冲突
|
### 样式冲突
|
||||||
|
|
||||||
使用 `className` 属性添加自定义样式类。
|
使用 `className` 属性添加自定义样式类。
|
||||||
|
|
||||||
## 贡献指南
|
## 贡献指南
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request 来改进这个组件。
|
欢迎提交 Issue 和 Pull Request 来改进这个组件。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="song-virtual-list">
|
<div class="song-virtual-list">
|
||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div v-if="showIndex" class="col-index"></div>
|
<div v-if="showIndex" class="col-index">#</div>
|
||||||
<div class="col-title">标题</div>
|
<div class="col-title">标题</div>
|
||||||
<div v-if="showAlbum" class="col-album">专辑</div>
|
<div v-if="showAlbum" class="col-album">专辑</div>
|
||||||
<div class="col-like">喜欢</div>
|
<div class="col-like">喜欢</div>
|
||||||
@@ -105,7 +105,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick, toRaw } from 'vue'
|
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 ContextMenu from '../ContextMenu/ContextMenu.vue'
|
||||||
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
||||||
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
|
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
|
||||||
@@ -136,6 +142,8 @@ interface Props {
|
|||||||
showIndex?: boolean
|
showIndex?: boolean
|
||||||
showAlbum?: boolean
|
showAlbum?: boolean
|
||||||
showDuration?: boolean
|
showDuration?: boolean
|
||||||
|
isLocalPlaylist?: boolean
|
||||||
|
playlistId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -143,10 +151,19 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
showIndex: true,
|
showIndex: true,
|
||||||
showAlbum: true,
|
showAlbum: true,
|
||||||
showDuration: true
|
showDuration: true,
|
||||||
|
isLocalPlaylist: false,
|
||||||
|
playlistId: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['play', 'pause', 'addToPlaylist', 'download', 'scroll'])
|
const emit = defineEmits([
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
|
'addToPlaylist',
|
||||||
|
'download',
|
||||||
|
'scroll',
|
||||||
|
'removeFromLocalPlaylist'
|
||||||
|
])
|
||||||
|
|
||||||
// 虚拟滚动相关状态
|
// 虚拟滚动相关状态
|
||||||
const scrollContainer = ref<HTMLElement>()
|
const scrollContainer = ref<HTMLElement>()
|
||||||
@@ -299,9 +316,6 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加分隔线
|
|
||||||
baseItems.push(createSeparator())
|
|
||||||
|
|
||||||
baseItems.push(
|
baseItems.push(
|
||||||
createMenuItem('download', '下载', {
|
createMenuItem('download', '下载', {
|
||||||
icon: DownloadIcon,
|
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
|
return baseItems
|
||||||
})
|
})
|
||||||
@@ -426,7 +455,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
padding-left: 10px;
|
padding-left: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, toRaw } from 'vue'
|
import { ref, onMounted, toRaw, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
|
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
singer: string
|
singer: string
|
||||||
@@ -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) => {
|
const replacePlaylist = (songsToReplace: MusicItem[], shouldShuffle = false) => {
|
||||||
if (!(window as any).musicEmitter) {
|
if (!(window as any).musicEmitter) {
|
||||||
@@ -299,9 +395,30 @@ onMounted(() => {
|
|||||||
<div class="fixed-header">
|
<div class="fixed-header">
|
||||||
<!-- 歌单信息 -->
|
<!-- 歌单信息 -->
|
||||||
<div class="playlist-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" />
|
<img :src="playlistInfo.cover" :alt="playlistInfo.title" />
|
||||||
|
<!-- 本地歌单显示编辑提示 -->
|
||||||
|
<div v-if="isLocalPlaylist" class="cover-overlay">
|
||||||
|
<svg class="edit-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>点击修改封面</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 隐藏的文件选择器 -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
<div class="playlist-details">
|
<div class="playlist-details">
|
||||||
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
||||||
<p class="playlist-author">by {{ playlistInfo.author }}</p>
|
<p class="playlist-author">by {{ playlistInfo.author }}</p>
|
||||||
@@ -362,10 +479,13 @@ onMounted(() => {
|
|||||||
:show-index="true"
|
:show-index="true"
|
||||||
:show-album="true"
|
:show-album="true"
|
||||||
:show-duration="true"
|
:show-duration="true"
|
||||||
|
:is-local-playlist="isLocalPlaylist"
|
||||||
|
:playlist-id="playlistInfo.id"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@pause="handlePause"
|
@pause="handlePause"
|
||||||
@download="handleDownload"
|
@download="handleDownload"
|
||||||
@add-to-playlist="handleAddToPlaylist"
|
@add-to-playlist="handleAddToPlaylist"
|
||||||
|
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,11 +571,58 @@ onMounted(() => {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地歌单封面可点击样式
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.cover-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.edit-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
import { ref, onMounted, computed, toRaw } from 'vue'
|
import { ref, onMounted, computed, toRaw } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { Edit2Icon, ListIcon } from 'tdesign-icons-vue-next'
|
import { Edit2Icon, ListIcon, PlayCircleIcon, DeleteIcon } from 'tdesign-icons-vue-next'
|
||||||
import songListAPI from '@renderer/api/songList'
|
import songListAPI from '@renderer/api/songList'
|
||||||
import type { SongList, Songs } from '@common/types/songList'
|
import type { SongList, Songs } from '@common/types/songList'
|
||||||
import defaultCover from '/default-cover.png'
|
import defaultCover from '/default-cover.png'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
|
import ContextMenu from '@renderer/components/ContextMenu/ContextMenu.vue'
|
||||||
|
import {
|
||||||
|
createMenuItem,
|
||||||
|
createSeparator,
|
||||||
|
calculateMenuPosition
|
||||||
|
} from '@renderer/components/ContextMenu/utils'
|
||||||
|
import type { ContextMenuItem, ContextMenuPosition } from '@renderer/components/ContextMenu/types'
|
||||||
|
|
||||||
// 扩展 Songs 类型以包含本地音乐的额外属性
|
// 扩展 Songs 类型以包含本地音乐的额外属性
|
||||||
interface LocalSong extends Songs {
|
interface LocalSong extends Songs {
|
||||||
@@ -134,6 +141,11 @@ const editPlaylistForm = ref({
|
|||||||
// 当前编辑的歌单
|
// 当前编辑的歌单
|
||||||
const currentEditingPlaylist = ref<SongList | null>(null)
|
const currentEditingPlaylist = ref<SongList | null>(null)
|
||||||
|
|
||||||
|
// 右键菜单状态
|
||||||
|
const contextMenuVisible = ref(false)
|
||||||
|
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
|
const contextMenuPlaylist = ref<SongList | null>(null)
|
||||||
|
|
||||||
// 将时长字符串转换为秒数
|
// 将时长字符串转换为秒数
|
||||||
const parseInterval = (interval: string): number => {
|
const parseInterval = (interval: string): number => {
|
||||||
if (!interval) return 0
|
if (!interval) return 0
|
||||||
@@ -830,6 +842,80 @@ const deleteSong = (song: Songs): void => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 右键菜单项配置
|
||||||
|
const contextMenuItems = computed((): ContextMenuItem[] => {
|
||||||
|
if (!contextMenuPlaylist.value) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
createMenuItem('play', '播放歌单', {
|
||||||
|
icon: PlayCircleIcon,
|
||||||
|
onClick: () => {
|
||||||
|
if (contextMenuPlaylist.value) {
|
||||||
|
playPlaylist(contextMenuPlaylist.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
createMenuItem('view', '查看详情', {
|
||||||
|
icon: 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(() => {
|
onMounted(() => {
|
||||||
loadPlaylists()
|
loadPlaylists()
|
||||||
@@ -894,7 +980,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 歌单网格 -->
|
<!-- 歌单网格 -->
|
||||||
<div v-else-if="playlists.length > 0" class="playlists-grid">
|
<div v-else-if="playlists.length > 0" class="playlists-grid">
|
||||||
<div v-for="playlist in playlists" :key="playlist.id" class="playlist-card">
|
<div
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-card"
|
||||||
|
@contextmenu="handlePlaylistContextMenu($event, playlist)"
|
||||||
|
>
|
||||||
<div class="playlist-cover" @click="viewPlaylist(playlist)">
|
<div class="playlist-cover" @click="viewPlaylist(playlist)">
|
||||||
<img
|
<img
|
||||||
v-if="playlist.coverImgUrl"
|
v-if="playlist.coverImgUrl"
|
||||||
@@ -1311,6 +1402,15 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t-dialog>
|
</t-dialog>
|
||||||
|
|
||||||
|
<!-- 歌单右键菜单 -->
|
||||||
|
<ContextMenu
|
||||||
|
v-model:visible="contextMenuVisible"
|
||||||
|
:position="contextMenuPosition"
|
||||||
|
:items="contextMenuItems"
|
||||||
|
@item-click="handleContextMenuItemClick"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user