ahutchen commited on
Commit
40f23a9
·
1 Parent(s): 70db065
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +0 -9
  2. .gitattributes +0 -1
  3. .gitignore +12 -19
  4. .prettierrc.json +0 -6
  5. .vscode/extensions.json +0 -8
  6. README.md +172 -38
  7. demo.html +1495 -0
  8. env.d.ts +0 -1
  9. eslint.config.ts +0 -22
  10. index.html +28 -11
  11. package-lock.json +0 -0
  12. package.json +13 -28
  13. public/favicon.ico +0 -0
  14. public/favicon.svg +5 -0
  15. public/icons/apple-touch-icon.svg +5 -0
  16. public/icons/icon-128x128.svg +5 -0
  17. public/icons/icon-192x192.svg +5 -0
  18. public/icons/icon-512x512.svg +5 -0
  19. public/icons/icon-72x72.svg +5 -0
  20. public/icons/icon-96x96.svg +5 -0
  21. src/App.vue +371 -55
  22. src/assets/base.css +0 -86
  23. src/assets/logo.svg +0 -1
  24. src/assets/main.css +0 -35
  25. src/components/HelloWorld.vue +0 -41
  26. src/components/TheWelcome.vue +0 -94
  27. src/components/WelcomeItem.vue +0 -87
  28. src/components/common/BottomSheet.vue +416 -0
  29. src/components/common/ConfirmDialog.vue +232 -0
  30. src/components/common/Empty.vue +168 -0
  31. src/components/common/Icon.vue +171 -0
  32. src/components/common/Loading.vue +139 -0
  33. src/components/common/Modal.vue +301 -0
  34. src/components/common/Toast.vue +271 -0
  35. src/components/favorites/FavoriteButton.vue +283 -0
  36. src/components/favorites/FavoriteItem.vue +458 -0
  37. src/components/favorites/FavoritesList.vue +853 -0
  38. src/components/icons/IconCommunity.vue +0 -7
  39. src/components/icons/IconDocumentation.vue +0 -7
  40. src/components/icons/IconEcosystem.vue +0 -7
  41. src/components/icons/IconSupport.vue +0 -7
  42. src/components/icons/IconTooling.vue +0 -19
  43. src/components/layout/AppTabBar.vue +152 -0
  44. src/components/layout/MiniPlayer.vue +377 -0
  45. src/components/layout/SearchHeader.vue +624 -0
  46. src/components/player/AlbumCover.vue +227 -0
  47. src/components/player/LyricsView.vue +454 -0
  48. src/components/player/MoreActionsPanel.vue +365 -0
  49. src/components/player/PlayControls.vue +314 -0
  50. 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
- # Logs
2
- logs
3
- *.log
 
 
 
 
 
 
 
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
- title: Music
3
- emoji: 🐢
4
- colorFrom: purple
5
- colorTo: indigo
6
- sdk: static
7
- pinned: false
8
- app_build_command: npm run build
9
- app_file: dist/index.html
10
- ---
11
-
12
- # vue
13
-
14
- This template should help get you started developing with Vue 3 in Vite.
15
-
16
- ## Recommended IDE Setup
17
-
18
- [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
19
-
20
- ## Type Support for `.vue` Imports in TS
21
-
22
- TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
23
-
24
- ## Customize configuration
25
-
26
- See [Vite Configuration Reference](https://vite.dev/config/).
27
-
28
- ## Project Setup
29
-
30
- ```sh
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  npm install
32
  ```
33
 
34
- ### Compile and Hot-Reload for Development
35
-
36
- ```sh
37
  npm run dev
38
  ```
39
 
40
- ### Type-Check, Compile and Minify for Production
 
 
41
 
42
- ```sh
 
43
  npm run build
44
  ```
45
 
46
- ### Lint with [ESLint](https://eslint.org/)
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
- <head>
4
- <meta charset="UTF-8">
5
- <link rel="icon" href="/favicon.ico">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Vite App</title>
8
- </head>
9
- <body>
10
- <div id="app"></div>
11
- <script type="module" src="/src/main.ts"></script>
12
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "0.0.0",
4
- "private": true,
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
- "build": "run-p type-check \"build-only {@}\" --",
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
- "pinia": "^3.0.1",
17
- "vue": "^3.5.13",
18
- "vue-router": "^4.5.0"
 
19
  },
20
  "devDependencies": {
21
- "@tsconfig/node22": "^22.0.1",
22
- "@types/node": "^22.14.0",
23
- "@vitejs/plugin-vue": "^5.2.3",
24
- "@vue/eslint-config-prettier": "^10.2.0",
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
- <header>
8
- <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- <div class="wrapper">
11
- <HelloWorld msg="You did it!" />
 
 
 
 
 
12
 
13
- <nav>
14
- <RouterLink to="/">Home</RouterLink>
15
- <RouterLink to="/about">About</RouterLink>
16
- </nav>
17
- </div>
18
- </header>
 
 
 
 
 
19
 
20
- <RouterView />
 
 
21
  </template>
22
 
23
- <style scoped>
24
- header {
25
- line-height: 1.5;
26
- max-height: 100vh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
- .logo {
30
- display: block;
31
- margin: 0 auto 2rem;
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
- nav {
35
- width: 100%;
36
- font-size: 12px;
37
- text-align: center;
38
- margin-top: 2rem;
39
  }
40
 
41
- nav a.router-link-exact-active {
42
- color: var(--color-text);
 
 
 
 
43
  }
44
 
45
- nav a.router-link-exact-active:hover {
46
- background-color: transparent;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
 
49
- nav a {
50
- display: inline-block;
51
- padding: 0 1rem;
52
- border-left: 1px solid var(--color-border);
53
  }
54
 
55
- nav a:first-of-type {
56
- border: 0;
 
 
57
  }
58
 
59
- @media (min-width: 1024px) {
60
- header {
61
- display: flex;
62
- place-items: center;
63
- padding-right: calc(var(--section-gap) / 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
 
65
 
66
- .logo {
67
- margin: 0 2rem 0 0;
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
- header .wrapper {
71
- display: flex;
72
- place-items: flex-start;
73
- flex-wrap: wrap;
 
 
 
 
 
74
  }
 
 
 
 
 
 
 
 
 
 
75
 
76
- nav {
77
- text-align: left;
78
- margin-left: -1rem;
79
- font-size: 1rem;
 
 
 
 
 
 
 
 
 
80
 
81
- padding: 1rem 0;
82
- margin-top: 1rem;
 
 
 
 
 
 
 
 
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>