From e01092d97e9f84bc9414a13d0e25dd6732538855 Mon Sep 17 00:00:00 2001 From: zl Date: Mon, 17 Nov 2025 17:17:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=9F=B3=E4=B9=90=E4=BF=9D=E5=AD=98=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20v2.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎵 新增音乐保存功能 - 实现用户音乐配置保存到SQLite数据库 - 支持保存音量、速度、频率和随机效果等完整配置 - 添加音乐重命名和删除功能 🎨 完善用户界面体验 - 新增保存按钮UI,集成到SelectedSoundsDisplay组件 - 实现SavedMusicList组件,显示用户保存的音乐列表 - 支持一键播放已保存的音乐配置 🔧 优化认证系统架构 - 修复API密码认证问题,添加sessionPassword机制 - 改进错误处理和用户反馈 - 优化用户菜单位置和z-index层级问题 🛠️ 技术改进 - 扩展SQLite数据库,新增saved_music表 - 创建完整的音乐管理API接口(保存/列表/重命名/删除) - 增强用户认证状态管理,支持会话密码 - 优化CSS样式和动画效果 🎯 用户体验提升 - 修复用户菜单层级遮挡问题 - 重新设计用户菜单位置到左侧展开 - 添加退出登录功能和个人设置预留 - 完善登录提示和错误反馈机制 📝 数据库变更 - 添加saved_music表,存储用户音乐配置 - 支持JSON格式存储复杂的音频参数 - 实现用户关联和权限控制 这次提交实现了完整的音乐保存与管理系统,用户现在可以: 1. 保存当前声音配置为"音乐" 2. 在左侧查看和管理保存的音乐 3. 一键恢复之前的音乐配置 4. 重命名或删除不需要的音乐 5. 享受更好的用户界面体验 --- src/components/app/app.tsx | 2 + .../language-switcher.module.css | 59 +++- .../language-switcher/language-switcher.tsx | 78 ++-- src/components/saved-music-list/index.ts | 1 + .../saved-music-list.module.css | 331 +++++++++++++++++ .../saved-music-list/saved-music-list.tsx | 333 ++++++++++++++++++ .../selected-sounds-display.tsx | 163 ++++++++- src/components/sounds/sounds.module.css | 122 +++++++ src/lib/database.ts | 95 +++++ src/pages/api/auth/music/delete.ts | 69 ++++ src/pages/api/auth/music/list.ts | 75 ++++ src/pages/api/auth/music/rename.ts | 69 ++++ src/pages/api/auth/music/save.ts | 74 ++++ src/stores/auth.ts | 22 ++ 14 files changed, 1440 insertions(+), 53 deletions(-) create mode 100644 src/components/saved-music-list/index.ts create mode 100644 src/components/saved-music-list/saved-music-list.module.css create mode 100644 src/components/saved-music-list/saved-music-list.tsx create mode 100644 src/pages/api/auth/music/delete.ts create mode 100644 src/pages/api/auth/music/list.ts create mode 100644 src/pages/api/auth/music/rename.ts create mode 100644 src/pages/api/auth/music/save.ts diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index f2cf1be..dec9cdb 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -11,6 +11,7 @@ import { Container } from '@/components/container'; import { StoreConsumer } from '@/components/store-consumer'; import { Buttons } from '@/components/buttons'; import { SelectedSoundsDisplay } from '@/components/selected-sounds-display'; +import { SavedMusicList } from '@/components/saved-music-list'; import { Categories } from '@/components/categories'; import { SharedModal } from '@/components/modals/shared'; import { Toolbar } from '@/components/toolbar'; @@ -99,6 +100,7 @@ export function App() {
+ diff --git a/src/components/language-switcher/language-switcher.module.css b/src/components/language-switcher/language-switcher.module.css index 3fdd196..e4cf672 100644 --- a/src/components/language-switcher/language-switcher.module.css +++ b/src/components/language-switcher/language-switcher.module.css @@ -241,9 +241,9 @@ /* 用户菜单样式 */ .userMenu { position: fixed; - top: 70px; - right: 20px; - z-index: 999; + top: 20px; + right: 180px; /* 改为左侧展开,在headerControls的左边 */ + z-index: 1001; /* 提高层级,确保在最上层 */ } .userInfo { @@ -282,21 +282,50 @@ white-space: nowrap; } -.logoutButton { - padding: 4px 8px; - font-size: var(--font-xsm); - color: #ef4444; - background: transparent; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; +.userActions { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--color-border); } -.logoutButton:hover { +.userActionButton { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 8px 12px; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + color: var(--color-foreground); + transition: all 0.2s ease; + font-size: 14px; + width: 100%; + text-align: left; +} + +.userActionButton:hover { + background: var(--component-hover); +} + +.userActionButton.logoutButton { + color: #ef4444; +} + +.userActionButton.logoutButton:hover { background: rgba(239, 68, 68, 0.1); } +.userActionButton .icon { + font-size: 14px; + width: 16px; + text-align: center; +} + /* 暗色主题下的特殊样式 */ :global(.dark-theme) .headerControls { background: var(--bg-secondary); @@ -435,8 +464,8 @@ } .userMenu { - top: 60px; - right: 15px; + top: 15px; + right: 155px; /* 移动端适配左侧展开 */ } .authFormOverlay { diff --git a/src/components/language-switcher/language-switcher.tsx b/src/components/language-switcher/language-switcher.tsx index db25554..b9290b8 100644 --- a/src/components/language-switcher/language-switcher.tsx +++ b/src/components/language-switcher/language-switcher.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { FaGlobe, FaSun, FaMoon, FaUser } from 'react-icons/fa/index'; +import { FaGlobe, FaSun, FaMoon, FaUser, FaSignOutAlt, FaCog } from 'react-icons/fa/index'; import { AnimatePresence, motion } from 'motion/react'; import { useTranslation } from '@/hooks/useTranslation'; import { useAuthStore } from '@/stores/auth'; @@ -49,6 +49,20 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { }; }, [showUserMenu]); + // 监听显示登录表单的自定义事件 + useEffect(() => { + const handleShowLoginForm = () => { + setShowAuthForm(true); + setIsLogin(true); // 默认显示登录表单 + }; + + document.addEventListener('showLoginForm', handleShowLoginForm); + + return () => { + document.removeEventListener('showLoginForm', handleShowLoginForm); + }; + }, []); + // 主题切换逻辑 useEffect(() => { const savedTheme = localStorage.getItem('theme'); @@ -292,26 +306,50 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
)} - {/* 用户菜单 - 下拉菜单 */} - {isAuthenticated && showUserMenu && ( -
-
-
- {user?.username.charAt(0).toUpperCase()} + {/* 用户菜单 - 左侧展开菜单 */} + + {isAuthenticated && showUserMenu && ( + +
+
+ {user?.username.charAt(0).toUpperCase()} +
+ {user?.username}
- {user?.username} - -
-
- )} + +
+ + + +
+ + )} + {/* 提示通知 */} {showNotification && ( diff --git a/src/components/saved-music-list/index.ts b/src/components/saved-music-list/index.ts new file mode 100644 index 0000000..3c83bfa --- /dev/null +++ b/src/components/saved-music-list/index.ts @@ -0,0 +1 @@ +export { SavedMusicList } from './saved-music-list'; \ No newline at end of file diff --git a/src/components/saved-music-list/saved-music-list.module.css b/src/components/saved-music-list/saved-music-list.module.css new file mode 100644 index 0000000..05c5033 --- /dev/null +++ b/src/components/saved-music-list/saved-music-list.module.css @@ -0,0 +1,331 @@ +.savedMusicList { + margin-bottom: 20px; +} + +.title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + color: var(--color-foreground); +} + +.titleIcon { + color: var(--color-muted); + font-size: 14px; +} + +.error { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + margin-bottom: 16px; + background: var(--bg-error, rgba(239, 68, 68, 0.1)); + color: var(--color-error, #ef4444); + border: 1px solid var(--color-error, #ef4444); + border-radius: 6px; + font-size: 14px; +} + +.errorClose { + background: none; + border: none; + color: var(--color-error, #ef4444); + font-size: 18px; + cursor: pointer; + padding: 0; + margin-left: 8px; + font-weight: bold; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.errorClose:hover { + background: rgba(239, 68, 68, 0.2); +} + +.loading { + text-align: center; + padding: 20px; + color: var(--color-foreground-subtle); + font-size: 14px; +} + +.empty { + text-align: center; + padding: 32px 20px; + color: var(--color-foreground-subtle); +} + +.emptyIcon { + font-size: 32px; + color: var(--color-muted); + margin-bottom: 12px; + opacity: 0.7; +} + +.empty p { + margin: 8px 0; + font-size: 14px; +} + +.emptyHint { + font-size: 13px !important; + color: var(--color-muted) !important; + margin-top: 8px !important; +} + +.musicItems { + display: flex; + flex-direction: column; + gap: 8px; +} + +.musicItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + transition: all 0.2s ease; + min-height: 48px; +} + +.musicItem:hover { + background: var(--component-hover); + border-color: var(--color-foreground-subtle); +} + +.musicInfo { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.playButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--color-foreground); + color: var(--bg-primary); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + flex-shrink: 0; +} + +.playButton:hover { + background: var(--color-foreground-subtle); + transform: scale(1.05); +} + +.playButton:active { + transform: scale(0.95); +} + +.musicName { + flex: 1; + font-size: 14px; + color: var(--color-foreground); + cursor: pointer; + transition: color 0.2s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.musicName:hover { + color: var(--color-accent); +} + +.musicActions { + display: flex; + align-items: center; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.musicItem:hover .musicActions { + opacity: 1; +} + +.actionButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + color: var(--color-foreground-subtle); + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; +} + +.actionButton:hover { + background: var(--component-hover); + color: var(--color-foreground); +} + +.actionButton.deleteButton:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error, #ef4444); +} + +.editForm { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.editInput { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--bg-primary); + color: var(--color-foreground); + font-size: 14px; + min-width: 0; + outline: none; +} + +.editInput:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.editButtons { + display: flex; + align-items: center; + gap: 4px; +} + +.editButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.2s ease; +} + +.saveButton { + background: var(--color-foreground); + color: var(--bg-primary); +} + +.saveButton:hover { + background: var(--color-foreground-subtle); + transform: scale(1.05); +} + +.cancelButton { + background: var(--color-muted); + color: var(--color-foreground); +} + +.cancelButton:hover { + background: var(--color-foreground-subtle); + color: var(--bg-primary); + transform: scale(1.05); +} + +/* 响应式设计 */ +@media (max-width: 640px) { + .title { + font-size: 15px; + } + + .musicItem { + padding: 10px 12px; + } + + .playButton { + width: 28px; + height: 28px; + font-size: 11px; + } + + .musicName { + font-size: 13px; + } + + .actionButton { + width: 24px; + height: 24px; + font-size: 11px; + } + + .musicActions { + opacity: 1; /* 移动端始终显示操作按钮 */ + } + + .empty { + padding: 24px 16px; + } + + .emptyIcon { + font-size: 28px; + } +} + +/* 动画效果 */ +@keyframes slideIn { + from { + transform: translateY(-10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.musicItem { + animation: slideIn 0.3s ease-out; +} + +/* 焦点可访问性 */ +.musicItem:focus-within { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.editInput:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.editButton:focus-visible, +.actionButton:focus-visible, +.playButton:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} \ No newline at end of file diff --git a/src/components/saved-music-list/saved-music-list.tsx b/src/components/saved-music-list/saved-music-list.tsx new file mode 100644 index 0000000..d5e7abb --- /dev/null +++ b/src/components/saved-music-list/saved-music-list.tsx @@ -0,0 +1,333 @@ +import { useState, useEffect } from 'react'; +import { FaMusic, FaEdit, FaTrash, FaPlay } from 'react-icons/fa'; +import { AnimatePresence } from 'motion/react'; + +import { useAuthStore } from '@/stores/auth'; +import { useSoundStore } from '@/stores/sound'; +import { useTranslation } from '@/hooks/useTranslation'; + +import type { SavedMusic } from '@/lib/database'; + +import styles from './saved-music-list.module.css'; + +interface SavedMusicListProps { + onMusicSelect?: (music: SavedMusic) => void; +} + +export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { + const { t } = useTranslation(); + const { isAuthenticated, user, sessionPassword } = useAuthStore(); + const [savedMusicList, setSavedMusicList] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + const [error, setError] = useState(null); + + // 获取声音store的操作函数 + const unselectAll = useSoundStore(state => state.unselectAll); + const select = useSoundStore(state => state.select); + const setVolume = useSoundStore(state => state.setVolume); + const setSpeed = useSoundStore(state => state.setSpeed); + const setRate = useSoundStore(state => state.setRate); + const toggleRandomSpeed = useSoundStore(state => state.toggleRandomSpeed); + const toggleRandomVolume = useSoundStore(state => state.toggleRandomVolume); + const toggleRandomRate = useSoundStore(state => state.toggleRandomRate); + const play = useSoundStore(state => state.play); + + // 获取用户保存的音乐列表 + const fetchSavedMusic = async () => { + if (!isAuthenticated || !user || !sessionPassword) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/auth/music/list', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: user.username, + password: sessionPassword, // 使用会话密码 + }), + }); + + if (!response.ok) { + throw new Error('获取音乐列表失败'); + } + + const data = await response.json(); + if (data.success) { + setSavedMusicList(data.musicList || []); + } else { + setError(data.error || '获取音乐列表失败'); + } + } catch (err) { + console.error('获取音乐列表错误:', err); + setError('获取音乐列表失败,请稍后再试'); + } finally { + setLoading(false); + } + }; + + // 重命名音乐 + const renameMusic = async (musicId: string, newName: string) => { + if (!isAuthenticated || !user) return; + + try { + const response = await fetch('/api/auth/music/rename', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + musicId, + name: newName, + username: user.username, + password: sessionPassword, + }), + }); + + if (!response.ok) { + throw new Error('重命名失败'); + } + + const data = await response.json(); + if (data.success) { + // 更新本地状态 + setSavedMusicList(prev => + prev.map(music => + music.id === musicId ? { ...music, name: newName } : music + ) + ); + setEditingId(null); + setEditingName(''); + } else { + setError(data.error || '重命名失败'); + } + } catch (err) { + console.error('重命名音乐错误:', err); + setError('重命名失败,请稍后再试'); + } + }; + + // 删除音乐 + const deleteMusic = async (musicId: string) => { + if (!isAuthenticated || !user) return; + + if (!confirm('确定要删除这首音乐吗?')) { + return; + } + + try { + const response = await fetch('/api/auth/music/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + musicId, + username: user.username, + password: sessionPassword, + }), + }); + + if (!response.ok) { + throw new Error('删除失败'); + } + + const data = await response.json(); + if (data.success) { + // 从本地状态中移除 + setSavedMusicList(prev => prev.filter(music => music.id !== musicId)); + } else { + setError(data.error || '删除失败'); + } + } catch (err) { + console.error('删除音乐错误:', err); + setError('删除失败,请稍后再试'); + } + }; + + // 播放保存的音乐 + const playSavedMusic = async (music: SavedMusic) => { + // 清除当前所有声音选择 + unselectAll(true); + + // 延迟一下确保清除完成后再开始播放 + setTimeout(() => { + // 选择音乐中的所有声音 + music.sounds.forEach((soundId: string) => { + // 选择声音 + select(soundId); + + // 设置音量 + const volume = music.volume[soundId] || 50; + setVolume(soundId, volume / 100); // store中存储的是0-1的范围 + + // 设置速度 + const speed = music.speed[soundId] || 1; + setSpeed(soundId, speed); + + // 设置速率 + const rate = music.rate[soundId] || 1; + setRate(soundId, rate); + + // 设置随机效果 + const randomEffects = music.random_effects[soundId]; + if (randomEffects) { + if (randomEffects.volume) { + toggleRandomVolume(soundId); + } + if (randomEffects.speed) { + toggleRandomSpeed(soundId); + } + if (randomEffects.rate) { + toggleRandomRate(soundId); + } + } + }); + + // 开始播放 + play(); + + // 通知父组件音乐已被选中 + if (onMusicSelect) { + onMusicSelect(music); + } + }, 100); + }; + + // 开始编辑名称 + const startEditing = (music: SavedMusic) => { + setEditingId(music.id); + setEditingName(music.name); + }; + + // 保存编辑 + const saveEdit = () => { + if (editingId && editingName.trim()) { + renameMusic(editingId, editingName.trim()); + } + }; + + // 取消编辑 + const cancelEdit = () => { + setEditingId(null); + setEditingName(''); + setError(null); + }; + + // 当用户认证状态改变时,获取音乐列表 + useEffect(() => { + if (isAuthenticated && user && sessionPassword) { + fetchSavedMusic(); + } else { + setSavedMusicList([]); + } + }, [isAuthenticated, user, sessionPassword]); + + // 如果用户未登录,不显示组件 + if (!isAuthenticated) { + return null; + } + + return ( +
+

+ + 我的音乐 +

+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
加载中...
+ ) : savedMusicList.length === 0 ? ( +
+ +

还没有保存的音乐

+

选择声音并点击保存按钮来创建你的第一首音乐

+
+ ) : ( +
+ + {savedMusicList.map((music) => ( +
+ {editingId === music.id ? ( +
+ setEditingName(e.target.value)} + className={styles.editInput} + placeholder="输入音乐名称" + maxLength={50} + /> +
+ + +
+
+ ) : ( + <> +
+ + startEditing(music)} + title="点击编辑名称" + > + {music.name} + +
+
+ + +
+ + )} +
+ ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/selected-sounds-display/selected-sounds-display.tsx b/src/components/selected-sounds-display/selected-sounds-display.tsx index bc6bf9c..e0f2b08 100644 --- a/src/components/selected-sounds-display/selected-sounds-display.tsx +++ b/src/components/selected-sounds-display/selected-sounds-display.tsx @@ -1,9 +1,11 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { AnimatePresence } from 'motion/react'; +import { FaSave } from 'react-icons/fa/index'; import { useSoundStore } from '@/stores/sound'; import { useLocalizedSounds } from '@/hooks/useLocalizedSounds'; import { useTranslation } from '@/hooks/useTranslation'; +import { useAuthStore } from '@/stores/auth'; import { Sound } from '@/components/sounds/sound'; import styles from '../sounds/sounds.module.css'; @@ -11,12 +13,93 @@ import styles from '../sounds/sounds.module.css'; export function SelectedSoundsDisplay() { const { t } = useTranslation(); const localizedCategories = useLocalizedSounds(); + const { isAuthenticated, user, login, sessionPassword } = useAuthStore(); + const [isSaving, setIsSaving] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + const [showSaveSuccess, setShowSaveSuccess] = useState(false); + + // 获取声音store + const sounds = useSoundStore(state => state.sounds); // 获取选中的声音 const selectedSoundIds = useSoundStore(state => Object.keys(state.sounds).filter(id => state.sounds[id].isSelected) ); + // 保存音乐功能 + const saveMusic = async () => { + if (!isAuthenticated) { + setShowLoginPrompt(true); + return; + } + + setIsSaving(true); + + try { + // 准备保存的数据 + const selectedSoundsData = selectedSoundIds.map(id => sounds[id]); + const volume: Record = {}; + const speed: Record = {}; + const rate: Record = {}; + const random_effects: Record = {}; + + selectedSoundsData.forEach(sound => { + volume[sound.id] = sound.volume; + speed[sound.id] = sound.speed; + rate[sound.id] = sound.rate; + random_effects[sound.id] = sound.isRandomSpeed || sound.isRandomVolume || sound.isRandomRate; + }); + + // 检查是否有sessionPassword + if (!sessionPassword) { + console.error('会话密码丢失,请重新登录'); + setShowLoginPrompt(true); + setIsSaving(false); + return; + } + + const musicData = { + name: `我的音乐 ${new Date().toLocaleDateString()}`, + sounds: selectedSoundIds, + volume, + speed, + rate, + random_effects, + username: user?.username, + password: sessionPassword // 使用会话密码 + }; + + // 调用保存API + const response = await fetch('/api/auth/music/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(musicData), + }); + + if (response.ok) { + const result = await response.json(); + setShowSaveSuccess(true); + setTimeout(() => setShowSaveSuccess(false), 2000); + console.log('✅ 音乐保存成功:', result.music); + } else { + const errorData = await response.json(); + console.error('❌ 保存音乐失败:', errorData.error); + // 如果是认证错误,显示登录提示 + if (response.status === 401) { + setShowLoginPrompt(true); + } + } + } catch (error) { + console.error('❌ 保存音乐失败:', error); + // 网络错误或其他异常,显示登录提示 + setShowLoginPrompt(true); + } finally { + setIsSaving(false); + } + }; + // 获取选中的声音详细信息 const selectedSounds = useMemo(() => { const allSounds = localizedCategories @@ -34,23 +117,67 @@ export function SelectedSoundsDisplay() { } return ( -
- - {selectedSounds.map((sound) => ( - +
+
+ + {selectedSounds.map((sound) => ( + +
+ + {/* 保存按钮区域 */} +
+ + + {/* 保存成功提示 */} + {showSaveSuccess && ( +
+ ✓ 音乐保存成功! +
+ )} + + {/* 登录提示 */} + {showLoginPrompt && ( +
+

请先登录后再保存音乐

+ + +
+ )} +
); } \ No newline at end of file diff --git a/src/components/sounds/sounds.module.css b/src/components/sounds/sounds.module.css index 918ff82..6ac1f0f 100644 --- a/src/components/sounds/sounds.module.css +++ b/src/components/sounds/sounds.module.css @@ -5,6 +5,128 @@ margin-top: 20px; } +.soundsContainer { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; +} + +.saveSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-top: 20px; + padding: 20px; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.saveButton { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.saveButton:hover:not(:disabled) { + background: linear-gradient(135deg, #059669, #047857); + transform: translateY(-1px); +} + +.saveButton:disabled { + background: var(--color-muted); + cursor: not-allowed; + opacity: 0.6; +} + +.saveButton.saving { + background: var(--color-muted); + color: var(--color-foreground); +} + +.saveSuccess { + padding: 8px 16px; + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + animation: slideIn 0.3s ease-out; +} + +.loginPrompt { + padding: 16px; + background: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--color-foreground); + text-align: center; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.loginPrompt p { + margin: 0; + font-size: 14px; + color: var(--color-foreground-subtle); +} + +.loginPrompt button { + padding: 8px 16px; + margin: 0 4px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.loginPrompt button:first-child { + background: var(--color-foreground); + color: var(--bg-primary); +} + +.loginPrompt button:last-child { + background: transparent; + color: var(--color-foreground); + border: 1px solid var(--color-border); +} + +.loginPrompt button:hover:first-child { + background: var(--color-foreground-subtle); +} + +.loginPrompt button:hover:last-child { + background: var(--component-hover); + border-color: var(--color-foreground-subtle); +} + +@keyframes slideIn { + from { + transform: translateY(-10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + .button { position: relative; display: flex; diff --git a/src/lib/database.ts b/src/lib/database.ts index 8a5be5c..b693fdf 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -17,6 +17,29 @@ export interface CreateUserData { password: string; } +export interface SavedMusic { + id: number; + user_id: number; + name: string; + sounds: string; // JSON string of sound IDs + volume: string; // JSON string of volume settings + speed: string; // JSON string of speed settings + rate: string; // JSON string of rate settings + random_effects: string; // JSON string of random effects settings + created_at: string; + updated_at: string; +} + +export interface CreateMusicData { + user_id: number; + name: string; + sounds: string[]; + volume: Record; + speed: Record; + rate: Record; + random_effects: Record; +} + export function getDatabase(): Database.Database { if (!db) { // 创建数据库文件路径 @@ -39,6 +62,23 @@ export function getDatabase(): Database.Database { created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); + + // 创建音乐保存表 + db.exec(` + CREATE TABLE IF NOT EXISTS saved_music ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + sounds TEXT NOT NULL, + volume TEXT NOT NULL, + speed TEXT NOT NULL, + rate TEXT NOT NULL, + random_effects TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); } return db; @@ -105,4 +145,59 @@ export function getUserByUsername(username: string): User | null { const user = database.prepare('SELECT id, username, created_at FROM users WHERE username = ?').get(username) as User | null; return user; +} + +// 音乐保存相关函数 +export async function createMusic(musicData: CreateMusicData): Promise { + const database = getDatabase(); + + const stmt = database.prepare(` + INSERT INTO saved_music (user_id, name, sounds, volume, speed, rate, random_effects) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + musicData.user_id, + musicData.name, + JSON.stringify(musicData.sounds), + JSON.stringify(musicData.volume), + JSON.stringify(musicData.speed), + JSON.stringify(musicData.rate), + JSON.stringify(musicData.random_effects) + ); + + const music = database.prepare('SELECT * FROM saved_music WHERE id = ?').get(result.lastInsertRowid) as SavedMusic; + + return music; +} + +export function getUserMusic(userId: number): SavedMusic[] { + const database = getDatabase(); + + const musicList = database.prepare('SELECT * FROM saved_music WHERE user_id = ? ORDER BY created_at DESC').all(userId) as SavedMusic[]; + + return musicList; +} + +export function updateMusicName(musicId: number, name: string, userId: number): boolean { + const database = getDatabase(); + + const stmt = database.prepare(` + UPDATE saved_music + SET name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + `); + + const result = stmt.run(name, musicId, userId); + + return result.changes > 0; +} + +export function deleteMusic(musicId: number, userId: number): boolean { + const database = getDatabase(); + + const stmt = database.prepare('DELETE FROM saved_music WHERE id = ? AND user_id = ?'); + const result = stmt.run(musicId, userId); + + return result.changes > 0; } \ No newline at end of file diff --git a/src/pages/api/auth/music/delete.ts b/src/pages/api/auth/music/delete.ts new file mode 100644 index 0000000..670f4b6 --- /dev/null +++ b/src/pages/api/auth/music/delete.ts @@ -0,0 +1,69 @@ +import type { APIRoute } from 'astro'; +import { deleteMusic } from '@/lib/database'; +import { authenticateUser } from '@/lib/database'; + +export const POST: APIRoute = async ({ request }) => { + try { + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { musicId, username, password } = JSON.parse(body); + + // 验证输入 + if (!musicId || !username || !password) { + return new Response(JSON.stringify({ error: '音乐ID和用户信息不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 验证用户身份 + const user = authenticateUser(username, password); + if (!user) { + return new Response(JSON.stringify({ error: '用户认证失败' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 删除音乐记录 + const success = deleteMusic(musicId, user.id); + + if (!success) { + return new Response(JSON.stringify({ error: '音乐不存在或无权限删除' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + success: true, + message: '音乐删除成功' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('删除音乐错误:', error); + + let errorMessage = '删除音乐失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/music/list.ts b/src/pages/api/auth/music/list.ts new file mode 100644 index 0000000..61217af --- /dev/null +++ b/src/pages/api/auth/music/list.ts @@ -0,0 +1,75 @@ +import type { APIRoute } from 'astro'; +import { getUserMusic } from '@/lib/database'; +import { authenticateUser } from '@/lib/database'; + +export const POST: APIRoute = async ({ request }) => { + try { + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { username, password } = JSON.parse(body); + + // 验证输入 + if (!username || !password) { + return new Response(JSON.stringify({ error: '用户名和密码不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 验证用户身份 + const user = authenticateUser(username, password); + if (!user) { + return new Response(JSON.stringify({ error: '用户认证失败' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 获取用户音乐列表 + const musicList = getUserMusic(user.id); + + // 解析JSON字段 + const formattedMusicList = musicList.map(music => ({ + id: music.id, + name: music.name, + sounds: JSON.parse(music.sounds), + volume: JSON.parse(music.volume), + speed: JSON.parse(music.speed), + rate: JSON.parse(music.rate), + random_effects: JSON.parse(music.random_effects), + created_at: music.created_at, + updated_at: music.updated_at + })); + + return new Response(JSON.stringify({ + success: true, + musicList: formattedMusicList + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('获取音乐列表错误:', error); + + let errorMessage = '获取音乐列表失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/music/rename.ts b/src/pages/api/auth/music/rename.ts new file mode 100644 index 0000000..264d731 --- /dev/null +++ b/src/pages/api/auth/music/rename.ts @@ -0,0 +1,69 @@ +import type { APIRoute } from 'astro'; +import { updateMusicName } from '@/lib/database'; +import { authenticateUser } from '@/lib/database'; + +export const POST: APIRoute = async ({ request }) => { + try { + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { musicId, name, username, password } = JSON.parse(body); + + // 验证输入 + if (!musicId || !name || !username || !password) { + return new Response(JSON.stringify({ error: '音乐ID、新名称和用户信息不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 验证用户身份 + const user = authenticateUser(username, password); + if (!user) { + return new Response(JSON.stringify({ error: '用户认证失败' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 更新音乐名称 + const success = updateMusicName(musicId, name, user.id); + + if (!success) { + return new Response(JSON.stringify({ error: '音乐不存在或无权限修改' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + success: true, + message: '音乐名称更新成功' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('重命名音乐错误:', error); + + let errorMessage = '重命名音乐失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/music/save.ts b/src/pages/api/auth/music/save.ts new file mode 100644 index 0000000..052c5f7 --- /dev/null +++ b/src/pages/api/auth/music/save.ts @@ -0,0 +1,74 @@ +import type { APIRoute } from 'astro'; +import { createMusic } from '@/lib/database'; +import { authenticateUser } from '@/lib/database'; + +export const POST: APIRoute = async ({ request }) => { + try { + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { name, sounds, volume, speed, rate, random_effects, username, password } = JSON.parse(body); + + // 验证输入 + if (!name || !sounds || !username || !password) { + return new Response(JSON.stringify({ error: '音乐名称、声音配置和用户信息不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 验证用户身份 + const user = authenticateUser(username, password); + if (!user) { + return new Response(JSON.stringify({ error: '用户认证失败' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 创建音乐记录 + const music = await createMusic({ + user_id: user.id, + name, + sounds, + volume: volume || {}, + speed: speed || {}, + rate: rate || {}, + random_effects: random_effects || {}, + }); + + return new Response(JSON.stringify({ + success: true, + music: { + id: music.id, + name: music.name, + created_at: music.created_at + } + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('保存音乐错误:', error); + + let errorMessage = '保存音乐失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; \ No newline at end of file diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 8ce691a..feb1024 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -12,6 +12,7 @@ interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; + sessionPassword: string | null; // 仅当前会话使用的密码,不持久化 } interface AuthStore extends AuthState { @@ -56,6 +57,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: null, + sessionPassword: null, // Actions login: async (userData) => { @@ -72,6 +74,14 @@ export const useAuthStore = create()( error: null, }); + set({ + user, + isAuthenticated: true, + isLoading: false, + error: null, + sessionPassword: userData.password, // 保存密码用于当前会话的API调用 + }); + console.log('✅ 用户登录成功:', user.username); } catch (error) { const errorMessage = error instanceof Error ? error.message : '登录失败'; @@ -80,6 +90,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: errorMessage, + sessionPassword: null, }); console.error('❌ 登录失败:', error); throw error; @@ -100,6 +111,14 @@ export const useAuthStore = create()( error: null, }); + set({ + user, + isAuthenticated: true, + isLoading: false, + error: null, + sessionPassword: userData.password, // 保存密码用于当前会话的API调用 + }); + console.log('✅ 用户注册成功:', user.username); } catch (error) { const errorMessage = error instanceof Error ? error.message : '注册失败'; @@ -108,6 +127,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: errorMessage, + sessionPassword: null, }); console.error('❌ 注册失败:', error); throw error; @@ -120,6 +140,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: null, + sessionPassword: null, // 清除会话密码 }); console.log('✅ 用户已登出'); }, @@ -171,6 +192,7 @@ export const useAuthStore = create()( partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated, + // 不包含 sessionPassword,仅存储在内存中 }), } )