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() {
-
-
- {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) => (
- {}}
- unselectHidden={() => {}}
- />
- ))}
-
+
+
+
+ {selectedSounds.map((sound) => (
+ {}}
+ unselectHidden={() => {}}
+ />
+ ))}
+
+
+
+ {/* 保存按钮区域 */}
+
+
+
+ {/* 保存成功提示 */}
+ {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,仅存储在内存中
}),
}
)