feat: 完整实现音乐保存与管理系统 v2.6.0

🎵 新增音乐保存功能
- 实现用户音乐配置保存到SQLite数据库
- 支持保存音量、速度、频率和随机效果等完整配置
- 添加音乐重命名和删除功能

🎨 完善用户界面体验
- 新增保存按钮UI,集成到SelectedSoundsDisplay组件
- 实现SavedMusicList组件,显示用户保存的音乐列表
- 支持一键播放已保存的音乐配置

🔧 优化认证系统架构
- 修复API密码认证问题,添加sessionPassword机制
- 改进错误处理和用户反馈
- 优化用户菜单位置和z-index层级问题

🛠️ 技术改进
- 扩展SQLite数据库,新增saved_music表
- 创建完整的音乐管理API接口(保存/列表/重命名/删除)
- 增强用户认证状态管理,支持会话密码
- 优化CSS样式和动画效果

🎯 用户体验提升
- 修复用户菜单层级遮挡问题
- 重新设计用户菜单位置到左侧展开
- 添加退出登录功能和个人设置预留
- 完善登录提示和错误反馈机制

📝 数据库变更
- 添加saved_music表,存储用户音乐配置
- 支持JSON格式存储复杂的音频参数
- 实现用户关联和权限控制

这次提交实现了完整的音乐保存与管理系统,用户现在可以:
1. 保存当前声音配置为"音乐"
2. 在左侧查看和管理保存的音乐
3. 一键恢复之前的音乐配置
4. 重命名或删除不需要的音乐
5. 享受更好的用户界面体验
This commit is contained in:
zl 2025-11-17 17:17:11 +08:00
parent f00263d18c
commit e01092d97e
14 changed files with 1440 additions and 53 deletions

View file

