fix
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .editorconfig +0 -9
- .gitattributes +0 -1
- .gitignore +12 -19
- .prettierrc.json +0 -6
- .vscode/extensions.json +0 -8
- README.md +172 -38
- demo.html +1495 -0
- env.d.ts +0 -1
- eslint.config.ts +0 -22
- index.html +28 -11
- package-lock.json +0 -0
- package.json +13 -28
- public/favicon.ico +0 -0
- public/favicon.svg +5 -0
- public/icons/apple-touch-icon.svg +5 -0
- public/icons/icon-128x128.svg +5 -0
- public/icons/icon-192x192.svg +5 -0
- public/icons/icon-512x512.svg +5 -0
- public/icons/icon-72x72.svg +5 -0
- public/icons/icon-96x96.svg +5 -0
- src/App.vue +371 -55
- src/assets/base.css +0 -86
- src/assets/logo.svg +0 -1
- src/assets/main.css +0 -35
- src/components/HelloWorld.vue +0 -41
- src/components/TheWelcome.vue +0 -94
- src/components/WelcomeItem.vue +0 -87
- src/components/common/BottomSheet.vue +416 -0
- src/components/common/ConfirmDialog.vue +232 -0
- src/components/common/Empty.vue +168 -0
- src/components/common/Icon.vue +171 -0
- src/components/common/Loading.vue +139 -0
- src/components/common/Modal.vue +301 -0
- src/components/common/Toast.vue +271 -0
- src/components/favorites/FavoriteButton.vue +283 -0
- src/components/favorites/FavoriteItem.vue +458 -0
- src/components/favorites/FavoritesList.vue +853 -0
- src/components/icons/IconCommunity.vue +0 -7
- src/components/icons/IconDocumentation.vue +0 -7
- src/components/icons/IconEcosystem.vue +0 -7
- src/components/icons/IconSupport.vue +0 -7
- src/components/icons/IconTooling.vue +0 -19
- src/components/layout/AppTabBar.vue +152 -0
- src/components/layout/MiniPlayer.vue +377 -0
- src/components/layout/SearchHeader.vue +624 -0
- src/components/player/AlbumCover.vue +227 -0
- src/components/player/LyricsView.vue +454 -0
- src/components/player/MoreActionsPanel.vue +365 -0
- src/components/player/PlayControls.vue +314 -0
- src/components/player/PlayModeToggle.vue +198 -0
.editorconfig
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
| 2 |
-
charset = utf-8
|
| 3 |
-
indent_size = 2
|
| 4 |
-
indent_style = space
|
| 5 |
-
insert_final_newline = true
|
| 6 |
-
trim_trailing_whitespace = true
|
| 7 |
-
|
| 8 |
-
end_of_line = lf
|
| 9 |
-
max_line_length = 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitattributes
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
* text=auto eol=lf
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,30 +1,23 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
npm-debug.log*
|
| 5 |
yarn-debug.log*
|
| 6 |
yarn-error.log*
|
| 7 |
-
pnpm-debug.log*
|
| 8 |
-
lerna-debug.log*
|
| 9 |
-
|
| 10 |
-
node_modules
|
| 11 |
-
.DS_Store
|
| 12 |
-
dist
|
| 13 |
-
dist-ssr
|
| 14 |
-
coverage
|
| 15 |
-
*.local
|
| 16 |
-
|
| 17 |
-
/cypress/videos/
|
| 18 |
-
/cypress/screenshots/
|
| 19 |
|
| 20 |
# Editor directories and files
|
| 21 |
-
.vscode/*
|
| 22 |
-
!.vscode/extensions.json
|
| 23 |
.idea
|
|
|
|
|
|
|
| 24 |
*.suo
|
| 25 |
*.ntvs*
|
| 26 |
*.njsproj
|
| 27 |
*.sln
|
| 28 |
*.sw?
|
| 29 |
-
|
| 30 |
-
*.tsbuildinfo
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
node_modules
|
| 3 |
+
#记得解除忽略dist目录
|
| 4 |
+
/dist
|
| 5 |
+
|
| 6 |
+
# local env files
|
| 7 |
+
.env.local
|
| 8 |
+
.env.*.local
|
| 9 |
+
|
| 10 |
+
# Log files
|
| 11 |
npm-debug.log*
|
| 12 |
yarn-debug.log*
|
| 13 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# Editor directories and files
|
|
|
|
|
|
|
| 16 |
.idea
|
| 17 |
+
.fleet
|
| 18 |
+
.vscode
|
| 19 |
*.suo
|
| 20 |
*.ntvs*
|
| 21 |
*.njsproj
|
| 22 |
*.sln
|
| 23 |
*.sw?
|
|
|
|
|
|
.prettierrc.json
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"$schema": "https://json.schemastore.org/prettierrc",
|
| 3 |
-
"semi": false,
|
| 4 |
-
"singleQuote": true,
|
| 5 |
-
"printWidth": 100
|
| 6 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.vscode/extensions.json
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"recommendations": [
|
| 3 |
-
"Vue.volar",
|
| 4 |
-
"dbaeumer.vscode-eslint",
|
| 5 |
-
"EditorConfig.EditorConfig",
|
| 6 |
-
"esbenp.prettier-vscode"
|
| 7 |
-
]
|
| 8 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,50 +1,184 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
npm install
|
| 32 |
```
|
| 33 |
|
| 34 |
-
###
|
| 35 |
-
|
| 36 |
-
```sh
|
| 37 |
npm run dev
|
| 38 |
```
|
| 39 |
|
| 40 |
-
###
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
| 43 |
npm run build
|
| 44 |
```
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
```sh
|
| 49 |
-
npm run lint
|
| 50 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎵 Vue3 PWA 音乐应用
|
| 2 |
+
|
| 3 |
+
一个基于 Vue3 的渐进式网络应用(PWA),模仿网易云音乐界面风格,支持13个音乐源,提供完整的音乐播放体验。
|
| 4 |
+
|
| 5 |
+
## ✨ 功能特性
|
| 6 |
+
|
| 7 |
+
### 🎯 核心功能
|
| 8 |
+
- **多源音乐搜索** - 支持13个主流音乐平台
|
| 9 |
+
- **全屏播放器** - 网易云风格界面,支持歌词显示
|
| 10 |
+
- **收藏管理** - 收藏喜爱的歌曲
|
| 11 |
+
- **播放历史** - 自动记录播放记录
|
| 12 |
+
- **智能设置** - 音质、播放模式、主题等个性化设置
|
| 13 |
+
|
| 14 |
+
### 🌟 技术特性
|
| 15 |
+
- **PWA 支持** - 可安装到桌面,离线缓存
|
| 16 |
+
- **响应式设计** - 适配手机、平板、桌面
|
| 17 |
+
- **MediaSession API** - 支持媒体控制(锁屏控制)
|
| 18 |
+
- **状态持久化** - 刷新不丢失播放状态
|
| 19 |
+
- **网易云主题** - 完美复刻的视觉体验
|
| 20 |
+
|
| 21 |
+
### 📱 支持的音乐源
|
| 22 |
+
1. **网易云音乐** (默认)
|
| 23 |
+
2. **QQ音乐**
|
| 24 |
+
3. **酷狗音乐**
|
| 25 |
+
4. **酷我音乐**
|
| 26 |
+
5. **咪咕音乐**
|
| 27 |
+
6. **Spotify**
|
| 28 |
+
7. **Apple Music**
|
| 29 |
+
8. **YouTube Music**
|
| 30 |
+
9. **JOOX**
|
| 31 |
+
10. **TIDAL**
|
| 32 |
+
11. **Deezer**
|
| 33 |
+
12. **Qobuz**
|
| 34 |
+
13. **喜马拉雅FM**
|
| 35 |
+
|
| 36 |
+
## 🚀 快速开始
|
| 37 |
+
|
| 38 |
+
### 环境要求
|
| 39 |
+
- Node.js 16+
|
| 40 |
+
- npm 或 yarn
|
| 41 |
+
|
| 42 |
+
### 安装依赖
|
| 43 |
+
```bash
|
| 44 |
npm install
|
| 45 |
```
|
| 46 |
|
| 47 |
+
### 启动开发服务器
|
| 48 |
+
```bash
|
|
|
|
| 49 |
npm run dev
|
| 50 |
```
|
| 51 |
|
| 52 |
+
### 或者直接运行启动脚本
|
| 53 |
+
- Windows: 双击 `启动应用.bat`
|
| 54 |
+
- macOS/Linux: 运行 `./启动应用.sh`
|
| 55 |
|
| 56 |
+
### 构建生产版本
|
| 57 |
+
```bash
|
| 58 |
npm run build
|
| 59 |
```
|
| 60 |
|
| 61 |
+
## 📁 项目结构
|
| 62 |
|
|
|
|
|
|
|
| 63 |
```
|
| 64 |
+
vue-music/
|
| 65 |
+
├── public/ # 静态资源
|
| 66 |
+
│ ├── icons/ # PWA 图标
|
| 67 |
+
│ └── manifest.json # PWA 配置
|
| 68 |
+
├── src/
|
| 69 |
+
│ ├── components/ # 组件
|
| 70 |
+
│ │ ├── layout/ # 布局组件
|
| 71 |
+
│ │ ├── search/ # 搜索相关
|
| 72 |
+
│ │ ├── player/ # 播放器相关
|
| 73 |
+
│ │ └── common/ # 通用组件
|
| 74 |
+
│ ├── views/ # 页面组件
|
| 75 |
+
│ ├── stores/ # Pinia 状态管理
|
| 76 |
+
│ ├── services/ # API 服务
|
| 77 |
+
│ ├── router/ # 路由配置
|
| 78 |
+
│ └── styles/ # 全局样式
|
| 79 |
+
├── 需求文档.md # 完整需求说明
|
| 80 |
+
└── 启动应用.bat/sh # 快速启动脚本
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## 🎮 使用说明
|
| 84 |
+
|
| 85 |
+
### 基本操作
|
| 86 |
+
1. **搜索音乐** - 在首页搜索框输入歌曲或歌手名
|
| 87 |
+
2. **切换音源** - 点击搜索框旁的源选择按钮
|
| 88 |
+
3. **播放控制** - 点击歌曲播放,使用底部播放条控制
|
| 89 |
+
4. **全屏播放器** - 点击底部播放条进入全屏播放器
|
| 90 |
+
5. **收藏歌曲** - 点击爱心图标收藏喜欢的歌曲
|
| 91 |
+
|
| 92 |
+
### 高级功能
|
| 93 |
+
- **播放模式** - 支持列表循环、随机播放、单曲循环
|
| 94 |
+
- **歌词显示** - 全屏播放器支持歌词滚动显示
|
| 95 |
+
- **设置调整** - 在设置页面个性化应用体验
|
| 96 |
+
- **PWA 安装** - 在浏览器地址栏点击安装按钮
|
| 97 |
+
|
| 98 |
+
## 🛠️ 技术栈
|
| 99 |
+
|
| 100 |
+
- **框架**: Vue 3.4 + Composition API
|
| 101 |
+
- **状态管理**: Pinia 2.1
|
| 102 |
+
- **路由**: Vue Router 4.3
|
| 103 |
+
- **构建工具**: Vite 5.3
|
| 104 |
+
- **PWA**: Vite PWA Plugin
|
| 105 |
+
- **样式**: CSS3 + CSS Variables
|
| 106 |
+
- **图标**: FontAwesome 6
|
| 107 |
+
- **音乐API**: 自建聚合接口
|
| 108 |
+
|
| 109 |
+
## ⚡ 性能优化
|
| 110 |
+
|
| 111 |
+
- **代码分割** - 路由级别的代码分割
|
| 112 |
+
- **图片懒加载** - 专辑封面按需加载
|
| 113 |
+
- **请求缓存** - API 响应智能缓存
|
| 114 |
+
- **Service Worker** - 静态资源离线缓存
|
| 115 |
+
- **组件复用** - 高效的组件设计模式
|
| 116 |
+
|
| 117 |
+
## 🔧 开发特性
|
| 118 |
+
|
| 119 |
+
### 代码质量
|
| 120 |
+
- **组件化架构** - 高内聚低耦合的组件设计
|
| 121 |
+
- **TypeScript 支持** - 可选的类型检查
|
| 122 |
+
- **ESLint 配置** - 代码风格统一
|
| 123 |
+
- **响应式设计** - 移动优先的设计理念
|
| 124 |
+
|
| 125 |
+
### 开发体验
|
| 126 |
+
- **热重载** - 代码修改实时预览
|
| 127 |
+
- **自动导入** - 组件和工具函数自动导入
|
| 128 |
+
- **开发工具** - Vue DevTools 完美支持
|
| 129 |
+
- **错误处理** - 完善的错误边界处理
|
| 130 |
+
|
| 131 |
+
## 📋 功能清单
|
| 132 |
+
|
| 133 |
+
### ✅ 已完成功能
|
| 134 |
+
- [x] 项目架构搭建
|
| 135 |
+
- [x] PWA 配置
|
| 136 |
+
- [x] 音乐API集成 (13个源)
|
| 137 |
+
- [x] 状态管理 (Pinia)
|
| 138 |
+
- [x] 首页搜索功能
|
| 139 |
+
- [x] 搜索结果展示
|
| 140 |
+
- [x] 全屏播放器
|
| 141 |
+
- [x] 播放控制
|
| 142 |
+
- [x] 进度条
|
| 143 |
+
- [x] 歌词显示
|
| 144 |
+
- [x] 收藏功能
|
| 145 |
+
- [x] 播放历史
|
| 146 |
+
- [x] 设置页面
|
| 147 |
+
- [x] 响应式布局
|
| 148 |
+
- [x] 通用组件库
|
| 149 |
+
|
| 150 |
+
### 🔄 可扩展功能
|
| 151 |
+
- [ ] 用户登录系统
|
| 152 |
+
- [ ] 社交分享功能
|
| 153 |
+
- [ ] 音乐下载功能
|
| 154 |
+
- [ ] 播放列表导入导出
|
| 155 |
+
- [ ] 桌面歌词显示
|
| 156 |
+
- [ ] 均衡器设置
|
| 157 |
+
- [ ] 定时关闭功能
|
| 158 |
+
- [ ] 音乐推荐算法
|
| 159 |
+
|
| 160 |
+
## 🤝 贡献指南
|
| 161 |
+
|
| 162 |
+
欢迎提交 Issue 和 Pull Request!
|
| 163 |
+
|
| 164 |
+
1. Fork 项目
|
| 165 |
+
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
| 166 |
+
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
| 167 |
+
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
| 168 |
+
5. 打开 Pull Request
|
| 169 |
+
|
| 170 |
+
## 📄 许可证
|
| 171 |
+
|
| 172 |
+
本项目基于 MIT 许可证开源 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
| 173 |
+
|
| 174 |
+
## 🙏 致谢
|
| 175 |
+
|
| 176 |
+
- [Vue.js](https://vuejs.org/) - 渐进式 JavaScript 框架
|
| 177 |
+
- [Vite](https://vitejs.dev/) - 下一代前端构建工具
|
| 178 |
+
- [Pinia](https://pinia.vuejs.org/) - Vue 状态管理库
|
| 179 |
+
- [FontAwesome](https://fontawesome.com/) - 图标库
|
| 180 |
+
- [网易云音乐](https://music.163.com/) - UI 设计参考
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
**🎵 享受音乐,享受编程!**
|
demo.html
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<!DOCTYPE html>
|
| 3 |
+
<html lang="zh-CN">
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>云音乐 - 在线音乐播放器</title>
|
| 8 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
* {
|
| 11 |
+
margin: 0;
|
| 12 |
+
padding: 0;
|
| 13 |
+
box-sizing: border-box;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
body {
|
| 17 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
| 18 |
+
background: #0c0c0c;
|
| 19 |
+
color: #fff;
|
| 20 |
+
overflow-x: hidden;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* 背景动画 */
|
| 24 |
+
.bg-animation {
|
| 25 |
+
position: fixed;
|
| 26 |
+
top: 0;
|
| 27 |
+
left: 0;
|
| 28 |
+
width: 100%;
|
| 29 |
+
height: 100%;
|
| 30 |
+
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
| 31 |
+
background-size: 400% 400%;
|
| 32 |
+
animation: gradientBG 15s ease infinite;
|
| 33 |
+
z-index: -2;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.bg-overlay {
|
| 37 |
+
position: fixed;
|
| 38 |
+
top: 0;
|
| 39 |
+
left: 0;
|
| 40 |
+
width: 100%;
|
| 41 |
+
height: 100%;
|
| 42 |
+
background: rgba(0, 0, 0, 0.85);
|
| 43 |
+
backdrop-filter: blur(20px);
|
| 44 |
+
z-index: -1;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@keyframes gradientBG {
|
| 48 |
+
0% { background-position: 0% 50%; }
|
| 49 |
+
50% { background-position: 100% 50%; }
|
| 50 |
+
100% { background-position: 0% 50%; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* 顶部导航 */
|
| 54 |
+
.navbar {
|
| 55 |
+
background: rgba(255, 255, 255, 0.1);
|
| 56 |
+
backdrop-filter: blur(20px);
|
| 57 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 58 |
+
padding: 15px 0;
|
| 59 |
+
position: sticky;
|
| 60 |
+
top: 0;
|
| 61 |
+
z-index: 100;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.nav-container {
|
| 65 |
+
max-width: 1400px;
|
| 66 |
+
margin: 0 auto;
|
| 67 |
+
padding: 0 30px;
|
| 68 |
+
display: flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
justify-content: space-between;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.logo {
|
| 74 |
+
display: flex;
|
| 75 |
+
align-items: center;
|
| 76 |
+
gap: 12px;
|
| 77 |
+
font-size: 24px;
|
| 78 |
+
font-weight: bold;
|
| 79 |
+
color: #fff;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.logo i {
|
| 83 |
+
color: #ff6b6b;
|
| 84 |
+
font-size: 28px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.search-container {
|
| 88 |
+
flex: 1;
|
| 89 |
+
max-width: 600px;
|
| 90 |
+
margin: 0 40px;
|
| 91 |
+
position: relative;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.search-wrapper {
|
| 95 |
+
display: flex;
|
| 96 |
+
background: rgba(255, 255, 255, 0.15);
|
| 97 |
+
border-radius: 25px;
|
| 98 |
+
overflow: hidden;
|
| 99 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 100 |
+
transition: all 0.3s ease;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.search-wrapper:focus-within {
|
| 104 |
+
background: rgba(255, 255, 255, 0.2);
|
| 105 |
+
border-color: #ff6b6b;
|
| 106 |
+
box-shadow: 0 0 20px rgba(255, 107, 107, 0.3);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.search-input {
|
| 110 |
+
flex: 1;
|
| 111 |
+
padding: 12px 20px;
|
| 112 |
+
background: transparent;
|
| 113 |
+
border: none;
|
| 114 |
+
color: #fff;
|
| 115 |
+
font-size: 16px;
|
| 116 |
+
outline: none;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.search-input::placeholder {
|
| 120 |
+
color: rgba(255, 255, 255, 0.6);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.source-select {
|
| 124 |
+
background: rgba(255, 255, 255, 0.1);
|
| 125 |
+
border: none;
|
| 126 |
+
color: #fff;
|
| 127 |
+
padding: 12px 15px;
|
| 128 |
+
outline: none;
|
| 129 |
+
cursor: pointer;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.source-select option {
|
| 133 |
+
background: #2a2a2a;
|
| 134 |
+
color: #fff;
|
| 135 |
+
padding: 8px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.search-btn {
|
| 139 |
+
background: #ff6b6b;
|
| 140 |
+
border: none;
|
| 141 |
+
color: #fff;
|
| 142 |
+
padding: 12px 20px;
|
| 143 |
+
cursor: pointer;
|
| 144 |
+
transition: all 0.3s ease;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.search-btn:hover {
|
| 148 |
+
background: #ff5252;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* 主要内容区域 */
|
| 152 |
+
.main-container {
|
| 153 |
+
max-width: 1600px;
|
| 154 |
+
margin: 0 auto;
|
| 155 |
+
padding: 30px;
|
| 156 |
+
display: grid;
|
| 157 |
+
grid-template-columns: 600px 450px 350px;
|
| 158 |
+
gap: 25px;
|
| 159 |
+
min-height: calc(100vh - 200px);
|
| 160 |
+
align-items: start;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* 搜索结果区域 */
|
| 164 |
+
.content-section {
|
| 165 |
+
background: rgba(255, 255, 255, 0.05);
|
| 166 |
+
border-radius: 20px;
|
| 167 |
+
padding: 25px;
|
| 168 |
+
backdrop-filter: blur(20px);
|
| 169 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 170 |
+
min-width: 0;
|
| 171 |
+
overflow: hidden;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.section-title {
|
| 175 |
+
font-size: 20px;
|
| 176 |
+
font-weight: 600;
|
| 177 |
+
margin-bottom: 20px;
|
| 178 |
+
color: #fff;
|
| 179 |
+
display: flex;
|
| 180 |
+
align-items: center;
|
| 181 |
+
gap: 10px;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.search-results {
|
| 185 |
+
max-height: 600px;
|
| 186 |
+
overflow-y: auto;
|
| 187 |
+
scrollbar-width: thin;
|
| 188 |
+
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.search-results::-webkit-scrollbar {
|
| 192 |
+
width: 6px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.search-results::-webkit-scrollbar-track {
|
| 196 |
+
background: transparent;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.search-results::-webkit-scrollbar-thumb {
|
| 200 |
+
background: rgba(255, 255, 255, 0.3);
|
| 201 |
+
border-radius: 3px;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.song-item {
|
| 205 |
+
display: flex;
|
| 206 |
+
align-items: center;
|
| 207 |
+
padding: 15px 20px;
|
| 208 |
+
border-radius: 12px;
|
| 209 |
+
cursor: pointer;
|
| 210 |
+
transition: all 0.3s ease;
|
| 211 |
+
margin-bottom: 8px;
|
| 212 |
+
position: relative;
|
| 213 |
+
overflow: hidden;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.song-item::before {
|
| 217 |
+
content: '';
|
| 218 |
+
position: absolute;
|
| 219 |
+
top: 0;
|
| 220 |
+
left: -100%;
|
| 221 |
+
width: 100%;
|
| 222 |
+
height: 100%;
|
| 223 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
| 224 |
+
transition: left 0.5s ease;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.song-item:hover {
|
| 228 |
+
background: rgba(255, 255, 255, 0.1);
|
| 229 |
+
transform: translateY(-2px);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.song-item:hover::before {
|
| 233 |
+
left: 100%;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.song-item.active {
|
| 237 |
+
background: linear-gradient(135deg, rgba(255, 107, 107, 0.3), rgba(255, 107, 107, 0.1));
|
| 238 |
+
border: 1px solid rgba(255, 107, 107, 0.5);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.song-index {
|
| 242 |
+
width: 40px;
|
| 243 |
+
height: 40px;
|
| 244 |
+
border-radius: 50%;
|
| 245 |
+
background: rgba(255, 255, 255, 0.1);
|
| 246 |
+
display: flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
justify-content: center;
|
| 249 |
+
margin-right: 15px;
|
| 250 |
+
font-size: 14px;
|
| 251 |
+
font-weight: 600;
|
| 252 |
+
color: rgba(255, 255, 255, 0.7);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.song-item.active .song-index {
|
| 256 |
+
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
| 257 |
+
color: #fff;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.song-info {
|
| 261 |
+
flex: 1;
|
| 262 |
+
min-width: 0;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.song-name {
|
| 266 |
+
font-weight: 600;
|
| 267 |
+
margin-bottom: 5px;
|
| 268 |
+
font-size: 16px;
|
| 269 |
+
white-space: nowrap;
|
| 270 |
+
overflow: hidden;
|
| 271 |
+
text-overflow: ellipsis;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.song-artist {
|
| 275 |
+
color: rgba(255, 255, 255, 0.7);
|
| 276 |
+
font-size: 14px;
|
| 277 |
+
white-space: nowrap;
|
| 278 |
+
overflow: hidden;
|
| 279 |
+
text-overflow: ellipsis;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.song-duration {
|
| 283 |
+
color: rgba(255, 255, 255, 0.5);
|
| 284 |
+
font-size: 14px;
|
| 285 |
+
margin-left: 15px;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/* 播放器区域 */
|
| 289 |
+
.player-section {
|
| 290 |
+
background: rgba(255, 255, 255, 0.05);
|
| 291 |
+
border-radius: 20px;
|
| 292 |
+
padding: 25px;
|
| 293 |
+
backdrop-filter: blur(20px);
|
| 294 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 295 |
+
position: sticky;
|
| 296 |
+
top: 120px;
|
| 297 |
+
height: fit-content;
|
| 298 |
+
min-height: calc(100vh - 240px);
|
| 299 |
+
display: flex;
|
| 300 |
+
flex-direction: column;
|
| 301 |
+
justify-content: space-between;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.current-song {
|
| 305 |
+
text-align: center;
|
| 306 |
+
margin-bottom: 25px;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.current-cover-container {
|
| 310 |
+
position: relative;
|
| 311 |
+
display: inline-block;
|
| 312 |
+
margin-bottom: 20px;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.current-cover {
|
| 316 |
+
width: 200px;
|
| 317 |
+
height: 200px;
|
| 318 |
+
border-radius: 50%;
|
| 319 |
+
object-fit: cover;
|
| 320 |
+
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
| 321 |
+
transition: all 0.3s ease;
|
| 322 |
+
border: 6px solid rgba(255, 255, 255, 0.1);
|
| 323 |
+
position: relative;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.current-cover::before {
|
| 327 |
+
content: '';
|
| 328 |
+
position: absolute;
|
| 329 |
+
top: 50%;
|
| 330 |
+
left: 50%;
|
| 331 |
+
transform: translate(-50%, -50%);
|
| 332 |
+
width: 40px;
|
| 333 |
+
height: 40px;
|
| 334 |
+
background: rgba(0, 0, 0, 0.3);
|
| 335 |
+
border-radius: 50%;
|
| 336 |
+
backdrop-filter: blur(10px);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.current-cover::after {
|
| 340 |
+
content: '';
|
| 341 |
+
position: absolute;
|
| 342 |
+
top: 50%;
|
| 343 |
+
left: 50%;
|
| 344 |
+
transform: translate(-50%, -50%);
|
| 345 |
+
width: 12px;
|
| 346 |
+
height: 12px;
|
| 347 |
+
background: rgba(255, 255, 255, 0.8);
|
| 348 |
+
border-radius: 50%;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.current-cover.playing {
|
| 352 |
+
animation: rotate 20s linear infinite;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
@keyframes rotate {
|
| 356 |
+
from { transform: rotate(0deg); }
|
| 357 |
+
to { transform: rotate(360deg); }
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.current-info h3 {
|
| 361 |
+
font-size: 20px;
|
| 362 |
+
font-weight: 600;
|
| 363 |
+
margin-bottom: 8px;
|
| 364 |
+
color: #fff;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.current-info p {
|
| 368 |
+
color: rgba(255, 255, 255, 0.7);
|
| 369 |
+
font-size: 16px;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/* 播放控制 */
|
| 373 |
+
.player-controls {
|
| 374 |
+
display: flex;
|
| 375 |
+
justify-content: center;
|
| 376 |
+
align-items: center;
|
| 377 |
+
gap: 20px;
|
| 378 |
+
margin-bottom: 25px;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.control-btn {
|
| 382 |
+
background: rgba(255, 255, 255, 0.1);
|
| 383 |
+
border: none;
|
| 384 |
+
border-radius: 50%;
|
| 385 |
+
color: #fff;
|
| 386 |
+
cursor: pointer;
|
| 387 |
+
transition: all 0.3s ease;
|
| 388 |
+
display: flex;
|
| 389 |
+
align-items: center;
|
| 390 |
+
justify-content: center;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.control-btn:hover {
|
| 394 |
+
background: rgba(255, 255, 255, 0.2);
|
| 395 |
+
transform: scale(1.1);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.control-btn.small {
|
| 399 |
+
width: 45px;
|
| 400 |
+
height: 45px;
|
| 401 |
+
font-size: 18px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.play-btn {
|
| 405 |
+
width: 65px;
|
| 406 |
+
height: 65px;
|
| 407 |
+
font-size: 28px;
|
| 408 |
+
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
| 409 |
+
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.play-btn:hover {
|
| 413 |
+
background: linear-gradient(135deg, #ff5252, #ff4444);
|
| 414 |
+
box-shadow: 0 12px 35px rgba(255, 107, 107, 0.6);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* 进度条 */
|
| 418 |
+
.progress-container {
|
| 419 |
+
margin-bottom: 20px;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.progress-bar {
|
| 423 |
+
width: 100%;
|
| 424 |
+
height: 6px;
|
| 425 |
+
background: rgba(255, 255, 255, 0.2);
|
| 426 |
+
border-radius: 3px;
|
| 427 |
+
cursor: pointer;
|
| 428 |
+
margin-bottom: 10px;
|
| 429 |
+
position: relative;
|
| 430 |
+
overflow: hidden;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.progress-fill {
|
| 434 |
+
height: 100%;
|
| 435 |
+
background: linear-gradient(90deg, #ff6b6b, #ff8a80);
|
| 436 |
+
border-radius: 3px;
|
| 437 |
+
width: 0%;
|
| 438 |
+
transition: width 0.1s ease;
|
| 439 |
+
position: relative;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.progress-fill::after {
|
| 443 |
+
content: '';
|
| 444 |
+
position: absolute;
|
| 445 |
+
right: -2px;
|
| 446 |
+
top: 50%;
|
| 447 |
+
transform: translateY(-50%);
|
| 448 |
+
width: 12px;
|
| 449 |
+
height: 12px;
|
| 450 |
+
background: #fff;
|
| 451 |
+
border-radius: 50%;
|
| 452 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.time-info {
|
| 456 |
+
display: flex;
|
| 457 |
+
justify-content: space-between;
|
| 458 |
+
font-size: 13px;
|
| 459 |
+
color: rgba(255, 255, 255, 0.7);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* 音量控制 */
|
| 463 |
+
.volume-container {
|
| 464 |
+
display: flex;
|
| 465 |
+
align-items: center;
|
| 466 |
+
gap: 12px;
|
| 467 |
+
margin-bottom: 25px;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.volume-icon {
|
| 471 |
+
color: rgba(255, 255, 255, 0.7);
|
| 472 |
+
font-size: 18px;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.volume-slider {
|
| 476 |
+
flex: 1;
|
| 477 |
+
height: 4px;
|
| 478 |
+
background: rgba(255, 255, 255, 0.2);
|
| 479 |
+
border-radius: 2px;
|
| 480 |
+
outline: none;
|
| 481 |
+
cursor: pointer;
|
| 482 |
+
-webkit-appearance: none;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.volume-slider::-webkit-slider-thumb {
|
| 486 |
+
-webkit-appearance: none;
|
| 487 |
+
width: 14px;
|
| 488 |
+
height: 14px;
|
| 489 |
+
background: #ff6b6b;
|
| 490 |
+
border-radius: 50%;
|
| 491 |
+
cursor: pointer;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/* 音质选择 */
|
| 495 |
+
.quality-container {
|
| 496 |
+
display: flex;
|
| 497 |
+
align-items: center;
|
| 498 |
+
justify-content: space-between;
|
| 499 |
+
margin-bottom: 20px;
|
| 500 |
+
padding: 12px 15px;
|
| 501 |
+
background: rgba(255, 255, 255, 0.05);
|
| 502 |
+
border-radius: 10px;
|
| 503 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.quality-label {
|
| 507 |
+
display: flex;
|
| 508 |
+
align-items: center;
|
| 509 |
+
gap: 8px;
|
| 510 |
+
color: rgba(255, 255, 255, 0.8);
|
| 511 |
+
font-size: 14px;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.quality-select {
|
| 515 |
+
background: rgba(255, 255, 255, 0.1);
|
| 516 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 517 |
+
border-radius: 8px;
|
| 518 |
+
color: #fff;
|
| 519 |
+
padding: 8px 12px;
|
| 520 |
+
outline: none;
|
| 521 |
+
cursor: pointer;
|
| 522 |
+
font-size: 14px;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.quality-select option {
|
| 526 |
+
background: #2a2a2a;
|
| 527 |
+
color: #fff;
|
| 528 |
+
padding: 8px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/* 下载区域 */
|
| 532 |
+
.download-container {
|
| 533 |
+
display: grid;
|
| 534 |
+
grid-template-columns: 1fr 1fr;
|
| 535 |
+
gap: 10px;
|
| 536 |
+
margin-bottom: 20px;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.download-btn {
|
| 540 |
+
display: flex;
|
| 541 |
+
align-items: center;
|
| 542 |
+
justify-content: center;
|
| 543 |
+
gap: 8px;
|
| 544 |
+
padding: 12px 15px;
|
| 545 |
+
background: rgba(255, 255, 255, 0.1);
|
| 546 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 547 |
+
border-radius: 10px;
|
| 548 |
+
color: #fff;
|
| 549 |
+
cursor: pointer;
|
| 550 |
+
transition: all 0.3s ease;
|
| 551 |
+
font-size: 14px;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.download-btn:hover:not(:disabled) {
|
| 555 |
+
background: rgba(255, 255, 255, 0.2);
|
| 556 |
+
border-color: #ff6b6b;
|
| 557 |
+
color: #ff6b6b;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.download-btn:disabled {
|
| 561 |
+
opacity: 0.5;
|
| 562 |
+
cursor: not-allowed;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
/* 歌曲操作按钮 */
|
| 566 |
+
.song-actions {
|
| 567 |
+
display: flex;
|
| 568 |
+
gap: 8px;
|
| 569 |
+
margin-right: 15px;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.action-btn {
|
| 573 |
+
width: 32px;
|
| 574 |
+
height: 32px;
|
| 575 |
+
border-radius: 50%;
|
| 576 |
+
background: rgba(255, 255, 255, 0.1);
|
| 577 |
+
border: none;
|
| 578 |
+
color: rgba(255, 255, 255, 0.7);
|
| 579 |
+
cursor: pointer;
|
| 580 |
+
transition: all 0.3s ease;
|
| 581 |
+
display: flex;
|
| 582 |
+
align-items: center;
|
| 583 |
+
justify-content: center;
|
| 584 |
+
font-size: 12px;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.action-btn:hover {
|
| 588 |
+
background: rgba(255, 107, 107, 0.3);
|
| 589 |
+
color: #ff6b6b;
|
| 590 |
+
transform: scale(1.1);
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
/* 歌词区域 */
|
| 594 |
+
.lyrics-section {
|
| 595 |
+
background: rgba(255, 255, 255, 0.05);
|
| 596 |
+
border-radius: 20px;
|
| 597 |
+
padding: 25px;
|
| 598 |
+
backdrop-filter: blur(20px);
|
| 599 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 600 |
+
position: sticky;
|
| 601 |
+
top: 120px;
|
| 602 |
+
height: calc(100vh - 240px);
|
| 603 |
+
overflow: hidden;
|
| 604 |
+
display: flex;
|
| 605 |
+
flex-direction: column;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.lyrics-container {
|
| 609 |
+
flex: 1;
|
| 610 |
+
overflow-y: auto;
|
| 611 |
+
scrollbar-width: thin;
|
| 612 |
+
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
| 613 |
+
padding-right: 10px;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
.lyrics-container::-webkit-scrollbar {
|
| 617 |
+
width: 6px;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.lyrics-container::-webkit-scrollbar-track {
|
| 621 |
+
background: transparent;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.lyrics-container::-webkit-scrollbar-thumb {
|
| 625 |
+
background: rgba(255, 255, 255, 0.3);
|
| 626 |
+
border-radius: 3px;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.lyric-line {
|
| 630 |
+
padding: 8px 0;
|
| 631 |
+
transition: all 0.3s ease;
|
| 632 |
+
cursor: pointer;
|
| 633 |
+
border-radius: 6px;
|
| 634 |
+
padding-left: 10px;
|
| 635 |
+
margin-bottom: 4px;
|
| 636 |
+
color: rgba(255, 255, 255, 0.6);
|
| 637 |
+
line-height: 1.6;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.lyric-line:hover {
|
| 641 |
+
background: rgba(255, 255, 255, 0.05);
|
| 642 |
+
color: rgba(255, 255, 255, 0.8);
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.lyric-line.active {
|
| 646 |
+
color: #ff6b6b;
|
| 647 |
+
font-weight: 600;
|
| 648 |
+
background: rgba(255, 107, 107, 0.1);
|
| 649 |
+
transform: scale(1.02);
|
| 650 |
+
border-left: 3px solid #ff6b6b;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
/* 加载和错误状态 */
|
| 654 |
+
.loading, .error, .empty-state {
|
| 655 |
+
text-align: center;
|
| 656 |
+
padding: 40px 20px;
|
| 657 |
+
color: rgba(255, 255, 255, 0.7);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.loading i, .error i, .empty-state i {
|
| 661 |
+
font-size: 48px;
|
| 662 |
+
margin-bottom: 15px;
|
| 663 |
+
display: block;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.loading i {
|
| 667 |
+
animation: spin 1s linear infinite;
|
| 668 |
+
color: #ff6b6b;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
@keyframes spin {
|
| 672 |
+
from { transform: rotate(0deg); }
|
| 673 |
+
to { transform: rotate(360deg); }
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.error i {
|
| 677 |
+
color: #ff5252;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.empty-state i {
|
| 681 |
+
color: rgba(255, 255, 255, 0.4);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
/* 响应式设计 */
|
| 685 |
+
@media (max-width: 1400px) {
|
| 686 |
+
.main-container {
|
| 687 |
+
grid-template-columns: 1fr 400px;
|
| 688 |
+
gap: 20px;
|
| 689 |
+
max-width: 1200px;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.lyrics-section {
|
| 693 |
+
display: none;
|
| 694 |
+
}
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
@media (max-width: 1024px) {
|
| 698 |
+
.main-container {
|
| 699 |
+
grid-template-columns: 1fr;
|
| 700 |
+
gap: 20px;
|
| 701 |
+
padding: 20px;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
.nav-container {
|
| 705 |
+
padding: 0 20px;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.search-container {
|
| 709 |
+
margin: 0 20px;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.lyrics-section {
|
| 713 |
+
display: block;
|
| 714 |
+
position: static;
|
| 715 |
+
max-height: 300px;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.player-section {
|
| 719 |
+
position: static;
|
| 720 |
+
min-height: auto;
|
| 721 |
+
height: auto;
|
| 722 |
+
}
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
@media (max-width: 768px) {
|
| 726 |
+
.nav-container {
|
| 727 |
+
flex-direction: column;
|
| 728 |
+
gap: 15px;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.search-container {
|
| 732 |
+
margin: 0;
|
| 733 |
+
max-width: none;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.current-cover {
|
| 737 |
+
width: 180px;
|
| 738 |
+
height: 180px;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.player-controls {
|
| 742 |
+
gap: 15px;
|
| 743 |
+
}
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
/* 自定义滚动条样式 */
|
| 747 |
+
::-webkit-scrollbar {
|
| 748 |
+
width: 8px;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
::-webkit-scrollbar-track {
|
| 752 |
+
background: rgba(255, 255, 255, 0.1);
|
| 753 |
+
border-radius: 4px;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
::-webkit-scrollbar-thumb {
|
| 757 |
+
background: rgba(255, 255, 255, 0.3);
|
| 758 |
+
border-radius: 4px;
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
::-webkit-scrollbar-thumb:hover {
|
| 762 |
+
background: rgba(255, 255, 255, 0.5);
|
| 763 |
+
}
|
| 764 |
+
</style>
|
| 765 |
+
</head>
|
| 766 |
+
<body>
|
| 767 |
+
<div class="bg-animation"></div>
|
| 768 |
+
<div class="bg-overlay"></div>
|
| 769 |
+
|
| 770 |
+
<!-- 顶部导航 -->
|
| 771 |
+
<nav class="navbar">
|
| 772 |
+
<div class="nav-container">
|
| 773 |
+
<div class="logo">
|
| 774 |
+
<i class="fas fa-music"></i>
|
| 775 |
+
<span>云音乐</span>
|
| 776 |
+
</div>
|
| 777 |
+
|
| 778 |
+
<div class="search-container">
|
| 779 |
+
<div class="search-wrapper">
|
| 780 |
+
<input type="text" class="search-input" placeholder="搜索音乐、歌手、专辑..." id="searchInput">
|
| 781 |
+
<select class="source-select" id="sourceSelect">
|
| 782 |
+
<option value="netease">网易云音乐</option>
|
| 783 |
+
<option value="tencent">QQ音乐</option>
|
| 784 |
+
<option value="kuwo">酷我音乐</option>
|
| 785 |
+
<option value="joox">JOOX</option>
|
| 786 |
+
<option value="kugou">酷狗音乐</option>
|
| 787 |
+
<option value="migu">咪咕音乐</option>
|
| 788 |
+
<option value="deezer">Deezer</option>
|
| 789 |
+
<option value="spotify">Spotify</option>
|
| 790 |
+
<option value="apple">Apple Music</option>
|
| 791 |
+
<option value="ytmusic">YouTube Music</option>
|
| 792 |
+
<option value="tidal">TIDAL</option>
|
| 793 |
+
<option value="qobuz">Qobuz</option>
|
| 794 |
+
<option value="ximalaya">喜马拉雅</option>
|
| 795 |
+
</select>
|
| 796 |
+
<button class="search-btn" onclick="searchMusic()">
|
| 797 |
+
<i class="fas fa-search"></i>
|
| 798 |
+
</button>
|
| 799 |
+
</div>
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
</nav>
|
| 803 |
+
|
| 804 |
+
<!-- 主要内容 -->
|
| 805 |
+
<div class="main-container">
|
| 806 |
+
<!-- 搜索结果区域 -->
|
| 807 |
+
<div class="content-section">
|
| 808 |
+
<h2 class="section-title">
|
| 809 |
+
<i class="fas fa-list-music"></i>
|
| 810 |
+
搜索结果
|
| 811 |
+
</h2>
|
| 812 |
+
<div class="search-results" id="searchResults">
|
| 813 |
+
<div class="empty-state">
|
| 814 |
+
<i class="fas fa-search"></i>
|
| 815 |
+
<div>在上方搜索框输入关键词开始搜索音乐</div>
|
| 816 |
+
</div>
|
| 817 |
+
</div>
|
| 818 |
+
</div>
|
| 819 |
+
|
| 820 |
+
<!-- 播放器区域 -->
|
| 821 |
+
<div class="player-section">
|
| 822 |
+
<div class="current-song">
|
| 823 |
+
<div class="current-cover-container">
|
| 824 |
+
<img class="current-cover" id="currentCover" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIwIiBoZWlnaHQ9IjIyMCIgdmlld0JveD0iMCAwIDIyMCAyMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMjAiIGhlaWdodD0iMjIwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSIyMCIvPgo8cGF0aCBkPSJNMTEwIDcwTDE0MCAx MTBIMTIwVjE1MEg5MFYxMTBINzBMMTEwIDcwWiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=" alt="专辑封面">
|
| 825 |
+
</div>
|
| 826 |
+
<div class="current-info">
|
| 827 |
+
<h3 id="currentTitle">未选择歌曲</h3>
|
| 828 |
+
<p id="currentArtist">请搜索并选择要播放的歌曲</p>
|
| 829 |
+
</div>
|
| 830 |
+
</div>
|
| 831 |
+
|
| 832 |
+
<div class="player-controls">
|
| 833 |
+
<button class="control-btn small" onclick="previousSong()">
|
| 834 |
+
<i class="fas fa-step-backward"></i>
|
| 835 |
+
</button>
|
| 836 |
+
<button class="control-btn play-btn" id="playBtn" onclick="togglePlay()">
|
| 837 |
+
<i class="fas fa-play"></i>
|
| 838 |
+
</button>
|
| 839 |
+
<button class="control-btn small" onclick="nextSong()">
|
| 840 |
+
<i class="fas fa-step-forward"></i>
|
| 841 |
+
</button>
|
| 842 |
+
</div>
|
| 843 |
+
|
| 844 |
+
<div class="progress-container">
|
| 845 |
+
<div class="progress-bar" onclick="seekTo(event)">
|
| 846 |
+
<div class="progress-fill" id="progressFill"></div>
|
| 847 |
+
</div>
|
| 848 |
+
<div class="time-info">
|
| 849 |
+
<span id="currentTime">0:00</span>
|
| 850 |
+
<span id="totalTime">0:00</span>
|
| 851 |
+
</div>
|
| 852 |
+
</div>
|
| 853 |
+
|
| 854 |
+
<!-- 音质选择 -->
|
| 855 |
+
<div class="quality-container">
|
| 856 |
+
<div class="quality-label">
|
| 857 |
+
<i class="fas fa-music"></i>
|
| 858 |
+
<span>音质</span>
|
| 859 |
+
</div>
|
| 860 |
+
<select class="quality-select" id="qualitySelect">
|
| 861 |
+
<option value="128">标准 128K</option>
|
| 862 |
+
<option value="192">较高 192K</option>
|
| 863 |
+
<option value="320" selected>高品质 320K</option>
|
| 864 |
+
<option value="740">无损 FLAC</option>
|
| 865 |
+
<option value="999">Hi-Res</option>
|
| 866 |
+
</select>
|
| 867 |
+
</div>
|
| 868 |
+
|
| 869 |
+
<div class="volume-container">
|
| 870 |
+
<i class="fas fa-volume-up volume-icon"></i>
|
| 871 |
+
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" onchange="setVolume(this.value)">
|
| 872 |
+
</div>
|
| 873 |
+
|
| 874 |
+
<!-- 下载区域 -->
|
| 875 |
+
<div class="download-container">
|
| 876 |
+
<button class="download-btn" onclick="downloadCurrentSong()" id="downloadSongBtn" disabled>
|
| 877 |
+
<i class="fas fa-download"></i>
|
| 878 |
+
<span>下载音乐</span>
|
| 879 |
+
</button>
|
| 880 |
+
<button class="download-btn" onclick="downloadCurrentLyric()" id="downloadLyricBtn" disabled>
|
| 881 |
+
<i class="fas fa-file-text"></i>
|
| 882 |
+
<span>下载歌词</span>
|
| 883 |
+
</button>
|
| 884 |
+
</div>
|
| 885 |
+
|
| 886 |
+
<audio id="audioPlayer" preload="metadata"></audio>
|
| 887 |
+
</div>
|
| 888 |
+
|
| 889 |
+
<!-- 歌词区域 -->
|
| 890 |
+
<div class="lyrics-section">
|
| 891 |
+
<h2 class="section-title">
|
| 892 |
+
<i class="fas fa-align-left"></i>
|
| 893 |
+
歌词
|
| 894 |
+
</h2>
|
| 895 |
+
<div class="lyrics-container" id="lyricsContainer">
|
| 896 |
+
<div class="lyric-line">暂无歌词</div>
|
| 897 |
+
</div>
|
| 898 |
+
</div>
|
| 899 |
+
</div>
|
| 900 |
+
|
| 901 |
+
<script>
|
| 902 |
+
const API_BASE = 'https://music-api.gdstudio.xyz/api.php';
|
| 903 |
+
let currentPlaylist = [];
|
| 904 |
+
let currentIndex = -1;
|
| 905 |
+
let currentLyrics = [];
|
| 906 |
+
let isPlaying = false;
|
| 907 |
+
|
| 908 |
+
const audioPlayer = document.getElementById('audioPlayer');
|
| 909 |
+
const playBtn = document.getElementById('playBtn');
|
| 910 |
+
const progressFill = document.getElementById('progressFill');
|
| 911 |
+
const currentTimeSpan = document.getElementById('currentTime');
|
| 912 |
+
const totalTimeSpan = document.getElementById('totalTime');
|
| 913 |
+
const lyricsContainer = document.getElementById('lyricsContainer');
|
| 914 |
+
const currentCover = document.getElementById('currentCover');
|
| 915 |
+
|
| 916 |
+
// 搜索音乐
|
| 917 |
+
async function searchMusic() {
|
| 918 |
+
const keyword = document.getElementById('searchInput').value.trim();
|
| 919 |
+
const source = document.getElementById('sourceSelect').value;
|
| 920 |
+
|
| 921 |
+
if (!keyword) {
|
| 922 |
+
showNotification('请输入搜索关键词', 'warning');
|
| 923 |
+
return;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
const resultsContainer = document.getElementById('searchResults');
|
| 927 |
+
resultsContainer.innerHTML = `
|
| 928 |
+
<div class="loading">
|
| 929 |
+
<i class="fas fa-spinner"></i>
|
| 930 |
+
<div>正在搜索音乐...</div>
|
| 931 |
+
</div>
|
| 932 |
+
`;
|
| 933 |
+
|
| 934 |
+
try {
|
| 935 |
+
const response = await fetch(`${API_BASE}?types=search&source=${source}&name=${encodeURIComponent(keyword)}&count=30`);
|
| 936 |
+
const data = await response.json();
|
| 937 |
+
|
| 938 |
+
if (data && data.length > 0) {
|
| 939 |
+
currentPlaylist = data;
|
| 940 |
+
displaySearchResults(data);
|
| 941 |
+
} else {
|
| 942 |
+
resultsContainer.innerHTML = `
|
| 943 |
+
<div class="error">
|
| 944 |
+
<i class="fas fa-exclamation-triangle"></i>
|
| 945 |
+
<div>未找到相关歌曲,请尝试其他关键词</div>
|
| 946 |
+
</div>
|
| 947 |
+
`;
|
| 948 |
+
}
|
| 949 |
+
} catch (error) {
|
| 950 |
+
console.error('搜索失败:', error);
|
| 951 |
+
resultsContainer.innerHTML = `
|
| 952 |
+
<div class="error">
|
| 953 |
+
<i class="fas fa-wifi"></i>
|
| 954 |
+
<div>网络连接失败,请检查网络后重试</div>
|
| 955 |
+
</div>
|
| 956 |
+
`;
|
| 957 |
+
}
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
// 获取专辑图片URL
|
| 961 |
+
async function getAlbumCoverUrl(song, size = 300) {
|
| 962 |
+
if (!song.pic_id) {
|
| 963 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjU1IiBoZWlnaHQ9IjU1IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0yNy41IDE4TDM1IDI3LjVIMzBWMzdIMjVWMjcuNUgyMEwyNy41IDE4WiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=';
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
try {
|
| 967 |
+
const response = await fetch(`${API_BASE}?types=pic&source=${song.source}&id=${song.pic_id}&size=${size}`);
|
| 968 |
+
const data = await response.json();
|
| 969 |
+
|
| 970 |
+
if (data && data.url) {
|
| 971 |
+
return data.url;
|
| 972 |
+
}
|
| 973 |
+
} catch (error) {
|
| 974 |
+
console.error('获取专辑图失败:', error);
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjU1IiBoZWlnaHQ9IjU1IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0yNy41IDE4TDM1IDI3LjVIMzBWMzdIMjVWMjcuNUgyMEwyNy41IDE4WiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=';
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
// 显示搜索结果
|
| 981 |
+
async function displaySearchResults(songs) {
|
| 982 |
+
const resultsContainer = document.getElementById('searchResults');
|
| 983 |
+
resultsContainer.innerHTML = '';
|
| 984 |
+
|
| 985 |
+
for (let index = 0; index < songs.length; index++) {
|
| 986 |
+
const song = songs[index];
|
| 987 |
+
const songItem = document.createElement('div');
|
| 988 |
+
songItem.className = 'song-item';
|
| 989 |
+
songItem.onclick = () => playSong(index);
|
| 990 |
+
|
| 991 |
+
songItem.innerHTML = `
|
| 992 |
+
<div class="song-index">${(index + 1).toString().padStart(2, '0')}</div>
|
| 993 |
+
<div class="song-info">
|
| 994 |
+
<div class="song-name">${song.name}</div>
|
| 995 |
+
<div class="song-artist">${Array.isArray(song.artist) ? song.artist.join(' / ') : song.artist} · ${song.album}</div>
|
| 996 |
+
</div>
|
| 997 |
+
<div class="song-actions">
|
| 998 |
+
<button class="action-btn" onclick="downloadSong(${index})" title="下载音乐">
|
| 999 |
+
<i class="fas fa-download"></i>
|
| 1000 |
+
</button>
|
| 1001 |
+
<button class="action-btn" onclick="downloadLyric(${index})" title="下载歌词">
|
| 1002 |
+
<i class="fas fa-file-text"></i>
|
| 1003 |
+
</button>
|
| 1004 |
+
</div>
|
| 1005 |
+
<div class="song-duration">--:--</div>
|
| 1006 |
+
`;
|
| 1007 |
+
|
| 1008 |
+
resultsContainer.appendChild(songItem);
|
| 1009 |
+
}
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
// 播放歌曲
|
| 1013 |
+
async function playSong(index) {
|
| 1014 |
+
if (index < 0 || index >= currentPlaylist.length) return;
|
| 1015 |
+
|
| 1016 |
+
currentIndex = index;
|
| 1017 |
+
const song = currentPlaylist[index];
|
| 1018 |
+
|
| 1019 |
+
// 更新UI
|
| 1020 |
+
await updateCurrentSongInfo(song);
|
| 1021 |
+
updateActiveItem();
|
| 1022 |
+
|
| 1023 |
+
try {
|
| 1024 |
+
showNotification('正在加载音乐...', 'info');
|
| 1025 |
+
|
| 1026 |
+
// 获取当前选择的音质
|
| 1027 |
+
const quality = document.getElementById('qualitySelect').value;
|
| 1028 |
+
|
| 1029 |
+
// 获取音乐URL
|
| 1030 |
+
const urlResponse = await fetch(`${API_BASE}?types=url&source=${song.source}&id=${song.id}&br=${quality}`);
|
| 1031 |
+
const urlData = await urlResponse.json();
|
| 1032 |
+
|
| 1033 |
+
if (urlData && urlData.url) {
|
| 1034 |
+
audioPlayer.src = urlData.url;
|
| 1035 |
+
audioPlayer.load();
|
| 1036 |
+
|
| 1037 |
+
// 获取歌词
|
| 1038 |
+
loadLyrics(song);
|
| 1039 |
+
|
| 1040 |
+
// 启用下载按钮
|
| 1041 |
+
document.getElementById('downloadSongBtn').disabled = false;
|
| 1042 |
+
document.getElementById('downloadLyricBtn').disabled = false;
|
| 1043 |
+
|
| 1044 |
+
// 自动播放
|
| 1045 |
+
const playPromise = audioPlayer.play();
|
| 1046 |
+
if (playPromise !== undefined) {
|
| 1047 |
+
playPromise.then(() => {
|
| 1048 |
+
isPlaying = true;
|
| 1049 |
+
updatePlayButton();
|
| 1050 |
+
currentCover.classList.add('playing');
|
| 1051 |
+
showNotification(`开始播放 (${getQualityText(urlData.br || quality)})`, 'success');
|
| 1052 |
+
}).catch(error => {
|
| 1053 |
+
console.error('播放失败:', error);
|
| 1054 |
+
showNotification('播放失败,请尝试其他歌曲', 'error');
|
| 1055 |
+
});
|
| 1056 |
+
}
|
| 1057 |
+
} else {
|
| 1058 |
+
showNotification('无法获取音乐链接,请尝试其他歌曲或更换音质', 'error');
|
| 1059 |
+
}
|
| 1060 |
+
} catch (error) {
|
| 1061 |
+
console.error('播放失败:', error);
|
| 1062 |
+
showNotification('播放失败,请检查网络连接', 'error');
|
| 1063 |
+
}
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
// 获取音质文本
|
| 1067 |
+
function getQualityText(br) {
|
| 1068 |
+
const qualityMap = {
|
| 1069 |
+
'128': '标准音质',
|
| 1070 |
+
'192': '较高音质',
|
| 1071 |
+
'320': '高品质',
|
| 1072 |
+
'740': '无损音质',
|
| 1073 |
+
'999': 'Hi-Res音质'
|
| 1074 |
+
};
|
| 1075 |
+
return qualityMap[br] || `${br}K`;
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
// 下载当前播放的歌曲
|
| 1079 |
+
async function downloadCurrentSong() {
|
| 1080 |
+
if (currentIndex === -1) {
|
| 1081 |
+
showNotification('请先选择要下载的歌曲', 'warning');
|
| 1082 |
+
return;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
const song = currentPlaylist[currentIndex];
|
| 1086 |
+
await downloadSong(currentIndex);
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
// 下载当前播放的歌词
|
| 1090 |
+
async function downloadCurrentLyric() {
|
| 1091 |
+
if (currentIndex === -1) {
|
| 1092 |
+
showNotification('请先选择要下载歌词的��曲', 'warning');
|
| 1093 |
+
return;
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
await downloadLyric(currentIndex);
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
// 下载歌曲
|
| 1100 |
+
async function downloadSong(index) {
|
| 1101 |
+
const song = currentPlaylist[index];
|
| 1102 |
+
const quality = document.getElementById('qualitySelect').value;
|
| 1103 |
+
|
| 1104 |
+
try {
|
| 1105 |
+
showNotification('正在获取下载链接...', 'info');
|
| 1106 |
+
|
| 1107 |
+
const response = await fetch(`${API_BASE}?types=url&source=${song.source}&id=${song.id}&br=${quality}`);
|
| 1108 |
+
const data = await response.json();
|
| 1109 |
+
|
| 1110 |
+
if (data && data.url) {
|
| 1111 |
+
// 创建下载链接
|
| 1112 |
+
const link = document.createElement('a');
|
| 1113 |
+
link.href = data.url;
|
| 1114 |
+
link.download = `${song.name} - ${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}.mp3`;
|
| 1115 |
+
link.target = '_blank';
|
| 1116 |
+
|
| 1117 |
+
// 触发下载
|
| 1118 |
+
document.body.appendChild(link);
|
| 1119 |
+
link.click();
|
| 1120 |
+
document.body.removeChild(link);
|
| 1121 |
+
|
| 1122 |
+
showNotification('开始下载音乐文件', 'success');
|
| 1123 |
+
} else {
|
| 1124 |
+
showNotification('无法获取下载链接', 'error');
|
| 1125 |
+
}
|
| 1126 |
+
} catch (error) {
|
| 1127 |
+
console.error('下载失败:', error);
|
| 1128 |
+
showNotification('下载失败,请稍后重试', 'error');
|
| 1129 |
+
}
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
// 下载歌词
|
| 1133 |
+
async function downloadLyric(index) {
|
| 1134 |
+
const song = currentPlaylist[index];
|
| 1135 |
+
|
| 1136 |
+
try {
|
| 1137 |
+
showNotification('正在获取歌词...', 'info');
|
| 1138 |
+
|
| 1139 |
+
const response = await fetch(`${API_BASE}?types=lyric&source=${song.source}&id=${song.lyric_id || song.id}`);
|
| 1140 |
+
const data = await response.json();
|
| 1141 |
+
|
| 1142 |
+
if (data && data.lyric) {
|
| 1143 |
+
// 创建歌词文件内容
|
| 1144 |
+
let lyricContent = `歌曲:${song.name}
|
| 1145 |
+
`;
|
| 1146 |
+
lyricContent += `歌手:${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}
|
| 1147 |
+
`;
|
| 1148 |
+
lyricContent += `专辑:${song.album}
|
| 1149 |
+
`;
|
| 1150 |
+
lyricContent += `来源:${song.source}
|
| 1151 |
+
|
| 1152 |
+
`;
|
| 1153 |
+
lyricContent += data.lyric;
|
| 1154 |
+
|
| 1155 |
+
if (data.tlyric) {
|
| 1156 |
+
lyricContent += '=== 翻译歌词 ===';
|
| 1157 |
+
lyricContent += data.tlyric;
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
// 创建Blob并下载
|
| 1161 |
+
const blob = new Blob([lyricContent], { type: 'text/plain;charset=utf-8' });
|
| 1162 |
+
const url = URL.createObjectURL(blob);
|
| 1163 |
+
|
| 1164 |
+
const link = document.createElement('a');
|
| 1165 |
+
link.href = url;
|
| 1166 |
+
link.download = `${song.name} - ${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}.lrc`;
|
| 1167 |
+
|
| 1168 |
+
document.body.appendChild(link);
|
| 1169 |
+
link.click();
|
| 1170 |
+
document.body.removeChild(link);
|
| 1171 |
+
|
| 1172 |
+
URL.revokeObjectURL(url);
|
| 1173 |
+
showNotification('歌词下载完成', 'success');
|
| 1174 |
+
} else {
|
| 1175 |
+
showNotification('该歌曲暂无歌词', 'warning');
|
| 1176 |
+
}
|
| 1177 |
+
} catch (error) {
|
| 1178 |
+
console.error('下载歌词失败:', error);
|
| 1179 |
+
showNotification('下载歌词失败,请稍后重试', 'error');
|
| 1180 |
+
}
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
// 音质改变时重新加载当前歌曲
|
| 1184 |
+
document.getElementById('qualitySelect').addEventListener('change', () => {
|
| 1185 |
+
if (currentIndex !== -1 && audioPlayer.src) {
|
| 1186 |
+
const currentTime = audioPlayer.currentTime;
|
| 1187 |
+
const wasPlaying = isPlaying;
|
| 1188 |
+
|
| 1189 |
+
playSong(currentIndex).then(() => {
|
| 1190 |
+
// 恢复播放位置
|
| 1191 |
+
audioPlayer.currentTime = currentTime;
|
| 1192 |
+
if (!wasPlaying) {
|
| 1193 |
+
audioPlayer.pause();
|
| 1194 |
+
}
|
| 1195 |
+
});
|
| 1196 |
+
}
|
| 1197 |
+
});
|
| 1198 |
+
|
| 1199 |
+
// 更新当前歌曲信息
|
| 1200 |
+
async function updateCurrentSongInfo(song) {
|
| 1201 |
+
document.getElementById('currentTitle').textContent = song.name;
|
| 1202 |
+
document.getElementById('currentArtist').textContent =
|
| 1203 |
+
`${Array.isArray(song.artist) ? song.artist.join(' / ') : song.artist} · ${song.album}`;
|
| 1204 |
+
|
| 1205 |
+
// 获取专辑图片URL
|
| 1206 |
+
const coverUrl = await getAlbumCoverUrl(song, 500);
|
| 1207 |
+
currentCover.src = coverUrl;
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
// 更新活跃项目
|
| 1211 |
+
function updateActiveItem() {
|
| 1212 |
+
document.querySelectorAll('.song-item').forEach((item, index) => {
|
| 1213 |
+
item.classList.toggle('active', index === currentIndex);
|
| 1214 |
+
});
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
// 更新播放按钮
|
| 1218 |
+
function updatePlayButton() {
|
| 1219 |
+
const icon = playBtn.querySelector('i');
|
| 1220 |
+
if (isPlaying) {
|
| 1221 |
+
icon.className = 'fas fa-pause';
|
| 1222 |
+
} else {
|
| 1223 |
+
icon.className = 'fas fa-play';
|
| 1224 |
+
}
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
// 加载歌词
|
| 1228 |
+
async function loadLyrics(song) {
|
| 1229 |
+
try {
|
| 1230 |
+
const response = await fetch(`${API_BASE}?types=lyric&source=${song.source}&id=${song.lyric_id || song.id}`);
|
| 1231 |
+
const data = await response.json();
|
| 1232 |
+
|
| 1233 |
+
if (data && data.lyric) {
|
| 1234 |
+
parseLyrics(data.lyric);
|
| 1235 |
+
} else {
|
| 1236 |
+
lyricsContainer.innerHTML = '<div class="lyric-line">暂无歌词</div>';
|
| 1237 |
+
currentLyrics = [];
|
| 1238 |
+
}
|
| 1239 |
+
} catch (error) {
|
| 1240 |
+
console.error('获取歌词失败:', error);
|
| 1241 |
+
lyricsContainer.innerHTML = '<div class="lyric-line">歌词加载失败</div>';
|
| 1242 |
+
currentLyrics = [];
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
// 解析LRC歌词
|
| 1247 |
+
function parseLyrics(lrcText) {
|
| 1248 |
+
const lines = lrcText.split('\n');
|
| 1249 |
+
currentLyrics = [];
|
| 1250 |
+
|
| 1251 |
+
lines.forEach(line => {
|
| 1252 |
+
const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/);
|
| 1253 |
+
if (match) {
|
| 1254 |
+
const minutes = parseInt(match[1]);
|
| 1255 |
+
const seconds = parseInt(match[2]);
|
| 1256 |
+
const milliseconds = parseInt(match[3].padEnd(3, '0'));
|
| 1257 |
+
const text = match[4].trim();
|
| 1258 |
+
|
| 1259 |
+
if (text) {
|
| 1260 |
+
const time = minutes * 60 + seconds + milliseconds / 1000;
|
| 1261 |
+
currentLyrics.push({ time, text });
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
});
|
| 1265 |
+
|
| 1266 |
+
currentLyrics.sort((a, b) => a.time - b.time);
|
| 1267 |
+
displayLyrics();
|
| 1268 |
+
}
|
| 1269 |
+
|
| 1270 |
+
// 显示歌词
|
| 1271 |
+
function displayLyrics() {
|
| 1272 |
+
lyricsContainer.innerHTML = '';
|
| 1273 |
+
if (currentLyrics.length === 0) {
|
| 1274 |
+
lyricsContainer.innerHTML = '<div class="lyric-line">暂无歌词</div>';
|
| 1275 |
+
return;
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
currentLyrics.forEach((lyric, index) => {
|
| 1279 |
+
const lyricLine = document.createElement('div');
|
| 1280 |
+
lyricLine.className = 'lyric-line';
|
| 1281 |
+
lyricLine.textContent = lyric.text;
|
| 1282 |
+
lyricLine.onclick = () => {
|
| 1283 |
+
audioPlayer.currentTime = lyric.time;
|
| 1284 |
+
};
|
| 1285 |
+
lyricsContainer.appendChild(lyricLine);
|
| 1286 |
+
});
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
// 更新歌词高亮
|
| 1290 |
+
function updateLyricHighlight() {
|
| 1291 |
+
const currentTime = audioPlayer.currentTime;
|
| 1292 |
+
let activeIndex = -1;
|
| 1293 |
+
|
| 1294 |
+
for (let i = 0; i < currentLyrics.length; i++) {
|
| 1295 |
+
if (currentLyrics[i].time <= currentTime) {
|
| 1296 |
+
activeIndex = i;
|
| 1297 |
+
} else {
|
| 1298 |
+
break;
|
| 1299 |
+
}
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
const lyricLines = document.querySelectorAll('.lyric-line');
|
| 1303 |
+
lyricLines.forEach((line, index) => {
|
| 1304 |
+
line.classList.toggle('active', index === activeIndex);
|
| 1305 |
+
});
|
| 1306 |
+
|
| 1307 |
+
// 改进的自动滚动逻辑
|
| 1308 |
+
if (activeIndex >= 0 && activeIndex < lyricLines.length) {
|
| 1309 |
+
const activeLine = lyricLines[activeIndex];
|
| 1310 |
+
const container = document.getElementById('lyricsContainer');
|
| 1311 |
+
|
| 1312 |
+
if (activeLine && container) {
|
| 1313 |
+
const containerHeight = container.clientHeight;
|
| 1314 |
+
const lineHeight = activeLine.offsetHeight;
|
| 1315 |
+
const lineOffsetTop = activeLine.offsetTop;
|
| 1316 |
+
const currentScrollTop = container.scrollTop;
|
| 1317 |
+
|
| 1318 |
+
// 计算理想的滚动位置(将当前歌词放在容器中间)
|
| 1319 |
+
const idealScrollTop = lineOffsetTop - (containerHeight / 2) + (lineHeight / 2);
|
| 1320 |
+
|
| 1321 |
+
// 只有当需要滚动超过一定距离时才滚动
|
| 1322 |
+
const scrollThreshold = containerHeight * 0.3;
|
| 1323 |
+
if (Math.abs(idealScrollTop - currentScrollTop) > scrollThreshold) {
|
| 1324 |
+
container.scrollTo({
|
| 1325 |
+
top: Math.max(0, idealScrollTop),
|
| 1326 |
+
behavior: 'smooth'
|
| 1327 |
+
});
|
| 1328 |
+
}
|
| 1329 |
+
}
|
| 1330 |
+
}
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
// 播放控制
|
| 1334 |
+
function togglePlay() {
|
| 1335 |
+
if (audioPlayer.src) {
|
| 1336 |
+
if (isPlaying) {
|
| 1337 |
+
audioPlayer.pause();
|
| 1338 |
+
} else {
|
| 1339 |
+
audioPlayer.play();
|
| 1340 |
+
}
|
| 1341 |
+
} else {
|
| 1342 |
+
showNotification('请先选择要播放的歌曲', 'warning');
|
| 1343 |
+
}
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
function previousSong() {
|
| 1347 |
+
if (currentIndex > 0) {
|
| 1348 |
+
playSong(currentIndex - 1);
|
| 1349 |
+
} else {
|
| 1350 |
+
showNotification('已经是第一首歌曲', 'info');
|
| 1351 |
+
}
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
function nextSong() {
|
| 1355 |
+
if (currentIndex < currentPlaylist.length - 1) {
|
| 1356 |
+
playSong(currentIndex + 1);
|
| 1357 |
+
} else {
|
| 1358 |
+
showNotification('已经是最后一首歌曲', 'info');
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
// 进度控制
|
| 1363 |
+
function seekTo(event) {
|
| 1364 |
+
if (audioPlayer.duration) {
|
| 1365 |
+
const rect = event.target.getBoundingClientRect();
|
| 1366 |
+
const percent = (event.clientX - rect.left) / rect.width;
|
| 1367 |
+
audioPlayer.currentTime = percent * audioPlayer.duration;
|
| 1368 |
+
}
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
function setVolume(value) {
|
| 1372 |
+
audioPlayer.volume = value / 100;
|
| 1373 |
+
|
| 1374 |
+
// 更新音量图标
|
| 1375 |
+
const volumeIcon = document.querySelector('.volume-icon');
|
| 1376 |
+
if (value == 0) {
|
| 1377 |
+
volumeIcon.className = 'fas fa-volume-mute volume-icon';
|
| 1378 |
+
} else if (value < 50) {
|
| 1379 |
+
volumeIcon.className = 'fas fa-volume-down volume-icon';
|
| 1380 |
+
} else {
|
| 1381 |
+
volumeIcon.className = 'fas fa-volume-up volume-icon';
|
| 1382 |
+
}
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
// 格式化时间
|
| 1386 |
+
function formatTime(seconds) {
|
| 1387 |
+
const mins = Math.floor(seconds / 60);
|
| 1388 |
+
const secs = Math.floor(seconds % 60);
|
| 1389 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
// 通知系统
|
| 1393 |
+
function showNotification(message, type = 'info') {
|
| 1394 |
+
// 创建通知元素
|
| 1395 |
+
const notification = document.createElement('div');
|
| 1396 |
+
notification.style.cssText = `
|
| 1397 |
+
position: fixed;
|
| 1398 |
+
top: 100px;
|
| 1399 |
+
right: 30px;
|
| 1400 |
+
background: ${type === 'success' ? 'rgba(76, 175, 80, 0.9)' :
|
| 1401 |
+
type === 'error' ? 'rgba(244, 67, 54, 0.9)' :
|
| 1402 |
+
type === 'warning' ? 'rgba(255, 152, 0, 0.9)' :
|
| 1403 |
+
'rgba(33, 150, 243, 0.9)'};
|
| 1404 |
+
color: white;
|
| 1405 |
+
padding: 15px 20px;
|
| 1406 |
+
border-radius: 10px;
|
| 1407 |
+
backdrop-filter: blur(10px);
|
| 1408 |
+
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
| 1409 |
+
z-index: 1000;
|
| 1410 |
+
transform: translateX(400px);
|
| 1411 |
+
transition: transform 0.3s ease;
|
| 1412 |
+
max-width: 300px;
|
| 1413 |
+
font-size: 14px;
|
| 1414 |
+
`;
|
| 1415 |
+
notification.textContent = message;
|
| 1416 |
+
|
| 1417 |
+
document.body.appendChild(notification);
|
| 1418 |
+
|
| 1419 |
+
// 显示动画
|
| 1420 |
+
setTimeout(() => {
|
| 1421 |
+
notification.style.transform = 'translateX(0)';
|
| 1422 |
+
}, 100);
|
| 1423 |
+
|
| 1424 |
+
// 自动隐藏
|
| 1425 |
+
setTimeout(() => {
|
| 1426 |
+
notification.style.transform = 'translateX(400px)';
|
| 1427 |
+
setTimeout(() => {
|
| 1428 |
+
document.body.removeChild(notification);
|
| 1429 |
+
}, 300);
|
| 1430 |
+
}, 3000);
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
// 音频事件监听
|
| 1434 |
+
audioPlayer.addEventListener('timeupdate', () => {
|
| 1435 |
+
if (audioPlayer.duration) {
|
| 1436 |
+
const percent = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
| 1437 |
+
progressFill.style.width = percent + '%';
|
| 1438 |
+
currentTimeSpan.textContent = formatTime(audioPlayer.currentTime);
|
| 1439 |
+
updateLyricHighlight();
|
| 1440 |
+
}
|
| 1441 |
+
});
|
| 1442 |
+
|
| 1443 |
+
audioPlayer.addEventListener('loadedmetadata', () => {
|
| 1444 |
+
totalTimeSpan.textContent = formatTime(audioPlayer.duration);
|
| 1445 |
+
});
|
| 1446 |
+
|
| 1447 |
+
audioPlayer.addEventListener('ended', () => {
|
| 1448 |
+
nextSong();
|
| 1449 |
+
});
|
| 1450 |
+
|
| 1451 |
+
audioPlayer.addEventListener('play', () => {
|
| 1452 |
+
isPlaying = true;
|
| 1453 |
+
updatePlayButton();
|
| 1454 |
+
currentCover.classList.add('playing');
|
| 1455 |
+
});
|
| 1456 |
+
|
| 1457 |
+
audioPlayer.addEventListener('pause', () => {
|
| 1458 |
+
isPlaying = false;
|
| 1459 |
+
updatePlayButton();
|
| 1460 |
+
currentCover.classList.remove('playing');
|
| 1461 |
+
});
|
| 1462 |
+
|
| 1463 |
+
// 键盘快捷键
|
| 1464 |
+
document.addEventListener('keydown', (e) => {
|
| 1465 |
+
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
|
| 1466 |
+
e.preventDefault();
|
| 1467 |
+
togglePlay();
|
| 1468 |
+
} else if (e.code === 'ArrowLeft' && e.target.tagName !== 'INPUT') {
|
| 1469 |
+
e.preventDefault();
|
| 1470 |
+
previousSong();
|
| 1471 |
+
} else if (e.code === 'ArrowRight' && e.target.tagName !== 'INPUT') {
|
| 1472 |
+
e.preventDefault();
|
| 1473 |
+
nextSong();
|
| 1474 |
+
}
|
| 1475 |
+
});
|
| 1476 |
+
|
| 1477 |
+
// 搜索框回车事件
|
| 1478 |
+
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
| 1479 |
+
if (e.key === 'Enter') {
|
| 1480 |
+
searchMusic();
|
| 1481 |
+
}
|
| 1482 |
+
});
|
| 1483 |
+
|
| 1484 |
+
// 初始化
|
| 1485 |
+
setVolume(80);
|
| 1486 |
+
|
| 1487 |
+
// 页面加载完成后的欢迎信息
|
| 1488 |
+
window.addEventListener('load', () => {
|
| 1489 |
+
setTimeout(() => {
|
| 1490 |
+
showNotification('欢迎使用云音乐播放器!', 'success');
|
| 1491 |
+
}, 1000);
|
| 1492 |
+
});
|
| 1493 |
+
</script>
|
| 1494 |
+
</body>
|
| 1495 |
+
</html>
|
env.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
/// <reference types="vite/client" />
|
|
|
|
|
|
eslint.config.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
import { globalIgnores } from 'eslint/config'
|
| 2 |
-
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
| 3 |
-
import pluginVue from 'eslint-plugin-vue'
|
| 4 |
-
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
| 5 |
-
|
| 6 |
-
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
| 7 |
-
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
| 8 |
-
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
| 9 |
-
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
| 10 |
-
|
| 11 |
-
export default defineConfigWithVueTs(
|
| 12 |
-
{
|
| 13 |
-
name: 'app/files-to-lint',
|
| 14 |
-
files: ['**/*.{ts,mts,tsx,vue}'],
|
| 15 |
-
},
|
| 16 |
-
|
| 17 |
-
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
| 18 |
-
|
| 19 |
-
pluginVue.configs['flat/essential'],
|
| 20 |
-
vueTsConfigs.recommended,
|
| 21 |
-
skipFormatting,
|
| 22 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index.html
CHANGED
|
@@ -1,13 +1,30 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="">
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
<
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=auto">
|
| 6 |
+
<title>云音乐PWA</title>
|
| 7 |
+
|
| 8 |
+
<!-- PWA相关 -->
|
| 9 |
+
<link rel="icon" href="/favicon.svg">
|
| 10 |
+
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg">
|
| 11 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 12 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 13 |
+
<meta name="apple-mobile-web-app-title" content="云音乐">
|
| 14 |
+
|
| 15 |
+
<!-- iOS状态栏和安全区域适配 -->
|
| 16 |
+
<meta name="format-detection" content="telephone=no">
|
| 17 |
+
<meta name="apple-touch-fullscreen" content="yes">
|
| 18 |
+
|
| 19 |
+
<!-- 主题色 -->
|
| 20 |
+
<meta name="theme-color" content="#ff6b6b">
|
| 21 |
+
<meta name="msapplication-TileColor" content="#ff6b6b">
|
| 22 |
+
|
| 23 |
+
<!-- Font Awesome图标库 -->
|
| 24 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 25 |
+
</head>
|
| 26 |
+
<body>
|
| 27 |
+
<div id="app"></div>
|
| 28 |
+
<script type="module" src="/src/main.js"></script>
|
| 29 |
+
</body>
|
| 30 |
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,37 +1,22 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "vue",
|
| 3 |
-
"version": "
|
| 4 |
-
"private": true,
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
-
"build": "
|
| 9 |
-
"preview": "vite preview"
|
| 10 |
-
"build-only": "vite build",
|
| 11 |
-
"type-check": "vue-tsc --build",
|
| 12 |
-
"lint": "eslint . --fix",
|
| 13 |
-
"format": "prettier --write src/"
|
| 14 |
},
|
| 15 |
"dependencies": {
|
| 16 |
-
"
|
| 17 |
-
"vue": "^
|
| 18 |
-
"
|
|
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
-
"@
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"@vue/eslint-config-typescript": "^14.5.0",
|
| 26 |
-
"@vue/tsconfig": "^0.7.0",
|
| 27 |
-
"eslint": "^9.22.0",
|
| 28 |
-
"eslint-plugin-vue": "~10.0.0",
|
| 29 |
-
"jiti": "^2.4.2",
|
| 30 |
-
"npm-run-all2": "^7.0.2",
|
| 31 |
-
"prettier": "3.5.3",
|
| 32 |
-
"typescript": "~5.8.0",
|
| 33 |
-
"vite": "^6.2.4",
|
| 34 |
-
"vite-plugin-vue-devtools": "^7.7.2",
|
| 35 |
-
"vue-tsc": "^2.2.8"
|
| 36 |
}
|
| 37 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "vue-music-pwa",
|
| 3 |
+
"version": "1.0.0",
|
|
|
|
| 4 |
"type": "module",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "vite",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
},
|
| 10 |
"dependencies": {
|
| 11 |
+
"vue": "^3.4.0",
|
| 12 |
+
"vue-router": "^4.2.0",
|
| 13 |
+
"pinia": "^2.1.0",
|
| 14 |
+
"@vueuse/core": "^10.5.0"
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
+
"@vitejs/plugin-vue": "^4.5.0",
|
| 18 |
+
"vite": "^5.0.0",
|
| 19 |
+
"vite-plugin-pwa": "^0.17.0",
|
| 20 |
+
"workbox-window": "^7.0.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
+
}
|
public/favicon.ico
DELETED
|
Binary file (4.29 kB)
|
|
|
public/favicon.svg
ADDED
|
|
public/icons/apple-touch-icon.svg
ADDED
|
|
public/icons/icon-128x128.svg
ADDED
|
|
public/icons/icon-192x192.svg
ADDED
|
|
public/icons/icon-512x512.svg
ADDED
|
|
public/icons/icon-72x72.svg
ADDED
|
|
public/icons/icon-96x96.svg
ADDED
|
|
src/App.vue
CHANGED
|
@@ -1,85 +1,401 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
import { RouterLink, RouterView } from 'vue-router'
|
| 3 |
-
import HelloWorld from './components/HelloWorld.vue'
|
| 4 |
-
</script>
|
| 5 |
-
|
| 6 |
<template>
|
| 7 |
-
<
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
</template>
|
| 22 |
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
margin-top: 2rem;
|
| 39 |
}
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
}
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
}
|
| 85 |
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<div id="app">
|
| 3 |
+
<!-- 主内容区域 -->
|
| 4 |
+
<main class="main-content">
|
| 5 |
+
<router-view v-slot="{ Component, route }">
|
| 6 |
+
<keep-alive>
|
| 7 |
+
<component
|
| 8 |
+
:is="Component"
|
| 9 |
+
v-if="route.meta?.keepAlive"
|
| 10 |
+
:key="route.name"
|
| 11 |
+
/>
|
| 12 |
+
</keep-alive>
|
| 13 |
+
<component
|
| 14 |
+
:is="Component"
|
| 15 |
+
v-if="!route.meta?.keepAlive"
|
| 16 |
+
:key="route.name"
|
| 17 |
+
/>
|
| 18 |
+
</router-view>
|
| 19 |
+
</main>
|
| 20 |
+
|
| 21 |
+
<!-- 迷你播放器 -->
|
| 22 |
+
<MiniPlayer
|
| 23 |
+
v-show="!shouldHideMiniPlayer"
|
| 24 |
+
@openFullPlayer="showFullPlayer = true"
|
| 25 |
+
@togglePlay="togglePlay"
|
| 26 |
+
@playNext="playNext"
|
| 27 |
+
@playPrevious="playPrevious"
|
| 28 |
+
/>
|
| 29 |
+
|
| 30 |
+
<!-- 底部导航栏 -->
|
| 31 |
+
<AppTabBar />
|
| 32 |
|
| 33 |
+
<!-- 全屏播放器 -->
|
| 34 |
+
<FullPlayerPage
|
| 35 |
+
v-if="showFullPlayer"
|
| 36 |
+
@close="showFullPlayer = false"
|
| 37 |
+
@seek="handleSeek"
|
| 38 |
+
@togglePlay="togglePlay"
|
| 39 |
+
/>
|
| 40 |
|
| 41 |
+
<!-- 音频元素 -->
|
| 42 |
+
<audio
|
| 43 |
+
ref="audioRef"
|
| 44 |
+
preload="metadata"
|
| 45 |
+
@loadedmetadata="handleLoadedMetadata"
|
| 46 |
+
@timeupdate="handleTimeUpdate"
|
| 47 |
+
@ended="handleEnded"
|
| 48 |
+
@play="handlePlay"
|
| 49 |
+
@pause="handlePause"
|
| 50 |
+
@error="handleError"
|
| 51 |
+
/>
|
| 52 |
|
| 53 |
+
<!-- 通知组件 -->
|
| 54 |
+
<Toast />
|
| 55 |
+
</div>
|
| 56 |
</template>
|
| 57 |
|
| 58 |
+
<script setup>
|
| 59 |
+
import { ref, computed, onMounted, watch } from 'vue'
|
| 60 |
+
import { useRoute } from 'vue-router'
|
| 61 |
+
import { usePlayerStore } from '@/stores/player'
|
| 62 |
+
import { useSearchStore } from '@/stores/search'
|
| 63 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 64 |
+
import { useSettingsStore } from '@/stores/settings'
|
| 65 |
+
import { musicApi, utils } from '@/services/musicApi'
|
| 66 |
+
import AppTabBar from '@/components/layout/AppTabBar.vue'
|
| 67 |
+
import MiniPlayer from '@/components/layout/MiniPlayer.vue'
|
| 68 |
+
import FullPlayerPage from '@/views/FullPlayerPage.vue'
|
| 69 |
+
import Toast from '@/components/common/Toast.vue'
|
| 70 |
+
|
| 71 |
+
// Router
|
| 72 |
+
const route = useRoute()
|
| 73 |
+
|
| 74 |
+
// Store
|
| 75 |
+
const playerStore = usePlayerStore()
|
| 76 |
+
const searchStore = useSearchStore()
|
| 77 |
+
const favoritesStore = useFavoritesStore()
|
| 78 |
+
const settingsStore = useSettingsStore()
|
| 79 |
+
|
| 80 |
+
// 响应式数据
|
| 81 |
+
const audioRef = ref(null)
|
| 82 |
+
const showFullPlayer = ref(false)
|
| 83 |
+
const isLoading = ref(false)
|
| 84 |
+
|
| 85 |
+
// 计算属性
|
| 86 |
+
const currentSong = computed(() => playerStore.currentSong)
|
| 87 |
+
const isPlaying = computed(() => playerStore.isPlaying)
|
| 88 |
+
|
| 89 |
+
// 判断是否应该隐藏迷你播放器(在全屏播放页面时隐藏)
|
| 90 |
+
const shouldHideMiniPlayer = computed(() => {
|
| 91 |
+
return route.meta?.fullScreen || false
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
// 播放控制
|
| 95 |
+
const togglePlay = async () => {
|
| 96 |
+
if (!currentSong.value) {
|
| 97 |
+
console.log('没有歌曲信息')
|
| 98 |
+
return
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (isPlaying.value) {
|
| 102 |
+
if (audioRef.value) {
|
| 103 |
+
audioRef.value.pause()
|
| 104 |
+
}
|
| 105 |
+
playerStore.setPlayingState(false)
|
| 106 |
+
} else {
|
| 107 |
+
// 如果没有音频源,需要先加载歌曲
|
| 108 |
+
if (!playerStore.audioSrc || !audioRef.value.src || audioRef.value.src === location.href) {
|
| 109 |
+
console.log('没有音频源,开始加载歌曲')
|
| 110 |
+
await loadAndPlaySong(currentSong.value)
|
| 111 |
+
return
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 有音频源,直接播放
|
| 115 |
+
try {
|
| 116 |
+
if (!audioRef.value) {
|
| 117 |
+
console.log('音频元素不可用')
|
| 118 |
+
return
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// 确保音频元素有正确的src
|
| 122 |
+
if (audioRef.value.src !== playerStore.audioSrc) {
|
| 123 |
+
audioRef.value.src = playerStore.audioSrc
|
| 124 |
+
audioRef.value.load()
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
await audioRef.value.play()
|
| 128 |
+
playerStore.setPlayingState(true)
|
| 129 |
+
console.log('开始播放:', currentSong.value.name)
|
| 130 |
+
} catch (error) {
|
| 131 |
+
console.error('播放失败:', error)
|
| 132 |
+
showNotification('播放失败,请重试', 'error')
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const playNext = () => {
|
| 138 |
+
const nextSong = playerStore.playNext()
|
| 139 |
+
if (nextSong) {
|
| 140 |
+
loadAndPlaySong(nextSong)
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const playPrevious = () => {
|
| 145 |
+
const prevSong = playerStore.playPrevious()
|
| 146 |
+
if (prevSong) {
|
| 147 |
+
loadAndPlaySong(prevSong)
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 快进到指定时间
|
| 152 |
+
const handleSeek = (time) => {
|
| 153 |
+
if (audioRef.value && isFinite(time) && time >= 0 && time <= (playerStore.duration || 0)) {
|
| 154 |
+
// 立即更新播放器时间
|
| 155 |
+
audioRef.value.currentTime = time
|
| 156 |
+
playerStore.setCurrentTime(time)
|
| 157 |
+
|
| 158 |
+
// 如果当前没有播放,且有音频源,尝试播放
|
| 159 |
+
if (!isPlaying.value && playerStore.audioSrc) {
|
| 160 |
+
audioRef.value.play().then(() => {
|
| 161 |
+
playerStore.setPlayingState(true)
|
| 162 |
+
console.log('拖动进度条后开始播��:', time)
|
| 163 |
+
}).catch(error => {
|
| 164 |
+
console.log('自动播放被阻止:', error)
|
| 165 |
+
})
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
console.log('跳转到时间:', time)
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// 加载并播放歌曲
|
| 173 |
+
const loadAndPlaySong = async (song) => {
|
| 174 |
+
if (!song || isLoading.value) return
|
| 175 |
+
|
| 176 |
+
isLoading.value = true
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
// 获取音乐链接 - 修复参数顺序:source, id, quality
|
| 180 |
+
const quality = settingsStore.getSetting('defaultQuality') || '320'
|
| 181 |
+
const result = await musicApi.getMusicUrl(song.source, song.id, quality)
|
| 182 |
+
|
| 183 |
+
if (result) {
|
| 184 |
+
playerStore.setAudioSrc(result)
|
| 185 |
+
|
| 186 |
+
// 重置播放进度(仅当切换歌曲时)
|
| 187 |
+
// 比较歌曲的唯一标识,包括名称确保是不同歌曲
|
| 188 |
+
const isSameSong = currentSong.value?.id === song.id &&
|
| 189 |
+
currentSong.value?.source === song.source &&
|
| 190 |
+
currentSong.value?.name === song.name
|
| 191 |
+
|
| 192 |
+
if (!isSameSong) {
|
| 193 |
+
playerStore.setCurrentTime(0)
|
| 194 |
+
playerStore.setDuration(0)
|
| 195 |
+
console.log('切换到新歌曲,重置播放进度:', song.name)
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// 更新音频元素
|
| 199 |
+
if (audioRef.value) {
|
| 200 |
+
audioRef.value.src = result
|
| 201 |
+
audioRef.value.load()
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// 添加到播放历史
|
| 205 |
+
favoritesStore.addToHistory(song)
|
| 206 |
+
|
| 207 |
+
console.log('歌曲加载成功:', song.name, result)
|
| 208 |
+
|
| 209 |
+
// 等待音频元数据加载后再尝试播放
|
| 210 |
+
if (audioRef.value) {
|
| 211 |
+
const tryPlay = async () => {
|
| 212 |
+
try {
|
| 213 |
+
if (audioRef.value.readyState >= 1) { // 有足够数据可以开始播放
|
| 214 |
+
await audioRef.value.play()
|
| 215 |
+
playerStore.setPlayingState(true)
|
| 216 |
+
console.log('开始播放:', song.name)
|
| 217 |
+
}
|
| 218 |
+
} catch (error) {
|
| 219 |
+
console.log('自动播放被阻止,用户需要手动播放')
|
| 220 |
+
playerStore.setPlayingState(false)
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
if (audioRef.value.readyState >= 1) {
|
| 225 |
+
tryPlay()
|
| 226 |
+
} else {
|
| 227 |
+
audioRef.value.addEventListener('loadeddata', tryPlay, { once: true })
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
} else {
|
| 231 |
+
showNotification('无法获取播放链接', 'error')
|
| 232 |
+
}
|
| 233 |
+
} catch (error) {
|
| 234 |
+
console.error('加载歌曲失败:', error)
|
| 235 |
+
showNotification('加载歌曲失败', 'error')
|
| 236 |
+
} finally {
|
| 237 |
+
isLoading.value = false
|
| 238 |
+
}
|
| 239 |
}
|
| 240 |
|
| 241 |
+
// 音频事件处理
|
| 242 |
+
const handleLoadedMetadata = () => {
|
| 243 |
+
if (audioRef.value) {
|
| 244 |
+
const duration = audioRef.value.duration
|
| 245 |
+
if (isFinite(duration)) {
|
| 246 |
+
playerStore.setDuration(duration)
|
| 247 |
+
|
| 248 |
+
// 恢复播放进度
|
| 249 |
+
const savedTime = playerStore.currentTime
|
| 250 |
+
if (savedTime > 0 && savedTime < duration && settingsStore.getSetting('rememberProgress')) {
|
| 251 |
+
audioRef.value.currentTime = savedTime
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
}
|
| 256 |
|
| 257 |
+
const handleTimeUpdate = () => {
|
| 258 |
+
if (audioRef.value && isFinite(audioRef.value.currentTime)) {
|
| 259 |
+
playerStore.setCurrentTime(audioRef.value.currentTime)
|
| 260 |
+
}
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
+
const handleEnded = () => {
|
| 264 |
+
if (settingsStore.getSetting('autoNext')) {
|
| 265 |
+
playNext()
|
| 266 |
+
} else {
|
| 267 |
+
playerStore.setPlayingState(false)
|
| 268 |
+
}
|
| 269 |
}
|
| 270 |
|
| 271 |
+
const handlePlay = () => {
|
| 272 |
+
playerStore.setPlayingState(true)
|
| 273 |
+
|
| 274 |
+
// 设置 MediaSession
|
| 275 |
+
if ('mediaSession' in navigator && currentSong.value) {
|
| 276 |
+
navigator.mediaSession.metadata = new MediaMetadata({
|
| 277 |
+
title: currentSong.value.name,
|
| 278 |
+
artist: utils.formatArtist(currentSong.value.artist),
|
| 279 |
+
album: currentSong.value.album,
|
| 280 |
+
artwork: [
|
| 281 |
+
{
|
| 282 |
+
src: `${import.meta.env.BASE_URL}icons/icon-512x512.svg`,
|
| 283 |
+
sizes: '512x512',
|
| 284 |
+
type: 'image/svg+xml'
|
| 285 |
+
}
|
| 286 |
+
]
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
// 设置播放控制
|
| 290 |
+
navigator.mediaSession.setActionHandler('play', togglePlay)
|
| 291 |
+
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
| 292 |
+
navigator.mediaSession.setActionHandler('nexttrack', playNext)
|
| 293 |
+
navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
const handlePause = () => {
|
| 298 |
+
playerStore.setPlayingState(false)
|
| 299 |
}
|
| 300 |
|
| 301 |
+
const handleError = (e) => {
|
| 302 |
+
console.error('音频播放错误:', e)
|
| 303 |
+
showNotification('播放出错,请重试', 'error')
|
| 304 |
+
playerStore.setPlayingState(false)
|
| 305 |
}
|
| 306 |
|
| 307 |
+
// 通知函数
|
| 308 |
+
const showNotification = (message, type = 'info') => {
|
| 309 |
+
// 这里需要实现通知组件的显示逻辑
|
| 310 |
+
console.log(`${type}: ${message}`)
|
| 311 |
}
|
| 312 |
|
| 313 |
+
// 监听当前歌曲变化
|
| 314 |
+
watch(currentSong, (newSong, oldSong) => {
|
| 315 |
+
if (newSong && newSong !== oldSong) {
|
| 316 |
+
// 如果是页面刷新后的恢复,且已有audioSrc,则不重新加载
|
| 317 |
+
if (oldSong === null && playerStore.audioSrc) {
|
| 318 |
+
console.log('恢复播放会话,跳过重新加载')
|
| 319 |
+
// 恢复音频元素的src
|
| 320 |
+
if (audioRef.value) {
|
| 321 |
+
audioRef.value.src = playerStore.audioSrc
|
| 322 |
+
audioRef.value.load()
|
| 323 |
+
}
|
| 324 |
+
return
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
loadAndPlaySong(newSong)
|
| 328 |
+
}
|
| 329 |
+
}, { immediate: true })
|
| 330 |
+
|
| 331 |
+
// 监听音量变化
|
| 332 |
+
watch(() => playerStore.volume, (newVolume) => {
|
| 333 |
+
if (audioRef.value) {
|
| 334 |
+
audioRef.value.volume = newVolume / 100
|
| 335 |
}
|
| 336 |
+
}, { immediate: true })
|
| 337 |
|
| 338 |
+
// 组件挂载
|
| 339 |
+
onMounted(() => {
|
| 340 |
+
// 加载所有存储状态
|
| 341 |
+
playerStore.loadPlayerState()
|
| 342 |
+
searchStore.loadSearchSettings()
|
| 343 |
+
searchStore.loadSearchHistory()
|
| 344 |
+
favoritesStore.loadFavorites()
|
| 345 |
+
favoritesStore.loadPlayHistory()
|
| 346 |
+
settingsStore.loadSettings()
|
| 347 |
+
|
| 348 |
+
// 建立音频元素连接
|
| 349 |
+
if (audioRef.value) {
|
| 350 |
+
playerStore.setAudioElement(audioRef.value)
|
| 351 |
+
audioRef.value.volume = (playerStore.volume || 80) / 100
|
| 352 |
}
|
| 353 |
|
| 354 |
+
// 应用主题
|
| 355 |
+
const theme = settingsStore.getSetting('theme') || 'light'
|
| 356 |
+
settingsStore.applyTheme(theme)
|
| 357 |
+
|
| 358 |
+
// 监听来自路由版FullPlayerPage的加载歌曲事件
|
| 359 |
+
const handleLoadAndPlaySong = (event) => {
|
| 360 |
+
if (event.detail?.song) {
|
| 361 |
+
loadAndPlaySong(event.detail.song)
|
| 362 |
+
}
|
| 363 |
}
|
| 364 |
+
window.addEventListener('loadAndPlaySong', handleLoadAndPlaySong)
|
| 365 |
+
|
| 366 |
+
// 恢复逻辑交由watch处理,避免重复设置
|
| 367 |
+
console.log('App mounted, 恢复状态:', {
|
| 368 |
+
currentSong: currentSong.value?.name,
|
| 369 |
+
hasAudioSrc: !!playerStore.audioSrc,
|
| 370 |
+
currentTheme: theme
|
| 371 |
+
})
|
| 372 |
+
})
|
| 373 |
+
</script>
|
| 374 |
|
| 375 |
+
<style>
|
| 376 |
+
.main-content {
|
| 377 |
+
flex: 1;
|
| 378 |
+
padding-bottom: calc(var(--tabbar-height) + var(--mini-player-height));
|
| 379 |
+
overflow-y: auto;
|
| 380 |
+
background: var(--bg-primary);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/* 当没有当前歌曲时,减少底部边距 */
|
| 384 |
+
.main-content:not(.has-mini-player) {
|
| 385 |
+
padding-bottom: var(--tabbar-height);
|
| 386 |
+
background: var(--bg-primary);
|
| 387 |
+
}
|
| 388 |
|
| 389 |
+
/* iOS PWA 模式适配 */
|
| 390 |
+
@supports (-webkit-touch-callout: none) {
|
| 391 |
+
@media all and (display-mode: standalone) {
|
| 392 |
+
.main-content {
|
| 393 |
+
padding-bottom: calc(var(--tabbar-height) + var(--mini-player-height) + 20px);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.main-content:not(.has-mini-player) {
|
| 397 |
+
padding-bottom: calc(var(--tabbar-height) + 20px);
|
| 398 |
+
}
|
| 399 |
}
|
| 400 |
}
|
| 401 |
</style>
|
src/assets/base.css
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
/* color palette from <https://github.com/vuejs/theme> */
|
| 2 |
-
:root {
|
| 3 |
-
--vt-c-white: #ffffff;
|
| 4 |
-
--vt-c-white-soft: #f8f8f8;
|
| 5 |
-
--vt-c-white-mute: #f2f2f2;
|
| 6 |
-
|
| 7 |
-
--vt-c-black: #181818;
|
| 8 |
-
--vt-c-black-soft: #222222;
|
| 9 |
-
--vt-c-black-mute: #282828;
|
| 10 |
-
|
| 11 |
-
--vt-c-indigo: #2c3e50;
|
| 12 |
-
|
| 13 |
-
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
| 14 |
-
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
| 15 |
-
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
| 16 |
-
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
| 17 |
-
|
| 18 |
-
--vt-c-text-light-1: var(--vt-c-indigo);
|
| 19 |
-
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
| 20 |
-
--vt-c-text-dark-1: var(--vt-c-white);
|
| 21 |
-
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
/* semantic color variables for this project */
|
| 25 |
-
:root {
|
| 26 |
-
--color-background: var(--vt-c-white);
|
| 27 |
-
--color-background-soft: var(--vt-c-white-soft);
|
| 28 |
-
--color-background-mute: var(--vt-c-white-mute);
|
| 29 |
-
|
| 30 |
-
--color-border: var(--vt-c-divider-light-2);
|
| 31 |
-
--color-border-hover: var(--vt-c-divider-light-1);
|
| 32 |
-
|
| 33 |
-
--color-heading: var(--vt-c-text-light-1);
|
| 34 |
-
--color-text: var(--vt-c-text-light-1);
|
| 35 |
-
|
| 36 |
-
--section-gap: 160px;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
@media (prefers-color-scheme: dark) {
|
| 40 |
-
:root {
|
| 41 |
-
--color-background: var(--vt-c-black);
|
| 42 |
-
--color-background-soft: var(--vt-c-black-soft);
|
| 43 |
-
--color-background-mute: var(--vt-c-black-mute);
|
| 44 |
-
|
| 45 |
-
--color-border: var(--vt-c-divider-dark-2);
|
| 46 |
-
--color-border-hover: var(--vt-c-divider-dark-1);
|
| 47 |
-
|
| 48 |
-
--color-heading: var(--vt-c-text-dark-1);
|
| 49 |
-
--color-text: var(--vt-c-text-dark-2);
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
*,
|
| 54 |
-
*::before,
|
| 55 |
-
*::after {
|
| 56 |
-
box-sizing: border-box;
|
| 57 |
-
margin: 0;
|
| 58 |
-
font-weight: normal;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
body {
|
| 62 |
-
min-height: 100vh;
|
| 63 |
-
color: var(--color-text);
|
| 64 |
-
background: var(--color-background);
|
| 65 |
-
transition:
|
| 66 |
-
color 0.5s,
|
| 67 |
-
background-color 0.5s;
|
| 68 |
-
line-height: 1.6;
|
| 69 |
-
font-family:
|
| 70 |
-
Inter,
|
| 71 |
-
-apple-system,
|
| 72 |
-
BlinkMacSystemFont,
|
| 73 |
-
'Segoe UI',
|
| 74 |
-
Roboto,
|
| 75 |
-
Oxygen,
|
| 76 |
-
Ubuntu,
|
| 77 |
-
Cantarell,
|
| 78 |
-
'Fira Sans',
|
| 79 |
-
'Droid Sans',
|
| 80 |
-
'Helvetica Neue',
|
| 81 |
-
sans-serif;
|
| 82 |
-
font-size: 15px;
|
| 83 |
-
text-rendering: optimizeLegibility;
|
| 84 |
-
-webkit-font-smoothing: antialiased;
|
| 85 |
-
-moz-osx-font-smoothing: grayscale;
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/assets/logo.svg
DELETED
src/assets/main.css
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
@import './base.css';
|
| 2 |
-
|
| 3 |
-
#app {
|
| 4 |
-
max-width: 1280px;
|
| 5 |
-
margin: 0 auto;
|
| 6 |
-
padding: 2rem;
|
| 7 |
-
font-weight: normal;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
a,
|
| 11 |
-
.green {
|
| 12 |
-
text-decoration: none;
|
| 13 |
-
color: hsla(160, 100%, 37%, 1);
|
| 14 |
-
transition: 0.4s;
|
| 15 |
-
padding: 3px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
@media (hover: hover) {
|
| 19 |
-
a:hover {
|
| 20 |
-
background-color: hsla(160, 100%, 37%, 0.2);
|
| 21 |
-
}
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
@media (min-width: 1024px) {
|
| 25 |
-
body {
|
| 26 |
-
display: flex;
|
| 27 |
-
place-items: center;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
#app {
|
| 31 |
-
display: grid;
|
| 32 |
-
grid-template-columns: 1fr 1fr;
|
| 33 |
-
padding: 0 2rem;
|
| 34 |
-
}
|
| 35 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/HelloWorld.vue
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
defineProps<{
|
| 3 |
-
msg: string
|
| 4 |
-
}>()
|
| 5 |
-
</script>
|
| 6 |
-
|
| 7 |
-
<template>
|
| 8 |
-
<div class="greetings">
|
| 9 |
-
<h1 class="green">{{ msg }}</h1>
|
| 10 |
-
<h3>
|
| 11 |
-
You’ve successfully created a project with
|
| 12 |
-
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
| 13 |
-
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
| 14 |
-
</h3>
|
| 15 |
-
</div>
|
| 16 |
-
</template>
|
| 17 |
-
|
| 18 |
-
<style scoped>
|
| 19 |
-
h1 {
|
| 20 |
-
font-weight: 500;
|
| 21 |
-
font-size: 2.6rem;
|
| 22 |
-
position: relative;
|
| 23 |
-
top: -10px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
h3 {
|
| 27 |
-
font-size: 1.2rem;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
.greetings h1,
|
| 31 |
-
.greetings h3 {
|
| 32 |
-
text-align: center;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
@media (min-width: 1024px) {
|
| 36 |
-
.greetings h1,
|
| 37 |
-
.greetings h3 {
|
| 38 |
-
text-align: left;
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/TheWelcome.vue
DELETED
|
@@ -1,94 +0,0 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
import WelcomeItem from './WelcomeItem.vue'
|
| 3 |
-
import DocumentationIcon from './icons/IconDocumentation.vue'
|
| 4 |
-
import ToolingIcon from './icons/IconTooling.vue'
|
| 5 |
-
import EcosystemIcon from './icons/IconEcosystem.vue'
|
| 6 |
-
import CommunityIcon from './icons/IconCommunity.vue'
|
| 7 |
-
import SupportIcon from './icons/IconSupport.vue'
|
| 8 |
-
|
| 9 |
-
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
| 10 |
-
</script>
|
| 11 |
-
|
| 12 |
-
<template>
|
| 13 |
-
<WelcomeItem>
|
| 14 |
-
<template #icon>
|
| 15 |
-
<DocumentationIcon />
|
| 16 |
-
</template>
|
| 17 |
-
<template #heading>Documentation</template>
|
| 18 |
-
|
| 19 |
-
Vue’s
|
| 20 |
-
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
| 21 |
-
provides you with all information you need to get started.
|
| 22 |
-
</WelcomeItem>
|
| 23 |
-
|
| 24 |
-
<WelcomeItem>
|
| 25 |
-
<template #icon>
|
| 26 |
-
<ToolingIcon />
|
| 27 |
-
</template>
|
| 28 |
-
<template #heading>Tooling</template>
|
| 29 |
-
|
| 30 |
-
This project is served and bundled with
|
| 31 |
-
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
| 32 |
-
recommended IDE setup is
|
| 33 |
-
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
| 34 |
-
+
|
| 35 |
-
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
| 36 |
-
you need to test your components and web pages, check out
|
| 37 |
-
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
| 38 |
-
and
|
| 39 |
-
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
| 40 |
-
/
|
| 41 |
-
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
| 42 |
-
|
| 43 |
-
<br />
|
| 44 |
-
|
| 45 |
-
More instructions are available in
|
| 46 |
-
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
| 47 |
-
>.
|
| 48 |
-
</WelcomeItem>
|
| 49 |
-
|
| 50 |
-
<WelcomeItem>
|
| 51 |
-
<template #icon>
|
| 52 |
-
<EcosystemIcon />
|
| 53 |
-
</template>
|
| 54 |
-
<template #heading>Ecosystem</template>
|
| 55 |
-
|
| 56 |
-
Get official tools and libraries for your project:
|
| 57 |
-
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
| 58 |
-
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
| 59 |
-
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
| 60 |
-
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
| 61 |
-
you need more resources, we suggest paying
|
| 62 |
-
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
| 63 |
-
a visit.
|
| 64 |
-
</WelcomeItem>
|
| 65 |
-
|
| 66 |
-
<WelcomeItem>
|
| 67 |
-
<template #icon>
|
| 68 |
-
<CommunityIcon />
|
| 69 |
-
</template>
|
| 70 |
-
<template #heading>Community</template>
|
| 71 |
-
|
| 72 |
-
Got stuck? Ask your question on
|
| 73 |
-
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
| 74 |
-
(our official Discord server), or
|
| 75 |
-
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
| 76 |
-
>StackOverflow</a
|
| 77 |
-
>. You should also follow the official
|
| 78 |
-
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
| 79 |
-
Bluesky account or the
|
| 80 |
-
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
| 81 |
-
X account for latest news in the Vue world.
|
| 82 |
-
</WelcomeItem>
|
| 83 |
-
|
| 84 |
-
<WelcomeItem>
|
| 85 |
-
<template #icon>
|
| 86 |
-
<SupportIcon />
|
| 87 |
-
</template>
|
| 88 |
-
<template #heading>Support Vue</template>
|
| 89 |
-
|
| 90 |
-
As an independent project, Vue relies on community backing for its sustainability. You can help
|
| 91 |
-
us by
|
| 92 |
-
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
| 93 |
-
</WelcomeItem>
|
| 94 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/WelcomeItem.vue
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
<template>
|
| 2 |
-
<div class="item">
|
| 3 |
-
<i>
|
| 4 |
-
<slot name="icon"></slot>
|
| 5 |
-
</i>
|
| 6 |
-
<div class="details">
|
| 7 |
-
<h3>
|
| 8 |
-
<slot name="heading"></slot>
|
| 9 |
-
</h3>
|
| 10 |
-
<slot></slot>
|
| 11 |
-
</div>
|
| 12 |
-
</div>
|
| 13 |
-
</template>
|
| 14 |
-
|
| 15 |
-
<style scoped>
|
| 16 |
-
.item {
|
| 17 |
-
margin-top: 2rem;
|
| 18 |
-
display: flex;
|
| 19 |
-
position: relative;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
.details {
|
| 23 |
-
flex: 1;
|
| 24 |
-
margin-left: 1rem;
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
i {
|
| 28 |
-
display: flex;
|
| 29 |
-
place-items: center;
|
| 30 |
-
place-content: center;
|
| 31 |
-
width: 32px;
|
| 32 |
-
height: 32px;
|
| 33 |
-
|
| 34 |
-
color: var(--color-text);
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
h3 {
|
| 38 |
-
font-size: 1.2rem;
|
| 39 |
-
font-weight: 500;
|
| 40 |
-
margin-bottom: 0.4rem;
|
| 41 |
-
color: var(--color-heading);
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
@media (min-width: 1024px) {
|
| 45 |
-
.item {
|
| 46 |
-
margin-top: 0;
|
| 47 |
-
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
i {
|
| 51 |
-
top: calc(50% - 25px);
|
| 52 |
-
left: -26px;
|
| 53 |
-
position: absolute;
|
| 54 |
-
border: 1px solid var(--color-border);
|
| 55 |
-
background: var(--color-background);
|
| 56 |
-
border-radius: 8px;
|
| 57 |
-
width: 50px;
|
| 58 |
-
height: 50px;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
.item:before {
|
| 62 |
-
content: ' ';
|
| 63 |
-
border-left: 1px solid var(--color-border);
|
| 64 |
-
position: absolute;
|
| 65 |
-
left: 0;
|
| 66 |
-
bottom: calc(50% + 25px);
|
| 67 |
-
height: calc(50% - 25px);
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
.item:after {
|
| 71 |
-
content: ' ';
|
| 72 |
-
border-left: 1px solid var(--color-border);
|
| 73 |
-
position: absolute;
|
| 74 |
-
left: 0;
|
| 75 |
-
top: calc(50% + 25px);
|
| 76 |
-
height: calc(50% - 25px);
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
.item:first-of-type:before {
|
| 80 |
-
display: none;
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
.item:last-of-type:after {
|
| 84 |
-
display: none;
|
| 85 |
-
}
|
| 86 |
-
}
|
| 87 |
-
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/common/BottomSheet.vue
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Teleport to="body">
|
| 3 |
+
<Transition name="bottom-sheet" appear>
|
| 4 |
+
<div v-if="visible" class="bottom-sheet-overlay" @click="handleOverlayClick">
|
| 5 |
+
<div
|
| 6 |
+
class="bottom-sheet"
|
| 7 |
+
:class="{ fullscreen: isFullscreen }"
|
| 8 |
+
@click.stop
|
| 9 |
+
ref="sheetRef"
|
| 10 |
+
>
|
| 11 |
+
<!-- 拖拽指示器 -->
|
| 12 |
+
<div class="drag-indicator" v-if="draggable" @touchstart="handleTouchStart" @mousedown="handleMouseDown">
|
| 13 |
+
<div class="drag-handle"></div>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- 头部 -->
|
| 17 |
+
<div class="bottom-sheet-header" v-if="title || closable">
|
| 18 |
+
<h3 class="sheet-title" v-if="title">{{ title }}</h3>
|
| 19 |
+
<button
|
| 20 |
+
v-if="closable"
|
| 21 |
+
class="sheet-close-btn"
|
| 22 |
+
@click="handleClose"
|
| 23 |
+
>
|
| 24 |
+
<i class="fas fa-times"></i>
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<!-- 内容 -->
|
| 29 |
+
<div class="bottom-sheet-body" ref="bodyRef">
|
| 30 |
+
<slot></slot>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</Transition>
|
| 35 |
+
</Teleport>
|
| 36 |
+
</template>
|
| 37 |
+
|
| 38 |
+
<script setup>
|
| 39 |
+
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
| 40 |
+
|
| 41 |
+
const props = defineProps({
|
| 42 |
+
// 标题
|
| 43 |
+
title: {
|
| 44 |
+
type: String,
|
| 45 |
+
default: ''
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
// 是否可关闭
|
| 49 |
+
closable: {
|
| 50 |
+
type: Boolean,
|
| 51 |
+
default: true
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
// 点击遮罩是否关闭
|
| 55 |
+
maskClosable: {
|
| 56 |
+
type: Boolean,
|
| 57 |
+
default: true
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
// 是否可拖拽
|
| 61 |
+
draggable: {
|
| 62 |
+
type: Boolean,
|
| 63 |
+
default: true
|
| 64 |
+
},
|
| 65 |
+
|
| 66 |
+
// 最大高度(百分比)
|
| 67 |
+
maxHeight: {
|
| 68 |
+
type: Number,
|
| 69 |
+
default: 90
|
| 70 |
+
}
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
const emit = defineEmits(['close', 'open'])
|
| 74 |
+
|
| 75 |
+
const visible = ref(false)
|
| 76 |
+
const isFullscreen = ref(false)
|
| 77 |
+
const sheetRef = ref(null)
|
| 78 |
+
const bodyRef = ref(null)
|
| 79 |
+
|
| 80 |
+
// 拖拽相关
|
| 81 |
+
const isDragging = ref(false)
|
| 82 |
+
const startY = ref(0)
|
| 83 |
+
const startHeight = ref(0)
|
| 84 |
+
const currentTranslateY = ref(0)
|
| 85 |
+
|
| 86 |
+
// 处理遮罩点击
|
| 87 |
+
const handleOverlayClick = () => {
|
| 88 |
+
if (props.maskClosable) {
|
| 89 |
+
handleClose()
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// 处理关闭
|
| 94 |
+
const handleClose = () => {
|
| 95 |
+
close()
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// 处理触摸开始
|
| 99 |
+
const handleTouchStart = (event) => {
|
| 100 |
+
if (!props.draggable) return
|
| 101 |
+
|
| 102 |
+
isDragging.value = true
|
| 103 |
+
startY.value = event.touches[0].clientY
|
| 104 |
+
startHeight.value = sheetRef.value.offsetHeight
|
| 105 |
+
currentTranslateY.value = 0
|
| 106 |
+
|
| 107 |
+
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
| 108 |
+
document.addEventListener('touchend', handleTouchEnd)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// 处理鼠标按下
|
| 112 |
+
const handleMouseDown = (event) => {
|
| 113 |
+
if (!props.draggable) return
|
| 114 |
+
|
| 115 |
+
isDragging.value = true
|
| 116 |
+
startY.value = event.clientY
|
| 117 |
+
startHeight.value = sheetRef.value.offsetHeight
|
| 118 |
+
currentTranslateY.value = 0
|
| 119 |
+
|
| 120 |
+
document.addEventListener('mousemove', handleMouseMove)
|
| 121 |
+
document.addEventListener('mouseup', handleMouseUp)
|
| 122 |
+
event.preventDefault()
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// 处理触摸移动
|
| 126 |
+
const handleTouchMove = (event) => {
|
| 127 |
+
if (!isDragging.value) return
|
| 128 |
+
|
| 129 |
+
const deltaY = event.touches[0].clientY - startY.value
|
| 130 |
+
|
| 131 |
+
// 只允许向下拖拽
|
| 132 |
+
if (deltaY > 0) {
|
| 133 |
+
currentTranslateY.value = deltaY
|
| 134 |
+
sheetRef.value.style.transform = `translateY(${deltaY}px)`
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
event.preventDefault()
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 处理鼠标移动
|
| 141 |
+
const handleMouseMove = (event) => {
|
| 142 |
+
if (!isDragging.value) return
|
| 143 |
+
|
| 144 |
+
const deltaY = event.clientY - startY.value
|
| 145 |
+
|
| 146 |
+
if (deltaY > 0) {
|
| 147 |
+
currentTranslateY.value = deltaY
|
| 148 |
+
sheetRef.value.style.transform = `translateY(${deltaY}px)`
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// 处理触摸结束
|
| 153 |
+
const handleTouchEnd = () => {
|
| 154 |
+
if (!isDragging.value) return
|
| 155 |
+
|
| 156 |
+
isDragging.value = false
|
| 157 |
+
|
| 158 |
+
// 如果拖拽距离超过阈值,关闭弹窗
|
| 159 |
+
if (currentTranslateY.value > startHeight.value * 0.3) {
|
| 160 |
+
close()
|
| 161 |
+
} else {
|
| 162 |
+
// 回弹到原位置
|
| 163 |
+
sheetRef.value.style.transform = ''
|
| 164 |
+
sheetRef.value.style.transition = 'transform 0.3s ease-out'
|
| 165 |
+
|
| 166 |
+
setTimeout(() => {
|
| 167 |
+
if (sheetRef.value) {
|
| 168 |
+
sheetRef.value.style.transition = ''
|
| 169 |
+
}
|
| 170 |
+
}, 300)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
currentTranslateY.value = 0
|
| 174 |
+
document.removeEventListener('touchmove', handleTouchMove)
|
| 175 |
+
document.removeEventListener('touchend', handleTouchEnd)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 处理鼠标释放
|
| 179 |
+
const handleMouseUp = () => {
|
| 180 |
+
if (!isDragging.value) return
|
| 181 |
+
|
| 182 |
+
isDragging.value = false
|
| 183 |
+
|
| 184 |
+
if (currentTranslateY.value > startHeight.value * 0.3) {
|
| 185 |
+
close()
|
| 186 |
+
} else {
|
| 187 |
+
sheetRef.value.style.transform = ''
|
| 188 |
+
sheetRef.value.style.transition = 'transform 0.3s ease-out'
|
| 189 |
+
|
| 190 |
+
setTimeout(() => {
|
| 191 |
+
if (sheetRef.value) {
|
| 192 |
+
sheetRef.value.style.transition = ''
|
| 193 |
+
}
|
| 194 |
+
}, 300)
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
currentTranslateY.value = 0
|
| 198 |
+
document.removeEventListener('mousemove', handleMouseMove)
|
| 199 |
+
document.removeEventListener('mouseup', handleMouseUp)
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// 处理键盘事件
|
| 203 |
+
const handleKeyDown = (event) => {
|
| 204 |
+
if (event.key === 'Escape' && props.closable) {
|
| 205 |
+
handleClose()
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// 显示底部弹窗
|
| 210 |
+
const open = () => {
|
| 211 |
+
visible.value = true
|
| 212 |
+
document.body.style.overflow = 'hidden'
|
| 213 |
+
document.addEventListener('keydown', handleKeyDown)
|
| 214 |
+
|
| 215 |
+
nextTick(() => {
|
| 216 |
+
// 检查内容是否需要全屏显示
|
| 217 |
+
if (bodyRef.value) {
|
| 218 |
+
const contentHeight = bodyRef.value.scrollHeight
|
| 219 |
+
const maxAllowedHeight = window.innerHeight * (props.maxHeight / 100)
|
| 220 |
+
|
| 221 |
+
if (contentHeight > maxAllowedHeight * 0.8) {
|
| 222 |
+
isFullscreen.value = true
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
emit('open')
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// 关闭底部弹窗
|
| 231 |
+
const close = () => {
|
| 232 |
+
visible.value = false
|
| 233 |
+
document.body.style.overflow = ''
|
| 234 |
+
document.removeEventListener('keydown', handleKeyDown)
|
| 235 |
+
emit('close')
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// 生命周期
|
| 239 |
+
onMounted(() => {
|
| 240 |
+
open()
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
onUnmounted(() => {
|
| 244 |
+
document.body.style.overflow = ''
|
| 245 |
+
document.removeEventListener('keydown', handleKeyDown)
|
| 246 |
+
document.removeEventListener('touchmove', handleTouchMove)
|
| 247 |
+
document.removeEventListener('touchend', handleTouchEnd)
|
| 248 |
+
document.removeEventListener('mousemove', handleMouseMove)
|
| 249 |
+
document.removeEventListener('mouseup', handleMouseUp)
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
// 暴露方法
|
| 253 |
+
defineExpose({
|
| 254 |
+
open,
|
| 255 |
+
close
|
| 256 |
+
})
|
| 257 |
+
</script>
|
| 258 |
+
|
| 259 |
+
<style scoped>
|
| 260 |
+
.bottom-sheet-overlay {
|
| 261 |
+
position: fixed;
|
| 262 |
+
top: 0;
|
| 263 |
+
left: 0;
|
| 264 |
+
right: 0;
|
| 265 |
+
bottom: 0;
|
| 266 |
+
background: rgba(0, 0, 0, 0.6);
|
| 267 |
+
backdrop-filter: blur(4px);
|
| 268 |
+
z-index: 2000;
|
| 269 |
+
display: flex;
|
| 270 |
+
align-items: flex-end;
|
| 271 |
+
justify-content: center;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.bottom-sheet {
|
| 275 |
+
width: 100%;
|
| 276 |
+
max-width: 500px;
|
| 277 |
+
max-height: v-bind(maxHeight + '%');
|
| 278 |
+
background: var(--bg-secondary);
|
| 279 |
+
border-radius: 16px 16px 0 0;
|
| 280 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 281 |
+
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3);
|
| 282 |
+
overflow: hidden;
|
| 283 |
+
display: flex;
|
| 284 |
+
flex-direction: column;
|
| 285 |
+
margin-bottom: 0;
|
| 286 |
+
user-select: none;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.bottom-sheet.fullscreen {
|
| 290 |
+
height: 90%;
|
| 291 |
+
border-radius: 16px;
|
| 292 |
+
margin: 20px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.drag-indicator {
|
| 296 |
+
padding: 8px 0 4px;
|
| 297 |
+
display: flex;
|
| 298 |
+
justify-content: center;
|
| 299 |
+
cursor: grab;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.drag-indicator:active {
|
| 303 |
+
cursor: grabbing;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.drag-handle {
|
| 307 |
+
width: 32px;
|
| 308 |
+
height: 4px;
|
| 309 |
+
background: rgba(255, 255, 255, 0.3);
|
| 310 |
+
border-radius: 2px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.bottom-sheet-header {
|
| 314 |
+
display: flex;
|
| 315 |
+
align-items: center;
|
| 316 |
+
justify-content: space-between;
|
| 317 |
+
padding: 16px 20px;
|
| 318 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 319 |
+
flex-shrink: 0;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.sheet-title {
|
| 323 |
+
font-size: 18px;
|
| 324 |
+
font-weight: 600;
|
| 325 |
+
color: var(--text-primary);
|
| 326 |
+
margin: 0;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.sheet-close-btn {
|
| 330 |
+
width: 32px;
|
| 331 |
+
height: 32px;
|
| 332 |
+
border: none;
|
| 333 |
+
background: rgba(255, 255, 255, 0.1);
|
| 334 |
+
color: var(--text-secondary);
|
| 335 |
+
border-radius: 50%;
|
| 336 |
+
display: flex;
|
| 337 |
+
align-items: center;
|
| 338 |
+
justify-content: center;
|
| 339 |
+
cursor: pointer;
|
| 340 |
+
transition: var(--transition-fast);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.sheet-close-btn:hover {
|
| 344 |
+
background: rgba(255, 255, 255, 0.2);
|
| 345 |
+
color: var(--text-primary);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.bottom-sheet-body {
|
| 349 |
+
flex: 1;
|
| 350 |
+
overflow-y: auto;
|
| 351 |
+
min-height: 0;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/* 动画 */
|
| 355 |
+
.bottom-sheet-enter-active,
|
| 356 |
+
.bottom-sheet-leave-active {
|
| 357 |
+
transition: all 0.3s ease-out;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.bottom-sheet-enter-from,
|
| 361 |
+
.bottom-sheet-leave-to {
|
| 362 |
+
opacity: 0;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.bottom-sheet-enter-from .bottom-sheet,
|
| 366 |
+
.bottom-sheet-leave-to .bottom-sheet {
|
| 367 |
+
transform: translateY(100%);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/* 响应式 */
|
| 371 |
+
@media (max-width: 500px) {
|
| 372 |
+
.bottom-sheet {
|
| 373 |
+
max-width: none;
|
| 374 |
+
margin: 0;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.bottom-sheet.fullscreen {
|
| 378 |
+
margin: 0;
|
| 379 |
+
height: 95%;
|
| 380 |
+
border-radius: 16px 16px 0 0;
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
@media (max-width: 375px) {
|
| 385 |
+
.bottom-sheet-header {
|
| 386 |
+
padding: 14px 16px;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.sheet-title {
|
| 390 |
+
font-size: 16px;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.sheet-close-btn {
|
| 394 |
+
width: 28px;
|
| 395 |
+
height: 28px;
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* 滚动条样式 */
|
| 400 |
+
.bottom-sheet-body::-webkit-scrollbar {
|
| 401 |
+
width: 4px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.bottom-sheet-body::-webkit-scrollbar-track {
|
| 405 |
+
background: transparent;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.bottom-sheet-body::-webkit-scrollbar-thumb {
|
| 409 |
+
background: rgba(255, 255, 255, 0.2);
|
| 410 |
+
border-radius: 2px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.bottom-sheet-body::-webkit-scrollbar-thumb:hover {
|
| 414 |
+
background: rgba(255, 255, 255, 0.3);
|
| 415 |
+
}
|
| 416 |
+
</style>
|
src/components/common/ConfirmDialog.vue
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="confirm-overlay" v-if="visible" @click="handleCancel">
|
| 3 |
+
<div class="confirm-dialog" @click.stop>
|
| 4 |
+
<div class="dialog-header">
|
| 5 |
+
<h3 class="dialog-title">{{ title }}</h3>
|
| 6 |
+
</div>
|
| 7 |
+
|
| 8 |
+
<div class="dialog-content">
|
| 9 |
+
<p class="dialog-message">{{ message }}</p>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="dialog-actions">
|
| 13 |
+
<button
|
| 14 |
+
class="dialog-btn dialog-btn-cancel"
|
| 15 |
+
@click="handleCancel"
|
| 16 |
+
>
|
| 17 |
+
{{ cancelText }}
|
| 18 |
+
</button>
|
| 19 |
+
<button
|
| 20 |
+
class="dialog-btn dialog-btn-confirm"
|
| 21 |
+
@click="handleConfirm"
|
| 22 |
+
:class="{ danger: type === 'danger' }"
|
| 23 |
+
>
|
| 24 |
+
{{ confirmText }}
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</template>
|
| 30 |
+
|
| 31 |
+
<script setup>
|
| 32 |
+
import { ref } from 'vue'
|
| 33 |
+
|
| 34 |
+
const props = defineProps({
|
| 35 |
+
title: {
|
| 36 |
+
type: String,
|
| 37 |
+
default: '确认操作'
|
| 38 |
+
},
|
| 39 |
+
message: {
|
| 40 |
+
type: String,
|
| 41 |
+
required: true
|
| 42 |
+
},
|
| 43 |
+
confirmText: {
|
| 44 |
+
type: String,
|
| 45 |
+
default: '确认'
|
| 46 |
+
},
|
| 47 |
+
cancelText: {
|
| 48 |
+
type: String,
|
| 49 |
+
default: '取消'
|
| 50 |
+
},
|
| 51 |
+
type: {
|
| 52 |
+
type: String,
|
| 53 |
+
default: 'normal', // 'normal' | 'danger'
|
| 54 |
+
}
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
const emit = defineEmits(['confirm', 'cancel'])
|
| 58 |
+
|
| 59 |
+
const visible = ref(false)
|
| 60 |
+
|
| 61 |
+
const show = () => {
|
| 62 |
+
visible.value = true
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const hide = () => {
|
| 66 |
+
visible.value = false
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const handleConfirm = () => {
|
| 70 |
+
emit('confirm')
|
| 71 |
+
hide()
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const handleCancel = () => {
|
| 75 |
+
emit('cancel')
|
| 76 |
+
hide()
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
defineExpose({
|
| 80 |
+
show,
|
| 81 |
+
hide
|
| 82 |
+
})
|
| 83 |
+
</script>
|
| 84 |
+
|
| 85 |
+
<style scoped>
|
| 86 |
+
.confirm-overlay {
|
| 87 |
+
position: fixed;
|
| 88 |
+
top: 0;
|
| 89 |
+
left: 0;
|
| 90 |
+
right: 0;
|
| 91 |
+
bottom: 0;
|
| 92 |
+
background: rgba(0, 0, 0, 0.6);
|
| 93 |
+
backdrop-filter: blur(10px);
|
| 94 |
+
z-index: 3000;
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: center;
|
| 97 |
+
justify-content: center;
|
| 98 |
+
animation: fadeIn 0.2s ease-out;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.confirm-dialog {
|
| 102 |
+
background: var(--bg-card);
|
| 103 |
+
border-radius: 16px;
|
| 104 |
+
padding: 0;
|
| 105 |
+
width: 90%;
|
| 106 |
+
max-width: 360px;
|
| 107 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
| 108 |
+
animation: slideUp 0.2s ease-out;
|
| 109 |
+
overflow: hidden;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.dialog-header {
|
| 113 |
+
padding: 24px 24px 16px;
|
| 114 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.dialog-title {
|
| 118 |
+
font-size: 18px;
|
| 119 |
+
font-weight: 600;
|
| 120 |
+
color: var(--text-primary);
|
| 121 |
+
margin: 0;
|
| 122 |
+
text-align: center;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.dialog-content {
|
| 126 |
+
padding: 16px 24px 24px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.dialog-message {
|
| 130 |
+
font-size: 16px;
|
| 131 |
+
color: var(--text-secondary);
|
| 132 |
+
line-height: 1.4;
|
| 133 |
+
margin: 0;
|
| 134 |
+
text-align: center;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.dialog-actions {
|
| 138 |
+
display: flex;
|
| 139 |
+
gap: 12px;
|
| 140 |
+
padding: 0 24px 24px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.dialog-btn {
|
| 144 |
+
flex: 1;
|
| 145 |
+
padding: 12px 24px;
|
| 146 |
+
border: none;
|
| 147 |
+
border-radius: 8px;
|
| 148 |
+
font-size: 16px;
|
| 149 |
+
font-weight: 500;
|
| 150 |
+
cursor: pointer;
|
| 151 |
+
transition: var(--transition-fast);
|
| 152 |
+
min-height: 44px;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.dialog-btn-cancel {
|
| 156 |
+
background: var(--bg-secondary);
|
| 157 |
+
color: var(--text-secondary);
|
| 158 |
+
border: 1px solid var(--border-light);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.dialog-btn-cancel:hover {
|
| 162 |
+
background: var(--bg-tertiary);
|
| 163 |
+
color: var(--text-primary);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.dialog-btn-confirm {
|
| 167 |
+
background: var(--primary-color);
|
| 168 |
+
color: white;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.dialog-btn-confirm:hover {
|
| 172 |
+
background: var(--primary-color-hover);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.dialog-btn-confirm.danger {
|
| 176 |
+
background: #ff4444;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.dialog-btn-confirm.danger:hover {
|
| 180 |
+
background: #ff2222;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
@keyframes fadeIn {
|
| 184 |
+
from { opacity: 0; }
|
| 185 |
+
to { opacity: 1; }
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
@keyframes slideUp {
|
| 189 |
+
from {
|
| 190 |
+
opacity: 0;
|
| 191 |
+
transform: translateY(20px);
|
| 192 |
+
}
|
| 193 |
+
to {
|
| 194 |
+
opacity: 1;
|
| 195 |
+
transform: translateY(0);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* 响应式 */
|
| 200 |
+
@media (max-width: 375px) {
|
| 201 |
+
.confirm-dialog {
|
| 202 |
+
width: 95%;
|
| 203 |
+
max-width: none;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.dialog-header {
|
| 207 |
+
padding: 20px 20px 12px;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.dialog-title {
|
| 211 |
+
font-size: 16px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.dialog-content {
|
| 215 |
+
padding: 12px 20px 20px;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.dialog-message {
|
| 219 |
+
font-size: 15px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.dialog-actions {
|
| 223 |
+
padding: 0 20px 20px;
|
| 224 |
+
gap: 8px;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.dialog-btn {
|
| 228 |
+
padding: 10px 16px;
|
| 229 |
+
font-size: 15px;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
</style>
|
src/components/common/Empty.vue
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="empty-container">
|
| 3 |
+
<div class="empty-content">
|
| 4 |
+
<!-- 空状态图标 -->
|
| 5 |
+
<div class="empty-icon">
|
| 6 |
+
<i :class="iconClass"></i>
|
| 7 |
+
</div>
|
| 8 |
+
|
| 9 |
+
<!-- 主要文本 -->
|
| 10 |
+
<h3 class="empty-title">{{ title }}</h3>
|
| 11 |
+
|
| 12 |
+
<!-- 描述文本 -->
|
| 13 |
+
<p class="empty-description" v-if="description">{{ description }}</p>
|
| 14 |
+
|
| 15 |
+
<!-- 操作按钮 -->
|
| 16 |
+
<div class="empty-actions" v-if="$slots.action">
|
| 17 |
+
<slot name="action"></slot>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
</template>
|
| 22 |
+
|
| 23 |
+
<script setup>
|
| 24 |
+
import { computed } from 'vue'
|
| 25 |
+
|
| 26 |
+
const props = defineProps({
|
| 27 |
+
// 标题文本
|
| 28 |
+
title: {
|
| 29 |
+
type: String,
|
| 30 |
+
default: '暂无内容'
|
| 31 |
+
},
|
| 32 |
+
|
| 33 |
+
// 描述文本
|
| 34 |
+
description: {
|
| 35 |
+
type: String,
|
| 36 |
+
default: ''
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// 图标类型:search, music, favorite, history, network
|
| 40 |
+
type: {
|
| 41 |
+
type: String,
|
| 42 |
+
default: 'default',
|
| 43 |
+
validator: (value) => ['default', 'search', 'music', 'favorite', 'history', 'network'].includes(value)
|
| 44 |
+
}
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
// 图标映射
|
| 48 |
+
const iconClass = computed(() => {
|
| 49 |
+
const iconMap = {
|
| 50 |
+
default: 'fas fa-inbox',
|
| 51 |
+
search: 'fas fa-search',
|
| 52 |
+
music: 'fas fa-music',
|
| 53 |
+
favorite: 'fas fa-heart-broken',
|
| 54 |
+
history: 'fas fa-history',
|
| 55 |
+
network: 'fas fa-wifi'
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return iconMap[props.type] || iconMap.default
|
| 59 |
+
})
|
| 60 |
+
</script>
|
| 61 |
+
|
| 62 |
+
<style scoped>
|
| 63 |
+
.empty-container {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: center;
|
| 67 |
+
min-height: 200px;
|
| 68 |
+
padding: 40px 20px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.empty-content {
|
| 72 |
+
display: flex;
|
| 73 |
+
flex-direction: column;
|
| 74 |
+
align-items: center;
|
| 75 |
+
text-align: center;
|
| 76 |
+
max-width: 300px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.empty-icon {
|
| 80 |
+
margin-bottom: 20px;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.empty-icon i {
|
| 84 |
+
font-size: 64px;
|
| 85 |
+
color: var(--text-tertiary);
|
| 86 |
+
opacity: 0.6;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.empty-title {
|
| 90 |
+
font-size: 18px;
|
| 91 |
+
font-weight: 600;
|
| 92 |
+
color: var(--text-primary);
|
| 93 |
+
margin: 0 0 8px 0;
|
| 94 |
+
line-height: 1.4;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.empty-description {
|
| 98 |
+
font-size: 14px;
|
| 99 |
+
color: var(--text-secondary);
|
| 100 |
+
margin: 0 0 24px 0;
|
| 101 |
+
line-height: 1.5;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.empty-actions {
|
| 105 |
+
display: flex;
|
| 106 |
+
gap: 12px;
|
| 107 |
+
flex-wrap: wrap;
|
| 108 |
+
justify-content: center;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/* 响应式 */
|
| 112 |
+
@media (max-width: 375px) {
|
| 113 |
+
.empty-container {
|
| 114 |
+
min-height: 160px;
|
| 115 |
+
padding: 30px 16px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.empty-icon i {
|
| 119 |
+
font-size: 48px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.empty-title {
|
| 123 |
+
font-size: 16px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.empty-description {
|
| 127 |
+
font-size: 13px;
|
| 128 |
+
margin-bottom: 20px;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* 动画效果 */
|
| 133 |
+
.empty-content {
|
| 134 |
+
animation: fadeInUp 0.6s ease-out;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
@keyframes fadeInUp {
|
| 138 |
+
from {
|
| 139 |
+
opacity: 0;
|
| 140 |
+
transform: translateY(20px);
|
| 141 |
+
}
|
| 142 |
+
to {
|
| 143 |
+
opacity: 1;
|
| 144 |
+
transform: translateY(0);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/* 特殊类型的样式 */
|
| 149 |
+
.empty-icon .fa-search {
|
| 150 |
+
color: #3182ce;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.empty-icon .fa-music {
|
| 154 |
+
color: var(--accent-red);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.empty-icon .fa-heart-broken {
|
| 158 |
+
color: #e53e3e;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.empty-icon .fa-history {
|
| 162 |
+
color: #805ad5;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.empty-icon .fa-wifi {
|
| 166 |
+
color: #38a169;
|
| 167 |
+
}
|
| 168 |
+
</style>
|
src/components/common/Icon.vue
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<i
|
| 3 |
+
:class="iconClass"
|
| 4 |
+
:style="iconStyle"
|
| 5 |
+
@click="handleClick"
|
| 6 |
+
></i>
|
| 7 |
+
</template>
|
| 8 |
+
|
| 9 |
+
<script setup>
|
| 10 |
+
import { computed } from 'vue'
|
| 11 |
+
|
| 12 |
+
const props = defineProps({
|
| 13 |
+
// 图标名称(FontAwesome类名,不需要前缀)
|
| 14 |
+
name: {
|
| 15 |
+
type: String,
|
| 16 |
+
required: true
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
// 图标大小
|
| 20 |
+
size: {
|
| 21 |
+
type: [String, Number],
|
| 22 |
+
default: 'inherit'
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
// 图标颜色
|
| 26 |
+
color: {
|
| 27 |
+
type: String,
|
| 28 |
+
default: 'inherit'
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
// 是否可点击
|
| 32 |
+
clickable: {
|
| 33 |
+
type: Boolean,
|
| 34 |
+
default: false
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
// 旋转角度
|
| 38 |
+
rotate: {
|
| 39 |
+
type: [String, Number],
|
| 40 |
+
default: 0
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
// 是否加载中(旋转动画)
|
| 44 |
+
loading: {
|
| 45 |
+
type: Boolean,
|
| 46 |
+
default: false
|
| 47 |
+
}
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
const emit = defineEmits(['click'])
|
| 51 |
+
|
| 52 |
+
// 图标类名
|
| 53 |
+
const iconClass = computed(() => {
|
| 54 |
+
const classes = []
|
| 55 |
+
|
| 56 |
+
// 基础类名
|
| 57 |
+
if (props.name.startsWith('fa-')) {
|
| 58 |
+
classes.push('fas', props.name)
|
| 59 |
+
} else {
|
| 60 |
+
classes.push('fas', `fa-${props.name}`)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// 可点击样式
|
| 64 |
+
if (props.clickable) {
|
| 65 |
+
classes.push('icon-clickable')
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// 加载动画
|
| 69 |
+
if (props.loading) {
|
| 70 |
+
classes.push('fa-spin')
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return classes.join(' ')
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
// 图标样式
|
| 77 |
+
const iconStyle = computed(() => {
|
| 78 |
+
const styles = {}
|
| 79 |
+
|
| 80 |
+
// 大小
|
| 81 |
+
if (props.size !== 'inherit') {
|
| 82 |
+
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
|
| 83 |
+
styles.fontSize = sizeValue
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// 颜色
|
| 87 |
+
if (props.color !== 'inherit') {
|
| 88 |
+
styles.color = props.color
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// 旋转
|
| 92 |
+
if (props.rotate && !props.loading) {
|
| 93 |
+
styles.transform = `rotate(${props.rotate}deg)`
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return styles
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
// 处理点击事件
|
| 100 |
+
const handleClick = (event) => {
|
| 101 |
+
if (props.clickable) {
|
| 102 |
+
emit('click', event)
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
</script>
|
| 106 |
+
|
| 107 |
+
<style scoped>
|
| 108 |
+
.icon-clickable {
|
| 109 |
+
cursor: pointer;
|
| 110 |
+
transition: var(--transition-fast);
|
| 111 |
+
user-select: none;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.icon-clickable:hover {
|
| 115 |
+
opacity: 0.8;
|
| 116 |
+
transform: scale(1.05);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.icon-clickable:active {
|
| 120 |
+
transform: scale(0.95);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* 常用图标预设 */
|
| 124 |
+
.fa-play {
|
| 125 |
+
margin-left: 2px; /* 播放图标视觉居中 */
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.fa-heart {
|
| 129 |
+
color: #e53e3e;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.fa-heart.far {
|
| 133 |
+
color: var(--text-secondary);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.fa-star {
|
| 137 |
+
color: #ed8936;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.fa-star.far {
|
| 141 |
+
color: var(--text-secondary);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* 加载动画优化 */
|
| 145 |
+
.fa-spin {
|
| 146 |
+
animation: fa-spin 1s infinite linear;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
@keyframes fa-spin {
|
| 150 |
+
0% {
|
| 151 |
+
transform: rotate(0deg);
|
| 152 |
+
}
|
| 153 |
+
100% {
|
| 154 |
+
transform: rotate(360deg);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* 无障碍支持 */
|
| 159 |
+
.icon-clickable:focus-visible {
|
| 160 |
+
outline: 2px solid var(--accent-red);
|
| 161 |
+
outline-offset: 2px;
|
| 162 |
+
border-radius: 2px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* 禁用状态 */
|
| 166 |
+
.icon-disabled {
|
| 167 |
+
opacity: 0.3;
|
| 168 |
+
cursor: not-allowed;
|
| 169 |
+
pointer-events: none;
|
| 170 |
+
}
|
| 171 |
+
</style>
|
src/components/common/Loading.vue
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="loading-container" :class="{ overlay: overlay }">
|
| 3 |
+
<div class="loading-content">
|
| 4 |
+
<!-- 加载动画 -->
|
| 5 |
+
<div class="loading-spinner" :class="size">
|
| 6 |
+
<div v-for="i in 12" :key="i" class="spinner-bar" :style="{ '--delay': `${i * 0.1}s` }"></div>
|
| 7 |
+
</div>
|
| 8 |
+
|
| 9 |
+
<!-- 加载文本 -->
|
| 10 |
+
<div v-if="text" class="loading-text">{{ text }}</div>
|
| 11 |
+
</div>
|
| 12 |
+
</div>
|
| 13 |
+
</template>
|
| 14 |
+
|
| 15 |
+
<script setup>
|
| 16 |
+
defineProps({
|
| 17 |
+
// 是否显示遮罩层
|
| 18 |
+
overlay: {
|
| 19 |
+
type: Boolean,
|
| 20 |
+
default: false
|
| 21 |
+
},
|
| 22 |
+
|
| 23 |
+
// 加载提示文字
|
| 24 |
+
text: {
|
| 25 |
+
type: String,
|
| 26 |
+
default: ''
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
// 大小:small, medium, large
|
| 30 |
+
size: {
|
| 31 |
+
type: String,
|
| 32 |
+
default: 'medium',
|
| 33 |
+
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
| 34 |
+
}
|
| 35 |
+
})
|
| 36 |
+
</script>
|
| 37 |
+
|
| 38 |
+
<style scoped>
|
| 39 |
+
.loading-container {
|
| 40 |
+
display: flex;
|
| 41 |
+
align-items: center;
|
| 42 |
+
justify-content: center;
|
| 43 |
+
padding: 20px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.loading-container.overlay {
|
| 47 |
+
position: fixed;
|
| 48 |
+
top: 0;
|
| 49 |
+
left: 0;
|
| 50 |
+
right: 0;
|
| 51 |
+
bottom: 0;
|
| 52 |
+
background: rgba(0, 0, 0, 0.6);
|
| 53 |
+
backdrop-filter: blur(4px);
|
| 54 |
+
z-index: 9999;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.loading-content {
|
| 58 |
+
display: flex;
|
| 59 |
+
flex-direction: column;
|
| 60 |
+
align-items: center;
|
| 61 |
+
gap: 16px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.loading-spinner {
|
| 65 |
+
position: relative;
|
| 66 |
+
display: inline-block;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.loading-spinner.small {
|
| 70 |
+
width: 20px;
|
| 71 |
+
height: 20px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.loading-spinner.medium {
|
| 75 |
+
width: 32px;
|
| 76 |
+
height: 32px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.loading-spinner.large {
|
| 80 |
+
width: 48px;
|
| 81 |
+
height: 48px;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.spinner-bar {
|
| 85 |
+
position: absolute;
|
| 86 |
+
width: 2px;
|
| 87 |
+
height: 25%;
|
| 88 |
+
background: var(--accent-red);
|
| 89 |
+
border-radius: 1px;
|
| 90 |
+
opacity: 0;
|
| 91 |
+
animation: spin 1.2s linear infinite;
|
| 92 |
+
animation-delay: var(--delay);
|
| 93 |
+
transform-origin: 50% 200%;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.spinner-bar:nth-child(1) { transform: rotate(0deg); }
|
| 97 |
+
.spinner-bar:nth-child(2) { transform: rotate(30deg); }
|
| 98 |
+
.spinner-bar:nth-child(3) { transform: rotate(60deg); }
|
| 99 |
+
.spinner-bar:nth-child(4) { transform: rotate(90deg); }
|
| 100 |
+
.spinner-bar:nth-child(5) { transform: rotate(120deg); }
|
| 101 |
+
.spinner-bar:nth-child(6) { transform: rotate(150deg); }
|
| 102 |
+
.spinner-bar:nth-child(7) { transform: rotate(180deg); }
|
| 103 |
+
.spinner-bar:nth-child(8) { transform: rotate(210deg); }
|
| 104 |
+
.spinner-bar:nth-child(9) { transform: rotate(240deg); }
|
| 105 |
+
.spinner-bar:nth-child(10) { transform: rotate(270deg); }
|
| 106 |
+
.spinner-bar:nth-child(11) { transform: rotate(300deg); }
|
| 107 |
+
.spinner-bar:nth-child(12) { transform: rotate(330deg); }
|
| 108 |
+
|
| 109 |
+
@keyframes spin {
|
| 110 |
+
0%, 40%, 100% {
|
| 111 |
+
opacity: 0.3;
|
| 112 |
+
}
|
| 113 |
+
20% {
|
| 114 |
+
opacity: 1;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.loading-text {
|
| 119 |
+
font-size: 14px;
|
| 120 |
+
color: var(--text-secondary);
|
| 121 |
+
text-align: center;
|
| 122 |
+
font-weight: 500;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* 响应式 */
|
| 126 |
+
@media (max-width: 375px) {
|
| 127 |
+
.loading-container {
|
| 128 |
+
padding: 16px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.loading-content {
|
| 132 |
+
gap: 12px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.loading-text {
|
| 136 |
+
font-size: 13px;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
</style>
|
src/components/common/Modal.vue
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Teleport to="body">
|
| 3 |
+
<Transition name="modal" appear>
|
| 4 |
+
<div v-if="visible" class="modal-overlay" @click="handleOverlayClick">
|
| 5 |
+
<div
|
| 6 |
+
class="modal"
|
| 7 |
+
:class="size"
|
| 8 |
+
@click.stop
|
| 9 |
+
>
|
| 10 |
+
<!-- 头部 -->
|
| 11 |
+
<div class="modal-header" v-if="title || closable">
|
| 12 |
+
<h3 class="modal-title" v-if="title">{{ title }}</h3>
|
| 13 |
+
<button
|
| 14 |
+
v-if="closable"
|
| 15 |
+
class="modal-close-btn"
|
| 16 |
+
@click="handleClose"
|
| 17 |
+
>
|
| 18 |
+
<i class="fas fa-times"></i>
|
| 19 |
+
</button>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<!-- 内容 -->
|
| 23 |
+
<div class="modal-body">
|
| 24 |
+
<slot></slot>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<!-- 底部 -->
|
| 28 |
+
<div class="modal-footer" v-if="$slots.footer">
|
| 29 |
+
<slot name="footer"></slot>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</Transition>
|
| 34 |
+
</Teleport>
|
| 35 |
+
</template>
|
| 36 |
+
|
| 37 |
+
<script setup>
|
| 38 |
+
import { ref, onMounted, onUnmounted } from 'vue'
|
| 39 |
+
|
| 40 |
+
const props = defineProps({
|
| 41 |
+
// 标题
|
| 42 |
+
title: {
|
| 43 |
+
type: String,
|
| 44 |
+
default: ''
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
// 大小:small, medium, large
|
| 48 |
+
size: {
|
| 49 |
+
type: String,
|
| 50 |
+
default: 'medium',
|
| 51 |
+
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
// 是否可关闭
|
| 55 |
+
closable: {
|
| 56 |
+
type: Boolean,
|
| 57 |
+
default: true
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
// 点击遮罩是否关闭
|
| 61 |
+
maskClosable: {
|
| 62 |
+
type: Boolean,
|
| 63 |
+
default: true
|
| 64 |
+
},
|
| 65 |
+
|
| 66 |
+
// 是否锁定滚动
|
| 67 |
+
lockScroll: {
|
| 68 |
+
type: Boolean,
|
| 69 |
+
default: true
|
| 70 |
+
}
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
const emit = defineEmits(['close', 'open'])
|
| 74 |
+
|
| 75 |
+
const visible = ref(false)
|
| 76 |
+
|
| 77 |
+
// 处理遮罩点击
|
| 78 |
+
const handleOverlayClick = () => {
|
| 79 |
+
if (props.maskClosable) {
|
| 80 |
+
handleClose()
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// 处理关闭
|
| 85 |
+
const handleClose = () => {
|
| 86 |
+
close()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 处理键盘事件
|
| 90 |
+
const handleKeyDown = (event) => {
|
| 91 |
+
if (event.key === 'Escape' && props.closable) {
|
| 92 |
+
handleClose()
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// 锁定/解锁滚动
|
| 97 |
+
const toggleScrollLock = (lock) => {
|
| 98 |
+
if (!props.lockScroll) return
|
| 99 |
+
|
| 100 |
+
if (lock) {
|
| 101 |
+
document.body.style.overflow = 'hidden'
|
| 102 |
+
} else {
|
| 103 |
+
document.body.style.overflow = ''
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// 显示模态框
|
| 108 |
+
const open = () => {
|
| 109 |
+
visible.value = true
|
| 110 |
+
toggleScrollLock(true)
|
| 111 |
+
document.addEventListener('keydown', handleKeyDown)
|
| 112 |
+
emit('open')
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// 关闭模态框
|
| 116 |
+
const close = () => {
|
| 117 |
+
visible.value = false
|
| 118 |
+
toggleScrollLock(false)
|
| 119 |
+
document.removeEventListener('keydown', handleKeyDown)
|
| 120 |
+
emit('close')
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// 生命周期
|
| 124 |
+
onMounted(() => {
|
| 125 |
+
open()
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
onUnmounted(() => {
|
| 129 |
+
toggleScrollLock(false)
|
| 130 |
+
document.removeEventListener('keydown', handleKeyDown)
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
// 暴露方法
|
| 134 |
+
defineExpose({
|
| 135 |
+
open,
|
| 136 |
+
close
|
| 137 |
+
})
|
| 138 |
+
</script>
|
| 139 |
+
|
| 140 |
+
<style scoped>
|
| 141 |
+
.modal-overlay {
|
| 142 |
+
position: fixed;
|
| 143 |
+
top: 0;
|
| 144 |
+
left: 0;
|
| 145 |
+
right: 0;
|
| 146 |
+
bottom: 0;
|
| 147 |
+
background: rgba(0, 0, 0, 0.6);
|
| 148 |
+
backdrop-filter: blur(4px);
|
| 149 |
+
z-index: 2000;
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center;
|
| 152 |
+
justify-content: center;
|
| 153 |
+
padding: 20px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.modal {
|
| 157 |
+
background: var(--bg-secondary);
|
| 158 |
+
border-radius: 16px;
|
| 159 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 160 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 161 |
+
max-height: calc(100vh - 40px);
|
| 162 |
+
overflow: hidden;
|
| 163 |
+
display: flex;
|
| 164 |
+
flex-direction: column;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.modal.small {
|
| 168 |
+
max-width: 400px;
|
| 169 |
+
width: 90%;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.modal.medium {
|
| 173 |
+
max-width: 600px;
|
| 174 |
+
width: 90%;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.modal.large {
|
| 178 |
+
max-width: 800px;
|
| 179 |
+
width: 95%;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.modal-header {
|
| 183 |
+
display: flex;
|
| 184 |
+
align-items: center;
|
| 185 |
+
justify-content: space-between;
|
| 186 |
+
padding: 20px 24px 16px;
|
| 187 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 188 |
+
flex-shrink: 0;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.modal-title {
|
| 192 |
+
font-size: 18px;
|
| 193 |
+
font-weight: 600;
|
| 194 |
+
color: var(--text-primary);
|
| 195 |
+
margin: 0;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.modal-close-btn {
|
| 199 |
+
width: 32px;
|
| 200 |
+
height: 32px;
|
| 201 |
+
border: none;
|
| 202 |
+
background: rgba(255, 255, 255, 0.1);
|
| 203 |
+
color: var(--text-secondary);
|
| 204 |
+
border-radius: 50%;
|
| 205 |
+
display: flex;
|
| 206 |
+
align-items: center;
|
| 207 |
+
justify-content: center;
|
| 208 |
+
cursor: pointer;
|
| 209 |
+
transition: var(--transition-fast);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.modal-close-btn:hover {
|
| 213 |
+
background: rgba(255, 255, 255, 0.2);
|
| 214 |
+
color: var(--text-primary);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.modal-body {
|
| 218 |
+
flex: 1;
|
| 219 |
+
padding: 20px 24px;
|
| 220 |
+
overflow-y: auto;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.modal-footer {
|
| 224 |
+
padding: 16px 24px 20px;
|
| 225 |
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
| 226 |
+
flex-shrink: 0;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/* 动画 */
|
| 230 |
+
.modal-enter-active,
|
| 231 |
+
.modal-leave-active {
|
| 232 |
+
transition: all 0.3s ease-out;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.modal-enter-from,
|
| 236 |
+
.modal-leave-to {
|
| 237 |
+
opacity: 0;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.modal-enter-from .modal,
|
| 241 |
+
.modal-leave-to .modal {
|
| 242 |
+
transform: scale(0.9) translateY(-20px);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/* 响应式 */
|
| 246 |
+
@media (max-width: 375px) {
|
| 247 |
+
.modal-overlay {
|
| 248 |
+
padding: 16px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.modal {
|
| 252 |
+
border-radius: 12px;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.modal.small,
|
| 256 |
+
.modal.medium,
|
| 257 |
+
.modal.large {
|
| 258 |
+
width: 100%;
|
| 259 |
+
max-width: none;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.modal-header {
|
| 263 |
+
padding: 16px 20px 12px;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.modal-title {
|
| 267 |
+
font-size: 16px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.modal-close-btn {
|
| 271 |
+
width: 28px;
|
| 272 |
+
height: 28px;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.modal-body {
|
| 276 |
+
padding: 16px 20px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.modal-footer {
|
| 280 |
+
padding: 12px 20px 16px;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/* 滚动条样式 */
|
| 285 |
+
.modal-body::-webkit-scrollbar {
|
| 286 |
+
width: 6px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.modal-body::-webkit-scrollbar-track {
|
| 290 |
+
background: transparent;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.modal-body::-webkit-scrollbar-thumb {
|
| 294 |
+
background: rgba(255, 255, 255, 0.2);
|
| 295 |
+
border-radius: 3px;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.modal-body::-webkit-scrollbar-thumb:hover {
|
| 299 |
+
background: rgba(255, 255, 255, 0.3);
|
| 300 |
+
}
|
| 301 |
+
</style>
|
src/components/common/Toast.vue
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Teleport to="body">
|
| 3 |
+
<Transition name="toast" appear>
|
| 4 |
+
<div
|
| 5 |
+
v-if="visible && message"
|
| 6 |
+
class="toast"
|
| 7 |
+
:class="[type, position]"
|
| 8 |
+
@click="handleClick"
|
| 9 |
+
>
|
| 10 |
+
<div class="toast-content">
|
| 11 |
+
<!-- 图标 -->
|
| 12 |
+
<div class="toast-icon" v-if="showIcon">
|
| 13 |
+
<i :class="iconClass"></i>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- 消息内容 -->
|
| 17 |
+
<div class="toast-message">{{ message }}</div>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</Transition>
|
| 21 |
+
</Teleport>
|
| 22 |
+
</template>
|
| 23 |
+
|
| 24 |
+
<script setup>
|
| 25 |
+
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
| 26 |
+
|
| 27 |
+
const props = defineProps({
|
| 28 |
+
// 消息内容
|
| 29 |
+
message: {
|
| 30 |
+
type: String,
|
| 31 |
+
default: ''
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
// 类型:success, error, warning, info
|
| 35 |
+
type: {
|
| 36 |
+
type: String,
|
| 37 |
+
default: 'info',
|
| 38 |
+
validator: (value) => ['success', 'error', 'warning', 'info'].includes(value)
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
// 显示时长(毫秒)
|
| 42 |
+
duration: {
|
| 43 |
+
type: Number,
|
| 44 |
+
default: 3000
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
// 位置:top, center, bottom
|
| 48 |
+
position: {
|
| 49 |
+
type: String,
|
| 50 |
+
default: 'top',
|
| 51 |
+
validator: (value) => ['top', 'center', 'bottom'].includes(value)
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
// 是否显示图标
|
| 55 |
+
showIcon: {
|
| 56 |
+
type: Boolean,
|
| 57 |
+
default: true
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
// 是否可点击关闭
|
| 61 |
+
closable: {
|
| 62 |
+
type: Boolean,
|
| 63 |
+
default: true
|
| 64 |
+
}
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
const emit = defineEmits(['close'])
|
| 68 |
+
|
| 69 |
+
const visible = ref(false)
|
| 70 |
+
let timer = null
|
| 71 |
+
|
| 72 |
+
// 图标映射
|
| 73 |
+
const iconClass = computed(() => {
|
| 74 |
+
const iconMap = {
|
| 75 |
+
success: 'fas fa-check-circle',
|
| 76 |
+
error: 'fas fa-times-circle',
|
| 77 |
+
warning: 'fas fa-exclamation-triangle',
|
| 78 |
+
info: 'fas fa-info-circle'
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return iconMap[props.type] || iconMap.info
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
// 处理点击事件
|
| 85 |
+
const handleClick = () => {
|
| 86 |
+
if (props.closable) {
|
| 87 |
+
close()
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// 显示Toast
|
| 92 |
+
const show = () => {
|
| 93 |
+
visible.value = true
|
| 94 |
+
|
| 95 |
+
if (props.duration > 0) {
|
| 96 |
+
timer = setTimeout(() => {
|
| 97 |
+
close()
|
| 98 |
+
}, props.duration)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// 关闭Toast
|
| 103 |
+
const close = () => {
|
| 104 |
+
visible.value = false
|
| 105 |
+
if (timer) {
|
| 106 |
+
clearTimeout(timer)
|
| 107 |
+
timer = null
|
| 108 |
+
}
|
| 109 |
+
emit('close')
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 生命周期
|
| 113 |
+
onMounted(() => {
|
| 114 |
+
show()
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
onUnmounted(() => {
|
| 118 |
+
if (timer) {
|
| 119 |
+
clearTimeout(timer)
|
| 120 |
+
}
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
// 暴露方法给父组件
|
| 124 |
+
defineExpose({
|
| 125 |
+
close
|
| 126 |
+
})
|
| 127 |
+
</script>
|
| 128 |
+
|
| 129 |
+
<style scoped>
|
| 130 |
+
.toast {
|
| 131 |
+
position: fixed;
|
| 132 |
+
left: 50%;
|
| 133 |
+
transform: translateX(-50%);
|
| 134 |
+
z-index: 10000;
|
| 135 |
+
max-width: calc(100vw - 32px);
|
| 136 |
+
min-width: 200px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.toast.top {
|
| 140 |
+
top: 20px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.toast.center {
|
| 144 |
+
top: 50%;
|
| 145 |
+
transform: translate(-50%, -50%);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.toast.bottom {
|
| 149 |
+
bottom: 20px;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.toast-content {
|
| 153 |
+
display: flex;
|
| 154 |
+
align-items: center;
|
| 155 |
+
gap: 12px;
|
| 156 |
+
padding: 12px 16px;
|
| 157 |
+
background: var(--bg-card);
|
| 158 |
+
backdrop-filter: blur(20px);
|
| 159 |
+
border-radius: 8px;
|
| 160 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 161 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.toast-icon {
|
| 165 |
+
flex-shrink: 0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.toast-icon i {
|
| 169 |
+
font-size: 16px;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.toast-message {
|
| 173 |
+
font-size: 14px;
|
| 174 |
+
font-weight: 500;
|
| 175 |
+
color: var(--text-primary);
|
| 176 |
+
line-height: 1.4;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/* 类型样式 */
|
| 180 |
+
.toast.success .toast-icon i {
|
| 181 |
+
color: #48bb78;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.toast.success .toast-content {
|
| 185 |
+
border-color: rgba(72, 187, 120, 0.3);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.toast.error .toast-icon i {
|
| 189 |
+
color: #e53e3e;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.toast.error .toast-content {
|
| 193 |
+
border-color: rgba(229, 62, 62, 0.3);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.toast.warning .toast-icon i {
|
| 197 |
+
color: #ed8936;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.toast.warning .toast-content {
|
| 201 |
+
border-color: rgba(237, 137, 54, 0.3);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.toast.info .toast-icon i {
|
| 205 |
+
color: #3182ce;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.toast.info .toast-content {
|
| 209 |
+
border-color: rgba(49, 130, 206, 0.3);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* 动画 */
|
| 213 |
+
.toast-enter-active,
|
| 214 |
+
.toast-leave-active {
|
| 215 |
+
transition: all 0.3s ease-out;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.toast-enter-from {
|
| 219 |
+
opacity: 0;
|
| 220 |
+
transform: translateX(-50%) translateY(-20px);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.toast.center.toast-enter-from {
|
| 224 |
+
transform: translate(-50%, -50%) scale(0.9);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.toast.bottom.toast-enter-from {
|
| 228 |
+
transform: translateX(-50%) translateY(20px);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.toast-leave-to {
|
| 232 |
+
opacity: 0;
|
| 233 |
+
transform: translateX(-50%) translateY(-20px);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.toast.center.toast-leave-to {
|
| 237 |
+
transform: translate(-50%, -50%) scale(0.9);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.toast.bottom.toast-leave-to {
|
| 241 |
+
transform: translateX(-50%) translateY(20px);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* 响应式 */
|
| 245 |
+
@media (max-width: 375px) {
|
| 246 |
+
.toast {
|
| 247 |
+
max-width: calc(100vw - 24px);
|
| 248 |
+
min-width: auto;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.toast-content {
|
| 252 |
+
padding: 10px 14px;
|
| 253 |
+
gap: 10px;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.toast-icon i {
|
| 257 |
+
font-size: 14px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.toast-message {
|
| 261 |
+
font-size: 13px;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/* 触摸反馈 */
|
| 266 |
+
@media (hover: none) {
|
| 267 |
+
.toast-content:active {
|
| 268 |
+
transform: scale(0.98);
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
</style>
|
src/components/favorites/FavoriteButton.vue
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<button
|
| 3 |
+
class="favorite-button"
|
| 4 |
+
:class="{
|
| 5 |
+
'favorited': isFavorited,
|
| 6 |
+
'loading': loading,
|
| 7 |
+
[`size-${size}`]: size
|
| 8 |
+
}"
|
| 9 |
+
@click="handleToggle"
|
| 10 |
+
:disabled="loading"
|
| 11 |
+
:title="isFavorited ? '取消收藏' : '添加收藏'"
|
| 12 |
+
>
|
| 13 |
+
<i
|
| 14 |
+
:class="iconClass"
|
| 15 |
+
class="heart-icon"
|
| 16 |
+
></i>
|
| 17 |
+
<span v-if="showText" class="favorite-text">
|
| 18 |
+
{{ isFavorited ? '已收藏' : '收藏' }}
|
| 19 |
+
</span>
|
| 20 |
+
</button>
|
| 21 |
+
</template>
|
| 22 |
+
|
| 23 |
+
<script setup>
|
| 24 |
+
import { ref, computed } from 'vue'
|
| 25 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 26 |
+
|
| 27 |
+
const props = defineProps({
|
| 28 |
+
// 歌曲信息
|
| 29 |
+
song: {
|
| 30 |
+
type: Object,
|
| 31 |
+
required: true
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
// 按钮大小
|
| 35 |
+
size: {
|
| 36 |
+
type: String,
|
| 37 |
+
default: 'medium', // small, medium, large
|
| 38 |
+
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
// 是否显示文字
|
| 42 |
+
showText: {
|
| 43 |
+
type: Boolean,
|
| 44 |
+
default: false
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
// 自定义样式类
|
| 48 |
+
variant: {
|
| 49 |
+
type: String,
|
| 50 |
+
default: 'default', // default, minimal, filled
|
| 51 |
+
validator: (value) => ['default', 'minimal', 'filled'].includes(value)
|
| 52 |
+
}
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
const emit = defineEmits(['toggle', 'favorited', 'unfavorited'])
|
| 56 |
+
|
| 57 |
+
const favoritesStore = useFavoritesStore()
|
| 58 |
+
const loading = ref(false)
|
| 59 |
+
|
| 60 |
+
// 是否已收藏
|
| 61 |
+
const isFavorited = computed(() => {
|
| 62 |
+
return favoritesStore.isFavorite(props.song.id)
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
// 图标类名
|
| 66 |
+
const iconClass = computed(() => {
|
| 67 |
+
if (loading.value) {
|
| 68 |
+
return 'fas fa-spinner fa-spin'
|
| 69 |
+
}
|
| 70 |
+
return isFavorited.value ? 'fas fa-heart' : 'far fa-heart'
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
// 切换收藏状态
|
| 74 |
+
const handleToggle = async () => {
|
| 75 |
+
if (loading.value) return
|
| 76 |
+
|
| 77 |
+
loading.value = true
|
| 78 |
+
|
| 79 |
+
try {
|
| 80 |
+
if (isFavorited.value) {
|
| 81 |
+
await favoritesStore.removeFavorite(props.song.id)
|
| 82 |
+
emit('unfavorited', props.song)
|
| 83 |
+
} else {
|
| 84 |
+
await favoritesStore.addFavorite(props.song)
|
| 85 |
+
emit('favorited', props.song)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
emit('toggle', {
|
| 89 |
+
song: props.song,
|
| 90 |
+
favorited: !isFavorited.value
|
| 91 |
+
})
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error('收藏操作失败:', error)
|
| 94 |
+
} finally {
|
| 95 |
+
setTimeout(() => {
|
| 96 |
+
loading.value = false
|
| 97 |
+
}, 300) // 延迟一点显示动画效果
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
</script>
|
| 101 |
+
|
| 102 |
+
<style scoped>
|
| 103 |
+
.favorite-button {
|
| 104 |
+
display: inline-flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
justify-content: center;
|
| 107 |
+
gap: 6px;
|
| 108 |
+
border: none;
|
| 109 |
+
background: transparent;
|
| 110 |
+
color: var(--text-secondary);
|
| 111 |
+
cursor: pointer;
|
| 112 |
+
transition: var(--transition-fast);
|
| 113 |
+
border-radius: 50%;
|
| 114 |
+
padding: 8px;
|
| 115 |
+
min-width: 44px;
|
| 116 |
+
min-height: 44px;
|
| 117 |
+
position: relative;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.favorite-button:hover {
|
| 121 |
+
color: var(--text-primary);
|
| 122 |
+
background: rgba(255, 255, 255, 0.1);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.favorite-button:disabled {
|
| 126 |
+
cursor: not-allowed;
|
| 127 |
+
opacity: 0.6;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.heart-icon {
|
| 131 |
+
font-size: 16px;
|
| 132 |
+
transition: all var(--transition-fast);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.favorite-text {
|
| 136 |
+
font-size: 12px;
|
| 137 |
+
font-weight: 500;
|
| 138 |
+
white-space: nowrap;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* 收藏状态 */
|
| 142 |
+
.favorite-button.favorited {
|
| 143 |
+
color: var(--accent-red);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.favorite-button.favorited:hover {
|
| 147 |
+
color: var(--accent-red-hover);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.favorite-button.favorited .heart-icon {
|
| 151 |
+
animation: heart-beat 0.3s ease-in-out;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
@keyframes heart-beat {
|
| 155 |
+
0% {
|
| 156 |
+
transform: scale(1);
|
| 157 |
+
}
|
| 158 |
+
50% {
|
| 159 |
+
transform: scale(1.2);
|
| 160 |
+
}
|
| 161 |
+
100% {
|
| 162 |
+
transform: scale(1);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* 尺寸变化 */
|
| 167 |
+
.favorite-button.size-small {
|
| 168 |
+
min-width: 36px;
|
| 169 |
+
min-height: 36px;
|
| 170 |
+
padding: 6px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.favorite-button.size-small .heart-icon {
|
| 174 |
+
font-size: 14px;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.favorite-button.size-small .favorite-text {
|
| 178 |
+
font-size: 11px;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.favorite-button.size-large {
|
| 182 |
+
min-width: 52px;
|
| 183 |
+
min-height: 52px;
|
| 184 |
+
padding: 10px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.favorite-button.size-large .heart-icon {
|
| 188 |
+
font-size: 18px;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.favorite-button.size-large .favorite-text {
|
| 192 |
+
font-size: 13px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* 样式变体 */
|
| 196 |
+
.favorite-button.variant-minimal {
|
| 197 |
+
background: none;
|
| 198 |
+
padding: 4px;
|
| 199 |
+
min-width: auto;
|
| 200 |
+
min-height: auto;
|
| 201 |
+
border-radius: 4px;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.favorite-button.variant-minimal:hover {
|
| 205 |
+
background: none;
|
| 206 |
+
color: var(--accent-red);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.favorite-button.variant-filled {
|
| 210 |
+
background: rgba(255, 255, 255, 0.1);
|
| 211 |
+
border-radius: 8px;
|
| 212 |
+
padding: 8px 12px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.favorite-button.variant-filled:hover {
|
| 216 |
+
background: rgba(255, 255, 255, 0.15);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.favorite-button.variant-filled.favorited {
|
| 220 |
+
background: var(--accent-red);
|
| 221 |
+
color: white;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.favorite-button.variant-filled.favorited:hover {
|
| 225 |
+
background: var(--accent-red-hover);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* 加载状态 */
|
| 229 |
+
.favorite-button.loading .heart-icon {
|
| 230 |
+
opacity: 0.6;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* 响应式 */
|
| 234 |
+
@media (max-width: 375px) {
|
| 235 |
+
.favorite-button {
|
| 236 |
+
min-width: 40px;
|
| 237 |
+
min-height: 40px;
|
| 238 |
+
padding: 6px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.heart-icon {
|
| 242 |
+
font-size: 14px;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.favorite-text {
|
| 246 |
+
font-size: 11px;
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* 按下效果 */
|
| 251 |
+
.favorite-button:active {
|
| 252 |
+
transform: scale(0.95);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* 收藏成功动画 */
|
| 256 |
+
.favorite-button.favorited .heart-icon::after {
|
| 257 |
+
content: '';
|
| 258 |
+
position: absolute;
|
| 259 |
+
top: 50%;
|
| 260 |
+
left: 50%;
|
| 261 |
+
width: 0;
|
| 262 |
+
height: 0;
|
| 263 |
+
border-radius: 50%;
|
| 264 |
+
background: var(--accent-red);
|
| 265 |
+
opacity: 0;
|
| 266 |
+
animation: heart-ripple 0.6s ease-out;
|
| 267 |
+
transform: translate(-50%, -50%);
|
| 268 |
+
pointer-events: none;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
@keyframes heart-ripple {
|
| 272 |
+
0% {
|
| 273 |
+
width: 0;
|
| 274 |
+
height: 0;
|
| 275 |
+
opacity: 0.8;
|
| 276 |
+
}
|
| 277 |
+
100% {
|
| 278 |
+
width: 40px;
|
| 279 |
+
height: 40px;
|
| 280 |
+
opacity: 0;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
</style>
|
src/components/favorites/FavoriteItem.vue
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div
|
| 3 |
+
class="favorite-item"
|
| 4 |
+
:class="{
|
| 5 |
+
'playing': isCurrentSong,
|
| 6 |
+
'selected': isSelected
|
| 7 |
+
}"
|
| 8 |
+
@click="handlePlay"
|
| 9 |
+
>
|
| 10 |
+
<!-- 专辑封面 -->
|
| 11 |
+
<div class="album-cover">
|
| 12 |
+
<img
|
| 13 |
+
:src="albumCoverUrl"
|
| 14 |
+
:alt="song.album"
|
| 15 |
+
@error="handleImageError"
|
| 16 |
+
loading="lazy"
|
| 17 |
+
>
|
| 18 |
+
<div class="play-indicator" v-if="isCurrentSong">
|
| 19 |
+
<i :class="playIconClass"></i>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- 歌曲信息 -->
|
| 24 |
+
<div class="song-info">
|
| 25 |
+
<h3 class="song-name">{{ song.name }}</h3>
|
| 26 |
+
<p class="song-meta">
|
| 27 |
+
<span class="artist">{{ formatArtist(song.artist) }}</span>
|
| 28 |
+
<span class="separator">•</span>
|
| 29 |
+
<span class="album">{{ song.album }}</span>
|
| 30 |
+
</p>
|
| 31 |
+
<div class="song-extra">
|
| 32 |
+
<span class="source">{{ getSourceName(song.source) }}</span>
|
| 33 |
+
<span class="separator">•</span>
|
| 34 |
+
<span class="favorite-time">{{ formatFavoriteTime(favoriteTime) }}</span>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- 操作按钮 -->
|
| 39 |
+
<div class="item-actions">
|
| 40 |
+
<!-- 收藏时间或播放次数 -->
|
| 41 |
+
<div class="play-count" v-if="playCount > 0">
|
| 42 |
+
<i class="fas fa-play"></i>
|
| 43 |
+
<span>{{ formatPlayCount(playCount) }}</span>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<!-- 更多操作按钮 -->
|
| 47 |
+
<button class="more-btn" @click.stop="showMoreActions" :title="'更多操作'">
|
| 48 |
+
<i class="fas fa-ellipsis-v"></i>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<!-- 取消收藏按钮 -->
|
| 52 |
+
<FavoriteButton
|
| 53 |
+
:song="song"
|
| 54 |
+
size="small"
|
| 55 |
+
variant="minimal"
|
| 56 |
+
@unfavorited="handleUnfavorited"
|
| 57 |
+
/>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<!-- 更多操作菜单 -->
|
| 61 |
+
<div class="more-menu" v-if="showMenu" @click.stop>
|
| 62 |
+
<button @click="handlePlayNext">下一首播放</button>
|
| 63 |
+
<button @click="handleAddToPlaylist">添加到播放列表</button>
|
| 64 |
+
<button @click="handleCopyLink">复制链接</button>
|
| 65 |
+
<button @click="handleViewDetails">查看详情</button>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</template>
|
| 69 |
+
|
| 70 |
+
<script setup>
|
| 71 |
+
import { ref, computed } from 'vue'
|
| 72 |
+
import { usePlayerStore } from '@/stores/player'
|
| 73 |
+
import { useHistoryStore } from '@/stores/history'
|
| 74 |
+
import FavoriteButton from './FavoriteButton.vue'
|
| 75 |
+
|
| 76 |
+
const props = defineProps({
|
| 77 |
+
// 歌曲信息
|
| 78 |
+
song: {
|
| 79 |
+
type: Object,
|
| 80 |
+
required: true
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
// 收藏时间
|
| 84 |
+
favoriteTime: {
|
| 85 |
+
type: Number,
|
| 86 |
+
default: Date.now
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
// 播放次数
|
| 90 |
+
playCount: {
|
| 91 |
+
type: Number,
|
| 92 |
+
default: 0
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
// 是否被选中
|
| 96 |
+
isSelected: {
|
| 97 |
+
type: Boolean,
|
| 98 |
+
default: false
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
// 显示模式
|
| 102 |
+
layout: {
|
| 103 |
+
type: String,
|
| 104 |
+
default: 'default', // default, compact, detailed
|
| 105 |
+
validator: (value) => ['default', 'compact', 'detailed'].includes(value)
|
| 106 |
+
}
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
const emit = defineEmits(['play', 'unfavorited', 'more-action'])
|
| 110 |
+
|
| 111 |
+
const playerStore = usePlayerStore()
|
| 112 |
+
const historyStore = useHistoryStore()
|
| 113 |
+
const showMenu = ref(false)
|
| 114 |
+
|
| 115 |
+
// 专辑封面URL
|
| 116 |
+
const albumCoverUrl = computed(() => {
|
| 117 |
+
if (props.song.pic_id) {
|
| 118 |
+
return `https://music-api.gdstudio.xyz/api.php?types=pic&source=${props.song.source}&id=${props.song.pic_id}&size=300`
|
| 119 |
+
}
|
| 120 |
+
return '/default-album.png'
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
// 是否是当前播放歌曲
|
| 124 |
+
const isCurrentSong = computed(() => {
|
| 125 |
+
return playerStore.currentSong?.id === props.song.id
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
// 播放图标类名
|
| 129 |
+
const playIconClass = computed(() => {
|
| 130 |
+
if (playerStore.isPlaying && isCurrentSong.value) {
|
| 131 |
+
return 'fas fa-pause'
|
| 132 |
+
}
|
| 133 |
+
return 'fas fa-play'
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
// 格式化歌手名
|
| 137 |
+
const formatArtist = (artist) => {
|
| 138 |
+
if (Array.isArray(artist)) {
|
| 139 |
+
return artist.join('/')
|
| 140 |
+
}
|
| 141 |
+
return artist || '未知歌手'
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// 获取音乐源名称
|
| 145 |
+
const getSourceName = (source) => {
|
| 146 |
+
const sourceMap = {
|
| 147 |
+
'netease': '网易云',
|
| 148 |
+
'tencent': 'QQ音乐',
|
| 149 |
+
'kugou': '酷狗',
|
| 150 |
+
'kuwo': '酷我',
|
| 151 |
+
'migu': '咪咕',
|
| 152 |
+
'spotify': 'Spotify',
|
| 153 |
+
'apple': 'Apple',
|
| 154 |
+
'ytmusic': 'YouTube',
|
| 155 |
+
'joox': 'JOOX',
|
| 156 |
+
'tidal': 'TIDAL',
|
| 157 |
+
'deezer': 'Deezer',
|
| 158 |
+
'qobuz': 'Qobuz',
|
| 159 |
+
'ximalaya': '喜马拉雅'
|
| 160 |
+
}
|
| 161 |
+
return sourceMap[source] || source
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// 格式化收藏时间
|
| 165 |
+
const formatFavoriteTime = (timestamp) => {
|
| 166 |
+
const now = Date.now()
|
| 167 |
+
const diff = now - timestamp
|
| 168 |
+
|
| 169 |
+
if (diff < 60000) {
|
| 170 |
+
return '刚刚收藏'
|
| 171 |
+
} else if (diff < 3600000) {
|
| 172 |
+
return `${Math.floor(diff / 60000)}分钟前收藏`
|
| 173 |
+
} else if (diff < 86400000) {
|
| 174 |
+
return `${Math.floor(diff / 3600000)}小时前收藏`
|
| 175 |
+
} else if (diff < 2592000000) {
|
| 176 |
+
return `${Math.floor(diff / 86400000)}天前收藏`
|
| 177 |
+
} else {
|
| 178 |
+
return new Date(timestamp).toLocaleDateString()
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// 格式化播放次数
|
| 183 |
+
const formatPlayCount = (count) => {
|
| 184 |
+
if (count >= 10000) {
|
| 185 |
+
return `${(count / 10000).toFixed(1)}万`
|
| 186 |
+
} else if (count >= 1000) {
|
| 187 |
+
return `${(count / 1000).toFixed(1)}k`
|
| 188 |
+
}
|
| 189 |
+
return count.toString()
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// 处理图片加载错误
|
| 193 |
+
const handleImageError = (event) => {
|
| 194 |
+
event.target.src = '/default-album.png'
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// 播放歌曲
|
| 198 |
+
const handlePlay = () => {
|
| 199 |
+
if (isCurrentSong.value) {
|
| 200 |
+
playerStore.togglePlay()
|
| 201 |
+
} else {
|
| 202 |
+
playerStore.playSong(props.song)
|
| 203 |
+
historyStore.addToHistory(props.song)
|
| 204 |
+
}
|
| 205 |
+
emit('play', props.song)
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// 取消收藏
|
| 209 |
+
const handleUnfavorited = () => {
|
| 210 |
+
emit('unfavorited', props.song)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// 显示更多操作
|
| 214 |
+
const showMoreActions = () => {
|
| 215 |
+
showMenu.value = !showMenu.value
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// 下一首播放
|
| 219 |
+
const handlePlayNext = () => {
|
| 220 |
+
playerStore.playNext(props.song)
|
| 221 |
+
showMenu.value = false
|
| 222 |
+
emit('more-action', { action: 'play-next', song: props.song })
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// 添加到播放列表
|
| 226 |
+
const handleAddToPlaylist = () => {
|
| 227 |
+
playerStore.addToPlaylist(props.song)
|
| 228 |
+
showMenu.value = false
|
| 229 |
+
emit('more-action', { action: 'add-to-playlist', song: props.song })
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// 复制链接
|
| 233 |
+
const handleCopyLink = () => {
|
| 234 |
+
const url = `${window.location.origin}/?play=${props.song.source}&id=${props.song.id}`
|
| 235 |
+
navigator.clipboard.writeText(url)
|
| 236 |
+
showMenu.value = false
|
| 237 |
+
emit('more-action', { action: 'copy-link', song: props.song })
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// 查看详情
|
| 241 |
+
const handleViewDetails = () => {
|
| 242 |
+
showMenu.value = false
|
| 243 |
+
emit('more-action', { action: 'view-details', song: props.song })
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// 点击外部关闭菜单
|
| 247 |
+
document.addEventListener('click', () => {
|
| 248 |
+
showMenu.value = false
|
| 249 |
+
})
|
| 250 |
+
</script>
|
| 251 |
+
|
| 252 |
+
<style scoped>
|
| 253 |
+
.favorite-item {
|
| 254 |
+
display: flex;
|
| 255 |
+
align-items: center;
|
| 256 |
+
padding: 12px 16px;
|
| 257 |
+
cursor: pointer;
|
| 258 |
+
transition: var(--transition-fast);
|
| 259 |
+
position: relative;
|
| 260 |
+
background: transparent;
|
| 261 |
+
border-radius: 8px;
|
| 262 |
+
margin-bottom: 4px;
|
| 263 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.favorite-item:hover {
|
| 267 |
+
background: rgba(255, 255, 255, 0.05);
|
| 268 |
+
border-bottom: 1px solid var(--border-light);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.favorite-item.playing {
|
| 272 |
+
background: rgba(255, 107, 107, 0.1);
|
| 273 |
+
border-bottom: 1px solid var(--accent-red);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.favorite-item.selected {
|
| 277 |
+
background: rgba(255, 255, 255, 0.1);
|
| 278 |
+
border-bottom: 1px solid var(--border-light);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.album-cover {
|
| 282 |
+
width: 48px;
|
| 283 |
+
height: 48px;
|
| 284 |
+
border-radius: 6px;
|
| 285 |
+
overflow: hidden;
|
| 286 |
+
margin-right: 12px;
|
| 287 |
+
position: relative;
|
| 288 |
+
flex-shrink: 0;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.album-cover img {
|
| 292 |
+
width: 100%;
|
| 293 |
+
height: 100%;
|
| 294 |
+
object-fit: cover;
|
| 295 |
+
transition: var(--transition-fast);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.favorite-item:hover .album-cover img {
|
| 299 |
+
transform: scale(1.05);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.play-indicator {
|
| 303 |
+
position: absolute;
|
| 304 |
+
top: 0;
|
| 305 |
+
left: 0;
|
| 306 |
+
right: 0;
|
| 307 |
+
bottom: 0;
|
| 308 |
+
background: rgba(0, 0, 0, 0.7);
|
| 309 |
+
display: flex;
|
| 310 |
+
align-items: center;
|
| 311 |
+
justify-content: center;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.play-indicator i {
|
| 315 |
+
color: white;
|
| 316 |
+
font-size: 14px;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.song-info {
|
| 320 |
+
flex: 1;
|
| 321 |
+
min-width: 0;
|
| 322 |
+
margin-right: 12px;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.song-name {
|
| 326 |
+
font-size: 14px;
|
| 327 |
+
font-weight: 500;
|
| 328 |
+
color: var(--text-primary);
|
| 329 |
+
margin: 0 0 4px 0;
|
| 330 |
+
white-space: nowrap;
|
| 331 |
+
overflow: hidden;
|
| 332 |
+
text-overflow: ellipsis;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.favorite-item.playing .song-name {
|
| 336 |
+
color: var(--accent-red);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.song-meta {
|
| 340 |
+
font-size: 12px;
|
| 341 |
+
color: var(--text-secondary);
|
| 342 |
+
margin: 0 0 2px 0;
|
| 343 |
+
white-space: nowrap;
|
| 344 |
+
overflow: hidden;
|
| 345 |
+
text-overflow: ellipsis;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.song-extra {
|
| 349 |
+
font-size: 11px;
|
| 350 |
+
color: var(--text-tertiary);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.separator {
|
| 354 |
+
margin: 0 6px;
|
| 355 |
+
opacity: 0.5;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.item-actions {
|
| 359 |
+
display: flex;
|
| 360 |
+
align-items: center;
|
| 361 |
+
gap: 8px;
|
| 362 |
+
flex-shrink: 0;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.play-count {
|
| 366 |
+
display: flex;
|
| 367 |
+
align-items: center;
|
| 368 |
+
gap: 4px;
|
| 369 |
+
color: var(--text-tertiary);
|
| 370 |
+
font-size: 11px;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.play-count i {
|
| 374 |
+
font-size: 10px;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.more-btn {
|
| 378 |
+
width: 28px;
|
| 379 |
+
height: 28px;
|
| 380 |
+
border: none;
|
| 381 |
+
background: transparent;
|
| 382 |
+
color: var(--text-tertiary);
|
| 383 |
+
border-radius: 50%;
|
| 384 |
+
display: flex;
|
| 385 |
+
align-items: center;
|
| 386 |
+
justify-content: center;
|
| 387 |
+
cursor: pointer;
|
| 388 |
+
transition: var(--transition-fast);
|
| 389 |
+
opacity: 0;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.favorite-item:hover .more-btn {
|
| 393 |
+
opacity: 1;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.more-btn:hover {
|
| 397 |
+
background: rgba(255, 255, 255, 0.1);
|
| 398 |
+
color: var(--text-secondary);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.more-menu {
|
| 402 |
+
position: absolute;
|
| 403 |
+
top: 100%;
|
| 404 |
+
right: 16px;
|
| 405 |
+
background: var(--bg-card);
|
| 406 |
+
border-radius: 8px;
|
| 407 |
+
padding: 8px 0;
|
| 408 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
| 409 |
+
z-index: 10;
|
| 410 |
+
min-width: 120px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.more-menu button {
|
| 414 |
+
display: block;
|
| 415 |
+
width: 100%;
|
| 416 |
+
padding: 8px 16px;
|
| 417 |
+
border: none;
|
| 418 |
+
background: transparent;
|
| 419 |
+
color: var(--text-primary);
|
| 420 |
+
text-align: left;
|
| 421 |
+
cursor: pointer;
|
| 422 |
+
font-size: 12px;
|
| 423 |
+
transition: var(--transition-fast);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.more-menu button:hover {
|
| 427 |
+
background: rgba(255, 255, 255, 0.1);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* 响应式 */
|
| 431 |
+
@media (max-width: 375px) {
|
| 432 |
+
.favorite-item {
|
| 433 |
+
padding: 10px 12px;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.album-cover {
|
| 437 |
+
width: 40px;
|
| 438 |
+
height: 40px;
|
| 439 |
+
margin-right: 10px;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.song-name {
|
| 443 |
+
font-size: 13px;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.song-meta {
|
| 447 |
+
font-size: 11px;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.song-extra {
|
| 451 |
+
font-size: 10px;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.favorite-item:last-child {
|
| 455 |
+
border-bottom: none;
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
</style>
|
src/components/favorites/FavoritesList.vue
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="favorites-list">
|
| 3 |
+
<!-- 顶部操作栏 -->
|
| 4 |
+
<div class="list-header" v-if="favoritesList.length > 0">
|
| 5 |
+
<div class="header-info">
|
| 6 |
+
<h2 class="list-title">
|
| 7 |
+
<i class="fas fa-heart"></i>
|
| 8 |
+
我喜欢的音乐
|
| 9 |
+
<span class="count">({{ favoritesList.length }})</span>
|
| 10 |
+
</h2>
|
| 11 |
+
<p class="list-subtitle">{{ totalDuration }} • {{ lastUpdateText }}</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<div class="header-actions">
|
| 15 |
+
<button class="play-all-btn" @click="playAll" :disabled="favoritesList.length === 0">
|
| 16 |
+
<i class="fas fa-play"></i>
|
| 17 |
+
播放全部
|
| 18 |
+
</button>
|
| 19 |
+
|
| 20 |
+
<button
|
| 21 |
+
class="batch-btn"
|
| 22 |
+
:class="{ 'active': batchMode }"
|
| 23 |
+
@click="toggleBatchMode"
|
| 24 |
+
>
|
| 25 |
+
<i :class="batchMode ? 'fas fa-times' : 'fas fa-check-square'"></i>
|
| 26 |
+
{{ batchMode ? '取消' : '批量管理' }}
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- 搜索栏 -->
|
| 32 |
+
<div class="search-bar" v-if="showSearch && favoritesList.length > 0">
|
| 33 |
+
<div class="search-input-wrapper">
|
| 34 |
+
<i class="fas fa-search"></i>
|
| 35 |
+
<input
|
| 36 |
+
type="text"
|
| 37 |
+
v-model="searchQuery"
|
| 38 |
+
placeholder="搜索收藏的歌曲..."
|
| 39 |
+
class="search-input"
|
| 40 |
+
@input="handleSearch"
|
| 41 |
+
>
|
| 42 |
+
<button
|
| 43 |
+
v-if="searchQuery"
|
| 44 |
+
class="clear-search-btn"
|
| 45 |
+
@click="clearSearch"
|
| 46 |
+
>
|
| 47 |
+
<i class="fas fa-times"></i>
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- 搜索过滤器 -->
|
| 52 |
+
<div class="search-filters" v-if="searchQuery">
|
| 53 |
+
<button
|
| 54 |
+
class="filter-btn"
|
| 55 |
+
:class="{ 'active': searchFilter === 'all' }"
|
| 56 |
+
@click="setSearchFilter('all')"
|
| 57 |
+
>
|
| 58 |
+
全部
|
| 59 |
+
</button>
|
| 60 |
+
<button
|
| 61 |
+
class="filter-btn"
|
| 62 |
+
:class="{ 'active': searchFilter === 'name' }"
|
| 63 |
+
@click="setSearchFilter('name')"
|
| 64 |
+
>
|
| 65 |
+
歌名
|
| 66 |
+
</button>
|
| 67 |
+
<button
|
| 68 |
+
class="filter-btn"
|
| 69 |
+
:class="{ 'active': searchFilter === 'artist' }"
|
| 70 |
+
@click="setSearchFilter('artist')"
|
| 71 |
+
>
|
| 72 |
+
歌手
|
| 73 |
+
</button>
|
| 74 |
+
<button
|
| 75 |
+
class="filter-btn"
|
| 76 |
+
:class="{ 'active': searchFilter === 'album' }"
|
| 77 |
+
@click="setSearchFilter('album')"
|
| 78 |
+
>
|
| 79 |
+
专辑
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- 批量操作栏 -->
|
| 85 |
+
<div class="batch-actions" v-if="batchMode && selectedItems.length > 0">
|
| 86 |
+
<div class="batch-info">
|
| 87 |
+
<span>已选择 {{ selectedItems.length }} 首</span>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="batch-buttons">
|
| 90 |
+
<button @click="batchPlay">播放选中</button>
|
| 91 |
+
<button @click="batchAddToPlaylist">添加到播放列表</button>
|
| 92 |
+
<button @click="batchRemove" class="danger">删除选中</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<!-- 排序选项 -->
|
| 97 |
+
<div class="sort-options" v-if="favoritesList.length > 0">
|
| 98 |
+
<label class="sort-label">排序方式:</label>
|
| 99 |
+
<select v-model="sortBy" @change="handleSort" class="sort-select">
|
| 100 |
+
<option value="favoriteTime">收藏时间</option>
|
| 101 |
+
<option value="name">歌名</option>
|
| 102 |
+
<option value="artist">歌手</option>
|
| 103 |
+
<option value="playCount">播放次数</option>
|
| 104 |
+
</select>
|
| 105 |
+
<button
|
| 106 |
+
class="sort-order-btn"
|
| 107 |
+
@click="toggleSortOrder"
|
| 108 |
+
:title="sortOrder === 'desc' ? '降序' : '升序'"
|
| 109 |
+
>
|
| 110 |
+
<i :class="sortOrder === 'desc' ? 'fas fa-sort-amount-down' : 'fas fa-sort-amount-up'"></i>
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<!-- 歌曲列表 -->
|
| 115 |
+
<div class="favorites-content">
|
| 116 |
+
<div v-if="loading" class="loading-container">
|
| 117 |
+
<Loading text="加载收藏列表..." />
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<div v-else-if="filteredList.length === 0 && !loading" class="empty-container">
|
| 121 |
+
<Empty
|
| 122 |
+
v-if="favoritesList.length === 0"
|
| 123 |
+
icon="fas fa-heart"
|
| 124 |
+
title="还没有收藏歌曲"
|
| 125 |
+
subtitle="在搜索结果中点击爱心收藏喜欢的歌曲"
|
| 126 |
+
/>
|
| 127 |
+
<Empty
|
| 128 |
+
v-else
|
| 129 |
+
icon="fas fa-search"
|
| 130 |
+
title="没有找到相关歌曲"
|
| 131 |
+
:subtitle="'没有找到包含「' + searchQuery + '」的歌曲'"
|
| 132 |
+
/>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div v-else class="favorites-items">
|
| 136 |
+
<!-- 批量选择模式 -->
|
| 137 |
+
<div v-if="batchMode" class="batch-select-all">
|
| 138 |
+
<label class="checkbox-wrapper">
|
| 139 |
+
<input
|
| 140 |
+
type="checkbox"
|
| 141 |
+
:checked="isAllSelected"
|
| 142 |
+
:indeterminate="isIndeterminate"
|
| 143 |
+
@change="toggleSelectAll"
|
| 144 |
+
>
|
| 145 |
+
<span class="checkbox-text">全选 ({{ selectedItems.length }}/{{ filteredList.length }})</span>
|
| 146 |
+
</label>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- 歌曲项列表 -->
|
| 150 |
+
<div class="song-items">
|
| 151 |
+
<div
|
| 152 |
+
v-for="(item, index) in paginatedList"
|
| 153 |
+
:key="item.song.id"
|
| 154 |
+
class="song-item-wrapper"
|
| 155 |
+
>
|
| 156 |
+
<!-- 批量选择复选框 -->
|
| 157 |
+
<label v-if="batchMode" class="item-checkbox">
|
| 158 |
+
<input
|
| 159 |
+
type="checkbox"
|
| 160 |
+
:value="item.song.id"
|
| 161 |
+
v-model="selectedItems"
|
| 162 |
+
@change="handleItemSelect"
|
| 163 |
+
>
|
| 164 |
+
</label>
|
| 165 |
+
|
| 166 |
+
<!-- 歌曲项 -->
|
| 167 |
+
<FavoriteItem
|
| 168 |
+
:song="item.song"
|
| 169 |
+
:favorite-time="item.favoriteTime"
|
| 170 |
+
:play-count="getPlayCount(item.song.id)"
|
| 171 |
+
:is-selected="selectedItems.includes(item.song.id)"
|
| 172 |
+
@play="handlePlay"
|
| 173 |
+
@unfavorited="handleUnfavorited"
|
| 174 |
+
@more-action="handleMoreAction"
|
| 175 |
+
/>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<!-- 加载更多 -->
|
| 180 |
+
<div class="load-more" v-if="hasMore">
|
| 181 |
+
<button class="load-more-btn" @click="loadMore" :disabled="loadingMore">
|
| 182 |
+
<i v-if="loadingMore" class="fas fa-spinner fa-spin"></i>
|
| 183 |
+
<i v-else class="fas fa-plus"></i>
|
| 184 |
+
{{ loadingMore ? '加载中...' : `加载更多 (${filteredList.length - displayCount}首)` }}
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
</template>
|
| 191 |
+
|
| 192 |
+
<script setup>
|
| 193 |
+
import { ref, computed, watch, onMounted } from 'vue'
|
| 194 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 195 |
+
import { usePlayerStore } from '@/stores/player'
|
| 196 |
+
import { useHistoryStore } from '@/stores/history'
|
| 197 |
+
import FavoriteItem from './FavoriteItem.vue'
|
| 198 |
+
import Loading from '@/components/common/Loading.vue'
|
| 199 |
+
import Empty from '@/components/common/Empty.vue'
|
| 200 |
+
|
| 201 |
+
const props = defineProps({
|
| 202 |
+
// 是否显示搜索
|
| 203 |
+
showSearch: {
|
| 204 |
+
type: Boolean,
|
| 205 |
+
default: true
|
| 206 |
+
},
|
| 207 |
+
|
| 208 |
+
// 初始显示数量
|
| 209 |
+
initialCount: {
|
| 210 |
+
type: Number,
|
| 211 |
+
default: 20
|
| 212 |
+
},
|
| 213 |
+
|
| 214 |
+
// 每次加载数量
|
| 215 |
+
loadCount: {
|
| 216 |
+
type: Number,
|
| 217 |
+
default: 20
|
| 218 |
+
}
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
const emit = defineEmits(['play', 'batch-action'])
|
| 222 |
+
|
| 223 |
+
const favoritesStore = useFavoritesStore()
|
| 224 |
+
const playerStore = usePlayerStore()
|
| 225 |
+
const historyStore = useHistoryStore()
|
| 226 |
+
|
| 227 |
+
// 响应式数据
|
| 228 |
+
const loading = ref(true)
|
| 229 |
+
const loadingMore = ref(false)
|
| 230 |
+
const searchQuery = ref('')
|
| 231 |
+
const searchFilter = ref('all')
|
| 232 |
+
const sortBy = ref('favoriteTime')
|
| 233 |
+
const sortOrder = ref('desc')
|
| 234 |
+
const batchMode = ref(false)
|
| 235 |
+
const selectedItems = ref([])
|
| 236 |
+
const displayCount = ref(props.initialCount)
|
| 237 |
+
|
| 238 |
+
// 计算属性
|
| 239 |
+
const favoritesList = computed(() => {
|
| 240 |
+
return favoritesStore.favorites
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
const filteredList = computed(() => {
|
| 244 |
+
let result = [...favoritesList.value]
|
| 245 |
+
|
| 246 |
+
// 搜索过滤
|
| 247 |
+
if (searchQuery.value) {
|
| 248 |
+
const query = searchQuery.value.toLowerCase()
|
| 249 |
+
result = result.filter(item => {
|
| 250 |
+
const song = item.song
|
| 251 |
+
const name = (song.name || '').toLowerCase()
|
| 252 |
+
const artist = formatArtist(song.artist).toLowerCase()
|
| 253 |
+
const album = (song.album || '').toLowerCase()
|
| 254 |
+
|
| 255 |
+
switch (searchFilter.value) {
|
| 256 |
+
case 'name':
|
| 257 |
+
return name.includes(query)
|
| 258 |
+
case 'artist':
|
| 259 |
+
return artist.includes(query)
|
| 260 |
+
case 'album':
|
| 261 |
+
return album.includes(query)
|
| 262 |
+
default:
|
| 263 |
+
return name.includes(query) || artist.includes(query) || album.includes(query)
|
| 264 |
+
}
|
| 265 |
+
})
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// 排序
|
| 269 |
+
result.sort((a, b) => {
|
| 270 |
+
let valueA, valueB
|
| 271 |
+
|
| 272 |
+
switch (sortBy.value) {
|
| 273 |
+
case 'name':
|
| 274 |
+
valueA = a.song.name || ''
|
| 275 |
+
valueB = b.song.name || ''
|
| 276 |
+
break
|
| 277 |
+
case 'artist':
|
| 278 |
+
valueA = formatArtist(a.song.artist)
|
| 279 |
+
valueB = formatArtist(b.song.artist)
|
| 280 |
+
break
|
| 281 |
+
case 'playCount':
|
| 282 |
+
valueA = getPlayCount(a.song.id)
|
| 283 |
+
valueB = getPlayCount(b.song.id)
|
| 284 |
+
break
|
| 285 |
+
default:
|
| 286 |
+
valueA = a.favoriteTime
|
| 287 |
+
valueB = b.favoriteTime
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
if (sortOrder.value === 'desc') {
|
| 291 |
+
return valueB > valueA ? 1 : valueB < valueA ? -1 : 0
|
| 292 |
+
} else {
|
| 293 |
+
return valueA > valueB ? 1 : valueA < valueB ? -1 : 0
|
| 294 |
+
}
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
return result
|
| 298 |
+
})
|
| 299 |
+
|
| 300 |
+
const paginatedList = computed(() => {
|
| 301 |
+
return filteredList.value.slice(0, displayCount.value)
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
const hasMore = computed(() => {
|
| 305 |
+
return filteredList.value.length > displayCount.value
|
| 306 |
+
})
|
| 307 |
+
|
| 308 |
+
const totalDuration = computed(() => {
|
| 309 |
+
// 这里应该计算总时长,暂时返回歌曲数量
|
| 310 |
+
return `${favoritesList.value.length} 首歌曲`
|
| 311 |
+
})
|
| 312 |
+
|
| 313 |
+
const lastUpdateText = computed(() => {
|
| 314 |
+
if (favoritesList.value.length === 0) return ''
|
| 315 |
+
|
| 316 |
+
const latest = Math.max(...favoritesList.value.map(item => item.favoriteTime))
|
| 317 |
+
const now = Date.now()
|
| 318 |
+
const diff = now - latest
|
| 319 |
+
|
| 320 |
+
if (diff < 3600000) {
|
| 321 |
+
return '最近更新'
|
| 322 |
+
} else if (diff < 86400000) {
|
| 323 |
+
return '今天更新'
|
| 324 |
+
} else if (diff < 2592000000) {
|
| 325 |
+
return `${Math.floor(diff / 86400000)}天前更新`
|
| 326 |
+
} else {
|
| 327 |
+
return new Date(latest).toLocaleDateString()
|
| 328 |
+
}
|
| 329 |
+
})
|
| 330 |
+
|
| 331 |
+
const isAllSelected = computed(() => {
|
| 332 |
+
return selectedItems.value.length === filteredList.value.length && filteredList.value.length > 0
|
| 333 |
+
})
|
| 334 |
+
|
| 335 |
+
const isIndeterminate = computed(() => {
|
| 336 |
+
return selectedItems.value.length > 0 && selectedItems.value.length < filteredList.value.length
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
// 方法
|
| 340 |
+
const formatArtist = (artist) => {
|
| 341 |
+
if (Array.isArray(artist)) {
|
| 342 |
+
return artist.join('/')
|
| 343 |
+
}
|
| 344 |
+
return artist || '未知歌手'
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
const getPlayCount = (songId) => {
|
| 348 |
+
return historyStore.getPlayCount(songId)
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const handleSearch = () => {
|
| 352 |
+
displayCount.value = props.initialCount
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
const clearSearch = () => {
|
| 356 |
+
searchQuery.value = ''
|
| 357 |
+
searchFilter.value = 'all'
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
const setSearchFilter = (filter) => {
|
| 361 |
+
searchFilter.value = filter
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
const handleSort = () => {
|
| 365 |
+
displayCount.value = props.initialCount
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const toggleSortOrder = () => {
|
| 369 |
+
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const loadMore = async () => {
|
| 373 |
+
loadingMore.value = true
|
| 374 |
+
|
| 375 |
+
setTimeout(() => {
|
| 376 |
+
displayCount.value += props.loadCount
|
| 377 |
+
loadingMore.value = false
|
| 378 |
+
}, 500)
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
const playAll = () => {
|
| 382 |
+
if (filteredList.value.length === 0) return
|
| 383 |
+
|
| 384 |
+
const songs = filteredList.value.map(item => item.song)
|
| 385 |
+
playerStore.playPlaylist(songs)
|
| 386 |
+
emit('play', songs)
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
const toggleBatchMode = () => {
|
| 390 |
+
batchMode.value = !batchMode.value
|
| 391 |
+
selectedItems.value = []
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
const toggleSelectAll = () => {
|
| 395 |
+
if (isAllSelected.value) {
|
| 396 |
+
selectedItems.value = []
|
| 397 |
+
} else {
|
| 398 |
+
selectedItems.value = filteredList.value.map(item => item.song.id)
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
const handleItemSelect = () => {
|
| 403 |
+
// 复选框变化处理
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const batchPlay = () => {
|
| 407 |
+
const songs = filteredList.value
|
| 408 |
+
.filter(item => selectedItems.value.includes(item.song.id))
|
| 409 |
+
.map(item => item.song)
|
| 410 |
+
|
| 411 |
+
if (songs.length > 0) {
|
| 412 |
+
playerStore.playPlaylist(songs)
|
| 413 |
+
emit('batch-action', { action: 'play', songs })
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
const batchAddToPlaylist = () => {
|
| 418 |
+
const songs = filteredList.value
|
| 419 |
+
.filter(item => selectedItems.value.includes(item.song.id))
|
| 420 |
+
.map(item => item.song)
|
| 421 |
+
|
| 422 |
+
songs.forEach(song => {
|
| 423 |
+
playerStore.addToPlaylist(song)
|
| 424 |
+
})
|
| 425 |
+
|
| 426 |
+
emit('batch-action', { action: 'add-to-playlist', songs })
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
const batchRemove = async () => {
|
| 430 |
+
if (confirm(`确定要删除选中的 ${selectedItems.value.length} 首歌曲吗?`)) {
|
| 431 |
+
for (const songId of selectedItems.value) {
|
| 432 |
+
await favoritesStore.removeFavorite(songId)
|
| 433 |
+
}
|
| 434 |
+
selectedItems.value = []
|
| 435 |
+
emit('batch-action', { action: 'remove', count: selectedItems.value.length })
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
const handlePlay = (song) => {
|
| 440 |
+
emit('play', song)
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
const handleUnfavorited = (song) => {
|
| 444 |
+
// 移除选中状态
|
| 445 |
+
const index = selectedItems.value.indexOf(song.id)
|
| 446 |
+
if (index > -1) {
|
| 447 |
+
selectedItems.value.splice(index, 1)
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
const handleMoreAction = (data) => {
|
| 452 |
+
emit('more-action', data)
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
// 生命周期
|
| 456 |
+
onMounted(async () => {
|
| 457 |
+
try {
|
| 458 |
+
await favoritesStore.loadFavorites()
|
| 459 |
+
} catch (error) {
|
| 460 |
+
console.error('加载收藏列表失败:', error)
|
| 461 |
+
} finally {
|
| 462 |
+
loading.value = false
|
| 463 |
+
}
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
// 监听收藏列表变化
|
| 467 |
+
watch(favoritesList, () => {
|
| 468 |
+
if (batchMode.value) {
|
| 469 |
+
// 过滤掉不存在的选中项
|
| 470 |
+
selectedItems.value = selectedItems.value.filter(id =>
|
| 471 |
+
favoritesList.value.some(item => item.song.id === id)
|
| 472 |
+
)
|
| 473 |
+
}
|
| 474 |
+
})
|
| 475 |
+
</script>
|
| 476 |
+
|
| 477 |
+
<style scoped>
|
| 478 |
+
.favorites-list {
|
| 479 |
+
height: 100%;
|
| 480 |
+
display: flex;
|
| 481 |
+
flex-direction: column;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.list-header {
|
| 485 |
+
display: flex;
|
| 486 |
+
align-items: center;
|
| 487 |
+
justify-content: space-between;
|
| 488 |
+
padding: 20px 16px;
|
| 489 |
+
border-bottom: 1px solid var(--border-strong);
|
| 490 |
+
background: var(--bg-card);
|
| 491 |
+
border-radius: var(--radius-small) var(--radius-small) 0 0;
|
| 492 |
+
margin: 0 16px;
|
| 493 |
+
border: 1px solid var(--border-light);
|
| 494 |
+
border-bottom: 1px solid var(--border-strong);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.header-info {
|
| 498 |
+
flex: 1;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.list-title {
|
| 502 |
+
display: flex;
|
| 503 |
+
align-items: center;
|
| 504 |
+
gap: 8px;
|
| 505 |
+
font-size: 18px;
|
| 506 |
+
font-weight: 600;
|
| 507 |
+
color: var(--text-primary);
|
| 508 |
+
margin: 0 0 4px 0;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.list-title i {
|
| 512 |
+
color: var(--accent-red);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.count {
|
| 516 |
+
color: var(--text-secondary);
|
| 517 |
+
font-weight: 400;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.list-subtitle {
|
| 521 |
+
font-size: 12px;
|
| 522 |
+
color: var(--text-secondary);
|
| 523 |
+
margin: 0;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.header-actions {
|
| 527 |
+
display: flex;
|
| 528 |
+
align-items: center;
|
| 529 |
+
gap: 8px;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.play-all-btn,
|
| 533 |
+
.batch-btn {
|
| 534 |
+
display: flex;
|
| 535 |
+
align-items: center;
|
| 536 |
+
gap: 6px;
|
| 537 |
+
padding: 8px 16px;
|
| 538 |
+
border: none;
|
| 539 |
+
background: var(--accent-red);
|
| 540 |
+
color: white;
|
| 541 |
+
border-radius: 20px;
|
| 542 |
+
font-size: 12px;
|
| 543 |
+
cursor: pointer;
|
| 544 |
+
transition: var(--transition-fast);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.play-all-btn:hover,
|
| 548 |
+
.batch-btn:hover {
|
| 549 |
+
background: var(--accent-red-hover);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.play-all-btn:disabled {
|
| 553 |
+
background: rgba(255, 255, 255, 0.1);
|
| 554 |
+
color: var(--text-tertiary);
|
| 555 |
+
cursor: not-allowed;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.batch-btn.active {
|
| 559 |
+
background: rgba(255, 255, 255, 0.1);
|
| 560 |
+
color: var(--text-primary);
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.search-bar {
|
| 564 |
+
padding: 16px;
|
| 565 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 566 |
+
background: var(--bg-card);
|
| 567 |
+
margin: 0 16px;
|
| 568 |
+
border-left: 1px solid var(--border-light);
|
| 569 |
+
border-right: 1px solid var(--border-light);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.search-input-wrapper {
|
| 573 |
+
position: relative;
|
| 574 |
+
margin-bottom: 12px;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.search-input-wrapper i {
|
| 578 |
+
position: absolute;
|
| 579 |
+
left: 12px;
|
| 580 |
+
top: 50%;
|
| 581 |
+
transform: translateY(-50%);
|
| 582 |
+
color: var(--text-tertiary);
|
| 583 |
+
font-size: 14px;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.search-input {
|
| 587 |
+
width: 100%;
|
| 588 |
+
height: 40px;
|
| 589 |
+
border: none;
|
| 590 |
+
background: rgba(255, 255, 255, 0.05);
|
| 591 |
+
border-radius: 20px;
|
| 592 |
+
padding: 0 40px;
|
| 593 |
+
color: var(--text-primary);
|
| 594 |
+
font-size: 14px;
|
| 595 |
+
outline: none;
|
| 596 |
+
transition: var(--transition-fast);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.search-input:focus {
|
| 600 |
+
background: rgba(255, 255, 255, 0.1);
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.clear-search-btn {
|
| 604 |
+
position: absolute;
|
| 605 |
+
right: 8px;
|
| 606 |
+
top: 50%;
|
| 607 |
+
transform: translateY(-50%);
|
| 608 |
+
width: 24px;
|
| 609 |
+
height: 24px;
|
| 610 |
+
border: none;
|
| 611 |
+
background: transparent;
|
| 612 |
+
color: var(--text-tertiary);
|
| 613 |
+
border-radius: 50%;
|
| 614 |
+
cursor: pointer;
|
| 615 |
+
transition: var(--transition-fast);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.clear-search-btn:hover {
|
| 619 |
+
background: rgba(255, 255, 255, 0.1);
|
| 620 |
+
color: var(--text-secondary);
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.search-filters {
|
| 624 |
+
display: flex;
|
| 625 |
+
gap: 8px;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.filter-btn {
|
| 629 |
+
padding: 4px 12px;
|
| 630 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 631 |
+
background: transparent;
|
| 632 |
+
color: var(--text-secondary);
|
| 633 |
+
border-radius: 16px;
|
| 634 |
+
font-size: 12px;
|
| 635 |
+
cursor: pointer;
|
| 636 |
+
transition: var(--transition-fast);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.filter-btn:hover {
|
| 640 |
+
background: rgba(255, 255, 255, 0.05);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.filter-btn.active {
|
| 644 |
+
background: var(--accent-red);
|
| 645 |
+
border-color: var(--accent-red);
|
| 646 |
+
color: white;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.batch-actions {
|
| 650 |
+
display: flex;
|
| 651 |
+
align-items: center;
|
| 652 |
+
justify-content: space-between;
|
| 653 |
+
padding: 12px 16px;
|
| 654 |
+
background: rgba(255, 107, 107, 0.1);
|
| 655 |
+
border-bottom: 1px solid var(--border-light);
|
| 656 |
+
margin: 0 16px;
|
| 657 |
+
border-left: 1px solid var(--border-light);
|
| 658 |
+
border-right: 1px solid var(--border-light);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.batch-info {
|
| 662 |
+
font-size: 12px;
|
| 663 |
+
color: var(--accent-red);
|
| 664 |
+
font-weight: 500;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.batch-buttons {
|
| 668 |
+
display: flex;
|
| 669 |
+
gap: 8px;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.batch-buttons button {
|
| 673 |
+
padding: 4px 12px;
|
| 674 |
+
border: none;
|
| 675 |
+
background: rgba(255, 255, 255, 0.1);
|
| 676 |
+
color: var(--text-primary);
|
| 677 |
+
border-radius: 12px;
|
| 678 |
+
font-size: 11px;
|
| 679 |
+
cursor: pointer;
|
| 680 |
+
transition: var(--transition-fast);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.batch-buttons button:hover {
|
| 684 |
+
background: rgba(255, 255, 255, 0.15);
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
.batch-buttons button.danger {
|
| 688 |
+
background: rgba(255, 107, 107, 0.2);
|
| 689 |
+
color: var(--accent-red);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.batch-buttons button.danger:hover {
|
| 693 |
+
background: rgba(255, 107, 107, 0.3);
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.sort-options {
|
| 697 |
+
display: flex;
|
| 698 |
+
align-items: center;
|
| 699 |
+
gap: 12px;
|
| 700 |
+
padding: 12px 16px;
|
| 701 |
+
background: rgba(255, 255, 255, 0.02);
|
| 702 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 703 |
+
margin: 0 16px;
|
| 704 |
+
border-left: 1px solid var(--border-light);
|
| 705 |
+
border-right: 1px solid var(--border-light);
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.sort-label {
|
| 709 |
+
font-size: 12px;
|
| 710 |
+
color: var(--text-secondary);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.sort-select {
|
| 714 |
+
border: none;
|
| 715 |
+
background: rgba(255, 255, 255, 0.05);
|
| 716 |
+
color: var(--text-primary);
|
| 717 |
+
border-radius: 6px;
|
| 718 |
+
padding: 4px 8px;
|
| 719 |
+
font-size: 12px;
|
| 720 |
+
outline: none;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.sort-order-btn {
|
| 724 |
+
width: 24px;
|
| 725 |
+
height: 24px;
|
| 726 |
+
border: none;
|
| 727 |
+
background: transparent;
|
| 728 |
+
color: var(--text-secondary);
|
| 729 |
+
border-radius: 4px;
|
| 730 |
+
cursor: pointer;
|
| 731 |
+
transition: var(--transition-fast);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
.sort-order-btn:hover {
|
| 735 |
+
background: rgba(255, 255, 255, 0.1);
|
| 736 |
+
color: var(--text-primary);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.favorites-content {
|
| 740 |
+
flex: 1;
|
| 741 |
+
overflow-y: auto;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.loading-container,
|
| 745 |
+
.empty-container {
|
| 746 |
+
padding: 40px 20px;
|
| 747 |
+
display: flex;
|
| 748 |
+
justify-content: center;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.batch-select-all {
|
| 752 |
+
padding: 12px 16px;
|
| 753 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 754 |
+
background: var(--bg-card);
|
| 755 |
+
margin: 0 16px;
|
| 756 |
+
border-left: 1px solid var(--border-light);
|
| 757 |
+
border-right: 1px solid var(--border-light);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.checkbox-wrapper {
|
| 761 |
+
display: flex;
|
| 762 |
+
align-items: center;
|
| 763 |
+
gap: 8px;
|
| 764 |
+
cursor: pointer;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
.checkbox-text {
|
| 768 |
+
font-size: 12px;
|
| 769 |
+
color: var(--text-secondary);
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
.song-item-wrapper {
|
| 773 |
+
display: flex;
|
| 774 |
+
align-items: center;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.item-checkbox {
|
| 778 |
+
padding: 0 16px;
|
| 779 |
+
cursor: pointer;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.load-more {
|
| 783 |
+
padding: 20px;
|
| 784 |
+
display: flex;
|
| 785 |
+
justify-content: center;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.load-more-btn {
|
| 789 |
+
display: flex;
|
| 790 |
+
align-items: center;
|
| 791 |
+
gap: 8px;
|
| 792 |
+
padding: 10px 20px;
|
| 793 |
+
border: none;
|
| 794 |
+
background: rgba(255, 255, 255, 0.05);
|
| 795 |
+
color: var(--text-secondary);
|
| 796 |
+
border-radius: 20px;
|
| 797 |
+
font-size: 12px;
|
| 798 |
+
cursor: pointer;
|
| 799 |
+
transition: var(--transition-fast);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.load-more-btn:hover {
|
| 803 |
+
background: rgba(255, 255, 255, 0.1);
|
| 804 |
+
color: var(--text-primary);
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.load-more-btn:disabled {
|
| 808 |
+
cursor: not-allowed;
|
| 809 |
+
opacity: 0.6;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
/* 响应式 */
|
| 813 |
+
@media (max-width: 375px) {
|
| 814 |
+
.list-header {
|
| 815 |
+
flex-direction: column;
|
| 816 |
+
align-items: flex-start;
|
| 817 |
+
gap: 12px;
|
| 818 |
+
padding: 16px 12px;
|
| 819 |
+
margin: 0 12px;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.header-actions {
|
| 823 |
+
width: 100%;
|
| 824 |
+
justify-content: flex-end;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.search-bar {
|
| 828 |
+
padding: 12px;
|
| 829 |
+
margin: 0 12px;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.batch-actions {
|
| 833 |
+
flex-direction: column;
|
| 834 |
+
align-items: flex-start;
|
| 835 |
+
gap: 8px;
|
| 836 |
+
padding: 12px;
|
| 837 |
+
margin: 0 12px;
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.batch-buttons {
|
| 841 |
+
width: 100%;
|
| 842 |
+
justify-content: flex-end;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
.sort-options {
|
| 846 |
+
margin: 0 12px;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
.batch-select-all {
|
| 850 |
+
margin: 0 12px;
|
| 851 |
+
}
|
| 852 |
+
}
|
| 853 |
+
</style>
|
src/components/icons/IconCommunity.vue
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
<template>
|
| 2 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
| 3 |
-
<path
|
| 4 |
-
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
| 5 |
-
/>
|
| 6 |
-
</svg>
|
| 7 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/icons/IconDocumentation.vue
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
<template>
|
| 2 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
| 3 |
-
<path
|
| 4 |
-
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
| 5 |
-
/>
|
| 6 |
-
</svg>
|
| 7 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/icons/IconEcosystem.vue
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
<template>
|
| 2 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
| 3 |
-
<path
|
| 4 |
-
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
| 5 |
-
/>
|
| 6 |
-
</svg>
|
| 7 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/icons/IconSupport.vue
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
<template>
|
| 2 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
| 3 |
-
<path
|
| 4 |
-
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
| 5 |
-
/>
|
| 6 |
-
</svg>
|
| 7 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/icons/IconTooling.vue
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
| 2 |
-
<template>
|
| 3 |
-
<svg
|
| 4 |
-
xmlns="http://www.w3.org/2000/svg"
|
| 5 |
-
xmlns:xlink="http://www.w3.org/1999/xlink"
|
| 6 |
-
aria-hidden="true"
|
| 7 |
-
role="img"
|
| 8 |
-
class="iconify iconify--mdi"
|
| 9 |
-
width="24"
|
| 10 |
-
height="24"
|
| 11 |
-
preserveAspectRatio="xMidYMid meet"
|
| 12 |
-
viewBox="0 0 24 24"
|
| 13 |
-
>
|
| 14 |
-
<path
|
| 15 |
-
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
| 16 |
-
fill="currentColor"
|
| 17 |
-
></path>
|
| 18 |
-
</svg>
|
| 19 |
-
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/layout/AppTabBar.vue
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div
|
| 3 |
+
class="app-tabbar"
|
| 4 |
+
v-show="!shouldHideTabbar"
|
| 5 |
+
>
|
| 6 |
+
<div class="tabbar-content">
|
| 7 |
+
<router-link
|
| 8 |
+
v-for="tab in tabs"
|
| 9 |
+
:key="tab.name"
|
| 10 |
+
:to="tab.path"
|
| 11 |
+
class="tab-item"
|
| 12 |
+
:class="{ active: currentRoute === tab.name }"
|
| 13 |
+
@click="handleTabClick(tab)"
|
| 14 |
+
>
|
| 15 |
+
<i :class="tab.icon" class="tab-icon"></i>
|
| 16 |
+
<span class="tab-label">{{ tab.label }}</span>
|
| 17 |
+
</router-link>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</template>
|
| 21 |
+
|
| 22 |
+
<script setup>
|
| 23 |
+
import { computed } from 'vue'
|
| 24 |
+
import { useRoute } from 'vue-router'
|
| 25 |
+
|
| 26 |
+
const route = useRoute()
|
| 27 |
+
|
| 28 |
+
const tabs = [
|
| 29 |
+
{
|
| 30 |
+
name: 'Home',
|
| 31 |
+
path: '/home',
|
| 32 |
+
label: '首页',
|
| 33 |
+
icon: 'fas fa-home'
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
name: 'MyMusic',
|
| 37 |
+
path: '/my-music',
|
| 38 |
+
label: '我的音乐',
|
| 39 |
+
icon: 'fas fa-heart'
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
name: 'Settings',
|
| 43 |
+
path: '/settings',
|
| 44 |
+
label: '设置',
|
| 45 |
+
icon: 'fas fa-cog'
|
| 46 |
+
}
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
const currentRoute = computed(() => route.name)
|
| 50 |
+
|
| 51 |
+
// 判断是否应该隐藏tabbar
|
| 52 |
+
const shouldHideTabbar = computed(() => {
|
| 53 |
+
return route.meta?.fullScreen || false
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
const handleTabClick = (tab) => {
|
| 57 |
+
// 触觉反馈(如果支持)
|
| 58 |
+
if (navigator.vibrate) {
|
| 59 |
+
navigator.vibrate(10)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
</script>
|
| 63 |
+
|
| 64 |
+
<style scoped>
|
| 65 |
+
.app-tabbar {
|
| 66 |
+
position: fixed;
|
| 67 |
+
bottom: 0;
|
| 68 |
+
left: 0;
|
| 69 |
+
right: 0;
|
| 70 |
+
background: var(--bg-card);
|
| 71 |
+
backdrop-filter: blur(20px);
|
| 72 |
+
border-top: 1px solid var(--border-strong);
|
| 73 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
| 74 |
+
z-index: 1000;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* iOS PWA 模式底部间距 */
|
| 78 |
+
@supports (-webkit-touch-callout: none) {
|
| 79 |
+
@media all and (display-mode: standalone) {
|
| 80 |
+
.app-tabbar {
|
| 81 |
+
padding-bottom: 20px;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.tabbar-content {
|
| 87 |
+
display: flex;
|
| 88 |
+
height: var(--tabbar-height);
|
| 89 |
+
max-width: 480px;
|
| 90 |
+
margin: 0 auto;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.tab-item {
|
| 94 |
+
flex: 1;
|
| 95 |
+
display: flex;
|
| 96 |
+
flex-direction: column;
|
| 97 |
+
align-items: center;
|
| 98 |
+
justify-content: center;
|
| 99 |
+
text-decoration: none;
|
| 100 |
+
color: var(--text-disabled);
|
| 101 |
+
transition: var(--transition-fast);
|
| 102 |
+
min-height: var(--touch-target);
|
| 103 |
+
position: relative;
|
| 104 |
+
padding: 4px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.tab-item:hover,
|
| 108 |
+
.tab-item.active {
|
| 109 |
+
color: var(--primary-color);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.tab-item.active::after {
|
| 113 |
+
content: '';
|
| 114 |
+
position: absolute;
|
| 115 |
+
bottom: -1px;
|
| 116 |
+
left: 50%;
|
| 117 |
+
transform: translateX(-50%);
|
| 118 |
+
width: 24px;
|
| 119 |
+
height: 2px;
|
| 120 |
+
background: var(--primary-color);
|
| 121 |
+
border-radius: 1px;
|
| 122 |
+
/* 使用与进度条不同的视觉效果 */
|
| 123 |
+
box-shadow: 0 -1px 4px var(--glow-color);
|
| 124 |
+
opacity: 0.9;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.tab-icon {
|
| 128 |
+
font-size: 20px;
|
| 129 |
+
margin-bottom: 2px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.tab-label {
|
| 133 |
+
font-size: 10px;
|
| 134 |
+
font-weight: 500;
|
| 135 |
+
line-height: 1;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* 响应式适配 */
|
| 139 |
+
@media (max-width: 320px) {
|
| 140 |
+
.tab-label {
|
| 141 |
+
font-size: 9px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.tab-icon {
|
| 145 |
+
font-size: 18px;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.app-tabbar {
|
| 149 |
+
border-top: 1px solid var(--border-strong);
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
</style>
|
src/components/layout/MiniPlayer.vue
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Transition name="slide-up">
|
| 3 |
+
<div
|
| 4 |
+
v-if="currentSong"
|
| 5 |
+
class="mini-player"
|
| 6 |
+
:data-playing="isPlaying"
|
| 7 |
+
@click="openFullPlayer"
|
| 8 |
+
@touchstart="handleTouchStart"
|
| 9 |
+
@touchmove="handleTouchMove"
|
| 10 |
+
@touchend="handleTouchEnd"
|
| 11 |
+
>
|
| 12 |
+
<div class="mini-player-content">
|
| 13 |
+
<!-- 专辑封面 -->
|
| 14 |
+
<div class="cover-container">
|
| 15 |
+
<img
|
| 16 |
+
:src="coverUrl"
|
| 17 |
+
:alt="currentSong.name"
|
| 18 |
+
class="cover-image"
|
| 19 |
+
@error="handleImageError"
|
| 20 |
+
/>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- 歌曲信息 -->
|
| 24 |
+
<div class="song-info">
|
| 25 |
+
<div class="song-name">{{ currentSong.name }}</div>
|
| 26 |
+
<div class="song-artist">{{ formatArtist(currentSong.artist) }}</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<!-- 播放控制 -->
|
| 30 |
+
<div class="play-controls">
|
| 31 |
+
<button
|
| 32 |
+
class="control-btn"
|
| 33 |
+
@click.stop="togglePlay"
|
| 34 |
+
:disabled="!audioSrc"
|
| 35 |
+
>
|
| 36 |
+
<i :class="isPlaying ? 'fas fa-pause' : 'fas fa-play'"></i>
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- 进度条 -->
|
| 41 |
+
<div class="progress-background">
|
| 42 |
+
<div
|
| 43 |
+
class="progress-fill"
|
| 44 |
+
:style="{ width: `${progress}%` }"
|
| 45 |
+
></div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</Transition>
|
| 50 |
+
</template>
|
| 51 |
+
|
| 52 |
+
<script setup>
|
| 53 |
+
import { ref, computed, onMounted, watch } from 'vue'
|
| 54 |
+
import { useRouter } from 'vue-router'
|
| 55 |
+
import { usePlayerStore } from '@/stores/player'
|
| 56 |
+
import { musicApi, utils } from '@/services/musicApi'
|
| 57 |
+
|
| 58 |
+
const emit = defineEmits(['openFullPlayer', 'togglePlay', 'playNext', 'playPrevious'])
|
| 59 |
+
|
| 60 |
+
const router = useRouter()
|
| 61 |
+
|
| 62 |
+
const playerStore = usePlayerStore()
|
| 63 |
+
const coverUrl = ref('')
|
| 64 |
+
|
| 65 |
+
// 触摸处理
|
| 66 |
+
const touchStartY = ref(0)
|
| 67 |
+
const touchStartX = ref(0)
|
| 68 |
+
const isSwiping = ref(false)
|
| 69 |
+
|
| 70 |
+
// 计算属性
|
| 71 |
+
const currentSong = computed(() => playerStore.currentSong)
|
| 72 |
+
const isPlaying = computed(() => playerStore.isPlaying)
|
| 73 |
+
const progress = computed(() => playerStore.progress)
|
| 74 |
+
const audioSrc = computed(() => playerStore.audioSrc)
|
| 75 |
+
|
| 76 |
+
// 格式化歌手名
|
| 77 |
+
const formatArtist = (artist) => {
|
| 78 |
+
return utils.formatArtist(artist)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// 播放控制
|
| 82 |
+
const togglePlay = () => {
|
| 83 |
+
// 通过事件通知父组件执行实际的播放控制逻辑
|
| 84 |
+
// 这样确保mini播放器和大播放器使用相同的播放控制逻辑
|
| 85 |
+
emit('togglePlay')
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 打开全屏播放器
|
| 89 |
+
const openFullPlayer = () => {
|
| 90 |
+
// 直接使用路由跳转,不再依赖emit
|
| 91 |
+
if (currentSong.value) {
|
| 92 |
+
router.push('/player')
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// 加载专辑封面
|
| 97 |
+
const loadCover = async () => {
|
| 98 |
+
if (!currentSong.value) {
|
| 99 |
+
coverUrl.value = getDefaultCover()
|
| 100 |
+
return
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
const coverUrlResult = await playerStore.getAlbumCover(currentSong.value, 300)
|
| 105 |
+
if (coverUrlResult) {
|
| 106 |
+
coverUrl.value = coverUrlResult
|
| 107 |
+
} else {
|
| 108 |
+
coverUrl.value = getDefaultCover()
|
| 109 |
+
}
|
| 110 |
+
} catch (error) {
|
| 111 |
+
console.error('加载封面失败:', error)
|
| 112 |
+
coverUrl.value = getDefaultCover()
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// 默认封面
|
| 117 |
+
const getDefaultCover = () => {
|
| 118 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0zMiAyMEw0MCAzMkgzNlY0NEgyOFYzMkgyNEwzMiAyMFoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4zKSIvPgo8L3N2Zz4K'
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// 图片加载错误处理
|
| 122 |
+
const handleImageError = () => {
|
| 123 |
+
coverUrl.value = getDefaultCover()
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 触摸事件处理
|
| 127 |
+
const handleTouchStart = (e) => {
|
| 128 |
+
touchStartY.value = e.touches[0].clientY
|
| 129 |
+
touchStartX.value = e.touches[0].clientX
|
| 130 |
+
isSwiping.value = false
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const handleTouchMove = (e) => {
|
| 134 |
+
const deltaY = e.touches[0].clientY - touchStartY.value
|
| 135 |
+
const deltaX = e.touches[0].clientX - touchStartX.value
|
| 136 |
+
|
| 137 |
+
// 判断是否为上滑手势
|
| 138 |
+
if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < -30) {
|
| 139 |
+
isSwiping.value = true
|
| 140 |
+
e.preventDefault()
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// 判断是否为左右滑动切歌
|
| 144 |
+
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
|
| 145 |
+
isSwiping.value = true
|
| 146 |
+
if (deltaX > 0) {
|
| 147 |
+
// 右滑:上一首
|
| 148 |
+
emit('playPrevious')
|
| 149 |
+
} else {
|
| 150 |
+
// 左滑:下一首
|
| 151 |
+
emit('playNext')
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const handleTouchEnd = (e) => {
|
| 157 |
+
const deltaY = touchStartY.value - e.changedTouches[0].clientY
|
| 158 |
+
|
| 159 |
+
if (deltaY > 50 && !isSwiping.value) {
|
| 160 |
+
// 上滑打开全屏播放器
|
| 161 |
+
openFullPlayer()
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
touchStartY.value = 0
|
| 165 |
+
touchStartX.value = 0
|
| 166 |
+
isSwiping.value = false
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// 监听当前歌曲变化
|
| 170 |
+
watch(() => currentSong.value, (newSong) => {
|
| 171 |
+
if (newSong) {
|
| 172 |
+
loadCover()
|
| 173 |
+
} else {
|
| 174 |
+
coverUrl.value = getDefaultCover()
|
| 175 |
+
}
|
| 176 |
+
}, { immediate: true })
|
| 177 |
+
|
| 178 |
+
onMounted(() => {
|
| 179 |
+
if (currentSong.value) {
|
| 180 |
+
loadCover()
|
| 181 |
+
}
|
| 182 |
+
})
|
| 183 |
+
</script>
|
| 184 |
+
|
| 185 |
+
<style scoped>
|
| 186 |
+
.mini-player {
|
| 187 |
+
position: fixed;
|
| 188 |
+
bottom: var(--tabbar-height);
|
| 189 |
+
left: 0;
|
| 190 |
+
right: 0;
|
| 191 |
+
height: var(--mini-player-height);
|
| 192 |
+
background: var(--bg-card);
|
| 193 |
+
backdrop-filter: blur(20px);
|
| 194 |
+
border-top: 1px solid var(--border-strong);
|
| 195 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
| 196 |
+
z-index: 999;
|
| 197 |
+
cursor: pointer;
|
| 198 |
+
transition: var(--transition-fast);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/* iOS PWA 模式适配 */
|
| 202 |
+
@supports (-webkit-touch-callout: none) {
|
| 203 |
+
@media all and (display-mode: standalone) {
|
| 204 |
+
.mini-player {
|
| 205 |
+
bottom: calc(var(--tabbar-height) + 20px);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.mini-player:hover {
|
| 211 |
+
background: rgba(255, 255, 255, 0.08);
|
| 212 |
+
box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.1);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.mini-player-content {
|
| 216 |
+
display: flex;
|
| 217 |
+
align-items: center;
|
| 218 |
+
height: 100%;
|
| 219 |
+
padding: 0 16px;
|
| 220 |
+
position: relative;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.cover-container {
|
| 224 |
+
width: 48px;
|
| 225 |
+
height: 48px;
|
| 226 |
+
margin-right: 12px;
|
| 227 |
+
flex-shrink: 0;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.cover-image {
|
| 231 |
+
width: 100%;
|
| 232 |
+
height: 100%;
|
| 233 |
+
border-radius: 8px;
|
| 234 |
+
object-fit: cover;
|
| 235 |
+
transition: var(--transition-fast);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.song-info {
|
| 239 |
+
flex: 1;
|
| 240 |
+
min-width: 0;
|
| 241 |
+
margin-right: 12px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.song-name {
|
| 245 |
+
font-size: 14px;
|
| 246 |
+
font-weight: 600;
|
| 247 |
+
color: var(--text-primary);
|
| 248 |
+
margin-bottom: 2px;
|
| 249 |
+
white-space: nowrap;
|
| 250 |
+
overflow: hidden;
|
| 251 |
+
text-overflow: ellipsis;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.song-artist {
|
| 255 |
+
font-size: 12px;
|
| 256 |
+
color: var(--text-secondary);
|
| 257 |
+
white-space: nowrap;
|
| 258 |
+
overflow: hidden;
|
| 259 |
+
text-overflow: ellipsis;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.play-controls {
|
| 263 |
+
flex-shrink: 0;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.control-btn {
|
| 267 |
+
width: 40px;
|
| 268 |
+
height: 40px;
|
| 269 |
+
border-radius: 50%;
|
| 270 |
+
background: var(--bg-overlay);
|
| 271 |
+
border: 1px solid var(--border-light);
|
| 272 |
+
color: var(--text-primary);
|
| 273 |
+
cursor: pointer;
|
| 274 |
+
display: flex;
|
| 275 |
+
align-items: center;
|
| 276 |
+
justify-content: center;
|
| 277 |
+
font-size: 16px;
|
| 278 |
+
transition: var(--transition-fast);
|
| 279 |
+
position: relative;
|
| 280 |
+
backdrop-filter: blur(10px);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/* 播放状态时的视觉反馈 - 立即显示 */
|
| 284 |
+
.mini-player .control-btn {
|
| 285 |
+
transition: all var(--transition-fast);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.mini-player[data-playing="true"] .control-btn {
|
| 289 |
+
background: var(--primary-color);
|
| 290 |
+
color: white;
|
| 291 |
+
border-color: var(--primary-color);
|
| 292 |
+
box-shadow: 0 0 20px var(--glow-color);
|
| 293 |
+
animation: playing-pulse 2s ease-in-out infinite;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* 非播放状态也给予适当的视觉提示 */
|
| 297 |
+
.mini-player[data-playing="false"] .control-btn {
|
| 298 |
+
background: var(--bg-overlay);
|
| 299 |
+
border: 1px solid var(--border-light);
|
| 300 |
+
color: var(--primary-color);
|
| 301 |
+
box-shadow: 0 2px 8px var(--shadow-color);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.mini-player[data-playing="false"] .control-btn:hover {
|
| 305 |
+
background: var(--primary-color);
|
| 306 |
+
color: white;
|
| 307 |
+
transform: scale(1.05);
|
| 308 |
+
box-shadow: 0 0 15px var(--glow-color);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
@keyframes playing-pulse {
|
| 312 |
+
0%, 100% {
|
| 313 |
+
box-shadow: 0 0 20px var(--glow-color);
|
| 314 |
+
}
|
| 315 |
+
50% {
|
| 316 |
+
box-shadow: 0 0 30px var(--glow-color), 0 0 40px var(--glow-color);
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.mini-player[data-playing="true"] .control-btn:hover:not(:disabled) {
|
| 321 |
+
background: var(--primary-color-hover);
|
| 322 |
+
transform: scale(1.1);
|
| 323 |
+
box-shadow: 0 0 25px var(--glow-color), 0 0 35px var(--glow-color);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.control-btn:disabled {
|
| 327 |
+
opacity: 0.5;
|
| 328 |
+
cursor: not-allowed;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.progress-background {
|
| 332 |
+
position: absolute;
|
| 333 |
+
bottom: 0;
|
| 334 |
+
left: 0;
|
| 335 |
+
right: 0;
|
| 336 |
+
height: 3px;
|
| 337 |
+
background: rgba(255, 255, 255, 0.15);
|
| 338 |
+
border-radius: 0 0 0 0;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.progress-fill {
|
| 342 |
+
height: 100%;
|
| 343 |
+
background: var(--primary-color);
|
| 344 |
+
transition: width 0.1s ease;
|
| 345 |
+
border-radius: 0 0 0 0;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
/* 动画 */
|
| 349 |
+
.slide-up-enter-active,
|
| 350 |
+
.slide-up-leave-active {
|
| 351 |
+
transition: transform 0.3s ease;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.slide-up-enter-from,
|
| 355 |
+
.slide-up-leave-to {
|
| 356 |
+
transform: translateY(100%);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
/* 响应式 */
|
| 360 |
+
@media (max-width: 375px) {
|
| 361 |
+
.mini-player-content {
|
| 362 |
+
padding: 0 12px;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.cover-container {
|
| 366 |
+
width: 44px;
|
| 367 |
+
height: 44px;
|
| 368 |
+
margin-right: 10px;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.control-btn {
|
| 372 |
+
width: 36px;
|
| 373 |
+
height: 36px;
|
| 374 |
+
font-size: 14px;
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
</style>
|
src/components/layout/SearchHeader.vue
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="search-header" :class="{ 'transparent': isTransparent, 'searching': isSearching }">
|
| 3 |
+
<div class="header-content">
|
| 4 |
+
<!-- 左侧返回按钮 -->
|
| 5 |
+
<button
|
| 6 |
+
v-if="showBackButton"
|
| 7 |
+
class="back-btn"
|
| 8 |
+
@click="handleBack"
|
| 9 |
+
:title="backButtonTitle"
|
| 10 |
+
>
|
| 11 |
+
<i class="fas fa-arrow-left"></i>
|
| 12 |
+
</button>
|
| 13 |
+
|
| 14 |
+
<!-- 搜索框 -->
|
| 15 |
+
<div class="search-container">
|
| 16 |
+
<div class="search-input-wrapper">
|
| 17 |
+
<i class="fas fa-search search-icon"></i>
|
| 18 |
+
<input
|
| 19 |
+
ref="searchInput"
|
| 20 |
+
type="text"
|
| 21 |
+
v-model="searchValue"
|
| 22 |
+
:placeholder="placeholder"
|
| 23 |
+
class="search-input"
|
| 24 |
+
@input="handleInput"
|
| 25 |
+
@focus="handleFocus"
|
| 26 |
+
@blur="handleBlur"
|
| 27 |
+
@keyup.enter="handleSearch"
|
| 28 |
+
@keyup.esc="handleEscape"
|
| 29 |
+
>
|
| 30 |
+
<button
|
| 31 |
+
v-if="searchValue && clearable"
|
| 32 |
+
class="clear-btn"
|
| 33 |
+
@click="handleClear"
|
| 34 |
+
title="清空"
|
| 35 |
+
>
|
| 36 |
+
<i class="fas fa-times"></i>
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- 音乐源选择按钮 -->
|
| 41 |
+
<button
|
| 42 |
+
v-if="showSourceSelector"
|
| 43 |
+
class="source-btn"
|
| 44 |
+
@click="handleSourceSelect"
|
| 45 |
+
:title="`当前音乐源: ${currentSourceName}`"
|
| 46 |
+
>
|
| 47 |
+
<i class="fas fa-music"></i>
|
| 48 |
+
<span class="source-text">{{ currentSourceName }}</span>
|
| 49 |
+
<i class="fas fa-chevron-down"></i>
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- 右侧操作按钮 -->
|
| 54 |
+
<div class="header-actions" v-if="$slots.actions">
|
| 55 |
+
<slot name="actions"></slot>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- 搜索建议下拉框 -->
|
| 60 |
+
<div
|
| 61 |
+
v-if="showSuggestions && suggestions.length > 0"
|
| 62 |
+
class="search-suggestions"
|
| 63 |
+
@click.stop
|
| 64 |
+
>
|
| 65 |
+
<div
|
| 66 |
+
v-for="(suggestion, index) in suggestions"
|
| 67 |
+
:key="index"
|
| 68 |
+
class="suggestion-item"
|
| 69 |
+
:class="{ 'active': selectedSuggestionIndex === index }"
|
| 70 |
+
@click="selectSuggestion(suggestion)"
|
| 71 |
+
@mouseover="selectedSuggestionIndex = index"
|
| 72 |
+
>
|
| 73 |
+
<i class="fas fa-search"></i>
|
| 74 |
+
<span class="suggestion-text">{{ suggestion }}</span>
|
| 75 |
+
<i class="fas fa-arrow-up-right suggestion-action"></i>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</template>
|
| 80 |
+
|
| 81 |
+
<script setup>
|
| 82 |
+
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
| 83 |
+
import { useSearchStore } from '@/stores/search'
|
| 84 |
+
|
| 85 |
+
const props = defineProps({
|
| 86 |
+
// 搜索值
|
| 87 |
+
modelValue: {
|
| 88 |
+
type: String,
|
| 89 |
+
default: ''
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
// 占位符文本
|
| 93 |
+
placeholder: {
|
| 94 |
+
type: String,
|
| 95 |
+
default: '搜索歌曲、歌手或专辑'
|
| 96 |
+
},
|
| 97 |
+
|
| 98 |
+
// 是否显示返回按钮
|
| 99 |
+
showBackButton: {
|
| 100 |
+
type: Boolean,
|
| 101 |
+
default: false
|
| 102 |
+
},
|
| 103 |
+
|
| 104 |
+
// 返回按钮标题
|
| 105 |
+
backButtonTitle: {
|
| 106 |
+
type: String,
|
| 107 |
+
default: '返回'
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
// 是否显示音乐源选择器
|
| 111 |
+
showSourceSelector: {
|
| 112 |
+
type: Boolean,
|
| 113 |
+
default: true
|
| 114 |
+
},
|
| 115 |
+
|
| 116 |
+
// 是否可清空
|
| 117 |
+
clearable: {
|
| 118 |
+
type: Boolean,
|
| 119 |
+
default: true
|
| 120 |
+
},
|
| 121 |
+
|
| 122 |
+
// 是否透明背景
|
| 123 |
+
isTransparent: {
|
| 124 |
+
type: Boolean,
|
| 125 |
+
default: false
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// 是否自动对焦
|
| 129 |
+
autofocus: {
|
| 130 |
+
type: Boolean,
|
| 131 |
+
default: false
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
// 是否显示搜索建议
|
| 135 |
+
enableSuggestions: {
|
| 136 |
+
type: Boolean,
|
| 137 |
+
default: true
|
| 138 |
+
},
|
| 139 |
+
|
| 140 |
+
// 搜索建议列表
|
| 141 |
+
suggestionsList: {
|
| 142 |
+
type: Array,
|
| 143 |
+
default: () => []
|
| 144 |
+
}
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
const emit = defineEmits([
|
| 148 |
+
'update:modelValue',
|
| 149 |
+
'search',
|
| 150 |
+
'focus',
|
| 151 |
+
'blur',
|
| 152 |
+
'back',
|
| 153 |
+
'source-select',
|
| 154 |
+
'clear',
|
| 155 |
+
'suggestion-select'
|
| 156 |
+
])
|
| 157 |
+
|
| 158 |
+
const searchStore = useSearchStore()
|
| 159 |
+
const searchInput = ref(null)
|
| 160 |
+
const searchValue = ref(props.modelValue)
|
| 161 |
+
const isSearching = ref(false)
|
| 162 |
+
const isFocused = ref(false)
|
| 163 |
+
const showSuggestions = ref(false)
|
| 164 |
+
const selectedSuggestionIndex = ref(-1)
|
| 165 |
+
|
| 166 |
+
// 计算属性
|
| 167 |
+
const currentSourceName = computed(() => {
|
| 168 |
+
return searchStore.getSourceName(searchStore.currentSource)
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
const suggestions = computed(() => {
|
| 172 |
+
if (!props.enableSuggestions || !searchValue.value || searchValue.value.length < 2) {
|
| 173 |
+
return []
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// 结合传入的建议和历史搜索
|
| 177 |
+
const allSuggestions = [
|
| 178 |
+
...props.suggestionsList,
|
| 179 |
+
...searchStore.getSearchSuggestions(searchValue.value)
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
// 去重并限制数量
|
| 183 |
+
const uniqueSuggestions = [...new Set(allSuggestions)]
|
| 184 |
+
return uniqueSuggestions.slice(0, 5)
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
// 监听props变化
|
| 188 |
+
watch(() => props.modelValue, (newValue) => {
|
| 189 |
+
searchValue.value = newValue
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
watch(searchValue, (newValue) => {
|
| 193 |
+
emit('update:modelValue', newValue)
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
// 方法
|
| 197 |
+
const handleInput = () => {
|
| 198 |
+
if (props.enableSuggestions && searchValue.value.length >= 2) {
|
| 199 |
+
showSuggestions.value = true
|
| 200 |
+
selectedSuggestionIndex.value = -1
|
| 201 |
+
} else {
|
| 202 |
+
showSuggestions.value = false
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const handleFocus = () => {
|
| 207 |
+
isFocused.value = true
|
| 208 |
+
emit('focus')
|
| 209 |
+
|
| 210 |
+
if (props.enableSuggestions && searchValue.value.length >= 2) {
|
| 211 |
+
showSuggestions.value = true
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
const handleBlur = () => {
|
| 216 |
+
isFocused.value = false
|
| 217 |
+
emit('blur')
|
| 218 |
+
|
| 219 |
+
// 延迟隐藏建议,允许点击建议项
|
| 220 |
+
setTimeout(() => {
|
| 221 |
+
showSuggestions.value = false
|
| 222 |
+
}, 200)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const handleSearch = () => {
|
| 226 |
+
if (selectedSuggestionIndex.value >= 0 && suggestions.value[selectedSuggestionIndex.value]) {
|
| 227 |
+
selectSuggestion(suggestions.value[selectedSuggestionIndex.value])
|
| 228 |
+
} else if (searchValue.value.trim()) {
|
| 229 |
+
performSearch(searchValue.value.trim())
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const handleEscape = () => {
|
| 234 |
+
if (showSuggestions.value) {
|
| 235 |
+
showSuggestions.value = false
|
| 236 |
+
selectedSuggestionIndex.value = -1
|
| 237 |
+
} else {
|
| 238 |
+
handleClear()
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const handleClear = () => {
|
| 243 |
+
searchValue.value = ''
|
| 244 |
+
showSuggestions.value = false
|
| 245 |
+
selectedSuggestionIndex.value = -1
|
| 246 |
+
emit('clear')
|
| 247 |
+
|
| 248 |
+
if (searchInput.value) {
|
| 249 |
+
searchInput.value.focus()
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const handleBack = () => {
|
| 254 |
+
emit('back')
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const handleSourceSelect = () => {
|
| 258 |
+
emit('source-select')
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
const selectSuggestion = (suggestion) => {
|
| 262 |
+
searchValue.value = suggestion
|
| 263 |
+
showSuggestions.value = false
|
| 264 |
+
selectedSuggestionIndex.value = -1
|
| 265 |
+
performSearch(suggestion)
|
| 266 |
+
emit('suggestion-select', suggestion)
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const performSearch = (query) => {
|
| 270 |
+
isSearching.value = true
|
| 271 |
+
emit('search', query)
|
| 272 |
+
|
| 273 |
+
// 添加到搜索历史
|
| 274 |
+
searchStore.addToHistory({
|
| 275 |
+
keyword: query,
|
| 276 |
+
source: searchStore.currentSource,
|
| 277 |
+
timestamp: Date.now()
|
| 278 |
+
})
|
| 279 |
+
|
| 280 |
+
setTimeout(() => {
|
| 281 |
+
isSearching.value = false
|
| 282 |
+
}, 1000)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const focus = () => {
|
| 286 |
+
if (searchInput.value) {
|
| 287 |
+
searchInput.value.focus()
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
const blur = () => {
|
| 292 |
+
if (searchInput.value) {
|
| 293 |
+
searchInput.value.blur()
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// 键盘导航
|
| 298 |
+
const handleKeyNavigation = (event) => {
|
| 299 |
+
if (!showSuggestions.value || suggestions.value.length === 0) return
|
| 300 |
+
|
| 301 |
+
switch (event.key) {
|
| 302 |
+
case 'ArrowDown':
|
| 303 |
+
event.preventDefault()
|
| 304 |
+
selectedSuggestionIndex.value = Math.min(
|
| 305 |
+
selectedSuggestionIndex.value + 1,
|
| 306 |
+
suggestions.value.length - 1
|
| 307 |
+
)
|
| 308 |
+
break
|
| 309 |
+
case 'ArrowUp':
|
| 310 |
+
event.preventDefault()
|
| 311 |
+
selectedSuggestionIndex.value = Math.max(
|
| 312 |
+
selectedSuggestionIndex.value - 1,
|
| 313 |
+
-1
|
| 314 |
+
)
|
| 315 |
+
break
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// 生命周期
|
| 320 |
+
onMounted(() => {
|
| 321 |
+
if (props.autofocus) {
|
| 322 |
+
nextTick(() => {
|
| 323 |
+
focus()
|
| 324 |
+
})
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
document.addEventListener('keydown', handleKeyNavigation)
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
onUnmounted(() => {
|
| 331 |
+
document.removeEventListener('keydown', handleKeyNavigation)
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
// 暴露方法
|
| 335 |
+
defineExpose({
|
| 336 |
+
focus,
|
| 337 |
+
blur
|
| 338 |
+
})
|
| 339 |
+
</script>
|
| 340 |
+
|
| 341 |
+
<style scoped>
|
| 342 |
+
.search-header {
|
| 343 |
+
background: var(--bg-card);
|
| 344 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 345 |
+
transition: var(--transition-fast);
|
| 346 |
+
position: relative;
|
| 347 |
+
z-index: 10;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.search-header.transparent {
|
| 351 |
+
background: transparent;
|
| 352 |
+
border-bottom: none;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.search-header.searching {
|
| 356 |
+
opacity: 0.9;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.header-content {
|
| 360 |
+
display: flex;
|
| 361 |
+
align-items: center;
|
| 362 |
+
padding: 12px 16px;
|
| 363 |
+
gap: 12px;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.back-btn {
|
| 367 |
+
width: 36px;
|
| 368 |
+
height: 36px;
|
| 369 |
+
border: none;
|
| 370 |
+
background: transparent;
|
| 371 |
+
color: var(--text-primary);
|
| 372 |
+
border-radius: 50%;
|
| 373 |
+
display: flex;
|
| 374 |
+
align-items: center;
|
| 375 |
+
justify-content: center;
|
| 376 |
+
cursor: pointer;
|
| 377 |
+
transition: var(--transition-fast);
|
| 378 |
+
flex-shrink: 0;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.back-btn:hover {
|
| 382 |
+
background: rgba(255, 255, 255, 0.1);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.search-container {
|
| 386 |
+
flex: 1;
|
| 387 |
+
display: flex;
|
| 388 |
+
align-items: center;
|
| 389 |
+
gap: 8px;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.search-input-wrapper {
|
| 393 |
+
flex: 1;
|
| 394 |
+
position: relative;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.search-icon {
|
| 398 |
+
position: absolute;
|
| 399 |
+
left: 12px;
|
| 400 |
+
top: 50%;
|
| 401 |
+
transform: translateY(-50%);
|
| 402 |
+
color: var(--text-tertiary);
|
| 403 |
+
font-size: 14px;
|
| 404 |
+
pointer-events: none;
|
| 405 |
+
z-index: 1;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.search-input {
|
| 409 |
+
width: 100%;
|
| 410 |
+
height: 40px;
|
| 411 |
+
border: none;
|
| 412 |
+
background: rgba(255, 255, 255, 0.08);
|
| 413 |
+
border-radius: 20px;
|
| 414 |
+
padding: 0 40px 0 40px;
|
| 415 |
+
color: var(--text-primary);
|
| 416 |
+
font-size: 14px;
|
| 417 |
+
outline: none;
|
| 418 |
+
transition: var(--transition-fast);
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.search-input:focus {
|
| 422 |
+
background: rgba(255, 255, 255, 0.12);
|
| 423 |
+
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.3);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.search-input::placeholder {
|
| 427 |
+
color: var(--text-tertiary);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.clear-btn {
|
| 431 |
+
position: absolute;
|
| 432 |
+
right: 8px;
|
| 433 |
+
top: 50%;
|
| 434 |
+
transform: translateY(-50%);
|
| 435 |
+
width: 24px;
|
| 436 |
+
height: 24px;
|
| 437 |
+
border: none;
|
| 438 |
+
background: transparent;
|
| 439 |
+
color: var(--text-tertiary);
|
| 440 |
+
border-radius: 50%;
|
| 441 |
+
cursor: pointer;
|
| 442 |
+
transition: var(--transition-fast);
|
| 443 |
+
z-index: 1;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.clear-btn:hover {
|
| 447 |
+
background: rgba(255, 255, 255, 0.1);
|
| 448 |
+
color: var(--text-secondary);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.source-btn {
|
| 452 |
+
display: flex;
|
| 453 |
+
align-items: center;
|
| 454 |
+
gap: 4px;
|
| 455 |
+
padding: 8px 12px;
|
| 456 |
+
border: none;
|
| 457 |
+
background: rgba(255, 255, 255, 0.05);
|
| 458 |
+
color: var(--text-secondary);
|
| 459 |
+
border-radius: 16px;
|
| 460 |
+
font-size: 12px;
|
| 461 |
+
cursor: pointer;
|
| 462 |
+
transition: var(--transition-fast);
|
| 463 |
+
white-space: nowrap;
|
| 464 |
+
flex-shrink: 0;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.source-btn:hover {
|
| 468 |
+
background: rgba(255, 255, 255, 0.1);
|
| 469 |
+
color: var(--text-primary);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.source-text {
|
| 473 |
+
max-width: 60px;
|
| 474 |
+
overflow: hidden;
|
| 475 |
+
text-overflow: ellipsis;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.header-actions {
|
| 479 |
+
display: flex;
|
| 480 |
+
align-items: center;
|
| 481 |
+
gap: 8px;
|
| 482 |
+
flex-shrink: 0;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.search-suggestions {
|
| 486 |
+
position: absolute;
|
| 487 |
+
top: 100%;
|
| 488 |
+
left: 16px;
|
| 489 |
+
right: 16px;
|
| 490 |
+
background: var(--bg-card);
|
| 491 |
+
border-radius: 12px;
|
| 492 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
| 493 |
+
backdrop-filter: blur(20px);
|
| 494 |
+
overflow: hidden;
|
| 495 |
+
z-index: 100;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.suggestion-item {
|
| 499 |
+
display: flex;
|
| 500 |
+
align-items: center;
|
| 501 |
+
gap: 12px;
|
| 502 |
+
padding: 12px 16px;
|
| 503 |
+
cursor: pointer;
|
| 504 |
+
transition: var(--transition-fast);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.suggestion-item:hover,
|
| 508 |
+
.suggestion-item.active {
|
| 509 |
+
background: rgba(255, 255, 255, 0.05);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.suggestion-item i:first-child {
|
| 513 |
+
color: var(--text-tertiary);
|
| 514 |
+
font-size: 12px;
|
| 515 |
+
width: 12px;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.suggestion-text {
|
| 519 |
+
flex: 1;
|
| 520 |
+
color: var(--text-primary);
|
| 521 |
+
font-size: 14px;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.suggestion-action {
|
| 525 |
+
color: var(--text-tertiary);
|
| 526 |
+
font-size: 10px;
|
| 527 |
+
opacity: 0;
|
| 528 |
+
transition: var(--transition-fast);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.suggestion-item:hover .suggestion-action {
|
| 532 |
+
opacity: 1;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/* 响应式 */
|
| 536 |
+
@media (max-width: 375px) {
|
| 537 |
+
.header-content {
|
| 538 |
+
padding: 10px 12px;
|
| 539 |
+
gap: 8px;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.back-btn {
|
| 543 |
+
width: 32px;
|
| 544 |
+
height: 32px;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.search-input {
|
| 548 |
+
height: 36px;
|
| 549 |
+
font-size: 13px;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.source-btn {
|
| 553 |
+
padding: 6px 10px;
|
| 554 |
+
font-size: 11px;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.source-text {
|
| 558 |
+
max-width: 50px;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
.search-suggestions {
|
| 562 |
+
left: 12px;
|
| 563 |
+
right: 12px;
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
/* 平板和桌面优化 */
|
| 568 |
+
@media (min-width: 768px) {
|
| 569 |
+
.header-content {
|
| 570 |
+
padding: 16px 24px;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
.search-input {
|
| 574 |
+
height: 44px;
|
| 575 |
+
font-size: 15px;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.source-text {
|
| 579 |
+
max-width: 80px;
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
/* 动画效果 */
|
| 584 |
+
.search-suggestions {
|
| 585 |
+
animation: slide-down 0.2s ease-out;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
@keyframes slide-down {
|
| 589 |
+
from {
|
| 590 |
+
opacity: 0;
|
| 591 |
+
transform: translateY(-10px);
|
| 592 |
+
}
|
| 593 |
+
to {
|
| 594 |
+
opacity: 1;
|
| 595 |
+
transform: translateY(0);
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.search-icon {
|
| 600 |
+
animation: search-pulse 2s ease-in-out infinite;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
@keyframes search-pulse {
|
| 604 |
+
0%, 100% {
|
| 605 |
+
opacity: 0.6;
|
| 606 |
+
}
|
| 607 |
+
50% {
|
| 608 |
+
opacity: 1;
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.search-header.searching .search-icon {
|
| 613 |
+
animation: search-spin 1s linear infinite;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
@keyframes search-spin {
|
| 617 |
+
from {
|
| 618 |
+
transform: translateY(-50%) rotate(0deg);
|
| 619 |
+
}
|
| 620 |
+
to {
|
| 621 |
+
transform: translateY(-50%) rotate(360deg);
|
| 622 |
+
}
|
| 623 |
+
}
|
| 624 |
+
</style>
|
src/components/player/AlbumCover.vue
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="album-cover-container" :style="coverStyle">
|
| 3 |
+
<div
|
| 4 |
+
class="album-cover"
|
| 5 |
+
:class="{ playing: isPlaying }"
|
| 6 |
+
@click="$emit('click')"
|
| 7 |
+
>
|
| 8 |
+
<img
|
| 9 |
+
:src="displayUrl"
|
| 10 |
+
:alt="alt"
|
| 11 |
+
class="cover-image"
|
| 12 |
+
@error="handleImageError"
|
| 13 |
+
@load="handleImageLoad"
|
| 14 |
+
/>
|
| 15 |
+
|
| 16 |
+
<!-- 唱片中心圆点 -->
|
| 17 |
+
<div class="center-dot">
|
| 18 |
+
<div class="inner-dot"></div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<!-- 加载状态 -->
|
| 22 |
+
<div v-if="loading" class="loading-overlay">
|
| 23 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<!-- 装饰性光晕效果 -->
|
| 28 |
+
<div class="cover-glow" :class="{ active: isPlaying }"></div>
|
| 29 |
+
</div>
|
| 30 |
+
</template>
|
| 31 |
+
|
| 32 |
+
<script setup>
|
| 33 |
+
import { ref, computed, watch } from 'vue'
|
| 34 |
+
|
| 35 |
+
const props = defineProps({
|
| 36 |
+
src: {
|
| 37 |
+
type: String,
|
| 38 |
+
default: ''
|
| 39 |
+
},
|
| 40 |
+
alt: {
|
| 41 |
+
type: String,
|
| 42 |
+
default: '专辑封面'
|
| 43 |
+
},
|
| 44 |
+
size: {
|
| 45 |
+
type: Number,
|
| 46 |
+
default: 300
|
| 47 |
+
},
|
| 48 |
+
isPlaying: {
|
| 49 |
+
type: Boolean,
|
| 50 |
+
default: false
|
| 51 |
+
}
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
const emit = defineEmits(['click', 'load', 'error'])
|
| 55 |
+
|
| 56 |
+
// 默认封面函数
|
| 57 |
+
const getDefaultCover = () => {
|
| 58 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjgwIiBoZWlnaHQ9IjI4MCIgdmlld0JveD0iMCAwIDI4MCAyODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyODAiIGhlaWdodD0iMjgwIiBmaWxsPSJyZ2JhKDIwMCwyMDAsMjAwLDAuMykiIHJ4PSI0MCIvPgo8Y2lyY2xlIGN4PSIxNDAiIGN5PSIxNDAiIHI9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiLz4KPHN2ZyB4PSIxMTAiIHk9IjExMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9InJnYmEoMTYwLDE2MCwxNjAsMC42KSI+CjxwYXRoIGQ9Ik0xMiAzdjEwLjU1Yy0uNTktLjM0LTEuMjctLjU1LTItLjU1QzcuNzkgMTMgNiAxNC43OSA2IDE3czEuNzkgNCA0IDRjMS45NCAwIDMuNS0xLjI5IDMuOTEtM0gxNFYzeiIvPgo8L3N2Zz4KPC9zdmc+'
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 响应式数据
|
| 62 |
+
const loading = ref(false)
|
| 63 |
+
const error = ref(false)
|
| 64 |
+
|
| 65 |
+
// 计算属性
|
| 66 |
+
const coverStyle = computed(() => ({
|
| 67 |
+
width: `${props.size}px`,
|
| 68 |
+
height: `${props.size}px`,
|
| 69 |
+
'--size': `${props.size}px`
|
| 70 |
+
}))
|
| 71 |
+
|
| 72 |
+
const displayUrl = computed(() => {
|
| 73 |
+
return props.src || getDefaultCover()
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
// 方法
|
| 77 |
+
const handleImageLoad = () => {
|
| 78 |
+
loading.value = false
|
| 79 |
+
error.value = false
|
| 80 |
+
emit('load')
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const handleImageError = () => {
|
| 84 |
+
loading.value = false
|
| 85 |
+
error.value = true
|
| 86 |
+
emit('error')
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 监听src变化
|
| 90 |
+
watch(() => props.src, (newSrc) => {
|
| 91 |
+
if (newSrc) {
|
| 92 |
+
loading.value = true
|
| 93 |
+
error.value = false
|
| 94 |
+
}
|
| 95 |
+
}, { immediate: true })
|
| 96 |
+
</script>
|
| 97 |
+
|
| 98 |
+
<style scoped>
|
| 99 |
+
.album-cover-container {
|
| 100 |
+
position: relative;
|
| 101 |
+
display: inline-block;
|
| 102 |
+
border-radius: 50%;
|
| 103 |
+
--cover-size: var(--size, 300px);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.album-cover {
|
| 107 |
+
position: relative;
|
| 108 |
+
border-radius: 50%;
|
| 109 |
+
overflow: hidden;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
transition: var(--transition-slow);
|
| 112 |
+
box-shadow:
|
| 113 |
+
0 8px 25px rgba(0, 0, 0, 0.3);
|
| 114 |
+
border: none;
|
| 115 |
+
width: 100%;
|
| 116 |
+
height: 100%;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.album-cover:hover {
|
| 120 |
+
transform: scale(1.02);
|
| 121 |
+
box-shadow:
|
| 122 |
+
0 12px 35px rgba(0, 0, 0, 0.4);
|
| 123 |
+
border: none;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.album-cover.playing {
|
| 127 |
+
animation: rotate 20s linear infinite;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
@keyframes rotate {
|
| 131 |
+
from { transform: rotate(0deg); }
|
| 132 |
+
to { transform: rotate(360deg); }
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.cover-image {
|
| 136 |
+
width: 100%;
|
| 137 |
+
height: 100%;
|
| 138 |
+
object-fit: cover;
|
| 139 |
+
transition: var(--transition-fast);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.center-dot {
|
| 143 |
+
position: absolute;
|
| 144 |
+
top: 50%;
|
| 145 |
+
left: 50%;
|
| 146 |
+
transform: translate(-50%, -50%);
|
| 147 |
+
width: calc(var(--cover-size) * 0.14);
|
| 148 |
+
height: calc(var(--cover-size) * 0.14);
|
| 149 |
+
border-radius: 50%;
|
| 150 |
+
background: rgba(0, 0, 0, 0.6);
|
| 151 |
+
backdrop-filter: blur(10px);
|
| 152 |
+
display: flex;
|
| 153 |
+
align-items: center;
|
| 154 |
+
justify-content: center;
|
| 155 |
+
z-index: 2;
|
| 156 |
+
border: 1px solid var(--border-light);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.inner-dot {
|
| 160 |
+
width: calc(var(--cover-size) * 0.04);
|
| 161 |
+
height: calc(var(--cover-size) * 0.04);
|
| 162 |
+
border-radius: 50%;
|
| 163 |
+
background: rgba(255, 255, 255, 0.9);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.loading-overlay {
|
| 167 |
+
position: absolute;
|
| 168 |
+
top: 0;
|
| 169 |
+
left: 0;
|
| 170 |
+
right: 0;
|
| 171 |
+
bottom: 0;
|
| 172 |
+
background: rgba(0, 0, 0, 0.7);
|
| 173 |
+
backdrop-filter: blur(4px);
|
| 174 |
+
display: flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
justify-content: center;
|
| 177 |
+
color: var(--accent-red);
|
| 178 |
+
font-size: 24px;
|
| 179 |
+
z-index: 3;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.cover-glow {
|
| 183 |
+
position: absolute;
|
| 184 |
+
top: -20px;
|
| 185 |
+
left: -20px;
|
| 186 |
+
right: -20px;
|
| 187 |
+
bottom: -20px;
|
| 188 |
+
border-radius: 50%;
|
| 189 |
+
background: radial-gradient(circle, var(--accent-red) 0%, transparent 70%);
|
| 190 |
+
opacity: 0;
|
| 191 |
+
transition: opacity var(--transition-slow);
|
| 192 |
+
z-index: -1;
|
| 193 |
+
pointer-events: none;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.cover-glow.active {
|
| 197 |
+
opacity: 0.2;
|
| 198 |
+
animation: pulse 2s ease-in-out infinite;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
@keyframes pulse {
|
| 202 |
+
0%, 100% { opacity: 0.2; transform: scale(1); }
|
| 203 |
+
50% { opacity: 0.3; transform: scale(1.05); }
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* 响应式 */
|
| 207 |
+
@media (max-width: 375px) {
|
| 208 |
+
.center-dot {
|
| 209 |
+
width: 32px;
|
| 210 |
+
height: 32px;
|
| 211 |
+
border: 1px solid var(--border-light);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.inner-dot {
|
| 215 |
+
width: 10px;
|
| 216 |
+
height: 10px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.loading-overlay {
|
| 220 |
+
font-size: 20px;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.album-cover {
|
| 224 |
+
border: 1px solid var(--border-light);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
</style>
|
src/components/player/LyricsView.vue
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="lyrics-view" ref="lyricsContainer">
|
| 3 |
+
<div class="lyrics-content" :style="{ transform: `translateY(${translateY}px)` }">
|
| 4 |
+
<!-- 无歌词状态 -->
|
| 5 |
+
<div v-if="!lyrics.length" class="no-lyrics">
|
| 6 |
+
<i class="fas fa-music"></i>
|
| 7 |
+
<p>暂无歌词</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<!-- 歌词列表 -->
|
| 11 |
+
<div
|
| 12 |
+
v-for="(line, index) in lyrics"
|
| 13 |
+
:key="index"
|
| 14 |
+
:ref="el => setLineRef(el, index)"
|
| 15 |
+
class="lyric-line"
|
| 16 |
+
:class="{
|
| 17 |
+
active: index === currentLineIndex,
|
| 18 |
+
passed: index < currentLineIndex,
|
| 19 |
+
future: index > currentLineIndex
|
| 20 |
+
}"
|
| 21 |
+
@click="handleLineClick(line, index)"
|
| 22 |
+
>
|
| 23 |
+
{{ line.text }}
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<!-- 底部占位 -->
|
| 27 |
+
<div class="lyrics-spacer"></div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<!-- PC端歌词提示 -->
|
| 31 |
+
<div class="desktop-lyrics-hint" v-if="isDesktop">
|
| 32 |
+
<p>歌词已完整显示</p>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</template>
|
| 36 |
+
|
| 37 |
+
<script setup>
|
| 38 |
+
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
const props = defineProps({
|
| 42 |
+
lyrics: {
|
| 43 |
+
type: Array,
|
| 44 |
+
default: () => []
|
| 45 |
+
},
|
| 46 |
+
currentTime: {
|
| 47 |
+
type: Number,
|
| 48 |
+
default: 0
|
| 49 |
+
}
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
const emit = defineEmits(['seekTo'])
|
| 53 |
+
|
| 54 |
+
// 响应式数据
|
| 55 |
+
const lyricsContainer = ref(null)
|
| 56 |
+
const lineRefs = ref({})
|
| 57 |
+
const translateY = ref(0)
|
| 58 |
+
const autoScroll = ref(true)
|
| 59 |
+
const fontSize = ref(18)
|
| 60 |
+
const currentLineIndex = ref(-1)
|
| 61 |
+
const lastScrollTime = ref(0)
|
| 62 |
+
const isDesktop = ref(window.innerWidth >= 768)
|
| 63 |
+
const startX = ref(0)
|
| 64 |
+
const startY = ref(0)
|
| 65 |
+
const isSwiping = ref(false)
|
| 66 |
+
const swipeDirection = ref('') // 'left' or 'right'
|
| 67 |
+
|
| 68 |
+
// 监听窗口大小变化
|
| 69 |
+
onMounted(() => {
|
| 70 |
+
const handleResize = () => {
|
| 71 |
+
isDesktop.value = window.innerWidth >= 768
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
window.addEventListener('resize', handleResize)
|
| 75 |
+
|
| 76 |
+
// 清理事件监听器
|
| 77 |
+
onUnmounted(() => {
|
| 78 |
+
window.removeEventListener('resize', handleResize)
|
| 79 |
+
})
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
// 计算属性
|
| 83 |
+
const parsedLyrics = computed(() => {
|
| 84 |
+
// 歌词已经从musicApi中解析过了,直接返回
|
| 85 |
+
return Array.isArray(props.lyrics) ? props.lyrics : []
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
// 方法
|
| 89 |
+
const setLineRef = (el, index) => {
|
| 90 |
+
if (el) {
|
| 91 |
+
lineRefs.value[index] = el
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const findCurrentLine = (time) => {
|
| 96 |
+
if (!parsedLyrics.value.length || time < 0) return -1
|
| 97 |
+
|
| 98 |
+
let currentIndex = -1
|
| 99 |
+
|
| 100 |
+
// 查找最后一行时间小于等于当前时间的歌词
|
| 101 |
+
for (let i = 0; i < parsedLyrics.value.length; i++) {
|
| 102 |
+
const lyricTime = parsedLyrics.value[i].time
|
| 103 |
+
if (lyricTime <= time) {
|
| 104 |
+
currentIndex = i
|
| 105 |
+
} else {
|
| 106 |
+
break
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return currentIndex
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const scrollToLine = (index, smooth = true) => {
|
| 114 |
+
if (!lyricsContainer.value || index < 0 || !lineRefs.value[index]) return
|
| 115 |
+
|
| 116 |
+
// 桌面端不滚动,显示完整歌词
|
| 117 |
+
if (isDesktop.value) return
|
| 118 |
+
|
| 119 |
+
const container = lyricsContainer.value
|
| 120 |
+
const lineElement = lineRefs.value[index]
|
| 121 |
+
|
| 122 |
+
if (!lineElement) return
|
| 123 |
+
|
| 124 |
+
const containerHeight = container.clientHeight
|
| 125 |
+
const lineOffsetTop = lineElement.offsetTop
|
| 126 |
+
const lineHeight = lineElement.clientHeight
|
| 127 |
+
|
| 128 |
+
// 计算目标位置(将当前行显示在容器中部稍上方的位置)
|
| 129 |
+
const targetOffset = containerHeight * 0.4 // 40% 的位置,而不是正中央
|
| 130 |
+
const targetY = targetOffset - lineOffsetTop - lineHeight / 2
|
| 131 |
+
|
| 132 |
+
if (smooth) {
|
| 133 |
+
// 使用更快的滚动动画
|
| 134 |
+
animateScroll(translateY.value, targetY, 300)
|
| 135 |
+
} else {
|
| 136 |
+
translateY.value = targetY
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const animateScroll = (from, to, duration) => {
|
| 141 |
+
const startTime = performance.now()
|
| 142 |
+
|
| 143 |
+
const animate = (currentTime) => {
|
| 144 |
+
const elapsed = currentTime - startTime
|
| 145 |
+
const progress = Math.min(elapsed / duration, 1)
|
| 146 |
+
|
| 147 |
+
// 使用 easeOutCubic 缓动函数
|
| 148 |
+
const easeProgress = 1 - Math.pow(1 - progress, 3)
|
| 149 |
+
|
| 150 |
+
translateY.value = from + (to - from) * easeProgress
|
| 151 |
+
|
| 152 |
+
if (progress < 1) {
|
| 153 |
+
requestAnimationFrame(animate)
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
requestAnimationFrame(animate)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const handleLineClick = (line, index) => {
|
| 161 |
+
if (line.time > 0) {
|
| 162 |
+
emit('seekTo', line.time)
|
| 163 |
+
currentLineIndex.value = index
|
| 164 |
+
scrollToLine(index)
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const toggleAutoScroll = () => {
|
| 169 |
+
autoScroll.value = !autoScroll.value
|
| 170 |
+
if (autoScroll.value && currentLineIndex.value >= 0) {
|
| 171 |
+
scrollToLine(currentLineIndex.value)
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const handleScroll = (event) => {
|
| 176 |
+
// 用户手动滚动时暂时禁用自动滚动
|
| 177 |
+
if (autoScroll.value) {
|
| 178 |
+
autoScroll.value = false
|
| 179 |
+
lastScrollTime.value = Date.now()
|
| 180 |
+
|
| 181 |
+
// 3秒后重新启用自动滚动
|
| 182 |
+
setTimeout(() => {
|
| 183 |
+
if (Date.now() - lastScrollTime.value >= 3000) {
|
| 184 |
+
autoScroll.value = true
|
| 185 |
+
}
|
| 186 |
+
}, 3000)
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// 手机端滑动切换处理
|
| 191 |
+
const handleTouchStart = (event) => {
|
| 192 |
+
if (!isDesktop.value) {
|
| 193 |
+
startX.value = event.touches[0].clientX
|
| 194 |
+
startY.value = event.touches[0].clientY
|
| 195 |
+
isSwiping.value = true
|
| 196 |
+
swipeDirection.value = ''
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const handleTouchMove = (event) => {
|
| 201 |
+
if (!isDesktop.value && isSwiping.value) {
|
| 202 |
+
const currentX = event.touches[0].clientX
|
| 203 |
+
const currentY = event.touches[0].clientY
|
| 204 |
+
const diffX = startX.value - currentX
|
| 205 |
+
const diffY = startY.value - currentY
|
| 206 |
+
|
| 207 |
+
// 判断是否为水平滑动
|
| 208 |
+
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 30) {
|
| 209 |
+
swipeDirection.value = diffX > 0 ? 'left' : 'right'
|
| 210 |
+
event.preventDefault()
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
const handleTouchEnd = (event) => {
|
| 216 |
+
if (!isDesktop.value && isSwiping.value) {
|
| 217 |
+
// 触发左右滑动事件
|
| 218 |
+
if (swipeDirection.value === 'left') {
|
| 219 |
+
emit('swipeLeft')
|
| 220 |
+
} else if (swipeDirection.value === 'right') {
|
| 221 |
+
emit('swipeRight')
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
isSwiping.value = false
|
| 225 |
+
swipeDirection.value = ''
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// 生命周期
|
| 230 |
+
onMounted(() => {
|
| 231 |
+
// 恢复字体大小设置
|
| 232 |
+
const savedFontSize = localStorage.getItem('lyrics-font-size')
|
| 233 |
+
if (savedFontSize) {
|
| 234 |
+
fontSize.value = parseInt(savedFontSize)
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// 添加触摸事件监听
|
| 238 |
+
if (lyricsContainer.value) {
|
| 239 |
+
lyricsContainer.value.addEventListener('touchstart', handleTouchStart, { passive: false })
|
| 240 |
+
lyricsContainer.value.addEventListener('touchmove', handleTouchMove, { passive: false })
|
| 241 |
+
lyricsContainer.value.addEventListener('touchend', handleTouchEnd, { passive: false })
|
| 242 |
+
}
|
| 243 |
+
})
|
| 244 |
+
|
| 245 |
+
onUnmounted(() => {
|
| 246 |
+
if (lyricsContainer.value) {
|
| 247 |
+
lyricsContainer.value.removeEventListener('touchstart', handleTouchStart)
|
| 248 |
+
lyricsContainer.value.removeEventListener('touchmove', handleTouchMove)
|
| 249 |
+
lyricsContainer.value.removeEventListener('touchend', handleTouchEnd)
|
| 250 |
+
}
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
// 监听当前时间变化
|
| 254 |
+
watch(() => props.currentTime, (newTime) => {
|
| 255 |
+
if (!parsedLyrics.value.length) return
|
| 256 |
+
|
| 257 |
+
const newIndex = findCurrentLine(newTime)
|
| 258 |
+
if (newIndex !== currentLineIndex.value) {
|
| 259 |
+
currentLineIndex.value = newIndex
|
| 260 |
+
|
| 261 |
+
// 只有在自动滚动开启且歌词行有效时才滚动
|
| 262 |
+
if (autoScroll.value && newIndex >= 0) {
|
| 263 |
+
nextTick(() => {
|
| 264 |
+
scrollToLine(newIndex)
|
| 265 |
+
})
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
}, { immediate: false })
|
| 269 |
+
|
| 270 |
+
// 监听歌词变化
|
| 271 |
+
watch(() => props.lyrics, (newLyrics) => {
|
| 272 |
+
// 重置滚动状态
|
| 273 |
+
currentLineIndex.value = -1
|
| 274 |
+
translateY.value = 0
|
| 275 |
+
autoScroll.value = true
|
| 276 |
+
|
| 277 |
+
nextTick(() => {
|
| 278 |
+
// 如果有播放时间,找到对应的歌词行
|
| 279 |
+
if (props.currentTime > 0 && newLyrics && newLyrics.length > 0) {
|
| 280 |
+
const index = findCurrentLine(props.currentTime)
|
| 281 |
+
currentLineIndex.value = index
|
| 282 |
+
if (index >= 0 && autoScroll.value) {
|
| 283 |
+
scrollToLine(index, false)
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
})
|
| 287 |
+
}, { immediate: true })
|
| 288 |
+
</script>
|
| 289 |
+
|
| 290 |
+
<style scoped>
|
| 291 |
+
.lyrics-view {
|
| 292 |
+
position: relative;
|
| 293 |
+
height: 100%;
|
| 294 |
+
width: 100%;
|
| 295 |
+
overflow: hidden;
|
| 296 |
+
padding: 0 20px;
|
| 297 |
+
display: flex;
|
| 298 |
+
flex-direction: column;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.lyrics-content {
|
| 302 |
+
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
| 303 |
+
padding: 60px 0;
|
| 304 |
+
font-size: v-bind(fontSize + 'px');
|
| 305 |
+
line-height: 1.8;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.no-lyrics {
|
| 309 |
+
display: flex;
|
| 310 |
+
flex-direction: column;
|
| 311 |
+
align-items: center;
|
| 312 |
+
justify-content: center;
|
| 313 |
+
height: 200px;
|
| 314 |
+
color: var(--text-tertiary);
|
| 315 |
+
text-align: center;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.no-lyrics i {
|
| 319 |
+
font-size: 48px;
|
| 320 |
+
margin-bottom: 16px;
|
| 321 |
+
opacity: 0.5;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.no-lyrics p {
|
| 325 |
+
font-size: 16px;
|
| 326 |
+
font-weight: 500;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.lyric-line {
|
| 330 |
+
padding: 12px 0;
|
| 331 |
+
text-align: center;
|
| 332 |
+
cursor: pointer;
|
| 333 |
+
transition: all 0.3s ease;
|
| 334 |
+
color: var(--text-tertiary);
|
| 335 |
+
font-weight: 400;
|
| 336 |
+
user-select: none;
|
| 337 |
+
word-break: break-word;
|
| 338 |
+
line-height: 1.6;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.lyric-line:hover {
|
| 342 |
+
color: var(--text-secondary);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.lyric-line.active {
|
| 346 |
+
color: var(--text-primary);
|
| 347 |
+
font-weight: 600;
|
| 348 |
+
font-size: 1.1em;
|
| 349 |
+
text-shadow: 0 0 10px rgba(255, 107, 107, 0.3);
|
| 350 |
+
transform: scale(1.02);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.lyric-line.passed {
|
| 354 |
+
color: var(--text-secondary);
|
| 355 |
+
opacity: 0.6;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.lyric-line.future {
|
| 359 |
+
color: var(--text-tertiary);
|
| 360 |
+
opacity: 0.4;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.lyrics-spacer {
|
| 364 |
+
height: 200px;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.desktop-lyrics-hint {
|
| 368 |
+
position: absolute;
|
| 369 |
+
top: 20px;
|
| 370 |
+
left: 50%;
|
| 371 |
+
transform: translateX(-50%);
|
| 372 |
+
background: rgba(0, 0, 0, 0.6);
|
| 373 |
+
color: rgba(255, 255, 255, 0.8);
|
| 374 |
+
padding: 6px 12px;
|
| 375 |
+
border-radius: 12px;
|
| 376 |
+
font-size: 12px;
|
| 377 |
+
backdrop-filter: blur(4px);
|
| 378 |
+
display: none;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
@media (min-width: 768px) {
|
| 382 |
+
.desktop-lyrics-hint {
|
| 383 |
+
display: block;
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* 响应式 */
|
| 388 |
+
@media (max-width: 375px) {
|
| 389 |
+
.lyrics-view {
|
| 390 |
+
padding: 0 16px;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.lyrics-content {
|
| 394 |
+
padding: 40px 0;
|
| 395 |
+
font-size: v-bind((fontSize - 2) + 'px');
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.lyric-line {
|
| 399 |
+
padding: 10px 0;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.no-lyrics i {
|
| 403 |
+
font-size: 36px;
|
| 404 |
+
margin-bottom: 12px;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.no-lyrics p {
|
| 408 |
+
font-size: 14px;
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
@media (min-width: 768px) {
|
| 413 |
+
.lyrics-view {
|
| 414 |
+
padding: 0 40px;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.lyrics-content {
|
| 418 |
+
padding: 80px 0;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.lyric-line {
|
| 422 |
+
padding: 16px 0;
|
| 423 |
+
font-size: 1.1em;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/* PC端显示所有歌词 */
|
| 427 |
+
.lyrics-content {
|
| 428 |
+
transform: translateY(0) !important;
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
/* 平滑滚动动画 */
|
| 433 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 434 |
+
.lyrics-content {
|
| 435 |
+
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.lyric-line {
|
| 439 |
+
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* 高对比度支持 */
|
| 444 |
+
@media (prefers-contrast: high) {
|
| 445 |
+
.lyric-line.active {
|
| 446 |
+
color: var(--accent-red);
|
| 447 |
+
text-shadow: none;
|
| 448 |
+
border-left: 3px solid var(--accent-red);
|
| 449 |
+
padding-left: 16px;
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
/* 暗色主题适配 */
|
| 454 |
+
</style>
|
src/components/player/MoreActionsPanel.vue
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="more-actions-overlay" @click="$emit('close')">
|
| 3 |
+
<div class="more-actions-panel" @click.stop>
|
| 4 |
+
<!-- 歌曲信息 -->
|
| 5 |
+
<div v-if="song" class="song-info">
|
| 6 |
+
<img
|
| 7 |
+
:src="song.cover || defaultCover"
|
| 8 |
+
:alt="song.name"
|
| 9 |
+
class="song-cover"
|
| 10 |
+
/>
|
| 11 |
+
|
| 12 |
+
<div class="song-details">
|
| 13 |
+
<div class="song-name">{{ song.name }}</div>
|
| 14 |
+
<div class="song-artist">{{ song.artist }}</div>
|
| 15 |
+
<div class="song-album" v-if="song.album">{{ song.album }}</div>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<!-- 操作按钮列表 -->
|
| 20 |
+
<div class="actions-list">
|
| 21 |
+
<button
|
| 22 |
+
class="action-item"
|
| 23 |
+
@click="handleAction('favorite')"
|
| 24 |
+
:class="{ active: isFavorite }"
|
| 25 |
+
>
|
| 26 |
+
<i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
|
| 27 |
+
<span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</span>
|
| 28 |
+
</button>
|
| 29 |
+
|
| 30 |
+
<button class="action-item" @click="handleAction('addToPlaylist')">
|
| 31 |
+
<i class="fas fa-plus"></i>
|
| 32 |
+
<span>添加到播放列表</span>
|
| 33 |
+
</button>
|
| 34 |
+
|
| 35 |
+
<button class="action-item" @click="handleAction('download')">
|
| 36 |
+
<i class="fas fa-download"></i>
|
| 37 |
+
<span>下载到本地</span>
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- 取消按钮 -->
|
| 42 |
+
<button class="cancel-btn" @click="$emit('close')">
|
| 43 |
+
取消
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</template>
|
| 48 |
+
|
| 49 |
+
<script setup>
|
| 50 |
+
import { computed } from 'vue'
|
| 51 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 52 |
+
|
| 53 |
+
const props = defineProps({
|
| 54 |
+
song: {
|
| 55 |
+
type: Object,
|
| 56 |
+
default: null
|
| 57 |
+
}
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
const emit = defineEmits(['close', 'action'])
|
| 61 |
+
|
| 62 |
+
const favoritesStore = useFavoritesStore()
|
| 63 |
+
|
| 64 |
+
// 计算属性
|
| 65 |
+
const isFavorite = computed(() => {
|
| 66 |
+
return props.song ? favoritesStore.isFavorite(props.song) : false
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
const defaultCover = computed(() => {
|
| 70 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSIxMiIvPgo8cGF0aCBkPSJNNDAgMjhMNDYgNDBINDJWNTBIMzhWNDBIMzRMNDAgMjhaIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMykiLz4KPC9zdmc+Cg=='
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
// 方法
|
| 74 |
+
const handleAction = async (action) => {
|
| 75 |
+
if (!props.song && action !== 'close') return
|
| 76 |
+
|
| 77 |
+
switch (action) {
|
| 78 |
+
case 'favorite':
|
| 79 |
+
try {
|
| 80 |
+
if (isFavorite.value) {
|
| 81 |
+
await favoritesStore.removeFromFavorites(props.song)
|
| 82 |
+
} else {
|
| 83 |
+
await favoritesStore.addToFavorites(props.song)
|
| 84 |
+
}
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error('收藏操作失败:', error)
|
| 87 |
+
}
|
| 88 |
+
break
|
| 89 |
+
|
| 90 |
+
case 'addToPlaylist':
|
| 91 |
+
// 实现添加到播放列表
|
| 92 |
+
break
|
| 93 |
+
|
| 94 |
+
case 'download':
|
| 95 |
+
// 实现下载功能
|
| 96 |
+
try {
|
| 97 |
+
// 这里可以调用下载逻辑
|
| 98 |
+
const link = document.createElement('a')
|
| 99 |
+
if (props.song.url) {
|
| 100 |
+
link.href = props.song.url
|
| 101 |
+
link.download = `${props.song.artist} - ${props.song.name}.mp3`
|
| 102 |
+
document.body.appendChild(link)
|
| 103 |
+
link.click()
|
| 104 |
+
document.body.removeChild(link)
|
| 105 |
+
}
|
| 106 |
+
} catch (error) {
|
| 107 |
+
console.error('下载失败:', error)
|
| 108 |
+
}
|
| 109 |
+
break
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
emit('action', action)
|
| 113 |
+
}
|
| 114 |
+
</script>
|
| 115 |
+
|
| 116 |
+
<style scoped>
|
| 117 |
+
.more-actions-overlay {
|
| 118 |
+
position: fixed;
|
| 119 |
+
top: 0;
|
| 120 |
+
left: 0;
|
| 121 |
+
right: 0;
|
| 122 |
+
bottom: 0;
|
| 123 |
+
background: rgba(0, 0, 0, 0.6);
|
| 124 |
+
backdrop-filter: blur(10px);
|
| 125 |
+
z-index: 2000;
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: flex-end;
|
| 128 |
+
animation: fadeIn 0.3s ease-out;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.more-actions-panel {
|
| 132 |
+
width: 100%;
|
| 133 |
+
background: var(--bg-secondary);
|
| 134 |
+
border-radius: 16px 16px 0 0;
|
| 135 |
+
padding: 0 0 20px;
|
| 136 |
+
animation: slideUp 0.3s ease-out;
|
| 137 |
+
overflow: hidden;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.song-info {
|
| 141 |
+
display: flex;
|
| 142 |
+
align-items: center;
|
| 143 |
+
gap: 16px;
|
| 144 |
+
padding: 24px 20px 20px;
|
| 145 |
+
border-bottom: 1px solid var(--border-light);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.song-cover {
|
| 149 |
+
width: 64px;
|
| 150 |
+
height: 64px;
|
| 151 |
+
border-radius: 12px;
|
| 152 |
+
object-fit: cover;
|
| 153 |
+
flex-shrink: 0;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.song-details {
|
| 157 |
+
flex: 1;
|
| 158 |
+
min-width: 0;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.song-name {
|
| 162 |
+
font-size: 18px;
|
| 163 |
+
font-weight: 600;
|
| 164 |
+
color: var(--text-primary);
|
| 165 |
+
margin-bottom: 6px;
|
| 166 |
+
white-space: nowrap;
|
| 167 |
+
overflow: hidden;
|
| 168 |
+
text-overflow: ellipsis;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.song-artist {
|
| 172 |
+
font-size: 14px;
|
| 173 |
+
color: var(--text-secondary);
|
| 174 |
+
margin-bottom: 4px;
|
| 175 |
+
white-space: nowrap;
|
| 176 |
+
overflow: hidden;
|
| 177 |
+
text-overflow: ellipsis;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.song-album {
|
| 181 |
+
font-size: 12px;
|
| 182 |
+
color: var(--text-tertiary);
|
| 183 |
+
white-space: nowrap;
|
| 184 |
+
overflow: hidden;
|
| 185 |
+
text-overflow: ellipsis;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.actions-list {
|
| 189 |
+
padding: 8px 0;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.action-item {
|
| 193 |
+
width: 100%;
|
| 194 |
+
display: flex;
|
| 195 |
+
align-items: center;
|
| 196 |
+
gap: 16px;
|
| 197 |
+
padding: 16px 20px;
|
| 198 |
+
border: none;
|
| 199 |
+
background: transparent;
|
| 200 |
+
color: var(--text-primary);
|
| 201 |
+
font-size: 16px;
|
| 202 |
+
text-align: left;
|
| 203 |
+
cursor: pointer;
|
| 204 |
+
transition: var(--transition-fast);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.action-item:hover {
|
| 208 |
+
background: rgba(255, 255, 255, 0.05);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.action-item:active {
|
| 212 |
+
background: rgba(255, 255, 255, 0.1);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.action-item i {
|
| 216 |
+
width: 20px;
|
| 217 |
+
text-align: center;
|
| 218 |
+
font-size: 16px;
|
| 219 |
+
flex-shrink: 0;
|
| 220 |
+
color: var(--text-secondary);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.action-item.active {
|
| 224 |
+
color: var(--accent-red);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.action-item.active i {
|
| 228 |
+
color: var(--accent-red);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.action-item.danger {
|
| 232 |
+
color: #ff4444;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.action-item.danger i {
|
| 236 |
+
color: #ff4444;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.cancel-btn {
|
| 240 |
+
width: calc(100% - 40px);
|
| 241 |
+
margin: 16px 20px 0;
|
| 242 |
+
padding: 16px;
|
| 243 |
+
border: none;
|
| 244 |
+
background: rgba(255, 255, 255, 0.1);
|
| 245 |
+
color: var(--text-primary);
|
| 246 |
+
font-size: 16px;
|
| 247 |
+
font-weight: 500;
|
| 248 |
+
border-radius: 12px;
|
| 249 |
+
cursor: pointer;
|
| 250 |
+
transition: var(--transition-fast);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.cancel-btn:hover {
|
| 254 |
+
background: rgba(255, 255, 255, 0.15);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.cancel-btn:active {
|
| 258 |
+
background: rgba(255, 255, 255, 0.2);
|
| 259 |
+
transform: scale(0.98);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* 响应式 */
|
| 263 |
+
@media (max-width: 375px) {
|
| 264 |
+
.song-info {
|
| 265 |
+
padding: 20px 16px 16px;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.song-cover {
|
| 269 |
+
width: 56px;
|
| 270 |
+
height: 56px;
|
| 271 |
+
border-radius: 10px;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.song-name {
|
| 275 |
+
font-size: 16px;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.song-artist {
|
| 279 |
+
font-size: 13px;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.song-album {
|
| 283 |
+
font-size: 11px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.action-item {
|
| 287 |
+
padding: 14px 16px;
|
| 288 |
+
font-size: 15px;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.action-item i {
|
| 292 |
+
width: 18px;
|
| 293 |
+
font-size: 15px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.cancel-btn {
|
| 297 |
+
width: calc(100% - 32px);
|
| 298 |
+
margin: 16px 16px 0;
|
| 299 |
+
padding: 14px;
|
| 300 |
+
font-size: 15px;
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
@media (min-width: 768px) {
|
| 305 |
+
.more-actions-panel {
|
| 306 |
+
max-width: 480px;
|
| 307 |
+
margin: 0 auto 0;
|
| 308 |
+
border-radius: 16px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.more-actions-overlay {
|
| 312 |
+
align-items: center;
|
| 313 |
+
padding: 20px;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.song-info {
|
| 317 |
+
padding: 28px 24px 24px;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.song-cover {
|
| 321 |
+
width: 72px;
|
| 322 |
+
height: 72px;
|
| 323 |
+
border-radius: 14px;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.action-item {
|
| 327 |
+
padding: 18px 24px;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.cancel-btn {
|
| 331 |
+
width: calc(100% - 48px);
|
| 332 |
+
margin: 20px 24px 0;
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* 动画 */
|
| 337 |
+
@keyframes fadeIn {
|
| 338 |
+
from { opacity: 0; }
|
| 339 |
+
to { opacity: 1; }
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
@keyframes slideUp {
|
| 343 |
+
from { transform: translateY(100%); }
|
| 344 |
+
to { transform: translateY(0); }
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/* 无障碍支持 */
|
| 348 |
+
.action-item:focus-visible {
|
| 349 |
+
outline: 2px solid var(--accent-red);
|
| 350 |
+
outline-offset: 2px;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.cancel-btn:focus-visible {
|
| 354 |
+
outline: 2px solid var(--accent-red);
|
| 355 |
+
outline-offset: 2px;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* 触摸反馈 */
|
| 359 |
+
@media (hover: none) {
|
| 360 |
+
.action-item:active {
|
| 361 |
+
background: rgba(255, 255, 255, 0.1);
|
| 362 |
+
transform: scale(0.98);
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
</style>
|
src/components/player/PlayControls.vue
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="play-controls">
|
| 3 |
+
<button
|
| 4 |
+
class="control-btn mode-btn"
|
| 5 |
+
@click="togglePlayMode"
|
| 6 |
+
:title="playModeText"
|
| 7 |
+
>
|
| 8 |
+
<i :class="playModeIcon"></i>
|
| 9 |
+
</button>
|
| 10 |
+
|
| 11 |
+
<button
|
| 12 |
+
class="control-btn prev-btn"
|
| 13 |
+
@click="$emit('previous')"
|
| 14 |
+
:disabled="!hasPrevious"
|
| 15 |
+
title="上一首"
|
| 16 |
+
>
|
| 17 |
+
<i class="fas fa-step-backward"></i>
|
| 18 |
+
</button>
|
| 19 |
+
|
| 20 |
+
<button
|
| 21 |
+
class="control-btn play-btn"
|
| 22 |
+
@click="$emit('togglePlay')"
|
| 23 |
+
:disabled="!hasAudio"
|
| 24 |
+
:title="isPlaying ? '暂停' : '播放'"
|
| 25 |
+
>
|
| 26 |
+
<i :class="isPlaying ? 'fas fa-pause' : 'fas fa-play'"></i>
|
| 27 |
+
<div v-if="loading" class="loading-spinner">
|
| 28 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 29 |
+
</div>
|
| 30 |
+
</button>
|
| 31 |
+
|
| 32 |
+
<button
|
| 33 |
+
class="control-btn next-btn"
|
| 34 |
+
@click="$emit('next')"
|
| 35 |
+
:disabled="!hasNext"
|
| 36 |
+
title="下一首"
|
| 37 |
+
>
|
| 38 |
+
<i class="fas fa-step-forward"></i>
|
| 39 |
+
</button>
|
| 40 |
+
|
| 41 |
+
<button
|
| 42 |
+
class="control-btn playlist-btn"
|
| 43 |
+
@click="$emit('showPlaylist')"
|
| 44 |
+
title="播放队列"
|
| 45 |
+
>
|
| 46 |
+
<i class="fas fa-list"></i>
|
| 47 |
+
<span v-if="playlistCount > 0" class="playlist-count">{{ playlistCount }}</span>
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
</template>
|
| 51 |
+
|
| 52 |
+
<script setup>
|
| 53 |
+
import { computed } from 'vue'
|
| 54 |
+
import { usePlayerStore } from '@/stores/player'
|
| 55 |
+
|
| 56 |
+
const props = defineProps({
|
| 57 |
+
loading: {
|
| 58 |
+
type: Boolean,
|
| 59 |
+
default: false
|
| 60 |
+
}
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
const emit = defineEmits([
|
| 64 |
+
'togglePlay',
|
| 65 |
+
'previous',
|
| 66 |
+
'next',
|
| 67 |
+
'togglePlayMode',
|
| 68 |
+
'showPlaylist'
|
| 69 |
+
])
|
| 70 |
+
|
| 71 |
+
const playerStore = usePlayerStore()
|
| 72 |
+
|
| 73 |
+
// 计算属性
|
| 74 |
+
const isPlaying = computed(() => playerStore.isPlaying)
|
| 75 |
+
const playMode = computed(() => playerStore.playMode)
|
| 76 |
+
const hasPrevious = computed(() => playerStore.hasPrevious)
|
| 77 |
+
const hasNext = computed(() => playerStore.hasNext)
|
| 78 |
+
const playlistCount = computed(() => playerStore.playlist.length)
|
| 79 |
+
const hasAudio = computed(() => !!playerStore.audioSrc)
|
| 80 |
+
|
| 81 |
+
const playModeIcon = computed(() => {
|
| 82 |
+
switch (playMode.value) {
|
| 83 |
+
case 'single':
|
| 84 |
+
return 'fas fa-redo'
|
| 85 |
+
case 'random':
|
| 86 |
+
return 'fas fa-random'
|
| 87 |
+
case 'list':
|
| 88 |
+
default:
|
| 89 |
+
return 'fas fa-retweet'
|
| 90 |
+
}
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
const playModeText = computed(() => {
|
| 94 |
+
switch (playMode.value) {
|
| 95 |
+
case 'single':
|
| 96 |
+
return '单曲循环'
|
| 97 |
+
case 'random':
|
| 98 |
+
return '随机播放'
|
| 99 |
+
case 'list':
|
| 100 |
+
default:
|
| 101 |
+
return '列表循环'
|
| 102 |
+
}
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
// 方法
|
| 106 |
+
const togglePlayMode = () => {
|
| 107 |
+
const modes = ['list', 'random', 'single']
|
| 108 |
+
const currentIndex = modes.indexOf(playMode.value)
|
| 109 |
+
const nextIndex = (currentIndex + 1) % modes.length
|
| 110 |
+
const nextMode = modes[nextIndex]
|
| 111 |
+
|
| 112 |
+
playerStore.setPlayMode(nextMode)
|
| 113 |
+
emit('togglePlayMode', nextMode)
|
| 114 |
+
}
|
| 115 |
+
</script>
|
| 116 |
+
|
| 117 |
+
<style scoped>
|
| 118 |
+
.play-controls {
|
| 119 |
+
display: flex;
|
| 120 |
+
align-items: center;
|
| 121 |
+
justify-content: center;
|
| 122 |
+
gap: 16px;
|
| 123 |
+
padding: 20px 0;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.control-btn {
|
| 127 |
+
border: none;
|
| 128 |
+
background: var(--bg-overlay);
|
| 129 |
+
color: var(--text-primary);
|
| 130 |
+
border-radius: 50%;
|
| 131 |
+
cursor: pointer;
|
| 132 |
+
transition: var(--transition-fast);
|
| 133 |
+
display: flex;
|
| 134 |
+
align-items: center;
|
| 135 |
+
justify-content: center;
|
| 136 |
+
position: relative;
|
| 137 |
+
backdrop-filter: blur(10px);
|
| 138 |
+
border: 1px solid var(--border-light);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.control-btn:hover:not(:disabled) {
|
| 142 |
+
background: var(--bg-card);
|
| 143 |
+
transform: scale(1.05);
|
| 144 |
+
border-color: var(--border-card);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.control-btn:active:not(:disabled) {
|
| 148 |
+
transform: scale(0.95);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.control-btn:disabled {
|
| 152 |
+
opacity: 0.4;
|
| 153 |
+
cursor: not-allowed;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* 按钮尺寸 */
|
| 157 |
+
.mode-btn,
|
| 158 |
+
.prev-btn,
|
| 159 |
+
.next-btn,
|
| 160 |
+
.playlist-btn {
|
| 161 |
+
width: 48px;
|
| 162 |
+
height: 48px;
|
| 163 |
+
font-size: 18px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.play-btn {
|
| 167 |
+
width: 72px;
|
| 168 |
+
height: 72px;
|
| 169 |
+
font-size: 28px;
|
| 170 |
+
background: linear-gradient(135deg, var(--accent-red), var(--accent-red-hover));
|
| 171 |
+
color: white;
|
| 172 |
+
box-shadow: var(--shadow-button);
|
| 173 |
+
position: relative;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.play-btn:hover:not(:disabled) {
|
| 177 |
+
background: linear-gradient(135deg, var(--accent-red-hover), #ff4444);
|
| 178 |
+
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.6);
|
| 179 |
+
transform: scale(1.08);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.play-btn:disabled {
|
| 183 |
+
background: var(--bg-overlay);
|
| 184 |
+
box-shadow: none;
|
| 185 |
+
border-color: var(--border-light);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* 加载动画 */
|
| 189 |
+
.loading-spinner {
|
| 190 |
+
position: absolute;
|
| 191 |
+
top: 50%;
|
| 192 |
+
left: 50%;
|
| 193 |
+
transform: translate(-50%, -50%);
|
| 194 |
+
font-size: 24px;
|
| 195 |
+
color: rgba(255, 255, 255, 0.8);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* 播放列表计数 */
|
| 199 |
+
.playlist-count {
|
| 200 |
+
position: absolute;
|
| 201 |
+
top: -4px;
|
| 202 |
+
right: -4px;
|
| 203 |
+
background: var(--accent-red);
|
| 204 |
+
color: white;
|
| 205 |
+
border-radius: 50%;
|
| 206 |
+
width: 20px;
|
| 207 |
+
height: 20px;
|
| 208 |
+
font-size: 10px;
|
| 209 |
+
font-weight: 600;
|
| 210 |
+
display: flex;
|
| 211 |
+
align-items: center;
|
| 212 |
+
justify-content: center;
|
| 213 |
+
line-height: 1;
|
| 214 |
+
min-width: 20px;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* 播放模式按钮状态 */
|
| 218 |
+
.mode-btn[title*="单曲"] {
|
| 219 |
+
color: var(--accent-red);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.mode-btn[title*="随机"] {
|
| 223 |
+
color: #4CAF50;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.mode-btn[title*="列表"] {
|
| 227 |
+
color: var(--text-primary);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* 响应式 */
|
| 231 |
+
@media (max-width: 375px) {
|
| 232 |
+
.play-controls {
|
| 233 |
+
gap: 12px;
|
| 234 |
+
padding: 16px 0;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.mode-btn,
|
| 238 |
+
.prev-btn,
|
| 239 |
+
.next-btn,
|
| 240 |
+
.playlist-btn {
|
| 241 |
+
width: 44px;
|
| 242 |
+
height: 44px;
|
| 243 |
+
font-size: 16px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.play-btn {
|
| 247 |
+
width: 64px;
|
| 248 |
+
height: 64px;
|
| 249 |
+
font-size: 24px;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.loading-spinner {
|
| 253 |
+
font-size: 20px;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.playlist-count {
|
| 257 |
+
width: 18px;
|
| 258 |
+
height: 18px;
|
| 259 |
+
font-size: 9px;
|
| 260 |
+
top: -3px;
|
| 261 |
+
right: -3px;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/* 按钮动画效果 */
|
| 266 |
+
.control-btn::before {
|
| 267 |
+
content: '';
|
| 268 |
+
position: absolute;
|
| 269 |
+
top: 50%;
|
| 270 |
+
left: 50%;
|
| 271 |
+
width: 0;
|
| 272 |
+
height: 0;
|
| 273 |
+
background: rgba(255, 255, 255, 0.3);
|
| 274 |
+
border-radius: 50%;
|
| 275 |
+
transform: translate(-50%, -50%);
|
| 276 |
+
transition: all 0.3s ease;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.control-btn:active:not(:disabled)::before {
|
| 280 |
+
width: 100%;
|
| 281 |
+
height: 100%;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/* 播放按钮特殊效果 */
|
| 285 |
+
.play-btn::after {
|
| 286 |
+
content: '';
|
| 287 |
+
position: absolute;
|
| 288 |
+
top: -2px;
|
| 289 |
+
left: -2px;
|
| 290 |
+
right: -2px;
|
| 291 |
+
bottom: -2px;
|
| 292 |
+
border-radius: 50%;
|
| 293 |
+
background: linear-gradient(135deg, var(--accent-red), var(--accent-red-hover));
|
| 294 |
+
z-index: -1;
|
| 295 |
+
opacity: 0;
|
| 296 |
+
transition: opacity var(--transition-fast);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.play-btn:hover:not(:disabled)::after {
|
| 300 |
+
opacity: 0.3;
|
| 301 |
+
animation: pulse-ring 1.5s ease-out infinite;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
@keyframes pulse-ring {
|
| 305 |
+
0% {
|
| 306 |
+
transform: scale(1);
|
| 307 |
+
opacity: 0.3;
|
| 308 |
+
}
|
| 309 |
+
100% {
|
| 310 |
+
transform: scale(1.3);
|
| 311 |
+
opacity: 0;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
</style>
|
src/components/player/PlayModeToggle.vue
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<button
|
| 3 |
+
class="play-mode-toggle"
|
| 4 |
+
:class="{ 'active': isActive }"
|
| 5 |
+
@click="toggleMode"
|
| 6 |
+
:title="modeText"
|
| 7 |
+
>
|
| 8 |
+
<i :class="modeIcon" class="mode-icon"></i>
|
| 9 |
+
<span class="mode-text" v-if="showText">{{ modeText }}</span>
|
| 10 |
+
</button>
|
| 11 |
+
</template>
|
| 12 |
+
|
| 13 |
+
<script setup>
|
| 14 |
+
import { computed } from 'vue'
|
| 15 |
+
import { usePlayerStore } from '@/stores/player'
|
| 16 |
+
|
| 17 |
+
const props = defineProps({
|
| 18 |
+
// 是否显示文字
|
| 19 |
+
showText: {
|
| 20 |
+
type: Boolean,
|
| 21 |
+
default: false
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
// 是否激活状态
|
| 25 |
+
isActive: {
|
| 26 |
+
type: Boolean,
|
| 27 |
+
default: false
|
| 28 |
+
},
|
| 29 |
+
|
| 30 |
+
// 按钮大小
|
| 31 |
+
size: {
|
| 32 |
+
type: String,
|
| 33 |
+
default: 'medium', // small, medium, large
|
| 34 |
+
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
| 35 |
+
}
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
const emit = defineEmits(['change'])
|
| 39 |
+
|
| 40 |
+
const playerStore = usePlayerStore()
|
| 41 |
+
|
| 42 |
+
// 播放模式图标映射
|
| 43 |
+
const modeIcon = computed(() => {
|
| 44 |
+
const iconMap = {
|
| 45 |
+
'list': 'fas fa-list-ul',
|
| 46 |
+
'loop': 'fas fa-redo-alt',
|
| 47 |
+
'random': 'fas fa-random'
|
| 48 |
+
}
|
| 49 |
+
return iconMap[playerStore.playMode] || iconMap.list
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
// 播放模式文本映射
|
| 53 |
+
const modeText = computed(() => {
|
| 54 |
+
const textMap = {
|
| 55 |
+
'list': '列表循环',
|
| 56 |
+
'loop': '单曲循环',
|
| 57 |
+
'random': '随机播放'
|
| 58 |
+
}
|
| 59 |
+
return textMap[playerStore.playMode] || textMap.list
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
// 切换播放模式
|
| 63 |
+
const toggleMode = () => {
|
| 64 |
+
const modes = ['list', 'loop', 'random']
|
| 65 |
+
const currentIndex = modes.indexOf(playerStore.playMode)
|
| 66 |
+
const nextIndex = (currentIndex + 1) % modes.length
|
| 67 |
+
const nextMode = modes[nextIndex]
|
| 68 |
+
|
| 69 |
+
playerStore.setPlayMode(nextMode)
|
| 70 |
+
emit('change', nextMode)
|
| 71 |
+
}
|
| 72 |
+
</script>
|
| 73 |
+
|
| 74 |
+
<style scoped>
|
| 75 |
+
.play-mode-toggle {
|
| 76 |
+
display: inline-flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
gap: 6px;
|
| 80 |
+
border: none;
|
| 81 |
+
background: transparent;
|
| 82 |
+
color: var(--text-secondary);
|
| 83 |
+
cursor: pointer;
|
| 84 |
+
transition: var(--transition-fast);
|
| 85 |
+
border-radius: 50%;
|
| 86 |
+
padding: 8px;
|
| 87 |
+
min-width: 44px;
|
| 88 |
+
min-height: 44px;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.play-mode-toggle:hover {
|
| 92 |
+
color: var(--text-primary);
|
| 93 |
+
background: rgba(255, 255, 255, 0.1);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.play-mode-toggle.active {
|
| 97 |
+
color: var(--accent-red);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.play-mode-toggle.active:hover {
|
| 101 |
+
color: var(--accent-red-hover);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.mode-icon {
|
| 105 |
+
font-size: 16px;
|
| 106 |
+
transition: var(--transition-fast);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.mode-text {
|
| 110 |
+
font-size: 12px;
|
| 111 |
+
font-weight: 500;
|
| 112 |
+
white-space: nowrap;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* 尺寸变化 */
|
| 116 |
+
.play-mode-toggle[data-size="small"] {
|
| 117 |
+
min-width: 36px;
|
| 118 |
+
min-height: 36px;
|
| 119 |
+
padding: 6px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.play-mode-toggle[data-size="small"] .mode-icon {
|
| 123 |
+
font-size: 14px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.play-mode-toggle[data-size="small"] .mode-text {
|
| 127 |
+
font-size: 11px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.play-mode-toggle[data-size="large"] {
|
| 131 |
+
min-width: 52px;
|
| 132 |
+
min-height: 52px;
|
| 133 |
+
padding: 10px;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.play-mode-toggle[data-size="large"] .mode-icon {
|
| 137 |
+
font-size: 18px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.play-mode-toggle[data-size="large"] .mode-text {
|
| 141 |
+
font-size: 13px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* 响应式 */
|
| 145 |
+
@media (max-width: 375px) {
|
| 146 |
+
.play-mode-toggle {
|
| 147 |
+
min-width: 40px;
|
| 148 |
+
min-height: 40px;
|
| 149 |
+
padding: 6px;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.mode-icon {
|
| 153 |
+
font-size: 14px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.mode-text {
|
| 157 |
+
font-size: 11px;
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* 动画效果 */
|
| 162 |
+
.play-mode-toggle:active {
|
| 163 |
+
transform: scale(0.95);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* 特殊模式动画 */
|
| 167 |
+
.play-mode-toggle .fa-random {
|
| 168 |
+
animation: random-pulse 2s ease-in-out infinite;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
@keyframes random-pulse {
|
| 172 |
+
0%, 100% {
|
| 173 |
+
opacity: 1;
|
| 174 |
+
}
|
| 175 |
+
50% {
|
| 176 |
+
opacity: 0.6;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.play-mode-toggle .fa-redo-alt {
|
| 181 |
+
animation: loop-spin 3s linear infinite;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@keyframes loop-spin {
|
| 185 |
+
from {
|
| 186 |
+
transform: rotate(0deg);
|
| 187 |
+
}
|
| 188 |
+
to {
|
| 189 |
+
transform: rotate(360deg);
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* 当播放模式激活时停止动画 */
|
| 194 |
+
.play-mode-toggle.active .fa-random,
|
| 195 |
+
.play-mode-toggle.active .fa-redo-alt {
|
| 196 |
+
animation-play-state: paused;
|
| 197 |
+
}
|
| 198 |
+
</style>
|