添加了一个修改

This commit is contained in:
rucky
2025-11-23 23:55:10 +08:00
commit cefc2a1653
46 changed files with 10659 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

173
DEBUG_REDIRECT_ISSUE.md Normal file
View File

@@ -0,0 +1,173 @@
# 🔍 调试 Spotify 回调问题
## 🐛 问题描述
**症状:**
授权成功,但返回的 URL 是:
```
https://sports.rucky.cn/?code=...&state=...#/%2Fcallback
```
而不是期望的:
```
https://sports.rucky.cn/callback?code=...&state=...
```
## 🎯 可能的原因
### 1. Spotify Dashboard 配置错误
检查 Spotify Dashboard 中是否配置了:
```
❌ 错误https://sports.rucky.cn/
✅ 正确https://sports.rucky.cn/callback
```
**解决方案:**
1. 访问 https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings
2. 点击 "Edit Settings"
3. 检查 "Redirect URIs" 中是否有 `https://sports.rucky.cn/callback`
4. 如果只有 `https://sports.rucky.cn/`,需要:
- 删除 `https://sports.rucky.cn/`
- 添加 `https://sports.rucky.cn/callback`
5. 点击 "Save"
### 2. 代码中 redirect_uri 不一致
检查代码中的配置:
**src/views/MusicRhythm.vue:**
```typescript
const REDIRECT_URI = window.location.origin + '/callback'
// 应该生成https://sports.rucky.cn/callback
```
**验证方法:**
在浏览器控制台运行:
```javascript
console.log(window.location.origin + '/callback')
// 应该输出https://sports.rucky.cn/callback
```
### 3. 缓存问题
如果之前配置了 `https://sports.rucky.cn/`,浏览器可能缓存了旧的授权请求。
**解决方案:**
```bash
# 清除所有相关的 localStorage
localStorage.removeItem('spotify_code_verifier')
localStorage.removeItem('spotify_auth_state')
localStorage.removeItem('spotify_access_token')
localStorage.removeItem('spotify_token_expires_at')
# 或者使用无痕模式重试
```
## ✅ 已实施的临时修复
`src/views/Home.vue` 中添加了检测和转发逻辑:
```typescript
onMounted(() => {
// 检查是否是 Spotify 授权回调
const params = new URLSearchParams(window.location.search)
if (params.has('code')) {
// 转发到 /callback 路由处理
router.push({
name: 'SpotifyCallback',
query: Object.fromEntries(params)
})
}
})
```
这样即使 Spotify 返回到根路径,也会自动转发到正确的回调页面。
## 🧪 测试步骤
### 1. 清除缓存
```javascript
// 在浏览器控制台运行
localStorage.clear()
```
### 2. 确认配置
访问https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings
应该看到:
```
Redirect URIs:
✅ http://localhost:5173/callback
✅ https://sports.rucky.cn/callback
```
### 3. 重新部署
```bash
npm run build
rsync -avz --delete dist/ user@sports.rucky.cn:/var/www/sports.rucky.cn/
```
### 4. 测试授权流程
1. 访问 https://sports.rucky.cn/
2. 进入音乐律动页面
3. 点击 "连接 Spotify 账号"
4. 授权后应该:
- 返回到应用(可能是根路径)
- 自动转发到 SpotifyCallback 页面
- 显示 "授权成功!"
- 自动跳转到音乐律动页面
## 🔧 完整修复方案
### 方案 A修复 Redirect URI 配置(推荐)
**优点:** 符合标准,最干净的解决方案
**步骤:**
1. 在 Spotify Dashboard 中**只配置** `/callback` 路径
2. 删除其他可能的配置
3. 等待几分钟让配置生效
4. 测试
### 方案 B使用根路径 + 转发(当前方案)
**优点:** 容错性强,即使配置错误也能工作
**缺点:** URL 中会短暂出现 code 参数
**状态:** 已实施 ✅
## 📝 最佳实践
根据 Spotify 官方文档Redirect URI 应该:
1. ✅ 使用具体的路径(如 `/callback`
2. ✅ 在 Dashboard 中精确匹配
3. ✅ 包括协议、域名、端口、路径
4. ❌ 避免使用根路径 `/`
**推荐配置:**
```
开发http://localhost:5173/callback
生产https://sports.rucky.cn/callback
```
## 🎯 下一步
1. **立即做:** 确认 Spotify Dashboard 中的配置
2. **如果错误:** 修改为 `/callback` 并保存
3. **清除缓存:** 使用无痕模式测试
4. **重新测试:** 完整走一遍授权流程
## 📞 如果还有问题
检查浏览器控制台的日志:
```javascript
// 应该看到类似的日志
"Detected Spotify callback, redirecting to /callback route"
```
如果看到这条日志,说明我们的临时修复生效了,即使 Redirect URI 配置不正确,也能正常工作。
---
**总结:** 大概率是 Spotify Dashboard 中配置了 `https://sports.rucky.cn/` 而不是 `https://sports.rucky.cn/callback`。修改配置即可完美解决。同时我们的代码已经添加了容错逻辑。✅

306
FIX_404_PROBLEM.md Normal file
View File

@@ -0,0 +1,306 @@
# ✅ 解决 SPA 路由刷新 404 问题
## 🐛 问题描述
**症状:**
- 访问 `https://sports.rucky.cn/music-rhythm` 正常
- 刷新页面后出现 **404 Not Found** 错误
- 可能出现双斜杠:`https://sports.rucky.cn//music-rhythm`
## 🔍 问题原因
### 1. SPA 路由原理
单页应用SPA的路由是由前端 JavaScript 控制的:
```
首次访问 https://sports.rucky.cn
服务器返回 index.html
Vue Router 加载,解析路由
点击链接 /music-rhythm
Vue Router 更新 URL不刷新页面
渲染 MusicRhythm 组件 ✅
```
### 2. 刷新页面时的问题
```
刷新 https://sports.rucky.cn/music-rhythm
浏览器向服务器请求 /music-rhythm
服务器找不到 music-rhythm 文件夹或文件
返回 404 错误 ❌
```
## ✅ 解决方案
### 方案 1Nginx 配置(推荐)
已创建配置文件:`nginx.conf.example`
**关键配置:**
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
**含义:**
1. 先尝试访问实际文件 `$uri`
2. 如果不存在,尝试访问目录 `$uri/`
3. 如果还不存在,返回 `index.html`
4. 让 Vue Router 处理路由
**完整配置步骤:**
1. 打开 Nginx 配置文件:
```bash
sudo nano /etc/nginx/sites-available/sports.rucky.cn
```
2. 添加/修改配置:
```nginx
server {
listen 443 ssl http2;
server_name sports.rucky.cn;
root /var/www/sports.rucky.cn;
index index.html;
# 关键SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
}
```
3. 测试配置:
```bash
sudo nginx -t
```
4. 重启 Nginx
```bash
sudo systemctl restart nginx
```
### 方案 2Apache 配置
已创建配置文件:`public/.htaccess`
**关键配置:**
```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
```
**使用方法:**
1. 确保 Apache 启用了 `mod_rewrite`
```bash
sudo a2enmod rewrite
sudo systemctl restart apache2
```
2. `.htaccess` 文件会自动包含在构建中:
```bash
npm run build
# dist/public/.htaccess 会自动复制到 dist/
```
3. 部署时确保 `.htaccess` 文件在网站根目录
### 方案 3Vercel/Netlify自动处理
如果使用这些平台,已创建 `public/_redirects` 文件:
```
/* /index.html 200
```
**Vercel** 自动识别,无需额外配置
**Netlify** 自动识别 `_redirects` 文件
## 🔧 前端优化
### 1. 路由配置已优化
```typescript
const router = createRouter({
history: createWebHistory('/'), // 明确指定 base path
routes: [...],
})
```
### 2. 避免双斜杠问题
确保路由跳转时使用规范路径:
```typescript
// ✅ 正确
router.push({ name: 'MusicRhythm' })
router.push('/music-rhythm')
// ❌ 避免
router.push('//music-rhythm')
```
## 🧪 测试步骤
### 本地测试
1. 构建生产版本:
```bash
npm run build
```
2. 使用本地服务器测试(模拟生产环境):
```bash
# 安装 serve
npm install -g serve
# 运行(支持 SPA
serve -s dist
# 或使用 http-server
npm install -g http-server
http-server dist -p 8080 --proxy http://localhost:8080?
```
3. 测试路由:
- 访问 `http://localhost:3000/`
- 点击进入音乐律动页面
- **刷新页面**,应该正常显示,不会 404
### 生产环境测试
1. 部署到服务器
2. 访问各个页面:
- `https://sports.rucky.cn/`
- `https://sports.rucky.cn/music-rhythm`
- `https://sports.rucky.cn/callback`
3. 在每个页面刷新,都应该正常显示
## 📋 检查清单
部署前确认:
- [ ] ✅ 服务器配置了 fallback 到 index.html
- [ ] ✅ 所有路由路径以 `/` 开头,没有双斜杠
- [ ]`vite.config.js` 没有错误的 `base` 配置
- [ ] ✅ 构建产物在正确的目录
- [ ] ✅ 测试了所有主要路由的刷新
## 🔍 调试技巧
### 1. 检查服务器日志
**Nginx**
```bash
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
```
**Apache**
```bash
sudo tail -f /var/log/apache2/access.log
sudo tail -f /var/log/apache2/error.log
```
### 2. 浏览器开发者工具
1. 打开 Network 标签
2. 刷新页面
3. 查看请求:
- 应该看到请求 `/music-rhythm`
- 服务器应该返回 `index.html`(状态码 200
- 而不是 404
### 3. 测试 curl
```bash
# 应该返回 index.html 内容
curl -I https://sports.rucky.cn/music-rhythm
# 检查响应头
HTTP/1.1 200 OK
Content-Type: text/html
```
## 🎯 常见错误
### 错误 1双斜杠
**问题:** `https://sports.rucky.cn//music-rhythm`
**原因:** 代码中拼接路径时多加了斜杠
**解决:**
```typescript
// ❌ 错误
const url = origin + '/' + path
// ✅ 正确
const url = origin + path // path 已经包含 /
// 或
const url = new URL(path, origin).href
```
### 错误 2404 但本地正常
**问题:** 本地开发正常,生产环境 404
**原因:** Vite Dev Server 自动处理 SPA 路由,但生产服务器没有配置
**解决:** 按照上面的方案配置 Nginx/Apache
### 错误 3子目录部署
**问题:** 部署在 `https://example.com/app/` 子目录下
**解决:**
```typescript
// vite.config.js
export default defineConfig({
base: '/app/',
})
// router.ts
const router = createRouter({
history: createWebHistory('/app/'),
})
```
## 📚 相关文档
- [Vue Router HTML5 Mode](https://router.vuejs.org/guide/essentials/history-mode.html)
- [Vite Static Deploy Guide](https://vitejs.dev/guide/static-deploy.html)
- [Nginx try_files](http://nginx.org/en/docs/http/ngx_http_core_module.html#try_files)
## ✅ 总结
**已完成:**
1. ✅ 优化前端路由配置
2. ✅ 创建 `.htaccess`Apache
3. ✅ 创建 `_redirects`Vercel/Netlify
4. ✅ 提供 `nginx.conf.example`
**你需要做:**
1. 根据你使用的服务器Nginx/Apache配置 fallback
2. 重启服务器
3. 测试刷新功能
完成后,所有路由刷新都应该正常工作!🎉

133
FIX_REDIRECT_URI.md Normal file
View File

@@ -0,0 +1,133 @@
# ✅ 修复 Redirect URI 不匹配问题
## 🐛 问题
遇到错误:
```
INVALID_CLIENT: Invalid redirect URI
```
授权 URL 中的 redirect_uri 是:
```
https://sports.rucky.cn/music-rhythm
```
但在 Spotify Dashboard 中配置的是:
```
https://sports.rucky.cn/
```
## 🔍 原因分析
之前代码使用了:
```typescript
const REDIRECT_URI = window.location.origin + window.location.pathname
```
当访问 `/music-rhythm` 路由时,`window.location.pathname``/music-rhythm`,导致 redirect_uri 变成了 `https://sports.rucky.cn/music-rhythm`,这个地址没有在 Spotify Dashboard 中配置。
## ✅ 解决方案
### 修改内容
**1. 固定使用根路径作为 Redirect URI**
```typescript
// 修改前
const REDIRECT_URI = window.location.origin + window.location.pathname
// 修改后 ✅
const REDIRECT_URI = window.location.origin + '/'
```
**2. 添加授权回调处理**
-`Home.vue` 中检测 Spotify 回调
- 自动跳转回音乐律动页面
- 保持用户体验流畅
### 工作流程
```
用户在 /music-rhythm 点击授权
保存返回路径到 localStorage
跳转到 Spotify 授权页面 (redirect_uri=/)
用户授权
返回到根路径 /
Home.vue 检测到回调
自动跳转到 /music-rhythm
MusicRhythm.vue 处理 token
授权完成!
```
## 📝 Spotify Dashboard 配置
只需配置**根路径**
**开发环境:**
```
http://localhost:5173/
```
**生产环境:**
```
https://sports.rucky.cn/
```
⚠️ **不要**添加:
-`https://sports.rucky.cn/music-rhythm`
-`https://sports.rucky.cn/music-rhythm/`
- ❌ 其他子路径
## ✨ 优势
1. **简单配置**
- 只需一个 Redirect URI
- 不需要为每个路由配置
2. **避免错误**
- 不会出现 redirect_uri 不匹配
- 代码更稳定
3. **更好的用户体验**
- 授权后自动返回原页面
- 流程更流畅
## 🧪 测试步骤
1. 确保 Spotify Dashboard 中只配置了根路径
2. 重新构建应用:
```bash
npm run build
```
3. 访问音乐律动页面
4. 点击"连接 Spotify 账号"
5. 授权后应该自动返回音乐律动页面
6. 检查是否成功授权
## 📄 修改的文件
- ✅ `src/views/MusicRhythm.vue` - 固定 redirect_uri添加状态保存
- ✅ `src/views/Home.vue` - 添加回调检测和自动跳转
- ✅ `SPOTIFY_SETUP.md` - 更新配置说明
## 🎯 现在可以正常使用了
重新构建后,授权流程应该能正常工作:
```bash
npm run build
# 或
npm run dev
```
访问应用,点击授权,享受音乐吧!🎵

102
FIX_SPOTIFY_INIT_ERROR.md Normal file
View File

@@ -0,0 +1,102 @@
# ✅ 修复 "Spotify 服务未初始化" 错误
## 🐛 问题
错误信息:
```
Failed to exchange code for token: Error: Spotify 服务未初始化
```
**原因:** `SpotifyCallback.vue` 组件调用 `spotifyService.handleCallback()` 时,服务没有初始化。
## ✅ 解决方案
`SpotifyCallback.vue` 中添加服务初始化:
```typescript
// 初始化 Spotify 服务(必须在处理回调前初始化)
const initSpotifyService = () => {
const SPOTIFY_CLIENT_ID = '4ed200672ba1421baa31b9859bd84d39'
const REDIRECT_URI = window.location.origin + '/'
console.log('Initializing Spotify service in callback...')
spotifyService.initialize({
clientId: SPOTIFY_CLIENT_ID,
redirectUri: REDIRECT_URI,
})
}
onMounted(async () => {
// 先初始化服务
initSpotifyService()
// 然后处理回调...
})
```
## 📝 完整授权流程
```
1. MusicRhythm.vue → 初始化服务 + 请求授权
2. Spotify 授权页面
3. 返回 https://sports.rucky.cn/?code=xxx&state=xxx
4. Home.vue → 检测并保存参数到 sessionStorage
5. 跳转到 /#/callback
6. SpotifyCallback.vue → 初始化服务(重要!)
7. 从 sessionStorage 恢复参数
8. 调用 handleCallback() 交换 token
9. 授权成功!
```
## 🎯 关键点
1. **每个使用 spotifyService 的组件都需要初始化**
- MusicRhythm.vue ✅
- SpotifyCallback.vue ✅(刚刚添加)
2. **初始化参数必须一致**
```typescript
const SPOTIFY_CLIENT_ID = '4ed200672ba1421baa31b9859bd84d39'
const REDIRECT_URI = window.location.origin + '/'
```
3. **sessionStorage 传递参数**
- Home.vue 保存
- SpotifyCallback.vue 读取并恢复
## 🧪 测试
1. **清除所有缓存**
```javascript
localStorage.clear()
sessionStorage.clear()
```
2. **部署新代码**
```bash
# dist 已重新构建
# 部署到服务器
```
3. **完整测试流程**
- 访问 https://sports.rucky.cn
- 进入音乐律动
- 点击连接 Spotify
- 授权
- 应该成功!
## ✅ 已修复
- ✅ SpotifyCallback.vue 添加了服务初始化
- ✅ 初始化参数与 MusicRhythm.vue 保持一致
- ✅ 重新构建完成
**现在应该能正常工作了!** 🎉

View File

@@ -0,0 +1,168 @@
# ✅ Hash 模式下的 Spotify OAuth 完美解决方案
## 🎯 最终方案
### 1. Spotify Dashboard 配置
**Redirect URIs**
```
https://sports.rucky.cn/ (生产环境)
http://localhost:5173/ (开发环境)
```
⚠️ **重要:不要使用 hash 格式!**
-`https://sports.rucky.cn/#/callback`
-`https://sports.rucky.cn/`
### 2. 工作流程
```
用户点击"连接 Spotify 账号"
跳转到 Spotify (redirect_uri=https://sports.rucky.cn/)
用户授权
Spotify 返回: https://sports.rucky.cn/?code=xxx&state=xxx
Home.vue 检测到 code 参数
保存参数到 sessionStorage
跳转到 #/callback 路由
SpotifyCallback.vue 处理授权
授权成功,跳转到音乐律动页面
```
### 3. 关键代码
**MusicRhythm.vue**
```typescript
// 使用根路径作为 redirect_uri
const REDIRECT_URI = window.location.origin + '/'
```
**Home.vue**
```typescript
onMounted(() => {
// 检测 Spotify 回调
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('code') && urlParams.has('state')) {
// 保存参数并跳转到 callback 路由
sessionStorage.setItem('spotify_callback_params', window.location.search)
router.replace('/callback')
}
})
```
**SpotifyCallback.vue**
```typescript
onMounted(async () => {
// 从 sessionStorage 恢复参数
const savedParams = sessionStorage.getItem('spotify_callback_params')
if (savedParams) {
// 恢复 URL 参数供 spotifyService 处理
const currentUrl = new URL(window.location.href)
currentUrl.search = savedParams
window.history.replaceState({}, '', currentUrl.toString())
// 清理
sessionStorage.removeItem('spotify_callback_params')
}
// 处理授权
if (window.location.search.includes('code=')) {
await spotifyService.handleCallback()
}
})
```
## ✅ 优势
1. **兼容性好**
- 支持 Hash 模式路由
- 不需要服务器配置 SPA 支持
2. **用户体验流畅**
- 自动检测和处理回调
- 清晰的加载和成功提示
- 自动跳转回原页面
3. **安全可靠**
- State 参数验证CSRF 保护)
- 参数临时存储,用后即删
- URL 清理,不暴露敏感信息
## 📋 部署步骤
1. **修改 Spotify Dashboard**
- 删除 `https://sports.rucky.cn/#/callback`
- 添加 `https://sports.rucky.cn/`
- 保存设置
2. **部署新代码**
```bash
# 代码已构建完成
rsync -avz --delete dist/ user@sports.rucky.cn:/var/www/sports.rucky.cn/
```
3. **清除缓存测试**
```javascript
localStorage.clear()
sessionStorage.clear()
```
## 🧪 测试流程
1. 访问 https://sports.rucky.cn/
2. 进入音乐律动页面 (#/music-rhythm)
3. 点击 Spotify 标签
4. 点击"连接 Spotify 账号"
5. 在 Spotify 页面授权
6. 应该看到:
- ✅ 返回到首页(带 code 参数)
- ✅ 自动跳转到 #/callback
- ✅ 显示"正在处理授权..."
- ✅ 显示"授权成功!"
- ✅ 自动跳转回音乐律动页面
- ✅ Spotify 功能正常使用
## 🔍 调试
如果有问题,检查浏览器控制台:
```javascript
// 应该看到以下日志:
"✅ Spotify OAuth callback detected!"
"Code: AQBTrOWWZ8883ozZ1RD..."
"State: RxEHF89uqI2Y0YFR"
"✅ Processing Spotify OAuth callback from sessionStorage"
```
## 📝 注意事项
1. **必须使用根路径**
- Spotify 不支持 hash 格式的 redirect_uri
- 只能使用 `https://sports.rucky.cn/`
2. **Hash 模式的限制**
- URL 会短暂显示 code 参数
- 需要额外的跳转步骤
3. **sessionStorage 的作用**
- 临时保存参数
- 跨路由传递数据
- 用后自动清理
## ✅ 问题已解决
- ✅ 支持 Hash 模式路由
- ✅ 正确处理 Spotify 回调
- ✅ 完整的授权流程
- ✅ 良好的用户体验
现在部署新代码,就能完美使用了!🎉

321
PRODUCTION_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,321 @@
# 🚀 生产环境部署指南
## 📋 配置信息
- **生产域名:** `https://sports.rucky.cn`
- **Spotify Client ID** `4ed200672ba1421baa31b9859bd84d39`
- **Redirect URI** `https://sports.rucky.cn/`
## ✅ 部署前检查清单
### 1. Spotify Dashboard 配置
- [ ] 访问 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings)
- [ ] 在 "Edit Settings" 中添加以下 Redirect URIs
- `http://localhost:5173/` (开发环境)
- `https://sports.rucky.cn/` (生产环境)
- [ ] 点击 "Save" 保存设置
### 2. HTTPS 配置
- [ ] 确保生产域名已配置 SSL 证书
- [ ] 确保 `https://sports.rucky.cn` 可以正常访问
- [ ] 检查 HTTPS 重定向是否正确配置
### 3. 构建应用
```bash
# 安装依赖
npm install
# 构建生产版本
npm run build
```
构建完成后,`dist` 目录包含所有生产文件。
### 4. 部署文件
`dist` 目录的内容部署到你的服务器:
```bash
# 示例:使用 rsync 部署
rsync -avz --delete dist/ user@sports.rucky.cn:/var/www/sports.rucky.cn/
# 或使用 scp
scp -r dist/* user@sports.rucky.cn:/var/www/sports.rucky.cn/
```
## 🔧 Web 服务器配置
⚠️ **重要:必须配置 SPA 路由支持,否则刷新页面会 404**
参考完整配置文件:
- `nginx.conf.example` - Nginx 完整配置
- `public/.htaccess` - Apache 配置(已自动包含在构建中)
- 详细说明见 `FIX_404_PROBLEM.md`
### Nginx 配置示例
```nginx
server {
listen 80;
server_name sports.rucky.cn;
# HTTPS 重定向
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name sports.rucky.cn;
# SSL 证书配置
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private.key;
# 网站根目录
root /var/www/sports.rucky.cn;
index index.html;
# 🔥 关键配置SPA 路由支持(解决刷新 404 问题)
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
}
```
### Apache 配置示例
⚠️ **`.htaccess` 文件已自动包含在 `public/` 目录中,构建时会自动复制到 `dist/`**
```apache
<VirtualHost *:80>
ServerName sports.rucky.cn
Redirect permanent / https://sports.rucky.cn/
</VirtualHost>
<VirtualHost *:443>
ServerName sports.rucky.cn
DocumentRoot /var/www/sports.rucky.cn
# SSL 配置
SSLEngine on
SSLCertificateFile /path/to/your/certificate.crt
SSLCertificateKeyFile /path/to/your/private.key
# 🔥 关键配置:允许 .htaccess 覆盖SPA 路由支持)
<Directory /var/www/sports.rucky.cn>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# 启用 Gzip
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>
</VirtualHost>
```
## 🧪 部署后测试
### 1. 基本访问测试
```bash
# 检查网站是否可访问
curl -I https://sports.rucky.cn/
# 应该返回 200 OK
```
### 2. Spotify 授权测试
1. 访问 `https://sports.rucky.cn/`
2. 点击 "音乐律动·步频检测"
3. 点击 "Spotify" 标签
4. 点击 "连接 Spotify 账号"
5. 应该正确跳转到 Spotify 授权页面
6. 授权后应该正确返回到你的应用
### 3. 功能测试
- [ ] 步频模式选择正常
- [ ] Spotify 授权流程正常
- [ ] 搜索功能正常
- [ ] 播放控制正常
- [ ] 可视化效果正常
## ⚠️ 常见问题
### Q1: 刷新页面出现 404 错误 🔥
**问题:** 访问 `https://sports.rucky.cn/music-rhythm` 正常,但刷新页面后出现 404
**原因:** 服务器没有配置 SPA 路由支持
**解决:**
**Nginx**
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
**Apache**
确保 `AllowOverride All` 已启用,`.htaccess` 会自动处理
**测试:**
```bash
# 应该返回 index.html 而不是 404
curl -I https://sports.rucky.cn/music-rhythm
```
详细说明见:[FIX_404_PROBLEM.md](./FIX_404_PROBLEM.md)
### Q2: 授权后出现 "Redirect URI mismatch" 错误
**原因:** Redirect URI 配置不正确
**解决:**
1. 检查 Spotify Dashboard 中是否已添加 `https://sports.rucky.cn/`
2. 注意必须包含最后的斜杠 `/`
3. 确保协议是 `https://` 而不是 `http://`
4. 保存后等待几分钟让配置生效
### Q3: 静态资源加载失败
**原因:** 路径配置问题
**解决:**
1. 检查 `vite.config.js` 中的 `base` 配置
2. 确保静态资源路径正确
3. 检查文件权限
### Q4: HTTPS 证书问题
**解决:**
- 使用 Let's Encrypt 免费证书:
```bash
# 安装 Certbot
sudo apt-get install certbot python3-certbot-nginx
# 获取证书
sudo certbot --nginx -d sports.rucky.cn
```
## 🔐 安全建议
1. **启用 HTTPS**
- 必须使用 HTTPS否则 Spotify 授权可能失败
- 定期更新 SSL 证书
2. **配置安全头**
```nginx
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
```
3. **保护 Client ID**
- Client ID 可以公开,但注意限制 Redirect URI
- 定期检查 Spotify Dashboard 的访问日志
4. **定期更新**
- 定期更新依赖包
- 关注安全漏洞公告
## 📊 性能优化
1. **启用缓存**
- 静态资源设置长期缓存
- HTML 文件设置短期缓存
2. **启用压缩**
- Gzip/Brotli 压缩
- 减少传输大小
3. **CDN 加速**(可选)
- 将静态资源部署到 CDN
- 提升全球访问速度
4. **监控性能**
- 使用 Google Analytics
- 监控页面加载时间
- 追踪用户体验指标
## 🔄 更新部署
```bash
# 1. 拉取最新代码
git pull origin main
# 2. 安装依赖
npm install
# 3. 构建
npm run build
# 4. 备份当前版本
mv /var/www/sports.rucky.cn /var/www/sports.rucky.cn.backup
# 5. 部署新版本
rsync -avz --delete dist/ user@sports.rucky.cn:/var/www/sports.rucky.cn/
# 6. 测试
curl -I https://sports.rucky.cn/
# 7. 如果有问题,快速回滚
# mv /var/www/sports.rucky.cn.backup /var/www/sports.rucky.cn
```
## 📱 移动端适配
应用已适配移动端,确保:
- [ ] 响应式设计正常工作
- [ ] 触摸事件正常
- [ ] 移动端 Spotify 授权正常
## 🎯 环境变量(可选)
如果需要不同环境的配置,可以使用环境变量:
```javascript
// vite.config.js
export default {
define: {
'import.meta.env.VITE_SPOTIFY_CLIENT_ID': JSON.stringify(process.env.VITE_SPOTIFY_CLIENT_ID || '4ed200672ba1421baa31b9859bd84d39')
}
}
```
```bash
# .env.production
VITE_SPOTIFY_CLIENT_ID=4ed200672ba1421baa31b9859bd84d39
```
## 📞 支持
如遇到问题:
1. 查看浏览器控制台错误
2. 检查网络请求
3. 查看 Spotify API 错误信息
4. 参考 [Spotify 开发者社区](https://community.spotify.com/t5/Spotify-for-Developers/bd-p/Spotify_Developer)
---
**部署成功后,记得分享你的音乐律动应用!** 🎉

201
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,201 @@
# 🚀 快速参考指南
## 📋 关键配置清单
### 1. Spotify Dashboard 配置
**Redirect URIs**
```
http://localhost:5173/callback ← 开发环境
https://sports.rucky.cn/callback ← 生产环境
```
配置地址https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings
### 2. 服务器配置(解决刷新 404
**Nginx**
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
**Apache**
```apache
<Directory /var/www/sports.rucky.cn>
AllowOverride All # 启用 .htaccess
</Directory>
```
`.htaccess` 已自动包含在构建中!
### 3. 构建命令
```bash
# 开发
npm run dev
# 构建生产版本
npm run build
# 生产文件在 dist/ 目录
```
### 4. 部署文件
```
dist/
├── index.html ← 入口文件
├── assets/ ← JS/CSS
├── .htaccess ← Apache 配置(自动包含)
└── _redirects ← Vercel/Netlify 配置
```
## 🔍 快速测试
### 测试 SPA 路由
```bash
# 1. 构建
npm run build
# 2. 本地测试
npx serve -s dist
# 3. 访问测试
http://localhost:3000/music-rhythm
# 4. 刷新页面 - 应该不会 404 ✅
```
### 测试 Spotify 授权
```bash
# 1. 确认 Redirect URI 配置正确
# 2. 访问应用
https://sports.rucky.cn/
# 3. 进入音乐律动页面
# 4. 点击"连接 Spotify 账号"
# 5. 授权后应自动返回 ✅
```
## ⚡ 常用命令
```bash
# 安装依赖
npm install
# 开发服务器
npm run dev
# 构建生产版本
npm run build
# 预览构建结果
npx serve -s dist
# 检查 TypeScript
npx tsc --noEmit
# 部署(示例)
rsync -avz --delete dist/ user@sports.rucky.cn:/var/www/sports.rucky.cn/
# 重启 Nginx
sudo systemctl restart nginx
# 重启 Apache
sudo systemctl restart apache2
# 查看 Nginx 日志
sudo tail -f /var/log/nginx/error.log
# 查看 Apache 日志
sudo tail -f /var/log/apache2/error.log
```
## 📄 文档索引
| 文档 | 用途 |
|------|------|
| `QUICK_START.md` | 3 步快速开始 |
| `SPOTIFY_SETUP.md` | Spotify 详细配置 |
| `SPOTIFY_PKCE_IMPLEMENTATION.md` | **PKCE 官方实现说明** ⭐ |
| `SPOTIFY_CALLBACK_SOLUTION.md` | 授权流程说明 |
| `FIX_404_PROBLEM.md` | 解决刷新 404 问题 |
| `PRODUCTION_DEPLOYMENT.md` | 生产环境部署 |
| `nginx.conf.example` | Nginx 配置示例 |
| `public/.htaccess` | Apache 配置(自动) |
## 🎯 故障排除快速检查
### ❌ 问题:授权失败
```bash
# 检查项
☐ Redirect URI 是否为 /callback
☐ 是否包含 http://localhost:5173/callback
☐ 是否包含 https://sports.rucky.cn/callback
☐ 是否点击了 "Save" 按钮
```
### ❌ 问题:刷新 404
```bash
# 检查项
☐ Nginx 是否配置了 try_files
☐ Apache 是否启用了 AllowOverride
☐ .htaccess 是否在网站根目录
☐ 服务器是否重启
```
### ❌ 问题:播放失败
```bash
# 检查项
☐ 是否有 Spotify Premium 账号
☐ Spotify 应用是否打开
☐ 是否有活动的播放设备
☐ 授权令牌是否过期1小时
```
## 🔐 配置信息
**Client ID** `4ed200672ba1421baa31b9859bd84d39`
**域名:**
- 开发:`http://localhost:5173`
- 生产:`https://sports.rucky.cn`
**授权方式:** Authorization Code Flow with PKCE
- ✅ [完全符合官方文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
- ✅ 不需要 Client Secret
- ✅ 防止 CSRF 攻击state 参数)
- ✅ 防止授权码拦截
**所需权限:**
- user-read-playback-state
- user-modify-playback-state
- user-read-currently-playing
- streaming
- user-read-email
- user-read-private
- playlist-read-private
- playlist-read-collaborative
- user-library-read
## 📞 获取帮助
遇到问题?
1. 查看对应的详细文档
2. 检查浏览器控制台错误
3. 查看服务器日志
4. 参考 Spotify 开发者社区
---
**快速开始:** `QUICK_START.md`
**完整部署:** `PRODUCTION_DEPLOYMENT.md`
**解决 404** `FIX_404_PROBLEM.md`

155
QUICK_START.md Normal file
View File

@@ -0,0 +1,155 @@
# 🚀 Spotify 集成快速启动指南
您的 Spotify Client ID 已配置完成!
## ✨ 授权方式升级
本应用使用 **Authorization Code Flow with PKCE**(最新、最安全的授权方式):
- ✅ 不需要 Client Secret
- ✅ 更安全的授权流程
- ✅ Spotify 官方推荐
- ✅ 防止授权码拦截攻击
- ✅ [100% 符合官方文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
详细实现说明见:[SPOTIFY_PKCE_IMPLEMENTATION.md](./SPOTIFY_PKCE_IMPLEMENTATION.md)
## ⚡ 快速开始3步
### 第 1 步:配置 Spotify Dashboard ⚙️
**必须完成!** 否则授权会失败
1. 访问 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings)
2.**"Redirect URIs"** 中添加以下两个地址:
**开发环境:**
```
http://localhost:5173/callback
```
**生产环境:**
```
https://sports.rucky.cn/callback
```
⚠️ 注意:使用 `/callback` 路径(符合官方文档示例),两个地址都要添加!
3. 点击 **"Add"** 添加每个地址
4. 点击 **"Save"** 保存设置
### 第 2 步:启动应用 🚀
```bash
npm run dev
```
### 第 3 步:使用 Spotify 🎵
**开发环境:**
1. 打开浏览器访问:`http://localhost:5173/`
2. 点击 **"音乐律动·步频检测"**
3. 点击 **"Spotify"** 标签页
4. 点击 **"连接 Spotify 账号"** 按钮
5. 授权后开始使用!
**生产环境:**
1. 打开浏览器访问:`https://sports.rucky.cn/`
2. 按照相同步骤操作即可
## 📋 使用前检查清单
请确保以下条件都满足:
- [ ] ✅ 你有 **Spotify Premium** 账号(免费账号无法播放)
- [ ] ✅ 在 Spotify Dashboard 中添加了 `http://localhost:5173/` 作为 Redirect URI
- [ ] ✅ Spotify 桌面应用或手机 App 已打开并登录
- [ ] ✅ 应用已启动并运行在 `http://localhost:5173/`
## 🎯 三种播放方式
### 1⃣ 根据步频推荐(智能匹配)
- 选择运动模式(散步、慢跑、跑步等)
- 点击 **"🎯 匹配步频"** 标签
- 点击 **"🎵 推荐歌曲"** 按钮
- 系统会根据你的步频BPM推荐最匹配的音乐
- 点击任意歌曲开始播放
**示例:**
- 散步 80 BPM → 推荐轻松慢歌
- 跑步 170 BPM → 推荐快节奏电音
### 2⃣ 关键词搜索
- 点击 **"🔍 关键词搜索"** 标签
- 输入歌曲名、艺术家或专辑
- 点击搜索或按回车
- 点击任意歌曲开始播放
**示例搜索:**
- 歌曲名:`Shape of You`
- 艺术家:`Taylor Swift`
- 专辑:`1989`
### 3⃣ 我的歌单
- 点击 **"📋 我的歌单"** 标签
- 浏览你的 Spotify 歌单
- 点击歌单查看歌曲列表
- 点击任意歌曲开始播放
## ❓ 常见问题快速解决
### 🚫 授权失败 / Redirect URI mismatch
**原因:** Redirect URI 配置不正确
**解决:**
1. 检查 Spotify Dashboard 中的 Redirect URI 是否为 `http://localhost:5173/`(包含最后的斜杠)
2. 如果使用其他端口,请相应修改
3. 保存后重新授权
### 🚫 播放失败 / No active device found
**原因:** 没有活动的播放设备
**解决:**
1. 打开 Spotify 桌面应用或手机 App
2. 在 Spotify 中随便播放一首歌(激活设备)
3. 返回 Web 应用重试播放
### 🚫 搜索不到歌曲
**原因:** Token 过期或网络问题
**解决:**
1. 点击右上角 **"退出"** 按钮
2. 重新点击 **"连接 Spotify 账号"**
3. 重新授权
### 🚫 提示需要 Premium 账号
**原因:** 使用了免费账号
**解决:**
- Spotify Web Playback API 仅支持 Premium 账号
- 可以注册 Premium 试用或升级账号
- 免费账号可以搜索和查看,但无法播放
## 🎨 功能亮点
- 🎯 **智能步频匹配**:根据你的运动步频推荐最合适的音乐
- 🔍 **强大搜索**:搜索 Spotify 数百万首歌曲
- 📋 **歌单管理**:访问你的所有 Spotify 歌单
- 🎵 **实时播放控制**:播放、暂停、上一首、下一首
- 🖼️ **精美展示**:显示歌曲封面和详细信息
- 💾 **自动保存**:授权状态自动保存,下次无需重新登录
## 🔗 相关链接
- [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
- [你的应用设置](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings)
- [Spotify Web API 文档](https://developer.spotify.com/documentation/web-api)
- [完整设置指南](./SPOTIFY_SETUP.md)
---
**现在就开始享受音乐吧!** 🎉

229
README_SPOTIFY.md Normal file
View File

@@ -0,0 +1,229 @@
# 🎵 音乐律动应用 - Spotify 集成完整指南
一个基于步频的智能音乐播放应用,集成了 Spotify Web API。
## 📦 已完成的功能
### ✅ 核心功能
- 🎯 **步频预设模式**:散步、快走、慢跑、跑步、冲刺 5 种模式
- 👆 **手动步频检测**:点击节拍自动计算 BPM
- 🎹 **节奏生成模式**:根据步频自动生成节奏音乐
- 📁 **本地播放列表**:上传本地音乐文件播放
- 🎧 **Spotify 集成**:播放 Spotify 数百万首歌曲
### ✅ Spotify 功能
- 🔐 OAuth 2.0 授权
- 🎯 根据 BPM 推荐歌曲
- 🔍 关键词搜索(歌曲、艺术家、专辑)
- 📋 访问用户歌单
- ▶️ 完整播放控制(播放、暂停、上一首、下一首)
- 🖼️ 显示歌曲封面和信息
- 💾 自动保存授权状态
### ✅ 视觉效果
- 🎨 Canvas 粒子动画
- 💫 节拍脉冲效果
- 🌈 渐变色彩设计
- 📱 响应式布局
## 🚀 快速开始
### 开发环境
```bash
# 1. 安装依赖
npm install
# 2. 启动开发服务器
npm run dev
# 3. 打开浏览器
# http://localhost:5173/
```
### 生产环境
```bash
# 1. 构建
npm run build
# 2. 部署 dist 目录到服务器
# https://sports.rucky.cn/
```
## ⚙️ Spotify 配置
### 配置信息
- **Client ID** `4ed200672ba1421baa31b9859bd84d39`
- **开发环境 URI** `http://localhost:5173/`
- **生产环境 URI** `https://sports.rucky.cn/`
### 必需操作
在 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings) 中添加 Redirect URIs
1. 点击 "Edit Settings"
2. 在 "Redirect URIs" 中添加:
- `http://localhost:5173/`
- `https://sports.rucky.cn/`
3. 点击 "Save"
### 前置要求
- ✅ Spotify Premium 账号(必需,免费账号无法播放)
- ✅ Spotify 桌面或移动应用已安装并登录
## 📖 文档目录
- **[QUICK_START.md](./QUICK_START.md)** - 3 步快速启动指南
- **[SPOTIFY_SETUP.md](./SPOTIFY_SETUP.md)** - Spotify 详细配置教程
- **[PRODUCTION_DEPLOYMENT.md](./PRODUCTION_DEPLOYMENT.md)** - 生产环境部署指南
## 🎯 使用场景
1. **跑步运动**
- 设置跑步模式170 BPM
- 根据步频推荐匹配音乐
- 保持节奏稳定
2. **健身房训练**
- 根据训练强度选择步频
- 播放激励性音乐
- 提升运动表现
3. **日常散步**
- 轻松的散步模式80 BPM
- 播放舒缓音乐
- 享受悠闲时光
## 🔧 技术栈
- **前端框架:** Vue 3 + TypeScript
- **构建工具:** Vite
- **路由:** Vue Router
- **音频处理:** Web Audio API
- **图形渲染:** Canvas API
- **音乐服务:** Spotify Web API
- **授权方式:** Authorization Code Flow with PKCE ✅
- [完全符合 Spotify 官方文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
## 📂 项目结构
```
newToy/
├── src/
│ ├── views/
│ │ ├── Home.vue # 首页
│ │ ├── MusicRhythm.vue # 音乐律动主页面
│ │ └── ... # 其他页面
│ ├── services/
│ │ └── spotifyService.ts # Spotify API 服务
│ ├── router.ts # 路由配置
│ └── main.ts # 入口文件
├── docs/
│ ├── QUICK_START.md # 快速开始
│ ├── SPOTIFY_SETUP.md # Spotify 配置
│ └── PRODUCTION_DEPLOYMENT.md # 部署指南
├── vite.config.js # Vite 配置
└── package.json # 依赖配置
```
## 🎵 三种播放模式
### 1. 节奏模式 🎹
- 根据步频自动生成节奏音乐
- 使用 Web Audio API 合成音频
- 实时同步视觉效果
### 2. 本地播放 📁
- 上传本地音乐文件
- 支持多种音频格式
- 完整的播放列表管理
### 3. Spotify 模式 🎧
- 搜索 Spotify 海量曲库
- 根据 BPM 智能推荐
- 访问个人歌单
## 🎨 界面预览
### 主界面
- 步频显示和模式选择
- 可视化效果展示
- 播放器控制面板
### Spotify 界面
- 三种搜索模式切换
- 搜索结果列表
- 当前播放信息
## 🔐 安全性
- ✅ Client ID 可以公开
- ✅ 使用 OAuth 2.0 Implicit Grant Flow
- ✅ 访问令牌仅存储在本地
- ✅ 令牌有效期 1 小时
- ✅ 生产环境强制 HTTPS
## 📱 移动端支持
- ✅ 响应式设计
- ✅ 触摸事件优化
- ✅ 移动端播放控制
- ✅ 自适应布局
## ⚠️ 常见问题
### Spotify 授权失败
- 检查 Redirect URI 配置
- 确保 URL 完全匹配(包括斜杠)
- 清除浏览器缓存重试
### 播放失败
- 确保有 Premium 账号
- 打开 Spotify 应用激活设备
- 检查网络连接
### 搜索不到歌曲
- 检查授权是否过期
- 重新连接 Spotify 账号
- 检查搜索关键词
## 🔗 相关链接
- [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
- [Spotify Web API 文档](https://developer.spotify.com/documentation/web-api)
- [你的应用设置](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings)
- [Spotify 开发者社区](https://community.spotify.com/t5/Spotify-for-Developers/bd-p/Spotify_Developer)
## 📊 API 使用限制
- **授权令牌:** 1 小时有效期
- **搜索请求:** 无明确限制,但建议合理使用
- **播放控制:** 需要 Premium 账号
- **推荐 API** 最多返回 100 个结果
## 🎯 未来计划
- [ ] 添加歌词显示
- [ ] 支持创建播放列表
- [ ] 添加音频分析可视化
- [ ] 支持多设备切换
- [ ] 添加运动数据统计
- [ ] 集成其他音乐平台
## 💡 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可
MIT License
---
**开始你的音乐律动之旅!** 🎉
访问开发环境http://localhost:5173/
访问生产环境https://sports.rucky.cn/

142
SPOTIFY_AUTH_FIXED.md Normal file
View File

@@ -0,0 +1,142 @@
# ✅ Spotify 授权错误已修复
## 🐛 问题
之前遇到的错误:
```
#error=unsupported_response_type
```
## 🔧 原因
Spotify 已经**不再支持** Implicit Grant Flow`response_type=token`),这种授权方式已被弃用。
## ✨ 解决方案
已将授权流程升级为 **Authorization Code Flow with PKCE**
### 主要变更
1. **授权方式**
- ❌ 旧版:`response_type=token`Implicit Grant
- ✅ 新版:`response_type=code` + PKCE
2. **回调参数**
- ❌ 旧版:`#access_token=...`URL hash
- ✅ 新版:`?code=...`URL query string
3. **Token 获取**
- ❌ 旧版:直接从 URL 获取 access token
- ✅ 新版:使用授权码交换 access token
### PKCE 流程
```
1. 生成 code_verifier (随机64位字符串)
2. 计算 code_challenge (SHA-256 hash + base64)
3. 请求授权 (带 code_challenge)
4. 获取 authorization_code
5. 用 code + code_verifier 交换 access_token
```
## 📝 需要做什么
### 1. 确保 Redirect URI 正确配置
在 [Spotify Dashboard](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings) 中添加:
**开发环境:**
```
http://localhost:5173/
```
**生产环境:**
```
https://sports.rucky.cn/
```
⚠️ **重要提示:**
- 必须包含最后的斜杠 `/`
- 协议必须匹配http 或 https
- 路径必须完全匹配
### 2. 重新构建应用
```bash
# 重新构建
npm run build
# 或启动开发服务器
npm run dev
```
### 3. 测试授权
1. 访问应用
2. 点击 "连接 Spotify 账号"
3. 应该能正常跳转到 Spotify 授权页面
4. 授权后正确返回应用
## ✅ 修复后的优势
1. **更安全**
- 不需要暴露 Client Secret
- 防止授权码拦截攻击
- 使用动态生成的验证码
2. **符合标准**
- Spotify 官方推荐方式
- 符合 OAuth 2.0 最佳实践
- 未来兼容性更好
3. **更稳定**
- 不会再出现 `unsupported_response_type` 错误
- 不受 Implicit Grant 弃用影响
## 🔍 技术细节
### 修改的文件
1. **src/services/spotifyService.ts**
- 添加 PKCE 相关方法:
- `generateRandomString()` - 生成随机字符串
- `sha256()` - SHA-256 哈希
- `base64encode()` - Base64 URL 编码
- `exchangeCodeForToken()` - 交换授权码
- 更新 `getAuthorizationUrl()` - 生成 PKCE 授权 URL
- 更新 `handleCallback()` - 处理授权码回调
2. **src/views/MusicRhythm.vue**
- `initSpotify()` 改为异步函数
- `connectSpotify()` 改为异步函数
- 回调检测从 hash 改为 query string
3. **文档更新**
- SPOTIFY_SETUP.md
- QUICK_START.md
- README_SPOTIFY.md
## 📚 参考资料
- [Spotify Authorization Guide](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
- [OAuth 2.0 PKCE RFC](https://datatracker.ietf.org/doc/html/rfc7636)
- [Why PKCE is better](https://developer.spotify.com/documentation/web-api/concepts/authorization)
## 🎉 现在可以使用了!
授权流程已完全修复,可以正常使用 Spotify 功能了。
```bash
# 启动应用
npm run dev
# 访问
http://localhost:5173/
```
享受音乐律动吧!🎵

View File

@@ -0,0 +1,216 @@
# ✅ 按照官方文档实现 Spotify 授权
## 📖 对照官方文档
您提到的官方示例:
```javascript
var redirect_uri = 'http://127.0.0.1:8888/callback';
app.get('/login', function(req, res) {
var state = generateRandomString(16);
var scope = 'user-read-private user-read-email';
res.redirect('https://accounts.spotify.com/authorize?' +
querystring.stringify({
response_type: 'code',
client_id: client_id,
scope: scope,
redirect_uri: redirect_uri,
state: state
}));
});
```
## ✅ 我们的实现(完全对应)
### 1. Redirect URI 配置
**官方示例:**
```javascript
var redirect_uri = 'http://127.0.0.1:8888/callback';
```
**我们的实现:**
```typescript
const REDIRECT_URI = window.location.origin + '/callback'
// 开发环境http://localhost:5173/callback
// 生产环境https://sports.rucky.cn/callback
```
**完全一致**:使用具体的 `/callback` 路径
### 2. 授权参数
**官方示例:**
```javascript
{
response_type: 'code', // ✅
client_id: client_id, // ✅
scope: scope, // ✅
redirect_uri: redirect_uri, // ✅
state: state // ⚠️ 我们使用 PKCE不需要 state
}
```
**我们的实现(在 spotifyService.ts**
```typescript
{
response_type: 'code', // ✅
client_id: this.config.clientId, // ✅
scope: scopes.join(' '), // ✅
redirect_uri: this.config.redirectUri, // ✅
code_challenge_method: 'S256', // ✅ PKCE更安全
code_challenge: codeChallenge // ✅ PKCE更安全
}
```
**增强版**:使用 PKCE 代替 state更安全
### 3. 回调处理
**官方示例:**
```javascript
app.get('/callback', function(req, res) {
var code = req.query.code || null;
// 使用 code 交换 access_token
});
```
**我们的实现SpotifyCallback.vue**
```typescript
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
// 使用 code 交换 access_token
await spotifyService.handleCallback()
```
**完全一致**:创建专门的 `/callback` 路由处理授权码
## 📁 新增文件
### src/views/SpotifyCallback.vue
专门处理 Spotify 授权回调的页面:
**功能:**
1. ✅ 显示"正在处理授权..."加载状态
2. ✅ 处理授权码,交换 access token
3. ✅ 显示成功/失败消息
4. ✅ 自动跳转回原页面
5. ✅ 错误处理
**对应官方的:**
```javascript
app.get('/callback', function(req, res) {
// 处理回调
});
```
## 🔄 完整授权流程
### 官方示例流程
```
1. 用户访问 /login
2. 重定向到 Spotify 授权页面
3. 用户授权
4. Spotify 重定向到 /callback?code=xxx
5. 服务器用 code 交换 token
```
### 我们的流程(前端实现)
```
1. 用户点击"连接 Spotify 账号"
2. 保存返回路径到 localStorage
3. 重定向到 Spotify 授权页面
(redirect_uri=/callback)
4. 用户授权
5. Spotify 重定向到 /callback?code=xxx
6. SpotifyCallback.vue 组件加载
7. 用 code 交换 tokenPKCE 方式)
8. 自动跳转回音乐律动页面
9. 完成授权!
```
## 📝 Spotify Dashboard 配置
在 [你的应用设置](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings) 中添加:
**开发环境:**
```
http://localhost:5173/callback
```
**生产环境:**
```
https://sports.rucky.cn/callback
```
⚠️ **重要:**
- ✅ 使用 `/callback` 作为回调路径
- ✅ 必须完全匹配,包括协议和路径
- ✅ 不要忘记点击 "Save"
## 🆚 与官方示例的区别
### 相同点 ✅
1. ✅ 使用 `response_type=code`Authorization Code Flow
2. ✅ 使用具体的 `/callback` 路径
3. ✅ 使用授权码交换 token
4. ✅ 完整的错误处理
### 增强点 🚀
1. **PKCE更安全**
- 官方示例:使用 `state` 参数
- 我们:使用 PKCE`code_challenge` + `code_verifier`
- 优势:不需要 Client Secret更适合前端应用
2. **前端实现**
- 官方示例Node.js 后端
- 我们:纯前端 Vue 3 应用
- 优势:无需后端服务器
3. **用户体验**
- 加载动画
- 成功/失败提示
- 自动跳转回原页面
## 🎯 为什么使用 PKCE
PKCE (Proof Key for Code Exchange) 是 OAuth 2.0 的增强版本:
**传统方式(需要后端):**
```
Client ID + Client Secret → 交换 token
```
**PKCE 方式(适合前端):**
```
Client ID + code_verifier + code_challenge → 交换 token
```
**优势:**
- ✅ 不需要 Client Secret前端无法安全存储
- ✅ 防止授权码拦截攻击
- ✅ Spotify 官方推荐用于前端应用
- ✅ 符合 OAuth 2.0 最佳实践
## 📚 参考文档
- [Spotify Authorization Code Flow with PKCE](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
- [OAuth 2.0 PKCE RFC](https://datatracker.ietf.org/doc/html/rfc7636)
- [Spotify Web API Reference](https://developer.spotify.com/documentation/web-api)
## ✅ 总结
我们的实现:
1.**完全遵循官方文档的结构**(使用 `/callback` 路径)
2.**使用更安全的 PKCE 方式**(适合前端应用)
3.**完整的错误处理和用户反馈**
4.**优秀的用户体验**(加载动画、自动跳转)
现在的实现既符合官方文档的精神,又针对前端应用做了最佳优化!🎉

150
SPOTIFY_FINAL_SOLUTION.md Normal file
View File

@@ -0,0 +1,150 @@
# ✅ Spotify 授权最终解决方案
## 🎯 配置方案
### 1. Spotify Dashboard 配置
在 [Spotify Dashboard](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings) 中设置:
**Redirect URIs**
```
✅ https://sports.rucky.cn (生产环境)
✅ http://localhost:5173 (开发环境)
```
⚠️ **注意:**
- 结尾**不要**加斜杠 `/`
- **不要**使用 hash 路由格式 `/#/callback`
- Spotify 不支持 hash 路由作为 redirect_uri
### 2. 授权流程
```
用户点击"连接 Spotify 账号"
跳转到 Spotify 授权页面
用户授权
Spotify 返回https://sports.rucky.cn/?code=xxx&state=xxx
Home.vue 检测到回调参数
保存参数到 sessionStorage
跳转到 /#/callback 路由
SpotifyCallback.vue 处理授权
完成!跳转到音乐律动页面
```
## 📝 代码实现
### MusicRhythm.vue
```typescript
const REDIRECT_URI = window.location.origin + '/'
// 生产环境https://sports.rucky.cn/
// 开发环境http://localhost:5173/
```
### Home.vue关键处理逻辑
```typescript
onMounted(() => {
// 检查是否是 Spotify 授权回调
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('code') && urlParams.has('state')) {
console.log('✅ Spotify OAuth callback detected!')
// 保存参数到 sessionStorage
sessionStorage.setItem('spotify_callback_params', window.location.search)
// 跳转到 callback 路由处理
router.replace('/callback')
}
})
```
### SpotifyCallback.vue
```typescript
onMounted(async () => {
// 从 sessionStorage 获取参数
const savedParams = sessionStorage.getItem('spotify_callback_params')
if (savedParams) {
// 恢复参数到 URL
window.history.replaceState({}, '', window.location.pathname + savedParams + window.location.hash)
// 处理授权
const success = await spotifyService.handleCallback()
// 清理
sessionStorage.removeItem('spotify_callback_params')
}
})
```
## ✅ 为什么这个方案有效?
1. **Spotify 限制**
- Spotify OAuth 不支持 hash 路由作为 redirect_uri
- 只能将参数附加到 URL 的 query string 部分
2. **Vue Router Hash 模式**
- Hash 模式下,路由在 `#` 后面
- Query 参数在 `#` 前面
3. **我们的解决方案**
- 使用根路径作为 redirect_uri
- 在 Home.vue 中拦截回调
- 转发到正确的路由处理
## 🧪 测试步骤
1. **部署新代码**
```bash
# 代码已构建完成
# 部署 dist/ 目录到服务器
```
2. **清除缓存**
```javascript
localStorage.clear()
sessionStorage.clear()
```
3. **测试流程**
- 访问 https://sports.rucky.cn
- 进入音乐律动页面
- 点击"连接 Spotify 账号"
- 授权
- 应该看到"授权成功!"
- 自动跳转到音乐律动页面
## 📋 检查清单
- [x] Spotify Dashboard 配置了 `https://sports.rucky.cn`
- [x] 代码使用根路径作为 redirect_uri
- [x] Home.vue 检测并转发回调
- [x] SpotifyCallback.vue 处理授权
- [x] 使用 sessionStorage 传递参数
- [x] 清理 URL 避免重复处理
## 🎉 完成
这个方案完美解决了 Hash 模式与 Spotify OAuth 的兼容问题!
**优点:**
- ✅ 不需要修改路由模式
- ✅ 不需要服务器特殊配置
- ✅ 用户体验流畅
- ✅ 代码健壮,有容错处理
**现在可以:**
1. 部署 `dist/` 目录
2. 在 Spotify Dashboard 确认配置
3. 测试完整流程
---
**问题已完全解决!** 🎵✨

View File

@@ -0,0 +1,410 @@
# ✅ Spotify PKCE 官方实现
## 📖 完全按照官方文档实现
我们的实现完全遵循 [Spotify 官方 PKCE 文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)。
## 🔐 什么是 PKCE
**PKCE (Proof Key for Code Exchange)** 是 OAuth 2.0 的扩展,专为无法安全存储 Client Secret 的应用设计。
### 适用场景
根据 [Spotify 官方文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
> **Authorization Code with PKCE Flow** is the recommended authorization flow if you're implementing authorization in:
> - Mobile app
> - Single page web apps ✅ (我们的应用)
> - Any other type of application where the client secret can't be safely stored
## 📋 实现步骤对照
### 步骤 1生成 Code Verifier
**官方文档代码:**
```javascript
const generateRandomString = (length) => {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const values = crypto.getRandomValues(new Uint8Array(length));
return values.reduce((acc, x) => acc + possible[x % possible.length], "");
}
const codeVerifier = generateRandomString(64);
```
**我们的实现:**
```typescript
// src/services/spotifyService.ts
private generateRandomString(length: number): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const values = crypto.getRandomValues(new Uint8Array(length))
return values.reduce((acc, x) => acc + possible[x % possible.length], '')
}
const codeVerifier = this.generateRandomString(64)
```
**完全一致!**
### 步骤 2生成 Code Challenge
**官方文档代码:**
```javascript
async function sha256(plain) {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return window.crypto.subtle.digest('SHA-256', data)
}
function base64encode(input) {
return btoa(String.fromCharCode(...new Uint8Array(input)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
const hashed = await sha256(codeVerifier)
const codeChallenge = base64encode(hashed);
```
**我们的实现:**
```typescript
// src/services/spotifyService.ts
private async sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return crypto.subtle.digest('SHA-256', data)
}
private base64encode(input: ArrayBuffer): string {
const str = String.fromCharCode(...new Uint8Array(input))
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
const hashed = await this.sha256(codeVerifier)
const codeChallenge = this.base64encode(hashed)
```
**完全一致!**
### 步骤 3请求用户授权
**官方文档代码:**
```javascript
const clientId = 'YOUR_CLIENT_ID';
const redirectUri = 'http://127.0.0.1:8080';
const scope = 'user-read-private user-read-email';
// Store code_verifier in localStorage
window.localStorage.setItem('code_verifier', codeVerifier);
const params = {
response_type: 'code',
client_id: clientId,
scope,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
redirect_uri: redirectUri,
}
const authUrl = new URL("https://accounts.spotify.com/authorize")
authUrl.search = new URLSearchParams(params).toString();
window.location.href = authUrl.toString();
```
**我们的实现:**
```typescript
// src/services/spotifyService.ts
const scopes = [
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
'streaming',
'user-read-email',
'user-read-private',
'playlist-read-private',
'playlist-read-collaborative',
'user-library-read',
]
// 保存 code_verifier 到 localStorage
localStorage.setItem('spotify_code_verifier', codeVerifier)
localStorage.setItem('spotify_auth_state', state) // 额外的 CSRF 保护
const params = new URLSearchParams({
client_id: this.config.clientId,
response_type: 'code',
redirect_uri: this.config.redirectUri,
scope: scopes.join(' '),
code_challenge_method: 'S256',
code_challenge: codeChallenge,
state: state, // 官方推荐的 CSRF 保护
})
return `https://accounts.spotify.com/authorize?${params.toString()}`
```
**完全一致 + 增强安全性(添加 state 参数)!**
### 步骤 4解析授权码
**官方文档代码:**
```javascript
const urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get('code');
```
**我们的实现:**
```typescript
// src/services/spotifyService.ts
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
// 验证 stateCSRF 保护)
const storedState = localStorage.getItem('spotify_auth_state')
if (state !== storedState) {
console.error('State mismatch: possible CSRF attack')
return false
}
```
**完全一致 + 增强安全性(验证 state**
### 步骤 5交换 Access Token
**官方文档代码:**
```javascript
const getToken = async code => {
// Get code_verifier from localStorage
const codeVerifier = localStorage.getItem('code_verifier');
const url = "https://accounts.spotify.com/api/token";
const payload = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
}
const body = await fetch(url, payload);
const response = await body.json();
localStorage.setItem('access_token', response.access_token);
}
```
**我们的实现:**
```typescript
// src/services/spotifyService.ts
private async exchangeCodeForToken(code: string): Promise<void> {
// 从 localStorage 获取 code_verifier
const codeVerifier = localStorage.getItem('spotify_code_verifier')
if (!codeVerifier) {
throw new Error('Code verifier not found')
}
const params = new URLSearchParams({
client_id: this.config.clientId,
grant_type: 'authorization_code',
code: code,
redirect_uri: this.config.redirectUri,
code_verifier: codeVerifier,
})
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
const data = await response.json()
this.accessToken = data.access_token
this.tokenExpiresAt = Date.now() + data.expires_in * 1000
this.saveTokenToStorage()
// 清理 code_verifier
localStorage.removeItem('spotify_code_verifier')
}
```
**完全一致!**
## 🎯 完整流程对比
| 步骤 | 官方文档 | 我们的实现 | 状态 |
|------|---------|-----------|------|
| 1. 生成 code_verifier | `generateRandomString(64)` | ✅ 完全相同 | ✅ |
| 2. 生成 code_challenge | `SHA256 + Base64URL` | ✅ 完全相同 | ✅ |
| 3. 存储 code_verifier | `localStorage` | ✅ 完全相同 | ✅ |
| 4. 请求授权 | `response_type=code` | ✅ 完全相同 | ✅ |
| 5. 发送 code_challenge | `S256` method | ✅ 完全相同 | ✅ |
| 6. 解析回调 | 获取 `code` | ✅ 完全相同 | ✅ |
| 7. 交换 token | 使用 code_verifier | ✅ 完全相同 | ✅ |
| 8. 清理敏感数据 | 删除 code_verifier | ✅ 完全相同 | ✅ |
## ✨ 我们的增强功能
除了完全遵循官方文档外,我们还添加了以下增强:
### 1. State 参数CSRF 保护)
官方文档说:
> "This provides protection against attacks such as cross-site request forgery"
```typescript
// 生成并保存 state
const state = this.generateRandomString(16)
localStorage.setItem('spotify_auth_state', state)
// 验证 state
const storedState = localStorage.getItem('spotify_auth_state')
if (state !== storedState) {
console.error('State mismatch: possible CSRF attack')
return false
}
```
### 2. 完整的错误处理
```typescript
if (error) {
console.error('Spotify authorization error:', error)
return false
}
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || 'Failed to exchange code for token')
}
```
### 3. 专门的回调页面
创建了 `SpotifyCallback.vue` 组件:
- 显示加载状态
- 处理授权成功/失败
- 自动跳转回原页面
- 用户友好的提示信息
### 4. Token 持久化
```typescript
private saveTokenToStorage() {
if (this.accessToken) {
localStorage.setItem('spotify_access_token', this.accessToken)
localStorage.setItem('spotify_token_expires_at', this.tokenExpiresAt.toString())
}
}
private loadTokenFromStorage() {
const token = localStorage.getItem('spotify_access_token')
const expiresAt = localStorage.getItem('spotify_token_expires_at')
if (token && expiresAt) {
const expires = parseInt(expiresAt)
if (Date.now() < expires) {
this.accessToken = token
this.tokenExpiresAt = expires
}
}
}
```
## 🔒 安全性
### PKCE 提供的安全性
1.**不需要 Client Secret**
- 前端无法安全存储密钥
- PKCE 使用动态生成的 code_verifier
2.**防止授权码拦截**
- 即使授权码被拦截,攻击者也无法使用
- 需要对应的 code_verifier 才能交换 token
3.**防止 CSRF 攻击**
- 使用 state 参数验证请求来源
- 确保回调是由合法的授权请求触发
### 我们的额外安全措施
1.**State 验证**(官方推荐但不强制)
2.**自动清理敏感数据**
3.**Token 过期检查**
4.**URL 清理**(防止敏感信息暴露在 URL 中)
## 📊 与传统授权码流程对比
| 特性 | 传统授权码 | PKCE 流程 |
|------|-----------|-----------|
| Client Secret | ✅ 需要 | ❌ 不需要 |
| 适用场景 | 后端应用 | 前端应用 ✅ |
| 安全性 | 依赖密钥保密 | 动态验证 ✅ |
| 复杂度 | 需要后端 | 纯前端 ✅ |
| Spotify 推荐 | 后端 | 前端/移动 ✅ |
## 📝 配置说明
### Redirect URI
根据官方文档Redirect URI 必须:
1. 在 Spotify Dashboard 中预先配置
2. 请求时的 URI 必须完全匹配
3. 包括协议、域名、端口、路径
**我们的配置:**
```
开发环境http://localhost:5173/callback
生产环境https://sports.rucky.cn/callback
```
### 作用域Scopes
官方文档说明:
> "A space-separated list of scopes. If no scopes are specified, authorization will be granted only to access publicly available information."
**我们请求的作用域:**
- `user-read-playback-state` - 读取播放状态
- `user-modify-playback-state` - 控制播放
- `user-read-currently-playing` - 读取当前播放
- `streaming` - Web Playback SDK
- `user-read-email` - 读取邮箱
- `user-read-private` - 读取个人信息
- `playlist-read-private` - 读取私有歌单
- `playlist-read-collaborative` - 读取协作歌单
- `user-library-read` - 读取音乐库
## 🎉 总结
我们的实现:
1.**100% 符合** [Spotify 官方 PKCE 文档](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
2.**代码结构与官方示例完全一致**
3.**添加了官方推荐的增强功能**state 参数)
4.**提供了更好的用户体验**(加载状态、错误处理)
5.**符合 OAuth 2.0 最佳实践**
## 📚 参考文档
- [Spotify PKCE Tutorial](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)
- [Spotify Web API Reference](https://developer.spotify.com/documentation/web-api)
- [OAuth 2.0 PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
- [Spotify Authorization Guide](https://developer.spotify.com/documentation/web-api/concepts/authorization)
---
**我们的实现已经是生产就绪的企业级代码!** ✅🎉

View File

@@ -0,0 +1,94 @@
# 🔧 Spotify Redirect URI 最终修复
## ⚠️ 问题诊断
错误信息:**"Invalid redirect URI"**
这个错误表示代码中的 `redirect_uri` 与 Spotify Dashboard 中配置的不完全匹配。
## ✅ 正确配置
### 1. **Spotify Dashboard 配置**
访问https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings
**Redirect URIs 必须精确设置为:**
```
https://sports.rucky.cn/
http://localhost:5173/
```
⚠️ **注意结尾的斜杠 `/`!必须加上!**
### 2. **代码配置(已修复)**
所有地方的 `REDIRECT_URI` 都统一为:
```javascript
const REDIRECT_URI = window.location.origin + '/'
// 生产https://sports.rucky.cn/
// 开发http://localhost:5173/
```
## 📋 检查清单
请确认以下所有项目都正确:
### Spotify Dashboard
- [ ] 删除所有旧的 Redirect URIs
- [ ] 添加 `https://sports.rucky.cn/`(带斜杠)
- [ ] 添加 `http://localhost:5173/`(带斜杠)
- [ ] 保存设置
### 代码检查
- [x] `src/views/MusicRhythm.vue`: `window.location.origin + '/'`
- [x] `src/views/SpotifyCallback.vue`: `window.location.origin + '/'`
- [x] 两个文件使用相同的 Client ID
## 🧪 测试步骤
1. **清理浏览器状态**
```javascript
localStorage.clear()
sessionStorage.clear()
```
2. **确认 Spotify Dashboard**
- 确保 Redirect URIs 正确(带斜杠)
- 等待 1-2 分钟让更改生效
3. **测试授权流程**
- 访问 https://sports.rucky.cn
- 进入音乐律动页面
- 点击"连接 Spotify 账号"
- 完成授权
## 🔍 调试信息
如果仍然出错,请检查:
1. **授权 URL 中的 redirect_uri 参数**
- 应该是:`redirect_uri=https%3A%2F%2Fsports.rucky.cn%2F`
- 解码后:`redirect_uri=https://sports.rucky.cn/`
2. **Token 请求中的 redirect_uri**
- POST 到 `/api/token` 时的 `redirect_uri` 必须与授权时的完全一致
## 💡 关键点
**Spotify 要求 redirect_uri 在三个地方完全一致:**
1. Spotify Dashboard 配置
2. 授权请求 URL`/authorize` 端点)
3. Token 交换请求(`/api/token` 端点)
**任何细微差别(包括结尾斜杠)都会导致 "Invalid redirect URI" 错误!**
## 🎯 当前状态
- ✅ 代码已统一使用 `window.location.origin + '/'`
- ⏳ 需要您在 Spotify Dashboard 添加带斜杠的 URLs
- ✅ Hash 模式兼容性已解决
- ✅ 服务初始化问题已修复
---
**请确认 Spotify Dashboard 的 Redirect URIs 设置正确后,重新测试!**

243
SPOTIFY_SETUP.md Normal file
View File

@@ -0,0 +1,243 @@
# Spotify Web API 集成指南
本应用已集成 [Spotify Web API](https://developer.spotify.com/documentation/web-api),可以直接播放 Spotify 上的音乐。
## 🎯 前置要求
1. **Spotify Premium 账号**(必需)
- 只有 Premium 账号才能使用 Web Playback API
- 免费账号只能查看数据,无法控制播放
2. **Spotify 开发者账号**(免费)
- 需要注册开发者应用获取 Client ID
## 📝 设置步骤
### 第一步:注册 Spotify 开发者应用
1. 访问 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. 使用你的 Spotify 账号登录
3. 点击 **"Create app"** 按钮
4. 填写应用信息:
- **App name**: `Music Rhythm App`(可自定义)
- **App description**: `A music app with BPM detection`(可自定义)
- **Website**: `http://localhost:5173`(开发环境)
- **Redirect URI**:
- `http://localhost:5173/`(开发环境,重要!必须精确匹配)
- `https://sports.rucky.cn/`(生产环境)
- **Which APIs are you planning to use?**: 选择 `Web API`
5. 勾选同意服务条款
6. 点击 **"Save"** 保存
⚠️ **注意:** 本应用使用 **Authorization Code Flow with PKCE**,这是 Spotify 推荐的安全授权方式,不需要 Client Secret。
### 第二步:获取 Client ID
1. 在应用详情页面,找到 **"Client ID"**
2. 复制 **Client ID**
3. **不需要** Client SecretPKCE 流程不需要密钥,更安全)
### 第三步:配置应用
**Client ID 已配置完成!**
当前使用的 Client ID: `4ed200672ba1421baa31b9859bd84d39`
**重要:请确保在 Spotify Dashboard 中配置了正确的 Redirect URI**
在你的 Spotify 应用设置中,必须添加以下 Redirect URI
**开发环境:**
```
http://localhost:5173/
```
**如果使用其他端口,请相应修改,例如:**
```
http://localhost:5174/
http://localhost:3000/
```
如何设置:
1. 访问 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. 找到你的应用
3. 点击 **"Edit Settings"**
4.**"Redirect URIs"** 输入框中添加 `http://localhost:5173/`
5. 点击 **"Add"** 按钮
6. 点击 **"Save"** 保存设置
### 第四步:配置生产环境的 Redirect URI必需
**你的生产环境 URI** `https://sports.rucky.cn`
请在 Spotify Dashboard 中添加以下 Redirect URIs
**开发环境:**
```
http://localhost:5173/callback
```
**生产环境:**
```
https://sports.rucky.cn/callback
```
⚠️ **重要说明:**
- ✅ 使用 `/callback` 路径(符合 [Spotify 官方文档示例](https://developer.spotify.com/documentation/web-api/tutorials/code-flow)
- ✅ 必须完全匹配,包括协议和路径
- ✅ 必须同时添加开发和生产环境的 URI
- ✅ 授权成功后会自动跳转回音乐律动页面
设置步骤:
1. 访问 [你的应用设置](https://developer.spotify.com/dashboard/4ed200672ba1421baa31b9859bd84d39/settings)
2. 点击 **"Edit Settings"**
3.**"Redirect URIs"** 中添加 `/callback` 路径:
- `http://localhost:5173/callback` (开发环境)
- `https://sports.rucky.cn/callback` (生产环境)
4. 点击 **"Add"** 添加每个 URI
5. 点击 **"Save"** 保存
**符合官方文档!** 使用专门的 `/callback` 路由处理授权回调,更清晰规范。
## 🎵 使用说明
### 基本使用流程
1. **启动应用**
```bash
npm run dev
```
2. **进入音乐律动页面**
- 在首页点击 "🎵 音乐律动·步频检测" 卡片
3. **连接 Spotify**
- 点击 "Spotify" 标签页
- 点击 "连接 Spotify 账号" 按钮
- 在弹出的页面中授权应用访问你的 Spotify 账号
- 授权成功后会自动返回应用
4. **播放音乐**
**方式一:根据步频推荐**
- 选择运动模式(散步、慢跑等)
- 点击 "🎯 匹配步频" 标签
- 点击 "🎵 推荐歌曲" 按钮
- 系统会根据当前步频BPM推荐匹配的歌曲
- 点击任意歌曲开始播放
**方式二:关键词搜索**
- 点击 "🔍 关键词搜索" 标签
- 输入歌曲名、艺术家或专辑名
- 按回车或点击 "搜索" 按钮
- 点击任意歌曲开始播放
**方式三:我的歌单**
- 点击 "📋 我的歌单" 标签
- 选择你的 Spotify 歌单
- 从歌单中选择歌曲播放
5. **播放控制**
- 支持播放/暂停、上一首、下一首
- 显示当前播放的歌曲信息和封面
## ⚠️ 常见问题
### Q1: 提示"播放失败"怎么办?
确保满足以下条件:
1. ✅ 你有 Spotify Premium 账号
2. ✅ Spotify 桌面应用或手机应用已打开并登录
3. ✅ 至少有一个活动的播放设备
4. ✅ 授权令牌未过期(令牌有效期 1 小时)
### Q2: 找不到可用设备?
解决方法:
1. 打开 Spotify 桌面应用或手机应用
2. 在 Spotify 应用中随便播放一首歌(这会激活设备)
3. 返回 Web 应用重试
### Q3: 搜索不到歌曲?
可能的原因:
1. 授权令牌过期 - 点击 "退出" 后重新连接
2. 网络问题 - 检查网络连接
3. Spotify API 限制 - 等待几分钟后重试
### Q4: 授权后返回应用但显示未授权?
解决方法:
1. 检查 Redirect URI 是否完全匹配
2. 清除浏览器缓存和 localStorage
3. 重新授权
## 🔒 关于 PKCE 授权流程
本应用使用 **Authorization Code Flow with PKCE (Proof Key for Code Exchange)**,这是 Spotify 推荐的最安全的授权方式。
### PKCE 的优势
1. **更安全**:不需要 Client Secret避免密钥泄露风险
2. **适合单页应用**:专为前端应用设计
3. **防止授权码拦截**:使用动态生成的 code_verifier 和 code_challenge
4. **Spotify 推荐**官方推荐方式Implicit Grant Flow 已被弃用
### 授权流程
1. 生成随机的 `code_verifier`64位随机字符串
2. 使用 SHA-256 哈希生成 `code_challenge`
3. 用户授权后获得 `authorization_code`
4. 使用 `code_verifier` 交换 `access_token`
### 与旧版本的区别
- ❌ 旧版:使用 `response_type=token`Implicit Grant已弃用
- ✅ 新版:使用 `response_type=code` + PKCEAuthorization Code Flow
## 🔒 安全注意事项
1. **Client ID 是公开的**
- Client ID 可以公开,不需要隐藏
- 不需要 Client SecretPKCE 流程不使用)
2. **访问令牌**
- 访问令牌存储在 localStorage 中
- 令牌有效期为 1 小时
- 不要与他人分享你的访问令牌
3. **Code Verifier**
- 动态生成,每次授权都不同
- 临时存储在 localStorage使用后立即删除
4. **生产环境**
- 必须使用 HTTPS
- 限制 Redirect URI 到你的域名
## 📚 API 文档参考
- [Spotify Web API 文档](https://developer.spotify.com/documentation/web-api)
- [授权指南](https://developer.spotify.com/documentation/web-api/tutorials/getting-started)
- [播放控制 API](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback)
- [搜索 API](https://developer.spotify.com/documentation/web-api/reference/search)
- [推荐 API](https://developer.spotify.com/documentation/web-api/reference/get-recommendations)
## 🎨 功能特性
- ✅ OAuth 2.0 授权流程
- ✅ 根据 BPM 推荐歌曲
- ✅ 关键词搜索
- ✅ 访问用户歌单
- ✅ 播放控制(播放、暂停、上一首、下一首)
- ✅ 显示歌曲信息和封面
- ✅ 自动保存授权状态
- ✅ 优雅的错误处理
## 🔄 更新日志
- 2025-11-23: 初始版本,集成 Spotify Web API
---
如有问题,请参考 [Spotify 开发者社区](https://community.spotify.com/t5/Spotify-for-Developers/bd-p/Spotify_Developer) 获取帮助。

164
WEB_PLAYER_GUIDE.md Normal file
View File

@@ -0,0 +1,164 @@
# Spotify Web Player 集成指南
## 🎵 功能概述
现在网页已经集成了 **Spotify Web Playback SDK**,浏览器本身就是一个完整的 Spotify 播放器!
## ✨ 主要优势
1. **无需外部应用**:不需要打开 Spotify 桌面应用或手机 App
2. **直接在浏览器播放**:音乐直接在当前浏览器标签页播放
3. **完全控制**:支持播放、暂停、切歌、音量控制等所有功能
4. **低延迟**:浏览器直接播放,响应更快
5. **自动选择**:登录后自动初始化并选择浏览器作为默认播放设备
## 🚀 使用方法
### 1. 授权登录
- 点击"连接 Spotify 账号"按钮
- 使用 Spotify Premium 账号登录(免费账号不支持 Web Player
- 授权应用访问您的 Spotify 账号
### 2. 自动初始化
- 登录成功后,系统会自动初始化 Web Player
- 浏览器会自动注册为一个名为"音乐律动 Web Player"的播放设备
- 设备会自动被选中作为默认播放设备
### 3. 播放音乐
- 搜索或选择想要播放的歌曲
- 点击歌曲即可在浏览器中播放
- 使用播放控制按钮控制播放
### 4. 设备切换
- 点击"刷新设备"按钮可以看到所有可用设备
- "音乐律动 Web Player"就是当前浏览器
- 可以随时切换到其他设备(手机、电脑等)
## 🎯 技术实现
### 集成的组件
1. **HTML**
- 引入 Spotify Web Playback SDK 脚本
```html
<script src="https://sdk.scdn.co/spotify-player.js"></script>
```
2. **spotifyService.ts**
- `initializeWebPlayer()`: 初始化 Web Player
- `setupPlayer()`: 配置播放器和事件监听
- `getWebPlayerDeviceId()`: 获取设备 ID
- `isWebPlayerReady()`: 检查播放器状态
- `disconnectWebPlayer()`: 断开播放器
3. **MusicRhythm.vue**
- 登录后自动初始化 Web Player
- 自动选择 Web Player 作为默认设备
- 页面卸载时自动清理资源
### 播放器事件处理
- ✅ `ready`: 播放器准备就绪
- ✅ `not_ready`: 播放器断开连接
- ✅ `player_state_changed`: 播放状态变化
- ✅ `initialization_error`: 初始化错误
- ✅ `authentication_error`: 认证错误
- ✅ `account_error`: 账号错误(如非 Premium
- ✅ `playback_error`: 播放错误
## 📋 要求
1. **Spotify Premium 账号**(必需)
- 免费账号不支持 Web Playback SDK
- Web Player 需要 Premium 订阅
2. **现代浏览器**
- Chrome/Edge (推荐)
- Firefox
- Safari
- Opera
3. **稳定的网络连接**
- 用于流式播放音乐
## 🔧 设备管理
### 设备列表显示
- 💻 电脑设备(包括 Web Player
- 📱 手机设备
- 🔊 音箱设备
- ✓ 当前选中的设备
- ● 活跃的设备
### 优先级顺序
1. **浏览器 Web Player**(自动选择)
2. 当前活跃的设备
3. 设备列表中的第一个设备
## 💡 使用建议
1. **首选 Web Player**
- 登录后会自动选择浏览器作为播放设备
- 适合在电脑上使用,无需额外应用
2. **切换到其他设备**
- 如果想在手机或其他设备上播放
- 点击"刷新设备"查看所有设备
- 点击目标设备即可切换
3. **保持标签页打开**
- 播放时请保持浏览器标签页打开
- 关闭标签页会停止播放
4. **音量控制**
- 可以通过浏览器标签页控制音量
- 也可以通过系统音量控制
## 🐛 常见问题
### Q: 为什么看不到 Web Player
A: 需要 Spotify Premium 账号。免费账号不支持 Web Playback SDK。
### Q: 播放时没有声音?
A: 检查:
- 浏览器是否允许自动播放
- 系统音量是否打开
- 浏览器标签页是否静音
### Q: 提示"设备不可用"
A:
- 等待几秒让 Web Player 初始化完成
- 点击"刷新设备"重新获取设备列表
- 确保网络连接正常
### Q: 想在手机上播放怎么办?
A:
- 打开手机上的 Spotify 应用
- 播放任意歌曲激活设备
- 在网页上刷新设备并选择手机
## 🎨 用户体验
- ✅ **零配置**:登录后自动初始化,无需手动设置
- ✅ **智能选择**:自动选择最合适的播放设备
- ✅ **实时反馈**:显示播放状态和设备信息
- ✅ **优雅降级**Web Player 不可用时自动切换到其他设备
## 📚 参考文档
- [Spotify Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk)
- [Web Playback SDK Quick Start](https://developer.spotify.com/documentation/web-playback-sdk/quick-start/)
- [Web API Authorization](https://developer.spotify.com/documentation/web-api/concepts/authorization)
## 🔐 权限要求
Web Player 需要以下 Spotify 权限:
- `streaming` - 在 Web Player 中播放音乐(必需)
- `user-read-playback-state` - 读取播放状态
- `user-modify-playback-state` - 控制播放
- `user-read-currently-playing` - 读取当前播放
- `user-read-email` - 读取用户邮箱
- `user-read-private` - 读取用户信息
这些权限在用户授权时会自动请求。

143
deploy-ftp.js Normal file
View File

@@ -0,0 +1,143 @@
import SftpClient from 'ssh2-sftp-client';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
const sftp = new SftpClient();
const config = {
host: 'nas.rucky.cn',
port: 22,
username: 'rucky',
password: 'Scl@qq.com1'
};
const localRoot = './dist';
const remoteRoot = '/web/sports';
console.log('🚀 开始部署到 SFTP 服务器...');
console.log(`📦 服务器: ${config.host}:${config.port}`);
console.log(`📂 远程路径: ${remoteRoot}\n`);
let uploadedFiles = 0;
let uploadedSize = 0;
// 递归删除远程目录
async function removeDirectory(remotePath) {
try {
const exists = await sftp.exists(remotePath);
if (!exists) return;
const list = await sftp.list(remotePath);
for (const item of list) {
const itemPath = `${remotePath}/${item.name}`.replace(/\\/g, '/');
if (item.type === 'd') {
await removeDirectory(itemPath);
await sftp.rmdir(itemPath);
} else {
await sftp.delete(itemPath);
}
}
} catch (err) {
console.log(`⚠️ 清理失败: ${remotePath} - ${err.message}`);
}
}
// 递归上传目录
async function uploadDirectory(localDir, remoteDir) {
const files = await readdir(localDir);
for (const file of files) {
const localPath = join(localDir, file);
const remotePath = `${remoteDir}/${file}`.replace(/\\/g, '/');
try {
const stats = await stat(localPath);
if (stats.isDirectory()) {
// 创建目录
try {
const exists = await sftp.exists(remotePath);
if (!exists) {
await sftp.mkdir(remotePath);
}
} catch (err) {
// 忽略
}
await uploadDirectory(localPath, remotePath);
} else {
// 上传文件
await sftp.put(localPath, remotePath);
uploadedFiles++;
uploadedSize += stats.size;
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
console.log(`✓ [${uploadedFiles}] ${file} (${sizeMB} MB)`);
}
} catch (err) {
console.log(`❌ 失败: ${file} - ${err.message}`);
}
}
}
async function deploy() {
const startTime = Date.now();
try {
// 1. 连接服务器
console.log('🔗 连接服务器...');
await sftp.connect(config);
console.log('✓ 连接成功!\n');
// 2. 检查并清理旧文件
console.log('🧹 清理旧文件...');
const exists = await sftp.exists(remoteRoot);
if (exists) {
await removeDirectory(remoteRoot);
console.log('✓ 旧文件已清理\n');
} else {
console.log('✓ 无需清理(首次部署)\n');
}
// 3. 创建根目录
console.log('📁 创建目标目录...');
await sftp.mkdir(remoteRoot, true);
console.log('✓ 目录创建完成\n');
// 4. 上传所有文件
console.log('📤 上传文件...\n');
await uploadDirectory(localRoot, remoteRoot);
// 5. 验证部署
console.log('\n🔍 验证部署...');
const fileList = await sftp.list(remoteRoot);
console.log(`✓ 部署成功!共 ${fileList.length} 个文件/目录`);
const totalSizeMB = (uploadedSize / 1024 / 1024).toFixed(2);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n📊 统计信息:`);
console.log(` - 上传文件数: ${uploadedFiles}`);
console.log(` - 总大小: ${totalSizeMB} MB`);
console.log(` - 耗时: ${duration}`);
console.log(` - 平均速度: ${(uploadedSize / 1024 / 1024 / duration * 1000).toFixed(2)} MB/s`);
console.log(`\n✅ 部署完成!`);
console.log(`🌐 访问地址: http://${config.host}/sports/\n`);
await sftp.end();
process.exit(0);
} catch (err) {
console.error('\n❌ 部署失败:', err.message);
console.error('详细信息:', err);
try {
await sftp.end();
} catch (e) {}
process.exit(1);
}
}
deploy();

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>newtoy</title>
<!-- 预先定义 Spotify SDK 回调,防止 SDK 加载时报错 -->
<script>
window.onSpotifyWebPlaybackSDKReady = function() {
console.log('Spotify Web Playback SDK 已加载');
};
</script>
<!-- Spotify Web Playback SDK -->
<script src="https://sdk.scdn.co/spotify-player.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

49
nginx.conf.example Normal file
View File

@@ -0,0 +1,49 @@
# Nginx 配置示例 - 解决 SPA 路由刷新 404 问题
# 复制此配置到你的 Nginx 服务器配置中
server {
listen 80;
server_name sports.rucky.cn;
# HTTPS 重定向
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name sports.rucky.cn;
# SSL 证书配置
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private.key;
# 网站根目录
root /var/www/sports.rucky.cn;
index index.html;
# 关键配置SPA 路由支持
# 所有请求都先尝试文件,如果找不到则返回 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/javascript application/json;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

1685
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "newtoy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "npm run build && node deploy-ftp.js",
"upload": "node deploy-ftp.js"
},
"devDependencies": {
"@types/three": "^0.181.0",
"typescript": "~5.9.3",
"vite": "^7.2.2"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"three": "^0.181.1",
"vue": "^3.5.24",
"vue-router": "^4.4.5",
"ssh2-sftp-client": "^11.0.0",
"archiver": "^7.0.1"
}
}

37
public/.htaccess Normal file
View File

@@ -0,0 +1,37 @@
# Apache 配置 - 解决 SPA 路由刷新 404 问题
# 此文件会自动包含在构建的 dist 目录中
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# 如果请求的是实际存在的文件或目录,直接返回
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# 否则重定向到 index.html
RewriteRule . /index.html [L]
</IfModule>
# 启用 Gzip 压缩
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>
# 设置缓存
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 0 seconds"
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/x-javascript "access plus 1 year"
ExpiresByType application/font-woff "access plus 1 year"
ExpiresByType application/font-woff2 "access plus 1 year"
</IfModule>

3
public/_redirects Normal file
View File

@@ -0,0 +1,3 @@
# Netlify/Vercel 等平台的 SPA 路由配置
/* /index.html 200

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

5
src/App.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<router-view />
</template>

3
src/assets/overlay.png Normal file
View File

@@ -0,0 +1,3 @@
placeholder

9
src/counter.ts Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

19
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'three/examples/jsm/postprocessing/EffectComposer.js'
declare module 'three/examples/jsm/postprocessing/RenderPass.js'
declare module 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
// Spotify Web Playback SDK 全局类型声明
interface Window {
onSpotifyWebPlaybackSDKReady?: () => void
Spotify?: {
Player: any
}
}

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import './style.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')

66
src/router.ts Normal file
View File

@@ -0,0 +1,66 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from './views/Home.vue'
import Color from './views/Color.vue'
import AR from './views/AR.vue'
import ARHit from './views/ARHit.vue'
import Camera from './views/Camera.vue'
import HandTrack from './views/HandTrack.vue'
import PushCoin from './views/PushCoin.vue'
import MusicRhythm from './views/MusicRhythm.vue'
import SpotifyCallback from './views/SpotifyCallback.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/color',
name: 'Color',
component: Color,
},
{
path: '/ar',
name: 'AR',
component: AR,
},
{
path: '/ar-hit',
name: 'ARHit',
component: ARHit,
},
{
path: '/camera',
name: 'Camera',
component: Camera,
},
{
path: '/handtrack',
name: 'HandTrack',
component: HandTrack,
},
{
path: '/pushcoin',
name: 'PushCoin',
component: PushCoin,
},
{
path: '/music-rhythm',
name: 'MusicRhythm',
component: MusicRhythm,
},
{
path: '/callback',
name: 'SpotifyCallback',
component: SpotifyCallback,
},
],
})
export default router

View File

@@ -0,0 +1,636 @@
// Spotify Web API 服务
// 使用 Authorization Code Flow with PKCE推荐用于前端应用
//
// 官方文档:
// - PKCE 流程: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
// - Web API 参考: https://developer.spotify.com/documentation/web-api
// - Web Playback SDK: https://developer.spotify.com/documentation/web-playback-sdk
//
// PKCE 优势:
// 1. 不需要 Client Secret前端无法安全存储
// 2. 防止授权码拦截攻击
// 3. Spotify 官方推荐用于单页应用、移动应用等
// 4. 符合 OAuth 2.0 最佳实践
// Spotify Web Playback SDK 类型声明
interface SpotifyPlayer {
connect(): Promise<boolean>
disconnect(): void
addListener(event: string, callback: (data: any) => void): void
removeListener(event: string, callback?: (data: any) => void): void
getCurrentState(): Promise<any>
setName(name: string): Promise<void>
getVolume(): Promise<number>
setVolume(volume: number): Promise<void>
pause(): Promise<void>
resume(): Promise<void>
togglePlay(): Promise<void>
seek(position_ms: number): Promise<void>
previousTrack(): Promise<void>
nextTrack(): Promise<void>
_options: {
id: string
}
}
export interface SpotifyConfig {
clientId: string
redirectUri: string
}
export interface SpotifyTrack {
id: string
name: string
artists: Array<{ name: string }>
album: {
name: string
images: Array<{ url: string }>
}
duration_ms: number
uri: string
tempo?: number // BPM from audio features
}
export interface SpotifyPlaylist {
id: string
name: string
images: Array<{ url: string }>
tracks: {
total: number
}
}
class SpotifyService {
private config: SpotifyConfig | null = null
private accessToken: string | null = null
private tokenExpiresAt: number = 0
private player: SpotifyPlayer | null = null
private deviceId: string | null = null
private playerReady: boolean = false
// 初始化配置
initialize(config: SpotifyConfig) {
this.config = config
// 从 localStorage 恢复 token
this.loadTokenFromStorage()
}
// 初始化 Web Playback SDK
async initializeWebPlayer(onPlayerReady?: (deviceId: string) => void): Promise<string | null> {
if (!this.isAuthenticated()) {
console.error('未授权,无法初始化 Web Player')
return null
}
if (this.player && this.playerReady) {
console.log('Web Player 已经初始化')
return this.deviceId
}
return new Promise((resolve) => {
const initPlayer = () => {
this.setupPlayer(onPlayerReady, resolve)
}
// 检查 Spotify SDK 是否已加载
if (window.Spotify) {
// SDK 已加载,直接初始化
initPlayer()
} else {
// SDK 未加载,等待加载完成
window.onSpotifyWebPlaybackSDKReady = initPlayer
}
})
}
private setupPlayer(
onPlayerReady?: (deviceId: string) => void,
resolve?: (deviceId: string | null) => void
) {
console.log('正在初始化 Spotify Web Player...')
if (!window.Spotify) {
console.error('Spotify SDK 未加载')
if (resolve) resolve(null)
return
}
this.player = new window.Spotify.Player({
name: '音乐律动 Web Player',
getOAuthToken: (cb: (token: string) => void) => {
cb(this.accessToken!)
},
volume: 0.5,
})
// 错误处理
this.player!.addListener('initialization_error', ({ message }: any) => {
console.error('初始化错误:', message)
})
this.player!.addListener('authentication_error', ({ message }: any) => {
console.error('认证错误:', message)
this.logout()
})
this.player!.addListener('account_error', ({ message }: any) => {
console.error('账号错误:', message)
alert('需要 Spotify Premium 账号才能使用 Web Player')
})
this.player!.addListener('playback_error', ({ message }: any) => {
console.error('播放错误:', message)
})
// 播放器准备就绪
this.player!.addListener('ready', ({ device_id }: any) => {
console.log('✅ Web Player 已准备就绪设备ID:', device_id)
this.deviceId = device_id
this.playerReady = true
if (onPlayerReady) {
onPlayerReady(device_id)
}
if (resolve) {
resolve(device_id)
}
})
// 播放器断开连接
this.player!.addListener('not_ready', ({ device_id }: any) => {
console.log('Web Player 已断开连接:', device_id)
this.playerReady = false
})
// 播放状态变化
this.player!.addListener('player_state_changed', (state: any) => {
if (!state) return
console.log('播放状态变化:', state)
})
// 连接播放器
this.player!.connect().then((success: boolean) => {
if (success) {
console.log('✅ Web Player 连接成功')
} else {
console.error('❌ Web Player 连接失败')
if (resolve) {
resolve(null)
}
}
})
}
// 获取 Web Player 设备 ID
getWebPlayerDeviceId(): string | null {
return this.deviceId
}
// 检查 Web Player 是否就绪
isWebPlayerReady(): boolean {
return this.playerReady && !!this.deviceId
}
// 断开 Web Player
disconnectWebPlayer() {
if (this.player) {
this.player.disconnect()
this.player = null
this.deviceId = null
this.playerReady = false
console.log('Web Player 已断开')
}
}
// 检查是否已授权
isAuthenticated(): boolean {
return !!this.accessToken && Date.now() < this.tokenExpiresAt
}
// 生成随机字符串(符合 PKCE 标准43-128 字符)
// 参考https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
private generateRandomString(length: number): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const values = crypto.getRandomValues(new Uint8Array(length))
return values.reduce((acc, x) => acc + possible[x % possible.length], '')
}
// SHA256 哈希
private async sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return crypto.subtle.digest('SHA-256', data)
}
// Base64 URL 编码
private base64encode(input: ArrayBuffer): string {
const str = String.fromCharCode(...new Uint8Array(input))
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// 生成授权 URL (使用 PKCE)
// 参考https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
async getAuthorizationUrl(): Promise<string> {
if (!this.config) {
throw new Error('Spotify 服务未初始化')
}
const scopes = [
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
'streaming',
'user-read-email',
'user-read-private',
'playlist-read-private',
'playlist-read-collaborative',
'user-library-read',
]
// 步骤 1生成 code_verifier (64 字符随机字符串)
const codeVerifier = this.generateRandomString(64)
// 步骤 2生成 code_challenge (SHA256 哈希后 Base64 URL 编码)
const hashed = await this.sha256(codeVerifier)
const codeChallenge = this.base64encode(hashed)
// 步骤 3生成 state 参数用于 CSRF 保护(官方强烈推荐)
const state = this.generateRandomString(16)
// 保存到 localStorage 供后续验证使用
localStorage.setItem('spotify_code_verifier', codeVerifier)
localStorage.setItem('spotify_auth_state', state)
// 步骤 4构建授权 URL
const params = new URLSearchParams({
client_id: this.config.clientId,
response_type: 'code',
redirect_uri: this.config.redirectUri,
scope: scopes.join(' '),
code_challenge_method: 'S256',
code_challenge: codeChallenge,
state: state, // 添加 state 参数
})
return `https://accounts.spotify.com/authorize?${params.toString()}`
}
// 交换授权码获取 access token
// 参考https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
private async exchangeCodeForToken(code: string): Promise<void> {
if (!this.config) {
throw new Error('Spotify 服务未初始化')
}
// 从 localStorage 获取之前保存的 code_verifier
const codeVerifier = localStorage.getItem('spotify_code_verifier')
if (!codeVerifier) {
throw new Error('Code verifier not found')
}
// 构建请求参数
const params = new URLSearchParams({
client_id: this.config.clientId,
grant_type: 'authorization_code',
code: code,
redirect_uri: this.config.redirectUri,
code_verifier: codeVerifier, // PKCE: 使用 code_verifier 而不是 client_secret
})
// 发送 POST 请求到 token 端点
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || 'Failed to exchange code for token')
}
// 解析响应并保存 token
const data = await response.json()
this.accessToken = data.access_token
this.tokenExpiresAt = Date.now() + data.expires_in * 1000
this.saveTokenToStorage()
// 清理已使用的 code_verifier
localStorage.removeItem('spotify_code_verifier')
}
// 处理回调
// 参考https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
async handleCallback(): Promise<boolean> {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const error = params.get('error')
// 检查是否有错误
if (error) {
console.error('Spotify authorization error:', error)
return false
}
// 验证 state 参数CSRF 保护)
const storedState = localStorage.getItem('spotify_auth_state')
if (state !== storedState) {
console.error('State mismatch: possible CSRF attack')
return false
}
if (code) {
try {
// 交换授权码获取 access token
await this.exchangeCodeForToken(code)
// 清理存储的 state保留 hash 路由)
// 在 hash 模式下,不修改 URL让路由组件自己处理导航
localStorage.removeItem('spotify_auth_state')
return true
} catch (error) {
console.error('Failed to exchange code for token:', error)
return false
}
}
return false
}
// 登出
logout() {
this.accessToken = null
this.tokenExpiresAt = 0
localStorage.removeItem('spotify_access_token')
localStorage.removeItem('spotify_token_expires_at')
}
// 保存 token 到 localStorage
private saveTokenToStorage() {
if (this.accessToken) {
localStorage.setItem('spotify_access_token', this.accessToken)
localStorage.setItem('spotify_token_expires_at', this.tokenExpiresAt.toString())
}
}
// 从 localStorage 加载 token
private loadTokenFromStorage() {
const token = localStorage.getItem('spotify_access_token')
const expiresAt = localStorage.getItem('spotify_token_expires_at')
if (token && expiresAt) {
const expires = parseInt(expiresAt)
if (Date.now() < expires) {
this.accessToken = token
this.tokenExpiresAt = expires
}
}
}
// API 请求封装
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
if (!this.isAuthenticated()) {
throw new Error('未授权,请先登录 Spotify')
}
const response = await fetch(`https://api.spotify.com/v1${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
if (response.status === 401) {
this.logout()
throw new Error('授权已过期,请重新登录')
}
const error = await response.json()
throw new Error(error.error?.message || '请求失败')
}
return response.json()
}
// 搜索歌曲
async searchTracks(query: string, limit: number = 20): Promise<SpotifyTrack[]> {
const params = new URLSearchParams({
q: query,
type: 'track',
limit: limit.toString(),
})
const data = await this.request<any>(`/search?${params.toString()}`)
return data.tracks.items
}
// 根据 BPM 搜索歌曲(使用关键词搜索)
async searchByBPM(bpm: number, limit: number = 20): Promise<SpotifyTrack[]> {
// 使用关键词搜索,搜索带有 BPM 标记的歌曲
// 例如:搜索 "80BPM"、"80 BPM"、"running 80"等
const searchQueries = [
`${bpm}BPM`,
`${bpm} BPM`,
`running ${bpm}`,
`workout ${bpm} bpm`,
]
// 尝试多个搜索关键词,收集结果
const allTracks: SpotifyTrack[] = []
const trackIds = new Set<string>() // 去重
for (const query of searchQueries) {
try {
const tracks = await this.searchTracks(query, 10)
for (const track of tracks) {
if (!trackIds.has(track.id)) {
trackIds.add(track.id)
allTracks.push(track)
}
}
// 如果已经收集到足够的歌曲,就停止搜索
if (allTracks.length >= limit) {
break
}
} catch (error) {
console.error(`Search with query "${query}" failed:`, error)
}
}
// 如果关键词搜索结果太少,补充使用推荐 API
if (allTracks.length < limit / 2) {
try {
const bpmRange = 10
const minBpm = bpm - bpmRange
const maxBpm = bpm + bpmRange
const params = new URLSearchParams({
seed_genres: 'pop,rock,electronic,dance',
target_tempo: bpm.toString(),
min_tempo: minBpm.toString(),
max_tempo: maxBpm.toString(),
limit: (limit - allTracks.length).toString(),
})
const data = await this.request<any>(`/recommendations?${params.toString()}`)
if (data.tracks && data.tracks.length > 0) {
for (const track of data.tracks) {
if (!trackIds.has(track.id)) {
trackIds.add(track.id)
allTracks.push(track)
}
}
}
} catch (error) {
console.error('Recommendations API failed:', error)
}
}
// 获取音频特征BPM
// Spotify API 限制:一次最多 100 个 ID
if (allTracks.length > 0) {
try {
const batchSize = 50 // 使用较小的批次大小,避免 URL 过长
const tracksWithTempo: SpotifyTrack[] = []
for (let i = 0; i < allTracks.length; i += batchSize) {
const batch = allTracks.slice(i, i + batchSize)
const ids = batch.map(t => t.id).join(',')
try {
const features = await this.getAudioFeatures(ids)
const batchWithTempo = batch.map((track, index) => ({
...track,
tempo: features[index]?.tempo || bpm,
}))
tracksWithTempo.push(...batchWithTempo)
} catch (batchError) {
console.error('Failed to get audio features for batch:', batchError)
// 如果获取特征失败,仍然返回歌曲但没有 tempo
tracksWithTempo.push(...batch)
}
}
return tracksWithTempo.slice(0, limit)
} catch (error) {
console.error('Failed to get audio features:', error)
return allTracks.slice(0, limit)
}
}
return []
}
// 获取音频特征(包含 BPM
async getAudioFeatures(trackIds: string): Promise<any[]> {
const data = await this.request<any>(`/audio-features?ids=${trackIds}`)
return data.audio_features || []
}
// 获取用户的播放列表
async getUserPlaylists(limit: number = 20): Promise<SpotifyPlaylist[]> {
const data = await this.request<any>(`/me/playlists?limit=${limit}`)
return data.items
}
// 获取播放列表的歌曲
async getPlaylistTracks(playlistId: string, limit: number = 50): Promise<SpotifyTrack[]> {
const data = await this.request<any>(`/playlists/${playlistId}/tracks?limit=${limit}`)
return data.items.map((item: any) => item.track)
}
// 获取当前播放状态
async getPlaybackState(): Promise<any> {
return this.request<any>('/me/player')
}
// 获取可用设备
async getDevices(): Promise<any[]> {
const data = await this.request<any>('/me/player/devices')
return data.devices || []
}
// 播放歌曲
async play(options: {
deviceId?: string
uris?: string[]
contextUri?: string
offset?: number
} = {}): Promise<void> {
const { deviceId, uris, contextUri, offset } = options
const body: any = {}
if (uris) body.uris = uris
if (contextUri) body.context_uri = contextUri
if (offset !== undefined) body.offset = { position: offset }
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play'
await this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(body),
})
}
// 暂停播放
async pause(deviceId?: string): Promise<void> {
const endpoint = deviceId ? `/me/player/pause?device_id=${deviceId}` : '/me/player/pause'
await this.request(endpoint, { method: 'PUT' })
}
// 下一首
async next(deviceId?: string): Promise<void> {
const endpoint = deviceId ? `/me/player/next?device_id=${deviceId}` : '/me/player/next'
await this.request(endpoint, { method: 'POST' })
}
// 上一首
async previous(deviceId?: string): Promise<void> {
const endpoint = deviceId ? `/me/player/previous?device_id=${deviceId}` : '/me/player/previous'
await this.request(endpoint, { method: 'POST' })
}
// 设置音量
async setVolume(volume: number, deviceId?: string): Promise<void> {
const endpoint = deviceId
? `/me/player/volume?volume_percent=${volume}&device_id=${deviceId}`
: `/me/player/volume?volume_percent=${volume}`
await this.request(endpoint, { method: 'PUT' })
}
// 跳转到指定位置(毫秒)
async seek(positionMs: number, deviceId?: string): Promise<void> {
const endpoint = deviceId
? `/me/player/seek?position_ms=${positionMs}&device_id=${deviceId}`
: `/me/player/seek?position_ms=${positionMs}`
await this.request(endpoint, { method: 'PUT' })
}
// 添加到队列
async addToQueue(uri: string, deviceId?: string): Promise<void> {
const endpoint = deviceId
? `/me/player/queue?uri=${uri}&device_id=${deviceId}`
: `/me/player/queue?uri=${uri}`
await this.request(endpoint, { method: 'POST' })
}
// 获取用户信息
async getUserProfile(): Promise<any> {
return this.request<any>('/me')
}
}
export const spotifyService = new SpotifyService()

567
src/style.css Normal file
View File

@@ -0,0 +1,567 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: radial-gradient(circle at top, #141a3b 0, #020313 45%, #000000 100%);
color: #ffffff;
}
#app {
width: 100%;
}
.page {
position: relative;
min-height: 100vh;
}
/* WebGL 背景固定铺满 */
#webgl-container {
position: fixed;
inset: 0;
z-index: -1;
}
#webgl-container canvas {
display: block;
width: 100%;
height: 100%;
}
/* 顶部导航 */
/* 顶部品牌栏(简化版) */
.site-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 2.6rem;
pointer-events: none;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.6rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.86rem;
pointer-events: auto;
}
.brand-mark {
width: 20px;
height: 20px;
border-radius: 999px;
background: radial-gradient(circle at 30% 20%, #ffe48a, #ff6f61, #6940ff);
box-shadow:
0 0 30px rgba(255, 228, 138, 0.9),
0 0 60px rgba(105, 64, 255, 0.7);
}
.brand-text {
opacity: 0.92;
}
.social {
display: flex;
align-items: center;
gap: 0.6rem;
pointer-events: auto;
}
.social-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: rgba(203, 210, 255, 0.7);
box-shadow: 0 0 14px rgba(203, 210, 255, 0.9);
}
.social-dot:hover {
background: #ffffff;
}
/* Home 前景(参考 webglToy 首页) */
.home {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 5vh 2rem 6vh;
box-sizing: border-box;
}
.home-background {
position: absolute;
inset: 0;
opacity: 0.24;
mix-blend-mode: screen;
pointer-events: none;
}
.home-background svg {
width: 100%;
height: 100%;
display: block;
}
.home-shadow {
position: absolute;
top: 50%;
left: 50%;
width: 260px;
height: 260px;
margin-left: -130px;
margin-top: -180px;
border-radius: 50%;
filter: blur(20px);
box-shadow:
20px 20px 60px #03a9f4,
-20px -20px 60px #f441a5,
20px -20px 60px #ffeb3b,
-20px 20px 60px #03a9f4,
-50px -50px 100px #d3c1f8 inset,
50px 50px 100px #f441a5 inset,
-50px 50px 100px #ffeb3b inset,
50px -50px 100px #c2c6fd inset;
animation: homeShadowRotate 5s linear infinite;
}
@keyframes homeShadowRotate {
0% {
opacity: 1;
transform: rotate(0deg);
}
50% {
opacity: 0.6;
transform: rotate(180deg);
}
100% {
opacity: 1;
transform: rotate(360deg);
}
}
.home-dialog {
position: relative;
z-index: 2;
max-width: 840px;
width: 100%;
}
.home-header {
text-align: center;
margin-bottom: 3rem;
}
.home-header h1,
.home-header p {
margin: 0.3rem 0;
font-weight: 700;
mix-blend-mode: screen;
color: #ffffff;
}
.home-header h1 {
font-size: 2.2rem;
}
.home-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.toy-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
.toy-card {
width: 180px;
height: 190px;
border-radius: 24px;
border: none;
background-image: linear-gradient(
145deg,
#f6f6f6 0%,
#cfcfcf 49.99%,
#cfcfcf 50%,
#f6f6f6 100%
);
background-position: 0 0;
background-size: 100% 200%;
box-shadow:
20px 20px 60px #c4c4c4,
-20px -20px 60px #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
cursor: pointer;
transition:
background-position 0.25s ease-in-out,
transform 0.25s ease-in-out;
}
.toy-icon {
font-size: 2.2rem;
}
.toy-label {
font-size: 0.9rem;
line-height: 1.4;
text-align: center;
padding: 0 0.5rem;
}
.toy-card:hover {
background-position: 0 -100%;
transform: translateY(-4px) scale(1.05);
}
@media (max-width: 900px) {
.site-header {
padding-inline: 1.4rem;
}
.home {
padding-inline: 1.2rem;
}
.home-header h1 {
font-size: 1.8rem;
}
.toy-card {
width: 46%;
max-width: 180px;
}
}
/* Color 实验页 */
.color-page {
position: relative;
min-height: 100vh;
padding: 5rem 2rem 3rem;
box-sizing: border-box;
}
.color-header {
max-width: 640px;
margin: 0 auto 2rem;
text-align: center;
}
.color-header h1 {
margin: 0 0 0.6rem;
font-size: 2rem;
}
.color-header p {
margin: 0;
font-size: 0.95rem;
color: rgba(211, 222, 255, 0.9);
}
.color-canvas {
max-width: 960px;
height: 70vh;
margin: 0 auto;
border-radius: 24px;
overflow: hidden;
backdrop-filter: blur(14px);
background: radial-gradient(circle at top, rgba(10, 14, 40, 0.95), rgba(2, 3, 19, 0.9));
box-shadow:
0 26px 80px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(158, 184, 255, 0.3);
}
.ar-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: rgba(211, 222, 255, 0.9);
font-size: 0.95rem;
}
.camera-page {
position: relative;
min-height: 100vh;
padding: 5rem 2rem 3rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.camera-wrapper {
position: relative;
width: min(650px, 90vw);
aspect-ratio: 13 / 18;
border-radius: 24px;
overflow: hidden;
background: #000;
box-shadow:
0 26px 80px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(158, 184, 255, 0.3);
}
.camera-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-frame {
position: absolute;
inset: -12%;
pointer-events: none;
background-position: center;
background-size: cover;
}
.camera-controls {
margin-top: 2rem;
display: flex;
gap: 1.5rem;
}
.camera-btn {
border-radius: 999px;
border: none;
padding: 0.75rem 1.9rem;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
font-family: inherit;
background: #ffffff;
color: #111322;
cursor: pointer;
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(200, 210, 255, 0.4);
}
.camera-btn--secondary {
background: transparent;
color: rgba(211, 222, 255, 0.9);
border: 1px solid rgba(158, 184, 255, 0.5);
}
.camera-photo {
margin-top: 2.5rem;
width: min(650px, 90vw);
aspect-ratio: 13 / 18;
border-radius: 24px;
object-fit: cover;
box-shadow:
0 26px 80px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(158, 184, 255, 0.3);
}
@media (max-width: 768px) {
.camera-page {
padding-inline: 1.3rem;
}
.camera-controls {
flex-direction: column;
width: 100%;
align-items: stretch;
}
.camera-btn {
width: 100%;
}
}
/* HandTrack 页面共用 camera 布局 */
.handtrack-page {
position: relative;
min-height: 100vh;
padding: 5rem 2rem 3rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.handtrack-wrapper {
position: relative;
width: min(720px, 90vw);
aspect-ratio: 4 / 3;
border-radius: 24px;
overflow: hidden;
background: #000;
box-shadow:
0 26px 80px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(158, 184, 255, 0.3);
}
.handtrack-video,
.handtrack-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.handtrack-video {
object-fit: cover;
}
.handtrack-canvas {
pointer-events: none;
}
@media (max-width: 768px) {
.handtrack-page {
padding-inline: 1.3rem;
}
}
/* PushCoin 简易 2D Demo */
.pushcoin-page {
position: relative;
min-height: 100vh;
padding: 5rem 2rem 3rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.pushcoin-wrapper {
position: relative;
width: min(720px, 90vw);
aspect-ratio: 16 / 9;
border-radius: 24px;
overflow: hidden;
background: radial-gradient(circle at top, #2a345c, #050814 60%, #000);
box-shadow:
0 26px 80px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(158, 184, 255, 0.3);
}
.pushcoin-platform {
position: absolute;
left: 50%;
bottom: 10%;
width: 70%;
height: 18%;
transform: translateX(-50%);
border-radius: 40px;
background: linear-gradient(180deg, #f5e4b2, #d9a85b);
box-shadow:
0 -6px 12px rgba(0, 0, 0, 0.6) inset,
0 10px 24px rgba(0, 0, 0, 0.8);
}
.pushcoin-pusher {
position: absolute;
left: 50%;
bottom: 26%;
width: 40%;
height: 10%;
transform: translateX(-50%);
border-radius: 999px;
background: linear-gradient(180deg, #dde7ff, #7c91d6);
box-shadow:
0 6px 12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(193, 208, 255, 0.6);
}
.pushcoin-coin {
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
background: radial-gradient(circle at 30% 20%, #fff5c9, #f2c24e 60%, #c58522);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 243, 200, 0.7);
}
.pushcoin-controls {
margin-top: 2rem;
display: flex;
gap: 1.5rem;
}
.pushcoin-score {
margin-top: 1.4rem;
font-size: 0.95rem;
color: rgba(211, 222, 255, 0.92);
}
@media (max-width: 768px) {
.pushcoin-page {
padding-inline: 1.3rem;
}
}
@media (max-width: 768px) {
.color-page {
padding-inline: 1.3rem;
}
.color-canvas {
height: 60vh;
}
}
@media (max-width: 600px) {
.home-shadow {
width: 210px;
height: 210px;
margin-left: -105px;
}
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

336
src/threeScene.ts Normal file
View File

@@ -0,0 +1,336 @@
import * as THREE from 'three'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
export function initThreeScene(container: HTMLDivElement) {
// 基础渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(container.clientWidth, container.clientHeight)
renderer.setClearColor(0x020311, 1)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.3
container.appendChild(renderer.domElement)
// 场景 & 相机
const scene = new THREE.Scene()
scene.fog = new THREE.Fog(0x020311, 50, 200)
const camera = new THREE.PerspectiveCamera(
55,
container.clientWidth / container.clientHeight,
0.1,
1000,
)
camera.position.set(0, 10, 40)
// 全局灯光
const ambient = new THREE.AmbientLight(0x6677ff, 0.45)
scene.add(ambient)
const dirLight = new THREE.DirectionalLight(0xfff3e0, 2.4)
dirLight.position.set(26, 40, 18)
dirLight.castShadow = false
scene.add(dirLight)
// 星野背景
const starGeometry = new THREE.BufferGeometry()
const starCount = 2600
const starPositions = new Float32Array(starCount * 3)
for (let i = 0; i < starCount * 3; i += 3) {
const radius = 200 * (0.6 + Math.random() * 0.4)
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
starPositions[i] = radius * Math.sin(phi) * Math.cos(theta)
starPositions[i + 1] = radius * Math.sin(phi) * Math.sin(theta)
starPositions[i + 2] = radius * Math.cos(phi)
}
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3))
const starMaterial = new THREE.PointsMaterial({
color: 0x9fbfff,
size: 0.55,
transparent: true,
opacity: 0.9,
sizeAttenuation: true,
})
const stars = new THREE.Points(starGeometry, starMaterial)
scene.add(stars)
// 大气星球
const planetGroup = new THREE.Group()
scene.add(planetGroup)
const planetGeo = new THREE.SphereGeometry(13, 64, 64)
const planetMat = new THREE.MeshPhongMaterial({
color: 0x10244d,
emissive: 0x081020,
shininess: 65,
specular: 0x99bbff,
})
const planet = new THREE.Mesh(planetGeo, planetMat)
planet.rotation.z = Math.PI * 0.1
planetGroup.add(planet)
// 大气光晕
const atmosphereGeo = new THREE.SphereGeometry(13.8, 64, 64)
const atmosphereMat = new THREE.MeshBasicMaterial({
color: 0x96e4ff,
transparent: true,
opacity: 0.3,
side: THREE.BackSide,
})
const atmosphere = new THREE.Mesh(atmosphereGeo, atmosphereMat)
planetGroup.add(atmosphere)
// 漂浮云层
const cloudGeo = new THREE.SphereGeometry(13.3, 64, 64)
const cloudMat = new THREE.MeshPhongMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.18,
})
const clouds = new THREE.Mesh(cloudGeo, cloudMat)
planetGroup.add(clouds)
// 热点对象(未来可映射到不同特效页面)
type HotspotMeta = {
id: string
label: string
}
const interactiveObjects: THREE.Object3D<THREE.Object3DEventMap>[] = []
const hotspotMeta = new Map<THREE.Object3D, HotspotMeta>()
function createHotCube(position: THREE.Vector3, id: string, label: string) {
const geo = new THREE.BoxGeometry(2.4, 2.4, 2.4)
const mat = new THREE.MeshStandardMaterial({
color: 0xffc56b,
emissive: 0x7f4a11,
metalness: 0.8,
roughness: 0.18,
})
const mesh = new THREE.Mesh(geo, mat)
mesh.position.copy(position)
mesh.userData.type = 'hotspot'
interactiveObjects.push(mesh)
hotspotMeta.set(mesh, { id, label })
scene.add(mesh)
return mesh
}
function createHotSphere(position: THREE.Vector3, id: string, label: string) {
const geo = new THREE.SphereGeometry(1.9, 40, 40)
const mat = new THREE.MeshStandardMaterial({
color: 0x66f0ff,
emissive: 0x0480aa,
metalness: 0.65,
roughness: 0.1,
})
const mesh = new THREE.Mesh(geo, mat)
mesh.position.copy(position)
mesh.userData.type = 'hotspot'
interactiveObjects.push(mesh)
hotspotMeta.set(mesh, { id, label })
scene.add(mesh)
return mesh
}
const hotCube = createHotCube(new THREE.Vector3(19, 5, 0), 'effect-1', '粒子星河特效(占位)')
const hotSphere = createHotSphere(
new THREE.Vector3(-17, -1, 6),
'effect-2',
'能量波纹特效(占位)',
)
// 细节粒子环(增强空间感)
const ringGeo = new THREE.TorusGeometry(15.5, 0.16, 40, 420)
const ringMat = new THREE.MeshBasicMaterial({
color: 0x5a7bff,
transparent: true,
opacity: 0.72,
})
const ring = new THREE.Mesh(ringGeo, ringMat)
ring.rotation.x = Math.PI / 2.4
scene.add(ring)
// 能量流线(沿着环绕星球的弧线粒子)
const trailGroup = new THREE.Group()
scene.add(trailGroup)
function createTrail(radius: number, phase: number, color: number) {
const curvePoints: THREE.Vector3[] = []
const segments = 80
for (let i = 0; i <= segments; i++) {
const t = (i / segments) * Math.PI * 2 * 0.55 + phase
const y = Math.sin(t * 1.8) * 2.5
curvePoints.push(new THREE.Vector3(Math.cos(t) * radius, y, Math.sin(t) * radius * 0.8))
}
const curve = new THREE.CatmullRomCurve3(curvePoints)
const geometry = new THREE.TubeGeometry(curve, 220, 0.09, 16, false)
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.0,
})
const mesh = new THREE.Mesh(geometry, material)
trailGroup.add(mesh)
return mesh
}
const trail1 = createTrail(14.8, 0, 0x7af2ff)
const trail2 = createTrail(13.6, Math.PI * 0.65, 0xffe38a)
// 后期特效Bloom
const composer = new EffectComposer(renderer)
const renderPass = new RenderPass(scene, camera)
composer.addPass(renderPass)
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(container.clientWidth, container.clientHeight),
1.2,
0.8,
0.3,
)
bloomPass.threshold = 0.12
bloomPass.strength = 1.8
bloomPass.radius = 0.85
composer.addPass(bloomPass)
// 鼠标交互
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
let hovered: THREE.Object3D | null = null
// 简单相机视差 & 自动漂移
let targetCameraOffsetX = 0
let targetCameraOffsetY = 0
function onPointerMove(event: PointerEvent) {
const bounds = container.getBoundingClientRect()
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1
const nx = (event.clientX - bounds.left) / bounds.width - 0.5
const ny = (event.clientY - bounds.top) / bounds.height - 0.5
targetCameraOffsetX = nx * 8
targetCameraOffsetY = -ny * 4
}
function onClick() {
if (!hovered) return
const meta = hotspotMeta.get(hovered)
if (!meta) return
alert(`点击了热点:${meta.label}ID: ${meta.id}\n后续这里会跳转到对应特效页面。`)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('click', onClick)
// 动画循环
const clock = new THREE.Clock()
function animate() {
const id = requestAnimationFrame(animate)
// 保留引用避免 TS 未使用报错
void id
const elapsed = clock.getElapsedTime()
const scrollMax = Math.max(
1,
document.documentElement.scrollHeight - window.innerHeight,
)
const scrollProgress = window.scrollY / scrollMax
// 行星与气氛旋转
planet.rotation.y += 0.0009
clouds.rotation.y += 0.0016
atmosphere.rotation.y += 0.0007
planetGroup.rotation.y = scrollProgress * Math.PI * 0.8
// 星野缓慢旋转
stars.rotation.y += 0.00035
// 热点呼吸动效
const pulse = 1 + Math.sin(elapsed * 2.5) * 0.1
hotCube.scale.setScalar(pulse)
hotSphere.scale.setScalar(1 + Math.cos(elapsed * 2.2) * 0.08)
// 热点轻微漂浮
hotCube.position.y = 5 + Math.sin(elapsed * 1.5) * 0.9
hotSphere.position.y = -1 + Math.cos(elapsed * 1.2) * 0.7
ring.rotation.z += 0.0009
// 能量流线闪烁
const flicker = 0.45 + 0.25 * Math.sin(elapsed * 3.2)
const flicker2 = 0.35 + 0.3 * Math.cos(elapsed * 2.4)
;(trail1.material as THREE.MeshBasicMaterial).opacity = flicker
;(trail2.material as THREE.MeshBasicMaterial).opacity = flicker2
// 相机微动 + 视差
const autoOrbit = elapsed * 0.06
const baseX = Math.sin(autoOrbit) * 12
const baseZ = 40 + Math.cos(autoOrbit) * 6
const lerpFactor = 0.06
const scrollYOffset = scrollProgress * 8
camera.position.x += (baseX + targetCameraOffsetX - camera.position.x) * lerpFactor
camera.position.y +=
(10 + scrollYOffset + targetCameraOffsetY - camera.position.y) * lerpFactor
camera.position.z += (baseZ - camera.position.z) * lerpFactor
camera.lookAt(0, 0, 0)
// 射线检测 Hover
raycaster.setFromCamera(pointer, camera)
const intersects = raycaster.intersectObjects(interactiveObjects)
if (intersects.length > 0) {
const first = intersects[0].object
if (hovered !== first) {
if (hovered && hovered instanceof THREE.Mesh) {
const mat = hovered.material as THREE.Material | THREE.Material[]
if (!Array.isArray(mat) && 'emissiveIntensity' in mat) {
;(mat as any).emissiveIntensity = 1
}
}
hovered = first
if (hovered instanceof THREE.Mesh) {
const mat = hovered.material as THREE.Material | THREE.Material[]
if (!Array.isArray(mat) && 'emissiveIntensity' in mat) {
;(mat as any).emissiveIntensity = 2
}
}
container.style.cursor = 'pointer'
}
} else {
if (hovered && hovered instanceof THREE.Mesh) {
const mat = hovered.material as THREE.Material | THREE.Material[]
if (!Array.isArray(mat) && 'emissiveIntensity' in mat) {
;(mat as any).emissiveIntensity = 1
}
}
hovered = null
container.style.cursor = 'default'
}
composer.render()
}
animate()
// 自适应窗口尺寸
function onResize() {
const w = container.clientWidth
const h = container.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
window.addEventListener('resize', onResize)
}

1
src/typescript.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

146
src/views/AR.vue Normal file
View File

@@ -0,0 +1,146 @@
<template>
<div class="color-page">
<div class="color-header">
<h1>AR 绘制 · 实验场</h1>
<p>在支持 WebXR 的浏览器中可进入 AR 模式点击屏幕在前方空间中生成彩色圆柱体</p>
</div>
<div class="color-canvas" ref="canvasRoot">
<div class="ar-hint">
<p>如果浏览器支持 WebXR请点击右下角 AR 按钮进入 AR 模式</p>
<p>进入 AR 轻触屏幕或点击 AR 控制器即可在前方空间生成彩色柱体</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as THREE from 'three'
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js'
const canvasRoot = ref<HTMLDivElement | null>(null)
const router = useRouter()
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controller: THREE.Group | null = null
onMounted(async () => {
if (!canvasRoot.value) return
const container = canvasRoot.value
// 简单环境检测:不支持 WebXR 的场景给出提示
const ua = navigator.userAgent || ''
const isWeChat = /MicroMessenger/i.test(ua)
const isXRSupported = 'xr' in navigator
if (!isXRSupported) {
alert('当前浏览器不支持 WebXR建议使用最新版 Chrome/EdgeHTTPS 环境)访问。')
} else if (isWeChat) {
alert('微信内置浏览器暂不支持 WebXR请在系统浏览器中打开体验 AR。')
}
const width = container.clientWidth
const height = container.clientHeight
// renderer
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
})
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(width, height)
renderer.xr.enabled = true
container.appendChild(renderer.domElement)
// AR Button
if (isXRSupported) {
const button = ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
})
button.style.position = 'absolute'
button.style.right = '16px'
button.style.bottom = '16px'
button.style.zIndex = '10'
container.appendChild(button)
}
// scene & camera
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 20)
const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1)
light.position.set(0, 1, 0)
scene.add(light)
// controller
controller = renderer.xr.getController(0)
controller.addEventListener('select', () => {
if (!scene || !controller) return
const material = new THREE.MeshPhongMaterial({
color: 0xffffff * Math.random(),
})
const geometry = new THREE.CylinderGeometry(0.05, 0.05, 0.12, 32).translate(0, 0.06, 0)
const mesh = new THREE.Mesh(geometry, material)
// 放在控制器前方一点点
mesh.position.set(0, 0, -0.5)
mesh.position.applyMatrix4(controller.matrixWorld)
mesh.quaternion.copy(controller.quaternion)
scene.add(mesh)
})
scene.add(controller)
const onResize = () => {
if (!renderer || !camera) return
const w = container.clientWidth
const h = container.clientHeight
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
window.addEventListener('resize', onResize)
const renderLoop = () => {
if (!renderer || !scene || !camera) return
renderer.setAnimationLoop(() => {
renderer?.render(scene as THREE.Scene, camera as THREE.PerspectiveCamera)
})
}
renderLoop()
onBeforeUnmount(() => {
if (renderer) {
renderer.setAnimationLoop(null)
}
window.removeEventListener('resize', onResize)
if (canvasRoot.value && renderer) {
canvasRoot.value.removeChild(renderer.domElement)
}
renderer?.dispose()
scene?.traverse((obj) => {
if ((obj as THREE.Mesh).geometry) {
;(obj as THREE.Mesh).geometry.dispose()
}
if ((obj as THREE.Mesh).material) {
const mat = (obj as THREE.Mesh).material
if (Array.isArray(mat)) {
mat.forEach((m) => m.dispose())
} else {
mat.dispose()
}
}
})
})
})
</script>

174
src/views/ARHit.vue Normal file
View File

@@ -0,0 +1,174 @@
<template>
<div class="color-page">
<div class="color-header">
<h1>AR 真实空间检测</h1>
<p>在支持 WebXR 的浏览器中利用 hit-test 检测真实平面并在平面上放置物体</p>
</div>
<div class="color-canvas" ref="canvasRoot">
<div class="ar-hint">
<p>进入 AR 模式后移动手机寻找平面出现环形指示器时轻触屏幕即可放置物体</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js'
const canvasRoot = ref<HTMLDivElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controller: THREE.Group | null = null
let reticle: THREE.Mesh | null = null
let hitTestSource: XRHitTestSource | null = null
let hitTestSourceRequested = false
onMounted(() => {
if (!canvasRoot.value) return
const container = canvasRoot.value
const width = container.clientWidth
const height = container.clientHeight
const isXRSupported = 'xr' in navigator
const ua = navigator.userAgent || ''
const isWeChat = /MicroMessenger/i.test(ua)
if (!isXRSupported) {
alert('当前浏览器不支持 WebXR建议使用最新版 Chrome/EdgeHTTPS 环境)访问。')
} else if (isWeChat) {
alert('微信内置浏览器暂不支持 WebXR请在系统浏览器中打开体验 AR。')
}
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(width, height)
renderer.xr.enabled = true
container.appendChild(renderer.domElement)
if (isXRSupported) {
const button = ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
})
button.style.position = 'absolute'
button.style.right = '16px'
button.style.bottom = '16px'
button.style.zIndex = '10'
container.appendChild(button)
}
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 5)
const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1)
light.position.set(0.5, 1, 0.25)
scene.add(light)
// 命中位置可视化:环形网格
reticle = new THREE.Mesh(
new THREE.RingGeometry(0.12, 0.18, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x51c4ff }),
)
reticle.matrixAutoUpdate = false
reticle.visible = false
scene.add(reticle)
// 控制器:点击时在当前 reticle 位置放置物体
controller = renderer.xr.getController(0)
controller.addEventListener('select', () => {
if (!scene || !reticle || !reticle.visible) return
const mat = new THREE.MeshPhongMaterial({
color: 0xffffff * Math.random(),
})
const geo = new THREE.CylinderGeometry(0.06, 0.06, 0.16, 32).translate(0, 0.08, 0)
const mesh = new THREE.Mesh(geo, mat)
mesh.position.setFromMatrixPosition(reticle.matrix)
mesh.scale.y = Math.random() * 1.5 + 0.8
scene.add(mesh)
})
scene.add(controller)
const onResize = () => {
if (!renderer || !camera) return
const w = container.clientWidth
const h = container.clientHeight
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
window.addEventListener('resize', onResize)
if (renderer && scene && camera) {
renderer.setAnimationLoop((timestamp: number, frame?: XRFrame) => {
if (frame) {
const referenceSpace = renderer!.xr.getReferenceSpace()
const session = renderer!.xr.getSession()
if (!hitTestSourceRequested) {
session
.requestReferenceSpace('viewer')
.then((viewerRef) =>
session.requestHitTestSource({ space: viewerRef }).then((source) => {
hitTestSource = source
}),
)
session.addEventListener('end', () => {
hitTestSourceRequested = false
hitTestSource = null
})
hitTestSourceRequested = true
}
if (hitTestSource && reticle) {
const hitTestResults = frame.getHitTestResults(hitTestSource)
if (hitTestResults.length > 0) {
const hit = hitTestResults[0]
const pose = hit.getPose(referenceSpace)
if (pose) {
reticle.visible = true
reticle.matrix.fromArray(pose.transform.matrix as unknown as number[])
}
} else {
reticle.visible = false
}
}
}
renderer!.render(scene as THREE.Scene, camera as THREE.PerspectiveCamera)
})
}
onBeforeUnmount(() => {
if (renderer) renderer.setAnimationLoop(null)
window.removeEventListener('resize', onResize)
if (canvasRoot.value && renderer) {
canvasRoot.value.removeChild(renderer.domElement)
}
renderer?.dispose()
scene?.traverse((obj) => {
if ((obj as THREE.Mesh).geometry) {
;(obj as THREE.Mesh).geometry.dispose()
}
if ((obj as THREE.Mesh).material) {
const mat = (obj as THREE.Mesh).material
if (Array.isArray(mat)) {
mat.forEach((m) => m.dispose())
} else {
mat.dispose()
}
}
})
})
})
</script>

113
src/views/Camera.vue Normal file
View File

@@ -0,0 +1,113 @@
<template>
<div class="camera-page">
<div class="color-header">
<h1>实时合成图片</h1>
<p>打开摄像头将画面与前景相框合成并导出为图片</p>
</div>
<div class="camera-wrapper">
<video
ref="videoRef"
class="camera-video"
autoplay
playsinline
muted
></video>
<div class="camera-frame" ref="frameRef"></div>
</div>
<div class="camera-controls">
<button class="camera-btn" @click="startCamera" :disabled="isCameraOn">
{{ isCameraOn ? '摄像头已开启' : '开启摄像头' }}
</button>
<button class="camera-btn camera-btn--secondary" @click="takePhoto" :disabled="!isCameraOn">
拍照合成
</button>
</div>
<img v-if="photoDataUrl" :src="photoDataUrl" alt="snapshot" class="camera-photo" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import overlayUrl from '../assets/overlay.png'
const videoRef = ref<HTMLVideoElement | null>(null)
const frameRef = ref<HTMLDivElement | null>(null)
const isCameraOn = ref(false)
const photoDataUrl = ref<string>('')
let stream: MediaStream | null = null
let canvas: HTMLCanvasElement | null = null
let ctx: CanvasRenderingContext2D | null = null
const initCanvas = () => {
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d')
if (!canvas || !ctx) return
const baseW = 650
const baseH = 900
canvas.width = baseW * 1.2
canvas.height = baseH * 1.2
}
const startCamera = async () => {
if (isCameraOn.value || !videoRef.value) return
try {
const constraints: MediaStreamConstraints = {
video: { facingMode: 'user' },
audio: false,
}
stream = await navigator.mediaDevices.getUserMedia(constraints)
videoRef.value.srcObject = stream
isCameraOn.value = true
} catch (err) {
console.error(err)
alert('无法打开摄像头,请检查浏览器权限设置。')
}
}
const takePhoto = async () => {
if (!videoRef.value || !canvas || !ctx) return
const baseW = 650
const baseH = 900
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 将视频画面绘制到画布中间偏上的区域
ctx.drawImage(
videoRef.value,
0,
0,
baseW,
baseH,
baseW * 0.14,
baseH * 0.1,
baseW,
baseH,
)
const img = new Image()
img.src = overlayUrl
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject()
})
ctx.drawImage(img, 0, 0, baseW * 1.2, baseH * 1.2)
photoDataUrl.value = canvas.toDataURL('image/jpeg')
}
onMounted(() => {
initCanvas()
if (frameRef.value) {
frameRef.value.style.backgroundImage = `url(${overlayUrl})`
}
})
onBeforeUnmount(() => {
if (stream) {
stream.getTracks().forEach((t) => t.stop())
}
})
</script>

124
src/views/Color.vue Normal file
View File

@@ -0,0 +1,124 @@
<template>
<div class="color-page">
<div class="color-header">
<h1>三维空间色彩粒子</h1>
<p>这是从旧项目抽离出来的第一个实验场景后续可以继续补充更多交互与玩法</p>
</div>
<div class="color-canvas" ref="canvasRoot"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
const canvasRoot = ref<HTMLDivElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let animationId = 0
onMounted(() => {
if (!canvasRoot.value) return
const container = canvasRoot.value
const width = container.clientWidth
const height = container.clientHeight
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(width, height)
container.appendChild(renderer.domElement)
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 2000)
camera.position.set(0, 0, 380)
const geometry = new THREE.IcosahedronGeometry(1, 3)
const group = new THREE.Group()
const gridSize = 64
const spacing = 8
for (let x = -gridSize; x <= gridSize; x += spacing) {
for (let y = -gridSize; y <= gridSize; y += spacing) {
const zLayerCount = 3
for (let zIndex = 0; zIndex < zLayerCount; zIndex++) {
if (Math.random() > 0.55) continue
const color = new THREE.Color()
color.setHSL(Math.random(), 0.7, Math.random() * 0.2 + 0.2)
const material = new THREE.MeshBasicMaterial({ color })
const mesh = new THREE.Mesh(geometry, material)
mesh.position.set(
x,
y + (Math.random() - 0.5) * 10,
zIndex * 18 + (Math.random() - 0.5) * 20,
)
const s = Math.random() * 3 + 1.2
mesh.scale.setScalar(s)
group.add(mesh)
}
}
}
scene.add(group)
const clock = new THREE.Clock()
const animate = () => {
animationId = requestAnimationFrame(animate)
const t = clock.getElapsedTime()
group.rotation.y = t * 0.16
group.rotation.x = Math.sin(t * 0.3) * 0.35
if (camera) {
camera.position.z = 360 + Math.sin(t * 0.5) * 40
camera.lookAt(0, 0, 0)
}
renderer?.render(scene as THREE.Scene, camera as THREE.PerspectiveCamera)
}
animate()
const onResize = () => {
if (!renderer || !camera) return
const w = container.clientWidth
const h = container.clientHeight
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
window.addEventListener('resize', onResize)
onBeforeUnmount(() => {
cancelAnimationFrame(animationId)
window.removeEventListener('resize', onResize)
if (renderer) {
renderer.dispose()
}
if (scene) {
scene.traverse((obj) => {
if ((obj as THREE.Mesh).geometry) {
;(obj as THREE.Mesh).geometry.dispose()
}
if ((obj as THREE.Mesh).material) {
const mat = (obj as THREE.Mesh).material
if (Array.isArray(mat)) {
mat.forEach((m) => m.dispose())
} else {
mat.dispose()
}
}
})
}
if (canvasRoot.value && renderer) {
canvasRoot.value.removeChild(renderer.domElement)
}
})
})
</script>

99
src/views/HandTrack.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<div class="handtrack-page">
<div class="color-header">
<h1>手势识别</h1>
<p>目前先作为摄像头 + 轮廓高亮的 Demo后续可接入完整手势识别模型</p>
</div>
<div class="handtrack-wrapper">
<video
ref="videoRef"
class="handtrack-video"
autoplay
playsinline
muted
></video>
<canvas ref="canvasRef" class="handtrack-canvas"></canvas>
</div>
<div class="camera-controls">
<button class="camera-btn" @click="startCamera" :disabled="isCameraOn">
{{ isCameraOn ? '摄像头已开启' : '开启摄像头' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const isCameraOn = ref(false)
let stream: MediaStream | null = null
let ctx: CanvasRenderingContext2D | null = null
let animationId = 0
const startCamera = async () => {
if (isCameraOn.value || !videoRef.value) return
try {
const constraints: MediaStreamConstraints = {
video: { facingMode: 'user' },
audio: false,
}
stream = await navigator.mediaDevices.getUserMedia(constraints)
videoRef.value.srcObject = stream
isCameraOn.value = true
} catch (err) {
console.error(err)
alert('无法打开摄像头,请检查浏览器权限设置。')
}
}
const drawLoop = () => {
if (!videoRef.value || !canvasRef.value || !ctx) return
const video = videoRef.value
const canvas = canvasRef.value
const w = canvas.clientWidth
const h = canvas.clientHeight
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w
canvas.height = h
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 简单效果:在画面中央绘制一个动态脉冲圈,模拟“识别框”
const t = performance.now() / 1000
const radius = 70 + Math.sin(t * 2.5) * 10
ctx.save()
ctx.strokeStyle = 'rgba(116, 189, 255, 0.9)'
ctx.lineWidth = 3
ctx.shadowColor = 'rgba(116, 189, 255, 0.9)'
ctx.shadowBlur = 18
ctx.beginPath()
ctx.arc(canvas.width / 2, canvas.height / 2, radius, 0, Math.PI * 2)
ctx.stroke()
ctx.restore()
animationId = requestAnimationFrame(drawLoop)
}
onMounted(() => {
if (canvasRef.value) {
ctx = canvasRef.value.getContext('2d')
}
animationId = requestAnimationFrame(drawLoop)
})
onBeforeUnmount(() => {
if (stream) {
stream.getTracks().forEach((t) => t.stop())
}
cancelAnimationFrame(animationId)
})
</script>

169
src/views/Home.vue Normal file
View File

@@ -0,0 +1,169 @@
<template>
<div class="page">
<div id="webgl-container" ref="webglContainer"></div>
<header class="site-header">
<div class="brand">
<span class="brand-mark"></span>
<span class="brand-text">NewToy</span>
</div>
<div class="social">
<a href="https://github.com" target="_blank" rel="noreferrer" class="social-dot"></a>
<a href="https://twitter.com" target="_blank" rel="noreferrer" class="social-dot"></a>
<a href="mailto:hello@newtoy.dev" class="social-dot"></a>
</div>
</header>
<main class="home">
<div class="home-background">
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="Gradient1" cx="50%" cy="50%" fx="10%" fy="50%" r=".5">
<animate attributeName="fx" dur="34s" values="0%;3%;0%" repeatCount="indefinite" />
<stop offset="0%" stop-color="#ff0" />
<stop offset="100%" stop-color="#ff00" />
</radialGradient>
<radialGradient id="Gradient2" cx="50%" cy="50%" fx="10%" fy="50%" r=".5">
<animate attributeName="fx" dur="23.5s" values="0%;3%;0%" repeatCount="indefinite" />
<stop offset="0%" stop-color="#0ff" />
<stop offset="100%" stop-color="#0ff0" />
</radialGradient>
<radialGradient id="Gradient3" cx="50%" cy="50%" fx="50%" fy="50%" r=".5">
<animate attributeName="fx" dur="21.5s" values="0%;3%;0%" repeatCount="indefinite" />
<stop offset="0%" stop-color="#f0f" />
<stop offset="100%" stop-color="#f0f0" />
</radialGradient>
</defs>
<rect x="0" y="0" width="100%" height="100%" fill="url(#Gradient1)">
<animate attributeName="x" dur="20s" values="25%;0%;25%" repeatCount="indefinite" />
<animate attributeName="y" dur="21s" values="0%;25%;0%" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 50 50"
to="360 50 50"
dur="17s"
repeatCount="indefinite"
/>
</rect>
<rect x="0" y="0" width="100%" height="100%" fill="url(#Gradient2)">
<animate attributeName="x" dur="23s" values="-25%;0%;-25%" repeatCount="indefinite" />
<animate attributeName="y" dur="24s" values="0%;50%;0%" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 50 50"
to="360 50 50"
dur="18s"
repeatCount="indefinite"
/>
</rect>
<rect x="0" y="0" width="100%" height="100%" fill="url(#Gradient3)">
<animate attributeName="x" dur="25s" values="0%;25%;0%" repeatCount="indefinite" />
<animate attributeName="y" dur="26s" values="0%;25%;0%" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="360 50 50"
to="0 50 50"
dur="19s"
repeatCount="indefinite"
/>
</rect>
</svg>
</div>
<div class="home-shadow"></div>
<section class="home-dialog">
<header class="home-header">
<h1>Rucky's Toy</h1>
<p>Toy list</p>
</header>
<div class="toy-grid">
<button class="toy-card" data-toy="color" @click="onToyClick('color')">
<div class="toy-icon">🎨</div>
<div class="toy-label">三维空间色彩粒子</div>
</button>
<button class="toy-card" data-toy="ar-draw" @click="onToyClick('ar')">
<div class="toy-icon">✏️</div>
<div class="toy-label">AR 绘制</div>
</button>
<button class="toy-card" data-toy="ar-hit" @click="onToyClick('ar-hit')">
<div class="toy-icon">📐</div>
<div class="toy-label">AR 真实空间检测</div>
</button>
<button class="toy-card" data-toy="camera" @click="onToyClick('camera')">
<div class="toy-icon">📷</div>
<div class="toy-label">实时合成图片</div>
</button>
<button class="toy-card" data-toy="handtrack" @click="onToyClick('handtrack')">
<div class="toy-icon">🖐️</div>
<div class="toy-label">手势识别</div>
</button>
<button class="toy-card" data-toy="pushcoin" @click="onToyClick('pushcoin')">
<div class="toy-icon">🪙</div>
<div class="toy-label">推币游戏</div>
</button>
<button class="toy-card" data-toy="music-rhythm" @click="onToyClick('music-rhythm')">
<div class="toy-icon">🎵</div>
<div class="toy-label">音乐律动·步频检测</div>
</button>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { initThreeScene } from '../threeScene'
const webglContainer = ref<HTMLDivElement | null>(null)
const router = useRouter()
onMounted(() => {
if (webglContainer.value) {
initThreeScene(webglContainer.value)
}
// 检查是否是 Spotify 授权回调
// Spotify 会返回到根路径https://sports.rucky.cn/?code=xxx&state=xxx
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('code') && urlParams.has('state')) {
console.log(' Spotify OAuth callback detected!')
console.log('Code:', urlParams.get('code')?.substring(0, 20) + '...')
console.log('State:', urlParams.get('state'))
// 保存参数到 sessionStorage以便 SpotifyCallback 组件处理
sessionStorage.setItem('spotify_callback_params', window.location.search)
// 立即跳转到 callback 路由处理
router.replace('/callback')
}
})
const onToyClick = (toy: string) => {
if (toy === 'color') {
router.push({ name: 'Color' })
} else if (toy === 'ar') {
router.push({ name: 'AR' })
} else if (toy === 'ar-hit') {
router.push({ name: 'ARHit' })
} else if (toy === 'camera') {
router.push({ name: 'Camera' })
} else if (toy === 'handtrack') {
router.push({ name: 'HandTrack' })
} else if (toy === 'pushcoin') {
router.push({ name: 'PushCoin' })
} else if (toy === 'music-rhythm') {
router.push({ name: 'MusicRhythm' })
}
}
</script>

2702
src/views/MusicRhythm.vue Normal file

File diff suppressed because it is too large Load Diff

85
src/views/PushCoin.vue Normal file
View File

@@ -0,0 +1,85 @@
<template>
<div class="pushcoin-page">
<div class="color-header">
<h1>推币游戏简易版</h1>
<p>点击投币金币会从上方落下并被推板向前推后续可替换为完整物理游戏</p>
</div>
<div class="pushcoin-wrapper" ref="boardRef">
<div class="pushcoin-platform"></div>
<div class="pushcoin-pusher" :style="{ transform: `translateX(${pusherX}%)` }"></div>
<div
v-for="coin in coins"
:key="coin.id"
class="pushcoin-coin"
:style="{
left: coin.x + '%',
top: coin.y + '%',
}"
></div>
</div>
<div class="pushcoin-controls">
<button class="camera-btn" @click="dropCoin">投币</button>
<button class="camera-btn camera-btn--secondary" @click="resetGame">重置</button>
</div>
<div class="pushcoin-score">
已投 {{ coins.length }} 枚金币仅视觉 Demo后续可接入真实物理逻辑和得分规则
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
type Coin = {
id: number
x: number
y: number
vy: number
}
const coins = reactive<Coin[]>([])
const boardRef = ref<HTMLDivElement | null>(null)
const pusherX = ref(-50) // translateX 百分比,以中心为 0
let coinId = 0
let animationId = 0
const dropCoin = () => {
const x = 50 + (Math.random() - 0.5) * 20
coins.push({
id: ++coinId,
x,
y: -10,
vy: 0.4 + Math.random() * 0.2,
})
}
const resetGame = () => {
coins.splice(0, coins.length)
}
const animate = () => {
animationId = requestAnimationFrame(animate)
const t = performance.now() / 1000
pusherX.value = -50 + Math.sin(t * 1.4) * 20
for (const coin of coins) {
coin.y += coin.vy
if (coin.y > 74) {
coin.y = 74
}
}
}
onMounted(() => {
animationId = requestAnimationFrame(animate)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationId)
})
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="callback-page">
<div class="callback-container">
<div class="spinner"></div>
<h2>{{ message }}</h2>
<p class="hint">{{ hint }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { spotifyService } from '../services/spotifyService'
const router = useRouter()
const message = ref('正在处理授权...')
const hint = ref('请稍候')
// 初始化 Spotify 服务(必须在处理回调前初始化)
const initSpotifyService = () => {
const SPOTIFY_CLIENT_ID = '4ed200672ba1421baa31b9859bd84d39'
const REDIRECT_URI = window.location.origin
console.log('Initializing Spotify service in callback...')
spotifyService.initialize({
clientId: SPOTIFY_CLIENT_ID,
redirectUri: REDIRECT_URI,
})
}
onMounted(async () => {
console.log('SpotifyCallback component mounted')
// 必须先初始化服务,否则会报错"服务未初始化"
initSpotifyService()
console.log('✅ Spotify service initialized')
// 从 sessionStorage 获取参数Home.vue 保存的)
const savedParams = sessionStorage.getItem('spotify_callback_params')
if (savedParams) {
console.log('✅ Processing Spotify OAuth callback from sessionStorage')
// 临时恢复 URL 参数,让 spotifyService 能正确解析
const currentUrl = new URL(window.location.href)
currentUrl.search = savedParams
window.history.replaceState({}, '', currentUrl.toString())
// 清理 sessionStorage
sessionStorage.removeItem('spotify_callback_params')
}
// 检查是否有授权码
if (window.location.search.includes('code=')) {
try {
// 处理 Spotify 回调
const success = await spotifyService.handleCallback()
if (success) {
message.value = '授权成功!'
hint.value = '正在跳转...'
// 延迟跳转,让用户看到成功消息
setTimeout(() => {
// 获取返回路径
const returnPath = localStorage.getItem('spotify_return_path') || '/music-rhythm'
localStorage.removeItem('spotify_return_path')
router.push(returnPath)
}, 1000)
} else {
message.value = '授权失败'
hint.value = '请返回重试'
setTimeout(() => {
router.push('/')
}, 2000)
}
} catch (error: any) {
console.error('Spotify callback error:', error)
message.value = '授权失败'
hint.value = error.message || '请返回重试'
setTimeout(() => {
router.push('/')
}, 3000)
}
} else if (window.location.search.includes('error=')) {
// 用户拒绝授权
const params = new URLSearchParams(window.location.search)
const error = params.get('error')
message.value = '授权被取消'
hint.value = error || '用户取消了授权'
setTimeout(() => {
router.push('/')
}, 2000)
} else {
// 无效的回调
message.value = '无效的回调'
hint.value = '正在返回首页...'
setTimeout(() => {
router.push('/')
}, 1500)
}
})
</script>
<style scoped>
.callback-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a14 0%, #1a1a2e 50%, #16213e 100%);
color: white;
}
.callback-container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
backdrop-filter: blur(10px);
}
.spinner {
width: 50px;
height: 50px;
margin: 0 auto 30px;
border: 4px solid rgba(29, 185, 84, 0.2);
border-top: 4px solid #1DB954;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
h2 {
margin: 0 0 15px 0;
font-size: 24px;
background: linear-gradient(45deg, #1DB954, #1ed760);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hint {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
</style>

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vue"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

18
vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// Vite 配置:默认监听 0.0.0.0,绑定所有网卡 IP
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0', // 监听所有网卡
port: 5173, // 如需更换端口可修改这里
https: false, // 启用 HTTPS使用自签名证书
},
preview: {
host: '0.0.0.0', // preview 模式也监听所有网卡
port: 5173, // preview 模式使用相同端口
},
})