@ -11,6 +11,7 @@ import { Container } from '@/components/container';
import { StoreConsumer } from '@/components/store-consumer'; import { StoreConsumer } from '@/components/store-consumer';
import { Buttons } from '@/components/buttons'; import { Buttons } from '@/components/buttons';
import { SelectedSoundsDisplay } from '@/components/selected-sounds-display'; import { SelectedSoundsDisplay } from '@/components/selected-sounds-display';
import { SavedMusicList } from '@/components/saved-music-list';
import { Categories } from '@/components/categories'; import { Categories } from '@/components/categories';
import { SharedModal } from '@/components/modals/shared'; import { SharedModal } from '@/components/modals/shared';
import { Toolbar } from '@/components/toolbar'; import { Toolbar } from '@/components/toolbar';
@ -99,6 +100,7 @@ export function App() {
<div id="app" /> <div id="app" />
<Buttons /> <Buttons />
<SelectedSoundsDisplay /> <SelectedSoundsDisplay />
<SavedMusicList />
<Categories categories={allCategories} /> <Categories categories={allCategories} />
</Container> </Container>

View file

@ -241,9 +241,9 @@
/* 用户菜单样式 */ /* 用户菜单样式 */
.userMenu { .userMenu {
position: fixed; position: fixed;
top: 70px; top: 20px;
right: 20px; right: 180px; /* 改为左侧展开在headerControls的左边 */
z-index: 999; z-index: 1001; /* 提高层级,确保在最上层 */
} }
.userInfo { .userInfo {
@ -282,21 +282,50 @@
white-space: nowrap; white-space: nowrap;
} }
.logoutButton { .userActions {
padding: 4px 8px; display: flex;
font-size: var(--font-xsm); flex-direction: column;
color: #ef4444; gap: 4px;
background: transparent; margin-top: 8px;
border: none; padding-top: 8px;
border-radius: 4px; border-top: 1px solid var(--color-border);
cursor: pointer;
transition: background-color 0.2s;
} }
.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); background: rgba(239, 68, 68, 0.1);
} }
.userActionButton .icon {
font-size: 14px;
width: 16px;
text-align: center;
}
/* 暗色主题下的特殊样式 */ /* 暗色主题下的特殊样式 */
:global(.dark-theme) .headerControls { :global(.dark-theme) .headerControls {
background: var(--bg-secondary); background: var(--bg-secondary);
@ -435,8 +464,8 @@
} }
.userMenu { .userMenu {
top: 60px; top: 15px;
right: 15px; right: 155px; /* 移动端适配左侧展开 */
} }
.authFormOverlay { .authFormOverlay {

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@ -49,6 +49,20 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
}; };
}, [showUserMenu]); }, [showUserMenu]);
// 监听显示登录表单的自定义事件
useEffect(() => {
const handleShowLoginForm = () => {
setShowAuthForm(true);
setIsLogin(true); // 默认显示登录表单
};
document.addEventListener('showLoginForm', handleShowLoginForm);
return () => {
document.removeEventListener('showLoginForm', handleShowLoginForm);
};
}, []);
// 主题切换逻辑 // 主题切换逻辑
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
@ -292,26 +306,50 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
</div> </div>
)} )}
{/* 用户菜单 - 下拉菜单 */} {/* 用户菜单 - 左侧展开菜单 */}
{isAuthenticated && showUserMenu && ( <AnimatePresence>
<div className={styles.userMenu}> {isAuthenticated && showUserMenu && (
<div className={styles.userInfo}> <motion.div
<div className={styles.userAvatar}> className={styles.userMenu}
{user?.username.charAt(0).toUpperCase()} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>
{user?.username.charAt(0).toUpperCase()}
</div>
<span className={styles.userName}>{user?.username}</span>
</div> </div>
<span className={styles.userName}>{user?.username}</span>
<button <div className={styles.userActions}>
onClick={() => { <button
handleLogout(); className={`${styles.userActionButton}`}
setShowUserMenu(false); onClick={() => {
}} // 这里可以添加个人设置功能
className={styles.logoutButton} setShowUserMenu(false);
> showNotificationMessage('个人设置功能开发中...', 'success');
退 }}
</button> >
</div> <FaCog className={styles.icon} />
</div>
)} </button>
<button
className={`${styles.userActionButton} ${styles.logoutButton}`}
onClick={() => {
handleLogout();
setShowUserMenu(false);
}}
>
<FaSignOutAlt className={styles.icon} />
退
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 提示通知 */} {/* 提示通知 */}
{showNotification && ( {showNotification && (

View file

@ -0,0 +1 @@
export { SavedMusicList } from './saved-music-list';

View file

@ -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;
}

View file

@ -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<SavedMusic[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [error, setError] = useState<string | null>(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 (
<div className={styles.savedMusicList}>
<h3 className={styles.title}>
<FaMusic className={styles.titleIcon} />
</h3>
{error && (
<div className={styles.error}>
{error}
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
</div>
)}
{loading ? (
<div className={styles.loading}>...</div>
) : savedMusicList.length === 0 ? (
<div className={styles.empty}>
<FaMusic className={styles.emptyIcon} />
<p></p>
<p className={styles.emptyHint}></p>
</div>
) : (
<div className={styles.musicItems}>
<AnimatePresence initial={false}>
{savedMusicList.map((music) => (
<div key={music.id} className={styles.musicItem}>
{editingId === music.id ? (
<div className={styles.editForm}>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className={styles.editInput}
placeholder="输入音乐名称"
maxLength={50}
/>
<div className={styles.editButtons}>
<button
onClick={saveEdit}
className={`${styles.editButton} ${styles.saveButton}`}
title="保存"
>
</button>
<button
onClick={cancelEdit}
className={`${styles.editButton} ${styles.cancelButton}`}
title="取消"
>
×
</button>
</div>
</div>
) : (
<>
<div className={styles.musicInfo}>
<button
onClick={() => playSavedMusic(music)}
className={styles.playButton}
title="播放这首音乐"
>
<FaPlay />
</button>
<span
className={styles.musicName}
onClick={() => startEditing(music)}
title="点击编辑名称"
>
{music.name}
</span>
</div>
<div className={styles.musicActions}>
<button
onClick={() => startEditing(music)}
className={styles.actionButton}
title="编辑名称"
>
<FaEdit />
</button>
<button
onClick={() => deleteMusic(music.id)}
className={`${styles.actionButton} ${styles.deleteButton}`}
title="删除"
>
<FaTrash />
</button>
</div>
</>
)}
</div>
))}
</AnimatePresence>
</div>
)}
</div>
);
}

View file

@ -1,9 +1,11 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import { FaSave } from 'react-icons/fa/index';
import { useSoundStore } from '@/stores/sound'; import { useSoundStore } from '@/stores/sound';
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds'; import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useAuthStore } from '@/stores/auth';
import { Sound } from '@/components/sounds/sound'; import { Sound } from '@/components/sounds/sound';
import styles from '../sounds/sounds.module.css'; import styles from '../sounds/sounds.module.css';
@ -11,12 +13,93 @@ import styles from '../sounds/sounds.module.css';
export function SelectedSoundsDisplay() { export function SelectedSoundsDisplay() {
const { t } = useTranslation(); const { t } = useTranslation();
const localizedCategories = useLocalizedSounds(); 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 => const selectedSoundIds = useSoundStore(state =>
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected) 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<string, number> = {};
const speed: Record<string, number> = {};
const rate: Record<string, number> = {};
const random_effects: Record<string, boolean> = {};
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 selectedSounds = useMemo(() => {
const allSounds = localizedCategories const allSounds = localizedCategories
@ -34,23 +117,67 @@ export function SelectedSoundsDisplay() {
} }
return ( return (
<div className={styles.sounds}> <div className={styles.soundsContainer}>
<AnimatePresence initial={false}> <div className={styles.sounds}>
{selectedSounds.map((sound) => ( <AnimatePresence initial={false}>
<Sound {selectedSounds.map((sound) => (
key={sound.id} <Sound
id={sound.id} key={sound.id}
icon={sound.icon} id={sound.id}
label={sound.label} icon={sound.icon}
src={sound.src} label={sound.label}
functional={false} src={sound.src}
displayMode={true} functional={false}
hidden={false} displayMode={true}
selectHidden={() => {}} hidden={false}
unselectHidden={() => {}} selectHidden={() => {}}
/> unselectHidden={() => {}}
))} />
</AnimatePresence> ))}
</AnimatePresence>
</div>
{/* 保存按钮区域 */}
<div className={styles.saveSection}>
<button
className={`${styles.saveButton} ${isSaving ? styles.saving : ''}`}
onClick={saveMusic}
disabled={isSaving || selectedSounds.length === 0}
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
>
<FaSave />
<span>
{isSaving ? '保存中...' : '保存音乐'}
</span>
</button>
{/* 保存成功提示 */}
{showSaveSuccess && (
<div className={styles.saveSuccess}>
</div>
)}
{/* 登录提示 */}
{showLoginPrompt && (
<div className={styles.loginPrompt}>
<p></p>
<button
onClick={() => {
setShowLoginPrompt(false);
// 触发LanguageSwitcher的登录表单
const event = new CustomEvent('showLoginForm', { bubbles: true });
document.dispatchEvent(event);
}}
>
</button>
<button onClick={() => setShowLoginPrompt(false)}>
</button>
</div>
)}
</div>
</div> </div>
); );
} }

View file

@ -5,6 +5,128 @@
margin-top: 20px; 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 { .button {
position: relative; position: relative;
display: flex; display: flex;

View file

@ -17,6 +17,29 @@ export interface CreateUserData {
password: string; 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<string, number>;
speed: Record<string, number>;
rate: Record<string, number>;
random_effects: Record<string, boolean>;
}
export function getDatabase(): Database.Database { export function getDatabase(): Database.Database {
if (!db) { if (!db) {
// 创建数据库文件路径 // 创建数据库文件路径
@ -39,6 +62,23 @@ export function getDatabase(): Database.Database {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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; 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; const user = database.prepare('SELECT id, username, created_at FROM users WHERE username = ?').get(username) as User | null;
return user; return user;
}
// 音乐保存相关函数
export async function createMusic(musicData: CreateMusicData): Promise<SavedMusic> {
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;
} }

View file

@ -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' },
});
}
};

View file

@ -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' },
});
}
};

View file

@ -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' },
});
}
};

View file

@ -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' },
});
}
};

View file

@ -12,6 +12,7 @@ interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
sessionPassword: string | null; // 仅当前会话使用的密码,不持久化
} }
interface AuthStore extends AuthState { interface AuthStore extends AuthState {
@ -56,6 +57,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: null, error: null,
sessionPassword: null,
// Actions // Actions
login: async (userData) => { login: async (userData) => {
@ -72,6 +74,14 @@ export const useAuthStore = create<AuthStore>()(
error: null, error: null,
}); });
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
});
console.log('✅ 用户登录成功:', user.username); console.log('✅ 用户登录成功:', user.username);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : '登录失败'; const errorMessage = error instanceof Error ? error.message : '登录失败';
@ -80,6 +90,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: errorMessage, error: errorMessage,
sessionPassword: null,
}); });
console.error('❌ 登录失败:', error); console.error('❌ 登录失败:', error);
throw error; throw error;
@ -100,6 +111,14 @@ export const useAuthStore = create<AuthStore>()(
error: null, error: null,
}); });
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
});
console.log('✅ 用户注册成功:', user.username); console.log('✅ 用户注册成功:', user.username);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : '注册失败'; const errorMessage = error instanceof Error ? error.message : '注册失败';
@ -108,6 +127,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: errorMessage, error: errorMessage,
sessionPassword: null,
}); });
console.error('❌ 注册失败:', error); console.error('❌ 注册失败:', error);
throw error; throw error;
@ -120,6 +140,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: null, error: null,
sessionPassword: null, // 清除会话密码
}); });
console.log('✅ 用户已登出'); console.log('✅ 用户已登出');
}, },
@ -171,6 +192,7 @@ export const useAuthStore = create<AuthStore>()(
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
// 不包含 sessionPassword仅存储在内存中
}), }),
} }
) )